早期 Agent 原型为了方便,把数据库和第三方 API key 直接放进执行进程。模型生成工具名和参数,代码几乎原样调用。演示阶段它能查数据、发消息,真实接入后很快出现两个问题:模型把测试项目 ID 带到生产工具;一次超时重试重复创建了外部任务。
我们把工具执行独立成权限网关。模型不持有密钥,也不能直接访问生产网络;它只提交结构化意图。网关用确定性规则决定参数是否有效、当前主体能否执行、是否需要审批以及怎样去重。
图 1:推理可以不确定,执行边界必须确定、最小授权并且可审计。
工具定义包含效果与风险
type ToolDefinition<Input, Output> = {
name: string;
version: number;
inputSchema: JsonSchema<Input>;
effect: "read" | "write" | "external_side_effect";
risk: "low" | "medium" | "high";
idempotency: "native" | "gateway" | "none";
timeoutMs: number;
execute(ctx: ToolContext, input: Input): Promise<Output>;
};工具不是一段描述文字。版本、输入边界、副作用等级和幂等能力都进入注册表。模型请求不存在的字段会在 Schema 层失败,不会被执行器“尽量理解”。
策略基于主体、资源和上下文
type ToolContext = {
actorId: string;
tenantId: string;
taskId: string;
approvedScopes: string[];
environment: "sandbox" | "production";
};策略检查:用户是否能访问目标资源;任务契约是否允许该工具;环境是否匹配;参数影响范围是否超过限制;当前调用是否已有审批。模型说“用户要求我这么做”不是授权证据。
| 调用 | 默认策略 |
|---|---|
| 读取当前租户公开文档 | 自动执行,结果仍做权限过滤 |
| 创建草稿 | 自动执行,返回可撤销草稿 ID |
| 发送外部邮件 | 展示收件人和正文,人工确认 |
| 删除生产资源 | 默认禁止或双人审批 |
| 执行任意 SQL/Shell | 不作为通用生产工具暴露 |
高风险审批绑定 planHash
Agent 先生成规范化计划,网关计算 hash。审批界面显示工具、资源、参数、预计影响和补偿动作。批准签名绑定 planHash;模型后来修改任何参数,都必须重新审批。
const canonicalPlan = stableStringify({
tool: request.tool,
version: request.version,
input: request.input,
tenantId: context.tenantId,
});
const planHash = sha256(canonicalPlan);只批准一句自然语言“允许发邮件”范围太大,也无法证明实际执行内容与人看到的一致。
执行使用短期、下放权限的凭证
网关按单次调用向凭证服务申请几分钟有效、只允许指定资源和动作的 token。外部系统日志能识别真实 actor 与 taskId。长期密钥留在凭证服务,不进入模型上下文、工具结果或普通日志。
重试必须服从副作用语义
读工具可以有限重试;写工具需要 idempotencyKey;不支持幂等且结果未知的外部副作用不能自动重放,进入 unknown 状态等待查询或人工处理。
requested -> authorized -> executing -> succeeded
|-> failed_retryable
|-> failed_terminal
|-> outcome_unknown网关保存规范化输入 hash、策略版本、审批、凭证范围、外部回执与耗时。工具输出回到模型前再做大小限制和敏感字段过滤,防止外部内容把秘密带入上下文。
这层网关看起来增加了延迟和代码,却让 Agent 从“拿着万能钥匙的脚本”变成一个受控调用方。模型擅长决定可能要做什么,真正执行仍需要软件工程里熟悉的 Schema、授权、事务、幂等和审计。
判断“哪些动作需要人在中途确认”不在网关内部完成,而在它上游的策略里:审批应该绑定不可变的执行计划、绑定 planHash、在计划变化时重新授权——这是人工审批不是弹窗整篇的内容。执行环境本身的隔离(文件、网络、密钥、资源)则是编码 Agent 的沙箱的主题,那里把这里的工具边界下沉到了进程级别。