一个 Agent 连续调用模型和工具,看起来已经形成了 Loop:
模型决策 → 调用工具 → 写回结果 → 再次决策 → 完成
但只要把进程重启、网络超时或人工等待放进来,这条顺滑的箭头很快会断掉:
- 模型已经规划完,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
跨进程、网络和外部系统时,“某段代码只运行一次”通常不是一个可以轻易得到的承诺。更现实的目标是:步骤可以重放,副作用可以去重,结果未知时可以查询或停住,恢复行为可以由证据解释。
一分钟概览
如果只保存这篇文章的结论,可以记住十二点:
- 普通 Loop 只描述控制流,Durable Loop 还要定义故障后的恢复语义。
- 模型 Context、RunState、Sandbox 工作区和业务回执是四种不同状态。 只保存聊天记录,不代表能恢复执行。
- 检查点要保存“下一步是什么”,也要保存“哪些步骤已经完成”。 否则恢复仍会从头猜。
- 写入前保存 pending action,写入后保存 receipt。 两者之间是最危险的结果未知窗口。
- 稳定
action_id必须跨重试、跨 Worker、跨进程保持不变。 每次尝试重新生成 key 会让幂等失效。 - 错误分类先于重试。 参数、权限和策略错误不应靠等待恢复;瞬时错误也只能在次数、deadline 和成本预算内重试。
- Timeout 不等于失败。 对写操作,它可能表示“已经成功,但响应丢了”。
- 查到业务回执就恢复为成功;查不到且无法证明未执行,就进入
waiting_reconciliation。 安全停住也是正确结果。 - 取消必须进入持久状态,并在每个副作用前重新检查。 关闭浏览器或终止请求不是可靠取消。
- Lease 防止多个 Worker 主动处理同一任务,fencing token 防止过期 Worker 迟到写入。 两者职责不同。
- Durable Runtime 不能替工具补幂等。 编排器能重放步骤,外部系统仍要识别重复动作。
- 故障测试要验证副作用和恢复路径,不只看最终状态。
completed可能掩盖重复写入,failed也可能掩盖已经成功的业务动作。
图 1:旧 Worker 在模型步骤后崩溃,新 Worker 从持久检查点恢复,查询回执并继续完成 Agent Loop
1. 普通 Loop 和 Durable Loop 差在哪里
一个最小 Agent Loop 大概是:
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])
这段代码在单进程、短任务和只读工具里可能很好用。问题是,它把几件重要事情都放在了进程内存里:
当前执行到哪一步
模型已经做过哪些决策
哪个工具动作正在进行
这次动作是否已经批准
一次超时是否应该重试
用户是否请求取消
外部写入是否已生效
进程退出以后,这些事实一起消失。
Durable Loop 不是把 while 换成某个框架 API,而是在 Loop 外增加一组明确协议:
普通 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.1 Model Context:下一次模型看见什么
它通常包含:
用户消息
模型输出
工具调用与结果
当前任务说明
被选中的证据
历史摘要
它解决的是推理连续性。上一篇 Context Architecture 已经讨论过 Context Packet 的来源、权限、新鲜度和预算。
但 Context 里写着“工单已更新”,并不能证明业务数据库真的存在这次更新。那可能只是模型生成的文字,也可能是旧工具输出。
2.2 RunState:Harness 执行到哪里
它至少应该包含:
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,工作区可能包含:
源代码修改
下载资料
生成报告
测试结果
运行环境
Sandbox snapshot
OpenAI 的 Sandbox Agents 文档 特别区分了 RunState、serialized session state 和 snapshot:前者恢复 Harness 侧执行位置,session state 用于重连同一个 Sandbox 会话,snapshot 用保存的文件内容启动新工作区。
这层解决产物连续性,但仍然不能替代业务回执。例如仓库里有一份“已发布”记录,不代表文章真的发布到了远端平台。
2.4 Business Receipt:外部世界到底发生了什么
生产化回执通常可以与稳定动作 ID 绑定:
{
"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:一次写操作从请求前检查点到业务提交、回执和状态保存之间存在三个不同故障窗口
窗口 A:请求发出前崩溃
此时业务副作用尚未发生。如果输入仍然有效,恢复后通常可以重新执行。
checkpoint saved
process crashes
request never sent
这也是为什么写入前要保存 pending_action。恢复器看到它以后,至少知道原本准备执行哪个动作。
窗口 B:业务提交成功,响应丢失
这是最危险的窗口:
request sent
business write committed
network connection lost
runtime sees timeout
对读操作,重试通常只是多花时间。对发邮件、记账、下单、发布内容或创建资源,盲目重试可能重复产生副作用。
此时 timeout 只能说明客户端没有收到确定答复,不能说明服务端没有执行。
窗口 C:响应已收到,检查点未保存
进程已经拿到成功结果,但来不及把 current_step 和 receipt 写入 RunState。恢复后,运行时可能再次进入同一步。
稳定动作 ID 和持久回执在这里发挥作用:同一动作再次到达业务系统时,应返回第一次的结果,而不是重新写入。
因此,比“是否重试”更基础的问题是:
这一步是否有副作用?
结果是 known success、known failure,还是 unknown?
同一动作能否被可靠识别?
业务系统能否查询或重放原始回执?
4. 检查点不是把整个 Python 对象 dump 下来
检查点要同时满足两个目标:
- 足够完整,能够恢复;
- 足够稳定,能够跨版本、跨进程和跨 Worker 读取。
本篇 Lab 的核心状态经过简化:
@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 保存逻辑游标,不保存不可重建对象
优先保存:
稳定 ID
枚举状态
JSON 输入与输出
版本号
来源引用
错误分类
时间戳或逻辑时间
谨慎保存:
数据库连接
文件句柄
协程对象
闭包
只在当前进程有效的 SDK 实例
明文 Secret
无法版本化的任意对象图
4.2 每个检查点要有清楚语义
不是“想到就存一下”,而是围绕可恢复边界:
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 文件和临时文件替换:
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 先判断永久错误、外部副作用、结果状态、幂等与预算,再选择恢复动作
5.1 永久错误不应该自动重试
典型例子:
invalid_arguments
permission_denied
approval_rejected
resource_not_found under a stable ID
policy_violation
unsupported_operation
等待 100 ms 不会让缺失参数自己出现,也不会让权限自动增加。正确动作通常是修输入、请求授权、改变计划或明确失败。
5.2 瞬时错误也不是无限重试
典型例子:
429 throttling
gateway timeout
temporary dependency unavailable
connection reset before any request was accepted
它们可以进入有限重试,但至少要有:
max_attempts
per-step deadline
whole-run deadline
cost / token budget
backoff
jitter
cancel check
5.3 Backoff 和 Jitter 解决不同问题
指数退避可以写成:
delay = min(base × 2^(attempt - 1), max_delay)
如果很多 Worker 同时失败,它们按相同时间表重试,仍可能同时冲击下游。Jitter 用随机扰动打散重试时间。
本篇 Lab 为了让报告可重复,使用固定的 100 ms、200 ms 逻辑时间,不加入随机抖动,也不真实等待。生产实现应根据下游接口建议加入 jitter,并把 Retry-After 等服务端信号纳入策略。
5.4 重试预算要跨步骤和跨恢复保存
如果 attempts 只存在 Worker 内存里,每次进程重启都会重新获得三次机会,所谓最大重试次数就没有意义。
因此 Lab 把尝试次数写进 RunState:
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 查询业务回执,查到则恢复成功,查不到则进入人工对账
Lab 为每个逻辑写入生成稳定 ID:
action_id = f"{state.run_id}::record-followup"
logical_operation = f"followup::{case.case_id}"
它不能在每次 retry 时重新生成。否则:
attempt 1 → key-a → 写入成功,响应丢失
attempt 2 → key-b → 业务系统认为是新动作,再写一次
正确恢复路径是:
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 选择:
status = waiting_reconciliation
而不是自动重放。
这也是 Durable Engineering 一个不太讨喜但很重要的原则:可靠系统不一定自动完成所有任务,但必须诚实表达自己不知道什么。
AWS 的 Idempotency and retries 明确区分 at-least-once 与 at-most-once,并提醒 replay 和 retry 都可能让同一操作运行多次。其建议同样是让外部副作用支持幂等键,或使用条件写、唯一约束和追加日志等数据库模式。
7. 取消是持久状态,不是关闭连接
用户点取消时,常见实现是终止 HTTP 请求或取消当前协程。对后台任务,这通常不够:
- Worker 可能已经把任务交给下游;
- 当前进程可能在取消消息到达前崩溃;
- 新 Worker 可能只看见旧检查点;
- 一个工具可能不能被强制中断;
- 写操作可能已经进入结果未知窗口。
更稳妥的方式是 cooperative cancellation:
1. 把 cancel_requested 持久化
2. 记录取消原因和请求者
3. 在每次模型调用前检查
4. 在每个副作用前再次检查
5. 在 retry wait 醒来后检查
6. 已进入结果未知时先对账,再决定 cancelled / completed / reconciliation
Lab 的 cancel-at-human-wait 案例这样运行:
模型步骤完成
→ 进入 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 可以表达:
run-42 当前由 worker-b 持有
租约在某个期限后失效
持有者需要 heartbeat 或续租
但 Lease 只能说明“谁现在应该工作”。它不能保证旧 Worker 已经停止。
图 6:Worker B 以更高 lease epoch 接管后,业务存储用 fencing token 拒绝 Worker A 的迟到写入
考虑这个顺序:
Worker A 获得 lease,epoch = 41
A 因长暂停或网络分区失去响应
lease 过期
Worker B 接管,epoch = 42
B 正常继续
A 恢复,以为自己仍然有权写入
如果只在调度器检查 Lease,A 的迟到写入仍可能到达业务系统。
Fencing token 的做法是:
每次接管产生单调递增 epoch
每次副作用携带 epoch
业务存储记住已接受的最高 epoch
低于最高值的迟到写入被拒绝
Lab 中的业务存储边界是:
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。
本篇使用:
| 状态 | 含义 | 是否自动继续 |
|---|---|---|
running | Worker 正在推进 | 是 |
waiting_human | 等待审批或人工输入 | 收到决定后继续 |
waiting_reconciliation | 外部结果未知,缺少安全重试证据 | 否,先对账 |
completed | 目标和副作用均有完成证据 | 否 |
failed | 已知失败,带稳定 failure code | 由策略决定是否新建 Run |
cancelled | 持久取消已在安全边界生效 | 否 |
还可以按业务需要加入:
retry_wait
compensating
paused_budget
expired
dead_letter
状态不要为了漂亮而增加。每个状态至少要回答:
谁能把它迁移到下一状态?
迁移前要检查什么?
要保存哪些证据?
运营人员看到它后能做什么?
10. 本篇 Lab:把故障变成固定测试集
这次没有换项目,仍然沿用同一个 Agent Reliability Lab。
版本从 0.5.0 升到 0.6.0,新增:
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 拓扑更聪明。模型服务是固定脚本,工具输入也是固定的。实验只验证:相同故障到来时,运行时是否遵守声明的恢复契约。
九个故障案例
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 上的结果
11. 跟着运行 Lab 0.6.0
方式一:直接下载
解压后进入:
phase-7-agent-engineering/agent-reliability-lab
方式二:从 GitHub 检出固定提交
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
第一步:运行故障测试
python run_lab.py fault-test --output reports/local
预期退出码是 0,终端摘要应包含:
{
"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
}
第二步:运行全部测试
python -m unittest discover -s tests -v
本文固定提交上的预期结果:
Ran 50 tests
OK
第三步:不要只看摘要
先看对照表:
reports/local/durable-comparison.md
再看失败台账:
reports/local/durable-failures.md
最后挑一个案例检查完整事件:
reports/local/durable-runs.jsonl
例如搜索:
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))"
你应该能在事件里看到:
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-v1 | durable-loop-v1 |
|---|---|---|
| 案例通过率 | 11.1% | 100.0% |
| 模型调用尝试总数 | 16 | 13 |
| 重复副作用 | 2 | 0 |
| 盲目重试 | 8 | 0 |
| 明确终态率 | 100.0% | 100.0% |
12.1 1/9 不代表普通 Loop 平时只有 11.1% 成功
这 9 个案例不是线上自然流量抽样,而是一组故意覆盖恢复边界的契约测试。正常案例只有一个,所以进程内基线通过 1 个并不意外。
这个数字只能解释为:
在本篇声明的 9 个固定恢复契约里,进程内控制组满足 1 个,候选实现满足 9 个。
不能解释为:
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 换成可靠状态存储
至少考虑:
乐观并发控制或 compare-and-set
schema version 与迁移
事务边界
状态与事件的一致性
备份和恢复
按 run_id / tenant 隔离
加密与敏感字段脱敏
归档和删除策略
13.2 把进程内队列换成有所有权协议的调度
至少需要:
任务领取
lease / heartbeat
到期接管
fencing token
最大并发
优先级
dead-letter / reconciliation queue
13.3 把回执放到正确事务边界
如果业务写入和幂等回执分别写入两个不一致的存储,仍然会出现:
业务已提交,回执未提交
回执显示成功,业务事务回滚
优先选择:
- 下游原生支持幂等键并返回稳定结果;
- 业务写入和回执在同一个数据库事务;
- 唯一约束、条件写或 upsert;
- 事务型 outbox / inbox;
- 可查询的业务事件日志。
13.4 用真实故障替代模拟故障
继续增加:
在写入前 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 Response | SDK 的流恢复能力仍需按版本核对;它不负责多步骤业务状态、工具幂等和跨系统事务 |
OpenAI Agents SDK RunState | Agent 运行和人工审批的序列化、暂停与恢复 | 业务回执、租约、生产状态存储与运营策略 |
| OpenAI Sandbox session / snapshot | 工作区会话重连与文件快照 | 外部业务副作用和整个工作流的 durable orchestration |
| LangGraph | Checkpointer、节点级恢复、interrupt、持久工作流 | 节点粒度、幂等副作用、部署和业务契约 |
| Temporal | Workflow Event History、重放、Activity、重试和定时器 | Activity 可能执行多次,应用仍要定义幂等、业务错误分类和外部事务 |
| AWS Lambda Durable Functions | Checkpoint / 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 层。其仓库同时明确提示项目仍处于早期开发、核心恢复协议可能有破坏性变化,因此适合作为方向参考,不适合在没有版本评估时当成稳定事实依赖。
我的判断是:
先用本篇的状态与故障清单定义业务语义;
再选择框架承接持久化、重放和调度;
不要先选框架,再假设默认行为等于你的业务正确性。
15. 什么时候不需要 Durable Loop
并不是每次模型调用都要引入 Durable Runtime。
更适合保持简单的情况:
| 情况 | 更简单的方案 |
|---|---|
| 单次短响应,无外部副作用 | 直接 API 调用 |
| 请求失败后用户可以无成本重试 | 普通同步 Handler |
| 只读且重复执行成本很低 | 有 timeout 的简单 Loop |
| 任务完全能在一个可靠事务里完成 | 事务内确定性代码 |
| 没有人类等待、跨小时任务或后台调度 | 暂时不引入持久状态机 |
开始值得引入 Durable Loop 的信号:
一次运行会跨分钟、小时或人工等待
存在不可忽略的写操作
Worker 重启后必须继续
重做模型调用明显昂贵
同一任务可能被多个 Worker 接管
用户需要取消和状态查询
运营人员需要知道卡在哪一步
先从最小状态和一个真实故障开始,不需要第一天就引入大型编排平台。
16. 45 分钟练习:给自己的 Agent 加一个恢复边界
先在 Fake、测试租户或 staging 中练习
不要直接对生产邮件、正式文章、真实 CRM 或支付接口注入“提交后断线”。最小安全做法是使用本地 Fake Store,或者创建可清理的测试数据,并记录测试前后的真实副作用数量。只有在重复写入、未知结果和取消路径都通过集成测试后,才把同一恢复协议接到生产工具。
选一个当前 Agent 中真实存在的写工具,例如:
发布文章
发送邮件
创建 issue
写入 CRM
生成并上传报告
第 1 步:画出副作用时间线
标出:
before request
request accepted
effect committed
receipt returned
run state saved
产物:三个故障窗口及各自恢复动作。
第 2 步:定义稳定动作 ID
回答:
它由 run_id + step_id 组成,还是业务主键?
跨重试是否保持不变?
同 key 不同参数怎样报冲突?
回执保存在哪里?
产物:idempotency contract。
第 3 步:持久化最小 RunState
至少包含:
status
current_step
completed_steps
attempts
pending_action
cancel_requested
failure_code
产物:一份可序列化状态样本。
第 4 步:注入一次“提交后断线”
让测试执行:
业务写入成功
→ 不返回响应
→ 重建 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 的目标也不是让所有事情自动重试到成功,而是建立一组可解释的恢复规则:
已完成的步骤不白做;
可安全重试的步骤有限重试;
永久错误尽早停止;
结果未知时先查外部事实;
没有证据时进入对账;
取消跨进程生效;
过期 Worker 不能迟到写入;
每个终态都有可检查证据。
如果只记住一句:
Durable Loop 不是保证代码永远只跑一次,而是让每次重放都有身份、每次副作用都有证据、每次中断都有明确恢复路径。
下一篇进入 Agent Tracing。Durable Loop 已经会保存状态和事件,但当线上一次 Run 变慢、走错工具或停在 reconciliation 时,我们还需要用统一 Trace 回答:当时看见了什么、在哪一步等待、花了多少预算,以及失败最早从哪里开始。
参考资料
OpenAI
- Background mode
- Sandbox Agents
- OpenAI Agents SDK:Human-in-the-loop
- OpenAI Agents SDK:RunState
- OpenAI Agents SDK:Results
Durable Execution 与长任务 Harness
- LangGraph overview
- LangGraph Functional API:Durable execution、determinism 与 idempotency
- LangGraph:Thinking in LangGraph
- Temporal:Workflow Execution
- Temporal:Activity Definition 与幂等
- AWS Lambda:Durable Functions
- AWS Durable Execution:Idempotency and retries
- Anthropic:Effective harnesses for long-running agents
- Anthropic:Harness design for long-running application development
- Google Agent Executor
- Martin Kleppmann:How to do distributed locking