人工审批真正批准的,不应该是一个模糊的“可以执行”,而应该是某个可信主体在某个任务中提出的那一次精确行动。
假设一个 Agent 帮用户处理订单退款。
第一次调用工具时,它提交了:
{
"order_id": "ORD-20260723-018",
"amount": 300,
"reason": "duplicate_payment"
}
系统判断这是一项高后果操作,于是暂停执行,并向审批人展示:
订单:ORD-20260723-018
退款金额:300 元
退款原因:重复支付
审批人确认信息无误,点击“批准”。
随后任务恢复,Agent 再次生成工具调用。但这一次,它提交的是:
{
"order_id": "ORD-20260723-018",
"amount": 3000,
"reason": "duplicate_payment"
}
如果运行时只记录:
这个任务已经审批通过
或者只记录:
refund_create 这个工具已经审批通过
那么第二次调用很可能会借用第一次审批,直接执行 3000 元退款。
这不一定来自恶意攻击。参数变化可能源于:
- 模型重新推理后生成了不同数字;
- 上下文压缩丢失了原始参数;
- 工作流恢复时重新执行了参数生成节点;
- 工具 Schema 的默认值或字段映射发生变化;
- Agent 根据后来读取到的信息重新规划了行动;
- 网络重试被错误实现成一次全新的工具调用;
- 人工审批等待期间,任务上下文或业务数据已经变化。
问题的本质并不是“模型为什么不听话”,而是:
系统把对一次具体行动的批准,错误地升级成了对未来调用的通用授权。
这类问题可以称为审批参数漂移。它揭示了 Human-in-the-loop 最容易被忽略的一层:有人工参与,不等于人工批准的内容与最终执行的内容一致。
一、审批不是一个布尔值
很多系统最初实现人工审批时,会在任务表上增加一个字段:
approved = true
这个字段能表达“有人点过同意”,却回答不了:
- 谁提出了这项行动?
- Agent 当时代表谁行动?
- 审批人看到的是哪个工具?
- 审批页面展示了哪些参数?
- 恢复执行时还是不是同一项能力?
- 参数是否发生了变化?
- 这份批准可以使用几次?
- 它是否已经过期?
- 审批发生后,业务状态是否仍然允许执行?
因此,生产系统中的审批对象不应该只是一个任务,也不应该只是一个工具名。
更准确的批准对象应当接近:
可信主体
+ 能力或工具
+ 规范化参数
+ 任务与请求身份
+ 审批决定及其签发者
可以把它表示成一个绑定元组:
(subject, capability, tool, args, job_id, request_id)
审批人批准的是这个元组所描述的行动,而不是向 Agent 发放一张可以自由填写内容的空白支票。
二、为什么只绑定工具名仍然不够
一种常见改进是把批准记录从任务级缩小到工具级:
job_id = job-123
tool = refund_create
status = approved
这比一个全局 approved=true 更好,但仍然不够。
同一个 refund_create 可以表达完全不同的业务后果:
{
"order_id":"A","amount":100}
和:
{
"order_id":"B","amount":100000}
工具名相同,不代表行动相同。
同样,下面两个调用也不应该共享批准:
{
"employee_id":"E-21","status":"disabled"}
{
"employee_id":"E-21","status":"deleted"}
一个是暂时停用,一个可能是不可逆删除。只看工具名,系统无法知道审批人究竟同意了哪种后果。
因此,审批至少必须绑定到精确参数。只要影响行动语义的字段发生变化,就应该视为另一项行动:
换对象 = 新行动
换金额 = 新行动
换状态 = 新行动
换收件人 = 新行动
换批量范围 = 新行动
新的行动应该重新评估风险、重新执行授权,并在需要时重新审批。
三、参数快照解决“审批人到底看见了什么”
当运行时决定暂停一项操作时,首先应该冻结一份参数快照。
例如:
{
"tool": "refund_create",
"scope": "refund.create",
"subject": "tenant-18:user-204",
"args": {
"order_id": "ORD-20260723-018",
"amount": 300,
"reason": "duplicate_payment"
},
"job_id": "job-7f93",
"request_id": "req-b285"
}
这份快照至少承担三种作用。
1. 生成审批展示
审批页面应该从被冻结的参数生成摘要,而不是在审批时再次让模型解释“它准备做什么”。
模型生成的自然语言可以帮助阅读,但不能成为批准范围的唯一依据。审批人最终看到的金额、订单、目标账号或批量范围,必须能回到原始结构化参数。
2. 恢复任务
审批结束后,系统应该恢复同一项任务,而不是重新创建一项“看起来差不多”的任务。
如果 Runtime 需要再次调用模型,也必须把已批准的精确调用作为不可修改的执行输入,而不是只告诉模型:
退款已经批准,请继续。
3. 建立审计证据
事后需要能够回答:
审批人看见的参数
是否等于
最终派发的参数
如果系统没有保留快照,只保留一句“某人批准了退款”,这条记录无法证明最终执行的就是当时展示的内容。
四、为什么还需要参数哈希
只保存参数快照还不够。恢复执行时,运行时还需要一种稳定方式判断:
本次参数和被批准参数是否完全一致?
直接比较原始 JSON 字符串会遇到一个问题。下面两段 JSON 的键顺序不同,但语义相同:
{
"order_id":"A","amount":300}
{
"amount":300,"order_id":"A"}
如果直接比较字符串,系统会错误地把它们判断成两个请求。
因此,常见做法是先规范化参数,再计算摘要:
args_hash = SHA-256(canonical_json(args))
一个基本的规范化过程通常需要明确:
- 对象键是否递归排序;
- 数组顺序是否保留;
- 缺失字段与
null是否区分; - 数字、布尔值和字符串是否禁止隐式转换;
- 非法 JSON 值如何处理;
- 不同语言实现如何得到相同字节序列。
例如:
{
"amount":300}
不能和:
{
"amount":"300"}
得到相同语义,因为一个是数字,一个是字符串。
而:
{
"ids":[1,2]}
和:
{
"ids":[2,1]}
是否相同,取决于数组顺序在该工具中的业务含义。通用实现通常应保留数组顺序,避免擅自改变调用语义。
哈希不是什么
参数哈希只是对快照的稳定引用,不是万能安全凭证。
它不能单独证明:
- 快照来自可信主体;
- 审批决定由有资格的人签发;
- 审批记录没有被篡改;
- 业务状态仍然有效;
- 原始参数中不存在敏感数据。
因此:
- 哈希需要和任务、主体、工具及审批记录一起保存;
- 跨信任域传递时,需要认证通道或签名信封;
- 原始快照仍需按敏感数据策略保存和脱敏;
- 不应把
args_hash当成权限令牌。
五、一条可审查的审批执行链
一条最小但完整的链路可以分成十步。
第一步:模型提出工具调用
tool = refund_create
args = { order_id, amount, reason }
此时的参数仍然是不可信输入。
第二步:运行时校验并规范化参数
系统先依据工具 Schema 校验类型、必填字段和结构,再把参数转换成稳定的 JSON 语义。
校验发生在计算快照之前,避免审批一份无法真实派发的参数。
第三步:判断是否需要外部决策
判断依据可以来自:
- 操作风险;
- 声明中的审批意图;
- 部署环境策略;
- 企业审批系统;
- 当前可信主体与场景。
这里的目标是判断“是否需要暂停”,不是让模型自己决定“我觉得这次不危险”。
第四步:冻结审批意图
系统保存:
approval_id
job_id
request_id
subject
tool
scope
args snapshot
args_hash
risk / reason
created_at
审批页面和外部审批通知都应引用同一份冻结数据。
第五步:审批权威作出决定
谁有资格批准,取决于企业自己的身份、组织和策略体系。
运行时可以发起审批并等待结果,但不应该凭空假设:
任何登录控制台的人都可以批准任何操作。
第六步:决定信封返回
一份可机器验证的决定至少应该重新带回:
approval_id
job_id
request_id
args_hash
decision
decision_id
approver
decided_at
decision_id 用于处理审批系统的重复回调;args_hash 用于确认它决定的是同一份参数快照。
如果审批系统和执行系统跨越不同信任域,还需要签名或等价的来源认证机制。
第七步:原子消费批准
批准不能被无限复用。
系统需要把:
approved + unused
原子地转换成:
approved + claimed
如果两个并发执行者同时尝试使用同一张审批单,只能有一个成功。
简单地先查询“尚未使用”,再在调用完成后更新“已使用”,中间存在并发窗口。更稳妥的做法是使用条件更新、事务或等价的 compare-and-set。
第八步:只按原参数恢复
恢复执行时再次计算当前参数哈希:
current_args_hash == approved_args_hash
相等,才有资格继续。
不相等时,系统应该:
- 拒绝当前调用;
- 记录参数漂移;
- 返回已批准的精确参数,要求按原样恢复;
- 如果确实要修改参数,创建新的行动并重新评估。
它不应该为了“流程顺畅”自动修改审批记录,也不应该悄悄为新参数创建第二张审批单,让 Agent 在审批循环里空转。
第九步:使用稳定幂等身份派发
审批单的一次性消费,解决的是“批准不能被重复使用”;业务幂等解决的是“副作用不能被重复形成”。
两者不能互相替代。
如果进程在“消费批准”后、“收到业务结果”前崩溃,恢复逻辑必须继续同一个任务和同一个业务请求身份,而不是重新创造一次退款。
高保证场景还需要持久任务状态、可靠派发或 Outbox 等机制,缩小批准状态和外部调用之间的崩溃窗口。
第十步:业务系统执行最终授权
即使工具、主体、参数和审批完全一致,业务系统仍然可能拒绝:
- 订单已经退款;
- 剩余可退金额发生变化;
- 操作人权限已被撤销;
- 当前租户或门店不匹配;
- 业务状态已不允许继续;
- 同一业务幂等键已经成功处理。
批准证明的是:
有人在某个时刻同意了这项行动
它不能证明:
这项行动在任何未来时刻都必须成功
最终 Authority 仍然属于业务系统。
六、审批有效期为什么只是问题的一部分
很多团队发现审批可能被长期复用后,会增加:
expires_at
这是有价值的,但它只解决时间窗口,不解决参数漂移。
一份 30 分钟内有效的批准,如果只绑定工具名,仍然可能在第 2 分钟授权错误金额;一份永不过期但严格绑定精确参数的批准,也可能因为业务状态变化而不再适合执行。
因此,至少需要同时区分:
内容一致性:执行的是不是批准的那项行动?
时间新鲜度:批准是否仍在有效窗口?
业务新鲜度:当前业务状态是否仍允许执行?
三者分别由参数绑定、运行时策略和最终业务校验承担。
七、五种常见但危险的实现
1. 把批准挂在整个会话上
session.approved = true
这会让同一会话后续所有高风险操作都有机会借用一次批准。
2. 只批准自然语言摘要
“用户同意退款”
摘要适合帮助人阅读,却不能替代结构化参数。自然语言遗漏的字段,往往正是决定真实后果的字段。
3. 批准后让模型重新生成参数
如果模型只收到“已经批准,请继续”,它会重新推理,而不是恢复一份不可修改的执行请求。
4. 发现参数不同后自动更新审批单
这等于让行动提出方在批准之后修改被批准内容。正确做法是拒绝漂移参数;需要修改时重新建立决策。
5. 把审批成功当成业务授权成功
审批人可以同意行动,但业务系统仍必须校验对象、租户、余额、状态和权限。上游批准不能覆盖业务不变量。
八、审计日志至少要证明什么
一次涉及人工审批的 Agent 行动,审计记录不应只有:
refund approved
至少应该能够关联:
- 发起请求的人或系统;
- Agent 实际代表的可信主体;
- 任务与请求身份;
- 工具、能力范围和风险;
- 审批前的参数快照或受保护引用;
- 参数摘要;
- 审批人、决定、时间与决定幂等键;
- 批准被何时、由哪个执行者消费;
- 最终派发参数是否匹配;
- 业务系统最终接受或拒绝的结果;
- 重试、去重、超时和恢复事件。
需要注意的是,日志字段齐全不等于证据天然可信。
如果同一个可能被攻陷的 Runtime 既能伪造审批结果,又能改写唯一的审计记录,这份日志更多是运维记录,而不是独立可验证证据。更高保证等级下,可以让审批权威签发决定、使用只追加存储,并由业务边界重新验证关键绑定。
九、一个最小执行伪代码
下面的伪代码强调的是责任顺序,而不是某个框架的固定 API:
const args = validateAndNormalize(toolSchema, modelArgs)
const argsJson = canonicalJson(args)
const hash = sha256(argsJson)
const exactApproval = await approvals.findApprovedUnused({
jobId,
subject,
tool,
argsHash: hash,
})
if (!exactApproval) {
const approvedOtherArgs = await approvals.findApprovedUnused({
jobId,
subject,
tool,
})
if (approvedOtherArgs) {
await audit("tool_args_drift", {
jobId,
tool,
expectedArgsHash: approvedOtherArgs.argsHash,
actualArgsHash: hash,
})
return rejectWithApprovedSnapshot(approvedOtherArgs)
}
return createApprovalIntent({
jobId,
requestId,
subject,
tool,
args,
argsHash: hash,
})
}
if (!await approvals.claimOnce(exactApproval.id)) {
return reject("approval_already_consumed")
}
return businessSystem.execute({
subject,
tool,
args,
stableRequestId,
})
在真实生产系统中,还要继续处理:
- 审批决定来源认证;
- 决定回调幂等;
- 任务持久化;
- 派发崩溃窗口;
- 业务幂等;
- 超时状态查询;
- 敏感字段脱敏;
- 证据保留策略。
伪代码没有消除这些问题,但它至少避免了最危险的一步:把一次批准误当成一段可自由改写的执行权限。
十、ACC 与运行时应该怎样分工
能力契约可以声明:
- 哪项操作需要可信主体;
- 操作风险是什么;
- 是否存在审批意图;
- 哪些参数条件会触发人工介入。
这些是可以随能力一起发布的可移植语义。
但能力声明不应该假装知道:
- 企业里谁有资格审批;
- 审批系统使用什么组织模型;
- 批准有效多久;
- 决定如何签名;
- 任务如何暂停和恢复;
- 业务对象此刻是否仍可操作。
这些属于部署时运行策略、审批权威、执行协调和业务系统。
因此,一条清晰的分工是:
声明层:
表达“这里需要外部决策”
运行时:
冻结精确行动、暂停任务、绑定决定、恢复原请求
审批系统:
判断谁有资格批准,并签发决定
业务系统:
执行最终对象级授权、状态校验和事务提交
声明层保持可移植,运行时负责兑现,业务系统保留最终 Authority。
十一、一个公开实现样本
BailingHub 采用了一种具体做法:
- 工具参数先按照实际 JSON 传输语义规范化;
- 对象键递归排序,数组顺序保留,再计算
args_hash; - 审批记录绑定
job_id + tool + args_hash; - 决定回调重新核对
approval_id / job_id / request_id / args_hash; - 已批准记录只能被原子消费一次;
- 同一工具出现已批准但参数不一致的调用时,记录
tool_args_drift并拒绝执行; - 运行时把已批准的精确参数返回给任务恢复链路;
- 副作用调用继续使用幂等账本;
- 业务系统仍然执行最终权限和业务状态判断。
这只是一个可运行的工程样本,不是唯一实现,也不是 ACC 核心规范强制的内部结构。
如果审批需要跨组织、跨 Runtime 或跨多个 Agent 传递,还需要进一步处理标准化规范化、签名委托链、独立时间基准和可验证证据。这些问题不应该被一个 args_hash 掩盖。
十二、上线前检查表
批准对象
- [ ] 批准是否绑定可信主体,而不是模型生成的 user_id?
- [ ] 是否绑定精确工具或能力标识?
- [ ] 是否绑定任务和请求身份?
- [ ] 审批人是否能看到真正影响后果的结构化参数?
参数一致性
- [ ] 参数是否先经过 Schema 校验和 JSON 规范化?
- [ ] 规范化规则是否对对象、数组、
null和类型有明确语义? - [ ] 执行前是否重新计算并比较参数摘要?
- [ ] 参数不一致时是否拒绝,而不是自动更新批准?
审批决定
- [ ] 谁有资格审批由可信企业系统决定吗?
- [ ] 决定回调是否有来源认证?
- [ ] 重复回调是否使用稳定
decision_id去重? - [ ] 批准是否只能被一个执行者原子领取一次?
执行与恢复
- [ ] 批准后是否恢复同一个任务,而不是创建新任务?
- [ ] 副作用调用是否使用稳定业务幂等键?
- [ ] 崩溃恢复是否会重复执行或丢失已批准行动?
- [ ] 超时后是否查询原任务状态,而不是盲目重试?
最终授权与证据
- [ ] 业务系统是否仍校验租户、对象、权限和实时状态?
- [ ] 审计记录能否对齐批准参数与真实派发参数?
- [ ] 敏感参数快照是否被适当保护和脱敏?
- [ ] 系统是否诚实区分运维日志与独立可验证证据?
结语
Human-in-the-loop 的价值,不是在人和 Agent 之间增加一次点击,而是把机器提出的高后果行动重新交给可信的外部决策。
要让这项决定真正有效,系统必须保证:
人批准的行动
=
运行时恢复的行动
=
业务系统最终收到的行动
只要工具、主体、参数、任务或业务状态发生变化,原批准就不能被静默外推。
这也是 Agent 进入真实业务系统后,一个看似细小却决定安全上限的工程问题:
审批不是给 Agent 一次通行权,而是只批准那一次被看见、被理解、被绑定的具体行动。
你们现在的 Agent 工具审批,是绑定到会话、任务、工具名,还是已经绑定到精确参数快照?如果任务在审批后重新推理,系统如何证明最终执行的仍然是审批人当时看到的那一次调用?