并发分支、同步汇总与 State 合并

LangGraph 入门第五课:并发分支、同步汇总与 State 合并

本文配套课程示例:parallel_merge_demo.py。程序对 [2, 3, 5] 求和、求最大值并生成报告,不需要模型服务。

如果熟悉 CompletableFuture、Promise 或并行任务编排,LangGraph 的分支图会让人感到亲切:把工作分给多个节点,等结果齐了再汇总。

但不要把它直接等同于 SQL 的快照隔离,或某一种并发库的具体实现。理解本课更稳妥的入口是 LangGraph 的 super-step(超级步):同一步里调度的节点并行处理各自的状态输入,本步更新在后续步骤使用;需要哪些结果齐全后再汇总,则要通过图的依赖关系明确表达。

Fan-out:让多个分支都进入执行路径

python 复制代码
builder.add_edge(START, "sum")
builder.add_edge(START, "maximum")

从 START 连到两个节点,表示两个任务都要执行。这和条件路由的"选 A 或 B"不同:这里是"既执行 A,也执行 B"。

Fan-in:显式声明汇总要等待谁

python 复制代码
builder.add_edge(["sum", "maximum"], "summarize")

来源列表表示:summarize 要等 sum 和 maximum 都完成后,才运行一次。列表形式的边不是两条独立边的简写。

这一点在分支长度不同时尤其重要。若分别写 add_edge("sum", "summarize") 和 add_edge("maximum", "summarize"),汇总节点可能在不同 super-step 各运行一次。若使用条件分支、并非所有来源都会执行,则列表边会一直等未执行的来源;这种图要根据实际分支设计汇合方式,例如评估 defer=True 等方案。

同一个 super-step 里的节点读到什么?

示例用不同的 time.sleep 模拟等待时间:

python 复制代码
def sum_node(state):
    print(f"[sum] 开始,已有结果={len(state['findings'])}")
    time.sleep(0.15)
    print(f"[sum] 完成,读到的已有结果={len(state['findings'])}")
    return {
        "findings": [{"metric": "sum", "value": sum(state["numbers"])}]
    }


def maximum_node(state):
    print(f"[maximum] 开始,已有结果={len(state['findings'])}")
    time.sleep(0.30)
    print(f"[maximum] 完成,读到的已有结果={len(state['findings'])}")
    return {
        "findings": [{"metric": "maximum", "value": max(state["numbers"])}]
    }

即使 sum_node 先返回,maximum_node 也不会因此立刻读到它刚写出的 findings。两个并行节点按本步的输入执行,运行时收集它们的更新;它们完成后,LangGraph 才合并本步结果并调度后续汇总。

所以本例中两个节点开始时都读到 0 条结果,maximum_node 睡醒后仍然看到本步输入中的 0 条。summarize_node 在下一步运行,才看到合并后的两条结果。

这和"多个线程随意改同一个 Python 字典,再看谁先写完"的模型不同。不过,将它称为 SQL 快照隔离并不准确:这里讨论的是 LangGraph 按 super-step 调度节点并提交 State 更新的执行模型,不是数据库事务语义。

Reducer:让多个节点把结果写入同一字段

python 复制代码
from operator import add
from typing import Annotated
from typing_extensions import TypedDict


class Finding(TypedDict):
    metric: str
    value: int


class ParallelState(TypedDict):
    numbers: list[int]
    findings: Annotated[list[Finding], add]
    report: str

findings 配置 operator.add 后,每个节点只需返回自己的新增结果。两个更新合并后,findings 同时包含 sum 和 maximum 的结果。结果列表的排列不应被当作分支完成顺序或业务标识。

因此每条结果带上 metric:

python 复制代码
def summarize_node(state: ParallelState):
    values = {
        item["metric"]: item["value"]
        for item in state["findings"]
    }
    return {
        "report": f"总和={values['sum']};最大值={values['maximum']}"
    }

汇总按名称查值,而不是假定列表第 0 项一定是 sum、第 1 项一定是 maximum。需要固定展示顺序时,按 metric 显式排序。

没有 Reducer 时,为什么会出现并发写入错误?

将 State 改成:

python 复制代码
class ConflictState(TypedDict):
    numbers: list[int]
    findings: list[Finding]  # 没有 reducer
    report: str

两个分支在同一个 super-step 都返回 findings 的更新。默认覆盖规则能处理顺序更新,但同一步出现多个对同一字段的更新时,LangGraph 不会猜测"谁赢",而是抛出 InvalidUpdateError。这避免了静默丢弃其中一个计算结果。

并行节点不一定都要 Reducer,重点在于是否有多个节点同时写同一字段:

写入方式 是否需要为并发配置同字段 Reducer
sum 写 sum_result,maximum 写 max_result 不需要,字段互不冲突
两个分支都写普通的 findings 字段 会冲突
两个分支都写带 add Reducer 的 findings 可以合并两份更新

Reducer 是状态字段的合并规则,不是"只要有并发就给所有字段加 add"。

也不要在分支节点中把完整 State 原样返回。节点只应提交自己实际更新的字段;否则多个分支可能把其他普通字段也重复提交,制造新的冲突。

运行与观察

powershell 复制代码
python -X utf8 parallel_merge_demo.py

有 Reducer 时,两条分支各自读到空的 findings,结束后汇总节点读到两条结果:

text 复制代码
总和=10;最大值=5

无 Reducer 的实验会在并发合并时抛出 InvalidUpdateError,汇总节点不会执行。日志的先后顺序可能变化,不要让业务依赖它;需要观察的是两个分支都完成、结果都被保留、汇总在两者之后运行。

示例里的 0.15 秒和 0.30 秒只是用来观察等待交叠的模拟值,不是性能基准。并发可帮助 I/O 等待重叠,但实际耗时还取决于调度开销、资源限制和外部服务限流;CPU 密集型计算也不一定因并发自动加速。

本课小结

  1. 多条从同一节点出发的边可以让多个分支都执行。
  2. 用来源节点列表声明汇合依赖,可以等所有指定来源完成后再运行一次汇总。
  3. 同一个 super-step 的并行节点不会即时读到彼此的返回值;后续步骤读取合并后的 State。
  4. 多节点同时写同一字段时,要设计 Reducer 或改用不同字段,不能依赖完成顺序。
  5. 给并行结果附上业务标识,汇总时按标识取值,不按列表位置猜来源。
相关推荐
zz在掘金337081 小时前
Django __init__.py 高级应用速记
后端
软件工程师文艺6661 小时前
Teamcenter FMS 文件管理系统深度解析:缓存策略、SSL 部署与性能调优
后端
Gopher_HBo1 小时前
类型系统 —— Type、rtype 与运行时类型元数据
后端
王川7081 小时前
商协会后台前端的模块聚合实践
前端·后端
旋律4141 小时前
String、StringBuilder、StringBuffer:三个"字符串",到底区别在哪?
java·后端
小卿噢1 小时前
94% 的集群在空等——数据倾斜实测
后端
没事写点啥1 小时前
WeKnora 问答实现
后端·agent·ai编程
Juicedata1 小时前
面向 EB 级 AI 存储:JuiceFS 企业版架构演进
数据库·人工智能·架构
五百万啊啊啊啊1 小时前
RSA+AES 混合加密的三个坑
后端