夜雨聆风学习资料网

ARTICLE · 1266823

【LangGraph源码精读】第08期|条件边的执行时机,90%的人搞错了

【LangGraph源码精读】第08期|条件边的执行时机,90%的人搞错了

【LangGraph源码精读】第08期|条件边的执行时机,90%的人搞错了

30篇源码精读系列·第08篇(用户API/编译层第二篇)。你以为条件边在节点执行前路由?不是。你以为路由函数读的是节点执行前的状态?不是。你以为一个节点配了条件边就不能再配普通边?也不是。这篇把条件边的执行时机、状态读取、路径映射、多目标返回、和Command的区别全部讲透,最后附上条件边不生效的5个原因。


一、开头:你的条件边为什么"不生效"?

你写了一个Agent,"agent"节点配了条件边:如果模型输出tool_calls,路由到"tools";否则路由到END。测试时,模型明明输出了tool_calls,却直接走到END了。

排查了半天,可能是这些原因:

  • ● 路由函数检查的字段名写错了,返回了END
  • ● 路由函数的参数是节点执行前的状态,看不到节点刚写入的tool_calls
  • ● 你同时配了普通边和条件边,普通边把执行流带到了别的地方
  • ● 路由函数返回的字符串在path_map里没有对应项
  • ● 条件边是在节点执行后才调用的,但你在节点函数内部用Command(goto=...)做了路由,两者冲突

条件边是LangGraph中最容易"看起来对但实际不生效"的机制。 核心原因是90%的人搞错了它的执行时机——它到底在节点执行前还是执行后路由?路由函数读到的是节点执行前还是执行后的状态?

这篇把条件边的执行时机彻底讲清。


二、条件边的执行时机:节点执行后

这是最关键的一点,先给结论。

2.1 条件边在source节点执行完成后才被调用

阶段
发生了什么
条件边在做什么
1. Plan
prepare_next_tasks选择要执行的节点
-
2. Execute
source节点执行,产生writes
还没被调用
3. Commit
source节点的writes写入缓冲区
还没被调用
4. Update
after_tick应用writes到Channel
还没被调用
5. 下一轮Plan
检查source节点的条件边,调用路由函数
此时被调用
6. 路由
根据路由函数返回值,选择下一轮的目标节点
-

条件边是在source节点完整执行完、writes已经应用到Channel之后、下一轮Plan阶段才被调用的。 不是在节点执行前,也不是在节点执行中。

2.2 这意味着什么?

你以为的
实际的
条件边在节点执行前决定"要不要执行这个节点"
条件边在节点执行后决定"下一个执行谁"
路由函数读到的是节点执行前的状态
路由函数读到的是节点执行后的状态(包含节点的writes)
条件边可以"跳过"source节点
条件边不能跳过source节点,source节点一定会执行

条件边是"出口路由",不是"入口守卫"。 它决定的是"从source节点出来后往哪走",不是"要不要进入source节点"。source节点一定会执行,条件边只影响它的后续。


三、路由函数读到的状态:包含source节点的writes

这是第二个关键结论。

3.1 路由函数的状态快照

因为条件边在source节点执行后才被调用,所以路由函数读到的状态已经包含了source节点的输出:

时间
状态中的值
source节点执行前
messages=[用户消息]
source节点执行后、after_tick
messages=[用户消息, AI消息(含tool_calls)]
路由函数被调用时
读到的是messages=[用户消息, AI消息],能看到tool_calls

这就是为什么经典的ReAct路由函数能检查last_message的tool_calls。 因为它在agent节点执行后才被调用,读到的状态已经包含了agent节点刚输出的AI消息。

3.2 经典ReAct路由的完整流程

