已发布资料基础官方资料 + 个人实践
·16 分钟AI Agent 工程进阶
文章/AI 与智能体

Durable Loop:让 Agent 在重启、重试与取消后继续正确运行

AI Agent 工程进阶第 6 篇:从检查点、稳定动作 ID 和错误分类讲到结果未知、回执对账、持久取消、租约与 fencing token,并在同一个 GitHub 工程中用 9 个故障案例验证 Agent Loop 能否安全恢复。

文章目录
  1. 一分钟概览
  2. 1. 普通 Loop 和 Durable Loop 差在哪里
  3. 2. 先分清四种“状态”
  4. 2.1 Model Context:下一次模型看见什么
  5. 2.2 RunState:Harness 执行到哪里
  6. 2.3 Workspace:文件和产物是否还在
  7. 2.4 Business Receipt:外部世界到底发生了什么
  8. 3. 一次写操作周围,有三个故障窗口
  9. 窗口 A:请求发出前崩溃
  10. 窗口 B:业务提交成功,响应丢失
  11. 窗口 C:响应已收到,检查点未保存
  12. 4. 检查点不是把整个 Python 对象 dump 下来
  13. 4.1 保存逻辑游标,不保存不可重建对象
  14. 4.2 每个检查点要有清楚语义
  15. 4.3 检查点粒度有代价
  16. 5. 重试先分类,再谈退避
  17. 5.1 永久错误不应该自动重试
  18. 5.2 瞬时错误也不是无限重试
  19. 5.3 Backoff 和 Jitter 解决不同问题
  20. 5.4 重试预算要跨步骤和跨恢复保存
  21. 6. 结果未知时,不要猜
  22. 6.1 查到回执,不是“忽略错误”
  23. 6.2 查不到回执,不等于证明没有写入
  24. 7. 取消是持久状态,不是关闭连接
  25. 8. Lease 与 fencing token 解决的是两个问题
  26. 9. 一个可用的 Durable 状态机
  27. 10. 本篇 Lab:把故障变成固定测试集
  28. 九个故障案例
  29. 11. 跟着运行 Lab 0.6.0
  30. 方式一:直接下载
  31. 方式二:从 GitHub 检出固定提交
  32. 第一步:运行故障测试
  33. 第二步:运行全部测试
  34. 第三步:不要只看摘要
  35. 12. 怎样读这组结果
  36. 12.1 1/9 不代表普通 Loop 平时只有 11.1% 成功
  37. 12.2 两个 completed 可能完全不同
  38. 12.3 waitingreconciliation 不是失败的自动化
  39. 12.4 模型尝试次数下降来自错误分类
  40. 13. 把代码换成真实基础设施时,要补什么
  41. 13.1 把 JSON Store 换成可靠状态存储
  42. 13.2 把进程内队列换成有所有权协议的调度
  43. 13.3 把回执放到正确事务边界
  44. 13.4 用真实故障替代模拟故障
  45. 14. OpenAI、LangGraph、Temporal、Anthropic 与 Google 各自提供什么
  46. 15. 什么时候不需要 Durable Loop
  47. 16. 45 分钟练习:给自己的 Agent 加一个恢复边界
  48. 第 1 步:画出副作用时间线
  49. 第 2 步:定义稳定动作 ID
  50. 第 3 步:持久化最小 RunState
  51. 第 4 步:注入一次“提交后断线”
  52. 第 5 步:再注入一次取消
  53. 17. Durable Loop 收藏清单
  54. 状态与检查点
  55. 副作用与结果未知
  56. 重试与取消
  57. 多 Worker
  58. 验证与运营
  59. 结语:可恢复,比一直运行更重要
  60. 参考资料
  61. OpenAI
  62. Durable Execution 与长任务 Harness
  63. 本文代码与证据
阅读提要

AI Agent 工程进阶第 6 篇:从检查点、稳定动作 ID 和错误分类讲到结果未知、回执对账、持久取消、租约与 fencing token,并在同一个 GitHub 工程中用 9 个故障案例验证 Agent Loop 能否安全恢复。

#Agent#Agent Engineering#Durable Execution#Checkpoint#幂等

一个 Agent 连续调用模型和工具,看起来已经形成了 Loop:

text
模型决策 → 调用工具 → 写回结果 → 再次决策 → 完成

但只要把进程重启、网络超时或人工等待放进来,这条顺滑的箭头很快会断掉:

  • 模型已经规划完,Worker 重启后却从头再问一次;
  • 工单已经写入,但响应在返回途中丢失,系统又写了一遍;
  • 一个临时超时被无限重试,Token、时间和下游配额一起耗尽;
  • 用户在审批页面点了取消,新 Worker 恢复后仍继续执行;
  • Worker A 的租约已经过期,Worker B 接管后,A 又醒来提交了一次旧写入;
  • 系统最后显示 failed,但没人知道失败前到底做到了哪一步。

