本节在全册位置:揭开第二章的运行时面纱。StateGraph 只是声明,compile 之后才生成可执行对象。执行器(Executor)负责按拓扑与条件不断挑选「入边已就绪」的节点运行,直到无节点可跑或到达 END。我们读源码时发现,很多人以为图是「依次执行」,其实执行器更接近一个调度循环。
执行器维护一张待运行集合。一个节点就绪的条件是:它的所有普通入边来源都已执行过(条件边按其路由结果动态加入)。这带来「超级步进」——同一层多个无依赖节点可并行。循环不会死机,因为每次循环都从状态判断退出条件。类比物理:执行器像调度台,节点像机床,边像传送带,只有当上游工件到位机床才启动。理解这一点,你才能解释「为什么我的两个节点顺序每次不一样」。
from typing import TypedDict from langgraph.graph import StateGraph, START, END class S(TypedDict): log: list def a(s: S): return {"log": ["a"]} def b(s: S): return {"log": ["b"]} def c(s: S): return {"log": ["c"]} bld = StateGraph(S) bld.add_node("a", a) bld.add_node("b", b) bld.add_node("c", c) bld.add_edge(START, "a") bld.add_edge("a", "b") bld.add_edge("a", "c") bld.add_edge("b", END) bld.add_edge("c", END) g = bld.compile() print(g.invoke({"log": []}))
# 这正是无依赖并行带来的不确定性,需要顺序就得显式加边
背景:a 之后要同时跑 b、c,业务要求 b 先于 c 落库,否则下游读到的数据不一致。
操作:若顺序敏感,显式加 b->c 的边,而不是仅靠都连到 END。
结果:改为线性依赖后顺序稳定,数据库不再出现中间态。
解读:超级步进的并行是性能优化,不能假设顺序;有顺序请画图表达,别靠运气。
变式:真正要并行且无共享写,可放到第五章多智能体或用 Send 分发,把并行意图写得更明白。

执行器维护待运行集合,节点就绪条件是入边来源都已执行,条件边按路由结果动态加入。
超级步进让无依赖节点并行,但不能假设它们之间的顺序,有顺序要求必须显式画成边。
循环不会死机,因为每次循环都从状态重新判断退出条件,这是图能表达环的安全阀。
以为并行分支天然有序,结果落库顺序错乱;有顺序要求的依赖要显式加边,不能靠执行器的并行恰好正确,否则偶发数据不一致最难查。
图执行器不只提供 invoke 一个入口。invoke 一次跑完、只返回最终状态;stream 则逐轮返回中间结果,能观察「每一步发生了什么」。理解两者的差异,等于理解执行器的调度节奏。
# invoke:只看最终结果 out = g.invoke({"log": []}) # {'log': ['a', 'b', 'c']} # stream:逐轮观察调度,能看到超级步进的真实分组 for step, update in g.stream({"log": []}, stream_mode="updates"): print(step, update)
stream 输出会按超级步进分组:同一轮并行执行的节点出现在同一个 dict 里。如果 b、c 是同轮并行,你会看到它们在一个 update 中同时出现。这是验证「我的边真的画对了吗」最快的手段——结构上没连边的节点不会同框出现。
并行不是无条件的,执行器只对「互不依赖」的节点做超级步进。判断哪些节点能并行,看它们的入边来源是否重叠:
| 依赖关系 | 调度结果 | 典型写法 |
|---|---|---|
| 完全独立 | 同轮并行 | 都从 START 引出 |
| 共享同一前驱 | 前驱完成后并行 | a 分出 b、c |
| 串行依赖 | 严格先后 | a -> b -> c |
| 前驱未执行 | 永不调度 | 孤儿节点 |
有一类边界值得单独提醒:即便 b、c 无依赖,只要它们写入同一条覆盖式状态通道,LangGraph 会在归约阶段抛冲突错误。所以「并行」与「共享状态写入」是一对矛盾,要么给通道配归约器,要么用 Send 给每个分支独立的片段状态,后者到 2.3 与 3.5 会展开。
不少读者第一次见到有环图都会担心死循环。执行器对循环有一套保障:每次循环都从状态重新评估条件边,而条件边必须在某个条件下返回 END。下面的代码演示一个带计数守卫的环:
from typing import TypedDict, Annotated import operator class LoopState(TypedDict): step: Annotated[int, operator.add] def work(s: LoopState): return {"step": 1} def still(s: LoopState): # 计数到达 3 就退出,否则继续 return "work" if s["step"] < 3 else END b = StateGraph(LoopState) b.add_node("work", work) b.add_edge(START, "work") b.add_conditional_edges("work", still, {"work": "work", "end": END})
要点:计数必须放在状态里并用 operator.add 累加,而不是写在节点局部——局部变量每次执行都从零开始,守卫形同虚设。把「退出条件」和「工作逻辑」分开画,条件边只做判断,这是执行器语义下最不容易错的环形写法。
调试执行器行为时,记住两个视角:节点的「入参视角」与运行期的「快照视角」。节点收到的 state 是当前快照,而它返回的增量会被归约器合成为新快照;这两个快照之间隔着一道归约,这是所有状态类 bug 的根源。
# 快照视角:每次执行后都能取到完整的中间态 for snap in g.get_state_history(cfg): print(snap.values) # 每一步的完整状态
把「节点返回什么」和「状态最终变成什么」分开想,是理解执行器最省力的一步:节点只是声明增量,合并语义完全由通道归约器决定。这也是为什么第二章反复强调「通道归约」——执行器本身不猜测你的合并意图。