步骤
发生了什么
状态变化
1
用户输入写入messages
messages=[HumanMessage]
2
agent节点执行,LLM输出AIMessage(tool_calls)
messages=[Human, AI(tool_calls)]
3
after_tick应用writes
Channel更新
4
下一轮Plan,调用路由函数
路由函数读到最后一条是AI(tool_calls)
5
路由函数返回"tools"
下一轮执行tools节点
6
tools节点执行,返回ToolMessage
messages=[Human, AI, Tool]
7
after_tick应用writes
Channel更新
8
下一轮Plan,调用路由函数
路由函数读到最后一条是ToolMessage
9
路由函数返回"agent"
下一轮执行agent节点
10
agent节点再执行,LLM看到工具结果,输出最终答案
messages=[..., AI(最终答案)]
11
路由函数读到最后一条AI消息没有tool_calls
返回END,执行终止

路由函数在每一轮source节点执行后都会被调用,不是只调用一次。 这就是循环的实现——agent→tools→agent→tools→...直到agent不再输出tool_calls。


四、条件边的完整执行流程

从编译到运行时,条件边经历了5个步骤。

4.1 编译时:attach_branch

步骤
做什么
1
add_conditional_edges创建Branch对象,存入self.branches
2
compile时调用attach_branch
3
Branch被包装成一个特殊的Runnable
4
为每个可能的目标节点建立Channel触发关系
5
注册到source节点的writers中

4.2 运行时:路由函数调用

步骤
做什么
1
source节点执行完成,writes应用到Channel
2
下一轮Plan,prepare_next_tasks检查source节点
3
如果source节点有条件边,读取当前状态快照
4
调用路由函数(同步或异步),传入状态
5
路由函数返回一个key或key列表
6
通过path_map把key映射到真实节点名
7
目标节点被加入待执行任务列表

五、path_map的作用和3种写法

path_map是路由函数返回值和真实节点名之间的映射。

5.1 path_map的3种写法

写法
说明
编译时校验
推荐度
不提供path_map
路由函数返回的字符串直接作为节点名
无法校验
不推荐
提供dict映射
路由函数返回key,通过dict找到真实节点名
可以校验dict的values
推荐
提供list
list中的每个元素既是key也是节点名
可以校验list中节点是否存在
推荐(目标节点名和key一致时)

5.2 为什么推荐提供path_map?

不提供path_map
提供path_map
编译时不知道路由函数会返回什么
编译时知道所有可能的目标
路由函数返回不存在的节点名,运行时才报错
编译时就能发现目标未注册
图可视化不完整(看不到分支)
图可视化完整,能画出所有分支
路由函数返回值必须和节点名完全一致
可以用简短key,通过映射找到节点名

5.3 path_map的特殊目标

特殊目标
说明
END
路由到出口,执行终止
START
不允许(不能回到入口)
其他source节点
可以路由到任何已注册节点

六、普通边+条件边共存:所有目标都会触发

这是第三个容易搞错的点。

6.1 一个节点可以同时有普通边和条件边

你以为的
实际的
一个节点配了条件边就不能配普通边
可以同时配
条件边会覆盖普通边
不会覆盖,所有边的目标都会被触发
二选一
普通边目标 + 条件边路由目标,合并后一起执行

6.2 一个具体例子

假设节点A有:

  • ● 一条普通边:A→B
  • ● 一条条件边,路由函数返回"C"
下一轮被触发的节点
说明
B(来自普通边)
一定会执行
C(来自条件边)
路由函数返回C时执行
B和C并行执行
同一Super-step

普通边和条件边不是"二选一",而是"合并"。 普通边的目标固定触发,条件边的目标根据路由函数动态触发,两者合并后在下一轮并行执行。

6.3 这个设计的应用场景

场景
普通边
条件边
日志记录
节点执行完一定记录日志
同时根据结果路由到不同处理节点
状态更新
节点执行完一定更新某个固定字段
同时根据结果决定下一步
审核流程
节点执行完一定进入审核节点
同时根据审核结果决定后续

但如果条件边也可能返回B,就会出现重复触发。 这时B只会执行一次(版本号去重),不会执行两次,但要注意这个设计可能不是你想要的。


