跳转到内容

第 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):这些更新要么全部提交,要么全部不提交。本例把取消副作用和对应回执放在同一个事务里。

第一次尝试
  1. 应用保存的记录PENDING → CALLING;预算 3 → 2
  2. 处理步骤检查审批与版本
  3. 模型之外提交取消+回执
  4. 应用保存的记录响应丢失 → UNCERTAIN

业务副作用计数已经是 1。

重新打开后的恢复
  1. 应用保存的记录读回 UNCERTAIN+原键
  2. 处理步骤CALLING;预算 2 → 1
  3. 文本片段找到回执 → DONE

副作用计数仍为 1。回放不会进入 CALLING,也不修改数据库行。

图 49.1:响应丢失,不会撤销已经提交的操作。 原创教学图示 · CC BY-SA 4.0

图 49.1 把响应丢失放在共同提交之后。图中的返回路径读取持久回执,不再重复修改业务状态。

例 49.2:取消已经提交,响应却丢失了。 初始副作用计数为 0,已经有一份具体且有效的审批,调用预算为三次,最多尝试两次。故障注入器在取消和回执提交后立即抛出超时异常。这是人为放置的故障点,不是测得的一次网络事故;但记录确实来自本地数据库路径的实际执行。

表 49.2:响应丢失后的完整轨迹。

步骤持久状态或观察结果剩余调用次数副作用计数
开始PENDING;保存提案、审批 ID 和键30
准许第一次尝试CALLING;尝试前先扣额度20
事务提交已取消订单与回执一起保存21
提交后触发故障调用方记录 UNCERTAIN21
关闭并重新打开数据库状态、键和预算仍在21
准许恢复尝试CALLING;沿用同一意图和键11
返回原回执DONE,receipt_reused: true11

恢复尝试虽然没有产生第二次业务副作用,仍然有调用成本,因此要消耗第二个调用额度。独立检查也不能只看“结果声称复用了回执”:测试实际关闭并重新打开数据库,再查看持久订单,确认副作用计数仍为一。

把故障移到提交之前,结果就不同。第一次尝试留下零次副作用、零份回执;获准进行的第二次尝试才完成操作,各留下一份。这两项测试分别把失败放在事务边界两侧,可以暴露“回执写得太早”或“副作用提交了、回执却没提交”的程序错误。

这种原子性只覆盖同一个数据库。若真实订单服务和助手日志分别使用不同数据库,在本地写一份回执,不能让远端操作也自动具备原子性。远端服务仍需要持久幂等契约,或其他核对结果的机制。给客户端函数起名 retry_safely,并不能让操作自动“恰好执行一次”。

49.4 预算也必须跨越失败保留下来

Section titled “49.4 预算也必须跨越失败保留下来”

重试需要几种不同上限,因为它们限制的问题不同。单次超时限制一次等待;尝试次数限制重复;调用预算限制获准发起的操作;总截止时间限制请求允许运行到什么时候。如果只保存当前阶段,每次重启又把预算重置成三次,就可以无限地开始一轮又一轮“新尝试”。

例 49.3:分别计算时间和调用次数。 下面的时长都是作者给定的逻辑时钟值,单位为秒。请求从开始算起,1.00 秒到期;单次超时为 0.20 秒,退避等待为 0.05 秒,初始调用预算为三次。第一次尝试等到 0.20 秒时超时,调用额度已经预先扣除,还剩两次。宿主再等 0.05 秒,然后进行第二次尝试;这次在 0.10 秒内成功,又消耗一次额度。

把占用的时间段加起来:0.20+0.05+0.10=0.350.20+0.05+0.10=0.35 秒。离总截止时间还剩 1.00−0.35=0.651.00-0.35=0.65 秒,调用预算则还剩 3−2=13-2=1 次。成功之后,不论还剩时间还是次数,都不要求应用继续执行。秒和调用次数也不能相加。

对于一次已经获准发起的尝试,若离截止时间还剩 D−tD-t,允许等待的时长为:

w=min⁡(τ,D−t).w=\min(\tau,D-t).

