返回 AIPA 笔记
AIPA Day 36

checkpoint 断点续跑 — 难点不在保存,在幂等重放

checkpoint 断点续跑 — 难点不在保存,在幂等重放

2026-07-20
durable-executionidempotencycrash-recovery

日期: 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 被杀),怎么办? 朴素答案是「把状态存盘,重启后从存盘点继续」。今天要证明这个答案只对了一半——断点续跑真正的难点不在「保存状态」,而在「重放时不重复产生副作用」

具体两问:

  1. durable execution 的崩溃恢复用什么交付语义? 直觉想要「exactly-once」,但今天证明分布式下做不到 exactly-once 执行,只能做到 at-least-once 执行 + 幂等的 no-more-than-once 业务效果。这两层必须拆开看。
  2. 怎么验证恢复真的对? 不能靠「重启后看着像恢复了」——必须做崩溃注入测试,且把「恢复成功率」纳入 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 engineat-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) → stringrecordOnce(key, kind, payload) → 'inserted' | 'duplicate'(v1 用 IndexedDB / 内存 Map 模拟唯一约束的 ON CONFLICT DO NOTHING 语义,接口对齐未来 Postgres)。所有 AML 副作用(审计日志、SAR 提交)必须经此通道,禁止裸写——这是 B 节幂等性论证成立的前提。
  • useTraceStore 衔接src/agent/trace/useTraceStore.tsRunTrace.steps[](含 idstartedAt/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。

参考资料

  1. 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)
  2. Jack Vanlightly — Demystifying Determinism in Durable Execution(jack-vanlightly.com, 2025-11-24):恢复=从顶部重执行控制流、复用已存副作用结果;侧效需「幂等或容重复」而非确定性
  3. Resonate — Durable Execution: From where do deterministic constraints come?(journal.resonatehq.io):事件历史=append-only 日志、经确定性函数重放;步骤与日志逐一比对、已跟踪步从日志取值以防重复副作用 (持续)
  4. Temporal — Error handling in distributed systems / Activity Definition(docs.temporal.io):重试定时器存数据库非 worker 内存、worker A 崩 worker B 接力 (2026)
  5. StreamShield (ByteDance) — A Production-Proven Resiliency Solution for Apache Flink(arXiv 2602.03189, 2026-02):进程级故障注入、活跃执行中主动终止 TaskManager/JobManager 验证恢复
  6. 本仓库 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 选型。