工具都批准了,为什么还不能执行?给 Agent 补上第二道校验

用户点了"批准",不代表工具从此拿到一张空白执行许可证。批准只对当时展示的调用成立;真正执行前,系统必须用最终输入重新做 Schema、权限、资源和审批绑定校验。任何一项不匹配,都应失败关闭,不能沿用旧批准。

这个问题看起来像安全常识,却很容易被两段式 Agent 流程漏掉:第一次模型调用生成工具参数,应用展示参数并收集用户决定;第二次模型调用带着批准响应继续。两次调用之间存在序列化、恢复、工具修复、上下文变化和代码升级,最终送到执行器的输入不一定还是用户看过的那一份。

Vercel AI SDK 在 ai@7.0.78 的官方 Release 中修复了一个很具体的边界:已经批准的 generateTextstreamTextWorkflowAgent turn,如果工具输入重校验后无效,SDK 会向模型提供可见的工具错误并继续 turn。本文不复刻 SDK 内部实现,而是把这个变化落成一个框架无关的执行门禁,并用本地 7 项 Node.js 测试验证拒绝路径。

一、批准的是一份快照,不是"这个工具以后都能跑"

AI SDK 当前工具文档说明,inputSchema 既提供给模型,也用于校验模型产生的工具调用。需要人工批准时,流程通常包含两次模型调用:第一次返回 tool-approval-request,应用取得用户决定后追加 tool-approval-response,第二次再继续生成或执行。

这里至少有三份状态:

  1. 模型第一次生成、准备展示的工具输入;
  2. 用户真正看到并同意的输入快照;
  3. 恢复后进入执行器的最终输入。

只有三者一致,批准才具有可执行意义。如果用户看到的是"查询订单库最近 7 天的 idamount",最终输入却变成"导出工资库最近 30 天并增加 phone 字段",系统不能因为两次调用都使用同一个 approvalId 就继续执行。

这不是只防模型"改主意"。工程链路本身也会改变输入:

  • 应用从数据库恢复 JSON 时补了默认字段或做了类型转换;
  • repairToolCall 修复了无效参数,但批准界面展示的是修复前版本;
  • 工具 Schema 在等待批准期间升级,旧参数不再满足新约束;
  • 多租户请求恢复时拿错了调用者、工作区或资源作用域;
  • 前端只回传 approved: true,服务端没有重新加载原始批准快照;
  • 重试或队列重复投递,让一次性批准被消费两次。

因此,批准记录至少要回答:谁批准、批准哪个工具、针对哪份最终参数、访问哪个资源、何时失效、是否已经消费。只保存一个布尔值,无法回答其中任何一个问题。

二、先把"输入无效"和"批准不匹配"拆成两类失败

不少实现把所有失败都写成 tool_error。用户只能看到"执行失败",观测平台也无法判断是 Provider、工具、Schema 还是授权问题。批准后的重校验至少要拆出两层:

检查层 典型失败 推荐终态 是否执行副作用
最终输入 Schema limit 从 50 变成 500,超出 1---100 validation_failed_after_approval
审批绑定 输入仍合法,但 select 变成 export approval_binding_mismatch
调用者 / 租户 user-42 的批准被 user-99 恢复 approval_binding_mismatch
资源作用域 db:orders 变成 db:payroll approval_binding_mismatch
有效期 队列等待超过批准期限 approval_expired
一次性状态 同一批准被重放 approval_already_consumed
工具执行 校验通过后,数据库或外部 API 返回错误 tool_execution_failed 可能已开始

第一层回答"最终参数本身还能不能被这个工具接受";第二层回答"即使参数合法,它是不是用户批准的那一次调用"。二者不能互相替代。

举例来说,把 limit: 50 改成 limit: 500,Schema 就能拒绝;把 operation: select 改成仍然合法的 operation: export,Schema 会放行,却已经超出原批准。反过来,如果只比较参数哈希,一份与旧快照完全相同但在新版 Schema 下已经失效的输入也可能被错误执行。正确顺序是重新验证最终输入,再验证它与批准票据的绑定。