这些问题并不只属于 Agent。它们是长流程、任务队列和分布式系统反复面对的可靠性问题。Agent 的特殊之处在于:中间步骤可能昂贵、非确定,Context 会变化,工具还会修改真实世界。

本文是 AI Agent 工程进阶 第 6 篇。上一篇 Tool Engineering 已经为工具补上权限、审批、幂等和结构化错误;这一篇继续回答:当一次 Agent 运行被打断时,系统怎样从最近的可信边界继续,而不是失忆、重做或重复产生副作用。

项目说明
内容类型Durable Loop 原理、故障恢复与可复现实验
适合读者已经实现 Agent Loop、后台任务或人工审批,开始处理长任务、重启与重复写入的开发者
阅读时间约 15-20 分钟
跟做时间45-60 分钟
环境要求Python 3.10+,零第三方依赖,不需要 API Key
代码检查点04c5d41
可带走产物文件型 RunState、故障注入器、稳定动作 ID、回执恢复、重试策略、取消和 fencing 测试
资料核对日期2026-07-31
实验边界重建 Runner 并从磁盘恢复 JSON;不杀真实 OS 进程,不测网络、数据库或多机一致性

本文不承诺 exactly-once

跨进程、网络和外部系统时,“某段代码只运行一次”通常不是一个可以轻易得到的承诺。更现实的目标是:步骤可以重放,副作用可以去重,结果未知时可以查询或停住,恢复行为可以由证据解释。

一分钟概览

如果只保存这篇文章的结论,可以记住十二点:

  1. 普通 Loop 只描述控制流,Durable Loop 还要定义故障后的恢复语义。
  2. 模型 Context、RunState、Sandbox 工作区和业务回执是四种不同状态。 只保存聊天记录,不代表能恢复执行。
  3. 检查点要保存“下一步是什么”,也要保存“哪些步骤已经完成”。 否则恢复仍会从头猜。
  4. 写入前保存 pending action,写入后保存 receipt。 两者之间是最危险的结果未知窗口。
  5. 稳定 action_id 必须跨重试、跨 Worker、跨进程保持不变。 每次尝试重新生成 key 会让幂等失效。
  6. 错误分类先于重试。 参数、权限和策略错误不应靠等待恢复;瞬时错误也只能在次数、deadline 和成本预算内重试。
  7. Timeout 不等于失败。 对写操作,它可能表示“已经成功,但响应丢了”。
  8. 查到业务回执就恢复为成功;查不到且无法证明未执行,就进入 waiting_reconciliation 安全停住也是正确结果。
  9. 取消必须进入持久状态,并在每个副作用前重新检查。 关闭浏览器或终止请求不是可靠取消。
  10. Lease 防止多个 Worker 主动处理同一任务,fencing token 防止过期 Worker 迟到写入。 两者职责不同。
  11. Durable Runtime 不能替工具补幂等。 编排器能重放步骤,外部系统仍要识别重复动作。
  12. 故障测试要验证副作用和恢复路径,不只看最终状态。 completed 可能掩盖重复写入,failed 也可能掩盖已经成功的业务动作。

图 1:旧 Worker 在模型步骤后崩溃,新 Worker 从持久检查点恢复,查询回执并继续完成 Agent Loop图 1:旧 Worker 在模型步骤后崩溃,新 Worker 从持久检查点恢复,查询回执并继续完成 Agent Loop

1. 普通 Loop 和 Durable Loop 差在哪里

一个最小 Agent Loop 大概是:

python
messages = [user_message]

while True:
    response = call_model(messages, tools)
    if response.final_output:
        return response.final_output

    result = call_tool(response.tool_call)
    messages.extend([response.message, result])

这段代码在单进程、短任务和只读工具里可能很好用。问题是,它把几件重要事情都放在了进程内存里:

text
当前执行到哪一步
模型已经做过哪些决策
哪个工具动作正在进行
这次动作是否已经批准
一次超时是否应该重试
用户是否请求取消
外部写入是否已生效

进程退出以后,这些事实一起消失。

Durable Loop 不是把 while 换成某个框架 API,而是在 Loop 外增加一组明确协议:

text
普通 Loop
  = decide + act + observe + stop

Durable Loop
  = 普通 Loop
  + persisted state
  + checkpoint boundaries
  + retry policy and budget
  + stable action identity
  + result reconciliation
  + cancellation
  + lease / fencing
  + explicit terminal states

它要回答的不是“正常时下一步做什么”,而是:

如果恰好在任意两行代码之间断电,下一次运行依据什么事实继续?

2. 先分清四种“状态”

很多恢复设计失败,是因为所有东西都被叫作 state。实际上,至少要分四层。

