Skip to content

Agentic RAG:基于 LangGraph 实现大模型自主决策的 RAG 闭环系统

公司内部的 Agent 基本都要用到 RAG。

因为大模型能思考,但它不知道公司内部的文档,而我们需要它能基于内部文档来回答。

传统 RAG 是这样的:查询的时候,把 query 用嵌入模型向量化,根据余弦相似度,匹配向量数据库中最相近的文档返回:

但这个流程太固定,会有一些问题:

  • 所有问题都走检索,其实简单常识类问题不需要检索,浪费资源
  • 没有纠错和评估机制,无法判断检索内容是否准确、是否足够
  • 处理不了需要多步检索的复杂问题,比如先查 A、再查 B 才能得出结论
  • 专业术语、精确实体更适合关键词检索,纯语义检索容易匹配不准
  • 本地知识库没有的内容,不会主动去网络搜索补充,容易编造答案

解决这些问题,显然要在 RAG 的固定流程中,引入大模型来思考:

  • 让模型根据问题类型选择检索策略,简单问题直接回答,复杂问题才走完整检索
  • 评估检索结果是否相关、是否足够,让模型判断是否需要重新检索或补充检索
  • 让模型自动拆解复杂问题,决定先查什么、后查什么,实现多步检索
  • 同时结合关键词检索与语义检索,由模型统一融合多路结果,提升专业场景准确率
  • 让模型判断本地知识库是否覆盖答案,覆盖不足时自动触发网络搜索补充信息

最终把原本"死板的检索 - 生成"流程,升级为可思考、可判断、可纠错的智能 RAG 架构。这种由大模型自主决策怎么检索、检索的信息是否足够、是否要重新检索等的 RAG 流程就叫 Agentic RAG

这很适合用 LangGraph 的多 Agent 架构来做,每个 Agent 负责其中一块功能。

项目搭建

我们先把工程建起来:

bash
mkdir advanced-rag
cd advanced-rag
npm init -y

安装依赖:

bash
pnpm install @langchain/langgraph @langchain/core @langchain/openai @langchain/community

创建 .env

bash
OPENAI_API_KEY=sk-xxx
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
MODEL_NAME=qwen-plus

后续每一版代码都依赖一个已存在的 Milvus 向量库(集合名 ebook_collection,存的是《天龙八部》小说切片),模型用阿里的 qwen-plus 兼容 OpenAI 接口,embedding 用 text-embedding-v3(1024 维)。下面各小节只贴关键代码,连接 Milvus 和流式打印回答的样板代码都是重复的,理解思路即可。

一、先用 LangGraph 实现传统 RAG

文件:src/naive-rag.mjs

LangGraph 的核心是 Annotation.Root 定义图的状态(state),再用 StateGraph 把节点(node)和边(edge)拼成一张图。

javascript
import "dotenv/config";
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { Annotation, END, START, StateGraph } from "@langchain/langgraph";
import { Milvus } from "@langchain/community/vectorstores/milvus";

const COLLECTION_NAME = "ebook_collection";
const TOP_K = 5;

const GraphState = Annotation.Root({
  question: Annotation,
  k: Annotation,
  documents: Annotation,
  generation: Annotation,
});

const model = new ChatOpenAI({
  temperature: 0,
  model: "qwen-plus",
  configuration: { baseURL: process.env.OPENAI_BASE_URL },
  apiKey: process.env.OPENAI_API_KEY,
});

const embeddings = new OpenAIEmbeddings({
  model: "text-embedding-v3",
  dimensions: 1024,
});

retrieveNode 把 query 向量化、从 Milvus 里相似度检索:

javascript
let vectorStore;

async function retrieveRelevantContent(question, k = TOP_K) {
  try {
    const docsWithScores = await vectorStore.similaritySearchWithScore(question, k);
    return docsWithScores.map(([doc, score]) => ({
      score,
      content: doc.pageContent,
      id: doc.metadata?.id ?? "unknown",
      book_id: doc.metadata?.book_id ?? "未知",
      chapter_num: doc.metadata?.chapter_num ?? "未知",
      index: doc.metadata?.index ?? "未知",
    }));
  } catch (error) {
    console.error("检索内容时出错:", error.message);
    return [];
  }
}

