✦ 大道至简 · 时光是画在卷上的河流 · 行到水穷处,坐看云起时

langchainlanggraph2026.09.02 · 38 分钟阅读

LangGraph 之 Agentic RAG

朴素 RAG 是一条不会转弯的流水线:简单问题照样检索、信息够不够没人判断、一步查不到的链式问题直接放弃、知识库里没有就编。本篇以《天龙八部》小说问答为例,用 LangGraph 逐步加入「意图路由 → 多跳循环检索 → 检索效果评估 + 联网兜底」,让大模型自己做决策中枢,把流水线改造成能自问自答、自我纠偏的闭环。适合已掌握 LangChain / LangGraph 基础、想理解 Agentic RAG 底层逻辑的学习者。

L

Leo

2026.09.02 · 更新于 2026.09.10

5 次浏览
LangGraph 之 Agentic RAG

从「搜了就答」到「自主决策」:用 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_queryrag-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() 换几个刁钻问题(比如知识库里没有的、需要两跳的),观察流程图每一步的 ---节点名--- 和最终回答,比看十遍概念都记得牢。

标签 / TAGSlangchainlanggraph
L

Leo

博主

独立开发者 / Blogger,原博客「大道至简」维护者。正在把 WordPress 上攒了几年的文章与拾语迁移到 Next.js。

读者留言

COMMENTS · 0

发表留言

评论经审核后展示 · 请友善发言0/100