七、条件边vs Command:两种路由方式

LangGraph有两种动态路由方式,容易混淆。

维度
条件边
Command
路由位置
节点外部,独立的路由函数
节点内部,节点返回Command(goto=...)
定义方式
add_conditional_edges
节点返回值是Command对象
状态读取
路由函数读到节点执行后的完整状态
节点在执行时自己决定路由
可测试性
路由函数是纯函数,可以单独测试
路由逻辑在节点内部,需要一起测试
同时更新状态
路由函数不能更新状态
Command可以同时update和goto
适用场景
路由逻辑独立、可复用、需要单独测试
路由逻辑和节点业务紧耦合

7.1 怎么选?

场景
推荐
路由逻辑只依赖state,和节点内部逻辑无关
条件边
路由逻辑需要在节点执行过程中决定
Command
路由函数需要被多个节点复用
条件边
路由同时要更新状态
Command
路由函数需要单独单元测试
条件边
想让节点"自包含",外部不需要知道路由细节
Command

不要同时用条件边和Command做路由。 如果节点返回了Command(goto=...),同时又配了条件边,两者会冲突或产生意外的合并结果。选一种。


八、条件边返回多个目标

条件边不仅可以路由到一个节点,还可以路由到多个节点。

8.1 多目标返回

返回类型
路由结果
字符串
路由到一个节点
字符串列表
路由到多个节点,并行执行
Send对象
动态创建并行分支(第22期详细讲)
Send列表
动态创建多个并行分支

8.2 多目标的典型场景

场景
路由函数返回
同时执行搜索和代码分析
["search", "analyze"]
模型输出多个tool_calls
每个tool call一个Send(v2)
同时记录日志和进入下一步
["log", "next"]

多目标返回时,所有目标节点在下一轮并行执行。 如果多个目标写入同一个Channel,需要有reducer,否则会冲突。


九、条件边不生效的5个原因

这是最实用的排查指南。

原因
现象
排查方法
路由函数返回值在path_map里没有
下一轮没有节点执行,或报错
检查路由函数返回的每个key是否在path_map中
路由函数检查的字段名错误
总是走到默认分支
打印路由函数读到的state,检查字段名
节点同时用了Command(goto=...)
条件边被忽略
检查节点返回值是否为Command
普通边把执行流带到了别的地方
条件边目标和普通边目标意外合并
检查source节点是否有普通边
路由函数抛出异常被error_handler捕获
走到错误处理分支
检查路由函数本身是否有异常

9.1 调试路由函数的3个方法

方法
说明
单独调用路由函数
构造state,直接调用路由函数,看返回值
在路由函数中打印state
打印路由函数收到的完整state,检查字段
用图可视化
检查编译后的图,条件边是否正确建立

最佳实践:路由函数永远写成纯函数,不调用LLM、不产生副作用、不依赖外部状态。 这样可以单独单元测试,也可以在出问题时直接调用排查。


十、路由函数的设计原则

原则
说明
纯函数
输入state,输出key或key列表,无副作用
不调用LLM
路由逻辑应该是确定性的,LLM调用在节点里做
用Literal标注返回类型
提供类型提示,也方便编译时校验
有默认分支
所有情况都处理,不要返回None或未定义值
可单独测试
构造state直接调用,不依赖整个图
字段名和State定义一致
不要拼写错误,用常量或枚举

10.1 把路由信号存在State里

最佳实践是:节点负责产生路由信号(写入State字段),路由函数只负责读取信号并路由。

做法
例子
可测试性
节点直接返回结果,路由函数检查复杂结构
检查最后一条消息的tool_calls
较差,需要构造完整消息
节点写入路由信号字段,路由函数读取
节点写入{"route": "tools"},路由函数读取route
好,简单明确

推荐第二种。 节点负责"业务决策"(写入明确的route字段),路由函数只负责"读字段返回key"。这样路由函数极其简单,不会出错,也方便单元测试。


