LangGraph设计与实现-第12章-Send 与动态并行

《LangGraph 设计与实现》完整目录

第12章 Send 与动态并行

12.1 引言

在前面的章节中,我们已经了解了 LangGraph 的静态图编译流程:节点通过边连接、Channel 传递状态、Pregel 按超步调度。然而,现实世界中的许多工作流并不是在编译时就能确定所有执行路径的。考虑这样一个场景------用户输入一篇文章,我们需要对文章中的每个段落分别进行翻译、摘要或情感分析,然后把所有结果汇总。段落的数量在编译时未知,只有在运行时读到输入后才能确定。

这就是 动态并行 的核心需求:在运行时根据数据决定要派生多少个并行任务,每个任务可以携带不同的输入,最终所有任务的输出通过 reducer 汇聚回主图状态。LangGraph 通过 Send 对象和 Topic Channel 精巧地解决了这个问题,实现了经典的 map-reduce 模式。

本章将从 Send 类的源码出发,追踪它在条件边中的返回、写入 TASKS Channel、被 prepare_next_tasks 消费、最终派生为并行 PregelExecutableTask 的完整链路。我们会深入 Topic Channel 的 pub/sub 语义,理解它如何支撑动态 fanout;我们还会分析条件边如何返回多个 Send 对象,以及 Command.goto 中嵌入 Send 的高级用法。

:::tip 本章要点

  1. Send 对象的数据结构与语义------向指定节点发送自定义输入
  2. Topic Channel 的 pub/sub 机制------支持多值累积与逐步消费
  3. Map-Reduce 模式的完整实现------从 fanout 到 aggregation
  4. 动态 fanout 的调度细节------prepare_push_task_send 的执行链路
  5. 条件边返回多个 Send 的工程实践与限制 :::

12.2 Send 对象的设计

12.2.1 数据结构

Send 是 LangGraph 中最简洁的数据结构之一,定义在 langgraph/types.py 中:

python 复制代码
class Send:
    """A message or packet to send to a specific node in the graph."""

    __slots__ = ("node", "arg")

    node: str
    arg: Any

    def __init__(self, /, node: str, arg: Any) -> None:
        self.node = node
        self.arg = arg

    def __hash__(self) -> int:
        return hash((self.node, self.arg))

    def __repr__(self) -> str:
        return f"Send(node={self.node!r}, arg={self.arg!r})"

    def __eq__(self, value: object) -> bool:
        return (
            isinstance(value, Send)
            and self.node == value.node
            and self.arg == value.arg
        )

Send 只有两个字段:node 指定目标节点名,arg 是传递给该节点的自定义输入。使用 __slots__ 优化内存占用,实现了 __hash____eq__ 以支持去重和集合操作。

关键设计决策在于 arg 的类型是 Any------这意味着 Send 携带的输入可以与图的主状态 schema 完全不同 。当一个节点通过 Send 被调用时,它接收的不是完整的图状态,而是 Send 中指定的 arg。这是实现动态并行的核心:每个并行任务可以有自己独立的输入。

12.2.2 Send 与普通边的本质区别

在静态图中,边定义了数据流的拓扑关系,所有节点共享同一份状态。而 Send 改变了这个规则:

graph LR subgraph 静态边模式 A1[NodeA] -->|完整图状态| B1[NodeB] end subgraph 动态Send模式 A2[条件边函数] -->|arg1| B2a["NodeB #0"] A2 -->|arg2| B2b["NodeB #1"] A2 -->|arg3| B2c["NodeB #2"] end

静态边下,NodeB 的每次执行都读取完整的图状态。而在动态 Send 模式下,同一个节点可以被实例化多次,每个实例接收不同的输入。这种区别在源码中体现为两种不同的任务类型------PULL 任务(由 Channel 更新触发)和 PUSH 任务(由 Send 对象创建)。

12.2.3 Send 的哈希与去重

Send 实现了 __hash____eq__,这使得它可以被放入集合或用作字典键。哈希基于 (node, arg) 的元组,这意味着:

  • 两个 Send("node_a", {"x": 1}) 是相等的
  • Send("node_a", {"x": 1})Send("node_a", {"x": 2}) 是不同的
  • 这在 checkpoint 恢复时用于比对已执行的任务