图 2:模型上下文、RunState、Workspace 与业务回执分别恢复对话、执行、产物和外部副作用图 2:模型上下文、RunState、Workspace 与业务回执分别恢复对话、执行、产物和外部副作用

2.1 Model Context:下一次模型看见什么

它通常包含:

text
用户消息
模型输出
工具调用与结果
当前任务说明
被选中的证据
历史摘要

它解决的是推理连续性。上一篇 Context Architecture 已经讨论过 Context Packet 的来源、权限、新鲜度和预算。

但 Context 里写着“工单已更新”,并不能证明业务数据库真的存在这次更新。那可能只是模型生成的文字,也可能是旧工具输出。

2.2 RunState:Harness 执行到哪里

它至少应该包含:

text
run_id
status
current_step
completed_steps
attempts per step
pending_action
approval / cancel state
next_retry_at
failure code
lease owner / epoch
event cursor

它解决的是执行连续性:重启后不需要靠模型重新阅读所有消息,猜测之前做到哪一步。

2.3 Workspace:文件和产物是否还在

对 Codex、Claude Code 或其他代码 Agent,工作区可能包含:

text
源代码修改
下载资料
生成报告
测试结果
运行环境
Sandbox snapshot

OpenAI 的 Sandbox Agents 文档 特别区分了 RunState、serialized session state 和 snapshot:前者恢复 Harness 侧执行位置,session state 用于重连同一个 Sandbox 会话,snapshot 用保存的文件内容启动新工作区。

这层解决产物连续性,但仍然不能替代业务回执。例如仓库里有一份“已发布”记录,不代表文章真的发布到了远端平台。

2.4 Business Receipt:外部世界到底发生了什么

生产化回执通常可以与稳定动作 ID 绑定:

json
{
  "action_id": "durable::run-42::record-followup",
  "request_fingerprint": "sha256:...",
  "status": "committed",
  "resource_id": "T-102",
  "result": {"recorded": true},
  "committed_at": "2026-07-31T12:30:00Z"
}

它解决副作用连续性:重试时能判断这是同一个动作,结果未知时能查询原始结果,同一个 key 配不同参数时能拒绝冲突。

四层状态可以互相引用,但不能互相冒充。

3. 一次写操作周围,有三个故障窗口

只说“工具调用失败了”不够。故障发生在副作用之前还是之后,会改变整个恢复策略。

图 3:一次写操作从请求前检查点到业务提交、回执和状态保存之间存在三个不同故障窗口图 3:一次写操作从请求前检查点到业务提交、回执和状态保存之间存在三个不同故障窗口

窗口 A:请求发出前崩溃

此时业务副作用尚未发生。如果输入仍然有效,恢复后通常可以重新执行。

text
checkpoint saved
process crashes
request never sent

这也是为什么写入前要保存 pending_action。恢复器看到它以后,至少知道原本准备执行哪个动作。

窗口 B:业务提交成功,响应丢失

这是最危险的窗口:

text
request sent
business write committed
network connection lost
runtime sees timeout

对读操作,重试通常只是多花时间。对发邮件、记账、下单、发布内容或创建资源,盲目重试可能重复产生副作用。

此时 timeout 只能说明客户端没有收到确定答复,不能说明服务端没有执行。

窗口 C:响应已收到,检查点未保存

进程已经拿到成功结果,但来不及把 current_step 和 receipt 写入 RunState。恢复后,运行时可能再次进入同一步。

稳定动作 ID 和持久回执在这里发挥作用:同一动作再次到达业务系统时,应返回第一次的结果,而不是重新写入。

因此,比“是否重试”更基础的问题是:

text
这一步是否有副作用?
结果是 known success、known failure,还是 unknown?
同一动作能否被可靠识别?
业务系统能否查询或重放原始回执?

4. 检查点不是把整个 Python 对象 dump 下来

检查点要同时满足两个目标:

  1. 足够完整,能够恢复;
  2. 足够稳定,能够跨版本、跨进程和跨 Worker 读取。

本篇 Lab 的核心状态经过简化:

python
@dataclass
class DurableRunState:
    run_id: str
    case_id: str
    strategy: str
    status: str = "ready"
    current_step: int = 0
    completed_steps: list[str] = field(default_factory=list)
    step_outputs: dict[str, dict[str, Any]] = field(default_factory=dict)
    attempts: dict[str, int] = field(default_factory=dict)
    pending_action: dict[str, Any] | None = None
    cancel_requested: bool = False
    failure_code: str | None = None
    next_retry_ms: int | None = None
    lease_owner: str | None = None
    lease_epoch: int = 0
    events: list[dict[str, Any]] = field(default_factory=list)

完整实现见 agent_lab/durable.py

4.1 保存逻辑游标,不保存不可重建对象

优先保存:

text
稳定 ID
枚举状态
JSON 输入与输出
版本号
来源引用
错误分类
时间戳或逻辑时间

谨慎保存:

