详情

首页手游攻略 Agentic RAG 入门:由固定检索到自主决策

Agentic RAG 入门:由固定检索到自主决策

佚名 2026-07-21 08:20:08

普通 RAG 的思路很直接:用户提出问题,系统从知识库检索相关片段,再让大模型根据这些片段生成回答。

这条链路解决了一个重要问题:模型不必只依赖训练时记住的知识,而是可以在回答前读取企业文档、产品手册、业务数据等外部资料,再根据资料组织答案。

但传统 RAG 的执行流程通常是固定的。这里的“固定”,不是说每次召回的文档都相同,而是说无论用户问什么、检索结果质量如何,程序都会按照上图展示的同一条路径执行。它不会在运行过程中重新判断,也不会主动改变检索策略。

但是,这种固定流程会遇到几个实际问题:

  • 不需要检索的问题也会进入知识库。 简单常识、计算或闲聊同样执行向量化和检索,增加了延迟和调用成本。
  • 一次检索失败后不会补救。 如果查询表达不准确或没有命中关键文档,流程仍然会直接进入生成,不会改写问题后重新检索。
  • 一个查询难以覆盖复杂问题。 当问题包含多个实体、关系或前后依赖的事实时,一次语义检索往往只能覆盖其中一部分。
  • 检索相关不等于资料足够。 召回的片段可能与问题有关,却没有包含得出最终结论所需的关键信息。
  • 本地知识库之外没有补充来源。 如果答案需要最新信息或外部资料,固定 RAG 不会主动切换到网络搜索等其他来源。

这些问题不能只靠增加 Top K 解决。Top K 只能多返回几条相似内容,无法改变固定的执行流程。真正需要改变的是:让系统先判断当前情况,再决定下一步。

  • 检索前,判断问题是否需要外部资料。
  • 问题复杂时,判断是否需要拆成多个子问题。
  • 检索后,判断现有资料是否足够。
  • 资料不足时,决定继续检索还是切换信息来源。

这些判断涉及对问题和文本语义的理解,很难只靠固定的 if/else 完成,却正是大模型擅长的事情。因此,可以让大模型负责判断和规划,再由程序负责执行节点、限制次数并保证流程结束。

当大模型不再只负责生成答案,还参与决定是否检索、怎样检索和何时停止时,这种架构就叫 Agentic RAG

普通 RAG 让模型读取资料;Agentic RAG 还让模型参与管理寻找资料的过程。

接下来,会通过四种逐步升级的 RAG 流程来理解这个过程的变化。

LangGraph:把判断变成可执行流程

本文使用 LangGraph 组织 Agentic RAG 的执行流程,但不再展开讲解 LangGraph 的基础用法。

这里只需要知道:LangGraph 把一个复杂流程表示成一张图。

  • State 保存问题、检索结果和最终答案等运行数据。
  • Node 表示路由、检索、评估和生成等具体步骤。
  • Edge 连接两个节点,规定执行顺序。
  • Conditional Edge 根据 State 选择不同分支,也可以让流程回到前面的节点形成循环。

后面看到流程图时,可以把它简单理解为:数据保存在 State 中,依次经过多个 Node,再由 Edge 决定下一步走向哪里。

如果还不熟悉 LangGraph,可以先阅读前面的文章:LangGraph 多 Agent 入门:从流程图到旅行规划助手。

基础检索:先跑通检索与回答

先从最容易理解的流程开始:每个问题都先检索,再生成答案。

先定义这条流程需要传递的数据:

 复制代码const GraphState = Annotation.Root({
  question: Annotation,  // 用户问题
  k: Annotation,         // 检索数量
  documents: Annotation, // 召回的文档
  generation: Annotation,// 最终答案
});

检索:从知识库找到相关片段

根据用户的问题去向量数据库中查询相关数据。

 复制代码/** 根据用户问题检索知识库,并把召回结果写入 Graph State。 */
