LangGraph 图编排:边、Send 与 Command
节点和 State 搞定之后,下一关是控制图的流转。
LangGraph 提供了三种层次的编排能力:边(Edge) 做常规路由,Send 做动态并行分发,Command 做「改状态 + 强制跳转」的特种操作。
日常开发里,90% 的路由用条件边就够了;但剩下 10% 的边界场景——工具强制收尾、中断恢复、子图交还父图——Command 是绕不开的。
边的四种形式
边决定 Node 之间的连接方式,分两类 API:
| 类型 | API | 说明 |
|---|---|---|
| 普通边 | add_edge("A", "B") |
固定从 A 到 B |
| 普通入口 | add_edge(START, "A") |
固定从 START 到 A |
| 条件边 | add_conditional_edges(...) |
根据函数返回值选路 |
| 条件入口 | add_conditional_edges(START, router, {...}) |
根据条件决定从哪开始 |
条件判断函数可以返回节点名,也可以只返回 True/False,然后在 add_conditional_edges 里映射:
1 | graph.add_conditional_edges( |
选路口诀:
- 只改 state → 普通
return - 只选路 → 条件边
- 改完 state 还要立刻改道 → Command
Send:动态并行分发
前面的边都是「固定下一个节点是谁」。但有些场景任务数量和目标在运行时才确定——比如 Supervisor 拆出了 3 个故障主题,要分别派 3 个 RCA Agent 并行诊断。
这时候用 Send:
1 | def continue_to_jokes(state: OverallState): |
Send 的本质是运行时动态创建边:有几个 subject 就派几个并行节点。
注意:分发时要带齐字段。Send 像共享数据——分发时不带的数据,子节点没法从父图里补拿。
配合父图的 Reducer(上一篇讲过),多个 Send 的结果会自动汇总到父 State 的列表字段里。这就是 LangGraph 的 Map-Reduce 模式。
graph TD
A[Supervisor 拆分任务] --> B[Send: RCA Agent 1]
A --> C[Send: RCA Agent 2]
A --> D[Send: RCA Agent 3]
B --> E[Reducer 汇总]
C --> E
D --> E
E --> F[Format Output]
Command:改状态 + 强制跳转
Command 是 LangGraph 里最容易被误解的 API。它有四个原语:
| 原语 | 作用 |
|---|---|
| update | 更新 State(类似 return) |
| goto | 跳转到指定节点 |
| graph | 指定跳转发生在哪张图(当前图 or 父图) |
| resume | 恢复被 interrupt 暂停的执行 |
Command 只有 update 时,是不是多此一举?
是的。只有 update 时 Command 等价于普通 return,日常直接 return 即可。Command 的价值在于 update + goto 捆在一起——改完状态立刻改道,这是 return + add_edge 拆不开的。
从「原来的理解」到「学会的理解」
几个典型的认知升级:
Command vs 条件边
原来的理解:Command 就是另一种写路由的方式,和 add_edge / 条件边差不多,很多场景可以互换。
学会的理解:日常选路用条件边最清楚;Command 是「改状态 + 强制跳转」捆在一起的特种工具。普通节点确实能拆成 return + add_edge,但工具在循环里要强制收尾时拆不开——工具 return 后默认还会回到 Agent,必须在工具返回值里直接 goto 收尾节点。
Command 的跳转范围
原来的理解:只要用了 Command,不管函数在不在图里都能跳;多个图时想从 tool 直接跳到子图内部的某个节点。
学会的理解:Command 只作用在当前正在执行的那张图。graph 只有当前图和 Command.PARENT,没有跳进子图内部的入口。从外面只能 goto 整张子图的父节点,子图从 START 重头跑。
interrupt 和 resume
原来的理解:Command(resume=…) 自己就能把执行接上,搞不清怎么知道「重续哪个」。
学会的理解:resume 只管塞回什么值;重续哪一次靠 config 里的 thread_id + checkpointer 找到同一次暂停。interrupt 写在节点里,invoke 跑到才停;第二次必须用同一个 config 再 invoke(Command(resume=…))。
什么时候需要 Command?
场景一:工具强制收尾
Agent 循环里,LLM 调完工具后默认还会回到 Agent 节点继续推理。但有些工具意味着「调查结束」——比如提交最终诊断报告,应该直接去 format-output,而不是再回 Agent 开会。
1 | def submit_final_diagnosis(...) -> Command[Literal["format-output"]]: |
这就是「强制收尾」:不只记账(update),还要立刻改道离开循环(goto)。
场景二:子图交还父图
Command 只作用在当前正在执行的那张图。graph 合法值基本是 None(当前图)和 Command.PARENT。
不能从工具节点一枪打进子图内部的某个中途节点——从外面只能 goto 整张子图的父节点,子图从 START 重头跑。
场景三:interrupt 恢复
节点里写了 interrupt(),第一次 invoke 跑到这里会暂停。第二次必须用同一个 config(thread_id) 再 invoke:
1 | graph.invoke(Command(resume="人工输入的内容"), config={"configurable": {"thread_id": "xxx"}}) |
resume 只管塞回什么值;重续哪一次靠 thread_id + checkpointer 找到同一条暂停记录。config 不是触发中断的开关——interrupt 写在节点函数里,invoke 跑到才会停。
Command 的坑
1. 不要和静态边混用
Command 的 goto 是追加动态边,静态 add_edge 仍会执行——同一节点两条出口可能都跑,副作用翻倍、State 乱。
每个节点要么 Command 出边,要么 add_edge/条件边,不要混用。
2. 返回类型要标注
用 Command 时函数要声明返回类型,图才能正确推断跳转目标:
1 | def my_node(...) -> Command[Literal["format-output", "other"]]: |
3. invoke 入参里 Command 几乎只能 resume
多轮接着聊用普通 dict,别用 Command(update=...)。
4. 改图要当心进行中的会话
有 checkpointer 的活 thread 时:已结束的随便改;中断中的别删/改名关键节点;state 加减字段一般 OK,改名会丢旧值。
Runtime Context:全局配置面板
Runtime Context 是一个所有节点都能读的配置面板,通过 invoke 传入:
1 | graph.invoke(inputs, context={"llm_provider": "anthropic"}) |
适合放 LLM 提供商、环境标识、用户权限等不属于 State 但需要全局访问的配置。
Recursion Limit:步数限制
Agent 循环最怕无限跑。LangGraph 用 recursion_limit 控制最大步数:
1 | graph.invoke(inputs, config={"recursion_limit": 5}) |
config 分层记忆:
- 顶层(
recursion_limit/tags/callbacks)→ 框架的 - configurable(
thread_id/ 业务参数)→ 你的
撞限前的两条路
1. 主动收尾(推荐)
条件边判断 remaining_steps <= 2 → 转收尾节点 → 正常 END。checkpoint 保住,部分结果能拿。
State 里声明 remaining_steps: RemainingSteps,框架自动填充剩余步数。当前步数在 config["metadata"]["langgraph_step"] 里。
2. 被动兜底
图外 try/except GraphRecursionError——简单,但执行被腰斩,只当保险。
子图:模块化你的 Agent
复杂 Agent 不要把所有节点堆在一张图里。LangGraph 支持子图(Subgraph):
- 子图有自己的 State 和节点
- 父图把子图当作一个节点挂载
- 典型用法:
triage_agent_graph作为子图挂到parent_graph
子图的好处是职责隔离——Triage 只管分类,RCA 只管诊断,Supervisor 只管调度。
以 SRE Agent 为例,一张典型的多 Agent 图长这样:
graph TD
START --> Triage[Triage 子图]
Triage --> Supervisor[Supervisor 拆分]
Supervisor --> Send1[Send: RCA Worker 1]
Supervisor --> Send2[Send: RCA Worker 2]
Send1 --> Merge[Reducer 汇总]
Send2 --> Merge
Merge --> Format[Format Output]
Format --> END
Triage 子图从 Prometheus / Jaeger / K8s 拉原始遥测,交给 LLM 聚合统一结构;Supervisor 根据故障主题 Send 并行分发;所有 RCA Worker 返回后 Reducer 汇总,最后 format 输出报告。
Persistence(持久化) 在这个阶段变得重要:compile(checkpointer=...) 让每次执行状态都被保存,调试时可以回溯、中断时可以恢复。config 里的 thread_id 标识同一次会话。
常见坑汇总
| # | 坑 | 解法 |
|---|---|---|
| 1 | 父图汇总子图结果时列表被覆盖 | 给列表字段加 Reducer |
| 2 | Command 返回没标类型,框架推断跳转出错 | -> Command[Literal["node_a", "node_b"]] |
| 3 | invoke 入参误用 Command(update=...) |
多轮对话用普通 dict,Command 只用于 resume |
| 4 | Command goto 和静态 add_edge 混用 | 每个节点只选一种出边方式 |
| 5 | 有活 thread 时删/改名节点 | 已结束的随便改,中断中的别动关键节点 |
小结
图编排的三层能力:
| 能力 | 适用场景 |
|---|---|
| 条件边 | 日常路由,最清晰 |
| Send | 运行时动态并行分发(Map-Reduce) |
| Command | 工具强制收尾、interrupt 恢复、子图交还父图 |
记住总口诀:只改 state → return;只选路 → 条件边;改完还要立刻改道 → Command。