Agent 崩在第 4 步,前 3 步的副作用撤不回来:我给它写了层补偿事务
我有个跑多步流程的 Agent,某天它干到第 4 步炸了。前 3 步已经把库存占掉、订单建好、发货单生成了——炸掉的那一步我重试过(之前那篇退避重试就是干这个的),也给它加过幂等键(那篇管「同一个调用别执行两次」),可这三条已经发生的副作用,一样都没收回去。
我翻日志的时候才发现这是第三类问题:重试管「再来一次」,幂等管「别来第二次」,但都没管「已经落地的东西怎么撤」。站里那几篇一层层攒下来的护栏,恰好在这漏了一块。
我先造了个会真改东西的实验台
空谈没意义,我搭了个五步的订单流程:占库存 → 建订单 → 建发货单 → 扣款 → 发邮件。后两步不可逆(真扣钱、真出网),前三步可逆。零依赖 Node,每一步都真改一份世界状态。
然后我按四档策略跑,每档都让每一步分别失败一次:
| 档位 | 做法 | 五档失败累计 damage |
|---|---|---|
| A | 什么都不做 | 10 |
| B | 反向补偿,增量式(stock += 3) | 1 |
| C | 反向补偿,目标态(stock = 快照)+ undo log 落盘 | 1 |
| D | C + 不可逆动作改成两阶段 | 0 |
A 档在第 5 步失败时最惨:3 条可逆副作用全留着,外加一笔真扣款已经出去了。
1000 次随机失败(每步 8% 概率)跑下来差距更清楚:
A 档:中断 330/1000,残留 518,中断时已发生的不可逆 61,damage 579
B 档:中断 343/1000,残留 0,中断时已发生的不可逆 65,damage 65
C 档:中断 353/1000,残留 0,中断时已发生的不可逆 62,damage 62
D 档:中断 332/1000,残留 0,中断时已发生的不可逆 0,damage 0
B 相对 A:消掉 88.8% 的意外副作用
D 相对 A:消掉 100.0% 的意外副作用第一版补偿我写成增量式,重放一次库存变成 103
我一开始的补偿写得很直白:占库存减了 3,撤销就加回 3。跑起来挺顺,直到我模拟「恢复流程被触发了两次」——崩溃重启后再补一遍是很常见的:
B 档:库存 = 103(原始 100,占 3 后应回到 100)
C 档:库存 = 100103。多出来的 3 件是凭空造的。增量式补偿天然不幂等,而补偿这个动作偏偏必须能重复执行——它会失败、会重放、会在崩溃恢复里被再跑一遍。
改成目标态(把快照值写回去)就稳了,而且顺带白送一个好处:补偿跑到一半进程没了,有持久 undo log 就能接着补。
=== 实验三:补偿跑到一半进程崩了 ===
A 档:残留 3
B 档:残留 2
C 档:残留 0
D 档:残留 0顺序也有讲究。我试了正向补偿(先撤第一步),最终状态看着是干净的,但过程中出现了悬挂引用——发货单还在,它引用的订单已经被删了:
正向补偿(先撤第一步) :最终残留 0,补偿过程中悬挂引用峰值 1
反向补偿(先撤最后一步):最终残留 0,补偿过程中悬挂引用峰值 0反向不是洁癖。中间那个瞬间如果有别的流程在读,它就会读到一份不自洽的数据。
不可逆那两步,补偿根本救不了
扣款和发邮件撤不回来。B、C 两档在「第 5 步失败」这个场景里 damage 都是 1,那 1 就是已经飞出去的扣款。穷尽补偿逻辑也没用,这是定义问题不是实现问题。
我把这两步改成两阶段:执行阶段只做 prepare(生成支付授权 / 邮件草稿),全部可逆步骤成功之后才 commit(真正捕获 / 真正投递)。中途任何一步失败,prepare 的产物直接丢掉,不可逆动作从头到尾没发生过。D 档 1000 次里中断时已发生的不可逆是 0。
这不是我拍脑袋想的花招,支付行业早就是这么干的。我查了 Stripe 文档(核对日期 2026-10-19):capture_method=manual 只授权不捕获,PaymentIntent 状态转为 requires_capture;授权过期未捕获,钱会被释放,支付状态变成 canceled。卡组织给的窗口是 Visa 商户发起 5 天(文档注明精确值是 4 天 18 小时,留给清算流程),Visa / Mastercard / Amex / Discover 的持卡人发起交易是 7 天,线下场景 Visa 5 天、其余 2 天。
给 Agent 用的话,这个窗口完全够——我不需要 7 天,我需要的只是「在不可逆之前把能失败的都跑完」这个顺序保证。
我以为目标态就是终点,然后被一句话打脸
为了让自己的方案站得住,我去翻了微软架构中心的 Compensating Transaction 模式(核对日期 2026-10-19),结果撞见这么一句:
You might think that you can simply restore the system to its original state, but this approach can overwrite changes from other concurrent application instances.
我那个「目标态补偿」干的就是「简单还原原状态」。我造了个并发场景压它:库存 100,我占 3 变成 97,这时别的流程并发卖掉 2 件变成 95,然后我的流程失败了要补偿。正确终态应该是 98(100 减掉别人卖的 2 件)。
正确终态应为 98(100 减去并发卖掉的 2)
target :最终库存 100,偏差 2,挂起待人工 否
cas :最终库存 95,偏差 3,挂起待人工 是
cas-delta :最终库存 98,偏差 0,挂起待人工 否盲写快照得到 100,凭空多出 2 件库存——我把并发方的操作一起吞了。微软那篇里还有一句更狠的:A compensating transaction doesn't necessarily return the system data to its state at the start of the original operation,补偿要抵的是「我自己干过的那部分」,不是整份状态。
我加了个前置校验(当前值必须等于我执行后的值才回滚),又矫枉过正了:不匹配就挂起,结果自己的 3 件也没撤掉,偏差 3。
最后落地的版本是校验失败就回落到带幂等键的差额补偿:
const key = 'undo:reserve:ORD-1';
const idemUsed = new Set(); // 幂等键已落盘
if (stock === afterMine) {
stock = before; // 没人动过,目标态最干净
} else if (!idemUsed.has(key)) {
idemUsed.add(key); // 被人动过,只撤我自己那部分,且只生效一次
stock += mine;
}偏差 0,而且恢复流程重放两次仍然是 98:
target :重放 2 次后库存 100,偏差 2
cas-delta :重放 2 次后库存 98,偏差 0目标态负责幂等,幂等键负责保底,两者不是二选一。我一开始只留了前者。
补偿自己也会失败,那重试几次才够
补偿动作也是网络调用,也会挂。我给了它 20% 的失败率,2000 次流程,看重试次数和残留的关系:
| 重试次数 | 每流程残留(也是挂起待人工数) |
|---|---|
| 0 | 0.5970 |
| 1 | 0.1205 |
| 2 | 0.0250 |
| 3 | 0.0060 |
就是 3 × 0.2^(n+1),跟手算的一样。重试 3 次能把残留压到千分之六,但压不到零。
这里我改了主意:我本来打算让补偿失败就一直重试下去,后来觉得不对。微软那篇给的处置是「重复重试仍失败就丢进死信队列并保留失败详情」,我照做了——重试 3 次还不行就把这条标记成待人工,写进告警,而不是假装它成功了。残留一个已知的错误,比藏着一个不知道的错误强。
什么时候我不这么干
补偿不是白来的。每个可逆动作都要多落一条 undo log,五步流程就是五次额外写;纯只读工具一个都不用加,别一刀切。
还有一类场景我根本不做补偿:流程很短、失败了重跑一遍比撤销更便宜。给两三步的只读链路套一套 Saga,纯粹是给自己找活干。
另外两阶段也有代价——不可逆动作被推迟到提交阶段,意味着「用户以为已经扣款了」和「真的扣款了」之间有个窗口。涉及真实资金时这个窗口必须显式告知,不能默默扛着。
我的判断
写完这七组实验,我最大的改变是对「重试」这件事的看法。我之前把可靠性几乎全押在重试和幂等上,默认「失败就重试,重试不行就算了」。现在我认为那只是把失败推后了一步:重试解决的是这件事没做成,补偿解决的是这件事做成了但整体不该做。两个方向,都得有。
第二个判断是:不可逆动作不该指望事后补救,该靠事前排队。我实测下来,两阶段这层是四档里唯一把 damage 打到 0 的,而且它的实现比补偿逻辑本身还简单——只是把「授权」和「执行」拆成两次调用。性价比高得不像话。
最后是那句打脸我的话。我要是没去翻原文,那套「目标态补偿」会一直带着一个并发下才复现的 bug,而且这种 bug 不会报错,只会让库存莫名其妙多几件,查的时候你根本不会想到是回滚代码干的。
(这几组实验的代码都是本地零依赖 Node,随机数用固定种子,结果可复现。)
信息源与核对日期(2026-10-19):
- Microsoft Learn《Compensating Transaction pattern》——补偿事务定义、幂等命令要求、不可逆步骤应安排在所有关键校验之后、死信队列处置、与 Saga 的关系(Saga uses compensating transactions for failure recovery)。原文链接:https://learn.microsoft.com/en-us/azure/architecture/patterns/compensating-transaction
- Stripe 文档《Place a hold on a payment method》——
capture_method=manual、状态转requires_capture、过期未捕获则资金释放且状态变canceled、各卡组织授权有效期。原文链接:https://docs.stripe.com/payments/place-a-hold-on-a-payment-method