LangChain LangGraph
Module 1 Module 2 Module 3 Module 4 Module 5 Module 6
Module 4 · Parallelization
1 4-1 Parallelization
2 4-2 Sub Graph
3 4-3 Map Reduce
4 4-4 Research Asst
HomeLangGraphModule 4 · Parallelization4-1 Parallelization
📓 Notebook 📖 Explained
LANGGRAPH MODULE 4 · LESSON 1

LangGraph 并行执行
Fan-out · Fan-in · Reducer

一个节点分裂成多条并行支路,同时运行,再汇聚回一个节点。
这就是 Fan-out / Fan-in——也是构建多智能体研究助手的基础能力。

1 什么是并行执行?为什么需要它?

在 LangGraph 的前三个模块里,我们学习的图都是线性的:节点 A 执行完,节点 B 开始,节点 B 执行完,节点 C 开始。这种"单车道"执行方式简单可预测,但有一个天然瓶颈:每个步骤都必须等待上一个步骤完成

现实场景中,很多任务是相互独立的,可以同时进行。比如:

如果这些独立任务串行执行,总耗时 = 任务 1 时间 + 任务 2 时间 + …;而如果并行执行,总耗时 ≈ 最慢那个任务的时间。当每个子任务都需要等待 LLM 或网络 IO 时,并行化带来的提速效果非常显著。

Module 4 的主题

Module 4 的目标是构建一个多智能体研究助手。并行执行是其中最关键的基础能力——让助手能同时从多个数据源检索信息,然后统一交给 LLM 综合分析。这节课(Lesson 1)先把并行执行的原理和 API 讲透。

Fan-out 和 Fan-in 是什么意思?

这两个术语来自电路设计,在 LangGraph 中含义如下:

术语 含义 LangGraph 实现方式
Fan-out(扇出) 一个节点的输出"分裂"成多条并行支路,同时触发多个下游节点 从同一个节点出发,add_edge 到多个不同节点
Fan-in(扇入) 多条并行支路的输出"汇聚"到一个节点,该节点等待所有上游完成后才运行 多个节点通过 add_edge 指向同一个下游节点

Fan-out / Fan-in 示意图

START
a
触发节点
Fan-out(扇出)
b
并行支路 1
c
并行支路 2
Fan-in(扇入)
d
汇聚节点(等待 b、c 都完成)
END

2 线性图 vs. 并行图:从顺序执行到 Fan-out / Fan-in

先看线性图:逐个覆盖 state

notebook 首先用一个线性图做基准对比。每个节点把自己的值覆盖state 字段(注意:这里没有 Reducer):

from typing import Any, List
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END

class State(TypedDict):
    # 注意:没有 Reducer,默认行为是"覆盖"
    state: List[str]

class ReturnNodeValue:
    def __init__(self, node_secret: str):
        self._value = node_secret

    def __call__(self, state: State) -> Any:
        print(f"Adding {self._value} to {state['state']}")
        return {"state": [self._value]}  # 直接覆盖,不追加

# 构建线性图:a → b → c → d
builder = StateGraph(State)
builder.add_node("a", ReturnNodeValue("I'm A"))
builder.add_node("b", ReturnNodeValue("I'm B"))
builder.add_node("c", ReturnNodeValue("I'm C"))
builder.add_node("d", ReturnNodeValue("I'm D"))

builder.add_edge(START, "a")
builder.add_edge("a", "b")
builder.add_edge("b", "c")
builder.add_edge("c", "d")
builder.add_edge("d", END)
graph = builder.compile()

运行结果如下。因为每个节点都覆盖上一个节点的值,最终 state 里只剩 D 的值:

执行输出
Adding I'm A to []
Adding I'm B to ["I'm A"]
Adding I'm C to ["I'm B"]
Adding I'm D to ["I'm C"]

{'state': ["I'm D"]}

改成并行图:Fan-out 到 b 和 c,Fan-in 到 d

现在把 b 和 c 改为并行执行。关键改变只有边的连接方式——从 a 同时连向 bc,然后 bc 都连向 d

