一句话价值:直线图里"下一步去哪"是写死的;条件边把它变成运行时由状态决定------于是"算错重试、该查资料才查、Agent 反复调工具直到满意"这些真实行为,终于能在一张图里表达出来。
从"固定流水"到"看情况走"
前两篇里,图的边都是固定的:A → B → END,编译时就定死了。这种图本质还是链。
但真实的 Agent 是看情况走 的:用户问的是数学题就走数学处理,是闲聊就走对话;检索式问答里"问题太偏,检索不到就换个问法再试"。这些"下一步去哪,取决于当前这一步的结果"的需求,就是条件边(conditional edge)的用武之地。
条件边:三件东西决定下一步
addConditionalEdges 接受三样东西:
js
.addConditionalEdges(
"router", // ① 从哪个节点出发做判断
(state) => state.route, // ② 路由函数:读 state,返回"去向的键"
{ math: "math", chat: "chat" } // ③ pathMap:把键翻译成"已注册的节点名"
)
机制一句话:节点 router 跑完后,运行时调用 ② 这个路由函数,用它的返回值当键去查 ③ 这张表,得到真正的目标节点名,再走过去。 返回值也可以直接用节点名或 END。
这里有个值得细品的写法------决策先落进状态,再被边读取:
js
// ① 先有个"路由"节点:它只负责判断,并把结论写进 state.route
const router = (state) => {
const isMath = /[+\-*/]/.test(state.query)
return { route: isMath ? 'math' : 'chat' }
}
// ② 条件边"只是读字段、查表",不含任何判断逻辑
.addConditionalEdges('router', (state) => state.route, { math: 'math', chat: 'chat' })
这跟"把判断直接塞进条件边函数"相比,多写了一个节点,但换来三个实实在在的好处:
- 决策可观测 ------
route落在状态里,日志、断点、后续节点都能看到"为什么走这条路",判断不是一次性丢弃的。 - 可复用 ------以后别的节点也要知道"这是数学还是闲聊",直接读
state.route,不用重算。 - router 可单测 ------它就是个
state → 增量的纯函数,脱离图也能测。
判断简单时可以省这个节点;判断复杂了(多字段联合、要调工具才能定),这种"状态化路由"会明显更好维护。
从"规则"进化到"LLM 路由"
上面用正则判断"有没有运算符",只能识别字面符号。用户问一句"帮我算一下三加五",正则就懵了------该走数学却没走。
把 router 的函数体 换成一次 LLM 调用,判断就从"规则"升级成"语义分类",而且拓扑一行都不用改:
js
import * as z from 'zod'
// 让 LLM 直接吐出结构化结果,zod 强约束只有两种取值
const RouteSchema = z.object({
route: z.enum(['math', 'chat']).describe('需要数学计算选 math,其余选 chat'),
})
const classifier = llm.withStructuredOutput(RouteSchema, { method: 'functionCalling' })
const router = async (state) => {
const { route } = await classifier.invoke([
['system', '你是一个问题分类器。'],
['user', state.query],
])
return { route } // 写进 state,条件边照旧读取
}
这正是前一篇讲"状态与流程正交"的回报:状态模型和边拓扑是 LLM 路由和规则路由共用的,切换只发生在节点内部。
顺带记一个真实的坑:withStructuredOutput 默认走 json_schema 模式,但 DeepSeek 目前不支持 (会报 400 This response_format type is unavailable now),要显式指定走 function calling:
js
llm.withStructuredOutput(RouteSchema, { method: 'functionCalling' })
循环?不过是"条件边指回自己"
分支是"一个出口选两个目标"。而 Agent 里最频繁的行为是循环 :算错了重试、调了工具看结果再决定还要不要继续------这需要一条边指回自己。
用同一个 State 就能搭出"最多试 3 次"的自环:
js
const State = Annotation.Root({
tries: Annotation({ reducer: (_prev, next) => next, default: () => 0 }),
ok: Annotation({ reducer: (_prev, next) => next, default: () => false }),
message: Annotation({ reducer: (_prev, next) => next, default: () => '' }),
})
const attempt = (state) => {
const tries = state.tries + 1
return { tries, ok: tries >= 3, message: tries >= 3 ? '第3次成功' : '第3次还不行' }
}
new StateGraph(State)
.addNode('attempt', attempt)
.addEdge(START, 'attempt')
.addConditionalEdges('attempt', (s) => (s.ok ? 'done' : 'retry'), {
retry: 'attempt', // 条件边指回自己 → 形成循环
done: END,
})
.compile()
注意那条 retry: 'attempt':条件边返回的键把自己送回出发节点,就是循环。 你之前听过的"Agent 反复调用工具直到满意",画成图就是模型节点 → 工具节点 → 条件边看还有没有工具要调 → 有就回到模型节点------同一个自环结构。
配合 tries 字段,每次回到 attempt 都能从状态里读到"已经试了几次",所以循环是有记忆、可计数 的,不是死循环。再配一个 recursion 上限(LangGraph 会抛 GraphRecursionError),循环就有了安全阀。
两个防呆提醒
- 条件函数返回的键必须最终映射到已注册的节点名(或
END),否则运行时会报"找不到节点"。 - pathMap(第三参数)不只是映射表,还充当编译期校验清单------用它把目标列全,拼错能尽早暴露,而不是等运行到那里才炸。
本篇小结
- 固定边 = 直线流水;条件边让**"下一步去哪"变成运行时由状态决定**------分支由此而来。
- 推荐"决策写进状态、条件边只读字段"的两段式,换来可观测、可复用、可单测。
- 把路由节点的函数体从正则换成 LLM 分类器,就从规则路由升级成语义路由,拓扑不用动;DeepSeek 记得用 function calling 模式。
- 条件边指回自己就是循环;循环配计数字段 + recursion 上限,才是安全的重试。
- 分支 + 循环合起来,就能表达"工具调用型 Agent"的完整自环。
系列预告
最后一篇收束主线:前几篇的图都是"一次调用里活着"。当状态要跨次调用地延续 ------记住上一个用户说了什么、把一笔转账停下来等人审批、崩溃后从断点续跑------需要 checkpointer。它一个机制,解锁记忆、人工介入、断点续跑三件事。