const retrieveNode = async (state) => {
  const documents = await retrieveRelevantContent(state.question, state.k);
  return { question: state.question, k: state.k, documents };
};

generateNode 把命中的文档拼进 prompt,调用大模型生成回答(这里用 model.stream 流式输出):

javascript
const generateNode = async (state) => {
  const context = state.documents
    .map(
      (item, i) =>
        `[片段 ${i + 1}]
章节: 第 ${item.chapter_num} 章
内容: ${item.content}`,
    )
    .join("\n\n ━━━━━ \n\n");

  const prompt = `你是一个专业的《天龙八部》小说助手。基于小说内容回答问题,用准确、详细的语言。
请根据以下《天龙八部》小说片段内容回答问题:
${context}
用户问题: ${state.question}
回答要求:
1. 如果片段中有相关信息,请结合小说内容给出详细、准确的回答
2. 可以综合多个片段的内容,提供完整的答案
3. 如果片段中没有相关信息,请如实告知用户
4. 回答要准确,符合小说的情节和人物设定
5. 可以引用原文内容来支持你的回答
AI 助手的回答:`;

  process.stdout.write("\n【AI 回答(流式)】\n");
  let generation = "";
  const stream = await model.stream(prompt);
  for await (const chunk of stream) {
    const text = typeof chunk.content === "string" ? chunk.content : "";
    if (!text) continue;
    generation += text;
    process.stdout.write(text);
  }
  process.stdout.write("\n");

  return { question: state.question, k: state.k, documents: state.documents, generation };
};

把两个节点和边拼起来编译成图:

javascript
const graph = new StateGraph(GraphState)
  .addNode("retrieve", retrieveNode)
  .addNode("generate", generateNode)
  .addEdge(START, "retrieve")
  .addEdge("retrieve", "generate")
  .addEdge("generate", END)
  .compile();

RAG 是一个线性的流程,之前我们用 LCEL 的链写过,这次用 LangGraph 来写:

  • 检索节点就是把 query 向量化从 Milvus 里检索相关文档
  • 生成节点是把检索的文档放到 prompt 里,调用大模型生成回答

跑一下就能得到回答。下面我们一条条来解决上面提出来的传统 RAG 问题。

二、加一个路由节点:简单问题不检索,复杂问题才检索

第一个要解决的问题:简单常识问题也走向量检索,浪费资源

做法是加一个节点来判断:是直接回答,还是先检索向量库再回答。

文件:src/rag-query-router.mjs

zod 定义路由的结构化输出 schema,再用 llm.withStructuredOutput 让模型按 schema 返回,从而控制输出格式:

javascript
import { z } from "zod";

const RouteSchema = z.object({
  strategy: z.enum(["simple", "complex"]),
  reason: z.string(),
});

const GraphState = Annotation.Root({
  question: Annotation,
  k: Annotation,
  strategy: Annotation,
  routeReason: Annotation,
  documents: Annotation,
  generation: Annotation,
});

路由节点:让模型判断 query 是 simple 还是 complex,并给出原因。

javascript
const routeQuestionNode = async (state) => {
  console.log("---ROUTE_QUESTION---");
  const router = llm.withStructuredOutput(RouteSchema);
  const route = await router.invoke(`
你是问答路由器。请判断用户问题是否需要外部检索。
规则:
- simple: 常识问答、简短定义、无需特定小说细节即可回答。
- complex: 需要《天龙八部》具体情节、人物关系、章节事实、原文细节或证据支持。
用户问题:${state.question}
`);
  console.log(`路由策略: ${route.strategy} (${route.reason})`);
  return { question: state.question, k: state.k, strategy: route.strategy, routeReason: route.reason };
};

directAnswerNode 走直接回答,ragGenerateNode 走"检索 + 生成"。关键是 decideNextconditional edge(条件边) 根据 state.strategy 把流程分到不同分支:

