LangGraph 之 Agentic RAG
朴素 RAG 是一条不会转弯的流水线:简单问题照样检索、信息够不够没人判断、一步查不到的链式问题直接放弃、知识库里没有就编。本篇以《天龙八部》小说问答为例,用 LangGraph 逐步加入「意图路由 → 多跳循环检索 → 检索效果评估 + 联网兜底」,让大模型自己做决策中枢,把流水线改造成能自问自答、自我纠偏的闭环。适合已掌握 LangChain / LangGraph 基础、想理解 Agentic RAG 底层逻辑的学习者。
Leo
2026.09.02 · 更新于 2026.09.10
从「搜了就答」到「自主决策」:用 LangGraph 一次吃透 Agentic RAG
开场白:朴素 RAG 的天花板
一个最朴素、最常见的 RAG 问答流程是这样一条直线:
用户问题向量化 → 相似度检索 → prompt 拼接 → 生成回答
它实现简单、效果直观,但一跑真实业务就会撞上一连串天花板:
| # | 朴素 RAG 的问题 | 具体表现 |
|---|---|---|
| 1 | 简单常识问题也走向量检索 | 问「红烧肉怎么做」,模型也先去小说知识库里捞一圈,纯属浪费 |
| 2 | 缺检索效果的评估与纠错 | 没人判断召回的片段到底够不够、对不对,够不够都硬着头皮答 |
| 3 | 处理不了多步检索的链式推理 | 问「A 是谁的儿子?他爹在江湖上的公开身份是什么?」必须查两轮才能答,一次检索只查了一层 |
| 4 | 纯语义检索对术语、精确实体匹配不准 | 问精确人名、武功名、章节编号,语义相似 ≠ 精确命中 |
| 5 | 没有联网补充能力,知识库缺信息就编 | 知识库里没这段内容时,模型被逼着「一本正经地胡说八道」 |
解决方案就是 Agentic RAG。
一句话定义:Agentic RAG 让大模型当「决策中枢」,自主控制检索方式、评估检索效果、判断是否需要补充检索或发起网络搜索,形成「思考 → 检索 → 评估 → 再补充」的闭环,而不是「搜了就答」的直线。
下面是我们在 advanced-rag 这个示例工程里,用 LangGraph 把这条直线逐步改造成闭环的完整过程。四份源码就在工程 src/ 目录下,可以 node src/xxx.js 直接跑:
| 源码文件 | 演示的能力 | 解决的问题 |
|---|---|---|
src/naive-rag.js | 朴素基线 | —— |
src/rag-query-router.js | 意图识别路由 | 问题 1 |
src/rag-multihop.js | 多跳循环检索 | 问题 3 |
src/rag-webfallback.js | 检索评估 + 联网兜底 | 问题 2、问题 5 |
| (预留,未实现) | 关键词检索(ElasticSearch) | 问题 4 |
提醒:本篇是「决策循环怎么搭」的进阶。如果你对 LangGraph 的 State / Node / Edge / 条件边还不熟,建议先读《从一张图到一支团队:LangGraph 与多 Agent 架构全解》,它把底下的积木讲透了,本篇只做 30 秒热身。
第一章 30 秒热身:搭一个能「反悔」的图,只需要两样东西
想让检索从「必经之路」变成「模型决定要不要走、走几次、走完够不够」,LangGraph 只额外需要两样东西:
① 条件边 + 环:让流程能分叉、能回头
普通边 A → B 是死路:走完 A 必走 B。要分叉就用 addConditionalEdges;要让流程回到前面的节点重跑,只需让条件边指回上游——这就是「环」。
// 一个条件边回调:根据 state 里某个字段决定下一步去哪个节点
const decideNext = (state) =>
state.ready ? "generate" : "retrieve"; // 没准备好就回到 retrieve 再查
graph.addConditionalEdges("evaluate", decideNext, {
generate: "generate",
retrieve: "retrieve", // ← 指回上游节点 = 循环
});
要点:
- 条件边 = 岔路开关:第三个参数是一张「节点名 → 节点名」的映射表,回调的返回值作 key 查表选路。
- 环 = 允许模型反悔:回到
retrieve再来一轮,就是「多跳」;前提是state里得有计数器(检索了几轮、查到第几条),否则会死循环。
② withStructuredOutput:让模型吐「可编程的决策」
岔路口由谁判断?由 LLM。可 LLM 返回的是自由文本,没法拿来 if/else。解决办法是给模型一张 zod schema,让它输出结构化 JSON:
import { z } from "zod";
const RouteSchema = z.object({
strategy: z.enum(["simple", "complex"]), // 路由模型只许吐这两个值之一
reason: z.string(), // 附带一句理由,方便调试
});
// 生成一个"被 schema 约束"的模型,invoke 直接返回校验过的对象
const router = llm.withStructuredOutput(RouteSchema);
const route = await router.invoke("……判断这个问题要不要检索……");
// route.strategy === "simple" | "complex",可以直接拿来路由
要点:
zod负责「校验 + 兜底」:模型输出不符合 schema 会被强制重试,保证进到状态里的字段一定是你要的类型。- 每个「角色」= 同一个 llm + 不同 prompt + 不同 schema:这就是本文「多 Agent」的含义——不是起了几个进程,而是让大模型在不同节点上分别扮演路由器 / 拆解器 / 规划器 / 评估器 / 生成器。
- 用
reason字段把模型的思考过程留档,调试时一目了然。
有了条件边和结构化输出,下面四个「Agent 化改造」就都顺理成章了。
第二章 模式①:意图识别路由 —— 别让简单问题白检索
一句话说明: 在检索之前加一个「路由 Agent」,判断问题是简单常识(直接答)还是需要小说细节(才检索),用一个条件边分叉,省掉无谓的向量检索。
对应源码 src/rag-query-router.js。核心就是第三章热身里的两块积木拼起来:
// 1. 路由 Agent:只负责"要不要查",并说明理由
const routeQuestionNode = async (state) => {
const router = llm.withStructuredOutput(RouteSchema);
const route = await router.invoke(`你是问答路由器。请判断用户问题是否需要外部检索。
规则:
- simple: 常识问答、简短定义,无需特定小说细节即可回答。
- complex: 需要《天龙八部》具体情节、人物关系、章节事实、原文细节或证据支持。
用户问题:${state.question}`);
console.log(`路由策略: ${route.strategy} (${route.reason})`);
return { strategy: route.strategy, routeReason: route.reason };
};
// 2. 岔路开关:simple → 直接答;complex → 走完整检索
const decideNext = (state) =>
state.strategy === "simple" ? "direct_answer" : "retrieve";
const graph = new StateGraph(GraphState)
.addNode("route_question", routeQuestionNode)
.addNode("direct_answer", directAnswerNode) // 简单问题:不查库,直接答
.addNode("retrieve", retrieveNode) // 复杂问题:才向量检索
.addNode("rag_generate", ragGenerateNode)
.addEdge(START, "route_question")
.addConditionalEdges("route_question", decideNext, {
direct_answer: "direct_answer",
retrieve: "retrieve",
})
.addEdge("retrieve", "rag_generate")
.addEdge("direct_answer", END)
.addEdge("rag_generate", END)
.compile();
要点:
- 角色分工让 prompt 更纯净:路由 Agent 的 prompt 只有一条判断规则,比「又要路由又要回答」的巨型 prompt 稳定得多,也省 token。
strategy存进 state 一路带下去:后面无论是检索分支还是打印结果,都能回看当初这个问题的路由结论。- 路由错了也「无害化」:最多是把简单问题当复杂问题多查一次(慢一点),或者把复杂问题判成 simple(会答错)——所以规则里要写清楚什么算 complex。
第三章 模式②:多跳循环检索 —— 链式问题,查一层不够就再查一层
一句话说明: 对需要多步推理的复杂问题,先加一个「拆解 Agent」把原题拆成有序的子问题队列,然后进入「查一条子问题 → 规划 Agent 判断够没够 → 不够就查下一条」的循环,直到凑够依据或撞上护栏。
对应源码 src/rag-multihop.js。它的状态里专门多了几个「循环计数器」:
const GraphState = Annotation.Root({
question: Annotation,
k: Annotation,
strategy: Annotation, // simple / complex(复用路由)
subQuestions: Annotation, // 拆解出的有序子问题队列
nextSubIdx: Annotation, // ← 下一轮该查第几条(循环下标)
documents: Annotation, // 累计召回的片段(跨轮合并)
retrievalCount: Annotation,// ← 已检索几轮(护栏计数)
maxRetrievals: Annotation, // ← 轮数上限
plannedNext: Annotation, // 规划 Agent 的裁决
generation: Annotation,
});
第一步:拆解 Agent,把「链」变成「队列」
一次检索只能查一个点,链式问题要先「拉直」。拆解器要求每条子问题都可独立检索(写全人名,禁用「他/她/上文」这类指代):
const DecomposeSchema = z.object({
sub_questions: z.array(z.string()).min(1).max(8),
reason: z.string(),
});
const decomposeQuestionNode = async (state) => {
const decomposer = llm.withStructuredOutput(DecomposeSchema);
const out = await decomposer.invoke(`你是多跳问答的「子问题拆解器」。
用户原始问题:${state.question}
任务:把问题拆成有序子问题列表,用于依次向量检索。
要求:
1. 链式、多层关系的问题必须拆成多条;单跳即可答的也可只输出 1 条。
2. 每条必须是可独立检索的完整中文问句,禁止用「他/她/此人/上文」等指代。
3. 顺序符合推理链:先搞清前置实体/事实,再查后续结论。`);
const subQuestions = out.sub_questions.map((s) => s.trim()).filter(Boolean);
return { subQuestions, nextSubIdx: 0, currentQuery: subQuestions[0] };
};
第二步:retrieve 只查「下一条」,跨轮合并去重
多跳和单跳的 retrieve 长得一样,但每次只查 subQuestions[nextSubIdx] 这一条,查完 nextSubIdx + 1、retrievalCount + 1。多轮结果要按片段 id 去重合并,保留更高相似度:
// 按 id 合并:同 id 保留 score 更高的一条,再按 score 降序
function mergeUnique(existing, fresh) {
const map = new Map();
for (const d of [...existing, ...fresh]) {
const key = String(d.id);
if (!map.has(key) || Number(d.score) > Number(map.get(key).score)) {
map.set(key, d);
}
}
return [...map.values()].sort((a, b) => Number(b.score) - Number(a.score));
}
第三步:规划 Agent 决定「继续查 or 够了就答」,外加两道护栏
每查完一轮,让一个「规划 Agent」看一眼当前累计的文档和剩余子问题,决定回 retrieve 还是去 generate:
const NextStepSchema = z.object({
nextAction: z.enum(["retrieve", "generate"]),
reason: z.string(),
});
const planNextStepNode = async (state) => {
const remaining = (state.subQuestions ?? []).length - (state.nextSubIdx ?? 0);
// ……把子问题进度、已召回文档摘要拼进 prompt 让模型判断……
const { nextAction, reason } = await (
llm.withStructuredOutput(NextStepSchema)).invoke(prompt);
let finalNext = nextAction;
if (state.retrievalCount >= state.maxRetrievals) finalNext = "generate"; // 护栏①:到轮数上限
if (remaining <= 0) finalNext = "generate"; // 护栏②:子问题查完
return { plannedNext: finalNext };
};
// 条件边:规划 Agent 说再查 → 指回 retrieve,形成环!
const afterPlan = (state) =>
state.plannedNext === "retrieve" ? "retrieve" : "generate";
完整连线,关键在最后那条 plan_next_step → retrieve 的回头路:
const graph = new StateGraph(GraphState)
.addNode("route_question", routeQuestionNode)
.addNode("direct_answer", directAnswerNode)
.addNode("decompose_question", decomposeQuestionNode)
.addNode("retrieve", retrieveNode)
.addNode("plan_next_step", planNextStepNode)
.addNode("generate", generateNode)
.addEdge(START, "route_question")
.addConditionalEdges("route_question", afterRoute, {
direct_answer: "direct_answer",
decompose_question: "decompose_question",
})
.addEdge("decompose_question", "retrieve")
.addEdge("retrieve", "plan_next_step")
.addConditionalEdges("plan_next_step", afterPlan, {
retrieve: "retrieve", // ← 环!回到 retrieve 查下一条子问题
generate: "generate",
})
.compile();
要点:
- 多跳的本质 = 一条「带状态计数器的回边」:
retrieve → plan_next_step → retrieve这个环,配合nextSubIdx/retrievalCount两个计数器,就是最朴素的「循环检索」。LangGraph 里没有任何特殊的「循环语法」,循环就是条件边指回上游。 - 护栏要写在代码里,不能只靠模型自觉:
maxRetrievals封顶轮数、remaining <= 0强制结束——防止模型贪心或抽风,把图跑成无限循环烧钱。 - 子问题要「自己带上下文」:拆解时就写全人名与事件,否则第二轮「他爹是谁」这种指代查询根本搜不到。
- 跨轮去重:多跳会反复召回重复片段,
mergeUnique按 id 去重并保留高分,防止同一段话在 prompt 里重复膨胀。
第四章 模式③:检索效果评估 + 联网兜底 —— 先问「够不够」,不够就去网上找
一句话说明: 在生成之前加一个「评估 Agent」,先审一遍本地召回的信息够不够;不够就根据评估结果给出的 web_query 发起联网搜索补一轮,再回来二次评估,确认补够了才让生成器开口——把「硬答 / 编造」堵死在生成之前。
对应源码 src/rag-webfallback.js。它同时治了开头两个病:问题 2(无评估纠错)和问题 5(知识库缺失就编造)。
const EvaluateSchema = z.object({
enough: z.boolean(), // 当前上下文够不够回答
missing: z.array(z.string()).max(6), // 若不够,缺哪些信息点
reason: z.string(),
web_query: z.string().optional(), // 若不够,给一句适合联网搜索的完整问句
});
评估节点「本地检索后」跑第一次。注意:它跑两次是同一个节点——第二轮时 webContext 已有值,节点会自动把联网结果也拼进去、并自称「二次评估」:
const evaluateNode = async (state) => {
const hasWeb = Boolean(state.webContext && String(state.webContext).trim());
console.log(hasWeb ? "---EVALUATE_CONTEXT_WITH_WEB---" : "---EVALUATE_LOCAL_CONTEXT---");
const evaluator = llm.withStructuredOutput(EvaluateSchema);
const out = await evaluator.invoke(`你是信息充分性评估器。判断当前上下文是否足以回答用户问题。
用户问题:${state.question}
已检索上下文(来自本地知识库):
${state.localContext || "(空)"}
${hasWeb ? `联网搜索结果:\n${state.webContext}\n` : ""}
输出字段:
- enough: 是否足够回答(true/false)
- missing: 若不够,列出缺失信息点(最多 6 条)
${hasWeb ? "" : "- web_query: 若不够,给出一个适合联网搜索的中文查询句"}`);
return { evaluation: JSON.stringify(out) };
};
分支逻辑 afterEvaluateLocal 有一条关键防线:一旦已经联网补过,无论如何都去生成,保证「补一轮就收手」,不无限补:
function afterEvaluateLocal(state) {
if (state.webContext && String(state.webContext).trim()) {
return "generate"; // 已联网补过 → 强制去生成,防止无限循环
}
const parsed = JSON.parse(state.evaluation || "{}");
return parsed.enough === true ? "generate" : "web_search"; // 不够 → 联网
}
const graph = new StateGraph(GraphState)
.addNode("route_question", routeQuestionNode)
.addNode("direct_answer", directAnswerNode)
.addNode("local_retrieve", retrieveLocalNode)
.addNode("evaluate_local", evaluateNode)
.addNode("web_search", webSearchNode)
.addNode("generate", generateNode)
.addEdge(START, "route_question")
.addConditionalEdges("route_question", afterRoute, {
direct_answer: "direct_answer",
local_retrieve: "local_retrieve",
})
.addEdge("local_retrieve", "evaluate_local")
.addConditionalEdges("evaluate_local", afterEvaluateLocal, {
generate: "generate",
web_search: "web_search",
})
.addEdge("web_search", "evaluate_local") // ← 联网后回来二次评估
.compile();
联网节点本身只是调一个网页搜索 API(工程里用的是博查 bochaai.com/v1/web-search),把命中的「标题 / URL / 摘要」整理成文本塞回 webContext:
const webSearchNode = async (state) => {
const parsed = JSON.parse(state.evaluation || "{}");
const query = (parsed.web_query ?? "").trim() || state.question; // 优先用评估器给的查询句
const webContext = await bochaWebSearch(query, 8); // 返回带 URL 的搜索结果文本
return { webContext };
};
要点:
- 把「答不答」也从生成器手里拿走:评估 Agent 在生成前先行使「否决权」——不够就说不够,宁可让流程去查,也不让生成器硬编。
missing+web_query是「纠错闭环」的接口:评估器说缺什么、该搜什么,下一步就照着查;检索从「蒙头搜一次」升级成「照着缺失点补」。- 同一个 evaluate 节点跑两轮是省事的妙招:第二次它会自动把
webContext拼进 prompt 并输出「二次评估」,逻辑只写一份。 - 一次联网 = 收手:
webContext一旦非空就强制去 generate。评估 + 联网是个有界的闭环,不是无底洞。
第五章 模式④(预告):语义检索的短板,留给关键词检索去补
还剩开头的问题 4——纯语义检索对专业术语、精确实体匹配不准,上面的四种模式都治不了,因为它发生在检索这一层本身:
- 向量检索是「模糊语义匹配」:它找「意思相近」的片段,而不是「字面上精确命中」的片段。问「萧峰的生父」「降龙十八掌第几式」这种带专名的查询,语义相近但术语不同,召回就可能飘走。
- 关键词检索(BM25 类)恰好互补:它对精确的专名、编号、版本、代码、人名按字面命中,召回更「准」;语义检索则召回更「全」。生产里两者通常做**混合检索(Hybrid Search)**再合并、去重、重排。
这个方向在我们工程里预留了坑位,用 **ElasticSearch(BM25 关键词检索)**实现,属于下一个学习阶段,本篇先埋个伏笔:
真正「准而全」的检索 = 语义检索召回广度 + 关键词检索保证精确命中 → 融合重排。 具体落地(ES 分词、BM25 打分、与 Milvus 分数如何归一化合并)我们下篇再拆。
第六章 一张「全家桶」蓝图 + 落地姿势
把上面三种模式都塞进一张图,就是 Agentic RAG 的「全家桶」形态——大模型在五个决策点上各司其职:
实际工程是分四个文件、每个文件演示其中一种决策的自包含小图(如第二章到第四章那样),而不是真把全家桶写死在一个文件里。这样每个文件干净、可单跑、可单独讲解。如果你愿意,把四种模式的手脚按第六章的连法接起来,就是上面这张图。
一张表看懂:每种模式补上了什么
| 模式 | 治的问题 | 新增的「角色」Agent | 核心机制 | 对应源码 |
|---|---|---|---|---|
| 朴素基线 | —— | 无(代码写死) | retrieve → generate 直线 | naive-rag.js |
| 意图路由 | 简单问题也检索 | 路由器 | zod enum 决策 + 条件边分叉 | rag-query-router.js |
| 多跳检索 | 链式推理查一层不够 | 拆解器 + 规划器 | 子问题队列 + 回边循环 + 轮数护栏 | rag-multihop.js |
| 效果评估 | 无法判断信息够不够 | 评估器 | enough / missing / web_query | rag-webfallback.js |
| 联网兜底 | 知识库缺失就编造 | 联网搜索 | 不足即联网 + 二次评估收手 | rag-webfallback.js |
| 关键词检索(预留) | 术语 / 精确匹配不准 | —— | BM25 混合检索(ElasticSearch) | 未实现,下期 |
落地姿势:别照搬全家桶
Agentic RAG 没有标准答案,是按业务裁剪决策点。我们公司里的项目就是典型——根据业务场景做了简化版、检索路径相对固定的版本:不需要多跳就别上拆解器,知识库覆盖率高就不必每次联网,评估器嫌慢可以只在低分时触发。
判断该上哪几样,可以按这张小决策图走:
结尾总结
朴素 RAG 把「检索」当一道必答题;Agentic RAG 把它当一道可选项——什么时候查、查几次、够不够、不够去哪补,全部交给大模型在每个岔路口现场决定,并用一条能回头的边把决定串成闭环。
回到开头的五连问,我们的答案其实只有一句话:
与其把检索流程从一条直线改粗,不如把它改成一个「带决策的环」——有多少个决策点,就有多少个角色 Agent。
- 要不要查? → 意图路由 Agent 决定(
simple/complex) - 查几层? → 拆解 Agent 划队列 + 规划 Agent 判断是否继续,轮数护栏兜底
- 够不够、准不准? → 评估 Agent 审稿,缺就补
- 本地没有去哪补? → 联网搜索,补完二次评估、一轮收手
- 术语 / 精确匹配? → 关键词检索,下篇填坑
这些原语(路由、拆解、规划、评估、补充)都独立可复用。真正的业务场景里,你按「值不值得加这个决策点」去裁剪它们,而不是把全家桶整套搬走——能忍住不加的 Agent 化,往往才是更好的 Agentic RAG。
如果你看完想动手:本工程 src/ 下四个文件各是一个能直接 node 跑的图,分别跑一遍路由 / 多跳 / 联网兜底的例子,把每个文件里的 main() 换几个刁钻问题(比如知识库里没有的、需要两跳的),观察流程图每一步的 ---节点名--- 和最终回答,比看十遍概念都记得牢。
读者留言
COMMENTS · 0发表留言