LangGraph 崩溃恢复实测:durability 三档各丢什么
进程被运维脚本 kill 掉的那一刻,你的 Agent 正在第三个节点里调支付接口。重启后 thread_id 还在,状态也读得回来,看起来一切正常 —— 直到对账时发现同一笔款扣了两次。
这不是框架的 bug,是持久化语义的边界:checkpoint 记的是状态,不是副作用。而 durability 这个参数决定崩溃发生的那一刻,到底有多少状态活着回来。官方给它的说明只有 API docstring 里的三行字,没有独立文档页,所以我实际测试了一遍:把进程在节点中途强杀 24 次,量出三档各自的损失。
口径声明:本文数字全部来自本机实测 —— Windows 11、CPython 3.13.12、langgraph 1.2.18(官方 release 日期 2026-10-26)、langgraph-checkpoint-sqlite 4.3.1 截至发稿。强杀手段是父进程用 TerminateProcess 直接结束子进程(等价于 SIGKILL,不给任何清理机会),每次实验结束后在独立的新进程里恢复,避免内存里残留的对象帮忙。本文不含任何联盟推广链接。
durability 三档的官方定义
langgraph/types.py 第 98 行把它定义成三个字面量:
Durability = Literal["sync", "async", "exit"]
Pregel.invoke() 的 docstring 逐字写着:
"sync": Changes are persisted synchronously before the next step starts."async": Changes are persisted asynchronously while the next step executes."exit": Changes are persisted only when the graph exits.
默认值是 "async",来自 langgraph/pregel/main.py 第 2696 行:durability = config.get(CONF, {}).get(CONFIG_KEY_DURABILITY, "async")。
三句话都只回答「什么时候写」,没回答「崩溃时丢什么」。而后者才是选型依据。
实验怎么设计
两个图,每个节点做两件事:往一个 append-only 的 JSONL 记一行 start 和一行 end(每条都 fsync 落盘,排除缓冲干扰),再返回状态增量。
- 线性图:a → b → c → d,强杀在 c 执行到一半(c 写完 start、还没写完 end)。
- 并行图:start → p1 / p2 / p3 → join,强杀在 p3 执行到一半,此时同一超步里的 p1、p2 可能已经完成。
- 慢速后端:给
SqliteSaver.put()套一层 250 毫秒延迟,模拟网络型 checkpointer(Postgres 或托管服务)的真实写延迟。这是让 sync 与 async 拉开差距的唯一办法。
每种配置重复 4 次,看分布而不是抓单次。干净跑一遍的副作用基线:线性图 8 行,并行图 10 行。
线性图:exit 档根本没有「恢复」这回事
| durability | 崩溃时 checkpoint 数 | 恢复结果 | 副作用总行数(基线 8) |
|---|---|---|---|
| exit | 0(4/4 次) | 抛 EmptyInputError,无法恢复 | 5 |
| async | 4(4/4 次) | 成功 | 9 |
| sync | 4(4/4 次) | 成功 | 9 |
exit 档 4 次崩溃后 checkpoint 表全是空的。此时 graph.invoke(None, config) 抛 EmptyInputError: Received no input for __start__ —— 因为在 LangGraph 里「无输入」等于「从已有 checkpoint 继续」,没有 checkpoint 就无从继续。已经发生的 5 次副作用(a、b 完整跑完,c 写了 start)全部变成孤儿。
async 与 sync 都保住了 4 个 checkpoint,恢复后副作用 9 行,比干净跑多 1 行 —— 多出来的是 c 的第二次 start。这就是恢复语义的核心:从最近一个完整超步继续,被中断的那个超步整段重跑。c 里那句「已经调用过支付接口」它不知道,也无从知道。
并行图:差距在这里才出现
| 模式与后端 | 崩溃时 checkpoint 数 | 恢复 | 副作用重复次数 |
|---|---|---|---|
| async 加本地 SQLite | 3 | 成功 | 2 至 4 次 |
| sync 加本地 SQLite | 3 | 成功 | 1 至 4 次 |
| async 加 250ms 延迟 | 1 | 成功 | 6 至 7 次 |
| sync 加 250ms 延迟 | 3 | 成功 | 0 至 3 次 |
本地 SQLite 下 sync 和 async 打平,都是 3 个 checkpoint。把 put() 拖到 250 毫秒之后,async 只剩 1 个 checkpoint,恢复后副作用比基线多 6 至 7 行 —— 整个第一步和第二步全部重跑;sync 仍保住 3 个,重复 0 至 3 次。
原因在 main.py 第 3078 行:只有 durability_ == "sync" 时才会执行 loop._put_checkpoint_fut.result() 阻塞等落盘;async 直接把写任务提交到后台线程,图继续往下走。写后端足够快时,后台写在你被杀之前就已经完成,两种模式表现一致;写后端一慢,async 的「在途窗口」就变成真实的数据缺口。
代价也量了。一次干净跑(5 节点并行图):本地后端三档分别 0.56 / 0.55 / 0.58 秒,checkpoint 数 1 / 5 / 5;慢速后端 0.81 / 1.33 / 1.90 秒。sync 多付的就是那几次 put 的延迟,在 250 毫秒的后端上等于全程慢 1.1 秒,占 1.9 秒的 58%。
为什么重复次数是一个区间
同样的图、同样的强杀位置,重复次数在 1 到 4 之间漂。原因在 checkpoint 库里其实是两张表:checkpoints 存每一步的状态快照,writes 存每个任务已经算完的增量。崩溃时这两张表的进度并不一致 —— 我数了崩溃瞬间的 writes 行数,sync 档 11 行、async 档 14 行,同样的图同样 4 次样本。已经落进 writes 的任务在恢复时会被直接复用、不再重跑;还停在内存里的那些,跟着被中断的超步一起重来。所以「重跑几次」不是常量,取决于强杀那一刻后台写线程跑到哪一行。
这个不确定性正好说明为什么不能按「发生概率很低」来设计。你能控制的只有两件事:让重跑变得安全(幂等键),或者让重跑的范围变小(把副作用收窄到最后一步)。指望它不发生,等于把对账交给运气。
所以它是 at-least-once,不是 exactly-once
三条能直接落地的判断:
1. 别指望图不重跑。恢复的单位是超步,不是节点,更不是「上次停下的那行代码」。任何有外部后果的动作(扣款、发信、建工单)都必须自带幂等键,由下游去重。
2. 副作用要么推到最后一个节点,要么走 outbox。先写本地表再由独立消费者投递,等于把「不可重跑的部分」移出图的重放范围。
3. 选档看后果而不是看性能。能容忍重跑就用默认的 async,成本最低;副作用不能重复且写后端不在地板上就用 sync;exit 只适合「崩了从头再来也无所谓」的一次性批处理,不要用在有状态的会话上。
顺带一个容易忽略的对照:内存型 checkpointer(InMemorySaver)在进程重启后连 checkpoint 本身都没了,官方 persistence 文档写得很直白 —— 「When the process restarts, all checkpoints are lost」。它适合开发调试,不适合「崩溃后还要能接着跑」的场景。
踩坑录:五个真实报错
报错一 — EmptyInputError: Received no input for __start__。两种触发方式:exit 档崩溃后恢复(checkpoint 表为空),以及给一个从未存在过的 thread_id 传 None。前者要靠「恢复到哪儿」的业务判据处理,后者要先 get_state() 判断有没有历史,再决定是新建线程还是继续。
报错二 — AttributeError: 'SyncPregelLoop' object has no attribute '_put_checkpoint_fut'. Did you mean: '_put_checkpoint'?。编译时不传 checkpointer,却设了 durability="sync":第 2894 行先警告 durability has no effect when no checkpointer is present,第 3079 行照样去取那个只有存在 checkpointer 时才在 _loop.py 里赋值的 future,于是崩掉。4/4 次稳定复现 —— 警告说你设的参数无效,实际是它会让你崩,这两句互相矛盾。
报错三 — ValueError: Cannot use both checkpoint_during and durability parameters. Please use durability instead.。checkpoint_during 是旧开关(LangGraphDeprecatedSinceV10),已经从内存里消失。迁移时把旧布尔和新档位一起传就会撞这条,去掉旧参数即可。
报错四 — sqlite3.OperationalError: database is locked。同一份 SQLite 文件被两个进程同时写:第一个开着写事务,第二个用 timeout=0.3 打开再写就抛这条。崩溃恢复型架构里很常见 —— 旧进程还没死透,新进程已经起来接着跑。要么先确认旧进程退出,要么改用支持多写入方的后端。这类「文件能打开、内容却不对」的坑,我在SQLite 热备 11 轮实测里已经逐个对拍过。
报错五 — 拼错档位不报错。把 durability="sync" 打成 "syncc",invoke 跑完返回 n=5,全程零警告。Durability 只是 typing.Literal,运行时没有任何校验,拼错等于静默退回非同步路径 —— 你会在下一次崩溃时才发现它没生效。
本文的边界
实测只覆盖 SqliteSaver、单机、同步 invoke、无子图。没有测 Postgres 或 Redis 后端的真实延迟分布(用 250 毫秒固定延迟近似),没有测 astream 异步路径,也没有测它与 retry_policy、interrupt 叠加时的交互 —— 人工中断那条链路在LangGraph 流式三口径之后的人审中断与恢复篇里单独拆过,两者语义不同:中断是「等你回来」,崩溃是「不等你」。持久化机制本身(checkpointer 存什么、checkpoint_ns 怎么分)在checkpointer 与 8 个真实报错那篇写过,本篇只关心「坏了之后剩什么」。
如果你的场景是「一个进程跑一次、失败了重跑整批」,exit 没有任何问题;但只要是长会话、有外部副作用、还可能被 k8s 的 SIGTERM 顺手带走,就要按 at-least-once 来设计,而不是按「理论上不会重跑」来设计。
👉 立即参与小米 MiMo 开放平台:国内领先的 AI 大模型开放平台,高性价比推理服务
👉 立即参与阿里云 AI:汇集爆款 AI 产品,热门模型专属权益优惠券助力企业创新加速
📌 本文由 AI 辅助生成并经人工审核发布 | TechPassive — AI 驱动的内容测试站点,专注于效率工具与 SaaS 真实评测
🔗 精选推荐工具
使用以下链接支持我们持续产出高质量内容(点击可直接前往购买):