text
数据库连接
文件句柄
协程对象
闭包
只在当前进程有效的 SDK 实例
明文 Secret
无法版本化的任意对象图

4.2 每个检查点要有清楚语义

不是“想到就存一下”,而是围绕可恢复边界:

text
run_started
after_model
before_retry_wait
before_write
after_write
human_wait
cancel_requested
completed / failed / reconciliation

4.3 检查点粒度有代价

检查点越细:

  • 重做工作越少;
  • 写存储次数越多;
  • 状态迁移和版本兼容越复杂。

检查点越粗:

  • 实现更简单;
  • 故障后重放更多;
  • 非确定步骤和副作用更难保护。

LangGraph 的 Thinking in LangGraph 说明,其 durable execution 在节点边界创建检查点;中断节点恢复时会从该节点开头重新执行。节点越大,故障后可能重复的工作越多。因此节点粒度也是恢复粒度。

本篇 Lab 使用 JSON 文件和临时文件替换:

python
def save(self, state: DurableRunState) -> None:
    target = self.root / f"{_safe_name(state.run_id)}.json"
    temporary = target.with_suffix(".json.tmp")
    temporary.write_text(json.dumps(state.to_dict(), indent=2))
    temporary.replace(target)

这只演示文件边界上的原子替换。生产环境还要处理数据库事务、并发更新、版本迁移、备份、可见性和持久化保证,不能把一个 JSON 文件当作生产 Durable Store。

5. 重试先分类,再谈退避

“失败就重试三次”容易写,也容易制造更大的故障。

图 4:Durable Loop 先判断永久错误、外部副作用、结果状态、幂等与预算,再选择恢复动作图 4:Durable Loop 先判断永久错误、外部副作用、结果状态、幂等与预算,再选择恢复动作

5.1 永久错误不应该自动重试

典型例子:

text
invalid_arguments
permission_denied
approval_rejected
resource_not_found under a stable ID
policy_violation
unsupported_operation

等待 100 ms 不会让缺失参数自己出现,也不会让权限自动增加。正确动作通常是修输入、请求授权、改变计划或明确失败。

5.2 瞬时错误也不是无限重试

典型例子:

text
429 throttling
gateway timeout
temporary dependency unavailable
connection reset before any request was accepted

它们可以进入有限重试,但至少要有:

text
max_attempts
per-step deadline
whole-run deadline
cost / token budget
backoff
jitter
cancel check

5.3 Backoff 和 Jitter 解决不同问题

指数退避可以写成:

text
delay = min(base × 2^(attempt - 1), max_delay)

如果很多 Worker 同时失败,它们按相同时间表重试,仍可能同时冲击下游。Jitter 用随机扰动打散重试时间。

本篇 Lab 为了让报告可重复,使用固定的 100 ms200 ms 逻辑时间,不加入随机抖动,也不真实等待。生产实现应根据下游接口建议加入 jitter,并把 Retry-After 等服务端信号纳入策略。

5.4 重试预算要跨步骤和跨恢复保存

如果 attempts 只存在 Worker 内存里,每次进程重启都会重新获得三次机会,所谓最大重试次数就没有意义。

因此 Lab 把尝试次数写进 RunState:

python
attempt = state.attempts.get("model", 0) + 1
state.attempts["model"] = attempt

if attempt >= self.max_attempts:
    self._fail(state, "retry_exhausted", detail)

6. 结果未知时,不要猜

本篇最值得收藏的边界,是 write_result_unknown

图 5:写入超时后通过稳定 action_id 查询业务回执,查到则恢复成功,查不到则进入人工对账图 5:写入超时后通过稳定 action_id 查询业务回执,查到则恢复成功,查不到则进入人工对账

Lab 为每个逻辑写入生成稳定 ID:

python
action_id = f"{state.run_id}::record-followup"
logical_operation = f"followup::{case.case_id}"

它不能在每次 retry 时重新生成。否则:

text
attempt 1 → key-a → 写入成功,响应丢失
attempt 2 → key-b → 业务系统认为是新动作,再写一次

正确恢复路径是:

python
try:
    receipt = effects.record_followup(action_id=action_id, ...)
except ResultUnknownError:
    receipt = effects.lookup_receipt(action_id)
    if receipt is None:
        state.status = "waiting_reconciliation"
        state.failure_code = "result_unknown"
        checkpoint(state)
        return

    # 原写入已成功,恢复结果,不再次执行副作用
    state.step_outputs["write"] = receipt

这里有两个容易混淆的结论。

6.1 查到回执,不是“忽略错误”

回执证明第一次写入已经提交。把当前步骤恢复为成功,是依据外部事实纠正客户端观察。

6.2 查不到回执,不等于证明没有写入

有些系统的回执落库和业务写入不在同一事务边界,或者外部 API 根本不支持按幂等键查询。此时“没有回执”可能只是回执延迟或系统不可用。

