培歌行学LangGraph(10):多分支汇聚Fan-in——多个分支如何汇聚到一起?

34 阅读8分钟
  1. 培歌行学LangGraph(1):从 LangChain 到 LangGraph 的思维跃迁]
  2. 培歌行学LangGraph(2):Reducer(归约器)彻底搞懂
  3. 培歌行学LangGraph(3):编译、执行与可视化
  4. 培歌行学LangGraph(4):一文搞懂图的状态(State)管理
  5. 培歌行学LangGraph(5):状态管理与graph.invoke入参深度解析
  6. 培歌行学LangGraph(6):Multi Schema多状态管理详解
  7. 培歌行学LangGraph(7):预定义状态MessagesState与AgentState
  8. 培歌行学LangGraph(8):控制流详解
  9. 培歌行学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​ 是一种经典的计算模式,包含两个阶段:

  1. Map(映射) :把一个大任务拆成多个小任务,分别处理
  2. 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的关键点

  1. Send动态创建多个 mapper_node实例,每个处理一行数据
  2. entries字段使用 addReducer,自动合并所有Map任务的输出
  3. reducer_node等待所有Map任务完成后才执行("与"触发)
  4. 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_nodereducer_node之间用的是普通边:

builder.add_edge("mapper_node", "reducer_node")

这里的 "mapper_node"虽然是单个节点名,但实际运行时可能有多个任务实例。LangGraph 的机制是:等所有指向 reducer_node的上游任务实例都完成后,才触发 reducer_node

这其实就是"与"触发的语义——所有上游任务完成,下游才触发。

面试题4:如果Map任务数量非常多(比如1万个),会有什么问题?怎么优化?

参考答案:

问题:

  1. 所有Map结果同时合并到 entries字段,可能占用大量内存
  2. Reducer节点一次性处理所有数据,可能成为瓶颈

优化方案:

  1. 分批次处理:不要一次性创建所有Send任务,分批进行
  2. 分层Reduce:先做局部汇总,再做全局汇总(多级Reduce)
  3. 流式处理:如果不需要等所有结果,可以用"或"触发的方式逐步处理

面试题5:什么场景适合用"或"触发?

参考答案:

"或"触发适合以下场景:

  1. 缓存预热:多个数据源,任何一个先准备好就可以先用
  2. 容错处理:主路径和备用路径,任何一个成功就行
  3. 渐进式展示:先到先得,逐步展示结果
  4. 监控告警:多个检测指标,任何一个超标就告警

希望这个通俗版的教程能帮你轻松掌握Fan-in和MapReduce的用法!