首页/LangGraph 框架指南/第 5 章 并行世界:超步执行模型
LangGraph 框架指南 · 第 05 章

第 5 章 并行世界:超步执行模型

本章目标:建立 LangGraph 执行模型的"节拍器"概念——超步(superstep)。理解它,并行的行为、汇合的时机、recursionlimit 的计数、以及并行写状态的种种规则,全都顺理成章。本章还会处理一个足…

约 17 分钟6,958 字8 个代码示例

本章目标:建立 LangGraph 执行模型的"节拍器"概念——超步(superstep)。理解它,并行的行为、汇合的时机、recursion_limit 的计数、以及并行写状态的种种规则,全都顺理成章。本章还会处理一个足以让生产事故的经典陷阱:汇合节点被执行两次。

5.1 第 4 章留下的悬念

第 4 章末尾的练习里,tech_review 和 background_check 被条件边同时触发。当时留了一个问题:它们谁先执行?

直觉答案是"按注册顺序"或"看运气"。但正确答案是第三种:它们在同一个超步里执行——语义上是同时的。"先后"这个问题本身就不成立:引擎不是把节点排成一条队依次消费,而是把执行切成一轮一轮的节拍,每一轮把"当前可以执行的节点"一起放行。

这个"节拍",就是本章的主角。

5.2 超步:执行的基本节拍

LangGraph 底层引擎 Pregel 的核心循环可以概括成一句话:以超步为单位,循环执行"激活—放行—合并",直到没有可激活的节点。

flowchart TD S0[超步 N 开始<br/>持有一份状态快照] --> S1[确定本轮激活的节点集合<br/>= 上一轮边触发者] S1 --> S2[执行本轮所有节点<br/>各自读取快照、产出补丁] S2 --> S3[超步结束<br/>reducer 统一合并所有补丁] S3 --> S4{还有被触发的节点?} S4 -->|有| S0 S4 -->|无| S5[图运行结束<br/>返回最终状态]

用第 2 章的线性图对照:extract 在超步 1 执行,summarize 在超步 2 执行——线性图每个超步只有一个节点,所以它"看起来"就是顺序执行。一旦出现并行分支,同一超步就会装下多个节点。

三条语义规则值得逐条咀嚼,它们是本章全部内容的地基:

规则一:同一超步的节点,看到同一份状态快照。 超步开始时状态定格,本轮所有节点读到的都是这份快照。

规则二:补丁不在节点返回时立即生效,而是在超步结束时统一合并。 中间存在一个缓冲区,reducer 在超步末尾仲裁。

规则三:下一超步的节点集合,由本超步完成的节点触发的边决定。 触发关系在超步边界统一结算。

这三条规则合起来,就是并行安全的全部秘密:并行节点互相看不见对方的写入,因此不存在"读到一半的中间状态";所有冲突推迟到超步边界,交给 reducer 集中仲裁。

5.3 用实验证明快照语义

空口无凭。设计一个能"抓现行"的实验:hard_review 和 soft_review 并行执行,两者都在开头读取 score 字段。如果合并是即时的,后执行的那个应该能看到先执行者的写入:

from typing import Annotated, TypedDict
import operator
from langgraph.graph import StateGraph, START, END

class ScreenState(TypedDict):
    resume_text: str
    score: Annotated[int, operator.add]           # 总分:累加
    breakdown: Annotated[list[str], operator.add]  # 明细:追加

def hard_review(state: ScreenState) -> dict:
    seen = state.get("score", 0)                  # 读的是超步起始快照
    print(f"[hard_review] 执行时看到的 score = {seen}")
    return {"score": 35, "breakdown": ["硬性条件 +35"]}

def soft_review(state: ScreenState) -> dict:
    seen = state.get("score", 0)
    print(f"[soft_review] 执行时看到的 score = {seen}")
    return {"score": 30, "breakdown": ["软性条件 +30"]}

def report(state: ScreenState) -> dict:
    print(f"[report] 汇总时看到的 score = {state['score']}")
    return {}

builder = StateGraph(ScreenState)
builder.add_node("hard_review", hard_review)
builder.add_node("soft_review", soft_review)
builder.add_node("report", report)

builder.add_edge(START, "hard_review")
builder.add_edge(START, "soft_review")        # 两条入口边:并行扇出
builder.add_edge("hard_review", "report")
builder.add_edge("soft_review", "report")     # 两条入边:汇合
builder.add_edge("report", END)
graph = builder.compile()

graph.invoke({"resume_text": "李思远 ...", "score": 0})

运行输出:

