🚓 分诊台与拆题术:让 RAG 学会判断和规划

文章来源声明: 原文作者:默_笙; 来源站点:掘金; 原文链接:https://juejin.cn/post/7689644399769731113; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

价值在于把 RAG 从“无脑检索”升级为“先判断再规划”,用结构化输出约束 LLM,工程可落地。适合正在构建 RAG 问答、希望优化成本与多跳推理的开发者参考。

> 写在前面:上篇我们搭了一个能跑的朴素 RAG,也认领了它的五个硬伤。这篇开始打补丁——针对其中两个最"伤智商"的问题:**所有问题都无脑检索**(1+1 也要翻书)和**多跳问题搜不准**(答案不在任何一段原文里,要跳好几次)。readme 给的药方很明确——前者靠"llm 来判断简单",后者靠"llm 规划能力,拆分,分步骤"。两个 demo 文件,一个教 RAG 分诊,一个教 RAG 拆题。以下所有代码均来自课堂真实文件。

一、补丁一:给 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>],
}

三层防御:

  1. .trim() 去空格
  2. .filter(Boolean) 过滤空字符串(Boolean("") 是 false)
  3. 空数组就抛异常——宁可报错,也不要带着空问题继续跑

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>

重复文档有两个害处:

  1. 浪费资源——同样的内容占两份 token
  2. 制造"错觉"——如果同一段文字出现三次,模型可能以为"这个信息被多次强调了,应该很重要",从而高估它的权重

第二条很微妙——冗余会被模型误读为强调。 这是很多人想不到的坑。

去重函数:一个经典的取舍

<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 前面学的"循环"能力——条件边指回上游节点。

而跳出循环的条件有三个:

  1. 模型判断"够了"(nextAction=generate)
  2. 子问题查完了(remaining <= 0)
  3. 达到轮数上限(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 知道自己"不知道",并且学会出门求助。