需要注意的是,如果 arg 包含不可哈希的对象(如嵌套的列表),__hash__ 会抛出 TypeError。这是 Python 标准行为的自然延伸。

12.3 Topic Channel:动态并行的基础设施

12.3.1 Topic 的 Pub/Sub 语义

Send 对象最终被写入一个名为 __pregel_tasks(即常量 TASKS)的特殊 Channel,这个 Channel 的类型就是 Topic[Send]Topic Channel 定义在 langgraph/channels/topic.py 中,它实现了经典的发布/订阅模式:

python 复制代码
class Topic(
    Generic[Value],
    BaseChannel[Sequence[Value], Value | list[Value], list[Value]],
):
    """A configurable PubSub Topic."""

    __slots__ = ("values", "accumulate")

    def __init__(self, typ: type[Value], accumulate: bool = False) -> None:
        super().__init__(typ)
        self.accumulate = accumulate
        self.values = list[Value]()

    def update(self, values: Sequence[Value | list[Value]]) -> bool:
        updated = False
        if not self.accumulate:
            updated = bool(self.values)
            self.values = list[Value]()
        if flat_values := tuple(_flatten(values)):
            updated = True
            self.values.extend(flat_values)
        return updated

    def get(self) -> Sequence[Value]:
        if self.values:
            return list(self.values)
        else:
            raise EmptyChannelError

LastValue Channel(只保留最新值)不同,Topic 可以在一个超步内接收多个值。_flatten 辅助函数将嵌套列表展平,使得无论是单个 Send 还是 Send 列表都能正确处理。

12.3.2 _flatten 的展平逻辑

python 复制代码
def _flatten(values: Sequence[Value | list[Value]]) -> Iterator[Value]:
    for value in values:
        if isinstance(value, list):
            yield from value
        else:
            yield value

这个简洁的生成器函数实现了一层展平。它的设计意图是:Channel 的 update 方法接收的是一个"来自各任务的写入值列表"。每个写入值本身可以是单个 Send,也可以是一个 Send 列表。_flatten 把这两层统一为一个平坦的 Send 序列。

12.3.3 accumulate 模式

accumulate 参数控制 Topic 的跨超步行为:

  • accumulate=False(默认):每个超步开始时清空旧值,只保留本步写入的新值。这是 TASKS Channel 使用的模式------每一轮只处理当前步产生的 Send。
  • accumulate=True:值跨超步累积,适用于需要收集所有历史消息的场景。
sequenceDiagram participant Step1 as 超步 1 participant Topic as Topic Channel participant Step2 as 超步 2 Step1->>Topic: update([Send_A, Send_B]) Note over Topic: values = [Send_A, Send_B] Topic-->>Step2: get() -> [Send_A, Send_B] Step2->>Topic: update([Send_C]) Note over Topic: accumulate=false
清空旧值后追加
values = [Send_C]

12.3.4 Topic 与其他 Channel 的对比

graph TB subgraph Channel类型对比 LV[LastValue
保留最新单值
覆盖语义] BOA[BinaryOperatorAggregate
通过 reducer 聚合
如 operator.add] TP[Topic
多值列表
pub/sub 语义] NB[NamedBarrierValue
同步屏障
等待所有写入者] end LV -->|用于| S1[普通状态字段] BOA -->|用于| S2["Annotated[list, op.add]"] TP -->|用于| S3[TASKS 分发 Send] NB -->|用于| S4[节点同步]
特性 LastValue BinaryOperatorAggregate Topic
单步值数量 1 1(经 reducer 合并) N
更新语义 覆盖 聚合 追加
跨步保留 可选(accumulate)
典型用途 普通状态字段 Annotated[list, operator.add] TASKS 分发

12.4 Map-Reduce 模式的完整实现

12.4.1 经典用法

让我们通过一个完整的 map-reduce 示例来理解 Send 的工作流程:

python 复制代码
from typing import Annotated, TypedDict
from langgraph.types import Send
from langgraph.graph import END, START, StateGraph
import operator

class OverallState(TypedDict):
    subjects: list[str]
    jokes: Annotated[list[str], operator.add]

