# Agent 可靠执行实战（三）：不可幂等工具的 unknown 状态与人工对账

> 用 Python 标准库、真实子进程和 SQLite 实验，拆解非幂等工具的回执丢失、查询盲区、迟到写入、人工对账版本冲突与补偿授权，解释为什么重试不能直接等同于恢复。

- 作者：芝士AI吃鱼
- 发布日期：2026-10-11
- 主题：Agent、AI工程、可靠性、分布式系统
- HTML 正文：[https://ai-knowledgepoints.cn/blog/agent-reliability-03-unknown-reconciliation](https://ai-knowledgepoints.cn/blog/agent-reliability-03-unknown-reconciliation)
- Markdown 永久链接：[https://ai-knowledgepoints.cn/blog/agent-reliability-03-unknown-reconciliation/index.html.md](https://ai-knowledgepoints.cn/blog/agent-reliability-03-unknown-reconciliation/index.html.md)

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

## 正文

Agent 面板上多了一颗红色按钮：重试。背后那张工单，却可能已经创建好了。

如果工具接受稳定操作 ID，并且把去重记录与业务写入原子提交，[第一篇](https://ai-knowledgepoints.cn/blog/agent-reliability-01-outbox-idempotency)的办法可以派上用场。可现实接口不一定配合：有的每调一次就新建一条记录，有的只能按标题搜索，有的连查询入口都没有。此时，给调用方再加一层重试，可能只是把一次不确定变成两次副作用。

本篇用合成工单演示这个问题。工单内容和业务规则是假设的；子进程退出、SQLite 提交、重启后的查询、对账冲突及测试是真实运行的。没有调用大模型，也没有连接真实客户、支付、邮件或工单服务。资料核对日期：2026 年 10 月 11 日。

我们要回答的具体问题是：**不可幂等工具没有返回结果之后，系统凭什么从 unknown 走向一个可负责的结论？**

## 1. unknown 应当是一种可持久化状态

先把三个容易混在一起的事实分开。

第一，调用方有没有收到回复。第二，接收端有没有执行动作。第三，当前证据能不能支持再次执行。第一件事失败，不会自动回答后两件事。

[RFC 9110 第 9.2.2 节](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.2)对自动重试有明确约束：非幂等请求不能仅因通信失败就自动重试，除非客户端知道其实际语义是幂等的，或能判断原请求从未生效。这里不能拿 HTTP 方法名称当作完整业务证明；一个具体接口是否有可靠去重、重放范围和保留期，还要看它自己的协议。

因此，本例把发送前的尝试记录先提交到本地数据库。若远端执行后回执丢失，本地保留 unknown。它的意思是“结果尚不能确定”，既不显示业务成功，也不宣称远端失败，更不会自动回到待执行队列。

如果调用方本身在记录结果前崩溃，重启扫描到尚未闭合的尝试，同样需要按结果不明处理。可靠恢复不依赖某一条超时异常恰好被捕获；安全默认值应覆盖已经发送、可能发送、但无法确认的边界。

![调用方先持久化尝试，远端提交工单后回执丢失，本地保留unknown并先查询和对账](https://ai-knowledgepoints.cn/images/articles/agent-reliability-03/unknown-boundary.svg)

红色提示描述的是回执问题，不能单凭颜色判断远端业务事实。

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

## 2. 能查询，与能证明，差了几个条件

假设一个查询接口返回了零条记录。这里至少有五个问题要问：

- 查的是哪个账户、租户、操作和时间范围，是否覆盖原动作？
- 用的是稳定关联标识，还是可能撞名的标题、文本和近似时间？
- 返回是否完整，分页有没有读完，权限是否会隐藏部分记录？
- 是权威状态，还是可能延迟的搜索索引或只读副本？
- 原请求是否已经终结，还是仍可能在查询之后到达并提交？

前三个问题没有答案，连“查的是同一件事”都不能确定。第四个问题没有答案，查不到可能只是暂时不可见。第五个问题最容易遗漏：**即便现在读到了权威库的完整结果，也不能仅凭一次空查询排除未来的迟到提交。**

反过来，查到一条内容一致的记录，通常可以支持“至少发生过一次”。但若查询不完整，或者仍有同一操作的旧尝试在途，它也不能证明“仅发生一次，而且不会再多出来一条”。存在性、唯一性和终结性需要不同强度的证据。

这里假设返回来自可信接收端。伪造或无法验证来源的响应连存在性都不能证明；本例没有实现证据签名。

本例故意把查询能力显式化。生产里的完整性、权威性和封口标记必须来自可信协议或服务端验证，不能让调用方自己填 true 就获得更强结论。普通快照允许表达不完整、非权威与未封口；更强的接收端能力则能将原操作封口，并在同一个事务中返回完整快照。后者是实验额外实现的协议，不能假设所有外部工具都有。如果接口只提供模糊搜索，就如实停留在候选证据，不把搜索结果包装成最终判决。

## 3. 把迟到请求挡住，才能证明终态不存在

[第二篇](https://ai-knowledgepoints.cn/blog/agent-reliability-02-leases-fencing)讨论接收端检查代次。本篇换一个更窄的机制：接收端保存“该操作已封口”的状态，业务写入与封口都在接收端的写事务内检查和提交。

如果原请求先拿到事务并提交，随后封口的快照里就能看到它。如果封口先提交，随后到达的原请求会被拒绝。没有一种合法交错能让封口返回“终态不存在”，再让同一操作悄悄新建一条记录。

这里的关键不是先后调用了两个 Python 函数，而是检查封口状态与新增业务行之间没有释放事务的空隙。[SQLite 事务文档](https://www.sqlite.org/lang_transaction.html)说明了 `BEGIN IMMEDIATE` 的写事务行为。本例利用它串行化一个接收端数据库中的写入，没有让发送端数据库与接收端数据库共享一个大事务。

这个机制只封住约定的操作范围。如果另一个请求换了操作 ID，或者某条旁路不检查封口标志，它仍可能写入。真实系统还要约束身份、租户、所有发送入口、记录保留期和恢复旧备份的行为。本例没有实现这些生产级条件。

封口会改变接收端状态，使原操作的后续请求被拒绝，因此它本身也需要覆盖该对象和目的的业务授权，不能藏在一个叫“查询”的按钮后面自动执行。实验中的 `snapshot(close=True)` 明确包含这一写入；普通读取应使用默认的 `close=False`。

封口也不是让所有工具突然具有幂等性。封口前，同一个操作仍可产生多条业务记录；再次调用并不会重放原回执。封口增加的是“未来不会再接受这个操作”的终结能力。这种能力不存在时，只能依据工具自身的终态查询、可靠取消确认或人工核验证据继续处理，不能在客户端凭空造一个布尔值充当保证。

![找到记录仅证明存在；暂时空查询不能排除延迟和在途请求；完整权威且封口的快照才能支持终态判断](https://ai-knowledgepoints.cn/images/articles/agent-reliability-03/query-evidence.svg)

查询应同时说明范围、完整性、权威性与是否仍会有迟到写入。

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

## 4. 用两本数据库，把错误恢复跑出来

实验使用两个独立 SQLite 文件，分别保存发送端账本与接收端效果。子进程是真实进程；“远端”是本机的另一个数据库，没有部署网络服务。

第一个故障点位于接收端提交之后、返回回执之前。子进程用 `os._exit` 退出，避免把普通异常处理误当成进程故障。新进程或重新打开的连接只读取已持久化的状态。接收端已有一条记录，本地仍不知道结果。

第二组交错让原请求先暂停，在它尚未提交时执行查询。空结果是真的，但原请求稍后仍能提交。危险对照组在空查询之后又执行一次，因此最终出现重复。安全组先通过接收端封口取得终态快照，随后恢复旧进程；旧写入遭到拒绝。

这些交错使用标准输入管道的许可信号协调，等待上限只用于防止挂死，不把 sleep 长短当作正确性证据。实验中的时间窗口和业务操作是预先设定的，不是生产延迟、吞吐量或故障概率的测量。SQLite 文件保存在临时目录，不读取用户数据或凭证。

另外，“不完整”和“非权威”分支通过保守的能力标记注入，接收端实际上读取本机可见的全部行；它们用于检验决策器不会越过证据强度，不是运行了真实延迟索引或复制集。

本地进程退出仍不等于机器断电、网络分区或磁盘损坏。本例只证明给定事务边界和明确交错下的状态结果，不宣称覆盖全部分布式执行。这个限制比“测试全绿”更重要：通过的断言是什么，文章的结论就只能走到哪里。

## 5. 从查询结果到处置，留住证据强弱

可把决策分成下面几类，但不要把它们压成一个真假值：

| 所见证据 | 能支持的结论 | 下一步边界 |
| --- | --- | --- |
| 非权威或不完整查询为空 | 还没有可靠结论 | 保留 unknown，继续核验 |
| 完整权威查询为空，原请求未终结 | 现在没看到结果 | 不自动再次执行 |
| 看见一条精确匹配，但快照不完整或未封口 | 至少发生过一次 | 不能断言唯一，继续对账 |
| 完整权威且封口，恰有一条精确匹配 | 在约定范围内确认成功 | 记录结果与证据版本 |
| 完整权威且封口，零条记录 | 在约定范围内确认未执行 | 是否重新发起仍受原意图和授权约束 |
| 多条记录或参数冲突 | 存在重复或身份关联问题 | 人工核对，不能随便选第一条 |

其中“确认未执行”并不等于重新获得一张不限次数、范围和期限的授权。原任务可能已经取消、对象可能变化，或者新的执行会超过用户批准的窗口。重新发起需要再次检查业务条件与已有授权是否仍然覆盖；本例不自动创建一个新操作来绕过已经封口的旧操作。

这里也不提供一个万能的“多等三十秒就能重试”。如果工具没有明确的最长处理时限、取消确认或终态契约，延长等待只能降低某些风险，无法把未知变成证明。等待期限应当控制何时升级处理，不能被当成远端绝不会再写入的凭据。

## 6. 人工对账不能只留一个“已处理”按钮

把困难交给人，并不会自动让系统安全。两位处理人可能同时打开同一条 unknown：甲核对后标记成功，乙拿着旧页面批准再次执行。若后提交的人总能覆盖前者，人工作业反而又制造了一种竞争。

因此，对账提交需要带着读取时的版本。系统在一个本地事务内检查当前版本，匹配才写入新状态与证据；版本已变则拒绝旧提交，要求重新读取。它保护的是发送端对账账本，与前面的接收端封口各管一处，不能互相替代。

一份能交接的对账记录至少要包含操作范围、原始参数或摘要、尝试标识、查询来源与能力、候选记录 ID、核验时间、处理人的决定和对应版本。生产中还要做最小化留存与访问控制；没有必要为了审计把全部用户正文、凭证或个人信息复制进日志。

本例将证据与状态变更一起提交，并测试两个处理人读到同一版本时，第二个旧决定无法覆盖第一个。示例里的审核者和授权对象都是本地合成字段，没有接入真实身份认证、审批平台或防篡改审计系统。不能把写了一个 `approved` 值等同于验证了真实用户同意。

![人工对账固定证据与版本，提交时检查版本冲突，并将补偿授权与事实核验分开](https://ai-knowledgepoints.cn/images/articles/agent-reliability-03/manual-reconciliation.svg)

人的判断需要落在可审查的证据和有冲突保护的提交上。

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

## 7. 补偿会改变世界，也会再次变成 unknown

发现两张重复工单，想关掉其中一张，这是一项新的业务动作。应该关闭哪张、是否已经有人处理、关闭会不会触发通知，都需要额外判断。原来允许“创建一张工单”，不当然包含“关闭任意一张工单”的权限。

[Azure 的补偿事务模式](https://learn.microsoft.com/en-us/azure/architecture/patterns/compensating-transaction)强调补偿逻辑依赖业务语义，可能失败，也不保证把整个系统恢复到开始时的样子。其他人已经作出的修改不能被粗暴覆盖；对重要或难以自动化的决策，应有人参与。

所以，本例的补偿示范将授权绑定到明确的效果记录与动作，并检查对应的对账版本。对象不匹配、版本过期或没有授权，执行入口拒绝。确认某条记录确实存在，只是一项事实判断，不能顺带替代补偿批准。

补偿也单独保存尝试与结果。若它在远端完成后回执丢失，留下的是补偿自身的结果不明；系统不会因为函数名字叫 compensation 就反复调用。原始效果记录继续保留，不能通过删除原始历史让账面看起来像从未发生。

真实补偿接口若支持事务幂等，可以为补偿定义独立且稳定的操作 ID。若它也不支持，就要重新面对查询能力、终态与人工核验的问题。发送过的通知不能靠关单让收件人忘记，已经消耗的资源也未必能够全额恢复。本例的合成效果和补偿记录没有模拟这些不可逆成本，更不涉及真实交易。

![补偿针对明确副作用，单独核验授权，并在回执丢失时保存补偿unknown，不盲目重试](https://ai-knowledgepoints.cn/images/articles/agent-reliability-03/compensation-boundary.svg)

补偿不是删除事故记录；它是另一项需要证据、权限和恢复协议的动作。

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

## 8. 复现时检查最终数据，不只看打印的成功

实验文件与运行命令见下方。读者应在空目录中放齐所有文件，再运行测试和结果重放；不需要 API key，也不需要第三方 Python 包。

下载五个文件放在同一目录：[reconciliation.py](https://ai-knowledgepoints.cn/examples/agent-reliability-03/reconciliation.py)、[run_experiment.py](https://ai-knowledgepoints.cn/examples/agent-reliability-03/run_experiment.py)、[test_reconciliation.py](https://ai-knowledgepoints.cn/examples/agent-reliability-03/test_reconciliation.py)、[results.json](https://ai-knowledgepoints.cn/examples/agent-reliability-03/results.json)、[README.md](https://ai-knowledgepoints.cn/examples/agent-reliability-03/README.md)。使用 Python 3.10 或更新版本：

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

本次运行通过 38 个测试。第二条命令重新创建临时数据库、启动子进程，并将本次完整语义结果与存档 JSON 比较；没有把存档里的结论直接当成实验输出。最终结果摘录如下：

| 实际运行场景 | 接收端事实 | 发送端或决策结果 |
| --- | --- | --- |
| 原动作提交后退出 | 已有 1 条原始效果，回执字节数为 0 | unknown |
| 空查询后盲目重试，再允许原请求到达 | 同一操作留下 2 条效果 | 空查询分类仍是 inconclusive |
| 先原子封口，再允许旧请求到达 | 旧写入被拒绝 | 空快照可分类为 terminal_absence |
| 两个处理人依次提交同一旧版本 | 第一次证据和状态一起提交 | 第二次拒绝；证据行仍只有 1 条 |
| 已批准补偿提交后退出 | 新增 1 条补偿效果，原始效果保留 | 补偿 unknown；再次派发被拒绝 |

例如对账完成的原动作是 `confirmed`、版本为 2；这个版本来自一次派发前的 unknown 转换和一次成功对账。它不是远端调用次数。测试还故意让证据插入失败，验证状态更新一起回滚；让补偿授权插入失败，验证待执行补偿也不会孤零零地留下。

测试要覆盖的不只是“查到一条就成功”。还包括重复记录、同一关联标识下参数不同、非权威或不完整快照、尚未封口的空结果、迟到写入、封口拒绝旧请求、版本冲突、授权对象不匹配，以及补偿结果不明时禁止盲目再次执行。对应断言与实际结果保存在代码和 JSON 中，文章里的判断应当能回到这些数据逐项核对。

真实系统还需要补上本例没有实现的部分：认证授权来源、可信时间与请求截止、跨账户范围检查、审计保留和防篡改、服务端限流、查询失败后的升级流程、网络及存储故障测试。不要直接把这个机制样例当成生产执行器。

## 9. 给 unknown 一个负责人和截止点

unknown 如果永远无人负责，会变成被遗忘的队列；如果由重试按钮自动“消化”，又会把风险交给外部业务。两种方式都没有真正完成恢复。

更可操作的做法是把查询尝试、证据变化、下一位处理人和升级条件一起记录。只读核验可以按工具契约继续；发生状态变化或到达处理期限时交给对应业务负责人。期限控制的是响应责任，不替代“远端已经失败”的证据。

如果最终仍无法确认，就保留 unresolved 的结论和决策记录。某些业务可以在明确接受重复风险后再次发起，另一些必须停止。那是风险与授权决策，不能由执行器偷偷改写成技术上的成功。

本系列前三篇逐步划出了三道边界：第一篇要求接收端识别同一业务意图；第二篇要求接收端拒绝已失效的旧写入；这一篇要求调用方承认自己不知道，并通过足够强的证据和授权走出 unknown。一个 Agent 的可靠性，往往体现在它什么时候敢于把重试按钮停下来。

相关阅读：[工具回执丢失与事务幂等](https://ai-knowledgepoints.cn/blog/agent-reliability-01-outbox-idempotency)、[租约与接收端 fencing](https://ai-knowledgepoints.cn/blog/agent-reliability-02-leases-fencing)。进一步讨论可查看[知识星球](https://ai-knowledgepoints.cn/planet)，或通过[公众号入口](https://ai-knowledgepoints.cn/about#wechat)关注更新。

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