LangGraph Send:你还在循环里硬调节点?Send 焊死「动态并发路由」,从单播广播到 Map-Reduce 一篇打通
基于 LangGraph 1.0(兼容 0.2.x+)编写,API 可能迭代,请以官方文档为准
一、痛点开场:for 循环是反模式,而且藏得很深
我第一次在 LangGraph 里写生产级工作流的时候,踩过一个很蠢的坑。这个坑蠢就蠢在,代码能跑,逻辑也对,单元测试通得过,甚至上线初期都没出问题------但它从根本上背叛了 LangGraph 的设计哲学。
1.1 一个看似合理的坏例子
需求是这样的:用户输入一个主题,系统先用 LLM 生成 5 个相关的子主题("加班"、"调试"、"需求变更"、"开会"、"写周报"),然后针对每个子主题调用同一个 generate_joke 函数生成笑话,最后汇总返回。
我的第一反应很直觉:在节点 A 里写一个 for 循环,逐个调用节点 B 的函数。
python
from typing import TypedDict, List
class AgentState(TypedDict):
topic: str
jokes: List[str]
def make_joke_subjects(state: AgentState):
"""
生成笑话主题并逐个生成笑话
"""
# 调用 LLM 生成 5 个主题
subjects = llm.generate_subjects(state["topic"])
jokes = []
for subject in subjects:
# 每个主题串行生成笑话
joke = generate_joke_node({"subject": subject})
jokes.append(joke)
return {"jokes": jokes}
这段代码跑了三个月,处理了上千次请求,看起来一切正常。直到我试图在 LangGraph Studio 里追踪一次异常调用,才发现问题有多严重。
1.2 三个隐藏的致命伤
第一,完全失去并行能力。
5 个笑话串行生成,假设每次 LLM 调用 2 秒,5 次就是 10 秒。如果 LangGraph 的调度器能看到这 5 个任务是独立的,它可以并行下发,理论上还是 2 秒------但我的 for 循环把调度器蒙在鼓里。调度器只看到"一个节点执行完毕",根本不知道里面藏了 5 次子调用。
更糟的是,我用的 LLM 有 rate limit。串行调用意味着我必须等前一个返回才能发下一个请求,而如果并行调用,我可以在同一时间窗口内利用 rate limit 的并发额度。举个例子,如果 rate limit 是每分钟 20 次请求,串行模式下 5 次调用要 10 秒;并行模式下只要 2 秒,剩余时间还能处理更多请求。
第二,失去独立重试能力。
有一天晚上第 3 个主题的 LLM 调用超时了。LangGraph 的 checkpoint 机制按节点粒度做持久化------整个 make_joke_subjects 节点失败后,只能从节点入口重试。这意味着前面已经生成好的 2 个笑话也丢了,必须从头再来。
如果我用 Send 把每次调用拆成独立节点,LangGraph 只会重试失败的那个 Send,其他 4 个的结果已经安全地存在 checkpoint 里了。这就是"节点级持久化"的真正含义:持久化的粒度越细,容错能力越强。
第三,失去可观测性。
LangGraph Studio 的追踪界面里,我只看到 make_joke_subjects 这一个节点。它是绿色的(成功)还是红色的(失败)?那 5 个笑话生成各自花了多久?哪个子主题的调用卡了 8 秒?返回值里有没有格式异常?
全不知道。图给我的信息粒度,就是我给图的节点粒度。我把 5 个独立任务塞在一个节点里,图就给我 1 个数据点。调试的时候跟瞎子一样,只能在外面加 print,回到了没有 LangGraph 之前的状态。
1.3 根本问题:我在图里挖了个洞
这三个问题的共同根因是:我在节点内部用代码逻辑替代了图调度器的工作。
LangGraph 的设计假设是:你把业务拆成节点,调度器负责节点的编排、并发、重试、追踪。这是一种声明式的编程范式------你"声明"节点和边的关系,调度器"执行"最优的调度策略。
而我的 for 循环是一种命令式编程------我"命令"代码按什么顺序执行,调度器无从干预。结果就是,LangGraph 的四大核心能力被我亲手干掉了:
- 并行执行
- 断点续跑(checkpoint 粒度粗化)
- 可视化追踪
- 条件路由的动态决策
后来我读到 LangGraph 很早就引入的 Send 机制,看完源码才意识到:这就是为了解决这个问题而生的。
二、Send 的核心原理:从"图内黑盒"到"声明式并发"
2.1 本质:条件边返回 Send 对象列表
在理解 Send 之前,先回顾 LangGraph 的两种基础边:
普通边(Fixed Edge):
python
builder.add_edge("node_a", "node_b")
无条件连接,A 执行完必定走到 B。适合线性流程。
条件边(Conditional Edge):
python
def router(state):
if state["score"] > 0.5:
return "high_score_node"
else:
return "low_score_node"
builder.add_conditional_edges("node_a", router, {
"high_score_node": "high_score_node",
"low_score_node": "low_score_node"
})
路由函数返回字符串,调度器根据字符串找下一个节点。这是"动态路由",但仍然是单播------一次只走一个分支。
Send 条件边(Dynamic Fan-out):
python
from langgraph.graph import StateGraph
from langgraph.types import Send
def router(state):
# 返回 Send 对象列表,不是字符串!
return [
Send("translator", {"text": state["text"], "target_lang": "en"}),
Send("translator", {"text": state["text"], "target_lang": "jp"}),
Send("translator", {"text": state["text"], "target_lang": "fr"})
]
builder.add_conditional_edges("node_a", router, {"translator": "translator"})
路由函数返回的是 Send 对象列表。每个 Send(target_node, state_override) 包含两个信息:
- 目标节点:图里哪个已注册的节点负责执行这次任务
- 状态覆盖:一个字典,会覆盖当前全局状态中对应的字段,形成这次调用的专属状态副本
调度器拿到这个列表后,会并行发起所有 Send 指定的节点执行。这是从"单播"到"广播"的关键一跃。
2.2 单播 vs 广播:两种 Send 模式
单播模式(一对一)
某些场景下,你只需要根据条件触发一次目标节点,和普通条件边类似,但带状态覆盖:
python
def route_single(state):
# 根据条件只发一个 Send
if state["urgency"] == "high":
return Send("fast_process", {"priority": 1, "timeout": 30})
else:
return Send("slow_process", {"priority": 3, "timeout": 120})
这本质上等价于普通条件边,但优势在于你可以精细化控制传递给目标节点的状态------只传它需要的字段,而不是整个全局状态。
广播模式(一对多)
这是 Send 最强大的用法,也是 Map-Reduce 的基础:
python
def route_broadcast(state):
# 为列表中的每个元素生成一个 Send
return [
Send("process", {"item": item, "index": i})
for i, item in enumerate(state["items"])
]
如果 state["items"] 有 100 个元素,这里会返回 100 个 Send 对象,调度器会尝试并行启动 100 个 process 节点实例。每个实例拿到的是全局状态 + {"item": ..., "index": ...} 的覆盖,互不影响。
2.3 底层调度机制解析
从 LangGraph 的源码看(核心在 Pregel 类和 Task 类),Send 的调度流程是这样的:
Step 1: 条件边路由函数执行完毕
|
Step 2: 返回值类型检查
- 如果是字符串 -> 普通条件边,解析为节点名
- 如果是 Send 对象 -> 提取 (target_node, state_override)
- 如果是 Send 列表 -> 提取 N 个 (target_node_i, state_override_i)
|
Step 3: 创建 Task 列表
对列表中的每个 Send:
- 创建独立的 Task 对象
- 将全局状态与 state_override 合并(override 优先级更高)
- Task 标记为可并行(只要目标节点不是其他 Task 的依赖)
|
Step 4: 提交执行池
- 所有独立的 Task 进入线程池或协程池并行执行
- 每个 Task 的执行上下文隔离
|
Step 5: 等待全部完成
- 收集每个 Task 的返回值
- 用 reducer 合并回全局状态
|
Step 6: 触发下游节点
- 所有 Send 的目标节点执行完毕后,触发条件边的下游(通常是聚合节点)
几个关键实现细节:
- 状态隔离:每个 Send 的状态覆盖只在本次 Task 的生命周期内有效,不会污染全局状态。Task 返回后,返回值经 reducer 合并。
- 并行度 :默认通过
asyncio.to_thread(Python 3.9+)把同步节点丢到线程池执行;异步节点则用asyncio.gather并发。可在调用时通过 config 传max_concurrency限制同一时间最多执行的节点数,防止资源打满。 - 执行顺序:并行 Task 之间没有保证的执行顺序,返回值的合并顺序取决于哪个 Task 先完成。如果你的 reducer 对顺序敏感(比如字符串拼接),要注意这一点。
2.4 add_conditional_edges 的配合方式
完整的接线方式要注意第三个参数:
python
from langgraph.graph import StateGraph
from langgraph.types import Send
builder = StateGraph(AgentState)
# 注册所有节点
builder.add_node("generate_subjects", generate_subjects)
builder.add_node("generate_joke", generate_joke)
builder.add_node("aggregate", aggregate_jokes)
# 条件边:generate_subjects -> 动态分发到 generate_joke
builder.add_conditional_edges(
"generate_subjects", # 起始节点
lambda state: [ # 路由函数
Send("generate_joke", {"subject": s})
for s in state["subjects"]
],
# 第三个参数:所有可能被 Send 到的目标节点
# 用于编译期的图拓扑校验
{"generate_joke": "generate_joke"} # 或者 ["generate_joke"]
)
# 所有 generate_joke 完成后统一到 aggregate
builder.add_edge("generate_joke", "aggregate")
builder.add_edge("aggregate", END)
graph = builder.compile()
第三个参数的几种写法:
python
# 写法 1:字典映射(推荐)
{"generate_joke": "generate_joke"}
# 写法 2:列表
["generate_joke"]
# 写法 3:如果有多个可能目标
{
"generate_joke": "generate_joke",
"fallback_node": "fallback_node"
}
这个参数不参与运行时逻辑------无论路由函数返回多少个 Send,调度器都会执行。它的存在是为了让 LangGraph 在 compile() 时验证:所有可能被 Send 到的节点,确实已经在图里注册过了。
为什么需要这个校验?
LangGraph 在编译期做可达性分析,确保图里没有"死节点"(注册了但永远不会被走到)和"悬空引用"(路由指向不存在的节点)。这个设计让错误在开发期暴露,而不是运行时才炸。
实际开发中,第三个参数最常见的坑是:路由函数返回了动态计算出的节点名,但第三个参数没包含。比如:
python
# 错误示例
def bad_router(state):
lang = state["target_lang"]
return Send(f"translate_{lang}", {...}) # 动态节点名
builder.add_conditional_edges(
"start",
bad_router,
{"translate_en": "translate_en"} # 漏了 translate_jp, translate_fr...
)
如果 target_lang 是运行时传入的 "jp",但第三个参数只写了 "translate_en",编译时不会报错(因为 "translate_en" 确实存在),运行时调度器会尝试路由到 "translate_jp" 却发现图里没有这个节点,抛出 ValueError。
正确做法:
- 如果节点名是动态生成的,确保所有可能值都在第三个参数里列出。
- 或者用静态节点名,把动态参数放进 Send 的
arg里:
python
# 正确示例:静态节点名 + 动态参数
def good_router(state):
return Send("translator", {
"text": state["text"],
"target_lang": state["target_lang"] # 动态参数放 arg 里
})
builder.add_conditional_edges(
"start",
good_router,
{"translator": "translator"} # 只有一个静态节点
)
2.5 状态传递的完整链路
很多初学者对 Send 中的状态覆盖理解不清。完整的链路是这样的:
全局状态(Global State)
|-- field_a: "value_a"
|-- field_b: "value_b"
|-- field_c: "value_c"
Send(target="process", arg={"field_b": "override_b", "field_d": "new_d"})
|
目标节点接收到的状态(Merged State):
|-- field_a: "value_a" <- 来自全局,未覆盖
|-- field_b: "override_b" <- 被 Send 覆盖
|-- field_c: "value_c" <- 来自全局,未覆盖
|-- field_d: "new_d" <- Send 新增的字段
目标节点返回:
{"field_e": "result_e"}
|
经 reducer 合并回全局状态:
|-- field_a: "value_a"
|-- field_b: "value_b" <- 注意:目标节点没返回 field_b,全局保持原值
|-- field_c: "value_c"
|-- field_d: "new_d" <- 如果 reducer 允许,可能保留
|-- field_e: "result_e" <- 新增
关键点:Send 的覆盖只影响目标节点的输入状态,不影响全局状态本身。目标节点返回的字段再经 reducer 决定如何合并到全局。
三、代码实战:三个完整示例
以下所有代码基于 LangGraph 1.0(兼容 0.2.x+)验证,安装命令:
bash
pip install langgraph langchain langchain-openai
3.1 示例一:多语言并行翻译
场景:用户输入一段中文文本,系统同时翻译成英、日、韩、德四种语言,最后汇总展示。
这个例子的设计重点是展示"同构任务并行化"------同一个节点逻辑被并行调用多次,每次传入不同的参数。
状态设计
python
from typing import TypedDict, List, Annotated
from langgraph.graph import StateGraph, END, START
from langgraph.types import Send
from langchain_openai import ChatOpenAI
import operator
# LLM 初始化(API 密钥填空)
llm = ChatOpenAI(model="gpt-4o-mini", api_key="___")
# 全局状态:翻译工作流的全局数据结构
class TranslationState(TypedDict):
text: str # 原始文本
target_languages: List[str] # 目标语言列表
translations: Annotated[List[dict], operator.add] # 聚合结果
# 单个翻译任务的状态(Send 传递的局部状态)
class SingleTranslationState(TypedDict):
text: str # 要翻译的文本
target_lang: str # 目标语言
节点实现
python
def router_node(state: TranslationState):
"""
路由节点:准备目标语言列表
这里也可以直接由外部输入提供,不需要 LLM 生成
"""
return {
"target_languages": ["英语", "日语", "韩语", "德语"]
}
def translate_node(state: SingleTranslationState):
"""
翻译节点:接收单个翻译任务,调用 LLM 完成翻译
这个节点会被并行调用多次,每次处理一种语言
"""
prompt = f"""
请将以下中文文本翻译成{state['target_lang']}。
只返回翻译结果,不要解释:
{state['text']}
"""
result = llm.invoke(prompt)
# 返回结构化的翻译结果,方便 reducer 聚合
return {
"translations": [
{
"language": state["target_lang"],
"original": state["text"],
"translated": result.content.strip()
}
]
}
def aggregate_translations(state: TranslationState):
"""
聚合节点:所有翻译完成后,整理最终输出
由于 translations 用了 operator.add,到这里已经是完整的列表了
"""
# 可以在这里做后处理,比如格式化、去重、排序
sorted_translations = sorted(
state["translations"],
key=lambda x: state["target_languages"].index(x["language"])
if x["language"] in state["target_languages"] else 999
)
return {"translations": sorted_translations}
路由函数
python
def route_to_translators(state: TranslationState):
"""
条件边路由函数:为每种目标语言生成一个 Send
这是 Send 的核心用法------从单一节点分发到多个并行节点
"""
return [
Send("translator", {"text": state["text"], "target_lang": lang})
for lang in state["target_languages"]
]
图组装
python
# 构建图
builder = StateGraph(TranslationState)
# 注册节点
builder.add_node("router", router_node)
builder.add_node("translator", translate_node)
builder.add_node("aggregate", aggregate_translations)
# 连线:START -> router -> [并行 translator] -> aggregate -> END
builder.add_edge(START, "router")
builder.add_conditional_edges(
"router",
route_to_translators,
{"translator": "translator"} # 编译期校验
)
builder.add_edge("translator", "aggregate")
builder.add_edge("aggregate", END)
# 编译
app = builder.compile()
# 执行
text = "人工智能正在改变我们的生活方式。"
result = app.invoke({"text": text})
# 输出结果
print(f"原文:{text}\n")
for t in result["translations"]:
print(f"[{t['language']}] {t['translated']}")
输出示例
原文:人工智能正在改变我们的生活方式。
[英语] Artificial intelligence is changing the way we live.
[日语] 人工知能が私たちの生活の仕方を変えています。
[韩语] 인공지능이 우리의 생활 방식을 바꾸고 있습니다.
[德语] Künstliche Intelligenz verändert unsere Lebensweise.
设计要点回顾
translations字段用了Annotated[List[dict], operator.add]------每个 translator 节点返回一个单元素列表,LangGraph 自动追加合并。SingleTranslationState是 translator 节点的局部状态类型。它不需要包含全局状态的所有字段,只需要自己用到的。Send 传递时会自动合并。- 路由函数里列表推导式一次性生成所有 Send,调度器在看到完整列表后才启动并行执行。
3.2 示例二:Map-Reduce 文档总结
场景:一篇长文档,先分块,每块并行提取要点(Map),最后汇总成总体摘要(Reduce)。
这是 Send 最经典的用例,也是很多 LLM 应用的性能瓶颈所在。比如 RAG 系统里,一个长文档可能需要分 20 个 chunk,串行处理要 40 秒,并行处理只要 2 秒。
状态设计
python
from typing import TypedDict, List, Annotated
from langgraph.graph import StateGraph, END, START
from langgraph.types import Send
from langchain_openai import ChatOpenAI
import operator
llm = ChatOpenAI(model="gpt-4o-mini", api_key="___")
# 全局状态
class MapReduceState(TypedDict):
document: str # 原始完整文档
chunk_size: int # 分块大小(字符数)
chunks: List[str] # 分块结果
summaries: Annotated[List[str], operator.add] # 各块摘要(Map 输出)
final_summary: str # 最终汇总(Reduce 输出)
error_count: int # 错误计数(容错设计)
# 单个 Map 任务的状态
class MapState(TypedDict):
chunk: str # 当前 chunk 内容
index: int # chunk 序号(用于调试)
分块策略
python
def split_document(state: MapReduceState):
"""
文档分块节点。
分块策略选择:
- 固定字符数:简单,但可能切断句子
- 按段落分:保持语义完整,但块大小不均匀
- 递归字符:LangChain 的 RecursiveCharacterTextSplitter
- 语义分块:用 embedding 相似度判断断点
这里演示最简单的按段落分,实际项目建议用语义分块
"""
# 按双换行分段
raw_chunks = state["document"].split("\n\n")
# 过滤空段落并做长度限制
chunks = []
current_chunk = ""
for paragraph in raw_chunks:
paragraph = paragraph.strip()
if not paragraph:
continue
# 如果当前段落加入后不超过限制,就合并
if len(current_chunk) + len(paragraph) < state.get("chunk_size", 500):
current_chunk += paragraph + "\n\n"
else:
# 先保存当前块
if current_chunk:
chunks.append(current_chunk.strip())
# 开始新块
current_chunk = paragraph + "\n\n"
# 不要遗漏最后一个块
if current_chunk:
chunks.append(current_chunk.strip())
return {"chunks": chunks}
Map 节点
python
def map_summarize(state: MapState):
"""
Map 节点:处理单个 chunk,提取核心要点。
设计考虑:
1. 每个 chunk 独立处理,互不影响
2. 返回格式固定,方便 reducer 聚合
3. 加入容错:LLM 调用失败时返回错误标记
"""
try:
prompt = f"""
请用 2-3 句话总结以下段落的核心观点。
只返回总结内容,不要添加标题或编号:
{state['chunk']}
"""
result = llm.invoke(prompt)
summary = result.content.strip()
return {
"summaries": [f"[段落 {state['index']}] {summary}"]
}
except Exception as e:
# 容错处理:单个 chunk 失败不影响整体
# 实际项目中可以记录日志、增加重试等
return {
"summaries": [f"[段落 {state['index']}] [处理失败: {str(e)}]"]
}
Reduce 节点
python
def reduce_summarize(state: MapReduceState):
"""
Reduce 节点:汇总所有 Map 输出,生成最终摘要。
所有 Map 节点完成后,它们的 summaries 已经通过 operator.add
聚合到全局状态中。这里做最终的综合和精炼。
"""
# 过滤掉失败的条目(成功摘要不会包含"[处理失败"标记,按标记过滤最可靠)
valid_summaries = [
s for s in state["summaries"]
if "[处理失败" not in s
]
if not valid_summaries:
return {"final_summary": "[所有段落处理失败,无法生成摘要]"}
# 组合所有有效摘要
combined = "\n".join(valid_summaries)
prompt = f"""
基于以下各段摘要,写一段 200 字以内的总体总结。
要求:
1. 涵盖所有段落的核心观点
2. 逻辑连贯,像一篇完整的摘要
3. 不要列出编号或段落标记
{combined}
"""
result = llm.invoke(prompt)
return {"final_summary": result.content.strip()}
路由函数
python
def route_map(state: MapReduceState):
"""
Map 路由:为每个 chunk 生成一个 Send。
这是 Map-Reduce 的核心------从 1 个分块节点到 N 个 Map 节点的广播。
每个 Send 携带独立的 chunk 和序号,互不影响。
"""
return [
Send("mapper", {"chunk": chunk, "index": i})
for i, chunk in enumerate(state["chunks"])
]
图组装与执行
python
# 构建图
builder = StateGraph(MapReduceState)
# 注册节点
builder.add_node("splitter", split_document)
builder.add_node("mapper", map_summarize)
builder.add_node("reducer", reduce_summarize)
# 连线:START -> splitter -> [并行 mapper] -> reducer -> END
builder.add_edge(START, "splitter")
builder.add_conditional_edges(
"splitter",
route_map,
{"mapper": "mapper"}
)
builder.add_edge("mapper", "reducer")
builder.add_edge("reducer", END)
app = builder.compile()
# 测试文档
document = """
LangGraph 是一个用于构建 LLM 应用的图框架。
它允许你将应用建模为有状态图,其中节点是函数,边是节点之间的连接。
Send 是 LangGraph 很早就引入的核心特性,用于实现动态并发路由。
通过 Send,你可以在条件边中返回多个目标节点调用,实现 Map-Reduce 等并行模式。
每个 Send 携带独立的状态副本,调度器会并行执行所有 Send 指定的节点。
这意味着你可以把一个大任务拆成多个独立子任务并行处理,最后再汇总结果。
对于长文档处理、批量数据分析、多语言翻译等场景,Send 能显著降低总执行时间。
"""
# 执行
result = app.invoke({
"document": document,
"chunk_size": 200 # 每块约 200 字符
})
# 输出
print("=== 各段摘要 ===")
for s in result["summaries"]:
print(f" {s}")
print(f"\n=== 最终总结 ===\n{result['final_summary']}")
关键设计决策分析
-
分块放在图内还是图外?
- 图内(当前做法):分块逻辑可以被追踪和重试。如果分块策略复杂(比如需要 LLM 判断断点),放在图里更有价值。
- 图外:如果分块是纯算法(如固定 Token 数),可以放在 invoke 之前,减少图的复杂度。
-
operator.add vs 自定义 reducer
operator.add是列表追加,适合 Map 输出是列表的场景。- 如果需要去重、排序、最大保留等逻辑,写自定义 reducer。
-
Reduce 的触发时机
- 所有 mapper 节点都完成后,调度器自动触发 reducer。不需要手动计数或等待。
- 这是声明式编程的好处:你声明"mapper 之后是 reducer",调度器处理依赖关系。
3.3 示例三:笑话生成(修复文章开头的反模式)
这是文章开头那个 for 循环例子的正确写法,展示了从反模式到正模式的完整重构。
状态设计
python
from typing import TypedDict, List, Annotated
from langgraph.graph import StateGraph, END, START
from langgraph.types import Send
from langchain_openai import ChatOpenAI
import operator
llm = ChatOpenAI(model="gpt-4o-mini", api_key="___")
# 全局状态
class JokeState(TypedDict):
topic: str # 用户输入的主题
count: int # 生成数量
subjects: List[str] # LLM 生成的子主题列表
jokes: Annotated[List[dict], operator.add] # 最终笑话列表
# 子主题生成节点的输入
class SubjectState(TypedDict):
topic: str
count: int
# 单个笑话生成节点的输入
class SingleJokeState(TypedDict):
subject: str # 子主题
index: int # 序号
节点实现
python
def generate_subjects(state: SubjectState):
"""
生成笑话子主题。
这个节点只做一件事:基于主题生成 N 个不同角度的子主题。
不在这里调用笑话生成------那是另一个节点的职责。
"""
prompt = f"""
基于主题'{state['topic']}',生成 {state['count']} 个不同角度的子主题。
每个子主题应该是可以独立写成笑话的具体场景或角度。
要求:
1. 每行一个子主题,不要编号
2. 子主题之间要有明显差异,覆盖不同角度
3. 每个子主题 5-15 个字
只返回子主题列表,不要其他内容。
"""
result = llm.invoke(prompt)
# 解析 LLM 输出
subjects = [
s.strip()
for s in result.content.strip().split("\n")
if s.strip() and not s.strip().startswith("#")
]
# 确保数量正确(LLM 有时会多给或少给)
subjects = subjects[:state["count"]]
return {"subjects": subjects}
def generate_joke(state: SingleJokeState):
"""
生成单个笑话。
这个节点会被并行调用多次,每次处理一个子主题。
由于状态隔离,每个调用互不干扰。
"""
prompt = f"""
写一个关于'{state['subject']}'的短笑话。
要求:
1. 50 字以内
2. 适合程序员/互联网从业者
3. 轻松幽默,不要冒犯
只返回笑话内容。
"""
result = llm.invoke(prompt)
return {
"jokes": [
{
"index": state["index"],
"subject": state["subject"],
"content": result.content.strip()
}
]
}
路由函数
python
def route_subjects(state: JokeState):
"""
核心路由:为每个子主题生成一个 Send。
对比反模式中的 for 循环:
- 反模式:在节点内部循环调用,调度器不可见
- 正模式:在条件边中返回 Send 列表,调度器并行执行
"""
return [
Send("joke_generator", {"subject": subject, "index": i})
for i, subject in enumerate(state["subjects"])
]
图组装
python
# 构建图
builder = StateGraph(JokeState)
# 注册节点
builder.add_node("subject_generator", generate_subjects)
builder.add_node("joke_generator", generate_joke)
# 连线
builder.add_edge(START, "subject_generator")
builder.add_conditional_edges(
"subject_generator",
route_subjects,
{"joke_generator": "joke_generator"}
)
# 所有 joke_generator 完成后直接结束
builder.add_edge("joke_generator", END)
app = builder.compile()
# 执行
result = app.invoke({
"topic": "程序员",
"count": 5
})
# 输出
print(f"主题:{result.get('topic', '程序员')}\n")
for joke in sorted(result["jokes"], key=lambda x: x["index"]):
print(f"[{joke['index']}] {joke['subject']}")
print(f" {joke['content']}\n")
反模式 vs 正模式的对比分析
反模式(串行黑盒)
+-----------------------+
| make_joke_subjects |
| +-----------------+ |
| | for joke 1 | | joke 1 -> 2秒
| | for joke 2 | | joke 2 -> 2秒 串行等待
| | for joke 3 | | joke 3 -> 2秒
| | for joke 4 | | joke 4 -> 2秒 总时间:10秒
| | for joke 5 | | joke 5 -> 2秒
| +-----------------+ |
+-----------------------+
正模式(并行白盒)
+--------------------+
| subject_generator |----Send("joke_1")---> joke_generator(1) -> 2秒
+--------------------+----Send("joke_2")---> joke_generator(2) -> 2秒
| Send("joke_3")---> joke_generator(3) -> 2秒
| Send("joke_4")---> joke_generator(4) -> 2秒
| Send("joke_5")---> joke_generator(5) -> 2秒
|
总时间:约 2 秒(并行执行)
核心差异总结
| 维度 | 反模式(循环调用) | 正模式(Send) |
|---|---|---|
| 执行方式 | 串行 | 并行 |
| 失败影响范围 | 整个节点重试 | 单个 Send 重试 |
| 可观测性 | LangGraph Studio 看不到内部 | 每个 Send 独立节点可见 |
| 状态隔离 | 共享 mutable list | 每个 Send 独立状态副本 |
| 代码耦合 | 节点 A 硬编码节点 B 的调用 | 纯声明式,节点互不知情 |
| 调试难度 | 高(黑盒) | 低(白盒) |
| 性能天花板 | 串行时间总和 | 并行时间约等于单个时间 |
| 扩展性 | 改循环逻辑等于改节点代码 | 改 Send 列表等于改路由函数 |
3.4 错误做法 vs 正确做法的完整对比
为了更清楚地展示区别,我把反模式和正模式并排写在一起:
反模式:节点内嵌循环
python
from typing import TypedDict, List
# 这个状态设计本身没问题
class BadState(TypedDict):
items: List[str]
results: List[str]
def bad_fanout_node(state: BadState):
"""
反模式:在节点内部用循环处理所有 items。
问题:
1. process_func 的调用隐藏在节点内部,调度器不可见
2. 串行执行,无法并行化
3. 失败时整个节点重试,前面的工作丢失
4. 无法单独追踪每个 item 的处理状态
"""
results = []
for item in state["items"]:
# 这里直接调用另一个节点的逻辑
# 调度器不知道这里发生了多次调用
result = process_func(item) # 假设这是某个处理函数
results.append(result)
return {"results": results}
def process_func(item: str) -> str:
"""假设的处理逻辑"""
# 调用 LLM 或做复杂计算
return f"Processed: {item}"
正模式:Send + 独立节点
python
from typing import TypedDict, List, Annotated
from langgraph.graph import StateGraph, END, START
from langgraph.types import Send
import operator
class GoodState(TypedDict):
items: List[str]
results: Annotated[List[str], operator.add] # 关键:用 operator.add
def good_router(state: GoodState):
"""
正模式:在条件边路由函数中返回 Send 列表。
每个 Send 携带独立的 item,调度器并行处理。
"""
return [
Send("processor", {"item": item})
for item in state["items"]
]
def processor_node(state):
"""
独立节点:只处理一个 item。
优势:
1. 调度器知道这个节点的存在,可以并行执行
2. 失败时只重试这个节点,不影响其他 item
3. 可以在 LangGraph Studio 追踪这个节点的输入输出
4. 节点逻辑单一,可测试、可复用
"""
result = process_func(state["item"])
# 返回单元素列表,operator.add 会自动追加
return {"results": [result]}
# 组装图
builder = StateGraph(GoodState)
builder.add_node("fanout", lambda state: state) # 透传节点
builder.add_node("processor", processor_node)
builder.add_edge(START, "fanout")
builder.add_conditional_edges(
"fanout",
good_router,
{"processor": "processor"}
)
builder.add_edge("processor", END)
app = builder.compile()
一句话结论:正模式(Send + 独立节点)在并行度、失败隔离、可观测性、解耦性上都优于节点内循环,详细对比见 3.3 的"核心差异总结"表。
四、关键概念对比表
| 特性 | Send(动态并发路由) | 普通条件边 | Batch(批处理) |
|---|---|---|---|
| 返回值 | Send 对象列表 |
字符串(节点名) | 不是路由,是输入形态 |
| 目标节点 | 运行时动态决定 | 运行时动态决定 | 固定节点 |
| 执行方式 | 并行(多个节点同时执行) | 串行(只选一个分支) | 串行(一个节点处理批次) |
| 状态副本 | 每个 Send 独立覆盖 | 全局状态传递 | 全局状态,批次循环处理 |
| 并发控制 | 调度器自动并行 | 无并发 | 用户手动分批 |
| 典型场景 | Map-Reduce、广播、Fan-out | 条件分支、If-else | 批 API 调用、批量数据库操作 |
| 可视化 | 每个 Send 独立节点可见 | 单一路径 | 单个节点 |
| 失败隔离 | 单个 Send 可独立重试 | 整条路径重试 | 整个 batch 重试 |
| 状态合并 | 通过 reducer(operator.add) | 直接返回覆盖 | 节点内手动合并 |
| API 引入 | LangGraph 1.0(0.2.x 起稳定) | LangGraph 基础功能 | 无专用 API |
| 适用数据量 | 中小规模(建议小于 100) | 任意 | 大规模数据 |
| 内存开销 | 每个 Send 一份状态副本 | 一份全局状态 | 一份全局状态 |
一句话区分三种模式
- 普通条件边:走哪条路------多个出口中选一个,串行执行。
- Send:一次派多少人、每个人带什么装备------一次派 N 个任务,并行执行,每人带专属参数。
- Batch:一个人一次扛多少货------单节点内部循环处理一批数据,不触发图调度。
五、局限与踩坑:Send 不是银弹
5.1 Send 只发状态、不产生新信息
这是最容易误解的一点。
Send 对象只有两个字段:node(目标节点名,字符串)和 arg(状态覆盖字典)。它在路由函数中一次性生成,调度器拿到列表后一次性派发。
这意味着:你不能在第一个 Send 执行完后,根据它的返回值动态决定第二个 Send 去哪。
python
# 这无法实现:
def bad_dynamic_router(state):
sends = []
for i, item in enumerate(state["items"]):
result = process(item) # 试图在路由函数里执行节点逻辑
if result["score"] > 0.5:
sends.append(Send("high_priority", {"item": item}))
else:
sends.append(Send("low_priority", {"item": item}))
return sends
路由函数在图的调度循环中执行,它不能调用 LLM 或执行耗时操作。如果你需要前序节点的输出影响后续 Send,有两种方案:
方案 A:子图嵌套
把"动态决策"封装成子图:外层图先执行一轮得到决策结果,然后根据结果触发第二轮 Send。
方案 B:分阶段路由
python
# 阶段 1:评估节点
def evaluate_items(state):
# 对每个 item 做简单评估(可以用 LLM 批量处理)
evaluations = []
for item in state["items"]:
score = quick_evaluate(item) # 轻量级评估
evaluations.append({"item": item, "score": score})
return {"evaluations": evaluations}
# 阶段 2:根据评估结果路由
def route_by_evaluation(state):
return [
Send("high" if e["score"] > 0.5 else "low", {"item": e["item"]})
for e in state["evaluations"]
]
5.2 目标节点必须是图内已注册节点
编译期校验会卡住你:
python
builder.add_conditional_edges(
"fanout",
lambda s: [Send("not_registered", {"item": "x"})],
{"not_registered": "not_registered"} # 编译报错!
)
错误信息类似:
ValueError: Node 'not_registered' not found in graph.
解决办法:
- 确保所有 Send 的目标节点都通过
builder.add_node(name, func)注册过。 - 如果目标节点是动态生成的,考虑用子图或者把逻辑合并到已有节点中。
5.3 Reducer 设计是命脉
Send 的并行节点返回结果怎么合并到全局状态,完全取决于你的 reducer。设计不当会导致数据丢失。
踩坑 1:默认覆盖行为
python
from typing import TypedDict, List
class BadState(TypedDict):
results: List[str] # 没有 Annotated + reducer
def node1(state):
return {"results": ["from_node1"]}
def node2(state):
return {"results": ["from_node2"]} # 覆盖 node1 的结果!
如果两个并行节点同时写 results 字段,且没有用 Annotated[..., operator.add],默认行为是覆盖。因为并行执行没有确定顺序,你可能只看到 node1 或 node2 的结果之一,另一个丢了。
正确做法:
python
from typing import Annotated
import operator
class GoodState(TypedDict):
results: Annotated[List[str], operator.add] # 追加而非覆盖
踩坑 2:自定义 reducer 没处理 None
python
def my_reducer(existing, new):
# 如果 existing 是 None,要处理
if existing is None:
existing = []
return existing + new
class MyState(TypedDict):
results: Annotated[List[str], my_reducer]
LangGraph 第一次调用 reducer 时,existing 可能为 None(字段还没有值)。你的 reducer 必须处理这种情况。
踩坑 3:reducer 的幂等性
如果节点执行了多次重试,reducer 会被调用多次。确保你的 reducer 逻辑是幂等的,或者 LangGraph 的状态管理机制能正确处理重试时的重复写入。
5.4 大量 Send 的内存与线程开销
生产环境中,这个坑我踩过。
场景:一次 Send 500 个任务,每个任务携带一个 1MB 的文档 chunk。
问题:
- 内存爆炸:500 个 Send 乘以 1MB 等于 500MB 状态副本。实际上 LangGraph 会保留更多中间状态,内存占用可能翻倍。
- 线程池耗尽:LangGraph 默认线程池大小有限,500 个任务不会真正并行 500 个线程,大部分会排队。但排队本身也有内存开销(每个等待的任务对象)。
- LLM rate limit:500 个并发 LLM 调用,大概率触发 rate limit,导致大量重试和超时。
实践建议:
| 问题 | 解决方案 |
|---|---|
| 状态太大 | Send 里只传轻量标识符(如 chunk_id),实际数据放外部存储(Redis/文件) |
| 并发太多 | 控制 Send 数量(建议小于 100),超量考虑分批或改用批处理 API |
| rate limit | 在目标节点内部做限速,或者减少并发数 |
| 内存监控 | 开启 LangSmith 追踪,观察内存曲线 |
5.5 错误传播与容错策略
默认行为:任一 Send 失败,整个图中断。
LangGraph 追求确定性执行,默认策略是"全有或全无"。这意味着如果你有 100 个 Send,99 个成功了,1 个失败了,那 99 个的结果也丢了(除非你开启了 checkpoint 且失败后从 checkpoint 恢复,但恢复时仍然需要重试失败的那个)。
容错 workaround:
在目标节点内部捕获异常,返回占位值:
python
def resilient_processor(state):
try:
result = risky_call(state["item"])
return {"results": [{"status": "ok", "data": result}]}
except Exception as e:
# 不抛异常,返回错误标记
return {
"results": [
{
"status": "failed",
"item": state["item"],
"error": str(e)
}
]
}
然后在下游节点过滤:
python
def filter_results(state):
successes = [r for r in state["results"] if r["status"] == "ok"]
failures = [r for r in state["results"] if r["status"] == "failed"]
# 记录日志或告警
if failures:
print(f"警告:{len(failures)} 个任务失败")
return {"successful_results": successes, "failed_items": failures}
未来展望 :LangGraph 团队可能会引入更原生的容错策略,比如 Send 的错误回调或者"ignore_failed"参数。关注官方 release notes。
5.6 Send 与循环的边界
不是所有循环都应该换成 Send。有些场景保持循环更合理:
适合 Send 的场景:
- 每次迭代是独立的 LLM/API 调用
- 迭代次数不确定(运行时决定)
- 需要独立重试和追踪
- 结果需要 reducer 聚合
适合循环的场景:
- 纯计算逻辑,不涉及 IO
- 迭代之间有依赖(后一次依赖前一次的结果)
- 迭代次数固定且很少(小于 5)
- 性能不是瓶颈
混合策略:
对于"很多小任务"的场景,考虑批处理:
python
# 不是 100 个 Send,而是 10 个 Send,每个处理 10 个 items
def batched_router(state):
batch_size = 10
items = state["items"]
return [
Send("batch_processor", {
"items": items[i:i + batch_size]
})
for i in range(0, len(items), batch_size)
]
def batch_processor(state):
# 内部循环处理 10 个 items
results = []
for item in state["items"]:
results.append(process(item))
return {"results": results} # 返回 10 个结果
这样 100 个任务变成 10 个 Send,每个 Send 内部处理 10 个,平衡了并行度和开销。
六、面试题速查
以下是我面试别人和被别人面试时,真实遇到过的 Send 相关问题,附答案要点。
Q1:LangGraph 的 Send 和普通条件边有什么区别?
普通条件边返回字符串(节点名),调度器根据字符串找到下一个节点,串行执行。Send 返回
Send对象列表,每个 Send 包含目标节点名和状态覆盖字典,调度器并行启动所有 Send 指定的节点。本质区别是"串行单播" vs "并行广播"。普通条件边是"走哪条路",Send 是"一次派多少人、每个人带什么装备"。
Q2:Send 的并行度由什么决定?
由 LangGraph 内部调度器决定,默认使用 Python 的
ThreadPoolExecutor。实际并行度受限于:
- 线程池大小(受 Python
ThreadPoolExecutor上限与config中max_concurrency约束)- 目标节点的性质(同步函数 vs async 函数)
- 外部依赖的并发能力(如 LLM API 的 rate limit)
用户不直接控制并发数,但可以通过控制 Send 列表长度间接控制。对于 async 节点,LangGraph 用
asyncio.gather并发,并行度更高。
Q3:多个 Send 同时写同一个状态字段,如何避免覆盖?
必须用
Annotated[T, reducer]声明 reducer。对于列表字段,常用operator.add做追加。默认无 reducer 时是覆盖行为,并行写入时后完成的覆盖先完成的,数据会丢失。示例:
results: Annotated[List[str], operator.add]
Q4:Send 能不能根据前序节点的输出动态决定?
不能。Send 列表在路由函数中一次性生成,是一次性派发的。路由函数本身不能执行耗时操作(如调用 LLM)。
如果需要前序输出影响后续 Send,有两种方案:
- 分阶段路由:先执行一轮评估节点,把评估结果写回状态,再触发条件边生成 Send。
- 子图嵌套:把动态决策封装成子图。
Q5:Map-Reduce 在 LangGraph 中的典型实现方式?
三个阶段:
- Split:分块节点把输入拆成 N 份(如文档分块)。
- Map :条件边返回 N 个 Send 到同一个 map 节点,每份带独立的 chunk。所有 map 节点并行执行,结果通过
operator.add聚合。- Reduce:所有 map 完成后,调度器自动触发 reduce 节点汇总结果。
核心设计:用
Annotated[List[T], operator.add]聚合 map 输出,reduce 节点自动等待所有 map 完成。
Q6:Send 的目标节点不在图里会怎样?
编译时报错。
add_conditional_edges的第三个参数(可能的节点映射)会触发 LangGraph 的编译期校验。如果 Send 的目标节点没有在add_node里注册过,compile()会抛出ValueError: Node 'xxx' not found in graph。
Q7:Send 的失败处理策略是什么?
默认策略:"全有或全无"。任一 Send 失败,整个图中断,所有并行的 Send 结果丢弃(除非从 checkpoint 恢复)。
容错 workaround:在目标节点内部 try-except 捕获异常,返回错误标记而不是抛异常。下游节点根据标记过滤结果。LangGraph 目前没有原生的 per-Send 错误回调或容错策略。
Q8:为什么不能在节点内部 for 循环调用另一个节点?
三点原因:
- 性能:串行执行,失去并行能力。LangGraph 调度器看不到内部循环,无法优化。
- 可靠性:失去节点级重试。整个节点作为一个整体 checkpoint,内部循环的任何失败导致整个节点重试。
- 可观测性:LangGraph Studio、LangSmith 等追踪工具只能看到外层节点,看不到内部循环的每次调用。调试困难。
Q9:Send 的状态副本是怎么合并的?
Send 的
arg参数(字典)会覆盖全局状态中对应的字段,形成目标节点的输入状态。目标节点看到的是:全局状态 + Send 局部覆盖(覆盖优先级更高)。目标节点返回的字段再经 reducer 合并回全局状态。如果字段没有 reducer,默认行为是覆盖。
Q10:Send 和子图(Subgraph)怎么选?
Send 适合"同一节点逻辑的 N 次并行调用",所有调用是同质化的。
子图适合"不同阶段的嵌套工作流",子图内部有自己的节点和边。
如果 map 阶段本身是一个复杂子流程(多节点、有条件分支),把 map 逻辑包成子图,然后在 Send 里调用子图的起始节点。LangGraph 支持在 Send 中指定子图的入口节点。
Q11:Send 机制下,如何确保 Reduce 节点等到所有 Map 节点完成才执行?
LangGraph 调度器会自动处理。条件边返回 Send 列表后,调度器会追踪所有 Send 对应的 Task。所有 Task 完成后,调度器才会继续执行条件边的下游节点(即 Reduce 节点)。
不需要手动计数或等待。这是声明式依赖管理的好处------你声明了"这些任务完成后才能到下一步",调度器负责实现。
Q12:Send 能跨图使用吗?比如在父图里 Send 到子图的节点?
不能直接跨图 Send。Send 的目标节点必须是当前编译后的图内已注册的节点。
如果需要父图向子图发送任务,有两种方式:
- 把子图编译后作为节点注册到父图(
builder.add_node("subgraph", compiled_subgraph)),然后 Send 到 "subgraph" 这个节点名。- 在父图节点中手动调用子图(类似文章开头的反模式,不推荐)。
Q13:使用 Send 时,reducer 的返回值类型必须和字段声明一致吗?
是的。如果你的状态字段声明为
Annotated[List[str], operator.add],那么所有向这个字段写入的节点都必须返回List[str]类型。常见错误:节点返回字符串而不是列表,导致
operator.add抛出 TypeError。确保每个并行节点返回的数据结构一致。
Q14:Send 列表可以为空吗?会发生什么?
可以返回空列表
[]。这意味着本次不派发任何任务,图会正常结束当前这条路径(效果等同直接走到END),不会报错。但要注意:如果路由函数在特定输入下总是返回空列表,下游节点将永远不会被触发,容易让人误以为"任务卡住了"。建议在路由函数里对空列表情况显式处理:返回一个兜底的 Send(如
Send("fallback", {...})),或给条件边配置 fallback 路径,保证业务流程有明确出口。
七、最佳实践速查表
我把日常使用 Send 的经验整理成一张速查表,写代码前扫一眼:
| 检查项 | 做法 | 反例 |
|---|---|---|
| 状态字段 reducer | 所有会被并行写入的字段必须用 Annotated[T, reducer] |
用裸 List[str],并行覆盖丢数据 |
| 路由函数耗时 | 路由函数里只做轻量计算,不调用 LLM 或 IO | 在路由函数里调用 LLM 做动态决策 |
| Send 目标校验 | 所有目标节点必须在 add_node 里注册 |
Send 到未注册节点,编译报错 |
| 并发数量控制 | 一次 Send 控制在 100 个以内 | 一次 Send 1000 个,内存爆炸 |
| 状态大小控制 | Send 的 arg 只传必要字段,大数据用外部存储 | Send 里传 10MB 文档 chunk |
| 错误处理 | 目标节点内 try-except,返回错误标记 | 直接抛异常导致整个图中断 |
| 输出格式统一 | 所有并行节点返回相同结构,方便 reducer | 有的返回 dict 有的返回 list |
| 节点职责单一 | 一个节点只做一件事,循环逻辑拆成 Send | 一个节点里塞 5 层嵌套逻辑 |
| 聚合节点等待 | 不用手动计数,调度器自动等所有 Send 完成 | 在聚合节点里轮询检查完成状态 |
| 类型标注完整 | 每个节点的 state 参数都有 TypedDict 类型 | 用裸 dict,IDE 无法提示 |
我的 workflow
- 先画 ASCII 图:哪些节点可以并行?哪些必须串行?
- 确定 reducer:每个会被并行写入的字段用什么合并策略?
- 写状态类型:全局状态 + 每个节点的局部状态(用于 Send 覆盖)
- 写路由函数:列表推导式生成 Send 列表
- 组装图:add_node -> add_edge -> add_conditional_edges -> compile
- 跑单测:先用 mock LLM 跑通流程,再接入真实 API
- 加容错:目标节点里 try-except,返回错误标记
- 开追踪:接入 LangSmith,观察并行度和耗时分布
八、参考资源
- LangGraph 官方文档 - Send 机制:https://langchain-ai.github.io/langgraph/how-tos/map-reduce/
- LangGraph 条件边文档:https://langchain-ai.github.io/langgraph/concepts/low_level/#conditional-edges
- LangGraph GitHub 源码(Pregel 调度器):https://github.com/langchain-ai/langgraph
- LangGraph 更新日志(Send 相关变更):关注 langchain-ai/langgraph Releases 页面
- LangGraph 官方示例 - 并行化工作流:https://langchain-ai.github.io/langgraph/how-tos/branching/
写在最后
我第一次用 Send 重构项目,是把一个长文档处理流程从 40 秒降到 8 秒。数字上的提升是次要的,更重要的是思维方式的变化。
在那之前,我对 LangGraph 的理解停留在"用图画工作流"。Send 让我意识到,LangGraph 真正的威力不在于可视化,而在于声明式并发调度------你把任务描述成"这些节点可以并行",调度器负责最优执行。
这和现代前端框架(React 的声明式 UI)或者基础设施即代码(Terraform 的声明式编排)是同一个范式:你描述"想要什么样的系统",框架负责"怎么实现"。
如果你还在节点里写 for 循环,现在就是替换的时机。Send 不只是一段代码,它代表一种思维方式:让图调度器做它该做的事,你的节点只负责业务逻辑。
把并发交给框架,把心思放回问题本身。
版本记录
- v1.0:2026-08-27,基于 LangGraph 1.0(兼容 0.2.x+)编写