第 3 章 Pregel/BSP 计算模型
3.1 历史背景
Pregel 是 Google 于 2010 年发表的大规模图计算框架论文《Pregel: A System for Large-Scale Graph Processing》,其底层采用 BSP(Bulk Synchronous Parallel,整体同步并行)计算模型。BSP 由 Leslie Valiant 于 1990 年提出,目标是为并行算法提供一个不依赖具体硬件拓扑的桥接模型:一次计算被切分为若干个超步(Superstep),每个超步包含本地计算、通信、屏障同步三个阶段,所有并行单元在屏障处对齐后才进入下一个超步。
LangGraph 将该模型从大规模离线图计算迁移到 Agent 运行时:每个节点(Node)类比为 Pregel 中的顶点(Vertex),Channel 类比为顶点之间传递消息的边与信箱,超步对应运行时的一轮调度,Checkpoint 对应超步结束时的全局快照。这种映射使 LangGraph 天然具备确定性执行、可快照、可中断恢复的特性。
3.2 BSP 三阶段
每个 Superstep 严格按以下顺序执行:
- 本地计算(Local Computation):每个被调度的顶点/节点基于上一个超步结束后的全局状态执行自身逻辑,产生要发送给其他顶点的消息/写入。在 LangGraph 中,该阶段对应节点函数执行,返回一个增量字典。
- 通信(Communication):节点产生的写入被放入一个待提交缓冲区(pending writes),按 Channel 的路由规则进入目标通道的暂存区。此阶段写入对其他正在同一超步执行的节点不可见。
- 屏障同步(Barrier Synchronization):运行时等待所有本轮被调度节点执行完毕,随后按 Channel 规则将待提交缓冲区合并到真实状态,生成新的全局状态版本;若配置了 Checkpointer,则写入快照;最后根据更新后的状态与边规则计算下一个超步要调度的节点集合。
关键点:本 Superstep 的写入对本轮其他节点不可见,必须等屏障同步后才在下一超步可见。这是 BSP 模型与函数嵌套调用模型最本质的差异。
3.3 LangGraph 对 BSP 的映射
| Pregel/BSP 概念 | LangGraph 对应 |
|---|---|
| Vertex(顶点) | Node(通过 add_node 注册的可调用对象) |
| Message(消息) | Channel(State 的字段,承载节点间数据流) |
| Superstep(超步) | 一轮执行(tick/atick),从 Plan 到 Update 完成 |
| Barrier(屏障) | Update 阶段的同步点,等待所有并发节点完成 |
| Vertex Value(顶点值) | Channel 当前值(State 字段的当前值) |
| Outgoing Message(出边消息) | 节点返回的增量字典中的字段值 |
| Checkpoint(检查点) | 超步末写入 Checkpointer 的状态快照 |
LangGraph 运行时将三阶段具体化为 Plan → Execute → Update:
- Plan 阶段:读取上轮结束后的 Channel 状态,沿普通边前进,在条件边处调用 path 函数根据已更新状态做路由决策,得出本轮要执行的节点集合;
- Execute 阶段:基于 asyncio 并发执行本轮选中的所有节点。每个节点接收的
state都是上一轮结束后的只读快照;节点返回的增量写入 pending writes 缓冲区,不修改真实 State; - Update 阶段:所有节点完成后,对每个 Channel 分别执行合并:LastValue 通道取唯一写入,BinaryOperatorAggregate 通道使用 reducer 将旧值与新写入做聚合,Topic 通道将消息追加到订阅列表。合并完成后将新状态写入 Checkpointer(若有),并进入下一个超步的 Plan。
3.4 双缓冲与可见性
LangGraph 使用**双缓冲(Double Buffering)**机制:状态分为"当前版本"(供节点读取)和"待提交版本"(本轮写入的暂存区)。Execute 阶段所有节点读取的都是上一个超步冻结的当前版本,因此同一超步内两个并发节点 A、B 之间互相看不到对方的返回值,完全消除了"先写入者影响后读取者"的竞态条件。屏障同步点才将待提交版本原子切换为新的当前版本。
该机制有三个直接推论:
- interrupt 只能发生在超步边界。节点函数一旦进入 Execute 阶段就会运行到返回(或抛异常),中途无法被运行时暂停。若在节点内部调用
interrupt(),实际抛出的是一个特殊的GraphInterrupt异常,它会沿着执行栈冒泡到超步边界,由 Update 阶段捕获并保存快照,等待 resume。 - 循环不会导致栈溢出。在 while 循环或递归函数里反复调用自身会压栈,但 LangGraph 的循环是"一个超步结束 → 下一个超步重新调度目标节点",每一轮都是新的调度帧,与函数调用栈解耦。默认 recursion_limit 为 25 超步,超限抛出
GraphRecursionError,而非递归栈溢出。 - 状态变更延迟一个 Superstep 可见。节点 A 在本超步写入字段
x,本超步内与 A 并行的节点 B 读到的x仍是旧值;若需要 B 基于新x执行,必须通过边把 B 放到下一个超步。
3.5 与 LCEL/DAG 执行模型对比
LCEL(LangChain Expression Language)采用的是函数调用栈模型:a | b | c 编译为 c(b(a.invoke(input))) 的嵌套调用,执行路径是深度优先的函数栈。该模型结构简单、延迟低,但无法表达回环,无法在中间任意节点做快照与恢复,流式输出只能沿调用栈向上传递。
LangGraph 采用的是迭代式 Superstep 推进模型:每一轮由调度器决定要执行哪些节点,节点之间通过 Channel 通信,每一轮末都有一个明确的"状态版本号",Checkpoint 按版本号落盘。该模型支持循环、支持任意超步中断/恢复、支持同超步内节点并发,代价是每轮存在屏障同步开销,长尾节点会拖慢整轮。
时间轴 →
Superstep 0 Superstep 1 Superstep 2
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Plan: {A,B} │ │ Plan: {C} │ │ Plan: {D} │
├─────────────┤ ├─────────────┤ ├─────────────┤
│ Execute: │ │ Execute: │ │ Execute: │
│ A ──┐ │ │ C reads │ │ D reads │
│ ├─►buf │ │ state_v1 ──┼─►buf │ state_v2 │
│ B ──┘ │ │ │ │ │
├─────────────┤ ├─────────────┤ ├─────────────┤
│ Update: │ │ Update: │ │ Update: │
│ buf → v1 │ │ buf → v2 │ │ buf → v3 │
│ ckpt[v1] │ │ ckpt[v2] │ │ ckpt[v3] │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
▼ ▼ ▼
可见性边界 可见性边界 可见性边界源码入口:Pregel 运行时实现在 langgraph/pregel/ 目录下,对外的核心调度方法为 Pregel.atick(异步)与 Pregel.tick(同步),每次调用对应一个 Superstep 的完整 Plan-Execute-Update 流程。
3.6 BSP 的代价
BSP 模型的工程代价需要明确:
- 屏障同步长尾等待:同一超步内若一个节点是慢节点(如外部 API 耗时 10 秒),其余节点即便毫秒级完成也要等慢节点,影响整轮延迟;
- 状态延迟可见:节点写入无法被同轮其他节点读取,对"边写边读"的细粒度协作需要拆分为多个超步,增加调度轮次;
- 不适合极低延迟场景:在微秒-毫秒级的在线推理链路中,每轮固定的调度与合并开销占比过高,这类场景更适合纯函数链。
本章小结
LangGraph 的执行模型直接继承自 Pregel/BSP:一次图执行由若干个三阶段(本地计算→通信→屏障同步)超步构成,节点在 Execute 阶段基于上一轮只读快照并发执行,写入经待提交缓冲区在 Update 阶段原子合并并快照。双缓冲消除了同轮竞态,也带来了 interrupt 必须在超步边界、循环不走调用栈、状态变更延迟一轮可见等特性。
常见错误
- 误认为同一 Superstep 内节点之间可以互相看到写入。现象:节点 A 写入
x=1后,并行节点 B 读到的x仍是旧值。原因:BSP 双缓冲机制保证同轮隔离。修复:将 B 放到下一个超步(通过边让 B 的前驱是 A),或把需要基于新值计算的逻辑合并到同一节点。 - 误认为
interrupt()能立即暂停当前节点。现象:在节点函数中调用 interrupt 后,后续代码似乎仍在执行或异常栈混乱。原因:interrupt 是抛出特殊异常冒泡到超步边界,并非暂停当前函数的协程。修复:将中断点设计在节点边界——即节点返回后、下一轮 Plan 之前挂起,不在节点中间等待。 - 误认为循环会栈溢出。现象:看到循环边担心深度递归。原因:把图上的环等同于函数递归。修复:理解超步调度模型,循环由递归深度由
recursion_limit参数控制(默认 25)而非 Python 调用栈。
生产建议
- 将外部调用(模型、HTTP、数据库)都实现为 async 节点,避免在 Execute 阶段阻塞事件循环,减少屏障长尾;
- 对耗时长的节点做粒度拆分,让同一超步内节点的耗时尽量相近,缓解长尾等待;
recursion_limit按业务显式设置,对明确知道只需 3–5 轮的图(如重试型循环)设得更小,异常情况尽早失败;- 重要业务流程在每轮 Update 后落 Checkpoint(使用 Postgres 后端),故障恢复时从最近一个版本号 resume,避免重跑已完成步骤;
- 阅读
langgraph/pregel/源码时从atick方法入手,沿 Plan→Execute→Update 三个私有方法追踪,可快速建立对运行时的整体心智模型。
