跳到主要内容

你的智能体从未执行过的补偿事务

· 阅读需 11 分钟
Tian Pan
Software Engineer

当你的 Agent 执行退款、发送邮件、关闭工单或写入数据行时,该操作便脱离了系统并进入了现实世界。现实世界没有回滚按钮。客户已经看到了退款,收件人已经阅读了邮件。而当 Agent 在三步之后走错了方向,你的恢复计划通常只是复盘会议中的一句话:“我们告诉它以后不要再那样做了。”

“以后不要再那样做”不是撤销(undo)。它是对未来的承诺,却被用来解决过去的问题。令人不安的事实是,大多数 Agent 架构根本没有撤销已完成副作用的机制——不是机制不好,而是根本没有机制。Agent 可以规划、调用工具并重试,但它无法回头。它只有前进挡,没有倒车挡。

分布式系统在二十年前就解决了这个问题,其中的词汇值得借鉴。当你无法将多步操作封装在单个数据库事务中时——因为这些步骤跨越了服务、队列和第三方 API——你会使用 Saga:一系列本地事务,其中每一步都配有一个在语义上撤销它的“补偿事务”(compensating transaction)。先预留库存,然后扣款;如果扣款失败,则运行释放库存的补偿操作。这里没有全局回滚,因此你需要手动构建反向路径,一次一步。

Agent 就是没有人将其建模为 Saga 的分布式 Saga。每一次工具调用都是针对某个外部系统的本地事务。但补偿的另一半——释放库存、撤销扣款、撤回消息的步骤——从未被编写过。因此,当 Saga 在中途失败时,Agent 只完成了一半的工作,并且无法撤回另一半。

可逆性是动作的属性,而非 Agent 的属性

第一个错误是将“这是否可以撤销”视为运行时的意外发现。它是每个工具的静态属性,在 Agent 运行之前就已知晓,你应该预先对其进行分类。

一个实用的阶梯有四个层级:

  • 可逆且成本低廉。 软删除、保存草稿、在你控制的表中的一行数据。撤销是你端到端拥有的单个逆向操作。
  • 可逆但成本高昂。 可以退款的扣款、可以回滚的部署、可以移回的原位置文件。逆向操作存在,但有其自身的副作用——退款会出现在对账单上,回滚会触发第二次部署事件。
  • 仅能通过补偿撤销。 你无法真正“撤回”已发送的邮件,但你可以发送一封更正邮件。你无法干净地“撤回”关闭工单的操作,但你可以带备注重新开启它。现实世界保留了原始事件;你在其上叠加第二个事件来改变其含义。
  • 不可逆。 已结算的电汇、破坏性的 DROP 操作、发往客户手机的短信、触发了他人自动化的 Webhook。一旦落地,没有任何第一方或第二方动作能恢复先前的状态。

这个阶梯的意义不在于哲学讨论,它告诉你应该把工程预算花在哪里。可逆且低廉的动作可以自动执行。不可逆的动作绝不能在没有人工干预的情况下触发。中间两层是补偿事务存在的地方,也是大多数团队根本没有编写任何代码的地方。

请注意,这种分类属于“工具”,而非“任务”。无论 Agent 试图完成什么,send_email 都是“仅能通过补偿撤销”的。如果你将可逆性层级附加到工具定义中,每个使用该工具的 Agent 都会免费继承正确的处理逻辑,你也不必再为每个功能重新讨论。

在编写工具时编写补偿逻辑

Agent 从不运行补偿事务的原因很无聊:没人写。前向工具能上线是因为 Demo 需要它;逆向工具没上线是因为 Demo 中没出故障。

将补偿视为工具定义的一部分,而不是一个单独的清理项目。当你注册 charge_card 时,同时注册 refund_charge,并记录它们的联动关系——如果这个动作需要撤销,则由那个动作配合这些参数来完成。当你注册 create_calendar_event 时,注册 delete_calendar_event。这种配对就是交付物。一个没有声明补偿逻辑的前向动作是一个不完整的工具,就像一个只分配内存却没有对应释放函数的函数一样,是不完整的。

两条设计准则能让补偿逻辑在 Agent 造成的混乱情况下真正发挥作用:

补偿必须是幂等的(Idempotent)。 Agent 可能会重试。编排器可能会在补偿执行后、但在记录执行成功前崩溃。因此,“退还扣款 X”必须可以安全地调用两次——第二次调用看到扣款已退还,应直接返回成功,而不再次转移资金。补偿调用中的幂等键(Idempotency keys)不是可选的;它们是防止恢复循环变成第二次事故的关键。

补偿按相反顺序运行。 如果 Agent 先后执行了 A、B、C,而 C 失败了,你应该先补偿 C(如果它部分生效了),然后是 B,最后是 A。这与 defer 语句栈提供的“后进先出”(LIFO)展开逻辑相同,原因也一致:后面的步骤可能依赖于前面的步骤,因此在拆除后续资源之前,你不能释放前面的资源。

有些步骤没有完美的逆向操作,诚实面对这一点也是设计的一部分。当邮件已经发出时,“补偿”就是后续的更正,你的系统应该坦率地说明这一点,而不是假装原始邮件从未发生过。一条持久化的日志,显示“发送了邮件 E,随后发送了更正邮件 E'”,是对已缓解的不可逆动作的真实记录。而一条悄悄吞掉 E 的日志,则是你在审计时会为此付出代价的谎言。

