langgraph — 本课题摘录
读了哪几篇: 01-pregel-bsp(核心引擎:超步循环)、03-persistence-hil(持久化、中断与恢复)。
其余三篇(channel 与合并规则、动态控制流、巧妙之处)本轮没读。
这一家在本课题里的位置:它是"循环不用 while 写"的最成熟的一个实现,而且把这条路的代价说得最诚实。
它对本课题回答了什么
决定一(架构):用"超步 + 同步栅栏"代替裸循环
它的模型借自图计算:计算分成一个个超步;一个超步内所有活跃节点并行跑,彼此的本 步写入互不可见;超步结束有一道同步栅栏,所有写入在这里一次性生效;没有节点活跃时停止。
它给的三条理由:
| 好处 | 说的是什么 |
|---|---|
| 并行无竞态 | 同一超步内多节点并行写状态,但写入要到栅栏才合并,不会互相踩 |
| 可恢复 | 栅栏处状态是一致快照,存盘即可;恢复就是从某张快照接着跑 |
| 确定性提交顺序 | 合并时按任务路径排序,保证可复现 |
(依据:前沿库 · LangGraph · 核心引擎:Pregel/BSP 超步循环 —— BSP 超步模型的三条好处是并行无竞态(写入到栅栏才合并)、可恢复(栅栏处是一致快照)、确定性提交顺序(按任务路径排序))
一个超步四个动作:选出本步要跑的任务 → 并行执行(写入暂存不生效)→ 栅栏处批量合并、改动的通道升版本号 → 存盘。
决定四:版本号一个机制干三件事
这是这一家最精巧的一处。 存盘的快照只有三样东西:
| 字段 | 装什么 | 为什么需要 |
|---|---|---|
| 各通道当前值 | 状态 | 恢复时重建 |
| 各通道的单调递增版本号 | 进展到哪 | 判断进度 |
| 每个节点见过的版本号 | 哪个节点还欠跑 | 判 断该跑谁 |
(依据:前沿库 · LangGraph · 持久化、中断与恢复 —— Checkpoint 只存 channel_values / channel_versions / versions_seen 三件套,「谁还该跑」= 存在某 channel 其 channel_versions 大于该节点的 versions_seen)
关键洞察(它自己写的):恢复一次运行不需要任何内存状态。 "谁还该跑"就是一个比较:某个通道的当前版本号 > 这个节点见过的版本号。
这条对我们直接有用。 我们的最小循环也需要回答"下一轮该不该继续",大多数实现是靠"这一轮有没有工具调用"。 langgraph 把它变成了一个跟具体业务无关的版本号比较——同一个机制还顺便回答了"崩了从哪续"和"该不该在这停下"。
递归上限的本质: 它就是"最多允许多少个超步",防止图无限循环。
决定四:中断靠"抛异常暂停 + 整个节点重放" —— 代价写得最诚实
在节点里调一个中断函数就能暂停整张图、把问题抛给客户端,等人答了再续。实现是:
第一次执行到这里 → 抛异常,整个图暂停
⋯ 人给出答案 ⋯
恢复时 → 从「节点开头」重新执行整个节点
→ 再执行到中断点时,发现已经有答案了,直接返回答案,不再抛异常
最重要的坑,它写在文档原文里:
- 中断之前的代码会再跑一遍。 所以中断之前不要做不可重复的副作用——发邮件、扣款、写数据库,会做两次。
- 一个节点里多个中断调用,靠调用顺序匹配答案;别在中断之间放条件分支改变调用次数。
(依据:前沿库 · LangGraph · 持久化、中断与恢复 —— interrupt 靠抛异常暂停、恢复时从节点开头重新执行整个节点,所以中断之前的代码会再跑一遍,不可重复的副作用(发邮件/扣款/写库)会做两次)
它为什么接受这个代价,理由写得很清醒: 节点函数是普通函数,没法在函数中间冻结调用栈。重放整段是用"幂等假设"换来了实现简单 + 完全靠快照恢复。
这一条要抄进配方,而且是作为分歧点抄:
- langgraph:重放整段,要求节点幂等;
- openai-agents-js / rig:把状态设计成可序列化的,不重放,从中 断点精确续跑。 后者不要求幂等,但要求"状态里的一切都能序列化"。这是一个真正要自己拍板的选择。
决定四补充:存盘时机三档
| 档位 | 何时存盘 | 取舍 |
|---|---|---|
| 同步 | 下一步开始前存完 | 最安全,最慢 |
| 异步 | 下一步执行的同时存 | 平衡 |
| 退出时 | 只在图跑完时存一次 | 最快,崩了丢中间步 |
(依据:前沿库 · LangGraph · 持久化、中断与恢复 —— Durability 三档 sync/async/exit,取舍是安全 vs 吞吐,agent 每步都很贵且不可重放时选 sync)
它给的选型口径:agent 每步都很贵且不可重放时选"同步";快速短任务可选"退出时"。
决定四再补充:静态断点也靠版本号
除了在节点里主动中断,还能在编译时指定"在某个节点之前/之后停"。判定逻辑同样是版本号比对:自上次中断后有没有新进展,有才停。 (依据:前沿库 · LangGraph · 持久化、中断与恢复 —— 静态断点 interrupt_before/after 的判定也是版本号比对——自上次中断后有新进展才停,命中就抛 GraphInterrupt)
一条对话/一次运行的主键是一个线程 id。 不给这个 id,持久化层就无法存也无法恢复。同一个 id 的状态跨多次调用累积——这就是"长期记忆"在这一层的实现。
它没回答什么
- 工具怎么定义、怎么执行——这两篇讲的是引擎,不是 agent 语义。
- 历史怎么压——不管。
- 不用原生工具调用怎么办——不在这一层。
坑与代价
- 中断前的副作用会重复执行(见上)。这是这条路最大的代价。
- 快照格式有版本迁移。 旧格式的快照在加载时会被改写通道命名。这是上游演进留下的兼容层——说明快照格式不是一开始就定对的。
- 文档里的版本号写错了。 快照格式版本当前是 3,但文档字符串里仍写着 1,是上游没及时更新。
(依据:前沿库 · LangGraph · 持久化、中断与恢复 —— Checkpoint 格式版本当前为 3(证据是迁移码 if checkpoint["v"] >= 3: return),但 docstring 原文仍写 "Currently 1",是上游未及时更新的过时文字)
这条本身就是个教训:文档里的数字会撒谎,以代码为准。 我们的配方卡里写数字时要注明它是从哪读出来的。
- 整套机制的门槛是"必须配持久化层"。 没有持久化就没有中断——没有现场可恢复。
判断(无锚): 我们的最小原型不上这一层。四个决定里它只回答了"什么时候停"的一小部分,却要先接受"通道/版本号/快照/线程 id"四个概念。 如果错,会错在: 如果第一版就要求"人在中途批准 + 关掉再打开还能接着跑",那自己搓一遍等于把 langgraph 重写一遍,不如直接用。