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() 调用,并在执行入口统一传入初始值:
<span>import</span> { <span>Annotation</span>, <span>StateGraph</span>, <span>START</span>, <span>END</span> } <span>from</span> <span>"@langchain/langgraph"</span>;
<span>const</span> <span>GraphState</span> = <span>Annotation</span>.<span>Root</span>({
<span>question</span>: <span>Annotation</span>(),
<span>k</span>: <span>Annotation</span>(),
<span>strategy</span>: <span>Annotation</span>(),
<span>routeReason</span>: <span>Annotation</span>(),
<span>documents</span>: <span>Annotation</span>(),
<span>generation</span>: <span>Annotation</span>(),
});
这些字段按最新更新值覆盖。两个回答节点都写入 generation,调用方因此不需要分别处理两种答案字段。routeReason 便于查看分类依据,但它只是模型输出的解释,不能作为分类正确的证明。
接下来,让模型返回一个程序可以直接读取的路由结果。
如果只让模型输出“我觉得需要检索”,程序还得从自然语言中猜测结论。源码使用 Zod 描述输出结构,再交给 withStructuredOutput():
<span>import</span> { z } <span>from</span> <span>"zod"</span>;
<span>const</span> <span>RouteSchema</span> = z.<span>object</span>({
<span>strategy</span>: z.<span>enum</span>([<span>"simple"</span>, <span>"complex"</span>]),
<span>reason</span>: z.<span>string</span>(),
});
<span>const</span> router = model.<span>withStructuredOutput</span>(<span>RouteSchema</span>);
strategy 只能是两个约定值之一,reason 是字符串。withStructuredOutput() 让模型调用按指定结构返回并解析结果;使用 Zod 时还会进行结构校验。具体实现方式受模型服务支持能力影响,需要确认所用服务与配置兼容。LangChain 结构化输出文档
对于“阿朱是怎么死的”,期望结果类似:
<span>{</span>
<span>"strategy"</span><span>:</span> <span>"complex"</span><span>,</span>
<span>"reason"</span><span>:</span> <span>"需要依据小说中的具体情节回答。"</span>
<span>}</span>
这是输出形状示意,并非运行日志。结构正确与判断正确是两件事:模型可能返回合法的 simple,却把一个需要证据的问题分错类。
路由节点负责调用这个结构化模型,并把结果写入 State。下面将原提示词中的“任务关系”改为“人物关系”,同时把检索对象说明得更具体:
<span>const</span> <span>routeQuestionNode</span> = <span>async</span> (<span>state</span>) => {
<span>const</span> route = <span>await</span> router.<span>invoke</span>([
{
<span>role</span>: <span>"system"</span>,
<span>content</span>: <span>`判断问题是否需要查询《天龙八部》知识库。
simple:通用常识、简单定义或通用编程问题,无需小说资料。
complex:涉及小说具体情节、人物关系、章节事实或原文证据。
涉及小说事实时,即使你记得答案,也选择 complex。
只做分类,并给出简短理由。`</span>,
},
{ <span>role</span>: <span>"user"</span>, <span>content</span>: state.<span>question</span> },
]);
<span>console</span>.<span>log</span>(<span>`路由:<span>${route.strategy}</span>;原因:<span>${route.reason}</span>`</span>);
<span>return</span> {
<span>strategy</span>: route.<span>strategy</span>,
<span>routeReason</span>: route.<span>reason</span>,
};
};
question、k 没有变化,节点不必重复返回它们。这里也没有执行检索:路由节点只产生分类结果,下一步去哪由条件边决定。
条件边使用源码中的 decideNext:
<span>const</span> <span>decideNext</span> = (<span>state</span>) =>
state.<span>strategy</span> === <span>"simple"</span> ? <span>"direct_answer"</span> : <span>"retrieve"</span>;
它是一个普通 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 条件边文档
<span>const</span> graph = <span>new</span> <span>StateGraph</span>(<span>GraphState</span>)
.<span>addNode</span>(<span>"route_question"</span>, routeQuestionNode)
.<span>addNode</span>(<span>"direct_answer"</span>, directAnswerNode)
.<span>addNode</span>(<span>"retrieve"</span>, retrieveNode)
.<span>addNode</span>(<span>"generate"</span>, generateNode)
.<span>addEdge</span>(<span>START</span>, <span>"route_question"</span>)
.<span>addConditionalEdges</span>(<span>"route_question"</span>, decideNext, {
<span>direct_answer</span>: <span>"direct_answer"</span>,
<span>retrieve</span>: <span>"retrieve"</span>,
})
.<span>addEdge</span>(<span>"retrieve"</span>, <span>"generate"</span>)
.<span>addEdge</span>(<span>"generate"</span>, <span>END</span>)
.<span>addEdge</span>(<span>"direct_answer"</span>, <span>END</span>)
.<span>compile</span>();
这段构图代码放在所有节点定义之后。addEdge() 表达固定连接;addConditionalEdges() 表达根据状态选择连接。本例已经用条件边连接了路由节点,不需要再给它添加一条无条件指向检索的边,否则会额外安排检索。
读到这里,可以记住这组分工:模型给出策略,路由节点保存策略,条件函数返回分支标识,LangGraph 按连接关系执行目标节点。
另一条新增路径是 direct_answer。它读取原始问题,不使用小说片段,直接生成回答。
源码中的这个节点尚未完成:它取得了 model.stream() 返回的流,却没有遍历流,也没有返回状态更新。因此,不能据此认为简单问题已经能完整输出并保存答案。可以将该函数替换为:
<span>const</span> <span>directAnswerNode</span> = <span>async</span> (<span>state</span>) => {
<span>console</span>.<span>log</span>(<span>"---DIRECT-ANSWER---"</span>);
<span>let</span> generation = <span>""</span>;
<span>const</span> stream = <span>await</span> model.<span>stream</span>([
{ <span>role</span>: <span>"system"</span>, <span>content</span>: <span>"请用中文直接回答问题。"</span> },
{ <span>role</span>: <span>"user"</span>, <span>content</span>: state.<span>question</span> },
]);
<span>for</span> <span>await</span> (<span>const</span> chunk <span>of</span> stream) {
<span>const</span> text = <span>typeof</span> chunk.<span>content</span> === <span>"string"</span> ? chunk.<span>content</span> : <span>""</span>;
generation += text;
process.<span>stdout</span>.<span>write</span>(text);
}
process.<span>stdout</span>.<span>write</span>(<span>"\n"</span>);
<span>return</span> { 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 字段取得答案。
按源码的初始化方式调用图:
<span>const</span> result = <span>await</span> graph.<span>invoke</span>({
<span>question</span>: <span>"阿朱是怎么死的?"</span>,
<span>k</span>: <span>5</span>,
<span>strategy</span>: <span>""</span>,
<span>routeReason</span>: <span>""</span>,
<span>documents</span>: [],
<span>generation</span>: <span>""</span>,
});
<span>console</span>.<span>log</span>({
<span>strategy</span>: result.<span>strategy</span>,
<span>reason</span>: result.<span>routeReason</span>,
<span>documentCount</span>: result.<span>documents</span>.<span>length</span>,
<span>answer</span>: result.<span>generation</span>,
});
原脚本在 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、检索和基于资料生成 |
本次增加了一次由模型参与的路径选择,所有可选节点和连接仍由开发者预先定义。小说分支依旧只检索一次、生成一次。如果一个问题需要先查到某个人物,再根据这个人物继续查另一段资料,这张图还没有表达这样的连续检索过程。后续文章可以从这个具体缺口继续演进。
适合已搭好 LangGraph RAG 的开发者参考:用一次模型分类换取按需检索,能减少简单问题的检索开销,但需权衡成本与失败处理,并补齐直接回答节点。