所以 Lab 选择:

text
status = waiting_reconciliation

而不是自动重放。

这也是 Durable Engineering 一个不太讨喜但很重要的原则:可靠系统不一定自动完成所有任务,但必须诚实表达自己不知道什么。

AWS 的 Idempotency and retries 明确区分 at-least-once 与 at-most-once,并提醒 replay 和 retry 都可能让同一操作运行多次。其建议同样是让外部副作用支持幂等键,或使用条件写、唯一约束和追加日志等数据库模式。

7. 取消是持久状态,不是关闭连接

用户点取消时,常见实现是终止 HTTP 请求或取消当前协程。对后台任务,这通常不够:

  • Worker 可能已经把任务交给下游;
  • 当前进程可能在取消消息到达前崩溃;
  • 新 Worker 可能只看见旧检查点;
  • 一个工具可能不能被强制中断;
  • 写操作可能已经进入结果未知窗口。

更稳妥的方式是 cooperative cancellation:

text
1. 把 cancel_requested 持久化
2. 记录取消原因和请求者
3. 在每次模型调用前检查
4. 在每个副作用前再次检查
5. 在 retry wait 醒来后检查
6. 已进入结果未知时先对账,再决定 cancelled / completed / reconciliation

Lab 的 cancel-at-human-wait 案例这样运行:

text
模型步骤完成
→ 进入 waiting_human
→ 保存 checkpoint
→ 用户请求取消
→ cancel_requested 写入磁盘
→ 新 Worker 恢复
→ 写入前检查取消
→ status = cancelled
→ side effects = 0

OpenAI Agents SDK 的 Human-in-the-loop 指南 使用可序列化 RunState 暂停审批,并在另一个时间或进程中恢复。它解决的是 SDK 运行状态与审批连续性。应用仍然要定义自己的取消语义、业务回执和外部副作用边界。

OpenAI Background mode 则允许一个长时间 Responses 请求异步运行、轮询状态和取消。它有助于避免客户端连接断开导致模型请求丢失,但不能自动让由多个模型调用、数据库写入和人工步骤组成的整个业务流程变得 durable。

8. Lease 与 fencing token 解决的是两个问题

当任务由多个 Worker 消费时,我们通常不希望两台机器同时推进同一个 Run。

Lease 可以表达:

text
run-42 当前由 worker-b 持有
租约在某个期限后失效
持有者需要 heartbeat 或续租

但 Lease 只能说明“谁现在应该工作”。它不能保证旧 Worker 已经停止。

图 6:Worker B 以更高 lease epoch 接管后,业务存储用 fencing token 拒绝 Worker A 的迟到写入图 6:Worker B 以更高 lease epoch 接管后,业务存储用 fencing token 拒绝 Worker A 的迟到写入

考虑这个顺序:

text
Worker A 获得 lease,epoch = 41
A 因长暂停或网络分区失去响应
lease 过期
Worker B 接管,epoch = 42
B 正常继续
A 恢复,以为自己仍然有权写入

如果只在调度器检查 Lease,A 的迟到写入仍可能到达业务系统。

Fencing token 的做法是:

text
每次接管产生单调递增 epoch
每次副作用携带 epoch
业务存储记住已接受的最高 epoch
低于最高值的迟到写入被拒绝

Lab 中的业务存储边界是:

python
if fence < self.highest_fence:
    raise StaleWorkerError(
        f"fence={fence} is older than active fence={self.highest_fence}"
    )

Martin Kleppmann 在 How to do distributed locking 中用递增 fencing token 解释了相同问题:锁或租约有超时以后,旧客户端可能在暂停后恢复;真正接收写入的存储必须拒绝落后的 token。

现实系统还要处理:

  • epoch 如何由线性一致的存储分配;
  • heartbeat 和租约时长怎样选择;
  • 业务数据库是否能原子校验并更新最高 epoch;
  • 长工具调用是否需要续租;
  • 失去租约的 Worker 如何停止后续步骤。

本篇 Lab 只模拟 lease takeover 和迟到写入,不声称实现了完整分布式锁服务。

9. 一个可用的 Durable 状态机

状态名要帮助运营和恢复,而不是只保留 running / failed

本篇使用:

状态含义是否自动继续
runningWorker 正在推进
waiting_human等待审批或人工输入收到决定后继续
waiting_reconciliation外部结果未知,缺少安全重试证据否,先对账
completed目标和副作用均有完成证据
failed已知失败,带稳定 failure code由策略决定是否新建 Run
cancelled持久取消已在安全边界生效

还可以按业务需要加入:

text
retry_wait
compensating
paused_budget
expired
dead_letter

状态不要为了漂亮而增加。每个状态至少要回答:

text
谁能把它迁移到下一状态?
迁移前要检查什么?
要保存哪些证据?
运营人员看到它后能做什么?

