Skip to content

第10章 Command 与高级控制流

10.1 引言

在前面的章节中,我们看到了 LangGraph 如何通过 Channel 和边(Edge)来定义数据的流转路径,通过 interrupt() 实现暂停与恢复。然而,在真实的 Agent 应用中,控制流往往不能在编译时完全确定——节点可能需要根据运行时的结果动态决定下一步去哪里,可能需要在更新状态的同时跳转到特定节点,甚至可能需要跨越图的边界向父图发送指令。

Command 类型正是 LangGraph 对这些需求的统一回答。它是一个多功能的控制流原语,将状态更新(update)、路由跳转(goto)、中断恢复(resume)和跨图通信(graph)四种能力融合在一个简洁的接口中。从设计哲学上看,Command 实现了一种"从节点内部控制图"的模式——节点不再是被动的数据处理器,而是可以主动驾驭图的执行引擎。

在传统的图计算框架中,控制流(节点之间的跳转)和数据流(状态的更新)是分离的——你在编译时通过边和条件边定义控制流,在运行时通过节点返回值更新数据。这种分离在简单场景下工作得很好,但在 Agent 系统中会带来摩擦:Agent 的决策往往是"在同一个推理步骤中既产生数据又决定下一步去哪里"。Command 打破了这个人为的分离,让节点可以在一次返回中同时表达数据更新和控制流意图。这种统一大幅简化了动态路由、分支合并、错误恢复等复杂场景的实现。

本章将从源码层面深入分析 Command 的实现:从数据结构定义、到 Pregel 循环中的处理逻辑、再到跨图通信的实现机制。我们还将对比 CommandSend 的区别,并通过实际用例展示其在复杂工作流中的应用。通过本章的学习,读者将能够在自己的 Agent 系统中自如地运用 Command 来构建灵活的动态控制流,包括条件路由、多目标分发、人机交互后的分支选择以及跨图的协调通信。

本章要点

  1. Command 类定义:理解 update/resume/goto/graph 四个字段的语义与交互
  2. 从节点内控制流程:掌握 Command 作为节点返回值时的处理机制
  3. map_command 映射:深入 Command 到 pending writes 的转换逻辑
  4. Command.PARENT 跨图通信:理解 ParentCommand 异常和父子图之间的控制流传递
  5. 与 Send 的区别:明确两者在语义、作用域和使用场景上的差异
  6. 实际用例:通过完整示例展示动态路由、多目标跳转和跨图控制的应用

10.2 Command 类定义

10.2.1 数据结构

Command 定义在 langgraph/types.py 中,是一个不可变的泛型数据类:

python
# langgraph/types.py

@dataclass(**_DC_KWARGS)  # kw_only=True, slots=True, frozen=True
class Command(Generic[N], ToolOutputMixin):
    """One or more commands to update the graph's state and send messages to nodes."""

    graph: str | None = None
    update: Any | None = None
    resume: dict[str, Any] | Any | None = None
    goto: Send | Sequence[Send | N] | N = ()

    PARENT: ClassVar[Literal["__parent__"]] = "__parent__"

四个字段各司其职,它们的设计体现了"正交组合"的原则——每个字段独立控制一个维度的行为,可以任意组合使用:

update:要应用到图状态的更新。可以是字典、元组列表或 Pydantic 模型——与节点直接返回字典时的效果相同。当 updateNone 时,不对状态做任何修改。这允许你创建纯控制流的 Command(只有 goto 没有数据更新),或纯恢复的 Command(只有 resume 没有状态变更)。update 的类型灵活性使得它可以适配不同的 Schema 定义方式,无论你使用 TypedDict、Pydantic 模型还是简单的字典。

goto:指定下一步要执行的节点。可以是单个节点名字符串、节点名列表、Send 对象或 Send 对象列表。这是 Command 最强大的能力——从节点内部直接控制路由,无需在编译时定义条件边。当 goto 为空序列时(默认值),不产生任何路由指令,图按照正常的边定义继续执行。字符串形式的 goto 和 Send 形式的 goto 在底层使用不同的 Channel 机制——字符串触发的是 PULL 类型的任务(从全局状态读取输入),Send 触发的是 PUSH 类型的任务(使用自定义的输入参数)。

resume:中断恢复值。可以是单个值或中断 ID 到值的映射。在上一章中我们已经详细讨论过。值得补充的是,resume 可以与 updategoto 同时使用——这在"恢复后立即跳转到特定节点"的场景中非常有用,例如人类审批后根据审批结果路由到不同的处理流程。

graph:指定命令的目标图。None 表示当前图,Command.PARENT(即 "__parent__")表示父图。PARENT 被定义为 ClassVar(类变量),这意味着它是一个常量,不参与实例化和序列化。它的值 "__parent__" 是一个内部约定的哨兵字符串,在 _control_branchmap_command 中被专门检测和处理。

10.2.2 _update_as_tuples 方法

Commandupdate 字段需要转换为 (channel, value) 元组列表才能被 Pregel 循环处理。_update_as_tuples 方法负责这个转换:

