一、补丁一:给 RAG 装一个分诊台
医院的分诊逻辑
去医院看病,进门先遇到分诊台——不是所有病人都直接推进手术室。护士问两句:
- "挂个号开个药" → 普通门诊
- "胸口疼、呼吸困难" → 急诊
分诊的价值在于把资源用在对的地方。对所有病人一律上全套检查,是浪费,也是灾难。
朴素 RAG 就是那种"全部上全套"的医院——你问"1+1 等于几",它也要去《天龙八部》里搜五段原文出来。readme 的判断:
"所有问题都走 RAG 检索?简单问题不需要检索,浪费资源(token 和 检索,增强流程)。1+1=?两个分支,一个简单的问题,一个复杂的问题。llm 来判断简单?"
"llm 来判断" —— 这四个字就是解法。
路由状态:先加一个"路线"字段
rag-query-router.mjs 比上篇的朴素版本多了一个关键字段:
<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>// ← 新增:策略</span>
<span>routeReason</span>: <span>Annotation</span>, <span>// ← 新增:判断理由</span>
<span>documents</span>: <span>Annotation</span>,
<span>generation</span>: <span>Annotation</span>,
})
strategy 存的是"走哪条路",routeReason 存的是"为什么这么走"——后者不只是为了好看,它是可观测性的基础。 出了问题你能看到模型当时是怎么想的。
路由节点:用结构化输出做判断
<span>const</span> <span>RouteSchema</span> = z.<span>object</span>({
<span>// 枚举</span>
<span>strategy</span>: z.<span>enum</span>([<span>"simple"</span>, <span>"complex"</span>]),
<span>reason</span>: z.<span>string</span>(),
});
这里用了结构化输出 + Zod 枚举——前面专门学过的知识,现在派上大用场了。
z.enum(["simple", "complex"]) 意味着模型只能在这两个值里选一个。这就避免了模型回答"可能是简单的吧"这种没法用代码判断的模糊输出。
<span>const</span> <span>routeQuestionNode</span> = <span>async</span> (<span>state</span>) => {
<span>console</span>.<span>log</span>(<span>'___ROUTE-QUESTION___'</span>);
<span>// 结构化输出</span>
<span>const</span> router = model.<span>withStructuredOutput</span>(<span>RouteSchema</span>);
<span>const</span> route = <span>await</span> router.<span>invoke</span>(<span>`
你是问答路由器,请判断用户问题是否需要外部检索。
规则:
- simple 常识问答、简短定义、无需特定小说细节即可回答。
- complex 需要《天龙八部》具体情节、人物关系、章节事实、原文细节或证据支持。
用户问题: <span>${state.question}</span>
`</span>)
<span>console</span>.<span>log</span>(<span>`路由策略:<span>${route.strategy}</span> <span>${route.reason}</span>`</span>)
<span>return</span> {
<span>question</span>:state.<span>question</span>,
<span>k</span>:state.<span>k</span>,
<span>strategy</span>: route.<span>strategy</span>,
<span>routeReason</span>: route.<span>reason</span>,
}
}
这就是"分诊护士"的全部工作。 几句 prompt 说清判断标准,剩下的交给模型。
prompt 里的两句规则设计得很精准:
| 策略 | 判断标准 |
|---|---|
| `simple` | 常识问答、简短定义、**无需特定小说细节** |
| `complex` | 需要《天龙八部》**具体情节、人物关系、章节事实、原文细节或证据** |
关键词是"无需特定小说细节"和"需要原文细节或证据"——把判断标准落在"是否需要查资料"这个本质问题上,而不是落在"问题长短"这种表面特征上。
这才是好的路由 prompt:给判断依据,不给判断捷径。
两条分支:直答 vs 检索
<span>const</span> <span>directAnswerNode</span> = <span>async</span> (<span>state</span>) => {
<span>console</span>.<span>log</span>(<span>'___DIRECT-ANSWER___'</span>);
process.<span>stdout</span>.<span>write</span>(<span>"\n [AI 回答(流式)] \n"</span>)
<span>let</span> generation = <span>""</span>;
<span>const</span> stream = <span>await</span> model.<span>stream</span>(<span>`你是一个中文回答助手,
请简洁回答问题。
问题:<span>${state.question}</span>
`</span>)
<span>// ... 流式输出 ...</span>
<span>return</span> {
<span>question</span>:state.<span>question</span>,
<span>k</span>:state.<span>k</span>,
<span>strategy</span>: state.<span>strategy</span>,
<span>routeReason</span>: state.<span>routeReason</span>,
<span>documents</span>: [], <span>// ← 注意:没有文档</span>
<span>generation</span>: generation,
}
}
注意 documents: [] ——直答节点不产生任何文档。这个细节很重要:它明确表达了"这条路没检索",而不是含糊地留空。
对比一下两条路的 prompt:
| 节点 | prompt 特点 |
|---|---|
| `directAnswerNode` | "你是一个中文回答助手,请**简洁**回答问题" —— 轻量、直接 |
| `generateNode`(RAG 那条路) | 五条防幻觉要求 + 拼接的 context —— 重装备、严谨 |
两条路的 prompt 完全不同——这也是分诊的意义:不是省了一次检索,而是给不同复杂度的问题配不同规格的处理流程。
编排:一个条件边搞定
<span>const</span> <span>decideNext</span> = (<span>state</span>) => {
<span>return</span> state.<span>strategy</span> === <span>"simple"</span> ? <span>"direct_answer"</span> : <span>"retrieve"</span>;
}
<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>"rag_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>"rag_generate"</span>)
.<span>addEdge</span>(<span>"direct_answer"</span>, <span>END</span>)
.<span>addEdge</span>(<span>"rag_generate"</span>, <span>END</span>)
.<span>compile</span>()
流程图长这样:
┌→ direct_answer → <span>END</span> (简单问题,直接答)
<span>START</span> → route_question
└→ retrieve → rag_generate → <span>END</span> (复杂问题,检索再答)
上篇那条"一条道走到黑"的直线,现在有了岔路口。
条件边 addConditionalEdges 前面学过——decideNext 返回 "direct_answer" 还是 "retrieve",决定了走哪条。
测试它:一个"不该检索"的问题
<span>const</span> question = <span>'js写一个add函数'</span>;
<span>const</span> k = <span>5</span>;
看到这个测试问题,我笑了——这是个用来问《天龙八部》助手的测试用例。
但它设计得太聪明了:js写一个add函数 跟《天龙八部》毫无关系,也完全不需要查小说。
- 朴素 RAG:硬去书里搜 5 段最相似的文字(大概是"武功招式"之类的)→ 然后基于这些莫名其妙的内容回答 → 大概率胡说
- 现在的路由版:判断为
simple→ 走direct_answer→ 不检索,直接正常回答
这个测试用例的精妙之处在于:它验证的不是"能不能答对",而是"能不能不检索"。
一条负向测试——好的测试用例常常是反着设计的。
二、补丁二:给 RAG 装上拆题能力
分诊解决了"要不要检索",接下来解决"检索什么"。
多跳问题长什么样
readme 给的那道题,我上篇引用过,这里再挖深一点:
"天龙八部中 四大恶人 排行第二的是谁?此人之子在身世揭晓前,其生父在武林中的公开身份是什么?"
这道题有两个问句,但实际推理链条更长:
问句 <span>1</span>:四大恶人排行第二的是谁?
↓ 答案:叶二娘(<span>"无恶不作"</span>)
问句 <span>2</span> 的第一环:叶二娘的儿子是谁?
↓ 答案:虚竹
问句 <span>2</span> 的第二环:虚竹的生父是谁?
↓ 答案:玄慈
问句 <span>2</span> 的第三环:玄慈在武林中的公开身份是什么?
↓ 答案:少林寺方丈
要回答它,得跳四步。 而每一步的答案,分散在书中不同的章节里。
文件开头的注释也点出了另一个典型例子:
<span>// 段誉遇到的第一个神仙姐姐画像,是谁的弟子?</span>
<span>// 直接把query 向量化匹配不够准确</span>
<span>// 支持子问题拆分的工作节点</span>
"直接把 query 向量化匹配不够准确" —— 这句话就是病因诊断。
为什么不准?因为整句话的向量是"混合语义":
<span>"四大恶人排行第二的是谁?此人之子在身世揭晓前,其生父在武林中的公开身份是什么?"</span>
↓ embedding
一个混合了【四大恶人】【儿子】【生父】【公开身份】的向量
↓ 去数据库搜
找到的片段:可能在讲<span>"四大恶人"</span>,也可能在讲<span>"生父"</span>
但很难有一段话同时覆盖这条链
一个向量,装不下一条推理链。 所以解法是——先拆,再分头检索。
拆解节点:让 LLM 当"出题老师"
<span>const</span> <span>DecomposeSchema</span> = z.<span>object</span>({
<span>sub_question</span>: z.<span>array</span>(z.<span>string</span>()).<span>min</span>(<span>1</span>).<span>max</span>(<span>8</span>),
<span>reason</span>: z.<span>string</span>(),
})
同样用结构化输出约束——sub_question 必须是 1 到 8 条的字符串数组。.min(1).max(8) 是 Zod 的数组长度约束。
<span>const</span> <span>decomposeQuestionNode</span> = <span>async</span> (<span>state</span>) => {
<span>console</span>.<span>log</span>(<span>'___DECOMPOSE-QUESTION___'</span>);
<span>// 结构化输出</span>
<span>const</span> decomposer = model.<span>withStructuredOutput</span>(<span>DecomposeSchema</span>);
<span>const</span> out = <span>await</span> decomposer.<span>invoke</span>(<span>`
你是《天龙八部》多跳问答的【子问题拆解器】。
用户原始问题:
<span>${state.question}</span>
任务:将问题拆成**有序**子问题列表 sub_questions,用于**依次向量检索**。要求:
1. 链式推理、多层关系、因果先后的问题,必须拆成多条;单跳即可答也可输出一条。
2. 每条子问题必须是**可独立检索**的完整中文问句,**禁止**使用「他/她/此人/上文」等指代;可写全人物名与事件名。
3. 顺序必须符合推理链:先搞清前置实体/事实,再查后续结论。
4. **不要**把整句原题原样复制成唯一一条(除非确实无法拆分);不要拆成过碎的关键词列表。
5. 输出 1~8 条即可。
请输出 sub_questions 与简短 reason。
`</span>)
这五条要求,每一条都在防一个具体的坑。 值得逐条分析:
| 要求 | 防的坑 |
|---|---|
| 1. 链式推理必须拆成多条 | 防止模型偷懒,整句照搬 |
| 2. **禁止指代**,要写全人物名 | **最精彩的一条** |
| 3. 顺序符合推理链 | 防止检索顺序颠倒导致后续查不到 |
| 4. 不要原样复制 / 不要拆太碎 | 两头防:防不拆,也防拆烂 |
| 5. 数量限定 1-8 条 | 防爆炸 |
重点看第 2 条——为什么禁止指代?
假设模型拆出这样的子问题:
❌ 他的儿子是谁? ← <span>"他"</span>指谁?检索时不知道
❌ 此人的公开身份是什么? ← <span>"此人"</span>又是谁?
因为检索是"独立"的——第二个子问题去数据库搜的时候,它不知道第一个子问题查到了"叶二娘"。指代词在脱离上下文后完全失效,向量化出来就是一团模糊语义。
正确的拆法是:
✅ 叶二娘的儿子是谁?
✅ 虚竹的生父是谁?
✅ 玄慈在武林中的公开身份是什么?
注意第三句——它用了"玄慈"这个只有走完前两步才知道的名字。 这说明模型在拆解时已经推演过整条链,把中间答案都补全了。
这就是 prompt 第 3 条"顺序必须符合推理链:先搞清前置实体/事实,再查后续结论"的深意——它要求模型先自己走一遍推理,再倒着把每一步写成可检索的问句。
一个"拆解器",其实干了两件事:规划 + 预演。
拿到子问题后的处理
<span>// 去除空格,排除不需要的question</span>
<span>const</span> subQuestions = out.<span>sub_question</span>.<span>map</span>(<span>(<span>s</span>) =></span> s.<span>trim</span>()).<span>filter</span>(<span>Boolean</span>);
<span>if</span> (subQuestions.<span>length</span> === <span>0</span>) {
<span>throw</span> <span>new</span> <span>Error</span>(<span>"decompose_question: sub_questions 为空"</span>);
}
<span>console</span>.<span>log</span>(<span>`拆解<span>${subQuestions.length}</span>条子问题(<span>${out.reason}</span>)`</span>);
subQuestions.<span>forEach</span>(<span>(<span>q, i</span>) =></span> {
<span>console</span>.<span>log</span>(<span>`[<span>${i+<span>1</span>}</span>] <span>${q}</span>`</span>);
});
<span>return</span> {
subQuestions,
<span>nextSubIdx</span>: <span>0</span>, <span>// 从第 0 条开始</span>
<span>currentQuery</span>: subQuestions[<span>0</span>],
}
三层防御:
.trim()去空格.filter(Boolean)过滤空字符串(Boolean("")是false)- 空数组就抛异常——宁可报错,也不要带着空问题继续跑
nextSubIdx: 0 是"游标"——标记下一条要检索哪个子问题。 这是整个多跳流程的驱动器。
状态设计:12 个字段的巧思
多跳版本的状态膨胀到了 12 个字段:
<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>subQuestions</span>: <span>Annotation</span>, <span>// 拆解出的子问题列表</span>
<span>nextSubIdx</span>: <span>Annotation</span>, <span>// question[i] 跳出循环</span>
<span>currentQuery</span>: <span>Annotation</span>, <span>// 当前子问题</span>
<span>retrievalCount</span>: <span>Annotation</span>, <span>// 已检索轮次</span>
<span>maxRetrievals</span>: <span>Annotation</span>, <span>// 最大轮次上限</span>
<span>plannedNext</span>: <span>Annotation</span>, <span>// 下一步决策</span>
<span>documents</span>: <span>Annotation</span>,
<span>generation</span>: <span>Annotation</span>,
})
三个字段是循环控制三件套:
| 字段 | 作用 |
|---|---|
| `nextSubIdx` | 走到第几条了(游标) |
| `retrievalCount` | 已经查了几轮(计数) |
| `maxRetrievals` | 最多查几轮(上限) |
注释写得很明白:
<span>nextSubIdx</span>: <span>Annotation</span>, <span>// question[i] 跳出循环</span>
question[i] 这个写法暗示了它的用法——像遍历数组一样,一条条处理子问题。 而"跳出循环"说明它兼作终止条件。
maxRetrievals 初始值 8:
<span>maxRetrievals</span>: state.<span>maxRetrievals</span> ?? <span>8</span>,
?? 8 是"没设置就用 8"——给循环上了一道安全阀。
检索节点:一次查一条,累积去重
<span>const</span> <span>retrieveNode</span> = <span>async</span> (<span>state</span>) => {
<span>const</span> subs = state.<span>subQuestions</span> ?? [];
<span>const</span> idx = state.<span>nextSubIdx</span> ?? <span>0</span>;
<span>const</span> q = subs[idx]?.<span>trim</span>(); <span>// 当前这一轮问题</span>
<span>if</span> (!q) {
<span>throw</span> <span>new</span> <span>Error</span>(<span>`retrieve: 子问题下标<span>${idx}</span> 无有效文本
(共<span>${subs.length}</span>条)
`</span>);
}
<span>const</span> round = state.<span>retrievalCount</span> + <span>1</span>;
<span>console</span>.<span>log</span>(<span>`----第<span>${round}</span>轮,子问题:<span>${idx+<span>1</span>}</span>/<span>${subs.length}</span>----`</span>)
<span>console</span>.<span>log</span>(<span>`---查询:<span>${q}</span>---`</span>);
<span>const</span> newDocs = <span>await</span> <span>retrieveRelevantContent</span>(q, state.<span>k</span>);
<span>// 多轮retrieve 有可能重复,会浪费资源</span>
<span>// 重复可能让llm 我们在强调,错觉</span>
<span>const</span> merged = <span>mergeUnique</span>(state.<span>documents</span> ?? [], newDocs);
<span>// ...</span>
<span>return</span> {
<span>documents</span>: merged,
<span>retrievalCount</span>: round,
<span>nextSubIdx</span>: idx + <span>1</span>,
<span>currentQuery</span>: q
}
}
这个节点每次只处理一条子问题——取出 subs[nextSubIdx],检索,然后把游标 +1。
代码注释点出了去重的必要性:
<span>// 多轮retrieve 有可能重复,会浪费资源</span>
<span>// 重复可能让llm 我们在强调,错觉</span>
重复文档有两个害处:
- 浪费资源——同样的内容占两份 token
- 制造"错觉"——如果同一段文字出现三次,模型可能以为"这个信息被多次强调了,应该很重要",从而高估它的权重
第二条很微妙——冗余会被模型误读为强调。 这是很多人想不到的坑。
去重函数:一个经典的取舍
<span>const</span> <span>mergeUnique</span> = (<span>existingDocs, newDocs</span>) => {
<span>// map key 可以是对象</span>
<span>// has get</span>
<span>const</span> map = <span>new</span> <span>Map</span>();<span>// es6新增的HashMap 数据结构 key:value</span>
<span>for</span> (<span>const</span> d <span>of</span> [...existingDocs, ...newDocs]) {
<span>const</span> key = <span>String</span>(d.<span>id</span>);
<span>const</span> prev = map.<span>get</span>(key);
<span>if</span>(!prev || <span>Number</span>(d.<span>score</span>) > <span>Number</span>(prev.<span>score</span>)){
map.<span>set</span>(key, d);
}
}
<span>return</span> <span>Array</span>.<span>from</span>(map.<span>values</span>()).<span>sort</span>(<span>(<span>a, b</span>) =></span> <span>Number</span>(b.<span>score</span>) - <span>Number</span>(a.<span>score</span>));
}
十几行代码,完成三件事:
| 步骤 | 做法 |
|---|---|
| 去重 | 用 `Map` 按 `id` 做 key,同一个 id 只保留一条 |
| 择优 | **保留分数更高的那条** |
| 排序 | 按分数降序排列 |
"保留高分"这个决策值得细想。
同一段文字可能被多个子问题命中——比如"叶二娘"那段,既被"叶二娘的儿子是谁"命中,也被"虚竹的生父"命中。两次的相似度分数可能不同:
第一次命中:<span>score</span> = <span>0.72</span>
第二次命中:<span>score</span> = <span>0.85</span> ← 这次更相关
保留哪个?这段代码选择留高分(Number(d.score) > Number(prev.score))。
为什么合理?因为分数代表"这段话对这个具体问题的相关度"——0.85 那次说明这个子问题跟它更贴合,用高分数更真实地反映它的价值。
Number(d.score) 的强制转换是必要的——分数有时是字符串(尤其经过 JSON 序列化后),不转成数字比较会出错。
注释还提了一句 ES6 知识:
<span>// es6新增的HashMap 数据结构 key:value</span>
<span>// map key 可以是对象</span>
<span>// has get</span>
Map 和普通对象的区别——对象(Object)的 key 只能是字符串/符号,Map 的 key 可以是任何类型(包括对象)。这里虽然用的是字符串 key,但注释把这个特性点出来了。
Array.from(map.values()) 把 Map 的迭代器转成数组——因为 Map 不能直接 .sort()。
决策节点:该继续查,还是该回答了?
检索完一条,接下来要判断:够了吗?还要不要继续?
<span>const</span> <span>NextStepSchema</span> = z.<span>object</span>({
<span>nextAction</span>: z.<span>string</span>([<span>"retrieve"</span> , <span>"generate"</span>]),
<span>reason</span>: z.<span>string</span>(),
})
又一次结构化输出——nextAction 只能是 "retrieve" 或 "generate"。
<span>const</span> <span>planNextStepNode</span> = <span>async</span> (<span>state</span>) => {
<span>console</span>.<span>log</span>(<span>'___PLAN-NEXT-STEP___'</span>);
<span>const</span> subs = state.<span>subQuestions</span> ?? [];
<span>const</span> nextIdx = state.<span>nextSubIdx</span> ?? <span>0</span>;
<span>const</span> remaining = subs.<span>length</span> - nextIdx;
<span>const</span> subList = subs.<span>map</span>(<span>(<span>s, i</span>) =></span> <span>`[<span>${i+<span>1</span>}</span>] <span>${s}</span>
<span>${i < nextIdx ? <span>"已检索"</span> : i === nextIdx ? <span>"(下一轮将检索,若选择继续)"</span> : <span>"未检索"</span>}</span>`</span>).<span>join</span>(<span>"\n"</span>);
这段代码在给模型画一张进度表:
[<span>1</span>] 四大恶人排行第二的是谁? 已检索
[<span>2</span>] 叶二娘的儿子是谁? (下一轮将检索,若选择继续)
[<span>3</span>] 虚竹的生父是谁? 未检索
[<span>4</span>] 玄慈在武林中的公开身份是什么? 未检索
让模型清楚知道"走到哪了、还剩什么"——这是让它做出正确判断的前提。
然后把已召回的文档也喂给它:
<span>const</span> docStr = state.<span>documents</span>.<span>length</span> === <span>0</span>
? <span>"(尚无检索结果)"</span>
: state.<span>documents</span>
.<span>slice</span>(<span>0</span>, <span>6</span>) <span>// ← 只取前 6 条</span>
.<span>map</span>(<span>(<span>d, i</span>) =></span> <span>`
[<span>${i+<span>1</span>}</span>] score=<span>${<span>Number</span>(d.score).toFixed(<span>4</span>)}</span> 第<span>${d.chapter_num}</span>章
<span>${d.content.slice(<span>0</span>, <span>200</span>)}</span> // ← 每条只取前 200 字
`</span>).<span>join</span>(<span>"\n\n"</span>);
两个截断:最多 6 条、每条 200 字。
这是上下文管理的经典手法——判断"够不够"不需要看全文,看摘要和分数就够了。全塞进去,反而浪费 token 还干扰判断。
prompt 部分写得很有意思,有一句特别关键:
<span>const</span> prompt = <span>`你是多跳 RAG 规则器。检索查询已由前置步骤拆解为**有序子问题**,
若需要继续检索,下一轮将自动使用[下一条子问题] 做向量检索,你**不要**自拟新的检索句
...
</span>
"你不要自拟新的检索句" —— 这条约束很聪明。
因为模型看到检索结果后,可能冒出"我觉得应该查查 XX"的想法,自己编一个新的搜索词。但这个流程的设计是"严格按拆解好的顺序走",让它自由发挥就破坏了规划的严谨性。
这叫"约束模型的自由度"——在需要严格流程的地方,明确告诉它"别乱来"。
然后是判断规则:
请判断下一步:
<span>1.</span> 已有足够依据回答用户原始问题 -> nextAction=generate
<span>2.</span> 仍缺关键事实、且仍存在未检索的子问题、且未超过轮数上限 -> nextAction=retrieve
硬性规则:
- 若剩余未检索子问题条数为<span>0</span>,必须 nextAction=generate
- 若已检索轮数已到达或超过最大检索轮数,必须 nextAction=generate。
注意"硬性规则"这四个字——prompt 里说了,但代码里还要再兜一遍:
<span>let</span> finalNext = nextAction;
<span>if</span>(state.<span>retrievalCount</span> >= state.<span>maxRetrievals</span>) finalNext = <span>"generate"</span>;
<span>if</span>(remaining <= <span>0</span>) finalNext = <span>"generate"</span>;
<span>console</span>.<span>log</span>(<span>`[决策] plannedNext=<span>${finalNext}</span>
(模型建议=<span>${nextAction}</span>)(<span>${reason}</span>)`</span>)
<span>return</span> {
<span>plannedNext</span>: finalNext,
}
这是"不信任模型"的正确姿势。
模型可能会忽略 prompt 里的"硬性规则"(LLM 对负向约束的遵守率并不完美)。所以代码层面再做一次强制校验——无论模型说什么,超轮数或没剩余子问题,一律转 generate。
日志还刻意区分了两个值:
<span>[决策]</span> <span>plannedNext</span>=generate (模型建议=retrieve)(...)
↑ 最终生效的 ↑ 模型原本想要的
把"模型建议"和"最终决策"都打出来——如果两者不一致,就能知道"是模型判断错了,还是被安全阀拦下来了"。这是非常好的调试设计。
完整的图:一个带反馈回路的闭环
<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>"decompose_question"</span>, decomposeQuestionNode)
.<span>addNode</span>(<span>"retrieve"</span>, retrieveNode)
.<span>addNode</span>(<span>"plan_next_step"</span>, planNextStepNode)
.<span>addNode</span>(<span>"generate"</span>, generateNode)
.<span>addEdge</span>(<span>START</span>, <span>"route_question"</span>)
.<span>addConditionalEdges</span>(<span>"route_question"</span>,afterRoute, {
<span>direct_answer</span>: <span>"direct_answer"</span>,
<span>decompose_question</span>: <span>"decompose_question"</span>,
})
.<span>addEdge</span>(<span>"decompose_question"</span>, <span>"retrieve"</span>)
.<span>addEdge</span>(<span>"retrieve"</span>, <span>"plan_next_step"</span>)
.<span>addConditionalEdges</span>(<span>"plan_next_step"</span>,afterPlan, {
<span>retrieve</span>: <span>"retrieve"</span>, <span>// ← 回到 retrieve!</span>
<span>generate</span>: <span>"generate"</span>,
})
.<span>addEdge</span>(<span>"direct_answer"</span>, <span>END</span>)
.<span>addEdge</span>(<span>"generate"</span>, <span>END</span>)
.<span>compile</span>()
看流程图:
┌→ direct_answer → <span>END</span>
<span>START</span> → route_question
└→ decompose_question → retrieve → plan_next_step
│
┌───────────────┤
↓ ↓
retrieve ────→ generate → <span>END</span>
(循环回来)
retrieve → plan_next_step → retrieve 形成了一个环。 这就是 LangGraph 前面学的"循环"能力——条件边指回上游节点。
而跳出循环的条件有三个:
- 模型判断"够了"(
nextAction=generate) - 子问题查完了(
remaining <= 0) - 达到轮数上限(
retrievalCount >= maxRetrievals)
一个受控的循环:能重复,但不会失控。
拆解效果
代码里的日志会打印拆解结果:
<span>console</span>.<span>log</span>(<span>`拆解<span>${subQuestions.length}</span>条子问题(<span>${out.reason}</span>)`</span>);
subQuestions.<span>forEach</span>(<span>(<span>q, i</span>) =></span> {
<span>console</span>.<span>log</span>(<span>`[<span>${i+<span>1</span>}</span>] <span>${q}</span>`</span>);
});
如果模型拆得对,你会看到类似这样的输出:
拆解<span>4</span>条子问题(需要多跳推理才能确定生父身份)
<span>[1]</span> 《天龙八部》中四大恶人排行第二的是谁?
<span>[2]</span> 叶二娘的儿子是谁?
<span>[3]</span> 虚竹的生父是谁?
<span>[4]</span> 玄慈在武林中的公开身份是什么?
从一句"绕晕人"的长问句,变成四句"每句都能独立检索"的短问句——这就是拆题术的价值。
测试问题:两个版本
<span>async</span> <span>function</span> <span>main</span> () {
<span>// const question = 'js写一个add函数';</span>
<span>const</span> question = <span>`《天龙八部》中【四大恶人】排行第二的是谁?
此人之子在身世揭晓前,其生父在武林中的公开身份是什么?`</span>;
注意被注释掉的那行——js写一个add函数。
这是上篇路由 demo 的测试问题,在这个多跳版本里被注释掉了。 为什么?因为这个问题会走 direct_answer 分支,压根到不了拆解节点——在这个 demo 里它测不出东西来。
留下这行注释的价值在于:它记录了两个 demo 之间的关联——同一个测试用例,在不同的架构版本里,会走出完全不同的路径。
三、两个补丁,治好了哪两个硬伤
回头对照上篇的五个硬伤表:
| \# | 硬伤 | 这篇的补丁 | 状态 |
|---|---|---|---|
| 1 | 所有问题都检索 | **路由分流**(simple/complex 二选一) | ✅ 已修 |
| 2 | 没有评估机制 | 决策节点部分涉及(判断"够了没") | 🟡 部分 |
| 3 | 处理不了多跳问题 | **问题拆解**(子问题 + 顺序检索 + 去重) | ✅ 已修 |
| 4 | 语义匹配不准 | 关键词检索 | ⬜ 待办 |
| 5 | 不会联网兜底 | 网络搜索 | ⬜ 待办 |
两个补丁,两个新节点结构,两次结构化输出的实战应用。
值得留意的是——这两个补丁的技术手段其实是同一个:让 LLM 做判断。
- 路由分诊:判断"简单还是复杂"
- 下一步决策:判断"继续还是停止"
而多跳拆解是"让 LLM 做规划"——判断是"选择题",规划是"简答题"。 前面的结构化输出(z.enum)天然适合做选择题,后面的子问题拆解(z.array(z.string()).min(1).max(8))用来做简答题。
这就是上篇说的"在这个骨架上插节点"——骨架上插的每个节点,本质都是一次"让模型做一次结构化判断"。
四、从"流水线"到"有回路"
最后看一下这两个 demo 的架构变化。上篇的朴素 RAG:
<span>START</span> → retrieve → generate → <span>END</span>
直线,走完就完。
这篇的两个版本:
路由版: <span>START</span> → route → (direct <span>|</span> retrieve→generate) → <span>END</span>
↑ 岔路口
多跳版: <span>START</span> → route → decompose → retrieve → plan ─┐
↑ │
└────────┘
(回路)
岔路口 + 回路。
这正是前面 LangGraph 那节课讲的核心——从"线性流水线"升级到"网状图"。 而这张网的每个分叉点、每个回路点,决策者都是 LLM 自己。
readme 的总结很到位:
"死板的检索生成流程,升级为可思考、可判断、可纠错的智能 RAG 架构。"
- 可思考 → 拆解问题(规划)
- 可判断 → 分诊、决定继续还是停止
- 可纠错 → 还没做完(下一篇的评估节点)
PS:这两篇看下来,最大的感受是——RAG 的升级不是在"检索算法"上做文章,而是把决策权交给模型。以前是"所有问题都走同一条路",现在是"让模型看看这是什么问题、走到哪了、够不够了"。下一篇我们补上最关键的一块:让 RAG 知道自己"不知道",并且学会出门求助。
价值在于把 RAG 从“无脑检索”升级为“先判断再规划”,用结构化输出约束 LLM,工程可落地。适合正在构建 RAG 问答、希望优化成本与多跳推理的开发者参考。