ESSAY·

故障恢复:事务边界、幂等、对账与补偿

恢复要同时回答“运行到了哪里”和“外部动作是否已发生”。从一次写入的提交边界进入幂等、对账与补偿,再用受控故障实验检查重复、结果未知和恢复证据。

最近修订:2026.09.07

阅读导航 · 第 2 / 2 篇

恢复要同时回答“运行到了哪里”和“外部动作是否已发生”。从一次写入的提交边界进入幂等、对账与补偿,再用受控故障实验检查重复、结果未知和恢复证据。

恢复位置,不等于恢复外部世界

LangGraph 的动态中断提供了一个值得认真理解的例子:恢复时,中断所在节点从头执行,中断之前的代码也会再次运行。因此,节点内的外部写入需要独立考虑重放行为,不能把界面里的“继续”理解为从原机器指令接着执行。官方 Interrupts 文档

Pi 的持久化架构与 DeepSeek 的事件日志已经解释了如何识别未完成工作。本篇继续讨论:识别之后,哪些动作可以安全重做?

把一次写入拆成四个提交边界

本地记录与外部写入之间存在多个故障窗口,它们不属于同一个数据库事务。

中断点本地知道什么恢复动作
保存意图后,写入前准备过请求查询操作结果;满足条件再用原键重试
外部提交后,响应记录前是否执行不确定按操作键对账,禁止换新键盲目创建
工具结果后,验收前有资源编号读取实际资源并验收
验收后,任务结束前可能已有通过记录恢复或重跑只读验收,再关闭任务

真实远端查询可能延迟、超时或最终一致。“暂时没有查到”只有在服务契约明确保证时,才能作为不存在的证据。否则应保留不确定状态,交给后续对账或人工处理。

幂等键必须表达同一次业务意图

本例采用 tenant + run_id + operation_slot 标识一次逻辑创建。第一次派发和恢复重试复用同一个键;真正的新任务使用新 Run。不能在每次网络请求前随机生成新键,那样服务端看到的始终是不同操作。

也不要只对标题和资料内容取哈希当作操作身份。用户可能确实想创建两条内容相同的工单;“参数相同”和“同一次意图”不是一回事。

服务端还必须保存原始参数。同一个键再次到达时,参数相同才返回原结果;参数改变应拒绝,避免把旧资源当成新动作的结果。AWS Builders’ Library 将请求标识、参数变化和去重记录留存作为幂等 API 设计的重要部分。原文

去重记录和副作用必须一起提交

只在客户端维护一个“调用过”的布尔值不够。如果先写布尔值再创建,崩溃可能导致工单永远不创建;如果先创建再写布尔值,崩溃可能导致重复创建。

实验中的工单服务用一个 SQLite 事务提交资源和唯一操作键。这样,在本地教学模型里,同一个键不会落成两条资源。运行状态保存在另一份数据库,刻意保留真实系统中两边不能原子提交的事实。

这不是普遍实现了端到端 exactly-once。它依赖服务端原子去重、正确的键作用域以及足够长的记录留存。换成不支持操作键的第三方接口,不能只加一张本地表就获得相同保证。

设计状态图:未知结果不能直接等同失败;补偿是针对既有影响的新操作,需要自己的授权与验收。

对账、重试与补偿分别解决什么

对账负责确认实际发生了什么。重试负责再次尝试同一次意图。补偿负责处理已经发生但不再符合目标的影响。

例如错误工单已经创建:删除它是新的写入,可能受审批、保留策略和下游引用约束。自动删除不能被藏在“恢复失败”分支里。生产系统可以用补偿工作流组织修复,但应记录原操作、补偿操作和最终残留影响。

同理,Outbox 可以把本地业务变更和待发送消息一起提交,却不自动保证远端消费者只执行一次。消息重复投递时,远端仍需去重或幂等消费。这些机制解决不同提交边界,不能互相替代。

故障矩阵

在自己的任务链上标出每个外部写入,逐项填写:操作键在哪里生成、谁做去重、查询是否权威、记录保留多久、无法确认时交给谁。

故障实验会在上述四个边界注入中断,并额外用子进程退出验证“外部提交后、本地结果前”的恢复。验收看实际工单数量与内容,不只看运行状态是否显示成功。后续接入真实服务时,这份矩阵可以直接成为回归系统的故障样本来源。