10. 本篇 Lab:把故障变成固定测试集

这次没有换项目,仍然沿用同一个 Agent Reliability Lab

版本从 0.5.0 升到 0.6.0,新增:

text
agent_lab/durable.py
agent_lab/durable_reporting.py
datasets/durable-cases.jsonl
tests/test_durable.py
reports/durable-comparison.json
reports/durable-comparison.md
reports/durable-failures.md
reports/durable-runs.jsonl

两组对照是:

策略说明
process-loop-v1故意保持简单的进程内控制组;重启从头、错误统一重试、取消不持久、没有 fencing
durable-loop-v1文件型 RunState、检查点、错误分类、稳定动作 ID、回执恢复、持久取消和 lease epoch

这不是在比较 Python 与某个框架,也不是在证明某种 Agent 拓扑更聪明。模型服务是固定脚本,工具输入也是固定的。实验只验证:相同故障到来时,运行时是否遵守声明的恢复契约。

九个故障案例

text
01 clean-run
02 restart-after-model
03 model-transient-retry
04 model-permanent-error
05 retry-budget-exhausted
06 write-receipt-recovery
07 write-unknown
08 cancel-at-human-wait
09 stale-worker

图 7:Durable Loop Lab 的九个案例,以及进程内基线和 Durable Loop 在恢复、重试、对账、取消与 fencing 上的结果图 7:Durable Loop Lab 的九个案例,以及进程内基线和 Durable Loop 在恢复、重试、对账、取消与 fencing 上的结果

11. 跟着运行 Lab 0.6.0

方式一:直接下载

下载 Durable Loop Lab 0.6.0 ZIP

解压后进入:

text
phase-7-agent-engineering/agent-reliability-lab

方式二:从 GitHub 检出固定提交

bash
git clone https://github.com/RalfNick/ai-agent-learn.git
cd ai-agent-learn
git checkout 04c5d41cd450fa737611e2b7680d77123fa90f83
cd phase-7-agent-engineering/agent-reliability-lab

第一步:运行故障测试

bash
python run_lab.py fault-test --output reports/local

预期退出码是 0,终端摘要应包含:

json
{
  "version": "0.6.0",
  "baseline": {
    "cases": 9,
    "passed_cases": 1,
    "duplicate_side_effects": 2,
    "blind_retries": 8
  },
  "candidate": {
    "cases": 9,
    "passed_cases": 9,
    "duplicate_side_effects": 0,
    "blind_retries": 0
  },
  "gate_passed": true
}

第二步:运行全部测试

bash
python -m unittest discover -s tests -v

本文固定提交上的预期结果:

text
Ran 50 tests
OK

第三步:不要只看摘要

先看对照表:

text
reports/local/durable-comparison.md

再看失败台账:

text
reports/local/durable-failures.md

最后挑一个案例检查完整事件:

text
reports/local/durable-runs.jsonl

例如搜索:

bash
python -c "import json; from pathlib import Path; rows=[json.loads(x) for x in Path('reports/local/durable-runs.jsonl').read_text(encoding='utf-8').splitlines()]; print(json.dumps(next(r for r in rows if r['strategy']=='durable-loop-v1' and r['case']['id']=='write-receipt-recovery'), ensure_ascii=False, indent=2))"

你应该能在事件里看到:

text
write_started
checkpoint_saved (before_write)
write_result_unknown
receipt_recovered
write_completed
checkpoint_saved (after_write)
run_completed

12. 怎样读这组结果

完整报告见 reports/durable-comparison.md

指标process-loop-v1durable-loop-v1
案例通过率11.1%100.0%
模型调用尝试总数1613
重复副作用20
盲目重试80
明确终态率100.0%100.0%

12.1 1/9 不代表普通 Loop 平时只有 11.1% 成功

这 9 个案例不是线上自然流量抽样,而是一组故意覆盖恢复边界的契约测试。正常案例只有一个,所以进程内基线通过 1 个并不意外。

这个数字只能解释为:

在本篇声明的 9 个固定恢复契约里,进程内控制组满足 1 个,候选实现满足 9 个。

不能解释为:

text
Durable Loop 让模型质量提升 88.9%
某个框架比另一个框架可靠 9 倍
线上故障率会按同样比例下降

12.2 两个 completed 可能完全不同

write-receipt-recovery 里,两种策略最终都显示 completed:

  • 基线因为盲目重试,写了两次;
  • 候选查询回执,只保留一次副作用。

如果只测最终状态,这个严重差异会被隐藏。

12.3 waiting_reconciliation 不是失败的自动化

write-unknown 案例中,候选没有追求 completed,而是停在对账状态,副作用为 0。

这里的价值不是“更自动”,而是系统没有在证据不足时制造第二次写入。

12.4 模型尝试次数下降来自错误分类