const retrieveNode = async (state) => {
  // 直接查询向量数据库,返回 [Document, score] 数组。
  const results = await vectorStore.similaritySearchWithScore(
    state.question,
    state.k,
  );  // 只保留后续节点需要的字段。
  const documents = results.map(([document, score]) => ({
    score,
    content: document.pageContent,
    id: document.metadata.id,
    chapter_num: document.metadata.chapter_num,
  }));  // 检索节点不负责回答,只把证据写入状态。
  return { documents };
};

Document.pageContent 是正文,Document.metadata 保存片段 ID、章节等附加信息。

检索返回的每条数据可以整理成统一结构:

 复制代码{
  score: 0.86,                  // 相似度分数
  content: "召回的小说原文",      // 后续交给模型的正文
  id: "1_000125",              // 文档片段唯一标识
  chapter_num: 12,             // 章节信息
}

检索节点只负责“找证据”,不要在这里顺手让模型回答。把检索和生成分开后,我们才能单独检查:到底是没有召回正确资料,还是模型拿到正确资料后回答错了。

生成:让模型只依据检索片段回答

 复制代码/** 把召回片段整理成上下文,再调用模型生成最终答案。 */
const generateNode = async (state) => {
  // 没有任何证据时不继续生成,避免模型脱离资料自由发挥。
  if (state.documents.length === 0) {
    return { generation: "" };
  }  // 核心逻辑:给每个片段加上序号和章节,方便模型区分证据。
  const context = state.documents
    .map((doc, index) => `
[片段 ${index + 1}]
章节:第 ${doc.chapter_num} 章
内容:${doc.content}`)
    .join("nn");  // 直接调用模型 API,不再封装额外函数。
  const response = await model.invoke(`
请只根据下面的小说片段回答问题。${context}用户问题:${state.question}
`);  // 只返回本节点更新的字段,LangGraph 会把它合并回 State。
  return { generation: String(response.content) };
};

这里最重要的不是 prompt 写得多华丽,而是明确证据边界:模型只能依据检索片段作答;资料不足时必须说明无法确认。

用 LangGraph 串起检索与生成

 复制代码const graph = new StateGraph(GraphState)
  // 注册两个节点。
  .addNode("retrieve", retrieveNode)
  .addNode("generate", generateNode)  // 定义固定执行顺序。
  .addEdge(START, "retrieve")
  .addEdge("retrieve", "generate")
  .addEdge("generate", END)
  .compile();

基础检索的优点是简单、可预测、容易调试。对于“用户一定是在查询知识库”的场景,它可能已经够用。

但如果系统同时接收普通问题和知识库问题,固定检索就显得浪费。下一步需要让流程先判断“这次到底要不要查”。

查询路由:不是所有问题都需要检索

查询路由在基础检索前增加一个决策节点:

这里的 simplecomplex 不是在判断语言难度,而是在判断是否需要外部知识:

  • simple:普通常识、定义、简单计算,不依赖特定资料。
  • complex:需要小说情节、人物关系、原文细节或其他知识库证据。

让路由结果可以被程序读取

如果让模型自由回答,它可能输出“这个问题比较复杂,我建议检索”。程序很难稳定解析这句话。因此先用 schema 约束模型输出:

 复制代码const GraphState = Annotation.Root({
  question: Annotation,
  k: Annotation,
  strategy: Annotation,    // simple 或 complex
  routeReason: Annotation, // 路由原因
  documents: Annotation,
  generation: Annotation,
});const RouteSchema = z.object({
  // strategy 只能是 simple 或 complex,不能生成其他值。
  strategy: z.enum(["simple", "complex"]),  // reason 用于日志和调试,不直接控制流程。
  reason: z.string(),
});

路由节点只判断,不检索:

 复制代码/** 判断当前问题是否需要查询知识库,不在这里执行检索。 */
const routeQuestionNode = async (state) => {
  const router = model.withStructuredOutput(RouteSchema);  // 核心逻辑:让模型只返回 simple 或 complex,供条件边直接使用。
  const route = await router.invoke(`
你是问答路由器,请判断用户问题是否需要查询小说知识库。- simple:不依赖小说原文即可回答。
- complex:需要具体情节、人物关系或原文证据。用户问题:${state.question}
`);  return {
    strategy: route.strategy,
    routeReason: route.reason,
  };
};