共享文档的版本冲突、条件更新与合并规则,见并发控制。它们处理多个写入者的竞争,与本篇的未知副作用恢复分开验收。

下载实验包。需要 Python 3.10+,只使用标准库,不需要模型密钥。

先运行,再对照证据

解压并进入 harness-foundations 目录后执行:

python3 lab.py
python3 -m unittest discover -p 'test_*.py' -v

包内有 README.md、lab.py 和 test_contracts.py。演示使用临时目录,每次运行重新创建数据库,结束后自动清理。

参考输出如下。线程竞争的获胜者不固定,但这些汇总值应保持一致:

{
  "concurrent_updates_accepted": 1,
  "document_version": 2,
  "late_response": "ignored_response",
  "needs_reconciliation": ["review-001:ticket"],
  "actual_ticket": [1, "source-001"],
  "actual_ticket_count": 1
}

一个更新被接受,对应文档版本从 1 变成 2。随后演示先创建工单,再模拟响应丢失和重连;旧响应被忽略,实际工单仍然只有一个。

needs_reconciliation 仍包含操作键是有意保留的:独立查询发现工单,不会自动修改协议模型的待对账集合。完整 Harness 还应检查目标与参数,再持久化任务终态。

哪些部分真正执行,哪些部分受控模拟

层次实验实际做了什么没有验证什么
并发存储两线程、独立连接、真实 SQLite 提交多机器竞争、目标数据库隔离级别
工单去重唯一约束、事务内查找与创建多租户身份、去重记录过期
协议状态Python 字典消息、等待表和受控事件网络传输、完整 Schema 与协议一致性
连接中断清除等待项并模拟重新初始化真实 TCP、HTTP、SSE 或子进程故障
业务观察读取实际提交的本地工单远端最终一致性与任务级自动验收

特别要注意:协议等待表、目录状态和未知操作集合都在内存中。SQLite 资源可以被新建的存储对象重新读取,不代表协议状态已经支持进程崩溃恢复。

下文的业务写入恢复实验提供了持久化执行意图和双库恢复;当前这组实验集中验证并发与消息边界。两套实验可以互相补充,但目前没有合并成一个生产 Runtime。

16 项测试按失败责任划分

组别数量关键断言
并发与业务状态5旧版本拒绝、新版本可提交、并发重试只有一个资源、同键异参拒绝、断线不撤销副作用
初始化与能力4初始化前禁用工具、完成通知后开放、版本不兼容断开、缺少能力拒绝调用
关联与取消4倒序响应正确关联、取消后忽略迟到响应、初始化不可取消、未知取消不改变状态
目录与错误3旧目录响应不能恢复缓存、区分协议与工具错误、畸形响应不完成请求

这些测试在编写时全部通过。数量描述的是覆盖的契约,不是 MCP 兼容性得分,也不是 Agent 任务成功率。

用小改动理解测试为何存在

可以在实验副本中尝试三种变化:去掉更新条件中的版本判断;把工单去重检查移到事务之外并移除唯一约束;允许旧目录响应重新将缓存标为有效。

先预测哪项断言会失败,再运行测试。对并发错误,某些错误实现不一定每次都暴露;应使用屏障控制关键交错,而不是重复运行直到碰到一次异常。实验当前已有的屏障确保两个更新者先读到同一版本。

这一步的目标是建立因果解释:某项机制具体阻止了哪个反例。若移除一个机制后没有相关测试失败,要么测试覆盖不足,要么它并不负责你以为的保证。

下一步接入真实服务时保留什么

真实接入应固定 SDK 与协议版本,在本地测试服务器上增加消息编码、分片、异常退出、会话失效和认证失败测试;再接实际写工具,验证业务操作键和权威查询。

当前实验没有实现完整 JSON-RPC 验证、工具分页、双向服务端请求、OAuth、HTTP/SSE、Tasks 或任何模型调用。协议依据来自 MCP 2025-11-25 生命周期及各篇所列规范;实验中的保守闸门和目录修订策略属于本文设计。

继续实践:Harness 接入与实战。

业务写入恢复实验

第二组实验将任务契约、运行状态和验收接成一个执行骨架:创建资料核验工单,在写入成功后制造中断,再恢复并检查实际结果。

