# Agent 可靠执行实战（十）：第二步失败，第一步怎么撤回来？

> 用三个独立 SQLite 文件、真实进程退出与并发恢复，构建两步资源预留的补偿状态机；验证明确拒绝、结果未知、补偿重放和迟到请求，说明为什么补偿不能直接恢复旧快照。

- 作者：芝士AI吃鱼
- 发布日期：2026-10-18
- 主题：Agent、AI工程、Saga、SQLite
- HTML 正文：[https://ai-knowledgepoints.cn/blog/agent-reliability-10-compensation-state-machine](https://ai-knowledgepoints.cn/blog/agent-reliability-10-compensation-state-machine)
- Markdown 永久链接：[https://ai-knowledgepoints.cn/blog/agent-reliability-10-compensation-state-machine/index.html.md](https://ai-knowledgepoints.cn/blog/agent-reliability-10-compensation-state-machine/index.html.md)

引用本文时，请注明作者与 HTML 正文链接。Markdown 版本用于机器阅读，与 HTML 正文共享同一内容来源。

## 正文

仓库还有十套设备。Agent 为一场活动预留三套，接着去订安装时段。时段没订上，它决定把第一步撤回来。

“撤回来”看着只有三个字，写成代码却有两种完全不同的意思：把自己占的三套放回去，或者把库存改回之前看到的十套。中间如果另一个订单已经拿走两套，后一种写法就凭空变出了两套设备。

这是合成业务场景，没有对应真实客户或作者亲历事故。[第三篇](https://ai-knowledgepoints.cn/blog/agent-reliability-03-unknown-reconciliation)处理单次工具调用结果未知，[第九篇](https://ai-knowledgepoints.cn/blog/agent-reliability-09-monetary-budget-reservations)处理费用预留与对账。这一篇把两步串起来：**第二步失败后，如何持久地安排第一步的补偿；补偿自己又崩溃时，如何恢复而不多退、不重占。**

资料核对日期为 2026 年 10 月 18 日。本文实际运行 Python 标准库、三个本地独立 SQLite 文件、并发进程和进程突然退出；库存、时段和故障输入均为合成样例。没有模型调用、网络服务、真实订单或跨机器实验，也没有把这些代码称为生产 Saga 框架。

## 1. 补偿是下一笔业务动作，不是时间倒流

一次数据库事务回滚，撤销的是尚未提交的本地改动。这里第一步已经提交给库存系统，第二步才去访问另一个系统。它们没有共同事务；再给协调器套一层 try/except，也不能让已经提交的库存自动退回。

[Azure 的补偿事务模式文档](https://learn.microsoft.com/en-us/azure/architecture/patterns/compensating-transaction)强调，直接恢复旧状态可能覆盖其他并发工作，补偿需要遵守具体业务规则，而且补偿本身也可能失败。本文只借用这几个设计原则，下面的状态机、故障点与数值全部来自自建实验，没有使用 Azure 服务。

我们的业务规则刻意收得很窄：设备和安装时段都只是可释放的预留，没有出库、发货、付款或已完成安装。每个预留用一个稳定的业务 ID 标识；释放只能改变这个 ID 对应的记录，不能覆盖整个资源池。已经交付的设备、已经发送的邮件、不能退的手续费，都不属于这个“释放预留”模型。

在合成算例里，初始可用量 10，A 预留 3，B 再预留 2，可用量变成 5。A 失败后释放自己的 3，可用量应是 8。错误的快照恢复会写成 10。run_experiment 中 unsafe_snapshot_restore 是确定性算术对照，不是两笔外部订单实测；正确方案则真的在 SQLite 中写入 A、B 的预留，再释放 A，检查 B 仍占 2。

![合成容量10，A占3，B占2；只释放A后B仍占2，可用8；恢复旧快照会错误显示10](https://ai-knowledgepoints.cn/images/articles/agent-reliability-10/owned-release.svg)

补偿以自己的预留为对象。数字是合成资源数量，不是性能指标。

[查看竖版大图](https://ai-knowledgepoints.cn/images/articles/agent-reliability-10/owned-release-mobile.svg)

## 2. 三个数据库，承认三次提交之间有缝

代码把系统拆成 stock.db、slots.db 和 flow.db。前两个是参与者，各自维护容量、预留表和事件表；最后一个是协调器，只保存业务 ID、不可变数量和当前阶段。三个文件没有 ATTACH 联合事务，协调器也不在自己的写锁里等待参与者调用。

每个参与者用 BEGIN IMMEDIATE 把“查现有预留、核可用容量、写结果”放在同一事务。SQLite 的[事务文档](https://www.sqlite.org/lang_transaction.html)说明 IMMEDIATE 会在事务开始时尝试取得写事务。这解决的是单个文件内的并发写入，不是跨文件原子性。锁超时仍会抛错，代码不会把数据库错误当成资源不足。

参与者返回的结果必须有明确语义。reserved 表示这个 ID 已经占住资源；rejected 表示这个 ID 被持久拒绝；released 表示已经释放，或者在首次预留到达前就被关闭。数量与已有 ID 不一致返回 conflict。相同数量的重放只能读回已经确定的结果，不能再次占用。

这里有一个重要取舍：资源暂时不足也会写成永久 rejected。稍后其他订单释放资源，旧 ID 仍然得到 rejected。这样，协调器收到拒绝并开始补偿之后，不会因为一个迟到重试突然给第二步订上时段。若业务要重新争取资源，需要一次新的、明确的业务决定和新 ID；不能把旧失败悄悄改写为成功。新 ID 同样不提供跨 ID 的业务去重，生产系统必须定义订单层唯一性。

这个契约不是所有 API 都提供的。普通 HTTP 503、连接重置、超时，甚至查询暂时查不到，都不能替代持久的业务拒绝。只有双方真的支持上述终态语义，本文的后续推理才成立。

## 3. 状态机里，只有一条路通向补偿

协调器从 stock_pending 开始。库存 reserved 后进入 slot_pending；库存 rejected 则直接 aborted，因为第一步没有占用。时段 reserved 后进入 done；时段 rejected 后进入 release_pending。最后只有库存释放确认成功，才能从 release_pending 进入 aborted。

```text
stock_pending → slot_pending → done
      │              │
      │              └→ release_pending → aborted
      └──────────────────────────────────→ aborted
```

箭头都不是“因为异常就随便选一条”。slot_pending 收不到回答时，仍然是 slot_pending，第一步仍占着三套设备。release_pending 收不到回答时，仍然是 release_pending，不能向用户报告“已撤销”。保守的代价是资源可能长时间被占用；系统需要恢复调度、告警和人工处置，而不是把超时解释成已经恢复。

代码中的 unavailable 是一个合成缺回音输入，抛出 TimeoutError，不会偷偷走业务拒绝分支。它发生在调用前，因此只验证协调器如何处理缺回音，不能冒充真实网络延迟或远端请求在途；提交后回音丢失由下面的进程退出实验单独覆盖。

每次推进都先读取阶段，再调用参与者，最后用阶段条件更新协调器。核心写法是：

```python
c.execute(
    'UPDATE saga SET phase=? WHERE key=? AND phase=?',
    (next_phase, key, phase),
)
```

多个恢复进程可能同时读取同一阶段，也可能重复调用参与者。参与者必须用稳定 ID 去重，协调器的条件更新负责不让旧结果倒退当前阶段。本文的阶段图没有回边，同一个阶段不会退出后再次进入，因此旧阶段相等检查足够用于这个受限状态机；不能原样推广到允许重开、版本迁移或循环执行的工作流。

![库存预留后申请时段，明确拒绝进入补偿待办，确认释放后结束；缺回音停在原阶段，不能宣告失败或撤销成功](https://ai-knowledgepoints.cn/images/articles/agent-reliability-10/recovery-path.svg)

协调器保存恢复位置。缺回音既不是业务拒绝，也不是补偿成功。

[查看竖版大图](https://ai-knowledgepoints.cn/images/articles/agent-reliability-10/recovery-path-mobile.svg)

## 4. 补偿提交了，确认却没写下来

最值得盯住的故障点，是库存已经把 A 改成 released，协调器仍停在 release_pending。恢复进程只看后者，会再次调用 release。

如果 release 的含义是“每来一次就把可用量加三”，第二次恢复就会多退。样例不这样做。它先查 A 的记录；已经 released 就返回原结果，不再改容量、不再写第二条释放事件。资源使用量由仍为 reserved 的记录计算，释放 A 不需要重写 B，更不需要保存旧的总量快照。

释放后也不能把 A 的记录删除。否则一个迟到的 reserve(A,3) 看见“从没来过”，又会占三套。released 记录是阻止旧请求复活的墓碑。这个边界与[第六篇的保留策略](https://ai-knowledgepoints.cn/blog/agent-reliability-06-idempotency-retention)相接：清理状态不是普通磁盘整理，删除终态会改变重放语义。本篇没有实现保留期限，测试记录持续保留。

release 还支持在预留尚未出现时写入 released：迟到的正向请求也会看到关闭结果，而不会新建占用。正常协调器并不依赖乱序释放，因为它只在第一步成功后补偿；这个参与者能力用于防住迟到命令，并不意味着任何调用者都可以取消任意预留。release 是可信内部接口，样例没有认证、租户隔离或真实审批，不能开放给模型直接调用。

测试专门让一个真实子进程读到 stock_pending，然后在调用库存前停住。另一条执行路径把整件事推进到 aborted、完成释放，再放行旧进程。它拿到 released 后重新读协调器阶段，返回 aborted，没有再占资源。若阶段没变却出现不符合契约的结果，代码会报错，留下需要调查的状态，不会猜一个成功结论。

## 5. 用崩溃把每条接缝撬开

对库存预留、时段预留和库存释放三个阶段，分别在四个位置让子进程执行 os._exit(73)：参与者提交前、参与者提交后、协调器提交前、协调器提交后，共十二个真实退出点。没有用正常异常捕获来假装进程已经死掉。

参与者提交前退出时，本地事务恢复后没有那次写入；提交后退出时，协调器仍可能保留旧阶段，恢复靠参与者回执重放。协调器提交前退出时，参与者的独立提交不会一起回滚；协调器提交后退出则直接从下一阶段继续。十二组恢复最后都到达 aborted，库存占用 0，A 的库存历史保留一次 reserved 和一次 released。

这也解释了为什么不能只测试“最后库存是零”：把历史全部删干净同样能得到零，却丢掉了迟到请求的防线。本次检查同时对照协调器阶段、参与者记录和事件，确认不是靠擦掉证据让结果好看。

另外两类测试分别启动六个进程：一类恢复同一个业务 ID，检查补偿只记一次；另一类推进不同业务 ID，让它们争抢同一个库存池，检查各自终态不会破坏别人记录。并发赢家取决于进程调度，所以输出不固定哪个订单最先拿到资源。另有上述握手控制的迟到进程测试，不靠 sleep 猜测竞态发生了没有。

实验使用 WAL 与 synchronous=FULL，但测到的是单机进程退出和 SQLite 恢复，不是断电、文件系统故障、跨区灾备或分布式一致性。snapshot 在单个资源库内使用显式读事务；跨库 report 仍仅在测试操作结束、子进程已退出后采样，不是实时跨数据库一致快照。看门狗超时只防测试挂死，不用于声称处理性能。

## 6. 哪些保证需要留在门外

在本例约束下，容量不会因为同一个 ID 的重试被重复占用，补偿不会释放别人的预留，协调器不会因为缺回音就进入终态。系统也允许出现“库存已占、时段尚未决定”的中间状态。Saga 在这里提供的是可恢复的业务过程，不能把三次独立提交包装成一次原子事务。

最终能结束还依赖恢复工作会被继续执行、参与者最终可访问、持久数据不丢失。本例 finish 只是有界测试驱动，错误直接抛给调用者；没有后台队列、重试退避、运维面板或自动告警。无法联系库存时，补偿可以一直待办。这是未完成，不是最终一致已经得到证明。

也没有“取消已完成订单”接口。done 后如果开始出库，释放预留就可能错误；需要另一套经过授权的售后状态机。本文的合成规则事先允许时段明确拒绝后释放库存，生产中的退款、撤销权限或用户通知必须分别获得业务授权。前文的[精确意图审批](https://ai-knowledgepoints.cn/blog/agent-reliability-08-intent-bound-approvals)可以作为设计参考，但本实验没有集成它，更没有自动继承一次批准来做所有补偿。

若两个资源都能放进同一可靠数据库事务，而且业务不允许中间占用被外部看见，首先考虑本地原子事务。为了使用 Saga 而拆库，可能只是主动给自己增加恢复工作。

## 7. 把三个库放在桌上，再看恢复结果

将五个文件下载到同一空目录：[saga.py](https://ai-knowledgepoints.cn/examples/agent-reliability-10/saga.py)、[test_saga.py](https://ai-knowledgepoints.cn/examples/agent-reliability-10/test_saga.py)、[run_experiment.py](https://ai-knowledgepoints.cn/examples/agent-reliability-10/run_experiment.py)、[results.json](https://ai-knowledgepoints.cn/examples/agent-reliability-10/results.json)、[README.md](https://ai-knowledgepoints.cn/examples/agent-reliability-10/README.md)。Python 3.10 或更新版本即可，无第三方库、模型密钥或付费服务。

```bash
python3 test_saga.py
python3 run_experiment.py --check
python3 run_experiment.py --output reproduced.json
```

本次验证环境为 Linux、Python 3.12.14、SQLite 3.53.1。37 项 unittest 通过，19 组具名场景与完整 results.json 重放一致。输入检查、同 ID 参数冲突、持久拒绝、先释放后预留、重复释放、迟到进程、同业务与跨业务并发，以及十二个崩溃切点都有覆盖。未验证 Windows；不同机器上不承诺相同进程次序或耗时。

建议先看 crash_release_after_commit：协调器仍说 release_pending，库存却已经释放。再看恢复后的同一条记录，就能看到这篇真正想保住的东西：不是一串永远显示成功的步骤，而是当两边说法暂时不一致时，仍有一条不会越退越多的回去的路。

系列讨论可查看[知识星球](https://ai-knowledgepoints.cn/planet)，或通过[公众号入口](https://ai-knowledgepoints.cn/about#wechat)关注后续。本文、原创图解与示例代码由 AI 辅助制作，并经过执行与内容核验；合成资源预留不代表真实订单、支付或生产权限系统。