def continue_to_jokes(state: OverallState):
    """条件边函数:为每个 subject 生成一个 Send"""
    return [Send("generate_joke", {"subject": s}) for s in state["subjects"]]

def generate_joke(state: dict) -> dict:
    """工作节点:注意它接收的不是 OverallState,而是 Send 的 arg"""
    return {"jokes": [f"Joke about {state['subject']}"]}

builder = StateGraph(OverallState)
builder.add_node("generate_joke", generate_joke)
builder.add_conditional_edges(START, continue_to_jokes)
builder.add_edge("generate_joke", END)
graph = builder.compile()

result = graph.invoke({"subjects": ["cats", "dogs", "robots"]})
# {'subjects': ['cats', 'dogs', 'robots'],
#  'jokes': ['Joke about cats', 'Joke about dogs', 'Joke about robots']}

这里的关键在于:

  1. continue_to_jokes 返回一个 Send 列表(而非字符串节点名)
  2. 每个 Send 都指向 "generate_joke" 节点,但携带不同的输入
  3. generate_joke 接收的 state{"subject": "cats"} 这样的小字典,而不是完整的 OverallState
  4. 输出通过 Annotated[list[str], operator.add] 的 reducer 自动合并

12.4.2 执行时序

sequenceDiagram participant Input as 用户输入 participant Start as __start__ participant Cond as 条件边函数 participant TASKS as TASKS Channel participant J1 as generate_joke #0 participant J2 as generate_joke #1 participant J3 as generate_joke #2 participant Agg as Reducer(operator.add) participant End as __end__ Input->>Start: {subjects: [cats, dogs, robots]} Start->>Cond: 读取状态 Cond->>TASKS: 写入 [Send_0, Send_1, Send_2] Note over TASKS: Topic Channel 存储三个 Send par 并行执行超步 TASKS->>J1: Send("generate_joke", {subject: cats}) TASKS->>J2: Send("generate_joke", {subject: dogs}) TASKS->>J3: Send("generate_joke", {subject: robots}) end J1->>Agg: {jokes: ["Joke about cats"]} J2->>Agg: {jokes: ["Joke about dogs"]} J3->>Agg: {jokes: ["Joke about robots"]} Agg->>End: jokes 合并为完整列表

12.5 Send 的调度链路深度剖析

12.5.1 条件边写入 TASKS Channel

当条件边函数返回 Send 对象时,分支处理逻辑会识别返回值的类型。如果返回值是 Send 或包含 Send 的列表,它们会被写入 TASKS Channel。在 Command 的处理路径中,这个逻辑体现在 map_command 函数里:

python 复制代码
# langgraph/pregel/_io.py
def map_command(cmd: Command) -> Iterator[tuple[str, str, Any]]:
    """Map input chunk to a sequence of pending writes."""
    if cmd.graph == Command.PARENT:
        raise InvalidUpdateError("There is no parent graph")
    if cmd.goto:
        if isinstance(cmd.goto, (tuple, list)):
            sends = cmd.goto
        else:
            sends = [cmd.goto]
        for send in sends:
            if isinstance(send, Send):
                yield (NULL_TASK_ID, TASKS, send)  # 写入 TASKS Channel
            elif isinstance(send, str):
                yield (NULL_TASK_ID, f"branch:to:{send}", START)

注意这里的 NULL_TASK_ID------这表示写入操作来自图的控制流层面,而非某个具体的运行中的任务。Send 对象被写入到 TASKS Channel(__pregel_tasks),而普通的字符串节点名被写入到 branch:to:{node} Channel。

12.5.2 prepare_next_tasks 中的 PUSH 任务创建

prepare_next_tasks 中,TASKS Channel 中的 Send 对象被逐一转化为可执行任务:

python 复制代码
# langgraph/pregel/_algo.py
def prepare_next_tasks(...):
    input_cache: dict[INPUT_CACHE_KEY_TYPE, Any] = {}
    tasks: list[PregelTask | PregelExecutableTask] = []

    # 第一步:消费 TASKS Channel 中的 Send 对象(PUSH 任务)
    tasks_channel = cast(Topic[Send] | None, channels.get(TASKS))
    if tasks_channel and tasks_channel.is_available():
        for idx, _ in enumerate(tasks_channel.get()):
            if task := prepare_single_task(
                (PUSH, idx),  # task_path 标记为 PUSH 类型
                None,
                checkpoint=checkpoint,
                ...
            ):
                tasks.append(task)

    # 第二步:处理 PULL 类型的任务(常规节点触发)
    for name in candidate_nodes:
        if task := prepare_single_task(
            (PULL, name),
            ...
        ):
            tasks.append(task)

    return {t.id: t for t in tasks}

