并行之力——Send API 与动态控制流 — LangGraph 实战——构建跨平台爆款图文 Agent 第5篇
第5章:并行之力——Send API 与动态控制流
本章目标
读完本章你会:
- 能用
SendAPI 让多个节点并行执行,实现 fan-out / map-reduce 模式 - 能用
Command(goto=..., update=...)在节点内部动态改道,跳过条件边 - 能让 LocalTrend 同时探索微信公众号、小红书、微博、B站、知乎五个平台
- 能处理并行执行中的状态合并冲突
知识讲解
从一个生活例子开始
你是一家报社的调查主编。今天收到线索:某公司涉嫌违法违规。你需要查清这件事,但涉及的线索分布在五个城市——北京、上海、广州、深圳、杭州。
你有两个选择:
方案 A(串行): 你一个人去北京查三天 → 飞上海查三天 → 飞广州查三天 → 飞深圳查三天 → 飞杭州查三天。15 天后出报告。
方案 B(并行): 你派五个记者分别去五个城市,每人查三天,回来把报告汇总。3 天后出报告。
这就是 Send API 做的事。它让你把一条执行路径"广播"成 N 条并行路径——每条路径处理不同的数据,最后结果自动汇总。
在 LocalTrend 的语境里:你要探索五个平台(微信公众号、小红书、微博、B站、知乎)的爆款趋势。串行的话,Agent 一个一个搜,用户等到睡着。用 Send 并行——五个平台的探索同时进行,用户只等最慢的那个。
| 调查团队 | LangGraph |
|---|---|
| 主编分配任务 | 条件边函数返回 [Send(...), Send(...), ...] |
| 五个记者同时行动 | 五个 explore_platform 节点并行执行 |
| 记者发回报导 | 每个节点返回部分 State 更新 |
| 主编汇总成一篇 | Reducer 自动合并结果(见第 1-2 章的 Annotated) |
工作原理
Send API:一条边如何变成 N 条
回顾前面章节的条件边:
# 第 2 章的写法——返回一个节点名字符串
def should_continue(state) -> str:
return "tools" # 或 END
builder.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END})
条件边函数返回一个字符串→ 图沿着那一条边继续。单行道。
Send API 的写法:
from langgraph.graph import Send
# 第 5 章的写法——返回一个 Send 对象列表
def fan_out_to_platforms(state) -> list[Send]:
platforms = state["platforms"] # ["微信", "小红书", "微博", "B站", "知乎"]
return [Send("explore_platform", {"platform": p}) for p in platforms]
条件边函数返回一个 Send 列表→ 图同时创建 N 条并行执行路径。每条路径都从 "explore_platform" 节点开始,但接收不同的参数。五车道高速公路。
思考一下: 并行执行时,五个 explore_platform 节点都修改同一个 State——如果它们都往 messages 里追加内容,消息的顺序会是什么?会不会冲突?
答案:不会冲突。因为 messages 使用的是 add_messages reducer——它不关心追加的顺序,只保证不丢数据。但它们返回的 messages 在最终 State 中的顺序是不确定的(取决于哪个先完成)。如果你需要确定性的顺序,应该在 State 中用 Annotated[list, your_custom_merge_function] 定义专用的合并逻辑。
Command API:在节点内部改道
第 4 章你用了 Command(resume=...) 来恢复被 interrupt 暂停的图。Command 还有另一种用法——在节点内部直接指定下一个要去的节点,绕过条件边的路由函数:
def smart_node(state) -> dict:
if state["urgent"]:
# Command 同时做三件事:更新 State + 指定下一站
return Command(update={"priority": "high"}, goto="fast_track")
else:
return Command(update={"priority": "normal"}, goto="standard_process")
Command(update=..., goto=...) 相当于说:"我不走你画好的条件边了,我直接跳到这里。" 这和 add_conditional_edges 并不冲突——条件边是声明式路由(在构建图时确定),Command 的 goto 是命令式路由(在运行时决定)。两者互补:
- 用条件边处理常规的、可预测的分支
- 用 Command goto 处理异常的、动态的改道
⚠️ 常见坑:不要在图的所有节点上滥用
Command(goto=...)。如果每个节点都手动指定下一站,图的结构就退化成了一堆函数调用,失去了 LangGraph 可视化、可追踪、可审查的优势。Command goto 的使用频率应该是"例外"而非"规则"。
代码实战
基础版 A:Send API——最简 fan-out 示例
从最简单的并行场景开始理解 Send 的机制。新建文件 chapter05_send_basics.py:
"""
第 5 章 基础演示 A:Send API 的 fan-out 机制
一个主管 → N 个并行工人 → 结果汇总
"""
import operator
from typing import TypedDict, Annotated
from langgraph.graph import StateGraph, START, END
from langgraph.graph import Send #
# ============================================================
# State——results 使用 operator.add reducer 来合并并行结果
# ============================================================
class ParallelState(TypedDict):
tasks: list[str] # 待处理的任务列表
results: Annotated[list[str], operator.add] # 每个并行分支的结果追加到此
# ============================================================
# 节点
# ============================================================
def create_tasks(state: ParallelState) -> dict:
"""主管节点:生成任务列表"""
print("[create_tasks] 分配任务:A、B、C、D、E")
return {"tasks": ["A", "B", "C", "D", "E"]}
def worker(state: ParallelState) -> dict:
""" 工人节点——会被 Send 并行调用多次,每次处理一个 task"""
# ⚠ 这里的 state 包含当前 Send 传入的额外字段
# 例如 Send("worker", {"task": "A"}) 会把这个 task 合并到 state 中
task = state.get("task", state["tasks"][-1] if state["tasks"] else "unknown")
import time
print(f" [worker] 处理任务 {task}...")
# 模拟每个任务耗时不同——观察并行效果
task_duration = {"A": 1.0, "B": 0.5, "C": 1.5, "D": 0.3, "E": 1.2}
time.sleep(task_duration.get(task, 0.5))
result = f"任务{task}完成(耗时{task_duration.get(task, 0.5)}秒)"
print(f" [worker] ✅ {result}")
return {"results": [result]}
def summarize(state: ParallelState) -> dict:
"""汇总节点:所有并行任务完成后自动到达这里"""
print(f"\n[summarize] 汇总 {len(state['results'])} 个结果:")
for r in state["results"]:
print(f" - {r}")
return {}
# ============================================================
# 关键:fan-out 函数——返回 Send 列表
# ============================================================
def fan_out_to_workers(state: ParallelState) -> list[Send]:
"""从 create_tasks 出发,为每个任务创建一个并行分支"""
tasks = state["tasks"]
print(f"\n[fan_out] 分发 {len(tasks)} 个任务到并行 worker...")
# 每个 Send 创建一个独立的分支
# 所有分支的 worker 节点同时开始执行
return [Send("worker", {"task": t}) for t in tasks]
# ============================================================
# 构建图
# ============================================================
def build_fanout_graph():
builder = StateGraph(ParallelState)
builder.add_node("create_tasks", create_tasks)
builder.add_node("worker", worker)
builder.add_node("summarize", summarize)
builder.add_edge(START, "create_tasks")
# 条件边 + Send 列表 = 并行 fan-out
builder.add_conditional_edges(
"create_tasks",
fan_out_to_workers,
# ⚠ 当条件边函数返回 Send 列表时,不需要 path_map
# LangGraph 自动识别并做 fan-out
)
# 所有 worker 完成后 → summarize(自动聚合)
builder.add_edge("worker", "summarize")
builder.add_edge("summarize", END)
return builder.compile()
if __name__ == "__main__":
import time
graph = build_fanout_graph()
print("=" * 60)
print(" 并行执行演示")
print("=" * 60)
start = time.time()
result = graph.invoke({"tasks": [], "results": []})
elapsed = time.time() - start
print(f"\n⏱ 总耗时: {elapsed:.1f} 秒")
# 五个任务串行需要:1.0+0.5+1.5+0.3+1.2 = 4.5 秒
# 并行只需要最慢的那个:1.5 秒
print(f" 串行理论耗时: 4.5 秒")
print(f" 并行实际耗时: {elapsed:.1f} 秒")
print(f" 加速比: {4.5/elapsed:.1f}x")
运行这段代码,关注两个数字:
- 串行理论耗时是所有任务时间之和(4.5 秒)
- 并行实际耗时应该接近最慢的单个任务(~1.5 秒 + 框架开销)
这就是 Send API 的核心价值——N 个独立任务同时执行,总耗时 ≈ max(单个任务耗时),而非 sum。
逐行解析
results: Annotated[list[str], operator.add]
这是并行执行的关键基础设施。五个 worker 并行执行,每个都返回 {"results": [...]}——如果没有 operator.add reducer,后面的 worker 会覆盖前面的结果。有了 reducer,所有结果被追加合并到一个列表中。
⚠️ 常见坑:如果 State 的字段没有定义 reducer 且多个并行节点都修改它,LangGraph 会抛出
INVALID_CONCURRENT_GRAPH_UPDATE错误。所有可能被并行写入的字段都必须有 reducer。
fan_out_to_workers(state) -> list[Send]
这是本章最关键的代码。和常规条件边函数返回字符串不同,这里的返回值是一个 Send 对象列表。LangGraph 识别到返回值是 list[Send] 时,自动进入 fan-out 模式——为每个 Send 创建一个独立的执行分支。
Send("worker", {"task": t})
Send 构造函数的两个参数:
- 第一个:目标节点名(所有分支都去同一个节点)
- 第二个:一个 dict,会被注入到该分支的 State 中——也就是说,在 worker 节点里可以通过
state["task"]读到这个值
所有 worker 完成后自动聚合
不需要手动写"等所有 worker 完成"的逻辑。LangGraph 自动追踪所有并行分支——当最后一个 worker 返回后,所有分支的 State 被 reducer 合并,然后图继续从 "worker" 节点的出边往下走(到 "summarize")。
基础版 B:Command API——动态改道
在深入 Send 实战之前,先理解 Command 的另一个面孔。新建文件 chapter05_command_goto.py:
"""
第 5 章 基础演示 B:Command(goto=...) 动态改道
节点内部直接指定下一站,绕过条件边
"""
from typing import TypedDict, Literal
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command #
class RouteState(TypedDict):
path: str # 当前选择的路径
data: str # 携带的数据
def entry_point(state: RouteState) -> dict:
"""入口:模拟分析请求,判断紧急程度"""
print("[entry] 分析请求...")
# 模拟:50% 概率紧急,50% 正常
import random
urgent = random.choice([True, False])
if urgent:
print(" ⚡ 紧急请求!走快速通道")
# Command 直接在节点内决定下一站
return Command(update={"path": "fast", "data": "urgent-001"}, goto="fast_handler")
else:
print(" 正常请求,走标准通道")
return Command(update={"path": "standard", "data": "normal-001"}, goto="standard_handler")
def fast_handler(state: RouteState) -> dict:
"""快速通道:跳过审核,直接处理"""
print(f"[fast_handler] 快速处理: {state['data']}")
return {}
def standard_handler(state: RouteState) -> dict:
"""标准通道:正常流程"""
print(f"[standard_handler] 标准处理: {state['data']}")
return {}
# ============================================================
# 对比:如果没有 Command,怎么实现同样逻辑?
# ============================================================
# 需要在 entry_point 之后加条件边 + 路由函数:
#
# def old_router(state) -> str:
# return "fast_handler" if state["path"] == "fast" else "standard_handler"
#
# builder.add_conditional_edges("entry_point", old_router, {
# "fast_handler": "fast_handler",
# "standard_handler": "standard_handler",
# })
#
# Command(goto=...) 的优雅之处:决策和跳转在一个地方,不需要额外的路由函数
def build_goto_graph():
builder = StateGraph(RouteState)
builder.add_node("entry_point", entry_point)
builder.add_node("fast_handler", fast_handler)
builder.add_node("standard_handler", standard_handler)
builder.add_edge(START, "entry_point")
# ⚠ 入口节点已经用 Command 指定了下一站,不需要条件边
# 但仍然需要告诉 LangGraph 这两个节点的去向
builder.add_edge("fast_handler", END)
builder.add_edge("standard_handler", END)
return builder.compile()
if __name__ == "__main__":
graph = build_goto_graph()
print("=" * 50)
print("运行 5 次,观察随机路由:")
print("=" * 50)
for i in range(5):
print(f"\n--- 第 {i+1} 次 ---")
result = graph.invoke({"path": "", "data": ""})
print(f" 路径: {result['path']}, 数据: {result['data']}")
运行后观察:每次执行可能走不同的路径——Command(goto=...) 在运行时动态决定走向,不需要预先定义所有条件分支。
扩展版:LocalTrend 多平台并行探索
现在把两个新武器(Send + Command)一起用在 LocalTrend 上。新建文件 chapter05_multiplatform.py:
"""
第 5 章 扩展演示:LocalTrend 多平台并行探索
Send API fan-out → 五个平台同时搜索 → 结果汇总分析
"""
import os
import operator
import sqlite3
from typing import TypedDict, Annotated, Literal
from langgraph.graph import StateGraph, START, END
from langgraph.graph import Send
from langgraph.graph.message import add_messages
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.types import interrupt, Command
from langchain_core.messages import HumanMessage, AIMessage, ToolMessage
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
# ============================================================
# 配置
# ============================================================
llm = ChatOpenAI(
model="deepseek-chat",
api_key=os.getenv("DEEPSEEK_API_KEY", "your-api-key-here"),
base_url="https://api.deepseek.com",
temperature=0.7,
)
# ============================================================
# State——注意所有并行共享字段都有 reducer
# ============================================================
class MultiPlatformState(TypedDict):
# ReAct 消息循环
messages: Annotated[list, add_messages]
iteration_count: int
# 多平台探索
platforms: list[str] # 要探索的平台列表
platform_reports: Annotated[list[str], operator.add] # 并行收集各平台报告
current_platform: str # Send 注入:当前分支的平台名
# 内容生成 + 审核
generated_content: str
review_decision: str
review_feedback: str
published: bool
# ============================================================
# 工具——这次搜索更"懂"平台
# ============================================================
@tool
def search_platform_trends(platform: str, query: str) -> str:
"""搜索指定平台的爆款内容趋势。参数 platform: 平台名;query: 搜索关键词"""
print(f" [搜索] {platform}: {query}")
# 模拟不同平台返回不同的趋势数据
trends = {
"微信公众号": f"[{platform}] 爆款:深度长文+数据可视化,'深度分析'系列完读率 45%+。热门话题:AI 职场、中年转型。",
"小红书": f"[{platform}] 爆款:封面标题党+教程合集,'保姆级'关键词 CTR 提升 60%。热门话题:AI 工具推荐、副业月入。",
"微博": f"[{platform}] 爆款:热搜话题+态度鲜明,转发量 Top 1% 的内容都有强情感触动。热门话题:AI 裁员、大厂八卦。",
"B站": f"[{platform}] 爆款:知识区+弹幕互动,'一口气看完'系列完播率 70%+。热门话题:AI 行业解析、编程入门。",
"知乎": f"[{platform}] 爆款:深度回答+专业背书,万字长文收藏率是短回答的 8 倍。热门话题:AI 算法、职业选择。",
}
return trends.get(platform, f"[{platform}] 趋势数据待补充")
tools = [search_platform_trends]
llm_with_tools = llm.bind_tools(tools)
# ============================================================
# 节点
# ============================================================
def planner_node(state: MultiPlatformState) -> dict:
""" 计划节点:决定探索哪些平台,进入 fan-out"""
# 默认探索全部五个平台
all_platforms = state.get("platforms", ["微信公众号", "小红书", "微博", "B站", "知乎"])
print(f"\n[planner] 计划探索 {len(all_platforms)} 个平台:{', '.join(all_platforms)}")
return {
"platforms": all_platforms,
# 给 Agent 发指令:分析所有平台的共同趋势
"messages": [HumanMessage(
content=f"请帮我逐一分析以下平台的爆款内容趋势:{', '.join(all_platforms)}。"
f"每个平台单独搜索,最后汇总共性规律。"
)],
}
# fan-out 函数:为每个平台创建一个并行分支
def fan_out_platforms(state: MultiPlatformState) -> list[Send]:
platforms = state["platforms"]
print(f"[fan_out] 分发 {len(platforms)} 个并行探索任务...")
# 每个 Send → 一个独立的 explore_platform 执行
return [Send("explore_platform", {"current_platform": p}) for p in platforms]
def explore_platform(state: MultiPlatformState) -> dict:
""" 单平台探索节点——会被并行调用 N 次"""
platform = state["current_platform"]
print(f"\n [探索] 开始分析 {platform}...")
# 使用 LLM + 工具搜索该平台趋势
search_prompt = [HumanMessage(content=f"搜索{platform}的爆款内容趋势")]
response = llm_with_tools.invoke(search_prompt)
# 如果 LLM 要调工具,执行它
if hasattr(response, "tool_calls") and response.tool_calls:
tool_map = {t.name: t for t in tools}
tool_msgs = []
for tc in response.tool_calls:
# 自动填充 platform 参数
args = dict(tc["args"])
if "platform" not in args:
args["platform"] = platform
result = tool_map[tc["name"]].invoke(args)
tool_msgs.append(ToolMessage(content=result, tool_call_id=tc["id"]))
print(f" ✅ [{platform}] 搜索完成")
# 基于工具结果生成该平台的简短报告
context = "\n".join([tm.content for tm in tool_msgs])
summary_response = llm.invoke([
HumanMessage(content=f"基于以下信息,用一句话总结{platform}的爆款特征:\n{context}")
])
report = f"【{platform}】{summary_response.content}"
else:
report = f"【{platform}】{response.content}"
print(f" [{platform}] 报告: {report[:80]}...")
return {"platform_reports": [report]}
def synthesize_reports(state: MultiPlatformState) -> dict:
"""汇总所有平台的报告,提炼跨平台爆款规律"""
reports = state["platform_reports"]
print(f"\n[synthesize] 汇总 {len(reports)} 个平台的报告...")
combined = "\n".join(reports)
synthesis_prompt = HumanMessage(content=(
f"以下是五个社交媒体平台的爆款内容分析报告:\n\n{combined}\n\n"
f"请提炼出跨平台的共同爆款规律(不超过 3 条核心规律)。"
))
response = llm.invoke([synthesis_prompt])
print(f" 跨平台规律: {response.content[:120]}...")
return {
"messages": [synthesis_prompt, response],
"generated_content": response.content, # 复用为生成的内容
}
def review_gate(state: MultiPlatformState) -> dict:
"""审核网关(与第 4 章相同)"""
content = state.get("generated_content", "")
print(f"\n{'='*60}")
print(f" 多平台分析结果:")
print(f"{'='*60}")
print(content)
print(f"{'='*60}")
decision = interrupt("请审核以上分析结果。回复 'approve' 批准,或输入意见。")
if decision.lower() in ("approve", "批准"):
return {"review_decision": "approved"}
return {"review_decision": "rejected", "review_feedback": decision}
def handle_approved(state: MultiPlatformState) -> dict:
print("\n✅ 分析报告已批准")
return {"published": True}
def handle_rejected(state: MultiPlatformState) -> dict:
print(f"\n❌ 驳回,意见: {state.get('review_feedback', '')}")
# 将驳回意见反馈给 planner,让它重新规划
return {
"messages": [HumanMessage(
content=f"分析被驳回。意见:{state.get('review_feedback', '')}。请重新分析。"
)],
}
def review_router(state: MultiPlatformState) -> Literal["approved", "rejected"]:
return "approved" if state["review_decision"] == "approved" else "rejected"
# ============================================================
# 构建图——注意 Send 相关的边
# ============================================================
def build_multi_platform_graph(db_path="localtrend_multi.db"):
builder = StateGraph(MultiPlatformState)
builder.add_node("planner", planner_node)
builder.add_node("explore_platform", explore_platform)
builder.add_node("synthesize_reports", synthesize_reports)
builder.add_node("review_gate", review_gate)
builder.add_node("handle_approved", handle_approved)
builder.add_node("handle_rejected", handle_rejected)
builder.add_edge(START, "planner")
# fan-out 边:planner → N 个并行 explore_platform
builder.add_conditional_edges("planner", fan_out_platforms)
# 所有 explore_platform 完成后 → 汇总
builder.add_edge("explore_platform", "synthesize_reports")
# 汇总 → 审核 → 批准/驳回
builder.add_edge("synthesize_reports", "review_gate")
builder.add_conditional_edges("review_gate", review_router, {
"approved": "handle_approved",
"rejected": "handle_rejected",
})
builder.add_edge("handle_approved", END)
# 驳回后回到 planner,重新探索(但会更聚焦于驳回意见)
builder.add_edge("handle_rejected", "planner")
conn = sqlite3.connect(db_path, check_same_thread=False)
return builder.compile(checkpointer=SqliteSaver(conn))
# ============================================================
# 运行
# ============================================================
if __name__ == "__main__":
import time
graph = build_multi_platform_graph()
config = {"configurable": {"thread_id": "multi-platform-demo-1"}}
print("=" * 60)
print(" LocalTrend 多平台并行探索")
print("=" * 60)
start = time.time()
# 启动图——planner → fan-out → 五个并行探索 → 汇总 → 审核暂停
result = graph.invoke(
{
"messages": [],
"iteration_count": 0,
"platforms": ["微信公众号", "小红书", "微博", "B站", "知乎"],
"platform_reports": [],
"current_platform": "",
"generated_content": "",
"review_decision": "",
"review_feedback": "",
"published": False,
},
config
)
elapsed = time.time() - start
print(f"\n⏱ 探索阶段耗时: {elapsed:.1f} 秒")
print(f" 收集到 {len(result['platform_reports'])} 个平台报告")
# 模拟审核通过
print("\n 审核:批准!")
result = graph.invoke(Command(resume="approve"), config)
print(f"发布状态: {'✅ 已发布' if result['published'] else '❌ 未发布'}")
print(f"\n 串行探索 5 个平台预计耗时: {elapsed * 4:.1f} 秒")
print(f" 并行实际耗时: {elapsed:.1f} 秒")
运行后的关键观察
- 五个
explore_platform并行执行——控制台输出会交错打印(因为并行),不是按顺序"微信→小红书→微博→B站→知乎" platform_reports收集了 5 条报告——operator.addreducer 自动合并- 时间对比:串行需要约 5× 的时间,并行约等于最慢的那个平台 + 汇总时间
本章小结
- Send API 让条件边函数返回
[Send(node, arg), ...]来实现 fan-out——N 条并行执行路径同时启动。 - Reducer 是并行执行的基石——所有可能被并行写入的 State 字段必须有 reducer(
operator.add/add_messages),否则会抛INVALID_CONCURRENT_GRAPH_UPDATE。 - 并行总耗时 ≈ 最慢分支的耗时——而非所有分支耗时之和。对 I/O 密集型任务(LLM 调用、网络请求)效率提升显著。
- Command(goto=..., update=...) 让节点在运行时动态指定下一站——适合异常跳转和紧急通道,但应作为"例外"而非"规则"使用。
- 所有并行分支到达同一节点后自动聚合——LangGraph 自动追踪分支生命周期,无需手动 join。
- Send 注入的字段(如
{"current_platform": "微信"})会合并到该分支的 State 中,让同一节点在不同分支看到不同上下文。
关键术语
| 术语 | 释义 |
|---|---|
| Send API | LangGraph 的并行原语,Send(node, arg) 创建一个独立执行分支 |
| fan-out | 一条执行路径分裂为 N 条并行路径的模式,常用于"同一个任务处理多个对象" |
| map-reduce | fan-out(map)→ 并行处理 → 自动聚合(reduce)的经典并行计算模式 |
| Command(goto=...) | 在节点内部动态指定下一站,跳过声明的条件边 |
| INVALID_CONCURRENT_GRAPH_UPDATE | 多个并行节点写入同一无 reducer 字段时抛出的错误 |
| operator.add | Python 标准库的列表追加函数,作为 reducer 用于并行结果合并 |

浙公网安备 33010602011771号