三、四步门禁怎样串起来

1. 生成参数后,先做可展示校验

工具输入完成流式生成后,先执行 Schema 校验和展示层归一化。批准界面不要展示模糊摘要后隐藏关键字段;它至少要显示工具名、动作、关键参数、资源、影响范围与可撤销性。

如果工具是查询订单,界面要展示目标库、查询还是导出、字段、过滤条件与数量上限。用户不需要看到内部 Trace,但必须看得到会改变授权判断的字段。

**过关证据:**批准界面使用的对象已经通过当前 Schema,展示快照可计算稳定哈希,并能由服务端按 approvalId 重新加载。

2. 批准时签发一次性票据

票据不必暴露为可伪造的客户端 Token;它可以只是服务端数据库的一条记录。最小字段建议包括:

js 复制代码
{
  approvalId,
  actor: 'user-42',
  tool: 'queryOrders',
  resource: 'db:orders',
  inputHash: 'sha256:...',
  issuedAt: 1787644800000,
  expiresAt: 1787644860000,
  consumed: false
}

实际系统还可以绑定租户、会话、run、工具版本、Schema 版本、策略版本与审批原因。关键不是字段越多越安全,而是执行器能够用独立的服务端事实还原"当时批准的是什么"。

inputHash 需要基于确定性序列化。JavaScript 对象键顺序、可选字段默认值、数字与字符串转换都可能导致同义对象哈希不同。项目应固定 canonicalization 规则,并把规则版本一起记录。更稳妥的做法是保存脱敏后的批准快照,同时保存哈希;哈希用于快速比较,快照用于审计和向用户解释差异。

**过关证据:**客户端只提交批准决定,服务端重新认证审批者,并从服务端存储读取批准快照;客户端不能通过修改 toolresourceinputHash 扩大授权。

3. 恢复后,用最终输入重新过两层校验

执行器收到恢复请求时,不直接把 approved: true 映射为 execute()。先对最终输入运行当前 Schema,再比较工具、调用者、租户、资源、输入哈希、有效期与消费状态。

本地实验的核心函数保持纯函数形态:任何拒绝路径都先返回结构化状态,不触碰真实副作用。

js 复制代码
export function revalidateApprovedCall({ approval, actor, tool, input, now }) {
  const schemaIssues = validateQueryInput(input);
  if (schemaIssues.length > 0) {
    return rejected(
      'validation_failed_after_approval',
      'final input no longer satisfies the schema',
      { schemaIssues },
    );
  }
  if (approval.consumed) {
    return rejected('approval_already_consumed', 'approval was already used');
  }
  if (now > approval.expiresAt) {
    return rejected('approval_expired', 'approval is outside its validity window');
  }
  if (tool !== approval.tool || actor !== approval.actor) {
    return rejected('approval_binding_mismatch', 'principal or tool changed');
  }
  if (input.resource !== approval.resource || inputHash(input) !== approval.inputHash) {
    return rejected('approval_binding_mismatch', 'resource or input changed');
  }
  return { status: 'ready_to_execute', sideEffectStarted: false };
}

这个例子把 Schema 校验放在审批绑定之前,是为了把"最终参数已经无效"单独记录。生产系统可以根据威胁模型调整内部顺序,但对外终态要稳定,而且任何失败都不能开始副作用。

**过关证据:**对漂移、过期、身份不符和重放做故障注入,工具的执行 Mock 调用次数仍为 0。

4. 校验通过后,原子消费票据再执行

最容易被忽略的是并发。两个 Worker 可能同时读到 consumed: false,分别通过校验,然后各执行一次。示例代码在单进程里先把票据标为已消费再调用模拟工具,只能解释顺序,不能解决分布式竞争。

生产环境应在数据库事务、条件更新或幂等存储中原子完成"未消费 → 已消费"。例如:

sql 复制代码
UPDATE approvals
SET consumed_at = CURRENT_TIMESTAMP
WHERE approval_id = :id
  AND consumed_at IS NULL
  AND expires_at >= CURRENT_TIMESTAMP;

