故障恢复:事务边界、幂等、对账与补偿
恢复要同时回答“运行到了哪里”和“外部动作是否已发生”。从一次写入的提交边界进入幂等、对账与补偿,再用受控故障实验检查重复、结果未知和恢复证据。
最近修订: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 的接管与队列规则见生产调度。