javascript
function decideNext(state) {
  return 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();

现在的 graph 如下:

我们加了一个对问题做路由的节点:

  • 根据问题返回不同的类型,然后用 conditional edge 转到不同节点来处理
  • 这个路由节点用 withStructuredOutput 来控制结构化输出
  • 用大模型识别 query 是哪种类型,并给出原因
  • 简单问题直接调大模型回答,复杂的问题先检索向量数据库再生成回答

跑下试试,这样就能识别出与小说相关的问题才走检索了。

三、多跳检索:复杂问题先拆解子问题,再循环检索

继续优化:处理不了需要多步检索的复杂问题,比如先查 A、再查 B 才能得出结论

比如这种问题:"段誉遇到的第一个神仙姐姐画像,是谁的弟子?"直接把这个 query 向量化匹配显然不够准确——应该是先检索神仙姐姐画像是谁,有了结果再去检索她是谁的弟子。

所以我们要支持子问题的拆分。

文件:src/rag-multihop.mjs

state 里多了几个字段来支撑"多轮检索":subQuestions(拆解得到的有序子问题)、nextSubIdx(下一轮要检索的下标)、retrievalCount(已检索轮数)、maxRetrievals(上限)。

javascript
const GraphState = Annotation.Root({
  question: Annotation,
  k: Annotation,
  strategy: Annotation,
  routeReason: Annotation,
  /** 拆解得到的有序子问题,仅用于检索 */
  subQuestions: Annotation,
  /** 下一轮 retrieve 要用的下标(指向 subQuestions 中尚未检索的那一条) */
  nextSubIdx: Annotation,
  documents: Annotation,
  currentQuery: Annotation,
  retrievalCount: Annotation,
  maxRetrievals: Annotation,
  plannedNext: Annotation,
  generation: Annotation,
});

三个结构化 schema:

javascript
const RouteSchema = z.object({
  strategy: z.enum(["simple", "complex"]),
  reason: z.string(),
});
const DecomposeSchema = z.object({
  sub_questions: z.array(z.string()).min(1).max(8),
  reason: z.string(),
});
const NextStepSchema = z.object({
  nextAction: z.enum(["retrieve", "generate"]),
  reason: z.string(),
});

拆解节点:让模型把原始问题拆成有序的子问题数组,并且要求每条子问题可独立检索、不能用"他/她/上文"等指代(否则循环里检索会失准)。

javascript
const decomposeQuestionNode = async (state) => {
  console.log("---DECOMPOSE_QUESTION---");
  const decomposer = llm.withStructuredOutput(DecomposeSchema);
  const out = await decomposer.invoke(`你是《天龙八部》多跳问答的「子问题拆解器」。
用户原始问题:
${state.question}
任务:将问题拆成 **有序** 子问题列表 sub_questions,用于 **依次向量检索**。要求:
1. 链式推理、多层关系、因果先后的问题,必须拆成多条;单跳即可答的也可只输出 1 条。
2. 每条子问题必须是 **可独立检索** 的完整中文问句,**禁止** 使用「他/她/此⼈/上文」等指代;可写全人物名与事件
3. 顺序必须符合推理链:先搞清前置实体/事实,再查后续结论。
4. **不要** 把整句原题原样复制成唯一一条(除非确实无法拆分);不要拆成过碎的关键词列表。
5. 输出 1~8 条即可。
请输出 sub_questions 与简短 reason。`);

  const subQuestions = out.sub_questions.map((s) => s.trim()).filter(Boolean);
  if (subQuestions.length === 0) throw new Error("decompose_question: sub_questions 为空");

  console.log(`拆解 ${subQuestions.length} 条子问题 (${out.reason})`);
  subQuestions.forEach((q, i) => console.log(` ${i + 1}. ${q}`));

  return { subQuestions, nextSubIdx: 0, currentQuery: subQuestions[0] };
};

检索节点:根据 state.nextSubIdx 取出当前要检索的子问题,检索完把文档合并(按 id 去重,同 id 保留更高 score),并把 nextSubIdx 加 1、轮数加 1。

javascript
/** 按 id 合并;同 id 保留更高 score */
function mergeUnique(existingDocs, newDocs) {
  const map = new Map();
  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));
}