# Fan-out:a → b 且 a → c(两条边同时从 a 出发)
builder.add_edge("a", "b")
builder.add_edge("a", "c")

# Fan-in:b → d 且 c → d(两条边汇聚到 d)
builder.add_edge("b", "d")
builder.add_edge("c", "d")
核心设计原则

LangGraph 的并行执行完全通过边(Edge)来声明,不需要任何特殊的并行 API 或线程管理代码。你只需要把"从同一个节点出发,连向多个节点"的边加上去,LangGraph 就会自动并行运行这些节点。

3 并行冲突:同一字段被多个节点同时写入

并行图如果直接用没有 Reducer 的 State,会报错。这是 LangGraph 的一个重要安全机制,理解它非常关键。

为什么会冲突?

在并行步骤里,bc 在同一个"步骤"(step)内同时执行,它们各自都要向 state 字段写入一个值。没有 Reducer 时,LangGraph 不知道该保留哪个——两个写入互相冲突,所以它直接抛出异常,而不是悄悄丢掉其中一个。

from langgraph.errors import InvalidUpdateError

try:
    graph.invoke({"state": []})
except InvalidUpdateError as e:
    print(f"An error occurred: {e}")
执行输出(含错误)
Adding I'm A to []
Adding I'm B to ["I'm A"]
Adding I'm C to ["I'm A"]

An error occurred: At key 'state': Can receive only one value per step.
Use an Annotated key to handle multiple values.
重要错误信息解读

InvalidUpdateError: At key 'state': Can receive only one value per step. 这条错误说的是:在同一个执行步骤内,State 的 state 字段收到了来自多个节点的更新,但没有配置处理多值的方式(即没有 Reducer)。解决方法是给该字段加上 Annotated + Reducer 函数

错误的根本原因:LangGraph 的步骤(Step)概念

LangGraph 把图的执行划分为一个个"步骤"。在并行场景下,同一步骤里可能有多个节点并发运行。当这些节点都要更新同一个 State 字段时,LangGraph 需要一个明确的规则来决定如何合并这些更新——这个规则就是 Reducer

4 Reducer:并行输出的安全合并机制

Reducer 是附加在 State 字段上的一个合并函数。当同一步骤内多个节点都对同一字段写入值时,LangGraph 会用 Reducer 函数把所有写入值合并成一个最终值。

operator.add 作为 Reducer

Python 标准库的 operator.add 应用在列表上等价于列表拼接(list1 + list2)。把它作为 Reducer,所有并行节点的输出列表会被依次拼接:

import operator
from typing import Annotated

class State(TypedDict):
    # Annotated 的第二个参数是 Reducer 函数
    # operator.add 作用于列表时 = 列表拼接(追加)
    state: Annotated[list, operator.add]

# 其余代码(节点定义、边连接)与之前完全相同
builder = StateGraph(State)
builder.add_node("a", ReturnNodeValue("I'm A"))
builder.add_node("b", ReturnNodeValue("I'm B"))
builder.add_node("c", ReturnNodeValue("I'm C"))
builder.add_node("d", ReturnNodeValue("I'm D"))

# Fan-out + Fan-in
builder.add_edge(START, "a")
builder.add_edge("a", "b")
builder.add_edge("a", "c")
builder.add_edge("b", "d")
builder.add_edge("c", "d")
builder.add_edge("d", END)
graph = builder.compile()

现在运行成功:

执行输出(成功)
Adding I'm A to []
Adding I'm C to ["I'm A"]
Adding I'm B to ["I'm A"]
Adding I'm D to ["I'm A", "I'm B", "I'm C"]

{'state': ["I'm A", "I'm B", "I'm C", "I'm D"]}
观察细节

注意输出里 C 比 B 先被 print 出来,但最终 state 的顺序是 B 在 C 前面。这说明:并行节点的执行顺序不可控(由 LangGraph 内部调度决定),但 Reducer 合并后的结果仍然是确定的(下一节会详细解释)。

Reducer 的工作原理图解

并行写入(无 Reducer)
  • b 写入: ["I'm B"]
  • c 写入: ["I'm C"]
  • 冲突!报错
