首页/LangGraph 框架指南/第 6 章 Command:把控制权交进节点
LangGraph 框架指南 · 第 06 章

第 6 章 Command:把控制权交进节点

本章目标:掌握节点内路由机制 Command——让节点在返回状态更新的同时声明"下一步去哪"。学完本章,你能在"条件边"与"Command"之间做出干净的选型,并用 Command 重写出更内聚的控制流

约 13 分钟5,050 字6 个代码示例

本章目标:掌握节点内路由机制 Command——让节点在返回状态更新的同时声明"下一步去哪"。学完本章,你能在"条件边"与"Command"之间做出干净的选型,并用 Command 重写出更内聚的控制流。

6.1 路由逻辑放错地方会怎样

第 4 章用条件边实现了"解析不完整就打回重做"。复盘一下那条路由函数:

def route_after_extract(state: ScreenState) -> str:
    return "evaluate" if state["ready"] else "extract"

它读的是状态里的 ready 字段。而 ready 是谁写的?extract 节点。也就是说,实际的决策链是:节点内部算出结论 → 写进状态 → 路由函数读状态 → 翻译成跳转。

状态在这里被当成了"节点与路由函数之间的传话筒"。需求一复杂,传话就开始遭罪:

  • 节点内部已经有现成的局部变量(比如解析出的字段数量),却必须先把它序列化进状态,路由函数才能用——状态里多出一堆只为路由服务的字段
  • 决策逻辑被劈成两半:一半在节点里(产出依据),一半在路由函数里(做判断),读代码要跳两个地方
  • 一个节点如果有好几种可能的出口,每种出口的判断依据各不相同,就得配一个越来越肥的路由函数

根本矛盾在于:决策依据产生于节点内部,决策机制却悬浮在节点外部。LangGraph 的解法是把控制权直接交进节点——这就是 Command。

6.2 Command 登场

Command 是一个可以被节点直接返回的对象,同时携带两件事:

from langgraph.types import Command

def some_node(state) -> Command:
    ...
    return Command(
        update={"score": 80},   # 状态补丁:等价于返回 dict
        goto="evaluate",        # 去向指令:下一个超步执行谁
    )
  • update:状态更新,语义与返回普通 dict 完全一致(补丁合并,遵循各字段的 reducer)
  • goto:去向声明,支持单个节点名、节点名列表(并行跳转)、或 END(直接结束)

节点返回 Command 后,"更新状态"和"决定去向"在一次返回里完成。决策依据可以是节点内部任何局部变量,不必再经过状态传话。

flowchart LR subgraph 旧[条件边模式] N1[节点<br/>产出决策依据] -->|写入状态| S[(状态)] S -->|读取| R[路由函数] R -->|标签| T[映射表] end subgraph 新[Command 模式] N2[节点<br/>内部直接决策] -->|update + goto| G[引擎] end

6.3 完整 demo:用 Command 重写解析重试

把第 4 章的解析重试图用 Command 重写。对照着看,差异一目了然:

from typing import Annotated, TypedDict
import operator
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command

class ScreenState(TypedDict):
    resume_text: str
    candidate: dict
    parse_attempts: Annotated[int, operator.add]   # 解析次数:累加
    score: int

def extract(state: ScreenState) -> Command:
    attempts = state.get("parse_attempts", 0)

    # 路由依据就是节点内部的局部变量,无需写入状态再读回来
    if attempts < 1:
        parsed = {"name": "李思远"}                 # 模拟首次解析缺字段
        complete = False
    else:
        parsed = {
            "name": "李思远",
            "skills": ["Python", "FastAPI", "PostgreSQL"],
            "years": 6,
        }
        complete = True

    return Command(
        update={"parse_attempts": 1, "candidate": parsed},
        goto="evaluate" if complete else "extract",  # 在节点内部直接决定去向
    )

def evaluate(state: ScreenState) -> dict:
    c = state["candidate"]
    score = min(100, 20 + len(c["skills"]) * 10 + c["years"] * 3)
    return {"score": score}

builder = StateGraph(ScreenState)
builder.add_node("extract", extract)
builder.add_node("evaluate", evaluate)

builder.add_edge(START, "extract")
# 注意:extract 没有任何出边——它的去向完全由返回的 Command 决定
builder.add_edge("evaluate", END)
graph = builder.compile()

result = graph.invoke({"resume_text": "李思远 ..."})
print(result["parse_attempts"])   # 2
print(result["score"])            # 68(20 + 3 个技能 ×10 + 6 年 ×3)

与第 4 章版本的三处本质差异:

  1. ready 字段消失了。它本来就是为路由而生的传话字段,决策收回节点内部后,状态更干净。
  2. route_after_extract 函数消失了。判断 complete 的逻辑与产生 complete 的逻辑回到了同一个函数体里。
  3. 图定义里少了一条条件边。extract 不再挂任何出边——它的流向写在节点代码里,而不是图结构里。

6.4 goto 的三种形态

形态一:单个节点。上例已经演示,最常用。