[hard_review] 执行时看到的 score = 0
[soft_review] 执行时看到的 score = 0
[report] 汇总时看到的 score = 65

soft_review 没有看到 hard_review 写入的 35——尽管从打印顺序看它后执行。这就是规则一和规则二在起作用:物理上的执行顺序与语义上的可见性是两回事。即使引擎恰好串行地跑了这两个节点,它们的补丁也被缓冲到超步末尾才合并,所以谁也看不见谁。

三个节点分属三个超步:

flowchart LR subgraph S1[超步 1] H[hard_review] So[soft_review] end subgraph S2[超步 2] R[report] end subgraph S3[超步 3] E((END)) end H --> R So --> R R --> E

顺带解释一个第 3 章的伏笔:为什么并行写同一字段时,默认的"覆盖" reducer 会直接抛错而不是静默覆盖?因为多个补丁在超步末尾同时到达同一个通道,"覆盖"语义无法仲裁"谁是最后一个"——引擎干脆抛出 InvalidUpdateError,错误信息会提示你使用带合并语义的 Annotated 字段。静默覆盖只发生在串行的不同超步之间;同一超步内的多写,覆盖语义必然报错。 这是一道提前暴露问题的好防线。

5.4 汇合:等边,还是等节点

report 有两条入边,它只执行了一次——因为两个上游在同一个超步完成,两条边的触发在超步边界"合流"成一次激活。这是最常见的汇合形态,也是大多数人想要的。

但汇合的真正语义是基于边触发的,不是基于节点集合的:一个节点在每个"有入边被触发"的超步之后都会执行一次。如果两条入边在不同的超步被触发,下游就会执行两次。

听起来抽象,看一个反例就懂了。

5.5 经典陷阱:汇合节点被执行两次

把图改成这样:hard_review 直接连 report,而 soft_review 前面多了一步 background_check(背景核查):

from typing import Annotated, TypedDict
import operator
from langgraph.graph import StateGraph, START, END

class ScreenState(TypedDict):
    resume_text: str
    log: Annotated[list[str], operator.add]     # 执行轨迹:追加

def hard_review(state: ScreenState) -> dict:
    return {"log": ["hard_review"]}

def background_check(state: ScreenState) -> dict:
    return {"log": ["background_check"]}

def soft_review(state: ScreenState) -> dict:
    return {"log": ["soft_review"]}

def report(state: ScreenState) -> dict:
    print(f"[report] 执行,起始 log = {state['log']}")
    return {"log": ["report"]}

builder = StateGraph(ScreenState)
builder.add_node("hard_review", hard_review)
builder.add_node("background_check", background_check)
builder.add_node("soft_review", soft_review)
builder.add_node("report", report)

builder.add_edge(START, "hard_review")
builder.add_edge(START, "background_check")
builder.add_edge("hard_review", "report")        # 短路径:直达
builder.add_edge("background_check", "soft_review")
builder.add_edge("soft_review", "report")        # 长路径:晚一个超步到达
builder.add_edge("report", END)
graph = builder.compile()

result = graph.invoke({"resume_text": "..."})
print(f"最终 log = {result['log']}")

运行输出:

[report] 执行,起始 log = ['hard_review', 'background_check']
[report] 执行,起始 log = ['hard_review', 'background_check', 'report', 'soft_review']
最终 log = ['hard_review', 'background_check', 'report', 'soft_review', 'report']

(第二次执行时看到的 'report' 与 'soft_review' 是超步 2 两个节点补丁的合并结果,二者的先后取决于引擎的合并顺序。)

report 执行了两次。逐步还原超步现场:

超步 执行的节点 触发的边
1 hard_review、background_check hard→report、background→soft
2 report(第一次!)、soft_review soft→report
3 report(第二次!) report→END

超步 1 结束时,hard_review→report 的边已被触发,report 在超步 2 被放行——此时 soft_review 还没跑。report 并不会"聪明地"等齐所有上游,因为边的触发是独立的,引擎不维护"等待所有父节点"的屏障语义。

这个陷阱的杀伤力在于它藏得很深:图小的时候一切正常(两条路径等长,触发天然合流);某天有人在其中一条路径上插入一个节点(就像本例的 background_check),下游节点就开始悄悄执行两次。如果那个节点是"发送通知邮件",用户就会收到两封。