只有受影响行数为 1 的 Worker 可以进入执行。外部 API 还要使用业务幂等键,避免执行器在网络超时后无法判断远端是否已经成功。批准票据的一次性消费与业务幂等是两道不同门禁:前者防本系统重复放行,后者防远端副作用重复发生。

**过关证据:**并发测试中只有一个请求取得执行权;超时重试能查询既有业务结果,不盲目再次创建订单、转账或发消息。

四、本地 7 项实验验证了哪些边界

本次实验使用本机 Node.js v22.19.0,只依赖 node:cryptonode:testnode:assert。没有安装 AI SDK,没有读取 API Key,没有连接模型、数据库或外部工具。

运行命令:

bash 复制代码
cd 02-技术沉淀/03-问题与方案/实验/tool-approval-revalidation-lab
node --check approval-gate.js
node --test approval-gate.test.js

测试矩阵如下:

场景 预期结果 实际结果
最终输入与批准快照一致 执行一次 executed,执行计数 1
select 改成合法的 export 审批绑定拒绝 approval_binding_mismatch
limit 改成 500 Schema 重校验拒绝 validation_failed_after_approval
调用者 user-42 改成 user-99 身份绑定拒绝 approval_binding_mismatch
资源 db:orders 改成 db:payroll 资源绑定拒绝 approval_binding_mismatch
当前时间超过有效期 过期拒绝 approval_expired
已消费票据再次使用 重放拒绝 approval_already_consumed

真实输出为 7 项测试全部通过、0 失败、0 跳过。所有拒绝对象都带有 sideEffectStarted: false。这条字段不是装饰:测试既断言终态,也断言工具没有开始执行,避免只检查错误文案却漏掉副作用。

这个实验能证明纯状态门禁的行为,不能证明真实模型续跑、AI SDK 序列化、数据库事务、分布式锁、消息队列重复投递或外部 API 幂等已经通过。它也没有模拟恶意客户端签名伪造,因为示例批准票据没有对外传输。

五、失败后不要自动沿用旧批准修参数

批准后发现最终输入无效,最危险的修复不是抛错,而是系统悄悄改参数后继续执行。自动修复可能改变资源、数量、收件人或操作类型,用户却没有看到新输入。

更稳妥的恢复路径是:

  1. validation_failed_after_approval 作为模型和 UI 都可见的独立事件;
  2. 告诉模型哪个字段失效,但不要给它继承旧批准的权限;
  3. 模型若生成新参数,把它当作一笔新的工具调用;
  4. 新调用重新经过 Schema、策略与用户批准;
  5. 原批准标记为失效或保持不可消费,审计中链接新旧调用。

如果修复仅是服务端无语义的规范化,例如稳定排序对象键、移除 UI 不展示的内部空字段,也要在签发批准前完成。批准后再做"看起来无害"的转换,会让哈希、审计和用户看到的内容产生分叉。

对于只读、低风险工具,策略可以自动批准,但仍要保留输入快照与重校验。自动批准表示策略代替用户做决定,不表示 Schema 与权限门禁可以省略。对于支付、发布、删除、外发消息和命令执行,任何会改变决策的参数都应重新展示。

六、UI 和观测要把批准后失败当成一等状态

UI 如果只显示"已批准"和"执行失败",用户会误以为工具运行后才报错。建议为工具 part 增加清楚的状态:

text 复制代码
input-ready
→ waiting-approval
→ approved
→ revalidating
→ running | validation-failed-after-approval | approval-binding-mismatch
→ completed | tool-execution-failed

批准后校验失败时,页面至少显示:失败字段、批准失效原因、是否产生副作用、是否需要重新批准。不要复用"用户拒绝"的样式,因为这是系统在保护用户,不是用户改变决定;也不要复用 Provider 错误,否则运营人员会把安全拒绝当成模型不稳定。

Trace 中可记录以下脱敏字段:

  • approval_idtool_call_idrun_id 的关联;
  • tool_name、Schema 版本、策略版本;
  • actor_id_hash、租户与资源类别;
  • 批准输入哈希、最终输入哈希,以及是否匹配;
  • approved_atexpires_atconsumed_at
  • 重校验终态、失败字段和 side_effect_started
  • 新调用是否由旧调用修复产生。

