多跳 RAG 实战:把"检索一次"改造成"会规划的循环"

一句话价值:前四篇讲的是"控制权交给 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] 检索并去重合并 documentsnextSubIdx+1
plan_next_step LLM 判断"依据够不够、要不要继续查" plannedNext
generate 基于 documents 流式生成最终答案 generation

注意 route_questionplan_next_step两个 LLM 决策点 ,而且它们用的是 withStructuredOutput + zod 把输出锁死在枚举里,不给模型乱发挥的空间:

js 复制代码
const RouteSchema = z.object({
  strategy: z.enum(["simple", "complex"]),
  reason: z.string()
})

整张图连起来是这样的:

flowchart TD START((START)) --> RQ[route_question<br/>路由] RQ -->|simple| DA[direct_answer<br/>直接回答] RQ -->|complex| DQ[decompose_question<br/>拆解子问题] DA --> ENDN((END)) DQ --> RT[retrieve<br/>检索当前子问题] RT --> PN{plan_next_step<br/>LLM 决策下一步} PN -->|retrieve| RT PN -->|generate| GN[generate<br/>生成答案] GN --> ENDN

核心: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_questionstrategy
怎么查(用什么 query) ❌ 还在代码手里 prompt 明确写"不要自拟新的检索句"
查得不好怎么办 ❌ 没有 没有 self-reflection,不会改写 query 重查
用什么工具 ❌ 没有 检索器是固定节点,不是 LLM 可调用的 tool

一句话定位:"何时停"的控制权已经交给 LLM 了,但"怎么查、查什么、查得不好怎么办"还焊死在代码里。 决策被锁在 retrieve/generate 的固定枚举里,检索动作是固定的(永远用下一条子问题),没有 query 改写、没有检索质量自评、没有真正的 function calling 循环。

离完整 Agentic 还差的三步,也是下一步的演进路线:

  1. query rewriting------检索质量差时,让 LLM 换个问法重查;
  2. self-reflection ------加一个 grade 节点评估"检索够不够好";
  3. tool calling ------把 retrieve 从固定节点升级成 LLM 按需调用的工具,让"查什么"也易主。

本篇小结

  1. 多跳问题不能整句向量化,要拆成有序子问题、按推理链逐个检索。
  2. 状态设计是"状态机"和"流水线"的分水岭:nextSubIdx/retrievalCount/maxRetrievals/plannedNext 这些字段,全是给循环服务的。
  3. plan_next_step 是"控制权易主"的最小现场:LLM 决定何时停,架构用 maxRetrievals + 剩余条数两条硬性兜底划边界。
  4. 条件边的路由函数必须读"上游节点实际写入的字段",一个字段名写错会让循环静默失效,不报错也不崩。
  5. 多轮检索要做 mergeUnique 去重:按 id 去重、保留高分、降序,否则重复片段费 token 又误导 LLM。
  6. 这个 demo 是"收窄版 Agentic":何时停已易主,但怎么查、查得不好怎么办还留在代码里。

系列预告

  • 下一篇:retrieve 升级成 LLM 可调用的工具------从"固定枚举里的二选一",走向真正的 function calling 循环,让"查什么、怎么查"的控制权也彻底交给 LLM。那时"控制权易主"才算走完。
相关推荐
小白要生发1 小时前
带你从 0 到 1 用 golang 手写一个 claude code
agent·ai编程
大模型真好玩1 小时前
仅需3轮对话,Seed-Evolving大模型帮我创造出 “知识跳动”——一个智能化时代的知识学习系统!
人工智能·agent·豆包marscode
沉默王二2 小时前
Codex 最新焚决发布,快!
gpt·agent·ai编程
武子康2 小时前
Data Parallel 深入解析:把请求分给真正有余量的副本
人工智能·llm·agent
一只游鱼2 小时前
agentloop
agent
GISMagic3 小时前
3.从零制作 Agent 管理工具:LangChain + Node + Vue 实现多 Agent 统一调度
ai·agent
hyunbar3 小时前
LangChain 实战:玩转短期记忆
langchain·agent·harness
米小虾13 小时前
拆给 8 个子智能体,只拿回 2.3 倍信息:多智能体分解的产出守恒律
人工智能·agent
JaydenAI14 小时前
[DeepSeek Harness深度拆解-04]完整的插件配置树如何构建?
ai·agent·deepseek·harness·cordis