第 49 章 可靠 Agent:权限、预算、回放与恢复
助手已经回答住宿限额,也查到了 mira 的 A-104 订单。员工现在要求取消订单。应用发出取消请求,等待了一段时间,超时之前却没有收到响应。应该再发一次吗?
超时只说明调用方停止等待,不能说明接收方有没有修改订单。Primer-HTTP 已经介绍过这个缺口;一旦工具可以写入,它就会带来实际后果。把失败解释得再有说服力,也填不上这个缺口。我们需要能在失败之后继续查验的证据。
49.1 第一次重试,就可能犯下新错误
Section titled “49.1 第一次重试,就可能犯下新错误”例 49.1:一个意图,两次副作用。 本章的订单全部是虚构的。我们给已有知识助手增加一个本地业务模拟器,保留第 42 章不可变的只读快照。我们把预先固定、用来重复同一测试条件的数据和返回值称为测试夹具(fixture)。新夹具把 mira 的 A-104 放在租户 north 下,初始状态为 processing,对象版本为 1。取消会改变状态,并记录一次副作用。可以把这次副作用理解为还要向下游发送取消通知:即使最终状态都叫 cancelled,通知发两遍仍可能造成问题。
故意不加防护的模拟器每接受一次取消,就把副作用计数加一。同一个请求发送两遍,第一次把计数从 0 改成 1,第二次从 1 改成 2。只看最终状态,就会漏掉重复。独立预期值之所以是两次副作用,是因为这条不安全规则接受了两次调用。
我们真正需要的规则不同:重复送达的是同一个操作意图时,不能重复产生业务副作用。这就是这里需要的幂等性(idempotency)。它不保证每次耗时相同,也不要求响应字符串完全一致;它同样不表示,以后所有带着 A-104 的请求都要永远当作同一次操作。
我们为这一次意图分配一个键,并持久保存带有结果的回执(receipt)。查找范围由租户和键共同确定;回执还绑定用户与操作参数的摘要。同一个键若带着不同参数到达,宿主就报告 idempotency_conflict;参数一致,才可以返回保存的结果。如果每次重试都换一个新键,恰好会切断我们需要保留的联系。
49.2 权限要比一句“允许执行”更具体
Section titled “49.2 权限要比一句“允许执行”更具体”第一次写入之前,宿主检查的提案相当于:
{ "name": "cancel_order", "arguments": {"order_id": "A-104", "expected_version": 1}}通过身份认证的访问主体(principal)由宿主提供,不在这份对象中任意填写。模拟器用一份不可变记录表示主体,包含租户、用户、角色、目标服务、权限范围和过期时间。权限范围(scope)给某项有限权限命名;目标服务(audience)说明凭据原本供哪个服务使用。这里的本地记录只是教学替身,不是签名凭据,也不是生产登录系统。
表 49.1:普通工具权限与具体审批分开检查。
| 操作 | 教学策略是否允许自动进行? | 需要检查什么? |
|---|---|---|
| 读取 mira 本人的订单 | 建立规定的读取权限后可以 | 主体有效、服务正确、租户与所有者匹配 |
| 读取他人的订单 | 不可以 | 模型请求再礼貌也要拒绝 |
| 提出取消建议 | 可以形成提案 | 提案本身不执行写入 |
| 批准取消 | 仅指定审批角色可以 | 相同租户、具体主体与操作、明确决定及有效期 |
| 执行取消 | 获批之后才可以 | 写权限、匹配且有效的审批、当前对象版本、可取消状态 |
| 任意发送 HTTP 或运行 shell 命令 | 不可以 | 允许清单中没有这些能力 |
审批(approval)记录某个人对一项具体操作给出的许可。模拟器中,审批者拥有 orders:approve,操作方拥有 orders:read:own 和 orders:cancel:own,这是不同的角色。审批绑定租户、用户、操作摘要和过期时间。批准 A-104 的第 1 版,不等于批准 A-105、另一个租户的 A-104,或同一订单后来变化出的新版本。
版本检查防止过时计划直接生效。假设提出建议之后、实际执行之前,订单已经发货,原先合理的取消就不应盲目作用于改变后的对象。宿主要重新读取状态,版本不符便拒绝。真实服务必须在自己的写入边界执行这个检查;如果客户端检查完版本,远端却不加条件地写入,中间仍可能插入别人的更新。
“只读自动”是说,应用已经为这些读取建立了范围明确的权限策略,不是说所有能读到的资料都能交给任意模型或第三方服务。第 53 章会继续追踪这些边界。
49.3 把副作用和证明它的回执一起提交
Section titled “49.3 把副作用和证明它的回执一起提交”本地模拟器用 SQLite 保存订单、审批、回执、运行检查点和事件。SQLite 是一种小型数据库,可以把相互关联的更新放进同一个事务(transaction):这些更新要么全部提交,要么全部不提交。本例把取消副作用和对应回执放在同一个事务里。
- 应用保存的记录PENDING → CALLING;预算 3 → 2
- 处理步骤检查审批与版本
- 模型之外提交取消+回执
- 应用保存的记录响应丢失 → UNCERTAIN
业务副作用计数已经是 1。
- 应用保存的记录读回 UNCERTAIN+原键
- 处理步骤CALLING;预算 2 → 1
- 文本片段找到回执 → DONE
副作用计数仍为 1。回放不会进入 CALLING,也不修改数据库行。
图 49.1 把响应丢失放在共同提交之后。图中的返回路径读取持久回执,不再重复修改业务状态。
例 49.2:取消已经提交,响应却丢失了。 初始副作用计数为 0,已经有一份具体且有效的审批,调用预算为三次,最多尝试两次。故障注入器在取消和回执提交后立即抛出超时异常。这是人为放置的故障点,不是测得的一次网络事故;但记录确实来自本地数据库路径的实际执行。
表 49.2:响应丢失后的完整轨迹。
| 步骤 | 持久状态或观察结果 | 剩余调用次数 | 副作用计数 |
|---|---|---|---|
| 开始 | PENDING;保存提案、审批 ID 和键 | 3 | 0 |
| 准许第一次尝试 | CALLING;尝试前先扣额度 | 2 | 0 |
| 事务提交 | 已取消订单与回执一起保存 | 2 | 1 |
| 提交后触发故障 | 调用方记录 UNCERTAIN | 2 | 1 |
| 关闭并重新打开数据库 | 状态、键和预算仍在 | 2 | 1 |
| 准许恢复尝试 | CALLING;沿用同一意图和键 | 1 | 1 |
| 返回原回执 | DONE,receipt_reused: true | 1 | 1 |
恢复尝试虽然没有产生第二次业务副作用,仍然有调用成本,因此要消耗第二个调用额度。独立检查也不能只看“结果声称复用了回执”:测试实际关闭并重新打开数据库,再查看持久订单,确认副作用计数仍为一。
把故障移到提交之前,结果就不同。第一次尝试留下零次副作用、零份回执;获准进行的第二次尝试才完成操作,各留下一份。这两项测试分别把失败放在事务边界两侧,可以暴露“回执写得太早”或“副作用提交了、回执却没提交”的程序错误。
这种原子性只覆盖同一个数据库。若真实订单服务和助手日志分别使用不同数据库,在本地写一份回执,不能让远端操作也自动具备原子性。远端服务仍需要持久幂等契约,或其他核对结果的机制。给客户端函数起名 retry_safely,并不能让操作自动“恰好执行一次”。
49.4 预算也必须跨越失败保留下来
Section titled “49.4 预算也必须跨越失败保留下来”重试需要几种不同上限,因为它们限制的问题不同。单次超时限制一次等待;尝试次数限制重复;调用预算限制获准发起的操作;总截止时间限制请求允许运行到什么时候。如果只保存当前阶段,每次重启又把预算重置成三次,就可以无限地开始一轮又一轮“新尝试”。
例 49.3:分别计算时间和调用次数。 下面的时长都是作者给定的逻辑时钟值,单位为秒。请求从开始算起,1.00 秒到期;单次超时为 0.20 秒,退避等待为 0.05 秒,初始调用预算为三次。第一次尝试等到 0.20 秒时超时,调用额度已经预先扣除,还剩两次。宿主再等 0.05 秒,然后进行第二次尝试;这次在 0.10 秒内成功,又消耗一次额度。
把占用的时间段加起来: 秒。离总截止时间还剩 秒,调用预算则还剩 次。成功之后,不论还剩时间还是次数,都不要求应用继续执行。秒和调用次数也不能相加。
对于一次已经获准发起的尝试,若离截止时间还剩 ,允许等待的时长为:
这里, 是按选定时钟起点计算的截止时刻, 是同一个时钟上的当前时刻, 是单次超时,单位都是秒。第一次超时和退避之后,,因此 。若总截止时间只有 0.21 秒,第一次超时后就放不下 0.05 秒的退避等待,调度程序便不会进行第二次尝试。
逻辑调度器演示的是这套计算,不会中断任意 Python 代码,也不能证明真实服务满足某个墙上时钟时限。沿用的 HTTP 客户端会实际测试传输超时;取消模拟器则在精确的提交位置注入故障,使案例可以重复。真实工作进程需要传输层或进程边界确实能执行的超时控制;恢复时还应使用跨重启有效的时间表示,不能直接沿用只属于原进程的计时起点。
表 49.3:按照真正发生的原因停止。
| 条件 | 记录结果 | 为什么不应盲目重试? |
|---|---|---|
| 审批被拒绝或已经过期 | DENIED | 重复请求不能补出许可 |
| 调用额度用完 | STOPPED,call_budget | 重启不能重新发放额度 |
| 超过总截止时间 | STOPPED,deadline | 还有调用次数,不等于可以延长时间 |
| 两次尝试均失败 | STOPPED,attempt_limit | 剩余预算可能留给其他工作 |
| 对象版本或幂等键冲突 | MANUAL_REVIEW | 操作意图与当前证据不再一致 |
| 响应丢失,但已保存有效回执 | 读取回执恢复结果 | 不需要再次写入业务状态 |
读取已有回执需要当前仍然有效的读取权限,却不需要重新批准取消,因为这次没有发生新的取消。如果回执不存在,要执行操作就必须重新通过当前的写权限和审批检查。已经因截止时间而停止的运行,也不会悄悄恢复;人可以通过获准的读取路径核对已记录的结果。
49.5 回放展示证据,恢复可能继续操作
Section titled “49.5 回放展示证据,恢复可能继续操作”轨迹(trace)记录可以从外部检查的事件:状态、步骤顺序、时间、所选操作、有关版本、审批引用和结果。它不是模型私有内部解题推理的逐字记录;一段流畅的解释也不能代替业务回执。轨迹应避免保存凭据和不必要的个人文字。
本项目的回放(replay)读取保存的事件,展示原来的顺序。回放测试检查数据库修改计数没有增加;过程不调用模型,也不执行工具写入。按旧记录重现一个画面,只说明当时保存了什么,不能证明今天的来源或服务还会给出相同结果。
恢复(recovery)则重新打开持久状态,从有效检查点继续。它可能执行新操作,因此必须保留预算,并重新核验权限。本地模拟器只有一个工作进程;并发工作进程的占用协调、分布式事务和远端凭据刷新,都不在已测试范围。检查点是持久的应用记录,不是一句“任务差不多完成了”的模糊笔记。
补偿(compensation)是为处理先前副作用而采取的新操作,不是把时间倒回去。订单取消后,再订一间房可能价格已经变了,也可能房间已经没有了。本例的补偿处理函数记录 COMPENSATION_REVIEW,返回 no_automatic_inverse,业务写入次数为零。这是针对这项操作明确规定的恢复策略,并不是遗漏了一个自动“撤销”功能。若要重新订房,需要另外取得授权,并处理它自己的失败。
49.6 独立 Agent 协作协议放在哪里
Section titled “49.6 独立 Agent 协作协议放在哪里”A2A 描述独立 Agent 之间的通信,包括任务和任务产生的成果;MCP 描述工具与上下文访问。两者可以配合,但把一个名字换成另一个,不会解决审批与恢复。当前规范的版本细节放在配套在线说明中。KA-6 不建立 A2A 连接,也不增加第二个 Agent:这个已知任务用一个宿主管理的工作流就能完成;增加 Agent 会带来新的协调与失败状态,需要另行说明必要性。
49.7 KA-6 的实践与适用范围
Section titled “49.7 KA-6 的实践与适用范围”reliable_runtime.py 提供本地状态存储与执行器,source_skill.py 接入来源核验包。实践程序保存正常、拒绝、预算耗尽和中途失败的轨迹。Notebook 会先关闭、再重新打开实际 SQLite 文件,然后恢复,并比较副作用计数。没有防护时的两次副作用基线,则保留在另一个临时数据库中。
至此,KA-6 具备可复用来源核验、限定范围的读取、明确审批后的本地写入、有限预算、依赖身份,以及可以恢复的失败证据。教学主体记录、逻辑调度器和单数据库事务的限制都直接列出。本实验不会修改真实用户订单、付款或远端服务。
49.8 本章小结
Section titled “49.8 本章小结”超时之后要查的是哪些操作已经提交,而不是助手说得有多确定。具体审批把权限绑定到操作意图;限定范围的幂等键和持久回执,则把重试与原意图联系起来。预算和检查点必须在失败后继续保留。回放读取历史,恢复可能继续操作,补偿也是一项可能失败的新操作。这些控制让具体失败路径可以检查;下一章要把视角扩展到整个系统,不能只数最后答对了几题。
49.9 习题
Section titled “49.9 习题”- 重算例 49.3:总截止时间为 0.30 秒,第一次在 0.20 秒超时,退避 0.05 秒,第二次操作需要 0.10 秒。第二次最多可以等多久?按给定规则能成功吗?
- 响应丢失之后,恢复程序生成了新的幂等键。为什么这破坏了原先想要的保证,即使本模拟器的版本检查碰巧挡住了第二次副作用?
- 审批批准的是 A-104 第 1 版,新请求却拿同一份审批取消 A-105 第 1 版。哪项检查失败?能够成功读取两笔订单,会改变结论吗?
- 仪表盘按保存的轨迹还原了一次成功取消,操作员称它为“重新执行工作流”。解释两者的区别,并给出一项可以观察的测试。
- 取消已经提交,但承诺重新预订的房间没有空余了。补偿程序应把原订单改回处理中吗?应该怎样如实记录,又由谁采取下一步?
49.10 参考解答
Section titled “49.10 参考解答”- 第二次开始时已经过去 0.25 秒,离截止只剩 秒,允许等待 秒。操作需要 0.10 秒,因此会超时。虽然消耗两次调用后还剩一次额度,它不能延长截止时间。
- 对回执查找来说,新键代表新意图,原来的保存结果就不能复用了。在这个特定夹具中,旧的预期版本又与已经变化的订单冲突,宿主于是转人工检查。但这项额外检查不是通用幂等保证:换一种副作用,或换一个更宽松的服务,仍可能重复执行。应保留原键及其参数绑定。
- 目标变化使操作摘要不同,审批不再匹配。读取权限既不等于写权限,也不等于新目标获得了审批。业务状态不应发生变化。
- 回放读取旧记录;重新执行则让当前代码作用于当前状态,可能产生副作用。可以记录前后的数据库修改次数和工具调用次数:本项目的回放不会增加两者。回放成功不能证明重新执行也会成功。
- 不应这样改。那会篡改已经提交的业务状态,让人误以为仍有一个有效预订。应保留原取消与回执,把补偿记为未解决、待人工处理,由负责操作的人取得具体替代方案并完成审批。替代操作还需要自己的回执和失败路径。
49.11 参考文献
Section titled “49.11 参考文献”-
MCP 授权规范,2026-07-28 版。权限范围处理与安全要求说明身份、目标服务和授权为何仍是宿主及服务的明确责任。
-
A2A 正式规范与 A2A 和 MCP 的关系。用来定位独立 Agent 之间的任务交换与工具访问;本书本地工作流不要求实现 A2A。
-
NIST AI RMF 1.0,GOVERN 2。帮助把技术恢复与具体人工责任联系起来。本章数据库案例与预算数值均为原创教学构造。
-
SQLite atomic commit。开头说明本地事务全有或全无的性质;远程副作用不在这个数据库边界内。