const retrieveNode = async (state) => {
  const subs = state.subQuestions ?? [];
  const idx = state.nextSubIdx ?? 0;
  const q = subs[idx]?.trim();
  if (!q) throw new Error(`retrieve: 子问题下标 ${idx} 无有效文本(共 ${subs.length} 条)`);

  const round = state.retrievalCount + 1;
  console.log(`---RETRIEVE (第 ${round} 轮,子问题 ${idx + 1}/${subs.length})---`);
  console.log(`查询: ${q}`);

  const newDocs = await retrieveRelevantContent(q, state.k);
  const merged = mergeUnique(state.documents ?? [], newDocs);
  console.log(`本轮命中 ${newDocs.length} 条,累计去重后 ${merged.length} 条`);

  return { documents: merged, retrievalCount: round, nextSubIdx: idx + 1, currentQuery: q };
};

规划节点:判断是继续检索还是可以生成回答了。这里用 conditional edge 实现循环——plannedNext === "retrieve" 就回到 retrieve 节点,否则去 generate。同时用两条"硬性规则"兜底(剩余子问题为 0 必须生成、超过轮数上限必须生成),防止模型决策出错而无限循环。

javascript
const planNextStepNode = async (state) => {
  console.log("---PLAN_NEXT_STEP---");
  const subs = state.subQuestions ?? [];
  const nextIdx = state.nextSubIdx ?? 0;
  const remaining = subs.length - nextIdx;
  // ... 把子问题序列、已召回文档摘要拼成 prompt
  const model = llm.withStructuredOutput(NextStepSchema);
  const { nextAction, reason } = await model.invoke(prompt);

  let finalNext = nextAction;
  if (state.retrievalCount >= state.maxRetrievals) finalNext = "generate";
  if (remaining <= 0) finalNext = "generate";
  console.log(`[决策] plannedNext=${finalNext} (模型建议=${nextAction}) (${reason})`);

  return { plannedNext: finalNext };
};

function afterRoute(state) {
  return state.strategy === "simple" ? "direct_answer" : "decompose_question";
}
function afterPlan(state) {
  return state.plannedNext === "retrieve" ? "retrieve" : "generate";
}

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",
    generate: "generate",
  })
  .addEdge("direct_answer", END)
  .addEdge("generate", END)
  .compile();

整体流程如图:

  • 首先把原始问题拆成多个子问题的数组
  • 然后检索的时候根据 state 里的当前下标来检索对应问题的文档(因为会检索多轮,所以做了 id 去重)
  • 接下来判断是否检索完了,如果没有就继续检索
  • 直到循环完所有子问题,接下来就生成回答

跑一下:对于"《天龙八部》中「四大恶人」排行第二的是谁?此人之子在身世揭晓前,其生父在武林中的公开身份是什么?"这类链式问题,模型会先拆成"四大恶人第二是谁"→"此人(叶二娘)之子是谁"→"段誉身世揭晓前的生父公开身份"几步,逐轮检索后综合回答。

四、评估 + 网络搜索兜底:知识库没有的内容去联网补充

继续看传统 RAG 的另一个问题:本地知识库没有的内容,不会主动去网络搜索补充,容易编造答案

如果知识库中没有的内容,这时候 agent 就不知道怎么回答了。这种情况我们可以调用网络搜索来兜底,把搜索结果放到 prompt 里来参考生成回答。

文件:src/rag-webfallback.mjs

state 里加了 localContext(本地检索结果)、webContext(联网搜索结果)、evaluation(评估结论)。

评估节点:让模型判断当前上下文是否足够回答问题。不够的话,生成一个适合联网搜索的 query。