基线对永久错误也重试三次;候选第一次遇到 invalid_request 就停止,因此总尝试数从 16 降到 13。

这不是模型变聪明,而是 Harness 不再把不可恢复错误当作瞬时故障。

13. 把代码换成真实基础设施时,要补什么

本篇 Lab 有意保持零依赖,因此很多生产责任只是接口雏形。

13.1 把 JSON Store 换成可靠状态存储

至少考虑:

text
乐观并发控制或 compare-and-set
schema version 与迁移
事务边界
状态与事件的一致性
备份和恢复
按 run_id / tenant 隔离
加密与敏感字段脱敏
归档和删除策略

13.2 把进程内队列换成有所有权协议的调度

至少需要:

text
任务领取
lease / heartbeat
到期接管
fencing token
最大并发
优先级
dead-letter / reconciliation queue

13.3 把回执放到正确事务边界

如果业务写入和幂等回执分别写入两个不一致的存储,仍然会出现:

text
业务已提交,回执未提交
回执显示成功,业务事务回滚

优先选择:

  • 下游原生支持幂等键并返回稳定结果;
  • 业务写入和回执在同一个数据库事务;
  • 唯一约束、条件写或 upsert;
  • 事务型 outbox / inbox;
  • 可查询的业务事件日志。

13.4 用真实故障替代模拟故障

继续增加:

text
在写入前 kill Worker
在业务提交后、状态保存前 kill Worker
让状态库短暂不可用
让下游返回 429 / 500 / timeout
让两个 Worker 竞争同一个 run
在 retry wait 中取消
在部署新版本后恢复旧 RunState

并检查真实副作用数量,而不只检查日志。

14. OpenAI、LangGraph、Temporal、Anthropic 与 Google 各自提供什么

这些资料讨论的是相通问题,但抽象层并不相同。

方案或资料主要提供仍需应用定义
OpenAI Background mode单个长时间 Response 的异步执行、轮询与取消;通过 HTTP 游标恢复已启用 streaming 的 background ResponseSDK 的流恢复能力仍需按版本核对;它不负责多步骤业务状态、工具幂等和跨系统事务
OpenAI Agents SDK RunStateAgent 运行和人工审批的序列化、暂停与恢复业务回执、租约、生产状态存储与运营策略
OpenAI Sandbox session / snapshot工作区会话重连与文件快照外部业务副作用和整个工作流的 durable orchestration
LangGraphCheckpointer、节点级恢复、interrupt、持久工作流节点粒度、幂等副作用、部署和业务契约
TemporalWorkflow Event History、重放、Activity、重试和定时器Activity 可能执行多次,应用仍要定义幂等、业务错误分类和外部事务
AWS Lambda Durable FunctionsCheckpoint / replay、Step、wait、retry 和长时间挂起Step 的 at-least-once / at-most-once 语义、幂等键和外部副作用仍要显式选择
Anthropic long-running harness通过 Git、进度文件和结构化产物跨 Session 继续工程任务通用业务工作流、回执、租约和分布式一致性
Google Agent Executor事件日志、快照、恢复与分布式 Actor 方向仍处于早期开发,具体生产边界需按版本核对

LangGraph 的 Functional API 文档 明确要求入口与 task 输出可序列化,并建议把 API 调用放在 task 中、让副作用幂等,因为中断任务恢复时可能重新执行。

Temporal 把易失败、非确定的外部交互放进 Activity,并由 Workflow 保存执行历史。其 Activity 文档 明确说明:Activity 完成业务动作后、向服务端报告完成前仍可能崩溃,因此 Activity 可能再次执行,幂等仍是应用责任。

AWS Lambda Durable Functions 同样基于 checkpoint 和 replay,但它把外部动作放进 Step,并允许为每次 retry attempt 选择 at-least-once 或 at-most-once。后者也不自动等于整个工作流 exactly-once;是否重试、是否使用稳定幂等键,仍要与业务副作用一起设计。

Anthropic 的 Effective harnesses for long-running agents 使用 claude-progress.txt、Git 历史、功能列表和可重复启动脚本,让新 Session 快速理解进度。这对代码 Agent 很实用,但它主要解决长任务的上下文与工程产物交接,不应直接等同于跨业务系统的 exactly-once 工作流。

Google 在 2026 年发布的 Agent Executor 把事件日志、快照、恢复和分布式执行放到 Agent Runtime 层。其仓库同时明确提示项目仍处于早期开发、核心恢复协议可能有破坏性变化,因此适合作为方向参考,不适合在没有版本评估时当成稳定事实依赖。

我的判断是:

text
先用本篇的状态与故障清单定义业务语义;
再选择框架承接持久化、重放和调度;
不要先选框架,再假设默认行为等于你的业务正确性。

15. 什么时候不需要 Durable Loop

并不是每次模型调用都要引入 Durable Runtime。

