checkpoint 断点续跑 — 难点不在保存,在幂等重放
checkpoint 断点续跑 — 难点不在保存,在幂等重放
日期: 2026-07-20 阶段: Phase 2 - AI-native 参考架构 标签: #durable-execution #idempotency #crash-recovery
核心问题
P2 给 orchestrator(src/agent/orchestrator/orchestratorAgent.ts,Lead-Subagent + Budget)接上真实 LLM 后,一个多步 AML 调查可能跑几分钟、烧十几次 LLM 调用。问题来了:进程在第 5 步崩了(OOM、部署滚动、worker 被杀),怎么办? 朴素答案是「把状态存盘,重启后从存盘点继续」。今天要证明这个答案只对了一半——断点续跑真正的难点不在「保存状态」,而在「重放时不重复产生副作用」。
具体两问:
- durable execution 的崩溃恢复用什么交付语义? 直觉想要「exactly-once」,但今天证明分布式下做不到 exactly-once 执行,只能做到 at-least-once 执行 + 幂等的 no-more-than-once 业务效果。这两层必须拆开看。
- 怎么验证恢复真的对? 不能靠「重启后看着像恢复了」——必须做崩溃注入测试,且把「恢复成功率」纳入 eval,否则恢复路径是一段从不执行、永远在腐烂的死代码。
对 AML Copilot 这是合规底线:一次调查如果在「已生成 SAR 草稿、已写审计日志」之后崩溃,重放时若把审计日志又写一遍、把 SAR 又提交一遍,就是重复上报——监管语境下等同伪造记录。
关键内容
A. at-least-once 执行语义与「两层契约」
durable execution(Temporal/Restate/LangGraph 这一类)的恢复模型本质是重放:进程崩溃后,在新进程里从函数顶部重新执行控制流,对于已完成的步骤,不重新执行,而是从事件历史里取出之前的输入/输出直接复用。Temporal 官方口径(temporal.io, idempotency 博客):
"activities that have already been executed are not re-executed. Instead Temporal simply takes activity input and output from its event history."
为什么默认只能是 at-least-once(至少一次)而非 exactly-once?因为单步执行不是原子的:
"Activity execution is not atomic due to factors such as failures, timeouts, environment failure, or other conditions that lead to partial success."(temporal.io, 2026)
考虑这个崩溃窗口——worker 成功执行了某步、产生了真实副作用(扣款/写库/调外部 API),但在把结果汇报给 durable engine 之前崩了。引擎无从知道这步成没成,只能重试。于是同一步被执行了两次。这就是 at-least-once 的来源:重试定时器活在数据库里而不是 worker 内存里,所以 worker A 崩了,worker B 接力重试,引擎记得指数退避的进度——可靠,但代价是「可能重放」。
由此引出两层契约,这是今天最关键的概念拆分:
| 层 | 谁保证 | 语义 | 失败后果 |
|---|---|---|---|
| 投递层 | durable engine | at-least-once delivery(每步至少跑一次) | 可能重复执行 |
| 业务效果层 | 你的代码(幂等) | no-more-than-once effect(业务上最多一次) | 不幂等→重复副作用 |
Temporal 原话把这层关系点透:activities 提供「at-least-once」执行保证,而幂等的 activity 实现提供「no-more-than-once」的业务效果。引擎只负责「至少跑到」,「不重复扣款」是你自己的事。 这就是为什么标题说难点不在保存——保存是引擎白送的;幂等是你的债。
B. 幂等重放:用幂等键把「重复执行」吸收成「单次效果」
幂等(idempotency)的定义(temporal.io):
"a request that produces the same result, regardless of how many times it is made"
注意一个反直觉的边界:幂等 ≠ 无副作用。foo += 3 不幂等(结果随调用次数变);「把客户地址更新为 X」幂等(调多少次最终都是 X)。AML 场景里,真正危险的是累加型/追加型副作用——「往审计表 append 一行」「向监管系统 submit 一份 SAR」,天然非幂等,重放即重复。
工程上把非幂等操作改造成幂等,核心武器是幂等键 + 唯一约束。durable execution 提供了一个绝佳的天然幂等键:
idempotencyKey = workflowRunId + '-' + activityId
它「跨重试恒定,跨所有 workflow 唯一」(temporal.io)——同一步无论重放几次,键都一样;不同步键不同。落地到数据库就是「operations 表 + 唯一约束 + ON CONFLICT DO NOTHING」:
CREATE TABLE aml_side_effects (
idempotency_key VARCHAR(255) PRIMARY KEY,
kind VARCHAR(32), -- 'audit_log' | 'sar_submit'
payload_hash CHAR(64),
created_at TIMESTAMPTZ DEFAULT now()
);
-- 第二次重放命中冲突,静默跳过,副作用只发生一次
INSERT INTO aml_side_effects(idempotency_key, kind, payload_hash)
VALUES (@key, @kind, @hash)
ON CONFLICT (idempotency_key) DO NOTHING;
反直觉洞察①(check-then-set 会被并发重放击穿):很多人第一反应是「先查一下做没做过,没做才做」——
if (!exists(key)) doSideEffect()。这在重放并发下有竞态:temporal.io 明确警告,「如果第二次尝试在第一次的CreateUser()完成前就越过了UserExists()检查,两次都会成功执行」。崩溃恢复经常制造「旧 worker 还没死透、新 worker 已接管」的并发窗口,check-then-set 恰好在这窗口被击穿。正确做法是把幂等下沉到存储层的原子写(唯一约束 + ON CONFLICT),让数据库的原子性替你裁决「谁先到」,而不是在应用层自己 if-else。
崩溃-恢复状态机(一步的生命周期):
┌──────────────────────────────────────────────┐
│ step(idempotencyKey) │
▼ │
[INTENT] 写意图到 history (durable engine 落盘) │
│ │
▼ │
[EXECUTE] 执行副作用 ──crash?──┐ │
│ │ worker B 接管 │
│ └──► 重放:从顶部重跑 │
▼ 已完成步→取 history │
[RECORD] 结果写 history 未完成步→重执行 ──┘
│ (幂等键吸收重复)
▼
[DONE] 业务效果恰好一次
幂等性论证(为什么这条状态机给出 no-more-than-once):设第 k 步的幂等键 $K_k = \text{runId}\text{-}k$ 在一次 run 内恒定。副作用以 INSERT ... ON CONFLICT(K_k) DO NOTHING 实现,存储层唯一约束保证「键 $K_k$ 至多一行」。重放任意多次 EXECUTE,第一次成功 INSERT,其余全部命中冲突跳过 ⇒ 副作用计数 $\le 1$。又因 at-least-once 保证至少执行一次 ⇒ 副作用计数 $\ge 1$。合起来副作用计数 $= 1$。注意这条论证的前提是副作用全部走幂等键通道——任何绕过通道的裸写(如直接 auditTable.append())都会破坏证明,这是落地审查的重点。
C. 崩溃注入测试 + 恢复成功率纳入 eval
恢复路径是典型的「正常永不执行、出事才执行」的死角代码——若不主动注入崩溃,它会无声腐烂。方法论借自混沌工程的 fault injection:进程级「在任务活跃执行中主动终止实例」(如 Flink 实践里 proactively terminate TaskManager/JobManager,arXiv 2602.03189, 2026-02)。落到 agent 上,崩溃注入要在每个 step 边界制造受控崩溃,验证重放后业务效果仍恰好一次:
for crashPoint in [afterIntent, afterExecuteBeforeRecord, afterRecord]:
run = startInvestigation(case, injectCrashAt=crashPoint)
run.runUntilCrash()
resumed = resume(run.runId) # 新 worker 接管,从 history 重放
assert resumed.finalState == golden[case] # 终态正确
assert countSideEffects(run.runId, 'sar_submit') == 1 # 副作用恰好一次
assert countSideEffects(run.runId, 'audit_log') == expectedRows[case]
最危险的崩溃点是 afterExecuteBeforeRecord——副作用已发生、结果未落 history,这正是 at-least-once 重复执行的窗口;幂等键在这里被真正考验。
反直觉洞察②(恢复成功率必须是一个被持续测量的指标,不是一次性验收):团队常把「断点续跑」当功能验收——演示一次崩溃恢复成功就打勾。但恢复正确性会随代码演进而退化:任何人新加一个绕过幂等键的副作用、或改了控制流引入非确定性(Day 38 详述),恢复就悄悄破功,而正常路径测试完全照不出来。所以恢复成功率(
resumedRunsWithExactlyOnceEffect / totalCrashInjectedRuns)必须像 recall/FPR 一样进 CI、每次 commit 跑、跌破阈值即阻断 merge(呼应 Day 19 的 blocking eval gate)。
恢复指标与 P1 评测体系的对齐:
| 指标 | 定义 | 门槛(v1 设计) | 归属 |
|---|---|---|---|
| 恢复成功率 | 崩溃注入后终态==golden 的比例 | = 1.00(任何 <1 阻断) | 新增 durable eval |
| 副作用幂等率 | 重放后副作用计数==期望的比例 | = 1.00 | 同上 |
| 重放开销 | 重放消耗的 LLM token / 正常 run token | 记录,不设硬门 | 接 $/案件成本 |
设计要点/决策表
| 要点 | 决策 | 理由 |
|---|---|---|
| 交付语义 | 接受 at-least-once 执行,不追求 exactly-once 执行 | 分布式下单步非原子,exactly-once 执行做不到 |
| 业务效果 | 用幂等键把效果收敛到 no-more-than-once | 引擎只保证至少一次,不重复是代码的责任 |
| 幂等实现 | 存储层唯一约束 + ON CONFLICT,禁 check-then-set | 后者在重放并发窗口被击穿 |
| 幂等键来源 | runId-stepId(跨重试恒定、全局唯一) | durable engine 天然提供,无需自造 |
| 验证手段 | 每个 step 边界崩溃注入,断言副作用==1 | 恢复路径不主动测就腐烂 |
| 恢复成功率 | 进 CI、每 commit 跑、跌破即阻断 | 防代码演进悄悄破坏恢复正确性 |
对本项目的落地
- 新增
src/agent/durable/idempotency.ts:导出idemKey(runId, stepId) → string与recordOnce(key, kind, payload) → 'inserted' | 'duplicate'(v1 用 IndexedDB / 内存 Map 模拟唯一约束的ON CONFLICT DO NOTHING语义,接口对齐未来 Postgres)。所有 AML 副作用(审计日志、SAR 提交)必须经此通道,禁止裸写——这是 B 节幂等性论证成立的前提。 - 与
useTraceStore衔接:src/agent/trace/useTraceStore.ts的RunTrace.steps[](含id、startedAt/endedAt)天然就是事件历史的雏形——重放时「已完成步取 history」即读step.endedAt != null && step.toolCalls[].result。P2 在其上加一个replayFrom(runId)读路径即可,无需新建持久层。 - 新增
src/agent/durable/__tests__/crashRecovery.test.ts:实现 C 节崩溃注入循环,在afterIntent / afterExecuteBeforeRecord / afterRecord三点注入崩溃,断言恢复后终态==golden(复用src/aml/generator.ts的 66 案 golden)且副作用计数==1。把「恢复成功率==1.00」「幂等率==1.00」写成 vitest 断言进 CI gate(呼应 Day 19)。 - Budget 交互:
src/agent/orchestrator/budget.ts的 cost/step 计数在重放时不应重复计费已完成步——重放复用 history 不重新调 LLM,故成本累加只对「真正重执行的未完成步」生效。落地时replayFrom需跳过已落 history 步的assertCostOk/incrementStep。 - 诚实标注:
idempotency.ts头注明确 v1 为浏览器内模拟(IndexedDB),生产级 Postgres 唯一约束为 P3 上线动作;恢复成功率门槛 1.00 为设计值,待真实崩溃注入跑通后回填实测。不谎称已接 Temporal——本项目 P2 自建轻量 durable 层,Day 39 论证为何此阶段不直接上 Temporal。
参考资料
- Temporal — What is idempotency? And why it matters for durable systems(temporal.io):at-least-once 执行 + 幂等=no-more-than-once 效果;
runId-activityId幂等键;operations 表 + ON CONFLICT;check-then-set 竞态警告;activity 执行非原子 (2026) - Jack Vanlightly — Demystifying Determinism in Durable Execution(jack-vanlightly.com, 2025-11-24):恢复=从顶部重执行控制流、复用已存副作用结果;侧效需「幂等或容重复」而非确定性
- Resonate — Durable Execution: From where do deterministic constraints come?(journal.resonatehq.io):事件历史=append-only 日志、经确定性函数重放;步骤与日志逐一比对、已跟踪步从日志取值以防重复副作用 (持续)
- Temporal — Error handling in distributed systems / Activity Definition(docs.temporal.io):重试定时器存数据库非 worker 内存、worker A 崩 worker B 接力 (2026)
- StreamShield (ByteDance) — A Production-Proven Resiliency Solution for Apache Flink(arXiv 2602.03189, 2026-02):进程级故障注入、活跃执行中主动终止 TaskManager/JobManager 验证恢复
- 本仓库
src/agent/trace/useTraceStore.ts(事件历史雏形)、src/agent/orchestrator/budget.ts(成本/步数计数)、src/dsdb-lab/wal/walEngine.ts(WAL replay 参照)(2026-06)
SOTA 检查 (2026-06-11)
- 「at-least-once 执行 + 幂等业务效果」是 2026 durable execution 的事实标准:Temporal、Restate、Resonate 三家口径一致——引擎给 at-least-once,幂等是用户责任;未见有引擎宣称在分布式下做到「exactly-once 执行」(区别于「exactly-once 效果」,后者靠幂等达成)。本笔记两层契约拆分与主线同向。
- 幂等键下沉存储层、禁 check-then-set 仍是 live 踩坑点:temporal.io 2026 仍专门撰文警告 check-then-set 竞态,说明这是工程界反复踩的坑,反直觉洞察①未过时。
- 崩溃注入纳入测试在升温:混沌工程(Chaos Mesh/Gremlin/Litmus)做进程级 kill 已成熟(Gartner 2026 市场评审);把它专门用于 agent 恢复正确性 + 副作用幂等性断言是较新做法,arXiv 2026-02 的 Flink 故障注入实践可迁移。待跟踪:是否出现 agent 专用的崩溃-恢复 eval 框架(类似 HAL 之于 agent 能力评测)。
- 过时认知警示:(1) 不要把「状态已存盘」等同「断点续跑已实现」——保存是易的、幂等重放是难的;(2) 不要用 check-then-set 实现幂等;(3) 不要把恢复当一次性验收——它会随代码演进退化,须进 CI 持续测。
- 待跟踪:本项目 P2 自建轻量 durable 层 vs P3 是否迁 Temporal(Day 39 决策);Temporal 2026-02-17 完成 $300M/$5B 融资、年执行量达 1.86 万亿(AI 公司贡献),生态成熟度影响 P3 选型。