这里的核心区分是 PUSH vs PULL

  • PULL 任务:由 Channel 版本更新触发的常规节点执行
  • PUSH 任务:由 Send 对象显式创建的动态任务

PUSH 任务总是在 PULL 任务之前被创建,两者在同一个超步内并行执行。

12.5.3 prepare_push_task_send 的核心逻辑

prepare_single_task 识别到 task_path[0] == PUSH 且不是函数式 API 的 Call 时,它调用 prepare_push_task_send

python 复制代码
def prepare_push_task_send(task_path, task_id_checksum, *, ...):
    # 从 TASKS Channel 中按索引取出 Send 对象
    idx = cast(int, task_path[1])
    sends: Sequence[Send] = channels[TASKS].get()
    if idx < 0 or idx >= len(sends):
        return
    packet = sends[idx]
    if not isinstance(packet, Send):
        logger.warning(f"Ignoring invalid packet type {type(packet)}")
        return

    # 验证目标节点存在
    if packet.node not in processes:
        logger.warning(f"Ignoring unknown node name {packet.node}")
        return
    proc = processes[packet.node]

    # 生成确定性任务 ID
    triggers = PUSH_TRIGGER  # (PUSH,)
    checkpoint_ns = f"{parent_ns}{NS_SEP}{packet.node}" if parent_ns else packet.node
    task_id = task_id_func(
        checkpoint_id_bytes,
        checkpoint_ns,
        str(step),
        packet.node,
        PUSH,
        str(idx),
    )

任务 ID 的生成是确定性的------基于 checkpoint ID、节点名、步数和 Send 索引。这保证了相同的图在相同的状态下会生成完全相同的任务 ID,这对于 checkpoint 恢复和幂等性至关重要。

12.5.4 Send 的输入如何传递给节点

创建 PregelExecutableTask 时,Send 的 arg 直接作为任务的 input

python 复制代码
# 在 prepare_push_task_send 中
return PregelExecutableTask(
    packet.node,       # name:目标节点名
    packet.arg,        # input:这就是 Send.arg
    proc_node,         # proc:节点的 Runnable
    writes,            # writes:输出 deque
    config,            # 配置
    triggers,          # (PUSH,) 触发器标记
    proc.retry_policy or retry_policy,
    cache_key,
    task_id,
    task_path[:3],
    writers=proc.flat_writers,
    subgraphs=proc.subgraphs,
)

这意味着 generate_joke 函数接收到的 state 参数实际上就是 packet.arg,即我们在 Send("generate_joke", {"subject": "cats"}) 中传入的字典。

12.6 动态 Fanout 的完整数据流

flowchart TB subgraph 编译期 CE[条件边注册] --> BranchSpec[BranchSpec 存储分支函数] BranchSpec --> Writer[生成 ChannelWrite 写入器] end subgraph "运行期 - 超步 N" CondFn[条件边函数执行] -->|返回 Send 列表| WriteTasks[写入 TASKS Channel] WriteTasks --> TopicCh["Topic<Send> Channel"] end subgraph "运行期 - 超步 N+1" TopicCh --> PNT[prepare_next_tasks] PNT --> PPTS1["prepare_push_task_send(idx=0)"] PNT --> PPTS2["prepare_push_task_send(idx=1)"] PNT --> PPTSN["prepare_push_task_send(idx=N)"] PPTS1 --> Task1[PregelExecutableTask 1] PPTS2 --> Task2[PregelExecutableTask 2] PPTSN --> TaskN[PregelExecutableTask N] end subgraph "运行期 - 执行与聚合" Task1 -->|并行执行| Runner[PregelRunner.tick] Task2 --> Runner TaskN --> Runner Runner -->|写入输出 Channel| AW[apply_writes] AW -->|BinaryOperatorAggregate| Merged[合并后的状态] end