形态二:节点列表——并行跳转。goto 传列表,多个目标在下一个超步并行执行,效果等价于条件边路由函数返回多标签:

def extract(state: ScreenState) -> Command:
    ...
    targets = ["tech_review"]
    if state["candidate"].get("company"):
        targets.append("background_check")
    return Command(update={...}, goto=targets)

形态三:END——在节点内直接终止。不等出边,节点自己宣布收工:

def reject(state: ScreenState) -> Command:
    return Command(update={"score": -1}, goto=END)

三种形态可以按需混用,goto 的值在每次执行时动态计算——这也是 Command 与静态边最大的不同:去向是运行时数据,不是编译期结构。

6.5 选型:条件边还是 Command

两套机制表达力等价(任何一边能做的另一边都能做),选型依据是代码组织偏好与场景特征:

维度 条件边 Command
决策逻辑位置 节点外部(独立路由函数) 节点内部
决策依据 只能读图状态 节点内任意局部变量
结构可见性 draw_mermaid() 能画出分支轮廓 流向藏在节点代码里,图上看不到
路由复用 一个路由函数可挂多个源节点 逻辑与节点绑定,不可复用
静态分析 编译期即可穷举所有去向 goto 是运行时值,无法提前穷举
适用场景 基于状态的通用分流(如入口分发) 决策依赖节点内部计算的复杂出口

实践建议:

  • 默认用条件边。结构看得见,新人接手、排查问题都受益。尤其"入口分发"(START 条件边)这类与具体节点无关的路由,条件边是唯一合理选择。
  • 当路由依据是节点内部计算结果时,切换到 Command。信号很明显:你开始往状态里写"只为路由服务"的字段了。
  • 两者可以共存于一张图,但不要共存于同一个节点——一个节点要么用出边表达去向,要么用 Command,混用会让静态边与 goto 叠加触发,流向难以推理。
  • 团队协作项目里,把这条约定写进代码规范:返回 Command 的节点,不挂出边。

6.6 Command 的另外两个参数(预告)

Command 还有两个参数本章不展开,先留下坐标:

  • resume:携带外部输入恢复中断的图。它是第 14 章"中断与人在回路"的核心机制——图暂停等人工审核,审核结果通过 Command(resume=...) 注入。
  • graph:声明跳转作用于哪一层图(如 Command(graph=Command.PARENT) 向父图跳转)。第 9 章讲子图时会用到——子图内部的节点想直接改写父图的流向时,靠它逃出层级。

常见坑与排错

  1. 静态出边与 Command goto 混用。节点既挂着普通出边/条件边,又返回带 goto 的 Command:静态边照常触发,goto 也生效,两套去向叠加进入下一超步——大概率不是你想要的。约定二选一(见 6.5)。

  2. goto 写了不存在的节点名。条件边的映射表错误在编译期就能暴露,而 goto 是运行时值,拼错节点名要到执行那一刻才报错。对策:节点名统一定义成模块级常量,goto 引用常量而非手写字符串。

  3. 以为 update 之外还要返回 dict。返回类型是"二选一":要么 dict(只有更新),要么 Command(更新 + 去向)。返回 ({"score": 1}, Command(...)) 这类组合是无效的。

  4. 用 Command 跳转却忘了循环计数。goto 指回上游节点同样构成循环,和第 4 章一样需要业务计数出口 + recursion_limit 兜底。换了机制不代表豁免安全规则。

  5. 在 Command 化的节点上保留"以防万一"的出边。有时重构不彻底,节点已经返回 Command,旧的出边还挂在图上——恰好落入坑 1。重构时连同图定义一起清理。

  6. 过度 Command 化。看到 Command 就把所有条件边改写一遍,流向全部藏进节点体,draw_mermaid() 输出变成一张没有分支的直链图——牺牲了结构可见性。按 6.5 的信号触发重构,而不是无差别替换。

动手练习

  1. 给 6.3 节的图增加业务出口:解析尝试达 3 次仍不完整时,extract 直接 goto="reject",由 reject 节点写入 {"score": -1} 后 goto=END。验证两条路径:一次成功解析、三次均失败。

  2. 用 Command 的列表形态重写第 4 章练习 2 的双路评估(tech_review 与 background_check 并行,汇合到 summarize),并用 graph.stream 确认两个节点处于同一超步。

  3. 对照实验:把第 4 章的入口分发(START 条件边区分 VIP/普通)改写成"第一个节点内用 Command 分流",然后分别执行 draw_mermaid()。观察两张结构图的差异,写下你的结论——哪种流向在图上可见?

本章速查

API / 概念 一句话
Command(update=..., goto=...) 节点返回值:状态补丁 + 去向声明
goto 单值 下一个超步执行单个目标节点
goto 列表 多目标并行跳转(等价条件边多标签)
goto=END 节点内直接宣告图结束
Command(resume=...) 携带外部输入恢复中断(第 14 章)
Command(graph=Command.PARENT) 跳转作用于父图层级(第 9 章)
选型默认 条件边优先;出现"传话字段"时切换 Command
铁律 返回 Command 的节点不挂出边;goto 是运行时值,节点名用常量