- 培歌行学LangGraph(1):从 LangChain 到 LangGraph 的思维跃迁]
- 培歌行学LangGraph(2):Reducer(归约器)彻底搞懂
- 培歌行学LangGraph(3):编译、执行与可视化
- 培歌行学LangGraph(4):一文搞懂图的状态(State)管理
- 培歌行学LangGraph(5):状态管理与
graph.invoke入参深度解析 - 培歌行学LangGraph(6):Multi Schema多状态管理详解
- 培歌行学LangGraph(7):预定义状态MessagesState与AgentState
- 培歌行学LangGraph(8):控制流详解
- 培歌行学LangGraph(9):控制流详解:defer延迟节点——让收尾工作自动排到最后
一、什么是Fan-in?
Fan-out(扇出) 像一个上游节点分发出多个下游分支,就像扇子打开。
Fan-in(扇入) 正好相反:多个上游分支汇聚到同一个下游节点,就像扇子合拢。
二、两种汇聚方式
多个上游分支汇聚到同一个下游节点时,有两种触发方式:
| 触发方式 | 含义 | 类比 |
|---|---|---|
| "与"触发 | 所有上游分支都到达,才触发下游 | 等人齐了再开饭 |
| "或"触发 | 任意一个上游分支到达,就触发下游 | 谁先到谁先吃 |
2.1 "与"触发:等待所有上游到达
用 列表形式 传多个上游节点,表示要等它们全部完成。
builder.add_edge(["node_c", "node_d"], "node_e")
这行代码的意思是:node_e 要等到 node_c 和 node_d 都执行完,才会触发一次。
完整示例:
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langchain_core.runnables import RunnableConfig
from loguru import logger
class EmptyState(TypedDict):
pass
def node_a(state: EmptyState, config: RunnableConfig) -> EmptyState:
step = config["metadata"]["langgraph_step"]
logger.info(f"step {step}: node_a 执行")
return {}
def node_b(state: EmptyState, config: RunnableConfig) -> EmptyState:
step = config["metadata"]["langgraph_step"]
logger.info(f"step {step}: node_b 执行")
return {}
def node_c(state: EmptyState, config: RunnableConfig) -> EmptyState:
step = config["metadata"]["langgraph_step"]
logger.info(f"step {step}: node_c 执行")
return {}
def node_d(state: EmptyState, config: RunnableConfig) -> EmptyState:
step = config["metadata"]["langgraph_step"]
logger.info(f"step {step}: node_d 执行")
return {}
def node_e(state: EmptyState, config: RunnableConfig) -> EmptyState:
step = config["metadata"]["langgraph_step"]
logger.info(f"step {step}: node_e 执行")
return {}
builder = StateGraph(state_schema=EmptyState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_node("node_c", node_c)
builder.add_node("node_d", node_d)
builder.add_node("node_e", node_e)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", "node_b")
builder.add_edge("node_a", "node_c")
builder.add_edge("node_b", "node_d")
# 👇 "与"触发:等 node_c 和 node_d 都完成
builder.add_edge(["node_c", "node_d"], "node_e")
graph = builder.compile()
graph.invoke({})
输出:
step 1: node_a 执行
step 2: node_b 执行
step 2: node_c 执行
step 3: node_d 执行
step 4: node_e 执行 ← 等到 node_c 和 node_d 都完成才触发
执行流程:
START
│
▼
node_a (step 1)
│
├──→ node_b (step 2) → node_d (step 3) ─┐
│ │
└──→ node_c (step 2) ─────────────────────┤
▼
node_e (step 4) ← 只触发一次
注意:虽然 node_c在第2步就执行完了,但 node_e不会立刻触发。它要等到 node_d也在第3步执行完后,才在第4步触发一次。
2.2 "或"触发:任意上游到达即可
用 多条独立边 连接,每条边都可以独立触发下游。
builder.add_edge("node_c", "node_e")
builder.add_edge("node_d", "node_e")
这表示:node_c 完成后可以触发 node_e,node_d 完成后也可以触发 node_e。
完整示例:
builder = StateGraph(state_schema=EmptyState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_node("node_c", node_c)
builder.add_node("node_d", node_d)
builder.add_node("node_e", node_e)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", "node_b")
builder.add_edge("node_a", "node_c")
builder.add_edge("node_b", "node_d")
# 👇 "或"触发:两条独立边
builder.add_edge("node_c", "node_e")
builder.add_edge("node_d", "node_e")
graph = builder.compile()
graph.invoke({})
输出:
step 1: node_a 执行
step 2: node_b 执行
step 2: node_c 执行
step 3: node_d 执行
step 3: node_e 执行 ← node_c 触发第一次
step 4: node_e 执行 ← node_d 触发第二次
执行流程:
START
│
▼
node_a (step 1)
│
├──→ node_b (step 2) → node_d (step 3) ──→ node_e (step 4)
│
└──→ node_c (step 2) ──→ node_e (step 3)
注意:node_e被触发了两次!一次是 node_c触发的,一次是 node_d触发的。
2.3 两种写法的关键区别
| 写法 | 触发次数 | 含义 |
|---|---|---|
add_edge(["A","B"], "C") | 1次 | 等A和B都完成,C才触发一次 |
add_edge("A","C")+ add_edge("B","C") | 最多2次 | A完成触发一次,B完成再触发一次 |
这两种写法不是等价的! 使用时一定要想清楚你要的是哪种行为。
三、动态扇入:MapReduce模式
3.1 什么是MapReduce?
MapReduce 是一种经典的计算模式,包含两个阶段:
- Map(映射) :把一个大任务拆成多个小任务,分别处理
- Reduce(归约) :把所有小任务的结果汇总起来
在LangGraph中:
- Map 用
Send实现:动态创建多个任务实例 - Reduce 用汇聚节点 + Reducer实现:收集并合并所有结果
3.2 词频统计案例
下面实现一个经典的词频统计:输入几行文本,统计每个单词出现的次数。
from typing import TypedDict, Annotated
from langgraph.graph import StateGraph, START, END
from langgraph.types import Send
from collections.abc import Sequence
from operator import add
from loguru import logger
class OverAllState(TypedDict):
input_values: list[str]
entries: Annotated[list[tuple[str, int]], add] # 使用add Reducer自动合并
word_counts: dict[str, int]
# ========== Map阶段:动态分发任务 ==========
def router_map(state: OverAllState) -> Sequence[Send]:
"""路由函数:为每行文本创建一个Map任务"""
return [
Send("mapper_node", {"input_value": line})
for line in state["input_values"]
]
class MapperInputState(TypedDict):
input_value: str
def mapper_node(state: MapperInputState) -> OverAllState:
"""Map节点:将一行文本拆分成单词,每个单词记为1次"""
words = state["input_value"].split(" ")
entries = [(word, 1) for word in words]
return {"entries": entries}
# ========== Reduce阶段:汇总结果 ==========
def reducer_node(state: OverAllState) -> OverAllState:
"""Reduce节点:将所有Map结果汇总,统计每个单词的总次数"""
entries = state["entries"]
logger.info(f"收到所有Map结果: {entries}")
# 按单词分组
shuffle = {}
for word, count in entries:
if word not in shuffle:
shuffle[word] = []
shuffle[word].append(count)
logger.info(f"分组后: {shuffle}")
# 统计每个单词的总次数
result = {word: sum(counts) for word, counts in shuffle.items()}
return {"word_counts": result}
# ========== 构建图 ==========
builder = StateGraph(state_schema=OverAllState)
builder.add_node("mapper_node", mapper_node)
builder.add_node("reducer_node", reducer_node)
builder.add_conditional_edges(START, router_map, path_map=["mapper_node"])
builder.add_edge("mapper_node", "reducer_node")
builder.add_edge("reducer_node", END)
graph = builder.compile()
# 测试
result = graph.invoke({
"input_values": [
"hello world",
"hello Atguigu",
"hello LLM"
]
})
print(f"\n最终词频统计: {result['word_counts']}")
输出:
INFO | 收到所有Map结果: [('hello', 1), ('world', 1), ('hello', 1), ('Atguigu', 1), ('hello', 1), ('LLM', 1)]
INFO | 分组后: {'hello': [1, 1, 1], 'world': [1], 'Atguigu': [1], 'LLM': [1]}
最终词频统计: {'hello': 3, 'world': 1, 'Atguigu': 1, 'LLM': 1}
3.3 执行流程
输入: ["hello world", "hello Atguigu", "hello LLM"]
│
▼
router_map(路由函数)
│
├── Send → mapper_node #1 → ("hello",1), ("world",1)
├── Send → mapper_node #2 → ("hello",1), ("Atguigu",1) ← 并行执行
└── Send → mapper_node #3 → ("hello",1), ("LLM",1)
│
▼
(entries字段通过add Reducer自动合并)
│
▼
reducer_node(汇总节点)
│
├── 分组: {"hello": [1,1,1], "world": [1], "Atguigu": [1], "LLM": [1]}
└── 求和: {"hello": 3, "world": 1, "Atguigu": 1, "LLM": 1}
│
▼
输出结果
3.4 MapReduce的关键点
Send动态创建多个mapper_node实例,每个处理一行数据entries字段使用addReducer,自动合并所有Map任务的输出reducer_node等待所有Map任务完成后才执行("与"触发)- Reducer节点内部完成分组和聚合
四、总结
| 概念 | 说明 | 实现方式 |
|---|---|---|
| "与"触发 | 等所有上游完成才触发下游 | add_edge(["A","B"], "C") |
| "或"触发 | 任意上游完成就触发下游 | add_edge("A","C")+ add_edge("B","C") |
| 静态扇入 | 上游节点数量固定 | 直接在图中定义 |
| 动态扇入(MapReduce) | 上游任务数量动态变化 | Send+ Reducer |
五、相关面试题
面试题1:add_edge(["A","B"], "C")和 add_edge("A","C")+ add_edge("B","C")有什么区别?
参考答案:
add_edge(["A","B"], "C"): "与"触发,等A和B都完成,C才触发一次add_edge("A","C")+add_edge("B","C"): "或"触发,A完成触发一次C,B完成再触发一次C,C可能被触发两次
一句话:前者是"等人齐了开饭",后者是"谁先到谁先吃"。
面试题2:MapReduce模式中,为什么 entries字段要用 Annotated[list, add]?
参考答案:
因为多个 mapper_node实例是并行执行的,每个实例都会返回自己的 entries列表。如果不使用 Reducer,后执行完的实例会覆盖先执行完的实例的结果。
使用 Annotated[list[tuple[str, int]], add]后,所有实例返回的列表会自动拼接成一个完整的列表,reducer_node才能拿到所有Map任务的完整输出。
面试题3:MapReduce模式中,reducer_node是怎么知道所有Map任务都完成了?
参考答案:
因为 mapper_node和 reducer_node之间用的是普通边:
builder.add_edge("mapper_node", "reducer_node")
这里的 "mapper_node"虽然是单个节点名,但实际运行时可能有多个任务实例。LangGraph 的机制是:等所有指向 reducer_node的上游任务实例都完成后,才触发 reducer_node。
这其实就是"与"触发的语义——所有上游任务完成,下游才触发。
面试题4:如果Map任务数量非常多(比如1万个),会有什么问题?怎么优化?
参考答案:
问题:
- 所有Map结果同时合并到
entries字段,可能占用大量内存 - Reducer节点一次性处理所有数据,可能成为瓶颈
优化方案:
- 分批次处理:不要一次性创建所有Send任务,分批进行
- 分层Reduce:先做局部汇总,再做全局汇总(多级Reduce)
- 流式处理:如果不需要等所有结果,可以用"或"触发的方式逐步处理
面试题5:什么场景适合用"或"触发?
参考答案:
"或"触发适合以下场景:
- 缓存预热:多个数据源,任何一个先准备好就可以先用
- 容错处理:主路径和备用路径,任何一个成功就行
- 渐进式展示:先到先得,逐步展示结果
- 监控告警:多个检测指标,任何一个超标就告警
希望这个通俗版的教程能帮你轻松掌握Fan-in和MapReduce的用法!