12.7 条件边返回多个 Send 的工程细节

12.7.1 分支处理中的 Send 识别

在编译过程中,条件边的返回值会经过一个写入器函数处理。这个写入器能够区分三种返回类型:

  1. 字符串 :表示一个目标节点名,转化为对应的 branch:to:{node} Channel 写入
  2. Send 对象:直接写入 TASKS Channel
  3. 列表:可以包含字符串和 Send 的混合
python 复制代码
# 条件边可以混合返回字符串和 Send
def route(state):
    sends = [Send("worker", {"task": t}) for t in state["tasks"]]
    sends.append("summary_node")  # 同时也触发一个常规节点
    return sends

这种混合返回的能力非常强大------你可以在一个条件边中同时触发多个动态并行任务和静态节点。

12.7.2 Command.goto 中的 Send

Command 是 LangGraph 更强大的控制流原语,它的 goto 字段也支持 Send:

python 复制代码
from langgraph.types import Command, Send

def my_node(state):
    return Command(
        update={"status": "dispatched"},
        goto=[
            Send("processor", {"item": item})
            for item in state["items"]
        ]
    )

map_command 函数中,Command.goto 中的 Send 对象以完全相同的方式被写入 TASKS Channel。这使得动态并行不仅可以在条件边中触发,还可以在任何节点的返回值中触发。

12.7.3 Send 与中断的交互

当 Send 派生的任务遇到 interrupt() 时,情况变得有趣。每个 PUSH 任务都有自己独立的 checkpoint_ns(格式为 {parent_ns}:{node_name}:{task_id}),这意味着:

  • 每个 Send 任务的中断是独立追踪的
  • 恢复时,框架能准确地将 resume 值路由到正确的任务
  • 尚未完成的 Send 任务可以被单独恢复,已完成的不会重新执行
stateDiagram-v2 [*] --> Dispatched: Send 列表写入 TASKS Dispatched --> Running: prepare_push_task_send Running --> Completed: 正常完成 Running --> Interrupted: 遇到 interrupt() Interrupted --> Resumed: Command(resume=value) Resumed --> Completed: 从头重新执行节点 Completed --> Aggregated: apply_writes 合并输出 Aggregated --> [*]

12.8 设计决策

12.8.1 为什么 Send 不是 Channel?

一个自然的疑问是:为什么不把 Send 设计为一种新的 Channel 类型?答案在于关注点分离:

  • Send 是数据:它只是一个"请求执行某节点"的消息包
  • Topic 是基础设施:它负责存储和分发这些消息包
  • TASKS 是约定:它是一个预定义的系统级 Channel 名

这种分层让 Send 保持简洁(仅两个字段),同时复用了 Topic Channel 的全部能力。如果未来需要支持新的分发语义(如优先级队列),只需替换 TASKS Channel 的实现,而 Send 本身无需改变。

12.8.2 确定性任务 ID 的重要性

prepare_push_task_send 中的任务 ID 生成使用了确定性哈希:

python 复制代码
task_id = task_id_func(
    checkpoint_id_bytes,
    checkpoint_ns,
    str(step),
    packet.node,
    PUSH,
    str(idx),  # Send 在列表中的索引
)

这个设计确保了:

  1. 幂等性:相同状态下重新执行会生成相同的任务 ID
  2. Checkpoint 恢复:从 checkpoint 恢复时,已完成的任务不会被重复创建
  3. 并发安全:不同的 Send 索引产生不同的任务 ID,避免冲突

task_id_func 根据 checkpoint 版本选择不同的哈希算法------v2+ 使用 xxhash(更快),旧版本使用 UUID5(兼容性)。

12.8.3 Send.arg 为 Any 类型的权衡

Send.arg 类型为 Any 带来了极大的灵活性------你可以发送字典、Pydantic 模型、甚至原始字符串。但这也意味着类型安全完全依赖于开发者:

python 复制代码
# 正确:节点期望 dict,Send 发送 dict
Send("process", {"key": "value"})

# 也正确:节点期望 str,Send 发送 str
Send("process", "hello")

# 运行时错误:类型不匹配不会在编译期被检查到
Send("process", 42)  # 如果节点期望 dict,运行时会失败

