第 4 章 让流程学会决策
本章目标:掌握 addconditionaledges——让"下一步去哪"由运行时的状态决定。分支、循环、打回重做,都由这一个 API 承担。同时建立两条安全意识:循环必须有出口,出口要有安全阀
本章目标:掌握
add_conditional_edges——让"下一步去哪"由运行时的状态决定。分支、循环、打回重做,都由这一个 API 承担。同时建立两条安全意识:循环必须有出口,出口要有安全阀。
4.1 静态边的局限
到目前为止,我们的图全是"静态边":add_edge("A", "B") 在编译时就写死了 A 之后必是 B。但真实流程很少这么听话:
简历解析完成后,如果关键字段缺失(比如没识别出技能清单),应该打回重新解析;解析完整了才继续评估。
"下一步去哪"取决于运行时的状态(这次解析得完不完整),而不是编译时的结构。静态边表达不了这件事。
你可能会想:把检查逻辑放进节点,节点内部自己调解析函数不就行了?可以,但这样"重试"就变成了节点内部的私有循环——从图上看不见。图的价值恰恰在于把所有控制流显式化:能看见,才能观测、才能重试单步、才能在持久化时精确恢复。凡是控制流,尽量画进图里,这是使用 LangGraph 的一条心法。
4.2 条件边:add_conditional_edges
条件边把"边"从一个固定连接升级为"路由函数 + 映射表":
builder.add_conditional_edges(
"extract", # 源节点:从谁出发
route_after_extract, # 路由函数:读状态,返回"去向标签"
{ # 映射表(可选):标签 -> 目标节点
"complete": "evaluate",
"incomplete": "extract",
},
)
三个参数各自的角色:
- 源节点:条件边挂在哪个节点后面
- 路由函数:签名与节点类似(入参是状态),返回值是"去向标签"——一个字符串,或一个字符串列表(多路并行,4.5 节)
- 映射表:把标签翻译成真正的节点名。省略时,返回值直接就是节点名
配上 END 还能表达"条件结束"。整体效果:extract 每次执行完,引擎调用 route_after_extract,按返回标签查表决定下一个超步执行谁。
4.3 完整 demo:解析不完整就打回重做
下面是可运行的完整例子。extract 负责解析简历;每次解析后由路由函数检查关键字段是否齐备,不齐备就回到 extract 再来一次(演示中模拟"第二次才能解析成功"):
from typing import Annotated, TypedDict
import operator
from langgraph.graph import StateGraph, START, END
class ScreenState(TypedDict):
resume_text: str
candidate: dict # 解析结果
parse_attempts: Annotated[int, operator.add] # 解析次数:累加
ready: bool # 是否解析完整
score: int # 最终得分
def extract(state: ScreenState) -> dict:
attempts = state.get("parse_attempts", 0)
if attempts < 1:
# 模拟第一次解析缺字段
return {"parse_attempts": 1, "candidate": {"name": "李思远"}, "ready": False}
# 第二次成功解析出完整字段
return {
"parse_attempts": 1,
"candidate": {
"name": "李思远",
"skills": ["Python", "FastAPI", "PostgreSQL"],
"years": 6,
},
"ready": True,
}
def evaluate(state: ScreenState) -> dict:
c = state["candidate"]
score = min(100, 20 + len(c["skills"]) * 10 + c["years"] * 3)
return {"score": score}
def route_after_extract(state: ScreenState) -> str:
return "evaluate" if state["ready"] else "extract"
builder = StateGraph(ScreenState)
builder.add_node("extract", extract)
builder.add_node("evaluate", evaluate)
builder.add_edge(START, "extract")
builder.add_conditional_edges("extract", route_after_extract, {
"evaluate": "evaluate",
"extract": "extract", # 注意:指回自己,形成循环
})
builder.add_edge("evaluate", END)
graph = builder.compile()
result = graph.invoke({"resume_text": "李思远 ..."})
print(result["parse_attempts"]) # 2 —— 解析执行了两轮
print(result["candidate"]) # 完整字段
注意几个细节:
- 循环的实现没有任何特殊 API——一条指回上游节点的边就是循环。图从不禁止环,终止靠路由函数的逻辑。
parse_attempts用了Annotated[int, operator.add]:每执行一次 extract 就 +1,天然是"计数器"。- 路由函数读的是合并后的最新状态(extract 的补丁已生效),所以它能立即看到
ready的值。
4.4 循环的安全阀:recursion_limit 与主动计数
有循环就有死循环的风险。假如 extract 永远解析不完整,上面的图就会永远转下去。LangGraph 给了两道防线。
第一道:引擎级安全阀 recursion_limit。引擎限制一次 invoke 最多执行的超步数(可以理解为"图推进的轮次",第 5 章精确定义),默认 25。超过即抛出 GraphRecursionError,防止进程挂死:
from langgraph.errors import GraphRecursionError
try:
graph.invoke({"resume_text": "永远解析不出来的简历..."})
except GraphRecursionError:
print("超过最大轮次,强制终止")
也可以在调用时显式调整:
config = {"recursion_limit": 10}
graph.invoke({"resume_text": "..."}, config)
第二道:业务级出口。安全阀是保险丝,不是业务逻辑。"最多重试几次"属于业务规则,应该自己数:
MAX_PARSE_ATTEMPTS = 3
def route_after_extract(state: ScreenState) -> str:
if state["ready"]:
return "evaluate"
if state["parse_attempts"] >= MAX_PARSE_ATTEMPTS:
return "reject" # 业务出口:放弃解析
return "extract"
def reject(state: ScreenState) -> dict:
return {"score": -1, "candidate": {"error": "简历无法解析"}}
两个实践原则:
- 依赖业务出口,兜底依赖安全阀。让正常流程永远走业务出口结束,
GraphRecursionError只应在你写出 bug 时出现。 - recursion_limit 调大要谨慎。它同时放大了最坏情况下的 token 消耗与执行时长,先问自己是不是该加业务出口。
4.5 多路分支:路由函数返回列表
路由函数返回单个标签是"单选",返回列表就是"多选"——多个目标节点会在下一个超步并行执行(并行语义的细节第 5 章展开,这里先建立用法直觉)。
需求:简历解析完成后,技术评估和背景核查同时进行:
def route_after_extract(state: ScreenState) -> list[str]:
targets = ["tech_review"]
if state["candidate"].get("company"): # 有公司信息才做背景核查
targets.append("background_check")
return targets
builder.add_conditional_edges("extract", route_after_extract, {
"tech_review": "tech_review",
"background_check": "background_check",
})
路由函数返回 ["tech_review", "background_check"] 时,两个节点并行跑;只返回 ["tech_review"] 时就只有一个。扇出数量由运行时状态决定——这是条件边比静态边强大的地方,也是第 7 章 Send(更彻底的动态扇出)的思想前身。
4.6 从 START 分流:入口路由
条件边不仅能挂在普通节点后面,也能直接挂在 START 上。典型用法是入口分发:按请求类型决定从哪个节点开始。
builder.add_conditional_edges(
START,
route_by_request,
{"normal": "extract", "vip": "fast_track"},
)
比如 VIP 候选人走快速通道,普通候选人走完整流程。这让"从哪开始"也成为了状态驱动的一部分,而不是写死的第一节点。
4.7 条件边与 Command 的分界线(预告)
条件边把路由逻辑放在节点外面:节点只产出数据,路由函数独立存在。这保证了节点本身的纯粹,但当路由规则越来越复杂、甚至需要节点"边干活边决定去向"时,代码会开始别扭。
LangGraph 为此准备了另一套机制:节点直接返回 Command 对象,同时携带"状态更新 + 去向指令"。两套机制怎么选、怎么混用,第 6 章专门讨论。现阶段记住一点:简单的、基于结果的分流用条件边;与节点内部逻辑强耦合的跳转,留给 Command。
常见坑与排错
-
路由函数返回了映射表里没有的标签。比如
route_after_extract返回了"evalute"(拼错)。若提供了映射表,未命中的标签会在运行时抛KeyError类错误;若没提供映射表(返回值即节点名),拼错会在运行时报找不到节点。对策:把标签定义成模块级常量或Literal类型,让类型检查器帮你盯。 -
条件边和普通出边同时挂在同一个源节点上。这是合法的:静态边的目标一定会执行,条件边的目标按路由结果执行,二者会合并成下一超步的节点集合。但这个组合很容易让人误读流程图。除非明确需要"固定 + 条件"混合扇出,否则一个源节点的出边尽量只用一种方式表达。
-
把 recursion_limit 当业务重试上限。
recursion_limit数的是超步不是"某个节点的重试次数",把它当重试上限会在图结构变化(比如插入一个新节点)后悄悄改变语义。重试上限永远用业务字段自己数(如parse_attempts)。 -
路由函数里做重活。路由函数每个超步都会被调用,且应该廉价、可重放。在里面调 LLM、查数据库都是反模式——需要"智能路由"时,把决策做成前一个节点的输出(节点里调 LLM 得到标签写入状态,路由函数只读状态),路由函数保持哑的。
-
循环里忘记累加计数。
parse_attempts若忘了加 reducer(用了默认覆盖语义且每次返回 1),计数永远停在 1,业务出口永远不触发,直到安全阀引爆。循环 + 计数器是固定搭配,计数器字段几乎必然要operator.add。 -
条件边指向的节点未注册。映射表的目标节点必须已
add_node注册,否则编译期报错。这个错倒是好事——编译期暴露优于运行期爆炸。
动手练习
-
给 4.3 节的图加一个
MAX_PARSE_ATTEMPTS = 3的业务出口:解析三次仍不完整时走reject节点,输出{"score": -1}。验证:把extract改成永远返回ready=False,确认最终走的是 reject 而不是GraphRecursionError。 -
把 4.5 节的多路分支补全成可运行程序:
tech_review与background_check各自给score累加分数(别忘了 reducer),两个节点都汇合到summarize。先猜一下两个并行节点是谁先执行、breakdown顺序如何,再运行验证(答案在第 5 章)。 -
用
START条件边实现入口分流:状态里加is_vip字段,VIP 走fast_track(直接打 85 分),非 VIP 走完整流程。思考:这种入口分流和把判断塞进第一个节点内部相比,可观测性上的差别在哪。
本章速查
| API / 概念 | 一句话 |
|---|---|
add_conditional_edges(src, route, mapping) |
源节点执行完,按路由函数返回的标签查映射表决定去向 |
| 路由函数签名 | 入参状态,返回标签(str)或标签列表(list[str],多路并行) |
| 映射表 | 标签 → 节点名;省略时返回值即节点名;值可以是 END |
| 循环 | 无专用 API,一条指回上游的边 + 路由逻辑即是循环 |
recursion_limit |
引擎级安全阀,限制一次 invoke 的最大超步数(默认 25),超限抛 GraphRecursionError |
| 业务出口 | 用状态里的计数字段(配 operator.add)自数重试次数,达到上限走终止节点 |
| START 条件边 | 入口分发:按状态决定第一个执行的真实节点 |
| 设计原则 | 控制流尽量画进图里;路由函数保持廉价、哑、可重放 |