python
def _update_as_tuples(self) -> Sequence[tuple[str, Any]]:
    if isinstance(self.update, dict):
        return list(self.update.items())
    elif isinstance(self.update, (list, tuple)) and all(
        isinstance(t, tuple) and len(t) == 2 and isinstance(t[0], str)
        for t in self.update
    ):
        return self.update
    elif keys := get_cached_annotated_keys(type(self.update)):
        # Pydantic 模型或 dataclass
        return get_update_as_tuples(self.update, keys)
    elif self.update is not None:
        # 标量值映射到 __root__ channel
        return [("__root__", self.update)]
    else:
        return []

这个方法支持四种输入格式,从简单到复杂逐级处理:

第一种是最常用的字典格式{"messages": [...], "count": 1} 被转换为 [("messages", [...]), ("count", 1)]。字典的键对应 Channel 名称,值对应要写入的数据。这是绝大多数场景下推荐使用的格式,简洁明了。

第二种是元组列表格式[("messages", [...]), ("count", 1)] 被直接透传。这种格式的优势在于支持同一个 Channel 的多次写入——在字典中,相同的键会被覆盖,而在元组列表中可以包含多个相同键的元组。这对于使用追加型 reducer 的 Channel 很有用。

第三种是结构化对象格式:Pydantic 模型或带注解的类型,通过 get_cached_annotated_keys 进行字段反射,提取每个字段的名称和值组成元组列表。这种格式适用于使用 Pydantic 或 dataclass 定义状态类型的项目,可以享受类型检查的保护。

第四种是标量值格式:当 update 是一个不符合上述任何格式的非 None 值时,它被映射到 ("__root__", value) 元组。这适用于使用单一 __root__ channel 的简单图,其中整个状态就是一个标量值。

10.2.3 ToolOutputMixin 集成

Command 继承了 ToolOutputMixin,这使它可以直接作为工具调用的返回值:

python
try:
    from langchain_core.messages.tool import ToolOutputMixin
except ImportError:
    class ToolOutputMixin:
        pass

这个设计使得 Command 可以在 LangChain 的工具执行流程中无缝使用——当一个工具函数返回 Command 时,它会被正确识别为需要特殊处理的控制流指令,而非普通的工具输出。这在 ReAct 模式的 Agent 中尤其有价值:工具不仅可以返回执行结果,还可以通过 Command 主动影响 Agent 的后续行为。例如,一个搜索工具在发现答案后可以返回 Command(update={"answer": result}, goto=END) 直接结束图的执行,而不需要 Agent 再做一次"决定是否结束"的 LLM 调用。

10.3 Command 的处理机制

10.3.1 作为图的输入

Command 作为 graph.invoke()graph.stream() 的输入时,它在 PregelLoop._first() 中被处理:

python
# langgraph/pregel/_loop.py

def _first(self, *, input_keys, updated_channels):
    input_is_command = isinstance(self.input, Command)

    if input_is_command:
        # 处理 resume
        if (resume := cast(Command, self.input).resume) is not None:
            ...

        # 将 Command 映射为写入
        writes: defaultdict[str, list] = defaultdict(list)
        for tid, c, v in map_command(cmd=cast(Command, self.input)):
            if not (c == RESUME and resume_is_map):
                writes[tid].append((c, v))

        if not writes and not resume_is_map:
            raise EmptyInputError("Received empty Command input")

        # 保存写入
        for tid, ws in writes.items():
            self.put_writes(tid, ws)

10.3.2 map_command:从 Command 到写入

map_command 函数将 Command 的各个字段转换为 Pregel 可以处理的 pending writes:

python
# langgraph/pregel/_io.py

def map_command(cmd: Command) -> Iterator[tuple[str, str, Any]]:
    """Map Command to pending writes: (task_id, channel, value)."""

    # 1. graph 字段:如果指向父图,在当前图中是错误
    if cmd.graph == Command.PARENT:
        raise InvalidUpdateError("There is no parent graph")

    # 2. goto 字段:转换为 TASKS 或 branch 写入
    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)
            elif isinstance(send, str):
                yield (NULL_TASK_ID, f"branch:to:{send}", START)
            else:
                raise TypeError(
                    f"In Command.goto, expected Send/str, got {type(send).__name__}"
                )

    # 3. resume 字段:转换为 RESUME 写入
    if cmd.resume is not None:
        yield (NULL_TASK_ID, RESUME, cmd.resume)

    # 4. update 字段:转换为 channel 写入
    if cmd.update:
        for k, v in cmd._update_as_tuples():
            yield (NULL_TASK_ID, k, v)

10.3.3 goto 的两种路由方式

goto 字段支持两种路由语义:

字符串路由goto="node_name" 被转换为 branch:to:node_name channel 的写入。这触发了 Pregel 的标准分支机制——在 CompiledStateGraph.attach_node 中,每个节点都注册了对 branch:to:{name} channel 的监听。

基于 VitePress 构建