
刚接触 LangGraph 的很多人会把 node 想成普通函数把 invoke 想成一个 for 循环。直到某一天你发现两个并行节点明明写的是同一条状态结果却是互相覆盖你在一个节点里调用 interrupt() 想等用户干预结果 resume 之后前面的节点居然又跑了一遍你明明只连了两条边图却在日志里出现几十次 step。遇到这些情况如果只看图定义根本定位不出问题必须往下钻到执行引擎里去看。LangGraph 的执行引擎核心就是这个 PregelLoop 类——你可以把它理解成一台不断重复“接收消息、唤醒节点、收集更新、应用状态”四个动作的循环泵。这篇文章我就带你把这一台泵整个拆开讲清楚它为什么是执行引擎的心脏以及理解它之后能帮你解决哪些真实项目里的疑难杂症。1. 为什么先聊 PregelLoopLangGraph 的执行顺序与你的直觉无关1.1 从 LangChain 到 LangGraph换了的是执行模型很多人是从 LangChain 转到 LangGraph 的所以容易带着“链”的思路来理解图。LangChain 里的 Chain 本质是一条顺序执行链上一步的输出传给下一步流程是单向的、可预测的。LangGraph 虽然也叫“Graph”但它不是给函数加了个图的前端包装而是彻底换了一套执行模型。LangGraph 底层跑的是一个基于 Google Pregel 论文思想实现的消息传递引擎这个引擎的核心就是一个叫 PregelLoop 的类。你定义一个StateGraph加上add_node、add_edge最后调用graph.invoke(...)时LangGraph 会把整张图编译成一个Pregel实例然后再由Pregel在内部实例化 PregelLoop 来真正驱动每一步执行。也就是说你写的节点函数、边条件、状态定义在编译之后都会变成 PregelLoop 手里的“素材”而不是直接被顺序调度。这也是为什么 LangGraph 能支持循环、条件分支、并行、人工中断、断点恢复这些 Chain 很难做干净的能力——因为它的执行模型本来就是为这类有状态、有循环、有并行同步的图计算设计的。1.2 Pregel 模型速成BSP 批同步并行一个 super-step 就是一次同步屏障Pregel 这个名字不是 LangGraph 原创的它来自 Google 在 2010 年公开的分布式图计算模型全称是 Pregel: A System for Large-Scale Graph Processing。Google 当时面对的是数亿节点、数十亿边的网页图计算传统 MapReduce 在迭代式图算法上效率太低于是设计了一个基于“批量同步并行”Bulk Synchronous Parallel简称 BSP的模型。BSP 的核心思想是整个图计算被拆成很多个“超级步”super-step每个超级步内部所有活跃的节点并行执行超级步结束时所有节点进行一次全局同步把各自产生的消息汇总并传递给下一超级步的节点然后进入下一轮。LangGraph 的 PregelLoop 继承的正是这个模型只是把“分布式机器上的节点”换成了“你定义的函数节点”把“网络消息”换成了“状态通道里的增量更新”。你可以这么理解一轮super-step对应 PregelLoop 里的一次完整迭代。每个节点在同一轮 super-step 里并行执行互相对本轮其他节点的写入一无所知。本轮结束后所有写入被统一应用到状态通道形成“同步屏障”。下一轮开始时节点只能看到屏障之后的最新状态。这个“同一步内并行、步与步之间同步”的机制就是 LangGraph 执行顺序和直觉不一致的根本原因。1.3 节点不是被“调用”的而是被消息“唤醒”的这是另一个容易产生误解的地方。你可能会觉得图里有一条边从 A 到 B所以 A 执行完就会直接调用 B。但在 PregelLoop 眼里A 和 B 之间的关系不是“调用”而是“消息传递”。A 执行后它的返回值会被写到某个 channel状态通道上PregelLoop 检查这个 channel 的更新发现 B 订阅了这个 channel于是在下一轮 super-step 里生成一个 B 的 task把 channel 的新值作为输入唤醒 B。如果 B 没有订阅任何被更新的 channel那么即使图定义里有边它也不会被执行。这个地方就是很多诡异 bug 的来源。比如你写了一个节点它既没有返回状态也没有更新任何被下游订阅的通道那么下游节点永远不会被触发。反过来如果一个节点订阅了某个通道而这个通道在本轮被两个并行来源同时更新那它收到的输入是两个来源合并后的结果而不是你以为的“只来一次”。理解了“消息唤醒”和“超步同步”之后再去读 PregelLoop 的初始化、run 方法、状态提交你就不会迷路了。2. 类初始化拆箱PregelLoop 新建时身上绑了哪些东西2.1 从 Graph 到 PregelLoop调用链与实例化时机先说清楚 PregelLoop 是什么时候被创建的。当你写好一个StateGraph并调用compile()时LangGraph 会生成一个CompiledStateGraph在内部本质上是Pregel实例。这个 Pregel 实例持有编译后的进程表processes、通道集合channels、检查点器checkpointer等元信息。而每一次真正的图执行——不管是invoke、stream还是异步ainvoke——都会在内部创建一个新的 PregelLoop 对象。也就是说PregelLoop 不是全局单例它是“每一次运行一个实例”的执行态对象。这是它和编译后的 Pregel 的重要区别Pregel 是静态结构PregelLoop 是动态执行上下文。创建一个 PregelLoop 时构造函数会接收一大串参数核心签名大致是这样的不同版本字段略有差异机制不变class PregelLoop(abc.ABC): def __init__( self, step: int, processes: dict[str, PregelNode], channels: dict[str, Channel], check_pointers: dict[int, Checkpointer], config: RunnableConfig, store: Optional[BaseStore], checkpointer: Optional[BaseCheckpointSaver], input_channels: Union[str, Sequence[str]], output_channels: Union[str, Sequence[str]], stream_channels: Optional[Union[str, Sequence[str]]], inspect: Optional[Callable], get_state: Optional[Callable], tasks: Optional[list[PregelTask]], map_input_keys: Optional[Callable] None, ) - None: ...看到这么多参数不要慌。真正要理解的是四个概念进程表、通道、检查点、配置。2.2 核心字段一览processes、channels、checkpointer、config、store先看processes。它不是操作系统里的进程而是你图上所有节点的“注册表”是一个dict[str, PregelNode]key 是节点名称value 是编译后的节点对象。每个 PregelNode 里记录了三样关键信息节点函数本身、它订阅了哪些通道triggers、它需要被传入哪些输入通道。然后是channels它是 LangGraph 状态管理的核心。你在StateGraph里定义的State会被编译成一个个 channel类型通常有LastValue和Topic两种。LastValue通道保存的是最新值后写覆盖先写Topic通道允许追加适合用来保存消息列表这类需要累积的状态。PregelLoop 每轮迭代的“状态”实际就是这些 channel 当前持有值的集合。checkpointer是检查点器负责在每个 super-step 结束时把状态写入持久化存储。没有它就没有断点续跑、没有 interrupt 恢复、没有时间旅行get_state / update_state。它的存在与否会直接改变 PregelLoop 的恢复逻辑。config是运行时配置里面带着thread_id、checkpoint_id、recursion_limit、tags、metadata这些控制执行行为的参数。PregelLoop 每轮读取 config判断当前是否有检查点要恢复、是否要处理 interrupt、是否达到最大步数限制。最后是store可选的长期记忆存储。它不参与每轮状态同步但会在节点执行时作为参数传入用来读写跨对话、跨图运行的长期数据。2.3 三个输出配置input_channels / output_channels / stream_channels这三个配置容易混刚开始我也经常搞不清。input_channels决定用户调用invoke时输入数据会写到哪些通道。比如你的状态里有messages和query两个字段你只想从messages进入那 input_channels 就是[messages]。output_channels决定图执行结束后返回给调用方的最终结果来自哪些通道。默认情况下返回状态的全部但某些场景你可能只关心最后一条消息就可以收窄输出。stream_channels是流式输出的过滤通道。你用graph.stream(...)时PregelLoop 会根据它来决定把哪些通道的更新以事件形式吐出来。如果你只想流式看到messages的变化而不想看中间状态在这里限制就好。这三个配置本质上是在同一个状态存取矩阵上做了不同维度的“投影”理解了它们你就知道 PregelLoop 在执行时哪些值该进、哪些值该出、哪些值该被外部看到。3. run() 的主循环一轮 super-step 的四段式完整生命3.1 自举run_input 如何把用户输入塞进 channelPregelLoop 开始执行时第一件事是调用run_input()。这个方法做的是“自举”把用户传入的输入数据映射到input_channels生成第一轮 super-step 的初始消息。具体来说它会做这么几件事如果传入了 checkpointer并且当前thread_id下已经有中断/检查点那么它要从保存的状态里恢复通道值而不是直接用新输入覆盖。如果有resume参数也就是你对上一个 interrupt 的应答它会读取中断信息拼接进状态。把原始输入通过map_input_keys映射成dict[channel, value]格式。把这些值包装成初始消息供后续生成第一批 task。注意run_input不是简单地把 dict 塞进去就完事。LangGraph 会区分“完全初始化”和“从检查点恢复”两种路径它们的通道版本号处理方式完全不同。一个常见问题是你用invoke第二次带着同一个thread_id调用时如果你没有处理 interruptPregelLoop 会从上次中断点之后继续而不是从头执行。很多人在这里发现“节点怎么没跑”或者“状态怎么还是旧值”原因就是 PregelLoop 走了恢复路径忽略了新的输入。3.2 并行前奏task 的生成与缓存run_input之后PregelLoop 进入run_step(step, _inputs)。这一步首先会为当前 super-step 生成 task 列表。一个 task 可以理解为“某一个节点的一次执行机会”它包含节点名、节点函数、输入值、通道版本快照。生成 task 的规则是遍历processes里的所有节点检查每个节点订阅的通道triggers里有没有在本轮携带了更新消息。有就生成 task没有就跳过。这里就要提到 PregelLoop 里的 task 缓存机制了。PregelLoop 内部会维护一个_tasks属性用于标记当前 super-step 的任务列表。如果已经解析过并且结果有效它就不会重新生成。你可能会在日志里看到“空转”的 super-step也就是存在可见的 task但所有 task 都标注为已完成或无需执行。这种情况通常是因为节点订阅了 A 通道而 A 通道虽然有更新但更新的通道版本和节点上次执行时看到的版本一致PregelLoop 判定无需重新唤醒。理解了这个你就知道为什么有些节点明明连了边却不按你预想次数执行。3.3 节点执行的主战场process_messages 与 compute_updatestask 生成后进入compute_updates。在源码的注释里PregelLoop 把一轮 super-step 归纳成四个阶段before_update、process_messages、compute_updates、post_updates。其中真正执行节点函数的是中间两个。process_messages的职责是逐条处理 task 消息把每个节点的输入消息准备好并依次唤醒节点。在需要并行的地方它把 node 调用提交进线程池。每个节点在线程里收到的输入是本轮 super-step 开始时对应当前通道值的一个“快照”而不是实时读取共享状态。这是 Pregel 模型的关键保证。节点执行完成后返回的结果会变成一组“写入操作”write形如(channel_name, value)。这些写入不会立刻应用到状态通道而是先进入一个 pending 队列。compute_updates会等待线程池里这一批 task 全部结束这就是同步屏障然后把所有 pending 写入收集成updates。这也是为什么同一轮 super-step 里的两个并行节点互相看不到对方的写入它们读到的都是本轮开始时的快照即使 A 执行得再快B 也无法在本轮读到 A 的新值必须等下一轮。3.4 状态落盘post_updates 与 checkpoint 写入updates收集完成后post_updates开始工作。它是本轮 super-step 的“收口阶段”要做三件事第一把updates里的每个写入通过Channel.update()正式应用到对应通道。LastValue通道直接覆盖Topic通道做追加合并。这一步执行完LangGraph 的状态才算真正变了。第二如果配置了 checkpointer立即把当前通道值、通道版本号、节点执行元数据一起写入检查点。注意检查点是在每个 super-step 结束时写入的不是在整个图结束后才写。这也是为什么 interrupt 恢复能做到“精确到步”的原因。第三根据更新后的通道值决定下一轮要不要继续生成新 task。如果本轮有 channel 更新且更新触发了某些节点的订阅那 PregelLoop 就会走下一轮 super-step如果所有节点都消费完没有新任务循环终止。post_updates还有一个容易被忽略的职责产生流式事件。你调用graph.stream()时看到的事件本质就是 PregelLoop 在post_updates阶段把通道更新按stream_channels过滤后吐出来的。事件里的step字段就是当前 super-step 的编号。4. 消息传递与并发调度一个 state 是怎么被多个节点同时改写的4.1 Pregel 消息结构进程间只认增量不认全局PregelLoop 的消息传递不传输整个状态只传“增量”。这是分布式图计算里的经典设计如果每个节点间都传全量状态网络和内存都会被撑爆传增量则每个节点只需要关心自己订阅的那部分变化。映射到 LangGraph 里增量就是 channel 上的写入项。一个节点更新state[messages]时它实际只是在messages这个 Topic 通道上追加了一条新消息。这不会广播给所有节点只有订阅了messages通道的节点会在下一轮 super-step 被唤醒。所以在调试时不要让节点订阅__all__或订阅所有通道否则任何状态变化都会唤醒它图会变成广播风暴既浪费算力也会让执行日志变得不可读。4.2 并行执行与并发合并LastValue 覆盖和 Topic 追加的实际行为PregelLoop 并发执行的真实语义可以用一个例子说清楚。假设你有一个状态class GraphState(TypedDict): count: int logs: Annotated[list[str], add_messages]图里有 A、B 两个并行节点分别做{count: 1, logs: [A done]}和{count: 100, logs: [B done]}。因为count是LastValue通道这一轮 super-step 里 A 和 B 的两个写入最终只会保留一个具体保留哪个取决于内部写入顺序而不是你以为的“两者相加”。如果你想让两个并行节点对同一个数值做合并就必须给count配一个 reducer 函数类似add把它变成类似 Topic 的累积行为。logs是追加型通道所以 A 的 “A done” 和 B 的 “B done” 都会被保留。但要注意如果 A 在写日志时把自己读到的旧logs整体返回而不是只返回新增项那么它的写入行为会和追加语义叠加导致重复追加。这是 LangGraph 里最常见的并发返回值错误。4.3 需要注意的并发边界同一步内节点互不可见 updates这句话值得单独拿出来强调因为它是 PregelLoop 最容易踩坑的地方。同一轮 super-step 内节点函数读到的 state 是“本轮开始时”的快照。A 节点执行过程中即使 B 节点已经写完并落库了A 节点依然读不到 B 的新值。只有等下一轮 super-step所有写入被 post_updates 应用后A 才能读到 B 的影响。这个特性既是优点也是坑。优点是它天然避免了死锁你不需要在节点里加锁处理并发共享变量缺点是如果你的业务依赖“A 先执行B 的结果立刻可见”那么你必须在图设计上把 B 放在 A 的前序 super-step 里或者用边把两者串成严格的先后顺序。我在实际项目里见过一个很典型的 bug一个节点内部用threading等待另一个并行节点更新状态结果永远等不到因为 PregelLoop 并行调度根本不保证这一步内能看到其他节点的写入。正确的做法是把“等待另一个节点”改成图上的条件边让 PregelLoop 在下一次 super-step 自然唤醒。5. 中断与恢复为什么 PregelLoop 能让 Agent 在“半路”等人类批准5.1 interrupt 机制的执行路径从节点内中断到 GraphInterrupt 抛出人工中断是 LangGraph 做 Agent 落地时最常用的功能之一典型场景是Agent 规划完要不要执行某个动作先停下来等管理员确认管理员点击“同意”之后图继续往下跑。它的执行路径其实是一条“由内而外再回到内”的链路节点函数里调用interrupt(payload)或返回Command(resume...)。这个调用会抛出GraphInterrupt异常。包裹节点执行逻辑的线程捕获异常把中断信息作为特殊写入项加入 pending 队列。PregelLoop 在compute_updates阶段发现了这个中断写入停止继续扩展新的 task。post_updates把中断状态和当前通道一起写入 checkpoint。PregelLoop 终止循环invoke调用方向外抛出Interrupt异常。这时候你手上的result不是最终结果而是一个中断信号。你拿到它保存数据等待用户审批完成再用graph.invoke(Command(resume用户审批结果), config)把它喂回去。5.2 resume 与恢复执行PregelLoop 怎么判断该重跑还是回放这是 PregelLoop 里最巧妙的地方。你用resume重新调用时它的目标不是重新执行整个图而是“恢复到中断点然后只执行中断点之后的节点”。为了做到这一点PregelLoop 依赖两个东西checkpoint 里的通道版本号和 task 执行记录。它从 checkpoint 恢复当时的通道值。检查是否有未完成/挂起的中断 task。如果在当前配置里找到了对应resume值就把这个值注入到待执行的 task 输入里而不是从入口重新输入。这个机制在源码里表现为“replay”逻辑。你可以把 PregelLoop 的恢复想成一场电影检查点记录了电影看到第几分钟时的画面和线索你从这一分钟继续播放而不是把开头重新看一遍。很多新手在 resume 时遇到的“重复执行”问题其实是因为调用的 config 里thread_id变了或者 checkpointer 没配好导致 PregelLoop 找不到旧检查点只好从头开始。所以 resume 时一定要复用之前的thread_id。5.3 恢复时常见的坑checkpoint_id、版本号与重复写入恢复执行最容易出问题的三个点我按踩坑频率排一下。第一checkpoint_id不一致。PregelLoop 在每次读检查点时会使用 config 里的checkpoint_id定位到具体历史版本。如果你在恢复时手动传了一个新的checkpoint_id它会读不到旧状态退化成好的旧路。大多数情况下你不需要传checkpoint_id只要传thread_id让它自动取最新检查点就行。第二节点重复写入。有些节点在恢复之后会把自己在中断前写入过的 channel 重新写一遍。如果 checkpointer 的版本机制没有把这次写入识别为“新写入”就可能出现日志重复或消息列表重复追加。解决办法是在节点里做幂等设计或者尽量用Command(resume...)来显式触发恢复避免依赖节点内部的状态判断。第三recursion_limit被恢复流程耗尽。恢复执行时PregelLoop 会把历史 step 数计入限制。如果最初的图已经跑了很深的循环恢复之后可能还没跑两步就触达recursion_limit上限报出 “Recursion limit reached”。实战里可以在恢复调用时把recursion_limit调大。6. 调试与业务落地把 PregelLoop 知识用到真实的 Agent 开发中6.1 Stream 事件与三个阶段怎么对应你平时 debug 用的看点PregelLoop 的知识不能只停留在源码阅读层面它最大的价值是帮你解释调试里看到的日志和事件流。当你用graph.stream()时事件大致分为几类updates事件对应post_updates阶段的通道更新结果会列出本轮有哪些节点更新了哪些通道。values事件对应每次 super-step 结束后的最新状态快照。__interrupt__事件中断触发时由 PregelLoop 吐出的特殊事件里面就是interrupt()传入的 payload。建议把这三个事件的逻辑对齐到你脑子里 PregelLoop 的四个阶段上。看到__interrupt__你要能立刻想到节点函数里抛了GraphInterrupt本轮 super-step 在compute_updates阶段被叫停了。看到values里的重复消息你要想到post_updates把 Topic 通道做了追加合并节点可能没有遵循“只返回增量”的惯例。再分享一个实用技巧调试并行节点时给每个节点在返回的 state 里塞一个时间戳字段用write事件的 channel 名称就能看到这样你能直观地看到同一轮 super-step 内不同节点的实际执行顺序从而确认“并行是否真的起来了”。在很多场景下你会发现PregelLoop 的并行并不等于业务的“全并行”它只是把当前步骤的所有 task 并发丢进线程池而后面的条件边会自然串行化。6.2 超时、递归限制与死循环三个容易混淆的控制项很多人分不清 LangGraph 里的timeout、recursion_limit和step步数限制。PregelLoop 的执行是一个 while 循环循环的出口有三类条件没有新的 task 产生正常收敛。达到recursion_limit上限抛出异常。触发 interrupt提前挂起。recursion_limit控制的是 super-step 的迭代次数上限不是节点函数执行的超时时间。一个节点函数如果写了一个while True它不会触发recursion_limit而是会在线程池里卡住——因为 PregelLoop 只能管到“节点函数返回之后”的调度管不到节点函数内部的死循环。节点函数的耗时控制你需要自己做或者依赖底层的 LLM 客户端超时。如果你真的担心整轮执行时间失控可以在外层给它加asyncio.wait_for或用一个 watchdog 线程PregelLoop 本身不会为单个节点函数设默认超时。这里也有一个经验不要把recursion_limit调得过大来掩盖图逻辑缺陷。正常 agent 图的 super-step 次数通常是个位数或十几如果你发现自己需要把它调到 50 以上才能跑通那大概率是条件边写错了导致节点在 A/B 之间无限互踢。6.3 我的三个实操建议如何把 PregelLoop 的规律用到生产里第一个建议把状态变更的“归属权”设计清楚。既然 PregelLoop 用 channel 更新来唤醒节点你就应该尽量让每个节点只更新自己负责的通道避免多个节点争抢同一个LastValue通道的写入权。并行分支需要合并时专门设计一个 reducer 或者一个“汇聚节点”而不是让每个分支都直接写共享字段。第二个建议检查点写入要当成业务事件来设计。因为 PregelLoop 在每个 super-step 结束都会写一次 checkpoint如果你在图上挂了非常长的消息列表或者每个节点都往 state 里塞大对象checkpoint 写入开销会线性增长。生产里建议每条消息只保留必要的字段大块内容放store长时存储不要全塞进通道。第三个建议用Command机制做显式控制少依赖节点内部副作用。LangGraph 的Command(resume...)、Command(goto...)其实都是在 PregelLoop 层面对任务调度做干预。显式使用这些机制比在节点里偷偷修改某个全局变量要可靠得多——因为 PregelLoop 本身并不认全局变量你改动的东西如果不通过 channel 写入它就永远不会成为下一次 super-step 的输入。最后再说一个我自己的经验。每次觉得 LangGraph 行为“玄学”的时候先别看业务代码直接用最小复现跑一遍stream看它每一步的step编号、updates事件和values快照。PregelLoop 的调度规律非常死板——它不会听你的直觉不会猜你的意图它只会按照“消息 → 唤醒 → 计算 → 提交”的循环一直泵下去。你接受了这一点把它当成一台机械泵来对待调试 LangGraph 的信心会大增。