javascript
const EvaluateSchema = z.object({
  enough: z.boolean(),
  missing: z.array(z.string()).max(6),
  reason: z.string(),
  web_query: z.string().optional(),
});

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 条)
- reason: 简短原因
${hasWeb ? "" : "- web_query: 若不够,给出一个适合联网搜索的中文查询句(完整句,不用代词;为空也可)"}
`);

  console.log(`${hasWeb ? "二次评估" : "评估"}: enough=${out.enough} (${out.reason})`);
  return { evaluation: JSON.stringify(out) };
};

联网搜索节点:调博查(Bocha)Web Search API——这也是 DeepSeek 同款搜索服务。注意 Authorization 头里 Bearer 后面要有空格,少了空格会一直报错(这是一个容易踩的坑)。

javascript
async function bochaWebSearch(query, count) {
  const apiKey = process.env.BOCHA_API_KEY;
  if (!apiKey) throw new Error("Bocha Web Search 的 API Key 未配置(环境变量 BOCHA_API_KEY)。");

  const url = "https://api.bochaai.com/v1/web-search";
  const body = { query, freshness: "noLimit", summary: true, count: count ?? 10 };

  const response = await fetch(url, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(body),
  });

  if (!response.ok) {
    const errorText = await response.text().catch(() => "");
    throw new Error(`搜索 API 请求失败,状态码: ${response.status}, 错误信息: ${errorText}`);
  }

  const json = await response.json();
  if (json?.code !== 200 || !json?.data) {
    throw new Error(`搜索 API 返回失败:${json?.msg ?? "未知错误"}`);
  }

  const webpages = json.data.webPages?.value ?? [];
  if (!webpages.length) return "未找到相关结果。";

  return webpages
    .map(
      (page, idx) => `引用: ${idx + 1}
标题: ${page.name}
URL: ${page.url}
摘要: ${page.summary}
网站名称: ${page.siteName}
网站图标: ${page.siteIcon}
发布时间: ${page.dateLastCrawled}`,
    )
    .join("\n\n");
}

const webSearchNode = async (state) => {
  console.log("---WEB_SEARCH---");
  const parsed = (() => {
    try { return JSON.parse(state.evaluation || "{}"); } catch { return {}; }
  })();
  const query = (parsed.web_query ?? "").trim() || state.question;
  console.log(`联网查询: ${query}`);
  const webContext = await bochaWebSearch(query, 8);
  console.log(`联网结果长度: ${webContext.length}`);
  return { webContext };
};

生成节点把本地上下文和联网上下文拼在一起:

javascript
const generateNode = async (state) => {
  console.log("---GENERATE---");
  const context = [state.localContext, state.webContext].filter(Boolean).join("\n\n===== 联网补充 =====\n\n");
  const stream = await llm.stream(`你是一个严谨的中文问答助手。优先依据上下文作答,不要编造。
