一个节点分裂成多条并行支路,同时运行,再汇聚回一个节点。
这就是 Fan-out / Fan-in——也是构建多智能体研究助手的基础能力。
在 LangGraph 的前三个模块里,我们学习的图都是线性的:节点 A 执行完,节点 B 开始,节点 B 执行完,节点 C 开始。这种"单车道"执行方式简单可预测,但有一个天然瓶颈:每个步骤都必须等待上一个步骤完成。
现实场景中,很多任务是相互独立的,可以同时进行。比如:
如果这些独立任务串行执行,总耗时 = 任务 1 时间 + 任务 2 时间 + …;而如果并行执行,总耗时 ≈ 最慢那个任务的时间。当每个子任务都需要等待 LLM 或网络 IO 时,并行化带来的提速效果非常显著。
Module 4 的目标是构建一个多智能体研究助手。并行执行是其中最关键的基础能力——让助手能同时从多个数据源检索信息,然后统一交给 LLM 综合分析。这节课(Lesson 1)先把并行执行的原理和 API 讲透。
这两个术语来自电路设计,在 LangGraph 中含义如下:
| 术语 | 含义 | LangGraph 实现方式 |
|---|---|---|
| Fan-out(扇出) | 一个节点的输出"分裂"成多条并行支路,同时触发多个下游节点 | 从同一个节点出发,add_edge 到多个不同节点 |
| Fan-in(扇入) | 多条并行支路的输出"汇聚"到一个节点,该节点等待所有上游完成后才运行 | 多个节点通过 add_edge 指向同一个下游节点 |
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 的值:
现在把 b 和 c 改为并行执行。关键改变只有边的连接方式——从 a 同时连向 b 和 c,然后 b 和 c 都连向 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 就会自动并行运行这些节点。
并行图如果直接用没有 Reducer 的 State,会报错。这是 LangGraph 的一个重要安全机制,理解它非常关键。
在并行步骤里,b 和 c 在同一个"步骤"(step)内同时执行,它们各自都要向 state 字段写入一个值。没有 Reducer 时,LangGraph 不知道该保留哪个——两个写入互相冲突,所以它直接抛出异常,而不是悄悄丢掉其中一个。
from langgraph.errors import InvalidUpdateError
try:
graph.invoke({"state": []})
except InvalidUpdateError as e:
print(f"An error occurred: {e}")
InvalidUpdateError: At key 'state': Can receive only one value per step. 这条错误说的是:在同一个执行步骤内,State 的 state 字段收到了来自多个节点的更新,但没有配置处理多值的方式(即没有 Reducer)。解决方法是给该字段加上 Annotated + Reducer 函数。
LangGraph 把图的执行划分为一个个"步骤"。在并行场景下,同一步骤里可能有多个节点并发运行。当这些节点都要更新同一个 State 字段时,LangGraph 需要一个明确的规则来决定如何合并这些更新——这个规则就是 Reducer。
Reducer 是附加在 State 字段上的一个合并函数。当同一步骤内多个节点都对同一字段写入值时,LangGraph 会用 Reducer 函数把所有写入值合并成一个最终值。
operator.add 作为 ReducerPython 标准库的 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()
现在运行成功:
注意输出里 C 比 B 先被 print 出来,但最终 state 的顺序是 B 在 C 前面。这说明:并行节点的执行顺序不可控(由 LangGraph 内部调度决定),但 Reducer 合并后的结果仍然是确定的(下一节会详细解释)。
state: Annotated[list, operator.add] 这一行的含义:
- Annotated:Python 的类型注释扩展,允许附加元数据
- 第一个参数 list:该字段的数据类型
- 第二个参数 operator.add:Reducer 函数,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 需要 b2 和 c 都完成",语义更清晰。
执行结果:
从输出中可以看出:
a 执行b 和 c 同时执行(因为它们都是 a 的直接后继)b2 执行(b 完成后触发),同时 c 已完成等待d 执行——只有当 b2 和 c 都完成后才触发LangGraph 保证:Fan-in 节点(即 d)只会在所有上游并行节点都完成后才运行。即使各条支路长度不同、耗时不同,LangGraph 都会自动等待最慢的那条路完成。你不需要写任何同步代码。
用 operator.add 合并并行输出时,各个节点写入的顺序由 LangGraph 内部调度决定,不可预测。有时候我们希望合并后的结果有确定的顺序,这就需要自定义 Reducer。
上一节的输出 ["I'm A", "I'm B", "I'm C", "I'm B2", "I'm D"] 中,C 在 B2 前面,但我们可能希望结果按字母顺序排序。
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 替换即可:
注意最终结果 ["I'm A", "I'm B", "I'm B2", "I'm C", "I'm D"] 按字母顺序排列了——即使在执行过程中 C 比 B 先被打印,排序 Reducer 确保了最终结果的有序性。
| 参数 | 含义 | 示例 |
|---|---|---|
left |
当前 State 中该字段的现有值(已积累的) | ["I'm A", "I'm B"] |
right |
新节点写入的值(本次更新) | ["I'm C"] |
| 返回值 | 合并后的新值,作为该字段的下一个状态 | ["I'm A", "I'm B", "I'm C"](排序后) |
如果需要更精细地控制合并顺序,可以采用三步策略:① 并行节点把输出写到临时字段(而不是最终字段);② 用一个"汇聚节点"读取所有临时字段,按自定义规则合并排序;③ 清空临时字段。这种方式比 Reducer 更灵活,适合顺序要求严格的场景。
前面的例子都用简单的字符串来演示并行机制。现在用一个真实的 AI 场景来展示并行执行的实际价值:同时从 Wikipedia 和 Web 搜索引擎检索资料,再由 LLM 综合两份资料回答用户问题。
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_web 和 search_wikipedia 两个并行节点同时写入,所以必须加 operator.add Reducer。question 和 answer 只会被一个节点写入,不需要 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()
result = graph.invoke({"question": "How were Nvidia's Q2 2025 earnings"})
print(result['answer'].content)
Web 搜索和 Wikipedia 搜索同时进行,总耗时约等于两者中较慢的那个,而不是两者之和。
两个搜索节点的结果通过 operator.add Reducer 自动拼接进 context 列表,generate_answer 节点收到的是完整的两份资料,无需手动合并。
LLM 基于 Web 的时效性内容 + Wikipedia 的权威知识库两个来源综合回答,比单一来源更全面准确。
构建好的并行图除了在 notebook 里直接 invoke,还可以部署到 LangGraph API Server,通过 HTTP 接口或 SDK 异步调用。
在 module-4/studio/ 目录下执行:
# 在终端里运行(不是 Python 代码)
langgraph dev
在浏览器打开 Studio UI 地址,可以可视化地看到图结构、实时执行状态、每个节点的输入/输出,这在调试复杂并行图时非常有用。
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="values":每当图执行完一个步骤(一批并行节点都完成),就推送当前完整的 State 值。适合需要实时展示中间进度的场景。还有 "updates"(只推送本步骤的 State 变更部分)和 "events"(推送更细粒度的事件)等模式。
| 方式 | 适合场景 | 主要限制 |
|---|---|---|
直接 invoke() |
本地开发、测试、一次性脚本 | 阻塞式调用,不支持持久化,不支持多客户端 |
| LangGraph API Server | 生产环境、Web 服务、多用户并发 | 需要启动服务,有一定运维成本 |
Annotated[list, operator.add]