结构化输出解决的是“程序怎样稳定读取模型判断”,它并不保证模型的判断永远正确。因此 reason 很重要:调试时可以看到模型为什么把问题分到某条路径。

可以看看:

根据路由结果选择回答路径

两条分支的职责很明确。简单问题直接调用模型,复杂问题复用前面已经实现的检索和生成节点:

 复制代码/** direct_answer 节点:处理不依赖知识库的普通问题。 */
const directAnswerNode = async (state) => {
  const response = await model.invoke(`
请直接、简洁地回答下面的问题:
${state.question}
`);  return { generation: String(response.content) };
};// retrieve 节点:complex 分支仍然使用“检索 -> 生成”的 RAG 节点(复用前面的函数)。
const ragGenerateNode = generateNode;

然后用条件边选择其中一条路径:

 复制代码/** 把路由结果转换成下一条要执行的边。 */
function decideNext(state) {
  // 条件函数只返回下一条边的名字。
  // 核心逻辑:simple 直接回答,complex 进入知识库检索。
  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();

查询路由是 Agentic RAG 的第一步:模型不再只负责生成文字,它开始影响程序接下来执行什么。

但复杂分支仍然只检索一次。如果问题依赖多个前后关联的事实,一次向量查询还是可能不够。

分步检索:拆开复杂问题,逐个寻找答案

先来考虑这个问题:

 复制代码云澈为什么会同时拥有云澈和萧澈两个名字,
他重生前后分别是什么身份?

它至少包含几个相互关联的信息点:

  • 云澈重生前是谁。
  • 萧澈是谁。
  • 重生事件发生了什么。
  • 两个名字为什么属于同一个人。

如果把整句话只做一次向量检索,Top K 结果可能全部集中在其中一个事实上。增加 Top K 只能“多取一些相似片段”,不能保证每个事实都被覆盖。

分步检索也就是常说的 Multi-hop RAG(多跳 RAG):先把复杂问题拆成有顺序的子问题,再逐条检索。

为多轮检索记录执行进度(定义 state)

 复制代码const GraphState = Annotation.Root({
  question: Annotation,
  k: Annotation,
  strategy: Annotation,  // 预先拆好的有序子问题。
  subQuestions: Annotation,  // 下一轮应该检索哪一个子问题。
  nextSubIdx: Annotation,  // 多轮检索累计得到的文档。
  documents: Annotation,  // 已执行的检索次数与最大次数。
  retrievalCount: Annotation,
  maxRetrievals: Annotation,  // 规划节点的决定:retrieve 或 generate。
  plannedNext: Annotation,  generation: Annotation,
});

nextSubIdx 是循环的游标。假设拆出三个子问题:

 复制代码subQuestions = ["问题 A", "问题 B", "问题 C"];
nextSubIdx = 0;

第一轮检索 A,结束后把 nextSubIdx 改成 1;第二轮就会检索 B。

把原问题拆成可独立检索的子问题

 复制代码const DecomposeSchema = z.object({
  sub_questions: z.array(z.string()).min(1).max(8),
  reason: z.string(),
});/** 把复杂问题一次性拆成有先后顺序的独立子问题。 */
const decomposeQuestionNode = async (state) => {
  const decomposer = model.withStructuredOutput(DecomposeSchema);  const result = await decomposer.invoke(`
把用户问题拆成有序子问题,用于逐条向量检索。要求:
1. 每条都是可以独立检索的完整问句。
2. 不使用“他、她、此人”等依赖上文的代词。
3. 顺序符合事实依赖:先查前置事实,再查后续结论。
4. 输出 1~8 条,不要拆成零散关键词。用户问题:${state.question}
`);  // 核心逻辑:清理空白结果,并从第一条子问题开始检索。
  const subQuestions = result.sub_questions
    .map((question) => question.trim())
    .filter(Boolean);  return {
    subQuestions,
    nextSubIdx: 0,
  };
};

为什么不允许“他是谁”“此人后来怎样”这样的子问题?因为向量数据库只会看到当前查询,不知道上一条子问题的上下文。独立、明确的查询更容易召回正确片段。

可以看看效果,LLM 把原问题拆分成了五个问题:

那么接下来就是对每个子问题,进行检索,对检索出来的资料进行汇集咯。

每轮检索一个子问题并累计证据

 复制代码/** 每次只检索一条子问题,并累计本轮找到的证据。 */
const retrieveNode = async (state) => {
  // 核心逻辑:nextSubIdx 是循环游标,决定这一轮查询哪条子问题。
  const index = state.nextSubIdx;
  const query = state.subQuestions[index];  if (!query) {
    throw new Error(`不存在下标为 ${index} 的子问题`);
  }  const results = await vectorStore.similaritySearchWithScore(
    query,
    state.k,
  );  const newDocuments = results.map(([document, score]) => ({
    score,
    content: document.pageContent,
    id: document.metadata.id,
    chapter_num: document.metadata.chapter_num,
  }));  // 多个子问题可能召回同一个片段,因此按 id 合并去重。
  const documents = mergeUniqueById(
    state.documents,
    newDocuments,
  );  return {
    documents,
    retrievalCount: state.retrievalCount + 1,
    nextSubIdx: index + 1,
  };
};

去重不能只用正文字符串。更稳定的方法是使用入库时生成的唯一 id;如果同一片段被多次召回,可以保留相似度更高的那一次。

 复制代码/** 合并多轮召回结果;相同文档只保留分数更高的一条。 */
function mergeUniqueById(existingDocuments, newDocuments) {
  const documentMap = new Map();  for (const document of [...existingDocuments, ...newDocuments]) {
    const previous = documentMap.get(document.id);    // 核心逻辑:文档第一次出现,或本轮分数更高时才覆盖旧值。
    if (!previous || document.score > previous.score) {
      documentMap.set(document.id, document);
    }
  }  return [...documentMap.values()]
    .sort((a, b) => b.score - a.score);
}

每轮检索后判断是否继续

当然检索完成不一定要把剩余子问题全部查完。如果第一轮已经拿到了完整证据,可以提前生成答案。

 复制代码const NextStepSchema = z.object({
  nextAction: z.enum(["retrieve", "generate"]),
  reason: z.string(),
});/** 根据当前证据和剩余子问题,决定继续检索还是开始回答。 */
const planNextStepNode = async (state) => {
  const remaining =
    state.subQuestions.length - state.nextSubIdx;  // 只取少量摘要,避免规划 prompt 过长。
  const documentSummary = state.documents
    .slice(0, 6)
    .map((document) => document.content.slice(0, 200))
    .join("nn");  const planner = model.withStructuredOutput(NextStepSchema);  const decision = await planner.invoke(`
请根据原问题、已检索文档和剩余子问题判断下一步。- 证据足以回答原问题:generate
- 仍缺关键事实且还有子问题:retrieve原问题:${state.question}
剩余子问题数量:${remaining}
已检索轮数:${state.retrievalCount}
最大检索轮数:${state.maxRetrievals}
已召回文档:${documentSummary}
`);  let plannedNext = decision.nextAction;  // 代码规则覆盖模型决定,保证流程一定能够结束。
  if (remaining <= 0) plannedNext = "generate";
  if (state.retrievalCount >= state.maxRetrievals) {
    plannedNext = "generate";
  }  return { plannedNext };
};

这里体现了一个很重要的工程原则:

模型可以做判断,但程序必须保留确定性的安全边界。

如果完全听模型决定是否继续,它可能一直返回 retrieve。最大轮数和剩余子问题数量就是代码层面的终止保证。

用条件边形成检索循环

 复制代码/** 简单问题直接回答,复杂问题先拆成子问题。 */
function afterRoute(state) {
  return state.strategy === "simple"
    ? "direct_answer"
    : "decompose_question";
}/** 把规划节点的决定映射到“继续检索”或“生成答案”。 */
function afterPlan(state) {
  // 核心逻辑:返回 retrieve 时,条件边会让图重新进入检索节点。
  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();

plan_next_step 返回 retrieve 时,图重新回到检索节点,这就形成了 LangGraph 循环。

当前实现属于“预先拆解式多跳检索”:子问题在进入循环前一次性生成,循环中只决定继续还是停止。更高级的实现还可以根据上一轮证据临时改写下一条查询,但复杂度和失控风险也会增加。

联网补充:本地资料不足时再去网络查找

向量数据库只能检索已经入库的内容。小说正文可以回答人物和情节,却不一定包含首发平台、最新改编消息或可点击来源链接。

例如:

 复制代码《逆天邪神》中云澈拥有的第一部功法是什么?
另外,这部小说的作者和首发平台是什么?请给出来源链接。

这个问题同时需要两类资料:

  • 小说内部情节:适合查询本地知识库。
  • 作者、平台和来源链接:更适合网络搜索。

联网补充对应这里实现的 Web Fallback。它不是一上来就联网,而是先使用本地资料;只有评估器判断本地内容不够时,才触发搜索。

分开保存本地资料和网络资料(定义 state)

 复制代码const GraphState = Annotation.Root({
  question: Annotation,
  k: Annotation,
  strategy: Annotation,  // 本地向量数据库返回的原始文档。
  retrievedDocs: Annotation,  // 供模型阅读的本地文本上下文。
  localContext: Annotation,  // 网络搜索返回的标题、链接和摘要。
  webContext: Annotation,  // 评估器的判断结果。
  evaluation: Annotation,  generation: Annotation,
});

本地资料和网络资料必须分开保存。生成答案时可以把它们合并,但调试时需要知道每条事实来自哪里。

优先查询本地知识库

 复制代码/** 优先从本地向量数据库检索,并整理出可读的文本上下文。 */
const retrieveLocalNode = async (state) => {
  const results = await vectorStore.similaritySearchWithScore(
    state.question,
    state.k,
  );  const retrievedDocs = results.map(([document, score]) => ({
    score,
    content: document.pageContent,
    id: document.metadata.id,
  }));  return {
    retrievedDocs,    // 将结构化文档整理成评估器和生成器可读的文本。
    localContext: retrievedDocs
      .map((document) => document.content)
      .join("nn"),
  };
};

这里采用的是直接使用完整问题检索一次(也可以采用分步检索)。但复合问题中的“作者、平台、链接”等词可能干扰小说情节的向量召回。实际项目可以再增加查询改写,把本地问题和联网问题分开。

判断本地资料是否足够

 复制代码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 hasWebContext = Boolean(state.webContext?.trim());
  const evaluator = model.withStructuredOutput(EvaluateSchema);  const evaluation = await evaluator.invoke(`
判断当前上下文是否足以完整回答用户问题。用户问题:${state.question}本地知识库:
${state.localContext || "(空)"}${hasWebContext
  ? `网络搜索结果:n${state.webContext}`
  : ""}如果资料不足,请列出 missing;第一次评估时还要生成 web_query。
`);  return { evaluation };
};

与“检索到文档数量大于零”相比,让模型评估内容是否充分更接近真实需求。命中八条高度相似的片段,也可能全部在讲同一件事,依然无法覆盖完整问题。

根据缺失信息生成联网查询

这里采用的是 博查,地址是 open.bochaai.com/

进入控制台,注册 key 和购买资源包,有免费的 1000 次调用。

具体使用开发文档,可以看看官网哈,下面就直接列出代码了。

 复制代码/** 使用评估器给出的查询词调用搜索 API,并保存可引用的结果。 */
const webSearchNode = async (state) => {
  // 优先使用评估器生成的精准查询;没有时退回原问题。
  const query =
    state.evaluation.web_query?.trim() || state.question;  // 直接调用搜索 API。
  const response = await fetch("https://api.bochaai.com/v1/web-search", {
    method: "POST",
    headers: {
	  // 使用博查注册的key
      Authorization: `Bearer ${process.env.BOCHA_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      query,
      count: 8,
      summary: true,
      freshness: "noLimit",
    }),
  });  if (!response.ok) {
    throw new Error(`网络搜索失败:${response.status}`);
  }  const result = await response.json();
  const pages = result.data?.webPages?.value ?? [];  // 核心逻辑:同时保存标题、URL 和摘要,方便最终答案标注来源。
  const webContext = pages
    .map((page, index) => `
引用:${index + 1}
标题:${page.name}
URL:${page.url}
摘要:${page.summary}`)
    .join("nn");  return { webContext };
};

联网结果不应该只保留摘要。至少还要保留标题和 URL,最终回答才能提供可核对的来源。

如果是生产环境还需要考虑:

  • 过滤低质量或重复域名。
  • 区分官方来源、媒体报道和用户内容。
  • 防止网页内容中的提示注入影响模型。
  • 设置超时、重试和搜索次数上限。
  • 对需要时效性的内容保留发布时间。

合并本地与网络资料生成答案

 复制代码/** 合并本地和网络上下文,再让模型生成带来源的答案。 */
const generateNode = async (state) => {
  // 核心逻辑:只合并实际存在的资料,避免 prompt 出现无意义空段落。
  const context = [
    state.localContext,
    state.webContext,
  ]
    .filter(Boolean)
    .join("nn===== 网络补充 =====nn");  const response = await model.invoke(`
请优先依据给定上下文回答,不要编造。${context || "(没有可用上下文)"}用户问题:${state.question}要求:
1. 对网络信息保留引用编号和 URL。
2. 资料不足时明确说明无法确认,并指出缺失内容。
`);  return { generation: String(response.content) };
};

这里的模型不是事实来源,而是资料整理者。事实来自本地知识库和搜索结果;模型负责把多种来源组织成连贯答案。

只在本地不足时触发搜索

 复制代码/** 简单问题直接回答,其余问题先查本地知识库。 */
function afterRoute(state) {
  return state.strategy === "simple"
    ? "direct_answer"
    : "local_retrieve";
}/** 根据资料评估结果选择生成答案或联网搜索。 */
function afterEvaluation(state) {
  // 已经搜索过一次,就进入生成,避免无限联网循环。
  if (state.webContext?.trim()) {
    return "generate";
  }  return state.evaluation.enough
    ? "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", afterEvaluation, {
    generate: "generate",
    web_search: "web_search",
  })
  .addEdge("web_search", "evaluate_local")
  .addEdge("direct_answer", END)
  .addEdge("generate", END)
  .compile();

注意,联网后会再次进入评估节点,但只要 webContext 已经存在,当前条件函数就固定进入生成。第二次评估可以记录“资料仍然缺什么”,却不会再次触发搜索。

这是一个有意设置的边界:避免模型因为总觉得资料不足而无限搜索。更严格的实现可以把二次评估的 missing 也放进生成 prompt,让最终答案准确说明哪些内容仍未确认。

总结

回顾上文,其实就是一直在改造同一条 RAG 链路:

  • 基础检索:收到问题后,固定查询一次知识库。
  • 查询路由:先判断是否需要检索,普通问题可以直接回答。
  • 分步检索:把复杂问题拆成多个子问题,再逐个查找资料。
  • 联网补充:本地资料不够时,再去网络补充信息。

随着流程一步步升级,大模型开始参与检索决策:判断要不要查、怎样拆解问题、当前资料够不够,以及是否需要继续检索或换一个资料来源。程序则负责保存状态、控制分支、限制次数,并保证流程能够停下来。

可以把 Agentic RAG 理解成一个会根据检索结果继续思考和调整的闭环:模型负责判断,程序负责控制;资料不够就继续找,方向不对就调整,满足条件后再生成答案。 LangGraph 的作用,就是把这个过程连接成一张可以执行、可以循环、也可以结束的图。

点击查看更多
推荐专题
热门阅读