operator.add Reducer
  • left = ["I'm B"]
  • right = ["I'm C"]
  • left + right =
合并后的 state
  • ["I'm B", "I'm C"]
  • 安全合并
语法速记

state: Annotated[list, operator.add] 这一行的含义:
- Annotated:Python 的类型注释扩展,允许附加元数据
- 第一个参数 list:该字段的数据类型
- 第二个参数 operator.add:Reducer 函数,LangGraph 用它来合并并发写入

5 不等长支路:LangGraph 如何等待所有并行路径完成

实际场景中,并行的多条支路往往长度不同——比如一条支路只有一个节点,另一条支路有两个节点串联。LangGraph 如何处理这种情况?

构建不等长并行图

在这个例子中,从 a Fan-out 出两条支路:支路一是 b → b2(两个节点串联),支路二是 c(只有一个节点)。两条支路都 Fan-in 到 d

builder = StateGraph(State)

builder.add_node("a",  ReturnNodeValue("I'm A"))
builder.add_node("b",  ReturnNodeValue("I'm B"))
builder.add_node("b2", ReturnNodeValue("I'm B2"))
builder.add_node("c",  ReturnNodeValue("I'm C"))
builder.add_node("d",  ReturnNodeValue("I'm D"))

builder.add_edge(START, "a")
builder.add_edge("a", "b")    # 支路一:a → b → b2
builder.add_edge("a", "c")    # 支路二:a → c
builder.add_edge("b", "b2")

# 列表语法:等 b2 和 c 都完成后,才触发 d
builder.add_edge(["b2", "c"], "d")
builder.add_edge("d", END)
graph = builder.compile()
列表语法

builder.add_edge(["b2", "c"], "d") 是一种简洁写法,等价于分别写 add_edge("b2", "d")add_edge("c", "d")。它明确声明"d 需要 b2c 都完成",语义更清晰。

不等长并行支路图结构

START
a
b
支路 1 第 1 步
b2
支路 1 第 2 步
c
支路 2(单步)
等待 b2 和 c 都完成
d
汇聚节点
END

执行结果:

执行输出
Adding I'm A to []
Adding I'm B to ["I'm A"]
Adding I'm C to ["I'm A"]
Adding I'm B2 to ["I'm A", "I'm B", "I'm C"]
Adding I'm D to ["I'm A", "I'm B", "I'm C", "I'm B2"]

{'state': ["I'm A", "I'm B", "I'm C", "I'm B2", "I'm D"]}

关键观察:LangGraph 的"步骤"调度机制

从输出中可以看出:

执行顺序保证

