ARTICLE · 1209407
【LangGraph源码精读】第02期|并行执行不冲突?状态副本+确定序合并太巧妙了
【LangGraph源码精读】第02期|并行执行不冲突?状态副本+确定序合并太巧妙了
30篇源码精读系列·第02篇(执行引擎层第二篇)。上一期讲了Pregel BSP模型的概念:Plan→Execute→Update三阶段。这一期深入源码,看这三个阶段在代码里到底是怎么实现的。主循环长什么样?prepare_next_tasks怎么从Channel更新反查出待执行节点?PregelRunner怎么保证并发安全?apply_writes怎么做到确定序合并?读完这篇,你能看懂PregelLoop的每一行核心代码。
一、开头:你的Agent并行执行时,内部到底在跑什么?
你写了一个LangGraph Agent,里面有"搜索"和"代码生成"两个独立节点,配置成并行执行。运行时你看到两个节点几乎同时完成,结果正确合并。
但你有没有想过:
- ● 两个节点同时读同一个状态字段,读到的是同一个值吗?
- ● 两个节点同时写同一个状态字段,谁覆盖谁?
- ● 如果一个节点失败了,另一个已经完成的结果会丢吗?
- ● 为什么同样的输入跑10次,结果永远一样?
这些问题的答案,都在PregelLoop的主循环里。
上一期讲了BSP三阶段的概念,这一期讲源码实现。 概念懂了不代表源码能看懂——很多人看Pregel的源码,看到loop.tick()、runner.tick()、after_tick()这三个方法就懵了,不知道谁先谁后、谁做什么。这篇把主循环彻底拆开。
二、主循环全景:一行代码看懂执行流程
Pregel的执行主循环,核心就是这一段(在pregel/main.py的stream方法里):
关键理解:tick()是"准备",runner.tick()是"执行",after_tick()是"提交"。 三者严格按顺序执行,构成一个完整的Super-step。
这就是为什么LangGraph的执行是确定性的——不管节点并行执行的先后顺序如何,after_tick()总是在所有节点执行完之后才统一合并,合并顺序是确定的。
三、Plan阶段源码:prepare_next_tasks()怎么找出谁该干活?
prepare_next_tasks()在pregel/_algo.py里,是Plan阶段的核心。它的输入是当前Checkpoint和Channel状态,输出是待执行任务列表。
3.1 算法步骤
3.2 PregelExecutableTask里装了什么?
PregelExecutableTask是执行的最小单位,它不是一个简单的函数调用,而是一个包含完整上下文的任务对象:
最关键的设计:input是快照,writes是缓冲区。 节点执行期间,它看到的状态是冻结的(input快照),它产生的输出是隔离的(writes缓冲区)。这两个设计保证了并发安全。
3.3 trigger_to_nodes:编译时构建的触发索引
你在StateGraph里写的add_edge("A", "B"),编译后变成了什么?
节点不是被"调用"的,是被"触发"的。 A执行完写入Channel,Channel版本号变化,下一个Super-step的prepare_next_tasks()发现这个Channel被更新了,通过trigger_to_nodes找到B,把B加入待执行列表。
这就是为什么LangGraph支持循环——A→B→A的边,编译后就是"A的Channel触发B,B的Channel触发A",每一轮Super-step自然形成循环。
四、Execute阶段源码:PregelRunner怎么保证并发安全?
PregelRunner在pregel/_runner.py里,负责Execute阶段的并发执行。它不决定图结构,只负责把已经选好的任务执行起来。
4.1 执行流程
4.2 commit():并发安全的核心
commit()是PregelRunner最关键的方法。它的语义是:节点执行结果不直接修改共享Channel,而是转为task writes,调用loop.put_writes(task_id, writes)。
这就是"状态副本+输出缓冲"的具体实现。 不是什么高深的技术,就是"每个线程写自己的缓冲区,最后统一合并"这个朴素的思想。但它解决了并发安全这个大问题。
4.3 错误处理
重试的粒度是task,不是整个graph。 一个节点失败重试,不会导致同Super-step中其他已成功的节点重新执行。这是BSP模型的另一个优势——失败隔离。
五、Update阶段源码:after_tick()怎么做到确定序合并?
after_tick()在pregel/_loop.py里,是Update阶段的核心。它的执行顺序非常关键,每一步都有严格的先后。
5.1 after_tick()的8步执行顺序
顺序不能乱。 比如Checkpoint必须在apply_writes之后保存——如果先保存再合并,Checkpoint里存的就是旧状态。interrupt_after必须在Checkpoint之后检查——如果先中断再保存,恢复时就找不到中断点了。
5.2 apply_writes():确定序合并的实现
apply_writes()在pregel/_algo.py里,负责把所有task的writes合并到Channel。
确定序合并的关键:按task_id排序遍历。 不管节点执行的先后顺序如何(因为并行),apply_writes总是按task_id的字典序来合并writes。所以同样的输入,永远产生同样的输出。
冲突检测是安全网。 如果两个节点在同一个Super-step里都写了同一个LastValue类型的Channel(覆盖语义),而且没有reducer,apply_writes会直接抛InvalidUpdateError。这不是bug,是设计——它逼着你在设计状态时就考虑好并行写入的合并策略。
5.3 Channel.update():不同Channel类型的合并行为
最常用的是BinaryOperatorAggregate。 比如messages: Annotated[list, add_messages],底层就是BinaryOperatorAggregate,reducer是add_messages。两个节点同时写messages,apply_writes会按确定序依次调用add_messages合并,结果是确定的。
六、恢复和幂等性:put_writes和pending writes
这是很多人忽略但非常重要的机制。LangGraph的持久化不是"每个Super-step保存一次Checkpoint"这么简单,它还有一层更细粒度的pending writes机制。
6.1 两层持久化
为什么需要两层? 因为Super-step是持久化的最小单位——如果一个Super-step里有3个并行节点,其中2个成功了1个失败了,整个Super-step没有Checkpoint。如果只有Checkpoint这一层,恢复时3个节点都要重跑。有了put_writes,恢复时已经成功的2个节点的writes可以从pending writes恢复,只重跑失败的1个。
6.2 恢复时发生了什么?
控制类writes不重放。 ERROR、INTERRUPT、RESUME这些控制信号不能按普通状态写入重放——它们代表的是异常状态,需要重新触发而不是直接恢复。
6.3 节点必须幂等
因为节点可能被重新执行(重试、恢复、时间旅行),所以节点函数必须是幂等的:
这是BSP模型的固有约束。 Super-step是原子单位,节点是可重放的。LangGraph不保证节点只执行一次,它保证的是"Super-step恰好提交一次"。节点层面是"至少一次",所以幂等性是你的责任。
七、常见bug排查指南:6个问题定位到源码
读源码最大的价值是排查问题。以下是6个常见问题,以及对应的源码定位:
这个排查表的价值: 遇到问题不用瞎猜,直接按表定位到具体源码文件和函数,读对应代码就能找到根因。
八、设计权衡:为什么是"准备-执行-提交"三段式?
PregelLoop的三段式设计(tick→runner.tick→after_tick)不是随意的,它是BSP模型的直接实现,但也有明确的权衡。
核心权衡:用内存和等待换确定性和可恢复性。 对于Agent编排来说,确定性比极致性能重要得多——Agent的瓶颈在大模型调用(秒级),不在执行引擎的同步屏障(毫秒级)。
九、面试关联:节点调度的5道高频题
十、本期小结
节点调度源码,核心就6句话:
- 1. 主循环是三段式:tick()准备→runner.tick()执行→after_tick()提交。 对应BSP的Plan/Execute/Update三阶段,严格按顺序执行,构成一个Super-step。
- 2. Plan阶段的核心是prepare_next_tasks()。 它检查Channel版本变化,通过编译时构建的trigger_to_nodes索引反查出待执行节点,为每个节点构造PregelExecutableTask(含input快照、writes缓冲区、config)。节点不是被调用的,是被Channel更新触发的。
- 3. Execute阶段的核心是PregelRunner和commit()。 多任务并发提交,FIRST_COMPLETED等待支持流式输出。commit()不直接修改共享Channel,而是把结果写入task.writes缓冲区——这是并发安全的关键,不需要锁。
- 4. Update阶段的核心是after_tick()和apply_writes()。 after_tick有严格的8步顺序(收集→合并→输出→清空→保存Checkpoint→检查中断)。apply_writes按task_id排序遍历,保证确定序合并;Channel.update()根据reducer合并;冲突检测抛InvalidUpdateError。
- 5. 两层持久化保证细粒度恢复。 put_writes在节点执行完立即保存task级writes,Checkpoint在Super-step结束保存完整快照。恢复时已成功节点的writes从pending writes恢复,只重跑失败节点。但节点必须幂等——LangGraph保证Super-step恰好提交一次,节点是至少一次执行。
- 6. 三段式设计的核心权衡是"用内存和等待换确定性和可恢复性"。 同步屏障让快节点等慢节点,输出缓冲增加内存占用,但换来了100%可复现的执行结果和完善的中断恢复能力。对于Agent编排,确定性比极致性能重要。
十一、下一期预告
下一期(源码03),执行引擎层第三篇,进入Channel系统:
【LangGraph源码精读】第03期|你写的每个State字段,底层都是一个Channel
从LastValue到BinaryOperatorAggregate,从Topic到DeltaChannel,5种Channel类型逐个拆解,搞懂reducer和版本号是怎么驱动整个执行引擎的。
你在使用LangGraph时遇到过并行写入冲突或状态合并的问题吗?评论区聊聊,我优先写你最关心的场景。
本文为《LangGraph源码精读30讲》系列第02篇。内容基于LangGraph v1.2.11源码结构梳理,核心文件位于libs/langgraph/langgraph/pregel/目录下的_loop.py、_algo.py、_runner.py。版本更新后部分细节可能变化。如有错漏欢迎指正。















































