这里,DD 是按选定时钟起点计算的截止时刻,tt 是同一个时钟上的当前时刻,τ\tau 是单次超时,单位都是秒。第一次超时和退避之后,t=0.25t=0.25,因此 w=min⁡(0.20,0.75)=0.20w=\min(0.20,0.75)=0.20。若总截止时间只有 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 会带来新的协调与失败状态,需要另行说明必要性。

reliable_runtime.py 提供本地状态存储与执行器,source_skill.py 接入来源核验包。实践程序保存正常、拒绝、预算耗尽和中途失败的轨迹。Notebook 会先关闭、再重新打开实际 SQLite 文件,然后恢复,并比较副作用计数。没有防护时的两次副作用基线,则保留在另一个临时数据库中。

至此,KA-6 具备可复用来源核验、限定范围的读取、明确审批后的本地写入、有限预算、依赖身份,以及可以恢复的失败证据。教学主体记录、逻辑调度器和单数据库事务的限制都直接列出。本实验不会修改真实用户订单、付款或远端服务。

超时之后要查的是哪些操作已经提交,而不是助手说得有多确定。具体审批把权限绑定到操作意图;限定范围的幂等键和持久回执,则把重试与原意图联系起来。预算和检查点必须在失败后继续保留。回放读取历史,恢复可能继续操作,补偿也是一项可能失败的新操作。这些控制让具体失败路径可以检查;下一章要把视角扩展到整个系统,不能只数最后答对了几题。

  1. 重算例 49.3:总截止时间为 0.30 秒,第一次在 0.20 秒超时,退避 0.05 秒,第二次操作需要 0.10 秒。第二次最多可以等多久?按给定规则能成功吗?
  2. 响应丢失之后,恢复程序生成了新的幂等键。为什么这破坏了原先想要的保证,即使本模拟器的版本检查碰巧挡住了第二次副作用?
  3. 审批批准的是 A-104 第 1 版,新请求却拿同一份审批取消 A-105 第 1 版。哪项检查失败?能够成功读取两笔订单,会改变结论吗?
  4. 仪表盘按保存的轨迹还原了一次成功取消,操作员称它为“重新执行工作流”。解释两者的区别,并给出一项可以观察的测试。
  5. 取消已经提交,但承诺重新预订的房间没有空余了。补偿程序应把原订单改回处理中吗?应该怎样如实记录,又由谁采取下一步?
  1. 第二次开始时已经过去 0.25 秒,离截止只剩 0.30−0.25=0.050.30-0.25=0.05 秒,允许等待 min⁡(0.20,0.05)=0.05\min(0.20,0.05)=0.05 秒。操作需要 0.10 秒,因此会超时。虽然消耗两次调用后还剩一次额度,它不能延长截止时间。
  2. 对回执查找来说,新键代表新意图,原来的保存结果就不能复用了。在这个特定夹具中,旧的预期版本又与已经变化的订单冲突,宿主于是转人工检查。但这项额外检查不是通用幂等保证:换一种副作用,或换一个更宽松的服务,仍可能重复执行。应保留原键及其参数绑定。
  3. 目标变化使操作摘要不同,审批不再匹配。读取权限既不等于写权限,也不等于新目标获得了审批。业务状态不应发生变化。
  4. 回放读取旧记录;重新执行则让当前代码作用于当前状态,可能产生副作用。可以记录前后的数据库修改次数和工具调用次数:本项目的回放不会增加两者。回放成功不能证明重新执行也会成功。
  5. 不应这样改。那会篡改已经提交的业务状态,让人误以为仍有一个有效预订。应保留原取消与回执,把补偿记为未解决、待人工处理,由负责操作的人取得具体替代方案并完成审批。替代操作还需要自己的回执和失败路径。
  • MCP 授权规范,2026-07-28 版。权限范围处理与安全要求说明身份、目标服务和授权为何仍是宿主及服务的明确责任。

  • A2A 正式规范与 A2A 和 MCP 的关系。用来定位独立 Agent 之间的任务交换与工具访问;本书本地工作流不要求实现 A2A。

  • NIST AI RMF 1.0,GOVERN 2。帮助把技术恢复与具体人工责任联系起来。本章数据库案例与预算数值均为原创教学构造。

  • SQLite atomic commit。开头说明本地事务全有或全无的性质;远程副作用不在这个数据库边界内。