一句话价值:前四篇讲的是"控制权交给 LLM"的道理,这一篇用一段能跑的真实代码,把"易主"演给你看------同一个多跳问题,朴素检索查不准;我们把
retrieve → generate的固定线,改造成「拆解 → 多轮检索 → LLM 规划 → 生成」的循环图,还踩了一个会让循环永远不转的状态字段 bug。
先看一个现实的坑
拿《天龙八部》当知识库,问一个多跳问题:
《天龙八部》中【四大恶人】排行第二的是谁?此人之子在身世揭晓前,其生父在武林中的公开身份是什么?
要答对,得连跳三下:四大恶人第二 → 叶二娘 → 叶二娘的儿子虚竹 → 虚竹生父(少林方丈玄慈)。
朴素 RAG 会怎么做?拿这一整句去向量化、匹配。结果大概率查不准------这句里混着"四大恶人""排行""儿子""生父""公开身份"一堆语义,向量被稀释,检索器"认错人"。
这正是 rag-multihop.mjs 文件开头那句注释点破的:
js
// 段誉遇到的第一个神仙姐姐画像, 是谁的弟子?
// 直接把 query 向量化匹配不够准确
// 支持子问题拆分的工作节点
坑的本质:多跳问题不能当一句话查,得拆成子问题、按推理顺序逐个查。 下面就把这件事落成 LangGraph 代码。
先把"共享内存"设计出来:GraphState
LangGraph 里所有节点读写的,是一份随流程流转的共享状态。多跳检索要记的东西,比朴素 RAG 多得多:
js
const GraphState = Annotation.Root({
question: Annotation, // 原始问题
k: Annotation, // 每个子问题检索多少条
strategy: Annotation, // 路由策略 simple / complex
routeReason: Annotation, // 路由判断的理由
subQuestions: Annotation, // 拆出的有序子问题
nextSubIdx: Annotation, // 下一个待检索子问题的下标(游标)
currentQuery: Annotation, // 当前正在查的子问题
retrievalCount: Annotation,// 已检索轮数
maxRetrievals: Annotation, // 最大检索轮数上限(默认 8)
plannedNext: Annotation, // LLM 规划的下一步
documents: Annotation, // 累积的检索结果
generation: Annotation // 最终回答
})
一眼看去,一半字段都在为**"循环"**服务:nextSubIdx 是循环的游标、retrievalCount/maxRetrievals 是循环的计数器与上限、plannedNext 是 LLM 给出的下一跳。这就是"状态机"和"流水线"的区别------流水线只关心"料传到哪一步了",状态机还要记住"我走到第几轮、该不该继续"。
六个节点,各司其职
| 节点 | 职责 | 关键产出 |
|---|---|---|
route_question |
LLM 判断问题简单还是复杂 | strategy、初始化状态 |
direct_answer |
simple 直接答,不检索 | generation |
decompose_question |
拆成 1~8 条有序子问题 | subQuestions |
retrieve |
取 subQuestions[nextSubIdx] 检索并去重合并 |
documents、nextSubIdx+1 |
plan_next_step |
LLM 判断"依据够不够、要不要继续查" | plannedNext |
generate |
基于 documents 流式生成最终答案 |
generation |
注意 route_question 和 plan_next_step 是两个 LLM 决策点 ,而且它们用的是 withStructuredOutput + zod 把输出锁死在枚举里,不给模型乱发挥的空间:
js
const RouteSchema = z.object({
strategy: z.enum(["simple", "complex"]),
reason: z.string()
})
整张图连起来是这样的:
核心:plan_next_step 是"控制权易主"的最小现场
朴素 RAG 的线是写死的 retrieve → generate,检索一次就完。而这个 demo 在两者中间插了一个 LLM 决策节点:
js
.addEdge("retrieve", "plan_next_step")
.addConditionalEdges("plan_next_step", afterPlan, {
retrieve: "retrieve", // 依据不够 → 回 retrieve 再查一条
generate: "generate" // 依据够了 → 去生成
})
plan_next_step 的 prompt 里,把已召回文档摘要 + 子问题进度 + 剩余条数 + 轮数上限 全喂给 LLM,让它自己判断"够了没有"。但光靠 LLM 不够,还叠了两条硬性兜底,防止它抽风:
js
let finalNext = nextAction; // 模型建议
if (state.retrievalCount >= state.maxRetrievals) finalNext = "generate"; // 兜底1:到上限强制停
if (remaining <= 0) finalNext = "generate"; // 兜底2:子问题查完强制停
这就是 04 篇说的控制层 :LLM 拥有"何时停"的决策权,但架构给它划了硬边界(maxRetrievals + 剩余条数),聪明用错了地方也不会死循环。"何时停"从代码写死,变成了 LLM 运行期判断------控制权易主的第一块拼图,就落在这一个节点上。
踩坑实录:一个让循环永远不转的 bug
规划节点写好后,发现循环一次都不走,检索完直接生成。排查半天,问题出在条件边的路由函数:
js
// ❌ 读错了字段
function afterPlan(state) {
return state.strategy === "retrieve" ? "retrieve" : "generate"
}
strategy 的值只可能是 simple / complex(由 route_question 写入),永远不等于 "retrieve" 。所以这个函数永远返回 generate,LLM 辛辛苦苦算出来的 plannedNext 根本没人读,循环形同虚设。
正确做法是读上一个节点实际写入的那个字段:
js
// ✅ 读 plannedNext(plan_next_step 节点 return 的就是它)
function afterPlan(state) {
return state.plannedNext === "retrieve" ? "retrieve" : "generate"
}
这个 bug 的教训很值得记:条件边的路由函数,读的是"边"上游节点返回值的字段,不是图里随便一个同名状态。 一个字段名写错,不报错、不崩,只是让整条循环静默失效------这正是 04 篇说的"控制层最容易漏"的地方。
工程细节:mergeUnique 去重
多轮检索还有一个隐藏成本:不同子问题很容易命中同一段原文。查"四大恶人"和查"叶二娘",都可能召回同一段介绍四大恶人的章节。重复片段既费 token,又给 LLM"这段很重要"的错觉(因为它在 prompt 里出现了两次)。
所以 retrieve 每轮都把新结果和旧结果做一次合并去重:
js
const mergeUnique = (existingDocs, newDocs) => {
const map = new Map() // key = 文档 id
for (const d of [...existingDocs, ...newDocs]) {
const key = String(d.id)
const prev = map.get(key)
if (!prev || Number(d.score) > Number(prev.score)) {
map.set(key, d) // 首次遇到,或分数更高 → 覆盖
}
}
return Array.from(map.values()).sort((a, b) => Number(b.score) - Number(a.score))
}
同一个片段被不同 query 命中时,score(余弦相似度)不同,保留分数更高的那次,最后按分数降序,让最相关的片段排在 prompt 最前面。
但这是一份"收窄版"的 Agentic
做完这个 demo,一个自然的问题是:这算 Agentic RAG 了吗? 算,但只算"半成品"。
对照 03 篇的"控制权易主",这个 demo 的完成度是:
| 控制权 | 已经交给 LLM 了吗 | 证据 |
|---|---|---|
| 何时停(检索几次) | ✅ 交了 | plan_next_step + 两条兜底 |
| 走哪条路(简单/复杂) | ✅ 交了 | route_question 的 strategy |
| 怎么查(用什么 query) | ❌ 还在代码手里 | prompt 明确写"不要自拟新的检索句" |
| 查得不好怎么办 | ❌ 没有 | 没有 self-reflection,不会改写 query 重查 |
| 用什么工具 | ❌ 没有 | 检索器是固定节点,不是 LLM 可调用的 tool |
一句话定位:"何时停"的控制权已经交给 LLM 了,但"怎么查、查什么、查得不好怎么办"还焊死在代码里。 决策被锁在 retrieve/generate 的固定枚举里,检索动作是固定的(永远用下一条子问题),没有 query 改写、没有检索质量自评、没有真正的 function calling 循环。
离完整 Agentic 还差的三步,也是下一步的演进路线:
- query rewriting------检索质量差时,让 LLM 换个问法重查;
- self-reflection ------加一个
grade节点评估"检索够不够好"; - tool calling ------把
retrieve从固定节点升级成 LLM 按需调用的工具,让"查什么"也易主。
本篇小结
- 多跳问题不能整句向量化,要拆成有序子问题、按推理链逐个检索。
- 状态设计是"状态机"和"流水线"的分水岭:
nextSubIdx/retrievalCount/maxRetrievals/plannedNext这些字段,全是给循环服务的。 plan_next_step是"控制权易主"的最小现场:LLM 决定何时停,架构用maxRetrievals+ 剩余条数两条硬性兜底划边界。- 条件边的路由函数必须读"上游节点实际写入的字段",一个字段名写错会让循环静默失效,不报错也不崩。
- 多轮检索要做
mergeUnique去重:按 id 去重、保留高分、降序,否则重复片段费 token 又误导 LLM。 - 这个 demo 是"收窄版 Agentic":何时停已易主,但怎么查、查得不好怎么办还留在代码里。
系列预告
- 下一篇:把
retrieve升级成 LLM 可调用的工具------从"固定枚举里的二选一",走向真正的 function calling 循环,让"查什么、怎么查"的控制权也彻底交给 LLM。那时"控制权易主"才算走完。