2.1 图执行器 (Graph Executor) 机制


2.1 图执行器 (Graph Executor) 机制

编译之后图去哪了

本节在全册位置:揭开第二章的运行时面纱。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 分发,把并行意图写得更明白。

02-01-fig01

工程清单

  • 执行器维护待运行集合,节点就绪条件是入边来源都已执行,条件边按路由结果动态加入。

  • 超级步进让无依赖节点并行,但不能假设它们之间的顺序,有顺序要求必须显式画成边。

  • 循环不会死机,因为每次循环都从状态重新判断退出条件,这是图能表达环的安全阀。

常见误区

以为并行分支天然有序,结果落库顺序错乱;有顺序要求的依赖要显式加边,不能靠执行器的并行恰好正确,否则偶发数据不一致最难查。

invoke 与 stream:消费轨迹的两种方式

图执行器不只提供 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) # 每一步的完整状态

把「节点返回什么」和「状态最终变成什么」分开想,是理解执行器最省力的一步:节点只是声明增量,合并语义完全由通道归约器决定。这也是为什么第二章反复强调「通道归约」——执行器本身不猜测你的合并意图。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U