不要为了排查方便记录完整密钥、个人数据、SQL、Shell 命令或消息正文。能用字段名、资源类别、哈希和脱敏摘要定位的问题,不应保存原始敏感值。

告警也要区分数量与比例。偶发 Schema 失败可能来自模型波动;同一工具在升级后突然大量出现 validation_failed_after_approval,更像 Schema 或序列化契约漂移;大量 approval_binding_mismatch 可能意味着恢复逻辑串租户、客户端重放或批准快照版本不一致。三者处理人和停止条件不同。

七、接入现有 Agent 的最小改造顺序

不要先重做整套 Agent Runtime。按依赖关系完成四个改造:

  1. 冻结批准快照。 在展示批准 UI 前完成 Schema 校验和规范化,保存服务端快照与哈希。
  2. 补齐批准绑定。 把调用者、租户、run、工具、资源、有效期和一次性状态加入批准记录。
  3. 统一执行入口。 所有工具执行都经过共享重校验层,不能让 SDK、MCP、队列 Worker 或直接函数调用各自旁路。
  4. 增加失败终态与回归。 UI、Trace、告警和测试都识别批准后校验失败,并断言副作用没有开始。

迁移时怎样处理正在等待的旧批准

真正上线时,最难处理的往往不是新请求,而是数据库里已经处于 waiting-approvalapproved 的历史记录。旧记录可能只有 approved: true 和一个会话标识,没有输入哈希、Schema 版本、资源范围、过期时间或消费状态。直接按新逻辑补默认值,等于替用户补签了一份从未看过的授权;继续走旧执行路径,又会让最危险的请求绕开新门禁。

我更倾向于把迁移拆成三个集合。第一类是尚未获得用户决定的请求:保留原输入供展示,但在用户打开审批页时先用当前 Schema 重新校验;如果字段已经失效,就终止旧请求并生成新的审批,而不是把修复结果塞回原记录。第二类是已经批准但没有完整绑定证据的请求:默认不可执行,向用户明确说明"系统升级后需要重新确认"。第三类是绑定字段完整、仍在有效期内且未消费的请求:也要经过新门禁,并把本次兼容路径写入 Trace,方便之后下线。

迁移脚本本身只应改变状态,不应调用工具。可以先在生产数据的脱敏副本上运行 dry-run,输出每类记录的数量、缺失字段、最老等待时间和预计重新审批比例;人工抽样确认分类规则后,再批量写入 needs_reapprovalexpiredlegacy_review_required。整个过程要有可回滚的状态映射,但"回滚"只能恢复记录状态,不能恢复已经消费的批准,更不能重放外部副作用。

发布时还需要一个短暂的双读阶段:新执行器读取新字段,同时把旧字段缺失视为拒绝;旧执行器则必须先停止接收新任务,否则同一队列里会同时存在两套授权语义。若采用滚动发布,可以先部署只记录判定、不执行副作用的影子校验,比较它与旧路径的结果;当不一致原因都能解释,再把新门禁切成强制模式。切换后重点观察重新审批率、过期率、绑定不匹配率和重复消费拦截数,而不是只看工具成功率。

最后要做一次完整演练:创建一笔旧格式批准,让它跨过部署窗口;分别注入 Schema 升级、调用者变化、资源变化、超时和重复投递;确认所有请求都进入可解释的失败终态,执行 Mock 仍为 0 次,用户能够从界面发起一笔全新的审批。只有这条跨版本链路通过,才能说明新门禁保护的不只是部署后的理想请求,也覆盖了真实系统里最容易遗忘的在途状态。

如果项目使用 Vercel AI SDK,应先核对实际版本与 Release,至少确认 ai@7.0.78 所描述的修复已经进入依赖线;同时阅读当前工具审批 API,因为文档会演进。不能只看 TypeScript 类型通过就推断恢复链路安全。

