很多 Agent 产品在危险操作前弹一句“是否继续”。这个交互给人一种已经加了安全控制的感觉,但如果批准之后模型仍能修改参数,或者执行器拿着同一份批准重复调用,用户实际确认的内容与系统最终做的事可能并不一致。
我们在一个内部规则管理场景里遇到过这种缺口。运营人员要求 Agent 把三条告警规则的接收组从 A 调整为 B。页面展示了三条规则,用户点击确认。审批等待期间,Agent 根据新返回的数据重新规划,把另一条“相似规则”也加入执行列表。四条修改都符合最初的自然语言目标,却只有三条真正经过人确认。
问题不在模型是否聪明,而在系统把一次 UI 确认误当成了执行授权。高风险审批需要是一段可验证的事务协议:人批准的是一份规范化、不可变、有时效的执行计划;计划任何实质变化都必须重新审批。
先定义哪些动作需要人,而不是所有动作都弹窗
频繁确认会让人形成机械点击,最后既降低效率,也没有提高安全性。审批应该根据后果分级,而不是根据“是否由 AI 发起”分级。
| 风险级别 | 示例 | 默认处理 |
|---|---|---|
| 低 | 读取公开数据、生成草稿、查询状态 | 自动执行,保留普通日志 |
| 中 | 修改可快速恢复的内部配置 | 策略校验、影响上限、必要时抽样确认 |
| 高 | 外部通知、权限变更、批量修改、费用操作 | 明确审批,绑定执行计划 |
| 极高 | 不可恢复删除、大额资金、越权访问 | 双人审批或禁止 Agent 直接执行 |
风险还要结合作用域。修改一条测试环境规则和修改全公司生产告警不是同一件事,即使调用的是同一个工具。策略引擎应基于主体、资源、环境、数量和可逆性判断,模型只负责提出意图,不能自行给动作定级。
审批对象必须是确定性执行计划
自然语言说明可以帮助人理解,但不能作为授权凭证。真正进入审批的计划需要稳定字段:
type ExecutionPlan = {
planId: string;
taskId: string;
actor: { userId: string; tenantId: string };
tool: string;
toolVersion: number;
input: unknown;
targets: Array<{
resourceType: string;
resourceId: string;
expectedVersion: number;
}>;
expectedEffect: string;
compensation?: ToolCall;
createdAt: string;
expiresAt: string;
};网关先对工具输入做 Schema 校验和规范化,再按稳定字段顺序序列化并计算 planHash。模型生成的解释、展示顺序和措辞可以变化,工具版本、目标资源、输入与补偿动作不能在批准后变化。
const planHash = sha256(canonicalJson({
actor: plan.actor,
tool: plan.tool,
toolVersion: plan.toolVersion,
input: plan.input,
targets: plan.targets,
compensation: plan.compensation,
expiresAt: plan.expiresAt,
}));把 actor 和 expiresAt 纳入 hash 是为了防止批准在另一个租户、用户或时间窗口中被复用。具体字段应按威胁模型决定,但原则一致:执行器必须能证明“正在执行的就是被批准的那一份计划”。
审批界面展示业务影响,不展示模型思维过程
审批者需要回答的是“我要承担什么后果”,而不是阅读模型长篇解释。页面至少应展示目标对象、before/after、影响数量、数据快照时间、是否可逆、失败后的处理方式,以及触发这次操作的业务原因。
对于批量策略变更,我们会展示完整数量和分组汇总,同时允许展开逐项差异。只展示三个抽样对象不够,因为真正的风险可能藏在未展示部分;把几百行 JSON 全部铺开也不够,因为人无法在有限时间内理解。
审批权限本身也要受控。能使用 Agent 的人,不一定有权批准生产权限变更。对于职责分离场景,提出计划的人和批准人不能是同一主体;极高风险操作还需要两位不同角色的独立批准。
批准是一张有期限、只能消费一次的票据
type Approval = {
approvalId: string;
planHash: string;
approvedBy: string;
approvedAt: string;
expiresAt: string;
status: "approved" | "consumed" | "revoked";
};执行器不能先读取 approved 再单独更新状态,这会在并发请求中被消费两次。批准消费和执行记录创建需要处于原子边界:
UPDATE approvals
SET status = 'consumed', consumed_at = now()
WHERE approval_id = $1
AND plan_hash = $2
AND status = 'approved'
AND expires_at > now()
RETURNING approval_id;只有返回一行才允许继续。按钮双击、网络重试或两个执行器竞争时,只有一个能拿到票据。对于支持幂等的外部系统,还要使用由 planHash 和执行序号派生的 idempotencyKey,避免内部防重与外部副作用脱节。
执行前必须处理 TOCTOU
审批时看到的资源可能在等待期间变化。这是典型的 time-of-check to time-of-use 问题。计划中为每个目标保存 expectedVersion,执行前通过 compare-and-set 更新:
UPDATE alert_rules
SET receiver_group = $1, version = version + 1
WHERE tenant_id = $2
AND id = $3
AND version = $4;影响行数为零时返回 PLAN_STALE。Agent 可以重新读取资源并生成新计划,但不能把旧批准自动套在新状态上。即使最终参数碰巧相同,目标集合、版本或影响范围变化也需要重新评估。
批量操作还要事先声明原子性。是全部成功才提交,还是允许部分成功?数据库内的同域修改可以放在一个事务中;跨多个外部系统通常无法获得强原子性,此时必须逐项记录结果,并在审批界面明确“可能部分完成”。
超时不等于失败,结果未知必须成为正式状态
外部系统超时时,调用可能已经成功,只是回执丢失。自动重试高风险副作用,可能重复发消息、重复扣费或重复修改权限。把它简单标记为 failed 会诱导系统再次执行。
图 1:outcome_unknown 不是普通失败分支。系统先查证外部事实,再决定确认成功、重新执行或进入人工恢复。
执行状态至少要区分:
planned -> awaiting_approval -> approved -> executing -> succeeded
|-> failed_before_effect
|-> outcome_unknown
|-> compensation_requiredfailed_before_effect 可以安全重试;outcome_unknown 先用幂等键查询对方回执,或通过资源当前状态核对。无法自动确认时,进入人工恢复队列,展示已知证据和推荐动作,而不是让 Agent猜测。
补偿也不是“回滚”两个字。外部消息无法真正收回,最多发送更正;权限恢复要确认期间是否发生新的合法修改;费用冲正本身可能需要新审批。补偿是一项新的业务动作,同样需要权限、幂等、审计和失败处理。
审计记录要能独立重放当时发生了什么
完整证据链包括:原始任务、规范化计划与 hash、策略判断、审批人和审批版本、一次性凭证消费、执行器身份、外部请求与回执、状态核对以及补偿结果。
审计不能只保存一段模型总结。半年后排查时,需要能够回答:人具体批准了哪些资源和参数,执行时资源版本是否一致,外部系统返回了什么,为什么进入结果未知,以及最后由谁确认恢复。
敏感字段可以脱敏或单独加密,但不能因此丢掉责任链。日志保留策略也应与业务风险匹配,而不是所有 Agent 调用统一保存七天。
人在环路里的价值是判断,不是替系统兜底
一个可靠的审批系统不会让人频繁点“允许”。低风险动作自动化,高风险动作才把影响压缩成可理解计划;系统负责保证批准和执行一致,处理并发、过期、状态漂移和未知结果。
人应该判断业务后果是否可以接受,不应该负责发现目标列表被悄悄改了、猜测按钮会不会执行两次,或者在超时后手工翻日志确认外部系统是否成功。这些属于工程系统应提供的确定性。
Agent 能让计划生成和工具编排更灵活,但授权边界必须更明确。模型负责提出候选,策略系统决定是否允许进入审批,审批人承担有上下文的业务判断,执行器严格消费被批准的计划。四个角色分开之后,人机协作才不是一层看起来安全的弹窗,而是一条可以审计和恢复的生产协议。
审批票据的消费与执行状态,和不要把生产密钥交给模型里的权限网关是同一套执行边界:前者管“人的确认如何被绑定”,后者管“模型请求如何被校验与幂等化”。超时后 outcome_unknown 的查询核对,与requestId 到了消息队列就断了里 traceId 与 jobId 的区分同源——执行链和业务身份必须分开保存。