上下文(本地知识库 + 可选联网补充):
${context || "(空)"}
用户问题:${state.question}
回答要求:
1. 如果上下文足够,给出清晰、可核对的回答;需要时引用 “引用: n / URL” 或小说片段来支撑。
2. 如果上下文仍不足以确定关键事实,明确说明 “不确定 / 无法从上下文确认”,并说明缺失点。
3. 不要输出表情符号。
回答:`);
  // ... 流式输出,略
  return { generation };
};

图的编排:路由之后走本地检索 → 评估 →(不够就联网搜索 → 再评估)→ 生成。注意 web_search 之后又连回 evaluate_local,形成"评估 → 搜索 → 再评估"的小闭环。

javascript
function afterRoute(state) {
  return state.strategy === "simple" ? "direct_answer" : "local_retrieve";
}
function afterEvaluateLocal(state) {
  if (state.webContext && String(state.webContext).trim()) return "generate";
  const parsed = (() => {
    try { return JSON.parse(state.evaluation || "{}"); } catch { return {}; }
  })();
  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")
  .addEdge("direct_answer", END)
  .addEdge("generate", END)
  .compile();

现在的流程如下:

  • 检索完向量数据库,会评估一下信息是否足够
  • 不够的话生成一个 web search 用的 query,走网络搜索节点(配置博查的 api key:.envBOCHA_API_KEY=xxx
  • 取出 state 里的网络搜索 query,调用博查来搜索

跑一下:"请回答《天龙八部》小说里'雁门关事件'的主谋是谁,并说明其儿子的最终结局;另外请补充:在《天龙八部》2000 年后改编的影视作品中,哪位演员饰演过虚竹?"——本地库能答前半句,后半句(影视改编)本地库没有,模型就会自动触发联网搜索补充。

五、还差的两个问题与总结

继续看 RAG 的其他问题:

  • 没有纠错和评估机制,无法判断检索内容是否准确、是否足够 —— 评估阶段我们已经加了(evaluate 节点)。
  • 专业术语、精确实体更适合关键词检索,纯语义检索容易匹配不准 —— 关键词检索需要用到 ElasticSearch 全文检索数据库,后面再讲。

至此,我们基于 LangGraph 的多 Agent 架构实现了自主决策的 Agentic RAG 流程。

什么是 Agentic RAG?

将 LLM 作为系统的决策大脑,让它自主决定如何检索、检索多少次、判断检索结果是否足够可靠,以及是否需要补充检索、优化查询或切换数据源,这种自我决策、自我反思、自我修正的自主检索闭环,就叫 Agentic RAG

当然,具体要根据业务场景来设计方案。比如我们公司项目的 RAG 是这样的:

有意图识别(也就是路由),后面按照不同的流程来检索,之后合并生成回答。并不是完全按照 Agentic RAG 那种有评估、有重新检索的闭环来的。但这是适合我们业务场景的 RAG 流程。

所以,学了 Agentic RAG 的各种策略并不是说都得用上,具体还是得根据业务场景来设计方案。

总结

传统的 RAG 流程很固定:用户问题向量化 → 相似度检索 → prompt 拼接 → 生成回答。但它有一系列的问题:

  • 简单常识问题也走向量检索,造成资源浪费
  • 缺乏检索结果的评估与纠错机制,无法判断信息是否准确充足
  • 无法处理需多步检索的链式推理问题
  • 纯语义检索对专业术语、精确实体匹配不准
  • 无联网补充能力,知识库缺失信息时易编造答案

解决方案就是 Agentic RAG。Agentic RAG 是由大模型作为决策中枢,自主控制检索方式、评估检索效果、判断是否需要补充检索或发起网络搜索,形成自主思考与迭代优化的闭环检索系统。我们基于 LangGraph 的图,实现了这个闭环的决策循环,用多 Agent 架构实现了 Agentic RAG。比如加入了意图识别路由、多跳检索的循环、效果评估和网络搜索(ElasticSearch 的关键词检索后面再学)。

当然,具体的 Agentic RAG 还是要根据业务场景来设计,不是完全照搬,比如我们公司项目就是简化版相对固定的检索流程。主要是理解什么是 Agentic RAG,如何基于 LangGraph 实现这个决策循环,然后针对传统 RAG 的不同问题怎么解决就可以了。

代码上传了课程仓库:https://github.com/QuarkGluonPlasma/ai-agent-course-code

延伸思考(评论区精选)

这一节的评论区有不少值得注意的点,整理如下:

  • 这算不算"多 Agent"? 有读者指出,上面四个例子本质还是单 Agent——只有一个共享的 LLM 实例被反复调用,所谓"路由、评估、生成、联网搜索"都是普通节点函数(routeQuestionNodeevaluateNodewebSearchNodegenerateNode),是同一套流程里的不同步骤,而不是彼此独立的 Agent。"Agentic" 的意思是大模型参与决策、自己思考怎么检索,能 agentic 的关键是有这些判断逻辑,而不是节点数量。
  • 为什么不用 tools 封装、让单 Agent 自主决策调用? 可以封装成 tools,但 LangGraph 的循环机制还是必要的——tool 调用和现在的路由逻辑差不多,图编排成固定 SOP 的优势是流程可控、可调试、可断点。
  • 评估节点的二次评估是不是没用? 代码里 evaluateNode 的二次评估(搜过一次 web 后再评估)确实只是输出了结果,web_queryhasWeb 为 true 时被去掉了,相当于"搜过一次就绝不再搜"。作者回应:这个节点是复用的,enough 不只是评估 web 搜索的信息,还会评估向量检索的信息是否够;跑通流程后还会优化,主要是理解 agentic rag 的闭环。想做成真正循环 web query 的读者,可以改成每次累加查询结果、每次重新生成查询句。
  • 能不能用 PostgreSQL 代替 Milvus 和 Elasticsearch? 大部分中小企业的项目用 PostgreSQL(配合 pgvector 扩展)应该是足够的。
  • 生产建议:示例里个别问题证据充足时仍多调一次大模型(多余的 token 开销),以及多跳推理时模型可能"根据常识而非检索片段"给出了偏离的答案——这些都是 prompt 调优和工程化时要解决的问题,先跑通流程再打磨。

预览到此为止,输入密码解锁全文

解锁后本机会记住,同密码的其他文章也无需重复输入

基于 VitePress 构建 · 专注前端与 AI 实战