还要注意官方文档的一个边界:Provider 端执行的工具不受本地 toolApproval 控制。工具如果由 Provider 执行,批准与参数约束必须使用 Provider 支持的机制,或改为应用侧可控制的工具执行。不要把本地审批配置误写成对所有工具的统一安全边界。

迁移期间建议保留旧状态的只读兼容,但对缺少绑定字段的历史批准默认重新请求批准。根据客户端残留的"已同意"按钮、Session ID 或 Resume Token 推断授权,属于 fail-open。

八、上线前检查清单

  • 批准界面展示了工具、动作、关键参数、资源和影响范围;
  • 展示前已经完成 Schema 校验与确定性规范化;
  • 批准记录绑定调用者、租户、run、工具、最终输入哈希、资源和有效期;
  • 服务端重新认证审批者,不相信客户端 approved: true
  • 恢复后对最终输入重新执行 Schema、权限、资源与批准绑定校验;
  • Schema 无效与批准不匹配使用不同终态;
  • 所有拒绝路径都断言没有开始副作用;
  • 批准票据原子消费,外部副作用另有业务幂等键;
  • 修复后的新参数会生成新的调用和新批准,不继承旧批准;
  • UI 显示失败字段、批准失效原因和是否产生副作用;
  • Trace 不保存密钥、完整个人数据或高敏工具输入;
  • SDK、MCP、队列 Worker 与直接函数调用共用执行门禁;
  • Provider 端工具的审批边界已单独确认;
  • 并发、过期、重放、跨租户和 Schema 升级已经进入回归测试。

九、事实、工程判断与验证边界

**官方事实:**Vercel AI SDK ai@7.0.78 于 2026-08-24 发布;Release 说明批准后的 generateTextstreamTextWorkflowAgent turn 在重校验工具输入无效时,会带着模型可见的工具错误继续。工具文档说明 inputSchema 用于校验模型工具调用,人工批准流程包含 approval request、approval response 与第二次调用;Provider 端执行工具不受本地审批设置控制。

**本地验证:**框架无关的 Node.js 门禁在 v22.19.0 上通过 7 项测试,覆盖参数漂移、Schema 失败、调用者变化、资源变化、过期和重放;拒绝路径没有调用模拟副作用。

**工程判断:**把批准绑定到调用者、工具、资源、最终输入哈希、有效期和一次性状态,是基于审批语义与常见恢复风险形成的实现建议,不是 AI SDK Release 宣称的完整内部数据模型。

**未验证边界:**本次没有安装 AI SDK、连接模型、发送真实工具调用,也没有验证数据库原子消费、消息队列、跨进程并发、MCP 或 Provider 端工具。上线前仍需在项目自己的 Runtime 和风险工具上补集成测试。

官方来源:Vercel AI SDK ai@7.0.78 ReleaseAI SDK Tool Calling 与 Tool Execution Approval。本文实验代码为框架无关的 Node.js 最小实现,复现命令和验证边界已在正文给出。

相关推荐
小小帅呀1 小时前
学习 VLA 第 2 天:深度学习基础
人工智能·深度学习·学习
科技小E1 小时前
国标GB28181视频平台EasyGBS监控为什么会“瞎”:视频质量诊断EasyVQD如何揪出黑屏卡顿偏色
大数据·人工智能·音视频
仙女修炼史1 小时前
ultralytics-yolov8的数据增强
人工智能·opencv·yolo
2601_967097221 小时前
接待机器人推荐:2026年主流品牌多场景选型指南
人工智能
audyxiao0011 小时前
热点快讯│2026年世界机器人大会精彩看点
人工智能·机器人·具身智能·世界机器人大会·wrc2026
夏雪coding1 小时前
JeecgBoot 动态路由踩坑(Vue2):刷新白屏、permissionList[0] 报 undefined,以及我最后为什么全删了
javascript·vue.js
南吕十七2 小时前
RAG与Agent_体系
人工智能·机器学习
飞凌嵌入式2 小时前
工业控制+AI视觉齐发,飞凌嵌入式将亮相国际物联网展·深圳站
人工智能·物联网