十一、常见坑:条件边的5个隐蔽问题

坑
现象
根因
解法
条件边在节点执行前路由
路由函数看不到节点输出
错误理解了执行时机
条件边在节点执行后,读到的是执行后状态
条件边和普通边冲突
下一轮执行了意外的节点
普通边和条件边目标会合并
检查是否需要同时配两种边
条件边和Command冲突
路由结果不可预测
节点返回Command同时又配了条件边
只选一种路由方式
路由函数返回多个目标但没有reducer
多目标写入冲突
并行节点写同一个无reducer字段
给字段加reducer
没提供path_map,路由返回拼写错误
运行时才报错,难以排查
编译时无法校验
总是提供path_map或list

十二、面试关联:条件边的5道高频题

面试题
答题要点
考察深度
条件边在什么时候执行?
source节点执行完成、writes应用后,下一轮Plan阶段调用路由函数
执行时机
路由函数读到的是节点执行前还是后的状态?
节点执行后的状态,包含source节点的writes,所以能检查tool_calls
状态读取
条件边和Command路由有什么区别?
条件边是外部独立纯函数,可单独测试;Command是节点内部返回,可同时更新状态
路由方式
一个节点可以同时有普通边和条件边吗?
可以,普通边目标固定触发,条件边目标动态触发,合并后并行执行
边的共存
怎么调试条件边不生效?
单独调用路由函数、打印state、检查path_map、检查是否用了Command、检查普通边
排查能力

十三、本期小结

条件边执行时机,核心就6句话:

  1. 1. 条件边在source节点执行完成后才被调用。 不是执行前,不是执行中,是writes应用到Channel之后、下一轮Plan阶段。它是"出口路由",不是"入口守卫"——source节点一定会执行,条件边只影响后续。
  2. 2. 路由函数读到的是节点执行后的状态。 包含source节点刚写入的writes,所以经典ReAct路由能检查last_message的tool_calls。路由函数在每一轮source节点执行后都会被调用,这就是循环的实现。
  3. 3. path_map是路由返回值和真实节点名的映射。 3种写法:不提供(不推荐,编译时无法校验)、dict映射(推荐)、list(推荐)。提供path_map让编译时能校验所有目标、图可视化完整。
  4. 4. 普通边和条件边可以共存,目标会合并。 普通边目标固定触发,条件边目标动态触发,合并后下一轮并行执行。不是二选一,不会覆盖。应用场景包括固定日志+动态路由。
  5. 5. 条件边和Command是两种路由方式,不要同时用。 条件边是外部纯函数,可单独测试、可复用;Command是节点内部返回,可同时更新状态。选哪种取决于路由逻辑是否独立、是否需要单独测试。
  6. 6. 条件边可以返回多个目标。 字符串列表或Send列表,所有目标并行执行。条件边不生效的5个原因:path_map缺项、字段名错误、用了Command、普通边冲突、路由函数异常。最佳实践:路由函数写成纯函数,路由信号存在State里,总是提供path_map。

十四、下一期预告

下一期(源码09),用户API/编译层第三篇:

【LangGraph源码精读】第09期|从StateGraph到CompiledStateGraph,compile时发生了什么

compile的完整流程:schema解析、Channel创建、节点编译、边和分支处理、校验、Pregel实例构建。搞懂你调用compile()的那一刻,底层到底做了多少工作。

你在使用条件边时遇到过不生效的问题吗?评论区聊聊,我优先写你最关心的场景。


本文为《LangGraph源码精读30讲》系列第08篇。内容基于LangGraph v1.2.11源码结构梳理,核心文件位于libs/langgraph/langgraph/graph/目录下的graph.py(add_conditional_edges、Branch)、state.py(StateGraph.compile、attach_branch),以及pregel/_algo.py(prepare_next_tasks中条件边路由)。版本更新后部分细节可能变化。如有错漏欢迎指正。

相关学习资料