第 6 章 Command:把控制权交进节点
本章目标:掌握节点内路由机制 Command——让节点在返回状态更新的同时声明"下一步去哪"。学完本章,你能在"条件边"与"Command"之间做出干净的选型,并用 Command 重写出更内聚的控制流
本章目标:掌握节点内路由机制
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 后,"更新状态"和"决定去向"在一次返回里完成。决策依据可以是节点内部任何局部变量,不必再经过状态传话。
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 章版本的三处本质差异:
ready字段消失了。它本来就是为路由而生的传话字段,决策收回节点内部后,状态更干净。route_after_extract函数消失了。判断complete的逻辑与产生complete的逻辑回到了同一个函数体里。- 图定义里少了一条条件边。
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 章讲子图时会用到——子图内部的节点想直接改写父图的流向时,靠它逃出层级。
常见坑与排错
-
静态出边与 Command goto 混用。节点既挂着普通出边/条件边,又返回带 goto 的 Command:静态边照常触发,goto 也生效,两套去向叠加进入下一超步——大概率不是你想要的。约定二选一(见 6.5)。
-
goto 写了不存在的节点名。条件边的映射表错误在编译期就能暴露,而 goto 是运行时值,拼错节点名要到执行那一刻才报错。对策:节点名统一定义成模块级常量,goto 引用常量而非手写字符串。
-
以为 update 之外还要返回 dict。返回类型是"二选一":要么 dict(只有更新),要么 Command(更新 + 去向)。返回
({"score": 1}, Command(...))这类组合是无效的。 -
用 Command 跳转却忘了循环计数。
goto指回上游节点同样构成循环,和第 4 章一样需要业务计数出口 + recursion_limit 兜底。换了机制不代表豁免安全规则。 -
在 Command 化的节点上保留"以防万一"的出边。有时重构不彻底,节点已经返回 Command,旧的出边还挂在图上——恰好落入坑 1。重构时连同图定义一起清理。
-
过度 Command 化。看到 Command 就把所有条件边改写一遍,流向全部藏进节点体,
draw_mermaid()输出变成一张没有分支的直链图——牺牲了结构可见性。按 6.5 的信号触发重构,而不是无差别替换。
动手练习
-
给 6.3 节的图增加业务出口:解析尝试达 3 次仍不完整时,
extract直接goto="reject",由reject节点写入{"score": -1}后goto=END。验证两条路径:一次成功解析、三次均失败。 -
用 Command 的列表形态重写第 4 章练习 2 的双路评估(
tech_review与background_check并行,汇合到summarize),并用graph.stream确认两个节点处于同一超步。 -
对照实验:把第 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 是运行时值,节点名用常量 |