实验使用固定候选动作代替模型,只验证执行契约。这样,同一个故障可以重复出现,不会被模型每次生成不同答案掩盖。它是完整 Agent 的一部分,尚不包含自主规划、真实推理或外部服务。

下载后即可运行

下载实验源码与测试,解压后在目录中运行,要求 Python 3.10+,无需额外依赖:

python3 lab.py
python3 -m unittest discover -p 'test_*.py' -v

如果已取得本站仓库,也可以从仓库根目录执行:

python3 docs/harness-engineering/lab.py
python3 -m unittest discover -s docs/harness-engineering -p 'test_*.py' -v

程序在临时目录创建数据库,演示完成后清理。不会访问真实工单平台、发送消息或修改你的知识库。

为什么使用两份数据库

runs.sqlite 保存任务契约、运行状态、派发次数与事件。tickets.sqlite 代表外部服务,保存实际工单和唯一操作键。两边独立提交,才能制造“工单已存在,本地却没有收到结果”的窗口。

如果把本地状态和所谓的外部写入包在同一个事务里,程序虽然更容易通过测试,却回避了真实接口最重要的失败边界。

固定动作的目标是为 source-001 创建“核验 Agent Harness 资料”。任务的预期结果由可信调用方单独传入,验收器读取实际工单再比较。候选动作和预期结果刻意分开,以便构造“动作执行成功,但内容错误”的反例。

演示中实际发生什么

默认运行会在工单提交后、工具结果落本地之前抛出受控异常。随后新建 Harness 对象,从同一组数据库继续处理。

关键输出如下;这是固定教学样本的结果,不是模型成功率统计:

{
  "before": {"state": "dispatching", "attempts": 1},
  "after": {"state": "succeeded", "attempts": 1},
  "actual_tickets": 1
}

恢复时先查询稳定操作键,找到实际工单,写入 effect_reconciled,再记录验收与终态。因此没有消耗新的派发次数,也没有第二条工单。完整输出还会列出事件序列,便于对照。

SQLite 在这里代表一个提供权威查询与原子去重的模拟服务。真实远端“查不到”可能只是尚未同步,不能照搬本地服务的不存在判断。

看反例,才能知道通过了什么

场景预期观察
正常执行后重复运行一条工单,终态保持成功
保存意图后、实际写入前中断在剩余预算内继续派发
外部提交后、本地结果前中断对账恢复,仍是一条工单
结果保存后或验收后中断重新读取实际资源并结束
同键换参数拒绝,不创建替代资源
缺审批、审批过期于当前策略等待匹配审批,不产生写入
当前策略拒绝拒绝执行
派发前取消、零预算没有工单
派发后取消停止新动作,对账后保留已发生影响的记录
工具写入了错误资料工具成功,但任务验收失败

测试还启动独立 Python 子进程,在外部提交后退出,再由父进程恢复。这验证数据库内容确实跨进程保留;它没有模拟磁盘损坏、机器断电或网络分区。

实验没有替你证明什么

审批值只是由测试驱动提供的动作摘要;身份和权限是可信输入,不是完整认证服务。数据库文件也没有防篡改保护。这里不验证提示注入防御、凭证隔离、多 Worker 调度或真实租户安全。

预算只记录派发尝试,不记录 Token、费用和墙钟时间。取消只在运行边界检查,没有终止远端在途请求的能力。服务去重依赖一台机器上的 SQLite 唯一约束,不能被描述为分布式 exactly-once。

这些限制决定了测试结论的范围:它证明几条确定性的执行契约在给定模型中成立。模型能否正确准备工单,需要另一组真实任务评测。

从执行骨架走向真实 Agent

下一步先把固定 Action 换成模型候选,保留任务目标、策略检查、稳定操作键与独立验收器。比较接入前后的无效参数、验收失败和任务成本,避免模型把本来确定的控制责任重新接走。

再替换工单服务,核对真实接口的幂等作用域、查询一致性、去重留存和鉴权。最后才加入并发 Worker、队列、沙箱和跨服务 Trace,每增加一种机制,都新增一个它应该解决的失败样本。已有评测工程篇可承接这些样本。

接入模型与真实 MCP 子进程见Mini Harness 实战;多 Worker 的接管与队列规则见生产调度。