Agent 面板上多了一颗红色按钮:重试。背后那张工单,却可能已经创建好了。
如果工具接受稳定操作 ID,并且把去重记录与业务写入原子提交,第一篇的办法可以派上用场。可现实接口不一定配合:有的每调一次就新建一条记录,有的只能按标题搜索,有的连查询入口都没有。此时,给调用方再加一层重试,可能只是把一次不确定变成两次副作用。
本篇用合成工单演示这个问题。工单内容和业务规则是假设的;子进程退出、SQLite 提交、重启后的查询、对账冲突及测试是真实运行的。没有调用大模型,也没有连接真实客户、支付、邮件或工单服务。资料核对日期:2026 年 10 月 11 日。
我们要回答的具体问题是:不可幂等工具没有返回结果之后,系统凭什么从 unknown 走向一个可负责的结论?
1. unknown 应当是一种可持久化状态#
先把三个容易混在一起的事实分开。
第一,调用方有没有收到回复。第二,接收端有没有执行动作。第三,当前证据能不能支持再次执行。第一件事失败,不会自动回答后两件事。
RFC 9110 第 9.2.2 节对自动重试有明确约束:非幂等请求不能仅因通信失败就自动重试,除非客户端知道其实际语义是幂等的,或能判断原请求从未生效。这里不能拿 HTTP 方法名称当作完整业务证明;一个具体接口是否有可靠去重、重放范围和保留期,还要看它自己的协议。
因此,本例把发送前的尝试记录先提交到本地数据库。若远端执行后回执丢失,本地保留 unknown。它的意思是“结果尚不能确定”,既不显示业务成功,也不宣称远端失败,更不会自动回到待执行队列。
如果调用方本身在记录结果前崩溃,重启扫描到尚未闭合的尝试,同样需要按结果不明处理。可靠恢复不依赖某一条超时异常恰好被捕获;安全默认值应覆盖已经发送、可能发送、但无法确认的边界。
2. 能查询,与能证明,差了几个条件#
假设一个查询接口返回了零条记录。这里至少有五个问题要问:
- 查的是哪个账户、租户、操作和时间范围,是否覆盖原动作?
- 用的是稳定关联标识,还是可能撞名的标题、文本和近似时间?
- 返回是否完整,分页有没有读完,权限是否会隐藏部分记录?
- 是权威状态,还是可能延迟的搜索索引或只读副本?
- 原请求是否已经终结,还是仍可能在查询之后到达并提交?
前三个问题没有答案,连“查的是同一件事”都不能确定。第四个问题没有答案,查不到可能只是暂时不可见。第五个问题最容易遗漏:即便现在读到了权威库的完整结果,也不能仅凭一次空查询排除未来的迟到提交。
反过来,查到一条内容一致的记录,通常可以支持“至少发生过一次”。但若查询不完整,或者仍有同一操作的旧尝试在途,它也不能证明“仅发生一次,而且不会再多出来一条”。存在性、唯一性和终结性需要不同强度的证据。
这里假设返回来自可信接收端。伪造或无法验证来源的响应连存在性都不能证明;本例没有实现证据签名。
本例故意把查询能力显式化。生产里的完整性、权威性和封口标记必须来自可信协议或服务端验证,不能让调用方自己填 true 就获得更强结论。普通快照允许表达不完整、非权威与未封口;更强的接收端能力则能将原操作封口,并在同一个事务中返回完整快照。后者是实验额外实现的协议,不能假设所有外部工具都有。如果接口只提供模糊搜索,就如实停留在候选证据,不把搜索结果包装成最终判决。
3. 把迟到请求挡住,才能证明终态不存在#
第二篇讨论接收端检查代次。本篇换一个更窄的机制:接收端保存“该操作已封口”的状态,业务写入与封口都在接收端的写事务内检查和提交。
如果原请求先拿到事务并提交,随后封口的快照里就能看到它。如果封口先提交,随后到达的原请求会被拒绝。没有一种合法交错能让封口返回“终态不存在”,再让同一操作悄悄新建一条记录。
这里的关键不是先后调用了两个 Python 函数,而是检查封口状态与新增业务行之间没有释放事务的空隙。SQLite 事务文档说明了 BEGIN IMMEDIATE 的写事务行为。本例利用它串行化一个接收端数据库中的写入,没有让发送端数据库与接收端数据库共享一个大事务。
这个机制只封住约定的操作范围。如果另一个请求换了操作 ID,或者某条旁路不检查封口标志,它仍可能写入。真实系统还要约束身份、租户、所有发送入口、记录保留期和恢复旧备份的行为。本例没有实现这些生产级条件。
封口会改变接收端状态,使原操作的后续请求被拒绝,因此它本身也需要覆盖该对象和目的的业务授权,不能藏在一个叫“查询”的按钮后面自动执行。实验中的 snapshot(close=True) 明确包含这一写入;普通读取应使用默认的 close=False。
封口也不是让所有工具突然具有幂等性。封口前,同一个操作仍可产生多条业务记录;再次调用并不会重放原回执。封口增加的是“未来不会再接受这个操作”的终结能力。这种能力不存在时,只能依据工具自身的终态查询、可靠取消确认或人工核验证据继续处理,不能在客户端凭空造一个布尔值充当保证。
4. 用两本数据库,把错误恢复跑出来#
实验使用两个独立 SQLite 文件,分别保存发送端账本与接收端效果。子进程是真实进程;“远端”是本机的另一个数据库,没有部署网络服务。
第一个故障点位于接收端提交之后、返回回执之前。子进程用 os._exit 退出,避免把普通异常处理误当成进程故障。新进程或重新打开的连接只读取已持久化的状态。接收端已有一条记录,本地仍不知道结果。
第二组交错让原请求先暂停,在它尚未提交时执行查询。空结果是真的,但原请求稍后仍能提交。危险对照组在空查询之后又执行一次,因此最终出现重复。安全组先通过接收端封口取得终态快照,随后恢复旧进程;旧写入遭到拒绝。
这些交错使用标准输入管道的许可信号协调,等待上限只用于防止挂死,不把 sleep 长短当作正确性证据。实验中的时间窗口和业务操作是预先设定的,不是生产延迟、吞吐量或故障概率的测量。SQLite 文件保存在临时目录,不读取用户数据或凭证。
另外,“不完整”和“非权威”分支通过保守的能力标记注入,接收端实际上读取本机可见的全部行;它们用于检验决策器不会越过证据强度,不是运行了真实延迟索引或复制集。
本地进程退出仍不等于机器断电、网络分区或磁盘损坏。本例只证明给定事务边界和明确交错下的状态结果,不宣称覆盖全部分布式执行。这个限制比“测试全绿”更重要:通过的断言是什么,文章的结论就只能走到哪里。
5. 从查询结果到处置,留住证据强弱#
可把决策分成下面几类,但不要把它们压成一个真假值:
| 所见证据 | 能支持的结论 | 下一步边界 |
|---|---|---|
| 非权威或不完整查询为空 | 还没有可靠结论 | 保留 unknown,继续核验 |
| 完整权威查询为空,原请求未终结 | 现在没看到结果 | 不自动再次执行 |
| 看见一条精确匹配,但快照不完整或未封口 | 至少发生过一次 | 不能断言唯一,继续对账 |
| 完整权威且封口,恰有一条精确匹配 | 在约定范围内确认成功 | 记录结果与证据版本 |
| 完整权威且封口,零条记录 | 在约定范围内确认未执行 | 是否重新发起仍受原意图和授权约束 |
| 多条记录或参数冲突 | 存在重复或身份关联问题 | 人工核对,不能随便选第一条 |
其中“确认未执行”并不等于重新获得一张不限次数、范围和期限的授权。原任务可能已经取消、对象可能变化,或者新的执行会超过用户批准的窗口。重新发起需要再次检查业务条件与已有授权是否仍然覆盖;本例不自动创建一个新操作来绕过已经封口的旧操作。
这里也不提供一个万能的“多等三十秒就能重试”。如果工具没有明确的最长处理时限、取消确认或终态契约,延长等待只能降低某些风险,无法把未知变成证明。等待期限应当控制何时升级处理,不能被当成远端绝不会再写入的凭据。
6. 人工对账不能只留一个“已处理”按钮#
把困难交给人,并不会自动让系统安全。两位处理人可能同时打开同一条 unknown:甲核对后标记成功,乙拿着旧页面批准再次执行。若后提交的人总能覆盖前者,人工作业反而又制造了一种竞争。
因此,对账提交需要带着读取时的版本。系统在一个本地事务内检查当前版本,匹配才写入新状态与证据;版本已变则拒绝旧提交,要求重新读取。它保护的是发送端对账账本,与前面的接收端封口各管一处,不能互相替代。
一份能交接的对账记录至少要包含操作范围、原始参数或摘要、尝试标识、查询来源与能力、候选记录 ID、核验时间、处理人的决定和对应版本。生产中还要做最小化留存与访问控制;没有必要为了审计把全部用户正文、凭证或个人信息复制进日志。
本例将证据与状态变更一起提交,并测试两个处理人读到同一版本时,第二个旧决定无法覆盖第一个。示例里的审核者和授权对象都是本地合成字段,没有接入真实身份认证、审批平台或防篡改审计系统。不能把写了一个 approved 值等同于验证了真实用户同意。
7. 补偿会改变世界,也会再次变成 unknown#
发现两张重复工单,想关掉其中一张,这是一项新的业务动作。应该关闭哪张、是否已经有人处理、关闭会不会触发通知,都需要额外判断。原来允许“创建一张工单”,不当然包含“关闭任意一张工单”的权限。
Azure 的补偿事务模式强调补偿逻辑依赖业务语义,可能失败,也不保证把整个系统恢复到开始时的样子。其他人已经作出的修改不能被粗暴覆盖;对重要或难以自动化的决策,应有人参与。
所以,本例的补偿示范将授权绑定到明确的效果记录与动作,并检查对应的对账版本。对象不匹配、版本过期或没有授权,执行入口拒绝。确认某条记录确实存在,只是一项事实判断,不能顺带替代补偿批准。
补偿也单独保存尝试与结果。若它在远端完成后回执丢失,留下的是补偿自身的结果不明;系统不会因为函数名字叫 compensation 就反复调用。原始效果记录继续保留,不能通过删除原始历史让账面看起来像从未发生。
真实补偿接口若支持事务幂等,可以为补偿定义独立且稳定的操作 ID。若它也不支持,就要重新面对查询能力、终态与人工核验的问题。发送过的通知不能靠关单让收件人忘记,已经消耗的资源也未必能够全额恢复。本例的合成效果和补偿记录没有模拟这些不可逆成本,更不涉及真实交易。
8. 复现时检查最终数据,不只看打印的成功#
实验文件与运行命令见下方。读者应在空目录中放齐所有文件,再运行测试和结果重放;不需要 API key,也不需要第三方 Python 包。
下载五个文件放在同一目录:reconciliation.py、run_experiment.py、test_reconciliation.py、results.json、README.md。使用 Python 3.10 或更新版本:
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 的可靠性,往往体现在它什么时候敢于把重试按钮停下来。
相关阅读:工具回执丢失与事务幂等、租约与接收端 fencing。进一步讨论可查看知识星球,或通过公众号入口关注更新。
本文及原创图解、样例代码由 AI 辅助制作,并经过代码执行与内容核验。合成业务数据仅用于机制实验,不构成生产系统可靠性承诺。

