# Agent 可靠执行实战（一）：工具干完了，回执丢了怎么办？

> 用 Python 标准库、SQLite 和真实子进程崩溃实验，拆开持久化意图、接收端幂等与结果对账。比较重复工单、漏单与安全重放，说明 outbox 为什么不能单独保证外部副作用只发生一次。

- 作者：芝士AI吃鱼
- 发布日期：2026-10-06
- 主题：Agent、AI工程、幂等、可靠性
- HTML 正文：[https://ai-knowledgepoints.cn/blog/agent-reliability-01-outbox-idempotency](https://ai-knowledgepoints.cn/blog/agent-reliability-01-outbox-idempotency)
- Markdown 永久链接：[https://ai-knowledgepoints.cn/blog/agent-reliability-01-outbox-idempotency/index.html.md](https://ai-knowledgepoints.cn/blog/agent-reliability-01-outbox-idempotency/index.html.md)

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

## 正文

Agent 替你建了一张工单。工具那边写入成功，回执却没回来。进程重启后，屏幕上只有一行失败记录。

这时最顺手的动作是再试一次。于是同一个问题，有了两张工单。为了不重单，把“已完成”提前写入日志？下一次恰好在写日志之后、调用工具之前崩溃，屏幕上就会出现一种更安静的事故：任务显示完成，工单根本不存在。

这是一个假设场景，不是作者的客户事故。但后面的实验真的会终止子进程，再从数据库恢复。我们不让模型表演“我记住了”，而是检查一个更基础的问题：**当调用方不知道工具是否已经生效，它凭什么决定重试？**

本篇开启「Agent 可靠执行实战」。[RAG 系列](https://ai-knowledgepoints.cn/blog/rag-evaluation-05-citation-audit)关注回答证据，执行系列关注工具改变外部状态后的责任边界。两者可以共同构成应用验收，但不能互相替代。资料核对日期：2026 年 10 月 6 日。

## 1. 先说清楚这次到底运行了什么

实验只使用 Python 标准库和 SQLite，业务动作是一条合成工单记录。发送端与接收端使用两个独立数据库；接收端由真实子进程执行，在指定边界使用 `os._exit` 退出。恢复时启动新进程，重新读取已提交的状态。没有调用大模型，没有真实工单系统、支付接口、客户数据或公网业务请求。

这是一组故障机制实验，不是生产可用性、吞吐量或费用测试。实际测到的是：给定这些事务边界与故障点，数据库最终有几条记录，调用方能知道什么。业务内容是合成的，进程退出、SQLite 提交与恢复查询是真实运行的。

本地文件数据库仍有假设：存储保持可读、操作系统与 SQLite 遵守其事务约定，幂等记录没有被提前删除。`os._exit` 模拟进程突然结束，不等于拔电、磁盘丢写、文件系统损坏或跨机房灾难。本实验实际启用 [WAL 模式](https://www.sqlite.org/wal.html)与 `synchronous=FULL`，以 WAL 提交和恢复规则为准；[atomic commit 文档](https://www.sqlite.org/atomiccommit.html)主要解释回滚日志模式，也列出底层存储假设，不能把两种实现混称为同一条写入路径，更不能从本篇几个测试推导“任何硬件故障都安全”。

## 2. 两本账之间，没有一个共同的提交按钮

发送端的账回答：“这项任务需要执行吗？我收到结果了吗？”接收端的账回答：“这个请求是否已经改变业务状态？”

在自己的数据库里写入 `done`，不会顺带提交对方的业务事务。反过来，对方已经提交，也不会保证回复一定抵达调用方。它们之间的空隙，正是丢单与重单的来源。

所谓 durable outbox，先解决的是意图不丢：把需要执行的动作持久化，后续工作进程从这份记录取任务。它不自动解决接收端的重复副作用。如果一个重试会再次建单，outbox 可以把“必达”推得更近，也可以把重复发送做得更稳定。

接收端需要另外一个约定：调用方为一次业务意图提供稳定的请求 ID；相同 ID、相同参数重放，返回原来的结果，不再新建业务记录。相同 ID 却携带不同参数，必须作为冲突拒绝，不能悄悄沿用旧结果。

[AWS 关于可安全重试的幂等 API 的说明](https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/)强调调用方表达意图的标识，以及同一标识下参数发生变化的处理。不能仅仅对参数做哈希：两次内容相同的真实需求，也可能应当各建一张工单。

![发送端持久化意图与接收端事务去重是两种保障；提交后的回执可能丢失](https://ai-knowledgepoints.cn/images/articles/agent-reliability-01/transaction-boundary.svg)

两端各有事务边界。发送端重试时，接收端必须识别同一个业务意图。

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

## 3. 把幂等记录和业务动作放进同一个事务

接收端至少需要保存请求 ID、规范化后的参数或其摘要，以及成功结果。关键不在字段名，而在提交顺序：检查旧请求、写业务记录、保存可重放的结果，必须共享一个事务边界。

如果先插入工单，再单独插入去重记录，中间崩溃仍然会重单。如果先保存“见过此 ID”，却没有把工单写入同一个事务，中间崩溃又会漏单。把一张新表叫作 idempotency 并不会消除这道缝。

本实验用 SQLite 的显式事务把接收端这几步绑定在一起。写事务的竞争由数据库负责串行化，唯一键承担最终约束。SQLite [事务文档](https://www.sqlite.org/lang_transaction.html)说明 `BEGIN IMMEDIATE` 会立即开始写事务，遇到其他写事务时可能返回忙错误。忙错误是需要处理的竞争结果，不是许可应用绕开唯一约束再写一遍。

下载代码中的事务包装器如下。接收端在这个上下文里写工单与收据，退出上下文才提交；故障注入位于提交之前或之后，而不是靠捕获异常假装进程崩溃。

```python
@contextmanager
def transaction(connection):
    connection.execute("BEGIN IMMEDIATE")
    try:
        yield
        connection.execute("COMMIT")
    except BaseException:
        if connection.in_transaction:
            connection.execute("ROLLBACK")
        raise
```

`os._exit` 不经过这里的异常清理。因此，提交前故障场景真正依赖 SQLite 在重启后的事务恢复，而不是依赖 `except` 帮忙撤销。只有 `BEGIN IMMEDIATE` 本身失败时，调用方才从事务尚未开始的状态收到错误。

参数的规范化同样是协议。JSON 对象字段顺序可以被统一，字段值、操作类型和所属业务范围则不能被随便忽略。生产系统还需要定义租户、权限主体与幂等键作用域；本文单一合成业务空间没有实现真实多租户授权，不能直接拿请求 ID 当作访问控制。

只有接收端成功提交后，回复才能表示“已完成”。回复丢了，调用方仍然可以携带原 ID 再请求：接收端找到旧结果，返回同一个工单 ID。这里没有让网络变可靠，只是让重复消息不再必然产生重复工单。

## 4. 让进程真的死在那道缝里

本次运行环境为 Python 3.12.14、SQLite 3.53.1。`run_experiment.py` 的 16 个具名场景全部执行并通过内置断言，结果保存在可下载的 `results.json`。下表摘出三种策略的关键对照：

| 策略与注入位置 | 恢复后的工单数 | 发送端最终状态 | 说明 |
| --- | ---: | --- | --- |
| 普通调用，接收端提交后丢确认，再次发送 | 2 | done | 重复副作用已经发生 |
| 发送前先记 done，随后在发送前退出 | 0 | done | 本地完成状态掩盖了漏单 |
| outbox + 接收端事务去重，提交后丢确认 | 1 | done | 用原 ID 取得已提交结果 |

三行分别对应 `naive_lost_ack`、`mark_before_send_loses_task`、`idempotent_lost_ack`。第一行与第三行中，接收端在 COMMIT 后以退出码 73 结束，发送端没有拿到可接受的成功回执；第二行则是发送端提前记 done 后退出，根本没有调用接收端。第三行的恢复没有删除旧工单、没有人工修数据库，也没有给重试换一个新 ID。

注意比较单位：同一个业务意图在恢复后留下的工单数。这里的数字不是统计概率，也不是某个架构的长期故障率。故障点由测试主动指定，测试就是要把平时难撞到的边界撞出来。

发送端在落库前退出，没有已接受的持久任务可恢复。这时上游需要用稳定意图标识重新提交。落库后、发出前退出，任务还在，可以继续发；接收端提交后退出，调用方没有回执，但原 ID 重放可以取回同一个结果；发送端记录结果后退出，恢复只读取完成状态，不再创建业务动作。

还有一个容易写错的边界：`after_response_write` 已把完整回包 flush 到管道，接收端随后虽然以 73 退出，发送端仍可验证结果并完成，本次尝试数为 1。不能只看子进程退出码就丢掉已经收到的有效业务回执。与之不同，`after_response_before_ack` 是发送端拿到回包后、尚未落库时自身退出，恢复仍需按原 ID 重放。

“发送端没有结果”和“接收端没有执行”是两个不同命题。错误处理最容易在这里偷换概念。日志中的 timeout，只能证明等待没有按预期完成，不能证明对方没干活。

## 5. 两个工作进程同时醒来，又怎么办

持久队列通常不止一个工作进程。两个进程同时看到 pending，都去发消息，即使最后工单没有重复，调用量和外部压力也可能翻倍。

因此发送端也需要原子领取：从可领取状态转为处理中时，只能有一个当前工作进程拿到任务。它缓解重复调度，但不能替代接收端去重，因为领取之后仍然可能发生“已经发送，尚未保存回复”的退出。

实验启动 8 个子进程竞争同一条意图，只有 1 个成功领取；另启动 8 个接收端子进程处理相同请求，最终只有 1 条工单、1 条去重记录，所有返回值相同。

发送端还给每次领取分配递增的尝试编号。保存确认时，必须匹配当前编号与处理中状态；测试中的旧编号确认被拒绝。这仅保护发送端这行记录不被旧尝试覆盖，并不能阻止一个已经失去租约的进程继续调用外部工具。

时间采用显式逻辑刻度，租约长度为 10，恢复刻度为 11。它让边界测试可重复，不是在测试现实中的时钟同步、长暂停或网络分区。对接收端同一请求的参数修改也会被拒绝，原工单保持不变。

生产系统中的租约失效还涉及旧工作进程复活：新进程接管后，旧进程是否还能覆盖结果？需要任务版本、条件更新或 fencing 等机制来约束，不能只加一个过期时间就宣告完成。本篇的崩溃恢复场景在旧进程已退出后重启，另有发送端旧编号确认的单独断言；跨主机租约与暂停后复活的完整协议留给下一篇，在有可复现交错测试之后再讨论。

## 6. 接收端不配合时，要敢于留下 unknown

如果工具只是一个不支持幂等键的外部接口，本地 outbox 管不住对方的业务事务。你可以保存调用前后日志，但无法靠本地事务把远端写入包进来。

这时“失败就重试”和“失败就当成功”都可能错。一个诚实的状态是 unknown：操作可能已经发生，当前证据不足以决定是否再做一次。若工具支持按业务 ID 查询，可以先对账；若不支持，就需要人工核验或经过明确授权的补偿流程。

实验额外放置一个独立的合成外部工单账本，故意不提供幂等协议。它仍然是本地模拟接收方，不是联网的第三方服务。故障分别注入在副作用发生前与发生后：

| 注入时机 | 实验观察者看到的外部工单数 | 发送端恢复状态 | 自动重试 |
| --- | ---: | --- | --- |
| 外部副作用前退出 | 0 | unknown | 否 |
| 外部副作用后退出 | 1 | unknown | 否 |

这里故意保留两种不同的真实结果、相同的调用方认知。测试程序有权限读取两边账本，所以能给出 0 和 1；发送端的恢复逻辑没有拿这份全知视角作弊。再额外强制绕过停止策略重放，反例留下了 2 条工单。

unknown 不是把工作扔掉。它应当保留意图、参数、尝试记录、可对账标识与下一步责任人，并阻止后台无限重试。所谓补偿也不是时间倒流：删掉重复工单也许可行，已发出的邮件无法让所有收件人忘记，外部动作的撤销必须按具体业务判断。

![有接收端幂等协议时按原ID重放；无协议且结果不明时先对账或人工核验](https://ai-knowledgepoints.cn/images/articles/agent-reliability-01/recovery-choice.svg)

恢复策略取决于工具契约，不取决于 Agent 有多自信。

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

## 7. 下载、运行，再检查哪些结论没有被证明

完整文件：[执行实现 reliability.py](https://ai-knowledgepoints.cn/examples/agent-reliability-01/reliability.py)、[实验驱动 run_experiment.py](https://ai-knowledgepoints.cn/examples/agent-reliability-01/run_experiment.py)、[边界测试 test_reliability.py](https://ai-knowledgepoints.cn/examples/agent-reliability-01/test_reliability.py)、[实际运行结果 results.json](https://ai-knowledgepoints.cn/examples/agent-reliability-01/results.json)、[运行说明 README.md](https://ai-knowledgepoints.cn/examples/agent-reliability-01/README.md)。五个文件放在同一空目录，使用 Python 3.10 或更新版本，无需安装第三方包：

```bash
python3 test_reliability.py
python3 run_experiment.py --check results.json --output reproduced.json
```

第一条执行 31 个测试：除真实数据库行为外，子进程超时和启动中途失败的清理使用 mock 验证，不能当作额外的真实网络故障实验。

第二条命令在临时目录重新创建数据库、执行全部场景，先断言行为，再比较整个结果对象；不是把已有 JSON 原样打印出来。成功后写出 `reproduced.json`，可以继续比较原始字节。临时实验数据随运行结束清理，不涉及真实业务数据。

建议先阅读结果 JSON，再看测试逐项验证的故障点。如果改动实现，重新运行完整测试并生成新的结果，不要只复制本文数字。不同 Python 或 SQLite 版本的环境信息可以变化，业务不变量则应当保持。

这里验证的“只留一条工单”，限定在接收端 SQLite 的同一个事务、稳定请求 ID、参数冲突检查和保留的去重记录之内。它没有证明任意外部工具的 exactly-once，也没有测试断电、跨地域复制、恶意客户端、访问控制或无限时间后的重放。幂等记录一旦清理，旧请求能否再次到达，需要另行定义保留窗口与过期策略。

最值得带走的不是某个重试装饰器，而是一张边界清楚的执行记录：意图在哪里落地，业务在哪里提交，结果在哪里保存，哪一步退出以后只能说“不知道”。Agent 可以负责决定调用什么工具；工具是否已经造成变化，最终还得让可查询的业务事实说话。

下一篇计划研究多工作进程接管时的租约与旧写入隔离；只有实验和边界验证齐备后发布。RAG 系列继续保留真实生成输出与细粒度支持标签的验证要求，不用合成回答代替缺失的模型实测。

相关阅读：[断点恢复的责任边界](https://ai-knowledgepoints.cn/blog/commentary-2026-10-04-durable-agent-receipt)；[RAG 评测实战：引用审计](https://ai-knowledgepoints.cn/blog/rag-evaluation-05-citation-audit)。进一步讨论可查看[知识星球](https://ai-knowledgepoints.cn/planet)，或通过[公众号入口](https://ai-knowledgepoints.cn/about#wechat)关注更新。

本文及原创图解、样例代码由 AI 辅助制作，并经过代码执行与内容核验。合成业务数据仅用于故障机制实验；不构成生产系统可靠性承诺。
