深入浅出 LangGraph —— 第5章:条件边与动态路由

📖 本章学习目标

  • ✅ 掌握条件边(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 中的标准实现

工作流程:

    1. planNode生成3个子话题
    1. dispatchSubTasks创建3个Send对象
    1. LangGraph并行执行3个processSubTask实例
    1. 每个实例返回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:接收子话题,生成简介,返回result
  • aggregateNode:将所有结果拼接为完整报告
  • 返回{}:不更新任何字段,只做日志记录
  • 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 的 JSON
  • z.enum([...]):限定 LLM 只能返回枚举中的值,防止幻觉导致路由出错
  • reason 字段让 LLM 解释决策原因,便于调试
  • 这种模式让路由具备了"理解能力"而不仅仅是规则匹配

优势:

  1. 语义理解:能识别"帮我算一下"="calculate"
  2. 灵活性:新增路由只需修改enum,无需重写规则
  3. 可解释性: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_calls
  • shouldCallTool:标准的工具路由函数,检查 LLM 是否想调用工具
  • 'tool_calls' in lastMessage:检查消息中是否存在工具调用字段

工作流程:

  1. LLM节点生成带tool_calls的消息
  2. shouldCallTool检测到tool_calls,路由到tools节点
  3. ToolNode执行工具,返回结果
  4. 结果追加到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:情感分析路由

  • 目标:根据用户消息的情感(正面/负面/中性)路由到不同的回复节点
  • 要求 :
    1. 使用 LLM 分析情感,用 withStructuredOutput 约束输出
    2. 定义三种情感对应的回复节点
    3. 每种情感使用不同风格的回复语气
  • 验收标准 :
    • 正面消息→热情回复
    • 负面消息→同理心回复
    • 中性消息→专业回复

练习 2:批量文章摘要

  • 目标:给定5篇文章列表,用 Send API 并行生成每篇的摘要
  • 要求 :
    1. 实现 plan(生成文章列表)→ 并行 summarize → aggregate(汇总)
    2. 每篇文章摘要不超过100字
    3. 最终汇总为完整报告
  • 验收标准 :
    • 5个摘要并行生成
    • 最终汇总到一个字段中
    • 总耗时接近单篇摘要时间(证明并行生效)

练习 3:带重试的路由

  • 目标:LLM 生成的内容质量不佳时,路由回生成节点重试(最多3次)
  • 要求 :
    1. State 中记录重试次数 retryCount
    2. 节点用 Command 控制重试流程
    3. 超过3次后跳转到错误节点
  • 验收标准 :
    • 低质量内容自动触发重试
    • 最多重试3次
    • 超限后优雅降级到错误处理节点

练习 4:智能客服路由系统

  • 目标:结合本章和第17章知识,实现完整的客服路由
  • 要求 :
    1. 意图识别节点(订单/退款/FAQ/投诉)
    2. 根据意图路由到对应处理节点
    3. 低置信度转人工
    4. 使用常量定义节点名
  • 验收标准 :
    • 4种意图正确路由
    • 置信度<0.7转人工
    • 无拼写错误和运行时异常

📚 延伸阅读


下一章:第6章 ------ 工具调用与 Tool Node

相关推荐
财迅通Ai1 小时前
九丰能源2025年年报:主业稳健提质,新兴业务开辟增长新极
人工智能·能源·九丰能源
刘佬GEO1 小时前
线下医美机构做 GEO 的实际价值:从策略到效果拆解
网络·人工智能·搜索引擎·ai·语言模型
前端摸鱼匠1 小时前
【AI大模型春招面试题26】大模型的“上下文窗口”(Context Window)是什么?长度对模型性能的影响?
人工智能·ai·面试·大模型·求职招聘
ZWZhangYu1 小时前
MCP 实战:从协议原理到 Java 自定义工具服务落地
java·开发语言·人工智能
IT_陈寒1 小时前
为什么我的JavaScript变量老是不听使唤?
前端·人工智能·后端
草莓熊Lotso2 小时前
从 LLM 底层原理到 LangChain 全链路打通:大模型应用开发新征程
linux·运维·服务器·人工智能·langchain
ai产品老杨2 小时前
【深度架构解析】高并发 AI 视频管理平台:兼容 GB28181/RTSP,支持 X86/ARM+GPU/NPU 异构部署与源码交付
人工智能·架构·音视频
liliangcsdn2 小时前
代码知识库开源方案的整理和探索
人工智能
花千树-0102 小时前
ReAct 思考-行动-观察循环的底层实现机制
langchain·agent·react·ai编程·ai agent·langgraph·mcp