这是一个典型的灵活性 vs 安全性权衡。LangGraph 选择了灵活性,因为动态并行的场景下,输入类型往往在运行时才确定。

12.8.4 Reduce 阶段的隐式实现

LangGraph 的 map-reduce 模式中,reduce 阶段是隐式的------通过 Channel 的 reducer 函数自动完成:

python 复制代码
class OverallState(TypedDict):
    jokes: Annotated[list[str], operator.add]  # reducer 就是 reduce 逻辑

这与 MapReduce 框架中显式定义 reduce 函数不同。在 apply_writes 中,所有并行任务的输出被收集后,通过 BinaryOperatorAggregate Channel 的 update 方法依次应用 reducer:

python 复制代码
# apply_writes 中的关键逻辑
pending_writes_by_channel: dict[str, list[Any]] = defaultdict(list)
for task in tasks:
    for chan, val in task.writes:
        if chan in channels:
            pending_writes_by_channel[chan].append(val)

for chan, vals in pending_writes_by_channel.items():
    if chan in channels:
        channels[chan].update(vals)  # BinaryOperatorAggregate 应用 reducer

这种隐式设计的优势在于,同一个 reducer 同时服务于静态边和动态 Send 的输出合并,无需开发者区分两种场景。

12.9 高级模式

12.9.1 Send 到子图节点

Send 可以将数据发送到一个子图节点,此时 arg 会成为子图的输入:

python 复制代码
def dispatch_to_subgraph(state):
    return [
        Send("analysis_subgraph", {"document": doc, "mode": "sentiment"})
        for doc in state["documents"]
    ]

子图会以 arg 作为输入开始执行,它的 checkpoint_ns 会自动嵌套在父图的命名空间下。

12.9.2 动态 Fan-in 的注意事项

所有 Send 任务在同一个超步内并行执行,它们的输出在下一个超步通过 apply_writes 合并。这意味着:

  • 如果需要等待所有并行任务完成后才进行下一步,只需让所有 Send 任务有相同的后续边
  • 如果某个并行任务失败,其错误会被记录但不会阻止其他任务的执行(除非配置了 interrupt 行为)
graph TB Start[START] -->|条件边| Fan{fanout} Fan -->|"Send(arg1)"| W1[worker] Fan -->|"Send(arg2)"| W2[worker] Fan -->|"Send(arg3)"| W3[worker] W1 -->|reducer 聚合| Agg[aggregator] W2 -->|reducer 聚合| Agg W3 -->|reducer 聚合| Agg Agg --> End[END] style Fan fill:#f0e6ff,stroke:#333,stroke-width:2px style Agg fill:#e6f0ff,stroke:#333,stroke-width:2px

12.9.3 Send 的性能考量

每个 Send 都会创建一个独立的 PregelExecutableTask,包括独立的配置、写入 deque、checkpoint 命名空间等。对于大量并行任务(如数百个 Send),需要注意:

  • 每个任务都会产生 checkpoint 写入(如果启用了 checkpointer)
  • 线程池的大小可能成为瓶颈
  • 所有任务的输出在内存中累积直到 apply_writes

对于极大规模的并行场景,建议在条件边中控制 Send 的数量,或使用批处理策略。

12.9.4 实战:文档批量处理流水线

以下是一个更完整的 map-reduce 实战示例------批量处理文档列表,每个文档独立分析后汇总:

python 复制代码
from typing import Annotated, TypedDict
from langgraph.types import Send
from langgraph.graph import END, START, StateGraph
import operator

class PipelineState(TypedDict):
    documents: list[dict]  # 输入文档列表
    results: Annotated[list[dict], operator.add]  # 分析结果(reducer 合并)
    summary: str  # 最终汇总

class DocInput(TypedDict):
    """Send 传递给 analyze_doc 的输入"""
    doc_id: str
    content: str
    analysis_type: str

def dispatch_documents(state: PipelineState):
    """条件边:为每个文档创建一个 Send"""
    return [
        Send("analyze_doc", DocInput(
            doc_id=doc["id"],
            content=doc["content"],
            analysis_type="sentiment"
        ))
        for doc in state["documents"]
    ]