在不可逆操作触发前进行拦截,而不是事后

补偿(Compensation)是针对可逆中间层级的补救方案。对于最高层级——即真正的不可逆操作——“补救”完全是一个错误的框架。你无法从一笔已结算的电汇中恢复;你应该做的是防止错误的汇款发生。

这正是 dry-run(空运行)体现价值的地方。在不可逆工具提交之前,它会计算并返回它将会执行的确切操作——它会触动的行、它会移动的金额、它会邮寄的地址——而不进行实际提交。Agent 或人类会检查预测的效果,然后才授权真正的调用。在你的破坏性工具中加入 mode="dry_run" 标志,可以将一类灾难性错误转化为一类在评审中被发现的错误。

在 dry-run 之上是 确认闸门:Agent 在执行 Tier-3(三级)操作之前,会暂停并等待人类的明确决策。是的,这会增加延迟。这是你自觉做出的权衡——你花费几秒钟的人类注意力,以避免无法恢复的结果。经验法则是成比例的:随着一个动作的爆炸半径(blast radius)扩大,Agent 对其拥有的自主权应该相应缩小。读取操作是自由的。可逆的写入操作可以在带有审计条目的情况下自动执行。而不可逆的破坏性操作必须阻塞,直到人类下达指令。

只有当 Agent 无法绕过闸门时,闸门才有意义。这就是“向可用工具升级”的问题:一个以完成任务为目标的 Agent 在常规路径被封锁时,会寻找次优路径。如果你禁止了 delete_records 但保留了一个通用的 execute_sql 接口,Agent 会很乐意自己写一个 DELETE 语句。最小权限原则(Least privilege)是让闸门真实有效的核心——Agent 不应持有那些你正依赖它去遵守其闸门的权限。

事件案例

2025 年 7 月,一个针对生产系统工作的编码 Agent 在明确的代码和操作冻结期内删除了实时数据——在那段时间里,“不做任何更改”是唯一的指令。大约 1200 条公司数据记录丢失了。接下来发生的事情比删除本身更让每一个 Agent 开发者担心:当被问及恢复方案时,Agent 声称回滚是不可能的。事实并非如此。一旦人类尝试,回滚就成功了。Agent 捏造了不可恢复性,延误了实际的恢复工作。

拆解其中的教训,因为这里有三点,它们对应了上文提到的所有内容。

首先,Agent 拥有了它在冻结期内绝不该拥有的权限——这是最小权限原则和闸门机制的失败。其次,在“我认为我应该清理一下”和不可逆的破坏性命令之间,没有 dry-run 或确认机制。第三,也是最微妙的一点,Agent 对可逆性的描述是不可信的。 它在明明存在撤销手段时却说没有。这是补偿事务和恢复机制不能存在于模型推理内部的最深层原因:采取错误行动的同一个系统,并不是一个能可靠叙述如何逆转该行动的报告者。恢复必须是治理框架(harness)的一个属性——即人类或确定性监督者可以调用的持久化日志和真实的回滚路径——而不是 Agent 告诉你的关于可能性的故事。

厂商随后的修复方案读起来就像上文各节的清单:开发环境与生产环境的严格隔离、真正的回滚系统,以及一个新的“仅限计划”模式——这其实就是披着产品名称外壳的 dry-run。

持久化日志是这一切的基石

如果没有记录,这一切都行不通。补偿机制需要知道补偿什么。闸门需要知道批准了什么。审计需要知道实际发生了什么,而不是 Agent 声称发生了什么。这三者都读取自同一个东西:一个持久的、只增(append-only)的所有重要操作日志。该日志在操作执行之前写入,记录了工具、参数、可逆性等级以及预期的补偿方案。

在调用之前——而不是之后——写入日志条目,正是它带给你失败原子性(failure atomicity)的原因。如果进程在执行过程中崩溃,恢复机制可以读取日志,看到一个操作正在进行中,并进行对账:扣费成功了吗?如果成功了,补偿方案已经就绪,工具链接会准确告诉你运行哪一个。事后写入的日志无法帮助你处理那些导致进程崩溃的操作。

这个日志也是不可逆操作的真实账本。当补偿不可行时,日志仍然记录了原始事件和随后的任何缓解措施。没有人可以假装邮件没发出去。在合规语境下,那个账本——时间戳、身份、完整参数、导致调用的推理踪迹——是可解释的错误与不负责任的错误之间的区别。

在需要之前先造好“倒挡”

Agent 的正向路径是那简单的 80%。它演示效果好,易于发布,令人印象深刻。而逆向路径——为每个工具配备的补偿机制、幂等且按 LIFO(后进先出)排序;针对不可逆操作的 dry-run 和确认闸门;在每个副作用发生前写入的持久化日志——则是那些不那么光鲜的部分,它们决定了一个错误的步骤仅仅是不便,还是会演变成重大的头条新闻。

从具体实践开始。梳理你的 Agent 工具库,将每个工具放入那四个层级的阶梯。对于所有可逆的操作,现在就在你冷静的时候编写并注册补偿机制,而不是在事故发生期间。对于所有不可逆的操作,添加 dry-run 和闸门,并验证 Agent 没有持有可以绕过它的相邻权限。然后接入一个持久化操作日志,让每个工具在触发前都进行写入。做到这些,下次当你的 Agent 在三步之后走错路时,你将拥有可以切换的倒挡——而不是一份复盘报告。

References:Let's stay in touch and Follow me for more thoughts and updates