并行之力——Send API 与动态控制流 — LangGraph 实战——构建跨平台爆款图文 Agent 第5篇

第5章:并行之力——Send API 与动态控制流

本章目标

读完本章你会:

  • 能用 Send API 让多个节点并行执行,实现 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} 秒")

运行后的关键观察

  1. 五个 explore_platform 并行执行——控制台输出会交错打印(因为并行),不是按顺序"微信→小红书→微博→B站→知乎"
  2. platform_reports 收集了 5 条报告——operator.add reducer 自动合并
  3. 时间对比:串行需要约 5× 的时间,并行约等于最慢的那个平台 + 汇总时间

本章小结

  1. Send API 让条件边函数返回 [Send(node, arg), ...] 来实现 fan-out——N 条并行执行路径同时启动。
  2. Reducer 是并行执行的基石——所有可能被并行写入的 State 字段必须有 reducer(operator.add / add_messages),否则会抛 INVALID_CONCURRENT_GRAPH_UPDATE
  3. 并行总耗时 ≈ 最慢分支的耗时——而非所有分支耗时之和。对 I/O 密集型任务(LLM 调用、网络请求)效率提升显著。
  4. Command(goto=..., update=...) 让节点在运行时动态指定下一站——适合异常跳转和紧急通道,但应作为"例外"而非"规则"使用。
  5. 所有并行分支到达同一节点后自动聚合——LangGraph 自动追踪分支生命周期,无需手动 join。
  6. 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 用于并行结果合并

posted @ 2026-06-16 11:40  Yobeeo  阅读(1)  评论(0)    收藏  举报