上一篇[《# LangChain 到 LangGraph: RAG 知识库改造》]把问答流程拆成了两个节点:
START → retrieve → generate → END
retrieve 读取问题并检索资料,generate 根据资料生成答案,节点之间通过 State 传递数据。这个版本已经能查询《天龙八部》知识库,但所有问题都会进入检索。
用户问“阿朱是怎么死的”,检索小说合理;用户问“1+1 等于几”,再去小说里搜索就没有必要。本篇沿着 rag-query-router.mjs 的代码,在原图前面增加一次判断:这个问题需要小说资料,还是可以直接回答?
建库、Milvus 集合和原来的检索生成节点继续复用。新增内容集中在三个地方:路由节点、直接回答节点、条件边。下文给出源码讲解和必要修正片段,原脚本未修改,也未实际调用模型或数据库验证。
图 1 对比升级前后的结构。蓝色部分是本篇新增能力,灰色部分沿用上一篇;这张图展示调用图后的问答流程,数据库初始化仍在图外。
图中的菱形代表条件边的选择逻辑,
decideNext 是路由函数,不是通过 addNode() 注册的独立节点。
此前“检索后进入生成”是固定顺序。现在入口先走 route_question,根据它写入的策略,选择其中一条分支。本例的一次执行只选择一条分支。
代码中的 simple 和 complex 是两个策略标签。判断标准是“回答是否依赖特定小说资料”,不能只看题目长短或计算难度。
| 问题 | 期望策略 | 原因 |
|---|---|---|
| 1+1 等于几? | simple | 无需小说资料 |
| JavaScript 数组的 map 有什么作用? | simple | 通用技术知识 |
| 阿朱是怎么死的? | complex | 需要核对小说情节 |
| 请引用原文说明阿朱与萧峰的关系 | complex | 需要原文依据 |
即使模型记得小说情节,只要知识库产品要求回答有资料依据,也应进入检索。这里选择的是回答策略,而不只是测试模型“会不会”。
这套二分类只覆盖示例约定的范围。“今天北京的天气”虽然也需要外部资料,却无法通过小说知识库得到答案。因此,路由提示词里的“外部检索”应具体理解为本例的小说检索;面向更多领域时,需要另行定义支持范围和其他去向。
沿用上一篇的 State,在原有四个字段上新增两个路由字段:
| 字段 | 用途 | 写入位置 |
|---|---|---|
question | 用户问题 | 调用入口 |
k | 检索片段数量 | 调用入口 |
strategy | simple 或 complex | 路由节点 |
routeReason | 模型给出的简短分类理由 | 路由节点 |
documents | 检索结果 | 检索节点 |
generation | 最终答案 | 直接回答或生成节点 |
为了清楚展示每个字段,教学代码使用显式的 Annotation() 调用,并在执行入口统一传入初始值:
import { Annotation, StateGraph, START, END } from "@langchain/langgraph";
const GraphState = Annotation.Root({
question: Annotation(),
k: Annotation(),
strategy: Annotation(),
routeReason: Annotation(),
documents: Annotation(),
generation: Annotation(),
});
这些字段按最新更新值覆盖。两个回答节点都写入 generation,调用方因此不需要分别处理两种答案字段。routeReason 便于查看分类依据,但它只是模型输出的解释,不能作为分类正确的证明。
接下来,让模型返回一个程序可以直接读取的路由结果。
如果只让模型输出“我觉得需要检索”,程序还得从自然语言中猜测结论。源码使用 Zod 描述输出结构,再交给 withStructuredOutput():
import { z } from "zod";
const RouteSchema = z.object({
strategy: z.enum(["simple", "complex"]),
reason: z.string(),
});
const router = model.withStructuredOutput(RouteSchema);
strategy 只能是两个约定值之一,reason 是字符串。withStructuredOutput() 让模型调用按指定结构返回并解析结果;使用 Zod 时还会进行结构校验。具体实现方式受模型服务支持能力影响,需要确认所用服务与配置兼容。LangChain 结构化输出文档
对于“阿朱是怎么死的”,期望结果类似:
{
"strategy": "complex",
"reason": "需要依据小说中的具体情节回答。"
}
这是输出形状示意,并非运行日志。结构正确与判断正确是两件事:模型可能返回合法的 simple,却把一个需要证据的问题分错类。
路由节点负责调用这个结构化模型,并把结果写入 State。下面将原提示词中的“任务关系”改为“人物关系”,同时把检索对象说明得更具体:
const routeQuestionNode = async (state) => {
const route = await router.invoke([
{
role: "system",
content: `判断问题是否需要查询《天龙八部》知识库。
simple:通用常识、简单定义或通用编程问题,无需小说资料。
complex:涉及小说具体情节、人物关系、章节事实或原文证据。
涉及小说事实时,即使你记得答案,也选择 complex。
只做分类,并给出简短理由。`,
},
{ role: "user", content: state.question },
]);
console.log(`路由:${route.strategy};原因:${route.reason}`);
return {
strategy: route.strategy,
routeReason: route.reason,
};
};
question、k 没有变化,节点不必重复返回它们。这里也没有执行检索:路由节点只产生分类结果,下一步去哪由条件边决定。
条件边使用源码中的 decideNext:
const decideNext = (state) =>
state.strategy === "simple" ? "direct_answer" : "retrieve";
它是一个普通 JavaScript 函数,不会再次调用模型。图 2 按时间顺序展示“模型分类”如何变成“程序选择路径”,其中 complex 是示意结果:
sequenceDiagram
participant G as LangGraph
participant R as routeQuestionNode
participant M as 结构化模型
participant S as 本次图状态 State
participant D as decideNext
G->>R: 执行节点,传入当前状态
R->>M: 问题与分类规则
M-->>R: strategy 为 complex,并给出 reason
R-->>G: 返回 strategy、routeReason 更新
G->>S: 合并节点返回的更新
G->>D: 传入更新后的状态
D-->>G: 返回 retrieve
Note over G,D: 此处只运行 JavaScript,不再调用模型
G->>G: 根据映射调度 retrieve 节点
顺序很重要:先保存路由节点的状态更新,再执行 decideNext,所以它能读到本轮刚得到的策略。
addConditionalEdges() 把这个选择逻辑接到路由节点后面。它的第三个参数将返回的标识映射为目标节点名。LangGraph 条件边文档
const graph = new StateGraph(GraphState)
.addNode("route_question", routeQuestionNode)
.addNode("direct_answer", directAnswerNode)
.addNode("retrieve", retrieveNode)
.addNode("generate", generateNode)
.addEdge(START, "route_question")
.addConditionalEdges("route_question", decideNext, {
direct_answer: "direct_answer",
retrieve: "retrieve",
})
.addEdge("retrieve", "generate")
.addEdge("generate", END)
.addEdge("direct_answer", END)
.compile();
这段构图代码放在所有节点定义之后。addEdge() 表达固定连接;addConditionalEdges() 表达根据状态选择连接。本例已经用条件边连接了路由节点,不需要再给它添加一条无条件指向检索的边,否则会额外安排检索。
读到这里,可以记住这组分工:模型给出策略,路由节点保存策略,条件函数返回分支标识,LangGraph 按连接关系执行目标节点。
另一条新增路径是 direct_answer。它读取原始问题,不使用小说片段,直接生成回答。
源码中的这个节点尚未完成:它取得了 model.stream() 返回的流,却没有遍历流,也没有返回状态更新。因此,不能据此认为简单问题已经能完整输出并保存答案。可以将该函数替换为:
const directAnswerNode = async (state) => {
console.log("---DIRECT-ANSWER---");
let generation = "";
const stream = await model.stream([
{ role: "system", content: "请用中文直接回答问题。" },
{ role: "user", content: state.question },
]);
for await (const chunk of stream) {
const text = typeof chunk.content === "string" ? chunk.content : "";
generation += text;
process.stdout.write(text);
}
process.stdout.write("\n");
return { generation };
};
这里有两个动作:process.stdout.write() 把内容显示在终端,return { generation } 把完整答案交回图。只打印而不返回,最终状态拿不到答案;只返回而不打印,则不会在终端逐块展示。
complex 分支继续使用上一篇的 retrieveNode 和 generateNode。它们分别执行相似度检索,以及用“问题+小说片段”生成回答。路由改造不要求重新切分电子书,也不要求重新建立向量库。
为了看清两条分支,可以逐条跟踪状态。以下假设分类符合预期,并且直接回答节点已经补齐:
| 问题 | route_question 之后 | 执行路径 | 最终 documents |
|---|---|---|---|
| 1+1 等于几? | strategy="simple" | direct_answer → END | 保持初始空数组 |
| 阿朱是怎么死的? | strategy="complex" | retrieve → generate → END | 本次检索结果 |
两条路径最后都会填充 generation。只有小说问答路径拥有检索证据;直接回答路径使用的是模型本身的能力,不能把它的答案当成“已核对知识库”的结果。
图 3 展示两条路径怎样更新同一套状态字段。它画的是一次执行中的状态变化;两个分支不会同时执行。蓝色框只列出该步骤更新的字段,未列出的字段继续保留。
沿左侧路径复习:documents 始终是初始空数组。沿右侧路径复习:检索先写入 documents,生成再读取它。无论哪条路径,调用方最后都从同一个 generation 字段取得答案。
按源码的初始化方式调用图:
const result = await graph.invoke({
question: "阿朱是怎么死的?",
k: 5,
strategy: "",
routeReason: "",
documents: [],
generation: "",
});
console.log({
strategy: result.strategy,
reason: result.routeReason,
documentCount: result.documents.length,
answer: result.generation,
});
原脚本在 main() 中把问题写死为“阿朱是怎么死的”。测试其他问题时,需要修改这个变量;当前代码没有读取命令行问题参数。沿用上一篇的依赖和模型配置,在项目目录运行:
cd D:\workspace\ysh_ai\ai\agent\agentic_rag\advanced-rag
node .\src\rag-query-router.mjs
运行前需要已有的小说集合,并确认模型支持本例的结构化输出。上一篇提到的 Embedding 和索引一致性也继续适用:本文件已经指定 dimensions: 1024,但仍使用 HNSW、ef 搜索配置,应核对它们与实际 Milvus 索引是否匹配。
这里还有一个容易忽略的区别:简单问题跳过了检索节点,但原脚本仍然会在 graph.invoke() 之前连接 Milvus、加载集合。所以按照当前写法,Milvus 不可用时,即使问题是“1+1”,也可能还没进入路由就失败。要让直接回答路径摆脱数据库依赖,需要把初始化延后到检索分支,并缓存已建立的连接;这属于后续实现改进。
路由也不保证每次都更快、更省。按一次成功执行、忽略重试来计数:
| 执行方案 | 路由模型请求 | 回答模型请求 | 问题向量化与相似度检索 |
|---|---|---|---|
| 上一篇固定 RAG | 0 次 | 1 次 | 1 次 |
| 本篇 simple 路径 | 1 次 | 1 次 | 0 次 |
| 本篇 complex 路径 | 1 次 | 1 次 | 1 次 |
simple 路径省去了问题向量化、检索和小说上下文,但新增了一次分类请求;complex 路径则在原有流程前额外分类一次。实际是否节省成本和时间,取决于问题分布、模型选择、上下文长度以及服务延迟。
验证时,分别输入常识问题、小说事实问题和要求引用原文的问题,观察三个结果:strategy 是否符合预期,实际进入了哪个节点,generation 是否有内容。可以在 retrieveNode 开头添加 console.log("---RETRIEVE---"),配合路由和直接回答日志核对路径。单看 documents.length === 0 不足以证明跳过了检索,因为检索也可能返回空结果。
源码还有两处失败路径需要理解:结构化输出解析失败时,路由调用会抛错,不会自动变成某个策略;检索函数捕获异常后返回 [],则会混淆“没有资料”和“数据库出错”。这些情况应明确记录并单独处理。decideNext 中的默认 retrieve 分支无法接住路由节点已经抛出的异常。
日后复习,可以对照这张表定位新增代码:
| 复习问题 | 对应代码与答案 |
|---|---|
| 谁定义可选策略? | RouteSchema 中的 z.enum() |
| 谁判断问题属于哪类? | routeQuestionNode 调用结构化模型 |
| 谁把策略交给后续步骤? | State 中的 strategy、routeReason |
| 谁选择下一条边? | decideNext 与 addConditionalEdges() |
| 谁写最终答案? | directAnswerNode 或 generateNode |
| 哪些能力沿用上一篇? | 建库、Embedding、Milvus、检索和基于资料生成 |
本次增加了一次由模型参与的路径选择,所有可选节点和连接仍由开发者预先定义。小说分支依旧只检索一次、生成一次。如果一个问题需要先查到某个人物,再根据这个人物继续查另一段资料,这张图还没有表达这样的连续检索过程。后续文章可以从这个具体缺口继续演进。