第 5 章 并行世界:超步执行模型
本章目标:建立 LangGraph 执行模型的"节拍器"概念——超步(superstep)。理解它,并行的行为、汇合的时机、recursionlimit 的计数、以及并行写状态的种种规则,全都顺理成章。本章还会处理一个足…
本章目标:建立 LangGraph 执行模型的"节拍器"概念——超步(superstep)。理解它,并行的行为、汇合的时机、recursion_limit 的计数、以及并行写状态的种种规则,全都顺理成章。本章还会处理一个足以让生产事故的经典陷阱:汇合节点被执行两次。
5.1 第 4 章留下的悬念
第 4 章末尾的练习里,tech_review 和 background_check 被条件边同时触发。当时留了一个问题:它们谁先执行?
直觉答案是"按注册顺序"或"看运气"。但正确答案是第三种:它们在同一个超步里执行——语义上是同时的。"先后"这个问题本身就不成立:引擎不是把节点排成一条队依次消费,而是把执行切成一轮一轮的节拍,每一轮把"当前可以执行的节点"一起放行。
这个"节拍",就是本章的主角。
5.2 超步:执行的基本节拍
LangGraph 底层引擎 Pregel 的核心循环可以概括成一句话:以超步为单位,循环执行"激活—放行—合并",直到没有可激活的节点。
用第 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——尽管从打印顺序看它后执行。这就是规则一和规则二在起作用:物理上的执行顺序与语义上的可见性是两回事。即使引擎恰好串行地跑了这两个节点,它们的补丁也被缓冲到超步末尾才合并,所以谁也看不见谁。
三个节点分属三个超步:
顺带解释一个第 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),下游节点就开始悄悄执行两次。如果那个节点是"发送通知邮件",用户就会收到两封。
三种对策,按推荐程度排序:
- 路径等长:审视所有汇合结构,确保通往同一节点的各条路径长度一致。适合结构简单的图,是最根本的解法。
- 引入同步节点:在汇合前放一个显式的
gather空节点,让各路径先等长地到达gather,再由它统一连向下游。本质是人为补齐路径长度。 - 下游幂等:让可能被多次执行的节点天然幂等(重复执行无副作用)。这是防御性设计,适合无法改结构的场景,但等于把正确性寄托在纪律上。
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 点限额。
这也解释了为什么把它当"重试次数上限"是错的:图里每多一个并行层、每多一次循环,消耗的超步数都会变。它是整个图推进轮次的预算,不是某个节点执行次数的预算。
常见坑与排错
-
并行节点里"读不到兄弟节点的数据"。原因见规则一:同超步节点共享的是起始快照。如果你需要"B 依赖 A 的结果",那它们本来就不该并行——把 A 挪到 B 的上游去。并行的前提正是互相独立。
-
汇合节点执行两次(5.5 节主角)。排查手法:用
stream打印事件流,看可疑节点出现了几次;再对照各条入边路径的长度。修复优先级:路径等长 > 同步节点 > 幂等设计。 -
并行写默认通道触发
InvalidUpdateError。报错信息类似Can receive only one value per step。这不是 bug 是特性——它在逼你显式声明合并语义。给字段加Annotated[T, operator.add](或合适的 reducer)即可。 -
以为并行一定更快。同一超步的节点在语义上同时执行,但物理上受限于引擎的调度方式(同步
invoke与异步ainvoke的并发行为不同)和节点内部是否用了阻塞调用。要拿到真实的并发收益,节点应使用异步实现(async def+ainvoke),或确认引擎对你的执行模式做了并发调度。语义并行 ≠ 物理并行。 -
在并行节点里写"互相商量好"的值。比如两个节点各写半份数据、指望对方的那半份已经在状态里——必然落空。并行的分叉点之前,把所有共享数据备齐。
-
循环 + 并行叠加时低估 recursion_limit 消耗。一次循环如果内部还有并行层,消耗的超步可能不止 1。调用量上去了,先想想是不是该给循环加业务出口,而不是无脑调大限额。
动手练习
-
修改 5.3 节的图:给
soft_review前面插一个background_check节点(什么都不做,返回{}),观察report的执行次数变化。再用"引入同步节点"的方式修复它,用 stream 验证修复后report只执行一次。 -
设计一个三路并行的评估图(硬性、软性、潜力),三条路径都直接汇合到
total节点。给三条路径人为制造不等长(一条路径上加节点),验证total的执行次数等于"最长路径上入边触发的超步数"。 -
思考题:如果把 5.3 节的
score字段改成默认覆盖语义(去掉 Annotated),运行会发生什么?先推理再实验。(提示:两个补丁同一超步到达同一通道。)
本章速查
| 概念 | 一句话 |
|---|---|
| 超步 superstep | 图执行的基本节拍:激活 → 放行 → 合并 |
| 规则一 | 同超步节点看到同一份状态快照 |
| 规则二 | 补丁缓冲到超步末尾,由 reducer 统一合并 |
| 规则三 | 下一超步的节点集合由本超步触发的边决定 |
| 并行扇出 | 一个节点的多条出边(或条件边多选)触发多个节点同超步执行 |
| 汇合语义 | 基于边触发而非屏障:入边在不同超步触发,下游执行多次 |
| 三种对策 | 路径等长 > 同步节点 > 下游幂等 |
InvalidUpdateError |
默认通道同一超步收到多次写入时的显式报错 |
| recursion_limit | 限制一次 invoke 的超步数(默认 25),非节点执行次数 |
graph.stream |
按执行进度吐出事件,观察超步结构的首选工具 |