三种对策,按推荐程度排序:

  1. 路径等长:审视所有汇合结构,确保通往同一节点的各条路径长度一致。适合结构简单的图,是最根本的解法。
  2. 引入同步节点:在汇合前放一个显式的 gather 空节点,让各路径先等长地到达 gather,再由它统一连向下游。本质是人为补齐路径长度。
  3. 下游幂等:让可能被多次执行的节点天然幂等(重复执行无副作用)。这是防御性设计,适合无法改结构的场景,但等于把正确性寄托在纪律上。

5.6 用 stream 观察超步边界

肉眼数超步容易出错,引擎其实愿意"直播"执行过程——stream(第 11 章系统展开)默认就按执行进度吐出事件,是观察超步的最佳工具:

for chunk in graph.stream({"resume_text": "..."}):
    print(chunk)

对 5.3 节的图,输出形如:

{'hard_review': {'score': 35, 'breakdown': ['硬性条件 +35']}}
{'soft_review': {'score': 30, 'breakdown': ['软性条件 +30']}}
{'report': {}}

每个事件对应一个节点的完成,事件按超步顺序吐出。调试并行图时,先跑一遍 stream、数一数事件的分组,超步结构一目了然。本书后面的章节会大量使用这个技巧。

5.7 超步视角下的 recursion_limit

第 4 章说 recursion_limit 限制的是"超步数",现在可以给出精确定义:它限制一次 invoke 里图最多推进多少个超步,默认 25。5.5 节那张表里图跑了 3 个超步,就消耗 3 点限额。

这也解释了为什么把它当"重试次数上限"是错的:图里每多一个并行层、每多一次循环,消耗的超步数都会变。它是整个图推进轮次的预算,不是某个节点执行次数的预算。

常见坑与排错

  1. 并行节点里"读不到兄弟节点的数据"。原因见规则一:同超步节点共享的是起始快照。如果你需要"B 依赖 A 的结果",那它们本来就不该并行——把 A 挪到 B 的上游去。并行的前提正是互相独立。

  2. 汇合节点执行两次(5.5 节主角)。排查手法:用 stream 打印事件流,看可疑节点出现了几次;再对照各条入边路径的长度。修复优先级:路径等长 > 同步节点 > 幂等设计。

  3. 并行写默认通道触发 InvalidUpdateError。报错信息类似 Can receive only one value per step。这不是 bug 是特性——它在逼你显式声明合并语义。给字段加 Annotated[T, operator.add](或合适的 reducer)即可。

  4. 以为并行一定更快。同一超步的节点在语义上同时执行,但物理上受限于引擎的调度方式(同步 invoke 与异步 ainvoke 的并发行为不同)和节点内部是否用了阻塞调用。要拿到真实的并发收益,节点应使用异步实现(async def + ainvoke),或确认引擎对你的执行模式做了并发调度。语义并行 ≠ 物理并行。

  5. 在并行节点里写"互相商量好"的值。比如两个节点各写半份数据、指望对方的那半份已经在状态里——必然落空。并行的分叉点之前,把所有共享数据备齐。

  6. 循环 + 并行叠加时低估 recursion_limit 消耗。一次循环如果内部还有并行层,消耗的超步可能不止 1。调用量上去了,先想想是不是该给循环加业务出口,而不是无脑调大限额。

动手练习

  1. 修改 5.3 节的图:给 soft_review 前面插一个 background_check 节点(什么都不做,返回 {}),观察 report 的执行次数变化。再用"引入同步节点"的方式修复它,用 stream 验证修复后 report 只执行一次。

  2. 设计一个三路并行的评估图(硬性、软性、潜力),三条路径都直接汇合到 total 节点。给三条路径人为制造不等长(一条路径上加节点),验证 total 的执行次数等于"最长路径上入边触发的超步数"。

  3. 思考题:如果把 5.3 节的 score 字段改成默认覆盖语义(去掉 Annotated),运行会发生什么?先推理再实验。(提示:两个补丁同一超步到达同一通道。)

本章速查

概念 一句话
超步 superstep 图执行的基本节拍:激活 → 放行 → 合并
规则一 同超步节点看到同一份状态快照
规则二 补丁缓冲到超步末尾,由 reducer 统一合并
规则三 下一超步的节点集合由本超步触发的边决定
并行扇出 一个节点的多条出边(或条件边多选)触发多个节点同超步执行
汇合语义 基于边触发而非屏障:入边在不同超步触发,下游执行多次
三种对策 路径等长 > 同步节点 > 下游幂等
InvalidUpdateError 默认通道同一超步收到多次写入时的显式报错
recursion_limit 限制一次 invoke 的超步数(默认 25),非节点执行次数
graph.stream 按执行进度吐出事件,观察超步结构的首选工具