📖 本章学习目标
- ✅ 掌握条件边(Conditional Edges)的完整用法
- ✅ 理解路由函数的设计原则与最佳实践
- ✅ 学会使用 Send API 实现动态并行分发
- ✅ 能够设计基于 LLM 输出的智能路由
- ✅ 掌握多分支合并的状态处理技巧
- ✅ 避免常见的路由陷阱和性能问题
一、从静态到动态:为什么需要条件边
1、静态路由的局限
上一章我们用 addEdge 构建的图,执行路径是编译时确定的,永远走同一条路。但真实的 Agent 需要根据情况"灵活应变":
现实场景举例:
- 用户问了一个需要联网搜索的问题 → 触发搜索节点
- 用户问了一个可以直接回答的问题 → 跳过搜索,直接回答
- LLM 的回复需要调用工具 → 执行工具节点
- LLM 的回复不需要工具 → 直接返回给用户
这些场景都需要运行时决策------只有程序真正执行到这一步,才能知道该走哪条路。
静态路由 addEdge
START
节点A
节点B
END
动态路由 addConditionalEdges
简单
复杂
需搜索
START
分类节点
简单回复
深度分析
搜索+回复
END
静态路由像地铁线路------固定站点,固定路线。动态路由像打车------根据目的地实时选择最优路线。
2、条件边的工作机制
'search'
'answer'
'error'
节点A
执行完毕
路由函数
读取 State
返回字符串
搜索节点
回答节点
错误处理节点
汇总节点
end
关键点是路由函数 ,它是一个纯函数,读取 State,返回字符串(下一节点名),不做任何副作用。
路由函数的签名:
typescript
// 基本签名
function router(state: StateType): string;
// 多目标签名(并行)
function router(state: StateType): string[];
// Send API 签名(动态并行)
function router(state: StateType): Send[];
二、条件边基础用法
1、简单二选一路由
步骤1:定义状态
typescript
import * as dotenv from 'dotenv';
dotenv.config();
import { Annotation, StateGraph } from '@langchain/langgraph';
import { ChatOpenAI } from '@langchain/openai';
import { HumanMessage } from '@langchain/core/messages';
const RouterState = Annotation.Root({
userInput: Annotation<string>(),
classification: Annotation<'simple' | 'complex' | ''>({
default: () => '',
}), // 分类
answer: Annotation<string>(),
});
type RouterStateType = typeof RouterState.State;
步骤2:定义路由函数
typescript
// 路由函数:读 State,返回节点名字符串
function routeByComplexity(state: RouterStateType): string {
return state.classification === 'complex' ? 'complexNode' : 'simpleNode';
}
路由函数签名(state: StateType) => string,返回值必须是已注册节点的名称,或 'end '。它是纯函数,不应该有副作用(不调用 API 等),在这里是根据classification字段决定路由。
步骤3:定义节点函数
typescript
// 初始化模型
const model = new ChatOpenAI({ model: 'gpt-4o-mini' });
// 分类节点:用 LLM 判断问题复杂度
async function classifyNode(state: RouterStateType) {
const response = await model.invoke([
new HumanMessage(
`判断以下问题的复杂度,只回答 'simple' 或 'complex':\n${state.userInput}`
),
]);
const classification = (response.content as string).trim().toLowerCase();
return { classification: classification as 'simple' | 'complex' };
}
// 简单问题节点
async function simpleNode(state: RouterStateType) {
const response = await model.invoke([new HumanMessage(state.userInput)]);
return { answer: response.content as string };
}
// 复杂问题节点
async function complexNode(state: RouterStateType) {
const response = await model.invoke([
new HumanMessage(`请深度分析并详细回答:${state.userInput}`),
]);
return { answer: response.content as string };
}
步骤4:构建图
typescript
const graph = new StateGraph(RouterState)
.addNode('classify', classifyNode)
.addNode('simpleNode', simpleNode)
.addNode('complexNode', complexNode)
.addEdge('__start__', 'classify')
// 条件边:classify 执行后调用路由函数
.addConditionalEdges('classify', routeByComplexity)
.addEdge('simpleNode', '__end__')
.addEdge('complexNode', '__end__')
.compile();
使用addConditionalEdges定义条件边,接受源节点和路由函数作为参数。LangGraph 会自动扫描路由函数的所有可能返回值,并根据返回值执行对应的节点。
运行测试:
typescript
const result = await graph.invoke({
userInput: '1+1等于几?',
});
console.log(result.answer);
// 输出: 2 (走 simpleNode)
const result2 = await graph.invoke({
userInput: '请分析量子计算对未来密码学的影响',
});
console.log(result2.answer);
// 输出: 长篇深度分析 (走 complexNode)
2、多目标路由(一对多)
路由函数可以返回字符串数组,同时触发多个节点(并行执行):
typescript
function routeToMultiple(state: RouterStateType): string[] {
const targets: string[] = [];
if (state.userInput.includes('价格')) targets.push('priceNode');
if (state.userInput.includes('评价')) targets.push('reviewNode');
if (state.userInput.includes('库存')) targets.push('stockNode');
// 至少触发一个
return targets.length > 0 ? targets : ['generalNode'];
}
路由函数返回字符串数组后,LangGraph 并行执行所有目标节点。并行节点执行完成后,它们的状态更新会按 Reducer 规则合并,适用于"一个问题需要从多个维度同时处理"的场景。
示例:
- 用户输入:"这款手机的价格和评价怎么样?"
- 路由返回:
['priceNode', 'reviewNode'] - 两个节点并行执行,结果合并到State中
并行执行示意图:
分类节点
路由函数
价格节点
评价节点
汇总节点
end
三、Send API:动态并行分发
1、Send API 的使用场景
addConditionalEdges 适合"路由到固定的几个节点"。但如果你需要根据运行时的数据动态创建多个并行任务 (比如对每个搜索关键词都起一个并行节点),就需要 Send API。
规划节点
生成子任务列表
Send API
为每个子任务
动态创建实例
处理子任务1
处理子任务2
处理子任务3
汇总节点
end
对比两种并行方式:
| 特性 | 多目标路由 | Send API |
|---|---|---|
| 并行数量 | 固定(编译时确定) | 动态(运行时决定) |
| 适用场景 | 少数固定分支 | 批量处理(Map-Reduce) |
| 状态隔离 | 共享同一State | 每个实例独立State |
| 返回值类型 | string[] |
Send[] |
2、Send API 实现动态并行
步骤1:定义主图和子任务状态
typescript
import { Annotation, StateGraph, Send } from '@langchain/langgraph';
// 主图的状态
const ParallelState = Annotation.Root({
topic: Annotation<string>(),
subTopics: Annotation<string[]>({ default: () => [] }),
results: Annotation<string[]>({
reducer: (c, u) => [...c, ...u],
default: () => [],
}),
});
// 子任务的状态(每个并行任务独享)
const SubTaskState = Annotation.Root({
subTopic: Annotation<string>(),
result: Annotation<string>(),
});
代码解读:
ParallelState:主图状态,包含主题、子主题列表、结果列表SubTaskState:子任务状态,每个并行实例独立拥有results使用追加Reducer,累积所有子任务的结果Send API会为每个子任务创建独立的SubTaskState实例
步骤2:定义规划节点
typescript
const model = new ChatOpenAI({ model: 'gpt-4o-mini' });
// 规划节点:生成子话题列表
async function planNode(state: typeof ParallelState.State) {
const response = await model.invoke([
new HumanMessage(`把"${state.topic}"分解为3个子话题,每行一个`),
]);
const subTopics = (response.content as string)
.split('\n')
.filter(s => s.trim());
return { subTopics };
}
步骤3:定义Send路由函数
typescript
// Send API 路由函数:为每个子话题创建一个并行任务
function dispatchSubTasks(state: typeof ParallelState.State): Send[] {
return state.subTopics.map(
subTopic => new Send('processSubTask', { subTopic })
);
}
代码解读:
Send(节点名, 初始状态):动态创建一个节点执行实例- 返回
Send[] 数组:有几个元素就并行执行几个实例 - 每个实例有独立的状态,结果通过 Reducer 合并回主图的 State
- 这是 Map-Reduce 模式在 LangGraph 中的标准实现
工作流程:
-
planNode生成3个子话题
-
dispatchSubTasks创建3个Send对象
-
- LangGraph并行执行3个
processSubTask实例
- LangGraph并行执行3个
-
- 每个实例返回
result,自动合并到主图的results数组
- 每个实例返回
步骤4:定义子任务和汇总节点
typescript
// 子任务处理节点
async function processSubTask(state: typeof SubTaskState.State) {
const response = await model.invoke([
new HumanMessage(`请简要介绍:${state.subTopic}`),
]);
return { result: response.content as string };
}
// 汇总节点
async function aggregateNode(state: typeof ParallelState.State) {
const summary = state.results.join('\n\n---\n\n');
console.log('所有子话题结果已汇总,共', state.results.length, '条');
return {};
}
代码解读:
processSubTask:接收子话题,生成简介,返回resultaggregateNode:将所有结果拼接为完整报告- 返回
{}:不更新任何字段,只做日志记录 results字段通过Reducer自动累积,无需手动合并
步骤5:构建图
typescript
const parallelGraph = new StateGraph(ParallelState)
.addNode('plan', planNode)
.addNode('processSubTask', processSubTask)
.addNode('aggregate', aggregateNode)
.addEdge('__start__', 'plan')
// Send API:plan 后动态分发子任务
.addConditionalEdges('plan', dispatchSubTasks)
.addEdge('processSubTask', 'aggregate')
.addEdge('aggregate', '__end__')
.compile();
运行测试:
typescript
const result = await parallelGraph.invoke({
topic: '人工智能的应用领域',
});
console.log(result.results);
// 输出: [
// "人工智能在医疗领域的应用...",
// "人工智能在金融领域的应用...",
// "人工智能在教育领域的应用..."
// ]
四、基于 LLM 的智能路由
1、让 LLM 决定路由
最强大的路由方式是让 LLM 自己决定走哪条路------这是构建智能 Agent 的核心模式:
typescript
import { z } from 'zod';
// 用结构化输出让 LLM 返回路由决策
const RouteSchema = z.object({
destination: z.enum(['search', 'calculate', 'answer', 'clarify']),
reason: z.string(),
});
const routerModel = model.withStructuredOutput(RouteSchema);
async function llmRouterNode(state: typeof RouterState.State) {
const decision = await routerModel.invoke([
new HumanMessage(
`分析用户问题,决定最合适的处理方式:\n` +
`- search:需要搜索外部信息\n` +
`- calculate:需要数学计算\n` +
`- answer:可以直接回答\n` +
`- clarify:问题不清晰,需要追问\n\n` +
`用户问题:${state.userInput}`
),
]);
return { classification: decision.destination };
}
代码解读:
withStructuredOutput(schema):强制 LLM 返回符合 Zod schema 的 JSONz.enum([...]):限定 LLM 只能返回枚举中的值,防止幻觉导致路由出错reason字段让 LLM 解释决策原因,便于调试- 这种模式让路由具备了"理解能力"而不仅仅是规则匹配
优势:
- 语义理解:能识别"帮我算一下"="calculate"
- 灵活性:新增路由只需修改enum,无需重写规则
- 可解释性:reason字段说明决策依据
路由函数配合LLM节点:
typescript
function smartRouter(state: typeof RouterState.State): string {
switch (state.classification) {
case 'search': return 'searchNode';
case 'calculate': return 'calculateNode';
case 'answer': return 'answerNode';
case 'clarify': return 'clarifyNode';
default: return 'answerNode'; // 兜底
}
}
2、Tool Calling 风格路由
LangGraph 内置了工具调用的路由支持(第6章详细介绍),预览核心模式:
typescript
import { tool } from '@langchain/core/tools';
import { ToolNode } from '@langchain/langgraph/prebuilt';
const searchTool = tool(
async ({ query }) => `搜索"${query}"的结果...`,
{
name: 'search',
description: '搜索网络信息',
schema: z.object({ query: z.string() })
}
);
// ToolNode 自动处理工具调用的执行和结果返回
const toolNode = new ToolNode([searchTool]);
// 内置路由函数:检查最后一条消息是否包含工具调用
import { MessagesAnnotation } from '@langchain/langgraph';
function shouldCallTool(state: typeof MessagesAnnotation.State): string {
const lastMessage = state.messages[state.messages.length - 1];
if ('tool_calls' in lastMessage && lastMessage.tool_calls?.length) {
return 'tools'; // 有工具调用 → 执行工具
}
return '__end__'; // 无工具调用 → 结束
}
代码解读:
ToolNode:LangGraph 预构建的工具执行节点,自动解析 tool_callsshouldCallTool:标准的工具路由函数,检查 LLM 是否想调用工具'tool_calls' in lastMessage:检查消息中是否存在工具调用字段
工作流程:
- LLM节点生成带tool_calls的消息
shouldCallTool检测到tool_calls,路由到tools节点ToolNode执行工具,返回结果- 结果追加到
messages,回到LLM节点继续推理
五、最佳实践和踩坑指南
💡 实践 1:路由函数保持纯粹
❌ 不好的做法:
typescript
// 路由函数里调用 API ------ 副作用,难以测试和追踪
async function badRouter(state: StateType) {
const result = await fetch('https://api.example.com/classify');
return result.ok ? 'nodeA' : 'nodeB';
}
✅ 推荐做法:
typescript
// 路由函数只读 State,判断逻辑放在专门的节点里
function goodRouter(state: StateType): string {
return state.classification === 'A' ? 'nodeA' : 'nodeB';
}
原因:路由函数有副作用会导致图的行为不可预测,也无法在 LangSmith 中被正确追踪。
💡 实践 2:处理"兜底"路由
typescript
function safeRouter(state: StateType): string {
const validTargets = ['nodeA', 'nodeB', 'nodeC'];
const target = computeTarget(state);
if (!validTargets.includes(target)) {
console.warn('意外的路由目标:', target, ',使用兜底节点');
return 'fallbackNode'; // 兜底处理
}
return target;
}
原因:防止LLM幻觉或代码Bug导致路由到不存在的节点。
💡 实践 3:使用常量定义节点名
❌ 不好的做法:
typescript
function router(state: StateType): string {
return 'serachNode'; // 拼写错误!应该是'searchNode'
}
✅ 推荐做法:
typescript
// 定义节点名常量
const NODES = {
SEARCH: 'searchNode',
CALCULATE: 'calculateNode',
ANSWER: 'answerNode',
} as const;
function router(state: StateType): string {
return NODES.SEARCH; // TypeScript 会检查拼写
}
原因:避免拼写错误导致的运行时错误,TypeScript提供编译时检查。
⚠️ 常见问题
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 路由函数返回不存在的节点名 | 运行时 Node not found 错误 |
使用常量枚举定义节点名,避免拼写错误 |
| Send API 返回空数组 | 图直接跳到 __end__,不经过后续节点 |
确保 Send 数组至少有一个元素,或添加兜底逻辑 |
| 并行节点的 State 合并冲突 | 某些字段数据丢失 | 为并行写入的字段设计追加 Reducer,而非覆盖 |
| 忘记为条件边目标节点添加出边 | 某些节点执行后图挂起 | 每个目标节点必须有出边连到下一节点或 __end__ |
| 路由函数有副作用 | 行为不可预测,难以调试 | 路由函数必须是纯函数,只做判断不做操作 |
| 循环路由无限执行 | 程序卡死 | 设置最大迭代次数或明确的终止条件 |
📝 本章小结
核心知识点回顾
| 知识点 | 关键要点 | 应用场景 |
|---|---|---|
| 条件边 | addConditionalEdges(node, routerFn) |
分支逻辑、根据状态选路 |
| 多目标路由 | 路由函数返回字符串数组 | 同时触发多个并行节点 |
| Send API | new Send(nodeName, state) 动态分发 |
Map-Reduce 批量并行处理 |
| LLM 路由 | withStructuredOutput 约束返回值 |
智能分类、意图识别 |
| 纯函数原则 | 路由函数无副作用 | 保证可预测性和可测试性 |
| 兜底策略 | 处理意外路由目标 | 提高系统鲁棒性 |
🎯 动手练习
练习 1:情感分析路由
- 目标:根据用户消息的情感(正面/负面/中性)路由到不同的回复节点
- 要求 :
- 使用 LLM 分析情感,用
withStructuredOutput约束输出 - 定义三种情感对应的回复节点
- 每种情感使用不同风格的回复语气
- 使用 LLM 分析情感,用
- 验收标准 :
- 正面消息→热情回复
- 负面消息→同理心回复
- 中性消息→专业回复
练习 2:批量文章摘要
- 目标:给定5篇文章列表,用 Send API 并行生成每篇的摘要
- 要求 :
- 实现 plan(生成文章列表)→ 并行 summarize → aggregate(汇总)
- 每篇文章摘要不超过100字
- 最终汇总为完整报告
- 验收标准 :
- 5个摘要并行生成
- 最终汇总到一个字段中
- 总耗时接近单篇摘要时间(证明并行生效)
练习 3:带重试的路由
- 目标:LLM 生成的内容质量不佳时,路由回生成节点重试(最多3次)
- 要求 :
- State 中记录重试次数
retryCount - 节点用 Command 控制重试流程
- 超过3次后跳转到错误节点
- State 中记录重试次数
- 验收标准 :
- 低质量内容自动触发重试
- 最多重试3次
- 超限后优雅降级到错误处理节点
练习 4:智能客服路由系统
- 目标:结合本章和第17章知识,实现完整的客服路由
- 要求 :
- 意图识别节点(订单/退款/FAQ/投诉)
- 根据意图路由到对应处理节点
- 低置信度转人工
- 使用常量定义节点名
- 验收标准 :
- 4种意图正确路由
- 置信度<0.7转人工
- 无拼写错误和运行时异常
📚 延伸阅读
下一章:第6章 ------ 工具调用与 Tool Node