用户点了"批准",不代表工具从此拿到一张空白执行许可证。批准只对当时展示的调用成立;真正执行前,系统必须用最终输入重新做 Schema、权限、资源和审批绑定校验。任何一项不匹配,都应失败关闭,不能沿用旧批准。
这个问题看起来像安全常识,却很容易被两段式 Agent 流程漏掉:第一次模型调用生成工具参数,应用展示参数并收集用户决定;第二次模型调用带着批准响应继续。两次调用之间存在序列化、恢复、工具修复、上下文变化和代码升级,最终送到执行器的输入不一定还是用户看过的那一份。
Vercel AI SDK 在 ai@7.0.78 的官方 Release 中修复了一个很具体的边界:已经批准的 generateText、streamText 和 WorkflowAgent turn,如果工具输入重校验后无效,SDK 会向模型提供可见的工具错误并继续 turn。本文不复刻 SDK 内部实现,而是把这个变化落成一个框架无关的执行门禁,并用本地 7 项 Node.js 测试验证拒绝路径。

一、批准的是一份快照,不是"这个工具以后都能跑"
AI SDK 当前工具文档说明,inputSchema 既提供给模型,也用于校验模型产生的工具调用。需要人工批准时,流程通常包含两次模型调用:第一次返回 tool-approval-request,应用取得用户决定后追加 tool-approval-response,第二次再继续生成或执行。
这里至少有三份状态:
- 模型第一次生成、准备展示的工具输入;
- 用户真正看到并同意的输入快照;
- 恢复后进入执行器的最终输入。
只有三者一致,批准才具有可执行意义。如果用户看到的是"查询订单库最近 7 天的 id 与 amount",最终输入却变成"导出工资库最近 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 规则,并把规则版本一起记录。更稳妥的做法是保存脱敏后的批准快照,同时保存哈希;哈希用于快速比较,快照用于审计和向用户解释差异。
**过关证据:**客户端只提交批准决定,服务端重新认证审批者,并从服务端存储读取批准快照;客户端不能通过修改 tool、resource 或 inputHash 扩大授权。
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:crypto、node:test 和 node: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 幂等已经通过。它也没有模拟恶意客户端签名伪造,因为示例批准票据没有对外传输。
五、失败后不要自动沿用旧批准修参数
批准后发现最终输入无效,最危险的修复不是抛错,而是系统悄悄改参数后继续执行。自动修复可能改变资源、数量、收件人或操作类型,用户却没有看到新输入。
更稳妥的恢复路径是:
- 把
validation_failed_after_approval作为模型和 UI 都可见的独立事件; - 告诉模型哪个字段失效,但不要给它继承旧批准的权限;
- 模型若生成新参数,把它当作一笔新的工具调用;
- 新调用重新经过 Schema、策略与用户批准;
- 原批准标记为失效或保持不可消费,审计中链接新旧调用。
如果修复仅是服务端无语义的规范化,例如稳定排序对象键、移除 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_id、tool_call_id与run_id的关联;tool_name、Schema 版本、策略版本;actor_id_hash、租户与资源类别;- 批准输入哈希、最终输入哈希,以及是否匹配;
approved_at、expires_at、consumed_at;- 重校验终态、失败字段和
side_effect_started; - 新调用是否由旧调用修复产生。
不要为了排查方便记录完整密钥、个人数据、SQL、Shell 命令或消息正文。能用字段名、资源类别、哈希和脱敏摘要定位的问题,不应保存原始敏感值。
告警也要区分数量与比例。偶发 Schema 失败可能来自模型波动;同一工具在升级后突然大量出现 validation_failed_after_approval,更像 Schema 或序列化契约漂移;大量 approval_binding_mismatch 可能意味着恢复逻辑串租户、客户端重放或批准快照版本不一致。三者处理人和停止条件不同。
七、接入现有 Agent 的最小改造顺序
不要先重做整套 Agent Runtime。按依赖关系完成四个改造:
- 冻结批准快照。 在展示批准 UI 前完成 Schema 校验和规范化,保存服务端快照与哈希。
- 补齐批准绑定。 把调用者、租户、run、工具、资源、有效期和一次性状态加入批准记录。
- 统一执行入口。 所有工具执行都经过共享重校验层,不能让 SDK、MCP、队列 Worker 或直接函数调用各自旁路。
- 增加失败终态与回归。 UI、Trace、告警和测试都识别批准后校验失败,并断言副作用没有开始。
迁移时怎样处理正在等待的旧批准
真正上线时,最难处理的往往不是新请求,而是数据库里已经处于 waiting-approval 或 approved 的历史记录。旧记录可能只有 approved: true 和一个会话标识,没有输入哈希、Schema 版本、资源范围、过期时间或消费状态。直接按新逻辑补默认值,等于替用户补签了一份从未看过的授权;继续走旧执行路径,又会让最危险的请求绕开新门禁。
我更倾向于把迁移拆成三个集合。第一类是尚未获得用户决定的请求:保留原输入供展示,但在用户打开审批页时先用当前 Schema 重新校验;如果字段已经失效,就终止旧请求并生成新的审批,而不是把修复结果塞回原记录。第二类是已经批准但没有完整绑定证据的请求:默认不可执行,向用户明确说明"系统升级后需要重新确认"。第三类是绑定字段完整、仍在有效期内且未消费的请求:也要经过新门禁,并把本次兼容路径写入 Trace,方便之后下线。
迁移脚本本身只应改变状态,不应调用工具。可以先在生产数据的脱敏副本上运行 dry-run,输出每类记录的数量、缺失字段、最老等待时间和预计重新审批比例;人工抽样确认分类规则后,再批量写入 needs_reapproval、expired 或 legacy_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 说明批准后的 generateText、streamText 与 WorkflowAgent 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 Release、AI SDK Tool Calling 与 Tool Execution Approval。本文实验代码为框架无关的 Node.js 最小实现,复现命令和验证边界已在正文给出。