更适合保持简单的情况:

情况更简单的方案
单次短响应,无外部副作用直接 API 调用
请求失败后用户可以无成本重试普通同步 Handler
只读且重复执行成本很低有 timeout 的简单 Loop
任务完全能在一个可靠事务里完成事务内确定性代码
没有人类等待、跨小时任务或后台调度暂时不引入持久状态机

开始值得引入 Durable Loop 的信号:

text
一次运行会跨分钟、小时或人工等待
存在不可忽略的写操作
Worker 重启后必须继续
重做模型调用明显昂贵
同一任务可能被多个 Worker 接管
用户需要取消和状态查询
运营人员需要知道卡在哪一步

先从最小状态和一个真实故障开始,不需要第一天就引入大型编排平台。

16. 45 分钟练习:给自己的 Agent 加一个恢复边界

先在 Fake、测试租户或 staging 中练习

不要直接对生产邮件、正式文章、真实 CRM 或支付接口注入“提交后断线”。最小安全做法是使用本地 Fake Store,或者创建可清理的测试数据,并记录测试前后的真实副作用数量。只有在重复写入、未知结果和取消路径都通过集成测试后,才把同一恢复协议接到生产工具。

选一个当前 Agent 中真实存在的写工具,例如:

text
发布文章
发送邮件
创建 issue
写入 CRM
生成并上传报告

第 1 步:画出副作用时间线

标出:

text
before request
request accepted
effect committed
receipt returned
run state saved

产物:三个故障窗口及各自恢复动作。

第 2 步:定义稳定动作 ID

回答:

text
它由 run_id + step_id 组成,还是业务主键?
跨重试是否保持不变?
同 key 不同参数怎样报冲突?
回执保存在哪里?

产物:idempotency contract。

第 3 步:持久化最小 RunState

至少包含:

text
status
current_step
completed_steps
attempts
pending_action
cancel_requested
failure_code

产物:一份可序列化状态样本。

第 4 步:注入一次“提交后断线”

让测试执行:

text
业务写入成功
→ 不返回响应
→ 重建 Runner
→ 以相同 action_id 恢复

验收标准:真实副作用数量仍为 1。

第 5 步:再注入一次取消

在人工等待或 retry wait 时写入取消标记,重启 Worker 后恢复。

验收标准:后续写工具没有被调用,终态明确为 cancelled。

17. Durable Loop 收藏清单

状态与检查点

  • RunState 可以序列化并带 schema version
  • 每个检查点都对应清楚的恢复边界
  • 已完成步骤和下一步骤都可判断
  • 尝试次数、预算和 deadline 跨进程保存
  • Secret 不进入持久 RunState

副作用与结果未知

  • 每个写操作有稳定 action_id
  • 相同 action_id、不同参数会冲突
  • 业务写入有持久回执或查询接口
  • Timeout 被区分为 known failure 与 unknown result
  • 无法证明安全时进入 reconciliation,而不是盲目重试

重试与取消

  • 永久错误不会自动重试
  • 瞬时错误受 max attempts、deadline 与成本预算控制
  • 生产重试使用 backoff 与 jitter
  • 取消请求持久化
  • 每个副作用前重新检查取消

多 Worker

  • 任务所有权使用 lease 或等价协议
  • 接管产生单调递增 epoch
  • 业务写入校验 fencing token
  • 失去 lease 的 Worker 会停止后续步骤

验证与运营

  • 故障测试检查真实副作用数量
  • 事件能重建 checkpoint、retry、resume 和 reconcile 顺序
  • 运营人员能区分 failed、cancelled 与 waiting_reconciliation
  • 旧版本 RunState 有迁移或拒绝策略
  • 已用真实 kill、网络故障和并发接管做过集成测试

结语:可恢复,比一直运行更重要

一个长时间 Agent 不可能依靠“进程永远不挂、网络永远不抖、用户永远及时审批”获得可靠性。

Durable Loop 的目标也不是让所有事情自动重试到成功,而是建立一组可解释的恢复规则:

text
已完成的步骤不白做;
可安全重试的步骤有限重试;
永久错误尽早停止;
结果未知时先查外部事实;
没有证据时进入对账;
取消跨进程生效;
过期 Worker 不能迟到写入;
每个终态都有可检查证据。

如果只记住一句:

Durable Loop 不是保证代码永远只跑一次,而是让每次重放都有身份、每次副作用都有证据、每次中断都有明确恢复路径。

下一篇进入 Agent Tracing。Durable Loop 已经会保存状态和事件,但当线上一次 Run 变慢、走错工具或停在 reconciliation 时,我们还需要用统一 Trace 回答:当时看见了什么、在哪一步等待、花了多少预算,以及失败最早从哪里开始。

参考资料

OpenAI

Durable Execution 与长任务 Harness

本文代码与证据