LangGraph 保证:Fan-in 节点(即 d只会在所有上游并行节点都完成后才运行。即使各条支路长度不同、耗时不同,LangGraph 都会自动等待最慢的那条路完成。你不需要写任何同步代码。

6 自定义 Reducer:控制并行输出的合并顺序

operator.add 合并并行输出时,各个节点写入的顺序由 LangGraph 内部调度决定,不可预测。有时候我们希望合并后的结果有确定的顺序,这就需要自定义 Reducer

问题描述

上一节的输出 ["I'm A", "I'm B", "I'm C", "I'm B2", "I'm D"] 中,C 在 B2 前面,但我们可能希望结果按字母顺序排序。

编写自定义 Reducer 函数

def sorting_reducer(left, right):
    """合并两个列表并按字母顺序排序"""
    # 确保 left 和 right 都是列表
    if not isinstance(left, list):
        left = [left]
    if not isinstance(right, list):
        right = [right]
    # 合并后排序
    return sorted(left + right, reverse=False)

class State(TypedDict):
    # 使用自定义 Reducer
    state: Annotated[list, sorting_reducer]

其他代码(节点、边的定义)与上一节相同,只需把 State 定义里的 Reducer 替换即可:

执行输出(使用 sorting_reducer)
Adding I'm A to []
Adding I'm C to ["I'm A"]
Adding I'm B to ["I'm A"]
Adding I'm B2 to ["I'm A", "I'm B", "I'm C"]
Adding I'm D to ["I'm A", "I'm B", "I'm B2", "I'm C"]

{'state': ["I'm A", "I'm B", "I'm B2", "I'm C", "I'm D"]}

注意最终结果 ["I'm A", "I'm B", "I'm B2", "I'm C", "I'm D"] 按字母顺序排列了——即使在执行过程中 C 比 B 先被打印,排序 Reducer 确保了最终结果的有序性。

Reducer 函数的签名约定

参数 含义 示例
left 当前 State 中该字段的现有值(已积累的) ["I'm A", "I'm B"]
right 新节点写入的值(本次更新) ["I'm C"]
返回值 合并后的新值,作为该字段的下一个状态 ["I'm A", "I'm B", "I'm C"](排序后)
稳定排序的高级技巧

如果需要更精细地控制合并顺序,可以采用三步策略:① 并行节点把输出写到临时字段(而不是最终字段);② 用一个"汇聚节点"读取所有临时字段,按自定义规则合并排序;③ 清空临时字段。这种方式比 Reducer 更灵活,适合顺序要求严格的场景。

7 真实案例:并行搜索 Wikipedia + Web 后用 LLM 汇总回答

前面的例子都用简单的字符串来演示并行机制。现在用一个真实的 AI 场景来展示并行执行的实际价值:同时从 Wikipedia 和 Web 搜索引擎检索资料,再由 LLM 综合两份资料回答用户问题

State 定义

import operator
from typing import Annotated
from typing_extensions import TypedDict

class State(TypedDict):
    question: str              # 用户问题(不需要 Reducer,只写一次)
    answer:   str              # LLM 的最终回答
    context:  Annotated[list, operator.add]  # 检索到的资料(需要 Reducer 追加)

context 字段会被 search_websearch_wikipedia 两个并行节点同时写入,所以必须加 operator.add Reducer。questionanswer 只会被一个节点写入,不需要 Reducer。

三个节点的完整实现

from langchain_core.messages import HumanMessage, SystemMessage
from langchain_community.document_loaders import WikipediaLoader
from langchain_tavily import TavilySearch
from langchain_deepseek import ChatDeepSeek

llm = ChatDeepSeek(model="deepseek-v4-pro", temperature=0)

# ──── 节点 1:Web 搜索 ────
def search_web(state):
    """从 Web 搜索引擎检索资料"""
    tavily_search = TavilySearch(max_results=3)
    data = tavily_search.invoke({"query": state['question']})
    search_docs = data.get("results", data)

    # 格式化为带 URL 的文档字符串
    formatted = "\n\n---\n\n".join([
        f'<Document href="{doc["url"]}">\n{doc["content"]}\n</Document>'
        for doc in search_docs
    ])
    return {"context": [formatted]}  # 返回列表,Reducer 会追加

# ──── 节点 2:Wikipedia 搜索 ────
def search_wikipedia(state):
    """从 Wikipedia 检索资料"""
    search_docs = WikipediaLoader(
        query=state['question'], load_max_docs=2
    ).load()

    # 格式化为带来源的文档字符串
    formatted = "\n\n---\n\n".join([
        f'<Document source="{doc.metadata["source"]}">\n{doc.page_content}\n</Document>'
        for doc in search_docs
    ])
    return {"context": [formatted]}  # 返回列表,Reducer 会追加

# ──── 节点 3:LLM 综合回答 ────
def generate_answer(state):
    """基于检索到的上下文,让 LLM 回答问题"""
    context  = state["context"]   # 已合并的两份资料
    question = state["question"]

    answer_template = "Answer the question {question} using this context: {context}"
    instructions = answer_template.format(question=question, context=context)

    answer = llm.invoke([
        SystemMessage(content=instructions),
        HumanMessage(content="Answer the question.")
    ])
    return {"answer": answer}

构建并行图

builder = StateGraph(State)

builder.add_node("search_web",       search_web)
builder.add_node("search_wikipedia", search_wikipedia)
builder.add_node("generate_answer",  generate_answer)

# 从 START 同时 Fan-out 到两个搜索节点(并行)
builder.add_edge(START, "search_wikipedia")
builder.add_edge(START, "search_web")

# 两个搜索节点都完成后,Fan-in 到 generate_answer
builder.add_edge("search_wikipedia", "generate_answer")
builder.add_edge("search_web",       "generate_answer")
builder.add_edge("generate_answer",  END)

graph = builder.compile()

真实案例图结构:并行检索 + LLM 汇总

START
search_web
Tavily Web 搜索
search_wikipedia
Wikipedia 搜索
context 字段通过 operator.add Reducer 合并两份资料
generate_answer
LLM 综合回答
END

运行示例

result = graph.invoke({"question": "How were Nvidia's Q2 2025 earnings"})
print(result['answer'].content)
LLM 回答
Nvidia's Q2 2025 earnings were strong, as the company reported an earnings per share (EPS) of $1.04, beating the forecast of $1.01, resulting in a 2.97% surprise. Revenue also exceeded expectations, with the company reporting $46.74 billion in revenue and adjusted earnings per share of $1.05, both surpassing analyst estimates. This performance drove a stock uptick, indicating positive market reception. However, there were concerns about the company's operations in China, which remains a question mark.

这个案例体现的核心价值

8 LangGraph Studio 部署与 API 调用

构建好的并行图除了在 notebook 里直接 invoke,还可以部署到 LangGraph API Server,通过 HTTP 接口或 SDK 异步调用。

本地启动 LangGraph Dev Server

module-4/studio/ 目录下执行:

# 在终端里运行(不是 Python 代码)
langgraph dev
启动输出
- API: http://127.0.0.1:2024
- Studio UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024
- API Docs: http://127.0.0.1:2024/docs

在浏览器打开 Studio UI 地址,可以可视化地看到图结构、实时执行状态、每个节点的输入/输出,这在调试复杂并行图时非常有用。

通过 SDK 异步流式调用

from langgraph_sdk import get_client

client = get_client(url="http://127.0.0.1:2024")

# 创建新线程(每个线程有独立的状态)
thread = await client.threads.create()

input_question = {"question": "How were Nvidia Q2 2025 earnings?"}

# 流式订阅执行过程,stream_mode="values" 表示每个步骤后推送完整 State
async for event in client.runs.stream(
    thread["thread_id"],
    assistant_id="parallelization",  # 对应 studio 里的图名称
    input=input_question,
    stream_mode="values"
):
    # 检查 answer 字段是否已经出现
    if event.data is not None:
        answer = event.data.get('answer', None)
        if answer:
            print(answer['content'])
stream_mode 的含义

stream_mode="values":每当图执行完一个步骤(一批并行节点都完成),就推送当前完整的 State 值。适合需要实时展示中间进度的场景。还有 "updates"(只推送本步骤的 State 变更部分)和 "events"(推送更细粒度的事件)等模式。

为什么要用 API Server 而不是直接 invoke?

方式 适合场景 主要限制
直接 invoke() 本地开发、测试、一次性脚本 阻塞式调用,不支持持久化,不支持多客户端
LangGraph API Server 生产环境、Web 服务、多用户并发 需要启动服务,有一定运维成本

本节知识全景总结

Module 4 Lesson 1 核心概念地图
Fan-out(扇出)
  • • 一个节点出发,多条 add_edge 指向不同节点
  • • 被指向的节点同时并行执行
  • • 无需任何特殊 API,纯边声明
Fan-in(扇入)
  • • 多个节点都连向同一个汇聚节点
  • • 汇聚节点等待所有上游完成才运行
  • • 支持不等长支路自动等待
Reducer(归并器)
  • Annotated[list, operator.add]
  • • 并行节点写同一字段时,用 Reducer 合并
  • • 可自定义排序、去重、过滤逻辑
InvalidUpdateError
  • • 并行节点写同一字段但没有 Reducer 时触发
  • • 解决:给字段加 Annotated + Reducer
  • • LangGraph 的安全保护机制,防止静默数据丢失
这节课在整个课程中的位置
并行执行是 Module 4 多智能体研究助手的基础能力。后续 Lesson 会在此基础上加入 Sub-graph(子图)、Map-Reduce 模式,最终构建能同时调度多个专业 Agent 的完整研究助手。