def analyze_doc(state: DocInput) -> dict:
    """工作节点:分析单个文档"""
    # 注意:state 的类型是 DocInput,不是 PipelineState
    result = {
        "doc_id": state["doc_id"],
        "sentiment": "positive",  # 实际中调用 LLM
        "length": len(state["content"])
    }
    return {"results": [result]}

def summarize(state: PipelineState) -> dict:
    """汇总节点:整合所有分析结果"""
    total = len(state["results"])
    positive = sum(1 for r in state["results"] if r["sentiment"] == "positive")
    return {"summary": f"Analyzed {total} docs, {positive} positive"}

builder = StateGraph(PipelineState)
builder.add_node("analyze_doc", analyze_doc)
builder.add_node("summarize", summarize)
builder.add_conditional_edges(START, dispatch_documents)
builder.add_edge("analyze_doc", "summarize")
builder.add_edge("summarize", END)
graph = builder.compile()

这个例子展示了 Send 的完整生命周期:条件边创建 Send 列表、每个 Send 携带独立的输入类型、工作节点独立执行、输出通过 reducer 自动合并、最终汇总节点处理合并后的结果。

12.9.5 Send 与 Checkpoint 的交互细节

当启用了 checkpointer 时,Send 任务的 checkpoint 行为值得深入理解:

  1. 任务写入持久化 :每个 PUSH 任务的 writes 会通过 put_writes 异步持久化
  2. 恢复时的匹配 :从 checkpoint 恢复时,pending_writes 中已有结果的任务不会被重新执行
  3. Send 的序列化:Send 对象本身作为 TASKS Channel 的值被保存在 checkpoint 中,恢复时用于重建任务
flowchart LR subgraph "首次执行" S1["Send 列表写入 TASKS"] --> T1["创建 PUSH 任务"] T1 --> E1["执行任务"] E1 --> W1["写入 pending_writes"] W1 --> CP1["保存 Checkpoint"] end subgraph "恢复执行" CP1 --> R1["加载 Checkpoint"] R1 --> R2["重建 TASKS Channel"] R2 --> R3["prepare_next_tasks"] R3 --> R4{"pending_writes
中有结果?"} R4 -->|是| Skip["跳过已完成的任务"] R4 -->|否| Exec["重新执行任务"] end

12.10 小结

本章深入分析了 LangGraph 的动态并行机制。Send 对象以极简的两字段设计(node + arg)实现了强大的运行时任务分发能力。它通过 Topic Channel 的 pub/sub 语义被收集和分发,通过 prepare_push_task_send 被转化为可执行任务,最终在 PregelRunner 中并行执行。

整个 map-reduce 模式的实现体现了 LangGraph 的核心哲学:复用而非重复发明 。Send 复用了 Topic Channel 的基础设施,reducer 复用了 BinaryOperatorAggregate 的聚合能力,任务调度复用了 Pregel 的超步机制。开发者只需关注两件事:在条件边中返回 Send 列表(map),在状态定义中声明 reducer(reduce),框架会处理其余一切。

下一章我们将转向另一个运行时话题------流式输出。LangGraph 提供了七种 StreamMode,覆盖了从完整状态快照到 LLM token 级别的多种粒度,让调用方能够以最合适的方式消费图的执行过程。

相关推荐
杨艺韬4 小时前
LangGraph设计与实现-第8章-Checkpoint 持久化
langchain·agent
杨艺韬4 小时前
LangGraph设计与实现-第4章-Channel 状态管理与 Reducer
langchain·agent
杨艺韬4 小时前
LangGraph设计与实现-第1章-为什么需要理解 LangGraph
langchain·agent
杨艺韬4 小时前
LangGraph设计与实现-第3章-StateGraph 图构建 API
langchain·agent
杨艺韬4 小时前
LangGraph设计与实现-第7章-任务调度与并行执行
langchain·agent
杨艺韬4 小时前
LangGraph设计与实现-第2章-架构纵览
langchain·agent
杨艺韬4 小时前
LangGraph设计与实现-第6章-Pregel 执行引擎
langchain·agent
杨艺韬4 小时前
LangGraph设计与实现-第9章-中断与人机协作
langchain·agent
杨艺韬4 小时前
LangGraph设计与实现-第10章-Command 与高级控制流
langchain·agent