别再用「死板 RAG」!手把手实现会思考、会纠错、会联网的 Agentic RAG(LangGraph 实战)

文章来源声明: 原文作者:Darling噜啦啦; 来源站点:掘金; 原文链接:https://juejin.cn/post/7686407926581903375; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

路由、拆解、评估、兜底四招搭配防死循环护栏,把 Naive RAG 升级成可控的智能体闭环,适合要做多跳推理与联网补全的生产级问答系统开发者借鉴。

别再用「死板 RAG」!手把手实现会思考、会纠错、会联网的 Agentic RAG(LangGraph 实战) -------------------------------------------------------

一句话导读:上一篇文章我们把《天龙八部》灌进了向量库,跑通了一个 Naive RAG。但它什么问题都硬走检索、没有判断、不会多步推理、也不会联网补知识。这篇我们把 RAG 升级成「会思考的 Agent」:问题路由、多跳检索、上下文评估、联网兜底,四招在手,跑出一个真正的 Agentic RAG 闭环。

为什么 Naive RAG 不够用了?

先把朴素 RAG 的短板摊开看。它的流水线固定是 检索 → 生成所有问题一视同仁,于是暴露出一堆问题:

需求场景Naive RAG 的表现问题本质
用户问 `1+1=?` 这种常识也傻乎乎地走一遍向量检索简单问题白白浪费 token 和时间
检索回的片段对不对、够不够不校验就直接喂给模型没有评估/纠错机制
「四大恶人第二的是谁?他儿子的生父公开身份是什么?」一次性检索,拆不出因果链处理不了需要多步检索的复杂问题
问「高血糖」这种专有术语纯语义检索可能捞回「低血糖」实体/术语更适合关键词精准匹配
本地知识库没有的东西硬编或干脆不答不会主动联网补充

这些单点问题的本质是同一个:流水线太死板了,缺一个「大脑」来做判断。

Agentic RAG(智能体化 RAG) 要做的,就是把这条方程式的控制权交给 LLM + 图编排:让它自主决定要不要检索、用什么检索、信息够不够、要不要重新检索。LangGraph 就是承载这套「思考流程」的最佳载体——节点是动作,边是流向,State 是共享的海绵板。

LLM 的 withStructuredOutput(结构化输出)会是本篇反复用到的魔术,后面每招都会遇到。

第一招:装一个「问题路由器」——简单 / 复杂分道扬镳

第一个优化:别让简单问题也硬走检索。

先用 LLM 判断问题是 simple 还是 complex,然后走不同的分支:

  • simple(常识问答、简短定义)→ 直接让模型作答,不进检索;
  • complex(需要具体情节、事实、原文证据)→ 才进入检索链路。
<span>import</span> <span>"dotenv/config"</span>;
<span>import</span> { <span>ChatOpenAI</span>, <span>OpenAIEmbeddings</span> } <span>from</span> <span>"@langchain/openai"</span>;
<span>import</span> { <span>Annotation</span>, <span>END</span>, <span>START</span>, <span>StateGraph</span> } <span>from</span> <span>'@langchain/langgraph'</span>;
<span>import</span> { <span>Milvus</span> } <span>from</span> <span>'@langchain/community/vectorstores/milvus'</span>;
<span>import</span> { z } <span>from</span> <span>'zod'</span>;

<span>const</span> <span>GraphState</span> = <span>Annotation</span>.<span>Root</span>({
  <span>question</span>: <span>Annotation</span>,
  <span>k</span>: <span>Annotation</span>,
  <span>strategy</span>: <span>Annotation</span>,   <span>// simple | complex</span>
  <span>routeReason</span>: <span>Annotation</span>,
  <span>documents</span>: <span>Annotation</span>,
  <span>generation</span>: <span>Annotation</span>
});

<span>const</span> model = <span>new</span> <span>ChatOpenAI</span>({
  <span>model</span>: process.<span>env</span>.<span>MODEL_NAME</span>,
  <span>temperature</span>: <span>0</span>,
  <span>configuration</span>: { <span>baseURL</span>: process.<span>env</span>.<span>OPENAI_BASE_URL</span> },
  <span>apiKey</span>: process.<span>env</span>.<span>OPENAI_API_KEY</span>
});
<span>const</span> embeddings = <span>new</span> <span>OpenAIEmbeddings</span>({
  <span>model</span>: <span>"text-embedding-v3"</span>,
  <span>dimensions</span>: <span>1024</span>
});

<span>let</span> vectorStore;

路由节点:让 LLM 决定走哪条路

<span>// 用 zod 声明一个结构化的输出契约(枚举 + 理由)</span>
<span>const</span> <span>RouteSchema</span> = z.<span>object</span>({
  <span>strategy</span>: z.<span>enum</span>([<span>"simple"</span>, <span>"complex"</span>]),
  <span>reason</span>: z.<span>string</span>()
});

<span>const</span> <span>routeQuestionNode</span> = <span>async</span> (<span>state</span>) => {
  <span>console</span>.<span>log</span>(<span>'___ROUTE-QUESTION___'</span>);
  <span>// withStructuredOutput:逼着模型严格按 schema 输出 JSON</span>
  <span>const</span> router = model.<span>withStructuredOutput</span>(<span>RouteSchema</span>);
  <span>const</span> route = <span>await</span> router.<span>invoke</span>(<span>`
你是问答路由器,请判断用户问题是否需要外部检索。
规则:
- simple: 常识问答、简短定义、无需特定小说细节即可回答。
- complex: 需要《天龙八部》具体情节、人物关系、章节事实、原文细节或证据支持。

用户问题:<span>${state.question}</span>
`</span>);
  <span>console</span>.<span>log</span>(<span>`路由策略:<span>${route.strategy}</span> (<span>${route.reason}</span>)`</span>);
  <span>return</span> {
    <span>question</span>: state.<span>question</span>,
    <span>k</span>: state.<span>k</span>,
    <span>strategy</span>: route.<span>strategy</span>,
    <span>routeReason</span>: route.<span>reason</span>
  };
};

withStructuredOutput 的意义在于:路由结果必须是 simple / complex 这种可被代码枚举判断的字符串,而不是模型胡诌的一段散文。这样下游才能用字符串相等来做分支。

直接回答 + 检索 + 生成节点

<span>const</span> <span>directAnswerNode</span> = <span>async</span> (<span>state</span>) => {
  <span>console</span>.<span>log</span>(<span>'----DIRECT_ANSWER----'</span>);
  <span>let</span> generation = <span>""</span>;
  <span>const</span> stream = <span>await</span> model.<span>stream</span>(
    <span>`你是一个中文回答助手,请直接简洁回答问题。
问题:<span>${state.question}</span>`</span>
  );
  <span>for</span> <span>await</span> (<span>const</span> chunk <span>of</span> stream) {
    <span>const</span> text = <span>typeof</span> chunk.<span>content</span> === <span>'string'</span> ? chunk.<span>content</span> : <span>""</span>;
    <span>if</span> (!text) <span>continue</span>;
    generation += text;
    process.<span>stdout</span>.<span>write</span>(text);
  }
  <span>return</span> { <span>question</span>: state.<span>question</span>, <span>k</span>: state.<span>k</span>, <span>strategy</span>: state.<span>strategy</span>,
           <span>routeReason</span>: state.<span>routeReason</span>, <span>documents</span>: [], generation };
};

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

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

<span>const</span> <span>generateNode</span> = <span>async</span> (<span>state</span>) => {
  <span>const</span> context = state.<span>documents</span>
    .<span>map</span>(<span>(<span>item, i</span>) =></span>
      <span>`[片段 <span>${i+<span>1</span>}</span>]
章节: 第 <span>${item.chapter_num}</span>章
内容:<span>${item.content}</span>`</span>)
    .<span>join</span>(<span>"\n\n----------\n\n"</span>);
  <span>let</span> generation = <span>""</span>;
  <span>const</span> stream = <span>await</span> model.<span>stream</span>(<span>`
你是一个专业的《天龙八部》小说助手,基于小说片段回答问题。
<span>${context}</span>
用户问题:<span>${state.question}</span>
回答要求:有信息就详细准确回答;可综合多片段;没有就如实告知;可引用原文。
AI 助手的回答:`</span>);
  <span>for</span> <span>await</span> (<span>const</span> chunk <span>of</span> stream) {
    <span>const</span> text = <span>typeof</span> chunk.<span>content</span> === <span>"string"</span> ? chunk.<span>content</span> : <span>""</span>;
    <span>if</span> (!text) <span>continue</span>;
    generation += text; process.<span>stdout</span>.<span>write</span>(text);
  }
  <span>return</span> { <span>question</span>: state.<span>question</span>, <span>k</span>: state.<span>k</span>, <span>documents</span>: state.<span>documents</span>, generation };
};

用条件边把分支接起来

关键来了:LangGraph 的 条件边(conditional edges) 能根据 State 内容动态决定下一步。

<span>const</span> <span>decideNext</span> = (<span>state</span>) =>
  state.<span>strategy</span> === <span>'simple'</span> ? <span>"direct_answer"</span> : <span>"retrieve"</span>;

<span>const</span> graph = <span>new</span> <span>StateGraph</span>(<span>GraphState</span>)
  .<span>addNode</span>(<span>"route_question"</span>, routeQuestionNode)
  .<span>addNode</span>(<span>"direct_answer"</span>, directAnswerNode)
  .<span>addNode</span>(<span>"retrieve"</span>, retrieveNode)
  .<span>addNode</span>(<span>"rag_generate"</span>, generateNode)
  .<span>addEdge</span>(<span>START</span>, <span>"route_question"</span>)
  .<span>addConditionalEdges</span>(<span>"route_question"</span>, decideNext, {
    <span>direct_answer</span>: <span>"direct_answer"</span>,  <span>// simple -> 直接答</span>
    <span>retrieve</span>: <span>"retrieve"</span>              <span>// complex -> 进检索</span>
  })
  .<span>addEdge</span>(<span>"retrieve"</span>, <span>"rag_generate"</span>)
  .<span>addEdge</span>(<span>"direct_answer"</span>, <span>END</span>)
  .<span>addEdge</span>(<span>"rag_generate"</span>, <span>END</span>)
  .<span>compile</span>();

现在问 1+1=? 会走直答分支,只有复杂问题才去检索。每一块钱的 token 都花在刀刃上。

第二招:多跳检索(Multi-Hop)——把复杂问题拆成子问题逐个攻破

路由解决了「要不要检索」,但没解决「检索一次够不够」。

有些问题天生需要多步推理,例如:

《天龙八部》中「四大恶人」排行第二的是谁?此人之子在身世揭晓前,其生父在武林中的公开身份是什么?

要答这道题,必须查出「四大恶人第二 = 叶二娘」,查「叶二娘的儿子(虚竹)的生父,其武林中的公开身份(玄慈方丈)」。一次性向量化整个长问题,匹配必然发散(这就是为什么这个 repo 注释里感叹「直接把 query 向量化匹配不够准确」)。

解决思路:子问题拆解(Decompose)。让 LLM 把大问题拆成一串有序、可独立检索的子问题,然后循环执行 检索一个子问题 → 汇总去重 → 判断够不够 → 继续下一个

要在上面那套 GraphState 上做多跳,得先给状态补充几个新字段:子问题列表、当前进度、累计检索次数与预算、以及「规划器」的裁决结果。

<span>const</span> <span>GraphState</span> = <span>Annotation</span>.<span>Root</span>({
  <span>question</span>: <span>Annotation</span>,
  <span>k</span>: <span>Annotation</span>,
  <span>strategy</span>: <span>Annotation</span>,
  <span>routeReason</span>: <span>Annotation</span>,
  <span>subQuestions</span>: <span>Annotation</span>,  <span>// 拆解出的有序子问题列表</span>
  <span>nextSubIdx</span>: <span>Annotation</span>,    <span>// 下一个要检索的子问题下标(做循环推进)</span>
  <span>currentQuery</span>: <span>Annotation</span>,  <span>// 当前这一轮正在检索的子问题</span>
  <span>retrievalCount</span>: <span>Annotation</span>,<span>// 已检索轮数</span>
  <span>maxRetrievals</span>: <span>Annotation</span>, <span>// 最大检索轮数(预算/护栏)</span>
  <span>plannedNext</span>: <span>Annotation</span>,   <span>// 规划器裁决:下一步 retrieve | generate</span>
  <span>documents</span>: <span>Annotation</span>,
  <span>generation</span>: <span>Annotation</span>
});

问题拆解器

<span>const</span> <span>DecomposeSchema</span> = z.<span>object</span>({
  <span>sub_questions</span>: z.<span>array</span>(z.<span>string</span>()).<span>min</span>(<span>1</span>).<span>max</span>(<span>8</span>),
  <span>reason</span>: z.<span>string</span>()
});

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

循环检索 + 去重

多轮检索容易重复捞回同一片段,既浪费又可能让模型产生「重复 = 强调」的错觉。用 Map 按文档 id 去重,留相似度更高版本。

<span>const</span> <span>mergeUnique</span> = (<span>existing, fresh</span>) => {
  <span>const</span> map = <span>new</span> <span>Map</span>(); <span>// ES6 的 HashMap,key: value</span>
  <span>for</span> (<span>const</span> d <span>of</span> [...existing, ...fresh]) {
    <span>const</span> key = <span>String</span>(d.<span>id</span>);
    <span>const</span> prev = map.<span>get</span>(key);
    <span>if</span> (!prev || <span>Number</span>(d.<span>score</span>) > <span>Number</span>(prev.<span>score</span>)) map.<span>set</span>(key, d);
  }
  <span>return</span> <span>Array</span>.<span>from</span>(map.<span>values</span>()).<span>sort</span>(<span>(<span>a, b</span>) =></span> <span>Number</span>(b.<span>score</span>) - <span>Number</span>(a.<span>score</span>));
};

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

  <span>const</span> round = state.<span>retrievalCount</span> + <span>1</span>;
  <span>console</span>.<span>log</span>(<span>`----第 <span>${round}</span> 轮,子问题 <span>${idx+<span>1</span>}</span>/<span>${subs.length}</span>:<span>${q}</span>----`</span>);
  <span>const</span> newDocs = <span>await</span> <span>retrieveRelevantContent</span>(q, state.<span>k</span>);
  <span>const</span> merged = <span>mergeUnique</span>(state.<span>documents</span> ?? [], newDocs);
  <span>console</span>.<span>log</span>(<span>`本轮命中 <span>${newDocs.length}</span> 条,累计去重后 <span>${merged.length}</span> 条`</span>);
  <span>return</span> {
    <span>documents</span>: merged,
    <span>retrievalCount</span>: round,
    <span>nextSubIdx</span>: idx + <span>1</span>,
    <span>currentQuery</span>: q
  };
};

规划器:判断「够了没」还是「继续搜」

每检索完一个子问题,让 LLM 看当前已召回的证据,决定下一步 retrieve(继续)还是 generate(收尾作答)。同时用 硬性规则护栏:子问题搜完、或轮数到达上限,必须强制 generate,防止死循环。

<span>const</span> <span>NextStepSchema</span> = z.<span>object</span>({
  <span>nextAction</span>: z.<span>enum</span>([<span>"retrieve"</span>, <span>"generate"</span>]),
  <span>reason</span>: z.<span>string</span>()
});

<span>const</span> <span>planNextStepNode</span> = <span>async</span> (<span>state</span>) => {
  <span>console</span>.<span>log</span>(<span>"---PLAN_NEXT_STEP---"</span>);
  <span>const</span> subs = state.<span>subQuestions</span> ?? [];
  <span>const</span> nextIdx = state.<span>nextSubIdx</span> ?? <span>0</span>;
  <span>const</span> remaining = subs.<span>length</span> - nextIdx;

  <span>const</span> subList = subs.<span>map</span>(<span>(<span>s, i</span>) =></span>
    <span>`<span>${i+<span>1</span>}</span>.<span>${s}</span> <span>${i < nextIdx ? <span>"已检索"</span> : i === nextIdx ? <span>"(下一轮将检索)"</span> : <span>"未检索"</span>}</span>`</span>).<span>join</span>(<span>"\n"</span>);
  <span>const</span> docStr = state.<span>documents</span>.<span>length</span> === <span>0</span>
    ? <span>"(尚无检索结果)"</span>
    : state.<span>documents</span>.<span>slice</span>(<span>0</span>, <span>6</span>)
        .<span>map</span>(<span>(<span>d, i</span>) =></span> <span>`[<span>${i+<span>1</span>}</span>] score=<span>${<span>Number</span>(d.score).toFixed(<span>4</span>)}</span> 第<span>${d.chapter_num}</span>章:<span>${d.content.slice(<span>0</span>, <span>200</span>)}</span>`</span>)
        .<span>join</span>(<span>"\n\n"</span>);

  <span>const</span> prompt = <span>`
你是多跳 RAG 规划器。检索查询已由前置步骤拆解为**有序子问题**,若需继续检索,下一轮将自动使用【下一条子问题】检索,你不要自拟新检索句。
用户原始问题:<span>${state.question}</span>
子问题序列:
<span>${subList || <span>"无"</span>}</span>
已检索轮次:<span>${state.retrievalCount}</span>;剩余未检索子问题:<span>${remaining}</span>;最大轮数:<span>${state.maxRetrievals}</span>
已召回文档摘要:
<span>${docStr}</span>
请判断下一步:
1)已有足够依据回答原问题 -> nextAction=generate
2)仍缺关键事实、且仍有未检索子问题、且未超过上限 -> nextAction=retrieve
硬性规则:剩余子问题为 0 必须 generate;已达/超最大轮数必须 generate。`</span>;

  <span>const</span> planModel = model.<span>withStructuredOutput</span>(<span>NextStepSchema</span>);
  <span>const</span> { nextAction, reason } = <span>await</span> planModel.<span>invoke</span>(prompt);

  <span>// 硬性护栏覆盖模型建议,防止死循环</span>
  <span>let</span> finalNext = nextAction;
  <span>if</span> (state.<span>retrievalCount</span> >= state.<span>maxRetrievals</span>) finalNext = <span>"generate"</span>;
  <span>if</span> (remaining <= <span>0</span>) finalNext = <span>"generate"</span>;
  <span>console</span>.<span>log</span>(<span>`[决策] plannedNext=<span>${finalNext}</span>(模型建议=<span>${nextAction}</span>)<span>${reason}</span>`</span>);
  <span>return</span> { <span>plannedNext</span>: finalNext };
};

串成「拆解 → 循环检索 → 规划 → 生成」的图

注意这里出现了一个回环retrieve -> plan_next_step -> retrieve(条件边)。这就是 Agentic 的标志——图允许「走一步、看一眼、决定要不要再走一步」。

<span>const</span> <span>afterRoute</span> = (<span>state</span>) =>
  state.<span>strategy</span> === <span>'simple'</span> ? <span>"direct_answer"</span> : <span>"decompose_question"</span>;

<span>// 条件边依据注入的 plannedNext 裁决,而不是字符串 strategy</span>
<span>const</span> <span>afterPlan</span> = (<span>state</span>) =>
  state.<span>plannedNext</span> === <span>"retrieve"</span> ? <span>"retrieve"</span> : <span>"generate"</span>;

<span>const</span> graph = <span>new</span> <span>StateGraph</span>(<span>GraphState</span>)
  .<span>addNode</span>(<span>"route_question"</span>, routeQuestionNode)
  .<span>addNode</span>(<span>"direct_answer"</span>, directAnswerNode)
  .<span>addNode</span>(<span>"decompose_question"</span>, decomposeQuestionNode)
  .<span>addNode</span>(<span>"retrieve"</span>, retrieveNode)
  .<span>addNode</span>(<span>"plan_next_step"</span>, planNextStepNode)
  .<span>addNode</span>(<span>"generate"</span>, generateNode)
  .<span>addEdge</span>(<span>START</span>, <span>"route_question"</span>)
  .<span>addConditionalEdges</span>(<span>"route_question"</span>, afterRoute, {
    <span>direct_answer</span>: <span>"direct_answer"</span>,
    <span>decompose_question</span>: <span>"decompose_question"</span>
  })
  .<span>addEdge</span>(<span>"decompose_question"</span>, <span>"retrieve"</span>)
  .<span>addEdge</span>(<span>"retrieve"</span>, <span>"plan_next_step"</span>)
  .<span>addConditionalEdges</span>(<span>"plan_next_step"</span>, afterPlan, {
    <span>retrieve</span>: <span>"retrieve"</span>,   <span>// 回环:继续检索下一条子问题</span>
    <span>generate</span>: <span>"generate"</span>
  })
  .<span>addEdge</span>(<span>"direct_answer"</span>, <span>END</span>)
  .<span>addEdge</span>(<span>"generate"</span>, <span>END</span>)
  .<span>compile</span>();

埋个知识点:条件边的判定函数必须严格依赖 State 中可判定的字段,千万别拿语义不稳定的字段去猜分支——否则在分支判定处会出现永远走不到的分支。这也是多跳图里最常见的隐性 bug。

第三招 & 第四招:评估纠错 + 联网兜底,把闭环焊死

前两招升级了「检索的智能」,但还有一个致命场景没处理:本地知识库里根本没有的东西,模型只能编。

比如用户要「可核对的来源链接」,或者问本地向量库之外的时效信息。正确的姿势是:检索后先让模型评估信息够不够,不够就去联网搜索补充,再加一道「二次评估」把「联网补充的上下文」也纳入判断,最后才生成。

状态与评估器

<span>const</span> <span>GraphState</span> = <span>Annotation</span>.<span>Root</span>({
  <span>question</span>: <span>Annotation</span>,
  <span>k</span>: <span>Annotation</span>,
  <span>strategy</span>: <span>Annotation</span>,
  <span>routeReason</span>: <span>Annotation</span>,
  <span>retrievedDocs</span>: <span>Annotation</span>, <span>// 召回的本地片段</span>
  <span>localContext</span>: <span>Annotation</span>,  <span>// RAG 上下文</span>
  <span>webContext</span>: <span>Annotation</span>,    <span>// 联网搜索补充</span>
  <span>evaluation</span>: <span>Annotation</span>,    <span>// { enough, missing, reason, web_query? }</span>
  <span>generation</span>: <span>Annotation</span>
});

<span>const</span> <span>EvaluateSchema</span> = z.<span>object</span>({
  <span>enough</span>: z.<span>boolean</span>(),                              <span>// 上下文是否足够回答</span>
  <span>missing</span>: z.<span>array</span>(z.<span>string</span>()).<span>max</span>(<span>6</span>),              <span>// 缺失的信息点</span>
  <span>reason</span>: z.<span>string</span>(),
  <span>web_query</span>: z.<span>string</span>().<span>optional</span>()                  <span>// 不足时,给一条联网搜索句</span>
});

沿用前面定义的聊天模型(这里记为 llm),并补一个「本地检索」节点,把召回片段拼成 localContext

<span>const</span> llm = model; <span>// 复用第一招定义的 ChatOpenAI 实例</span>

<span>const</span> <span>retrieveLocalNode</span> = <span>async</span> (<span>state</span>) => {
  <span>console</span>.<span>log</span>(<span>"---LOCAL_RETRIEVE---"</span>);
  <span>const</span> retrievedDocs = <span>await</span> <span>retrieveRelevantContent</span>(state.<span>question</span>, state.<span>k</span>);
  <span>console</span>.<span>log</span>(<span>`本地检索命中 <span>${retrievedDocs.length}</span> 条`</span>);
  <span>const</span> localContext = (retrievedDocs ?? []).<span>map</span>(<span>(<span>d</span>) =></span> d.<span>content</span>).<span>join</span>(<span>"\n\n"</span>);
  <span>return</span> { retrievedDocs, localContext };
};

评估节点:LLM 当「质检员」

<span>const</span> <span>evaluateNode</span> = <span>async</span> (<span>state</span>) => {
  <span>const</span> hasWeb = <span>Boolean</span>(state.<span>webContext</span> && <span>String</span>(state.<span>webContext</span>).<span>trim</span>());
  <span>console</span>.<span>log</span>(hasWeb ? <span>"---EVALUATE_CONTEXT_WITH_WEB---"</span> : <span>"---EVALUATE_LOCAL_CONTEXT---"</span>);
  <span>const</span> evaluator = llm.<span>withStructuredOutput</span>(<span>EvaluateSchema</span>);
  <span>const</span> out = <span>await</span> evaluator.<span>invoke</span>(<span>`
你是信息充分性评估器,判断当前上下文是否足以回答用户问题。
用户问题:<span>${state.question}</span>
已检索上下文(来自本地知识库):
<span>${state.localContext || <span>"(空)"</span>}</span>
<span>${hasWeb ? <span>`联网搜索结果:\n<span>${state.webContext || <span>"(空)"</span>}</span>`</span> : <span>""</span>}</span>

输出字段:
- enough: 是否足够回答(true/false)
- missing: 若不够,列出缺失信息点(最多 6 条)
- reason: 简短原因
<span>${hasWeb ? <span>""</span> : <span>"- web_query: 若不够,给出一个适合互联网搜索的中文查询句(完整句,可为空)"</span>}</span>
`</span>);
  <span>console</span>.<span>log</span>(<span>`<span>${hasWeb ? <span>"二次评估"</span> : <span>"评估"</span>}</span>:enough=<span>${out.enough}</span>(<span>${out.reason}</span>)`</span>);
  <span>if</span> (!out.<span>enough</span> && out.<span>missing</span>?.<span>length</span>) out.<span>missing</span>.<span>forEach</span>(<span>(<span>m, i</span>) =></span> <span>console</span>.<span>log</span>(<span>`缺失 <span>${i+<span>1</span>}</span>: <span>${m}</span>`</span>));
  <span>return</span> { <span>evaluation</span>: <span>JSON</span>.<span>stringify</span>(out) };
};

注意 hasWeb 这个开关:第一次评估(只有本地上下文)时,LLM 可以输出 web_query 告诉系统「去网上搜什么」;但经过联网后进入第二次评估时,强制模型不要再发起联网,而是基于「本地 + 联网」一起判断。这个开关是后面防死循环的一半关键。

联网搜索兜底节点

<span>async</span> <span>function</span> <span>bochaWebSearch</span>(<span>query, count</span>) {
  <span>const</span> apiKey = process.<span>env</span>.<span>BOCHA_API_KEY</span>;
  <span>if</span> (!apiKey) <span>throw</span> <span>new</span> <span>Error</span>(<span>"未配置环境变量 BOCHA_API_KEY。"</span>);
  <span>const</span> url = <span>"https://api.bochaai.com/v1/web-search"</span>;
  <span>const</span> body = { query, <span>freshness</span>: <span>"noLimit"</span>, <span>summary</span>: <span>true</span>, <span>count</span>: count ?? <span>10</span> };

  <span>const</span> response = <span>await</span> <span>fetch</span>(url, {
    <span>method</span>: <span>"POST"</span>,
    <span>headers</span>: { <span>Authorization</span>: <span>`Bearer <span>${apiKey}</span>`</span>, <span>"Content-Type"</span>: <span>"application/json"</span> },
    <span>body</span>: <span>JSON</span>.<span>stringify</span>(body)
  });
  <span>if</span> (!response.<span>ok</span>) {
    <span>const</span> errorText = <span>await</span> response.<span>text</span>().<span>catch</span>(<span>() =></span> <span>""</span>);
    <span>throw</span> <span>new</span> <span>Error</span>(<span>`搜索 API 失败,状态码 <span>${response.status}</span>:<span>${errorText}</span>`</span>);
  }
  <span>const</span> json = <span>await</span> response.<span>json</span>();
  <span>const</span> webpages = json.<span>data</span>.<span>webPages</span>?.<span>value</span> ?? [];
  <span>if</span> (!webpages.<span>length</span>) <span>return</span> <span>"未找到相关结果。"</span>;   <span>// 注意:没结果才返回这句</span>
  <span>return</span> webpages
    .<span>map</span>(<span>(<span>p, i</span>) =></span> <span>`引用 <span>${i+<span>1</span>}</span>
标题:<span>${p.name}</span>
URL: <span>${p.url}</span>
摘要:<span>${p.summary}</span>
发布时间:<span>${p.dateLastCrawled}</span>`</span>)
    .<span>join</span>(<span>"\n\n"</span>);
}

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

包一层 bochaWebSearch 是很有价值的工程习惯——把搜索厂商隔离成一个函数,将来要换 Brave、Serper、周泊查等任何服务商,只改这一个函数即可,流程图的 State 完全不用动。

条件边的防死循环护栏

这是整篇最容易踩坑的地方。看这条条件边:

<span>const</span> <span>afterEvaluateLocal</span> = (<span>state</span>) => {
  <span>// 死循环护栏:一旦有联网上下文就放行到 generate,绝不再评估(避免 本地⇄联网 无限循环)</span>
  <span>if</span> (state.<span>webContext</span> && <span>String</span>(state.<span>webContext</span>).<span>trim</span>()) {
    <span>return</span> <span>"generate"</span>;
  }
  <span>const</span> parsed = (<span>() =></span> { <span>try</span> { <span>return</span> <span>JSON</span>.<span>parse</span>(state.<span>evaluation</span> || <span>"{}"</span>); } <span>catch</span> { <span>return</span> {}; } })();
  <span>return</span> parsed.<span>enough</span> === <span>true</span> ? <span>"generate"</span> : <span>"web_search"</span>;
};

<span>const</span> graph = <span>new</span> <span>StateGraph</span>(<span>GraphState</span>)
  .<span>addNode</span>(<span>"route_question"</span>, routeQuestionNode)
  .<span>addNode</span>(<span>"direct_answer"</span>, directAnswerNode)
  .<span>addNode</span>(<span>"local_retrieve"</span>, retrieveLocalNode)
  .<span>addNode</span>(<span>"evaluate_local"</span>, evaluateNode)
  .<span>addNode</span>(<span>"generate"</span>, generateNode)
  .<span>addNode</span>(<span>"web_search"</span>, webSearchNode)
  .<span>addEdge</span>(<span>START</span>, <span>"route_question"</span>)
  .<span>addConditionalEdges</span>(<span>"route_question"</span>, afterRoute, {
    <span>direct_answer</span>: <span>"direct_answer"</span>,
    <span>local_retrieve</span>: <span>"local_retrieve"</span>
  })
  .<span>addEdge</span>(<span>"local_retrieve"</span>, <span>"evaluate_local"</span>)
  .<span>addConditionalEdges</span>(<span>"evaluate_local"</span>, afterEvaluateLocal, {
    <span>generate</span>: <span>"generate"</span>,
    <span>web_search</span>: <span>"web_search"</span>
  })
  .<span>addEdge</span>(<span>"web_search"</span>, <span>"evaluate_local"</span>)   <span>// 联网后再回评估做二次判断</span>
  .<span>addEdge</span>(<span>"direct_answer"</span>, <span>END</span>)
  .<span>addEdge</span>(<span>"generate"</span>, <span>END</span>)
  .<span>compile</span>();

推演一遍完整流程:

  1. route_question 判定 simple → 直接答结束;
  2. complex → local_retrieve 在本地向量库里召回片段,拼成 localContext
  3. evaluate_local 第一次评估:本地上下文够不够?
    • enough=truegenerate 结束;
    • enough=false → 输出 web_queryweb_search
  4. web_search 联网,把结果写进 webContext
  5. web_search -> evaluate_local但此时 hasWeb=true,第二次评估不再允许触发联网
  6. 条件边 afterEvaluateLocal 看到 webContext 非空 → 直接 generate不管够不够都收尾

这条护栏的存在保证了:联网最多发生一次,永不陷入「本地→网络→本地→网络」的死循环。这就是 Agentic RAG 里「可以自由,但不能失控」的经典设计。

生成节点:本地 + 联网统一融合

<span>const</span> <span>generateNode</span> = <span>async</span> (<span>state</span>) => {
  <span>console</span>.<span>log</span>(<span>"---GENERATE---"</span>);
  <span>// 增强 prompt:本地知识库 + 可选联网补充,一起喂给模型</span>
  <span>const</span> context = [state.<span>localContext</span>, state.<span>webContext</span>].<span>filter</span>(<span>Boolean</span>).<span>join</span>(<span>"\n\n==联网补充==\n\n"</span>);
  <span>let</span> generation = <span>""</span>;
  <span>const</span> stream = <span>await</span> llm.<span>stream</span>(<span>`
你是一个严谨的中文问答助手,优先依据上下文回答,不要编造。
上下文(本地知识库 + 可选联网补充):
<span>${context || <span>"(空)"</span>}</span>
用户问题:<span>${state.question}</span>
回答要求:
1. 如果上下文足够,给出清晰、可核对的回答,需要时引用来源 / 链接。
2. 如果上下文仍不足以确认关键事实,明确说明"不确定/无法从上下文确认",并说明缺失点。
3. 不要输出表情符号。
回答:`</span>);
  <span>for</span> <span>await</span> (<span>const</span> chunk <span>of</span> stream) {
    <span>const</span> text = <span>typeof</span> chunk.<span>content</span> === <span>"string"</span> ? chunk.<span>content</span> : <span>""</span>;
    <span>if</span> (!text) <span>continue</span>;
    generation += text; process.<span>stdout</span>.<span>write</span>(text);
  }
  <span>return</span> { generation };
};

到这里,一个完整的 Agentic RAG 闭环成型了:

问题 →
  ↗ simple → 直接回答
路由判断 →
  ↘ complex → 本地检索 → 评估
                              ├ enough → 生成
                              └ 不足 → 联网搜索 → 二次评估(不再联网) → 生成

用一张图看清「从 Naive 到 Agentic」的四次进化

    <span>A</span><span>[普通 RAG 固定管线]</span> -->|加问题路由| <span>B</span><span>[简单直接答,复杂才检索]</span>
    <span>B</span> -->|加子问题拆解| C<span>[多跳 RAG 循环检索]</span>
    C -->|加上下文评估| D<span>[信息不足自动联网补]</span>
    D -->|加二次评估护栏| E<span>[闭环 Agentic RAG 收尾生成]</span>

复盘:Agentic RAG 的四个核心设计原则

  1. 路由分流:用 withStructuredOutput 让 LLM 输出可枚举的 strategy,简单与复杂问题各走各的,省 token。
  2. 拆解 + 回环:复杂问题拆成有序、去指代的可独立检索子问题;用条件边造 retrieve → plan → retrieve 回环,每轮回溯去重。
  3. 评估纠错:检索后不盲目生成,先让 LLM 当质检员判断 enough / missing / web_query
  4. 兜底 + 护栏:本地缺失时联网补;用「联网后不再触发联网」的条件边焊死「防死循环」的最后一道闸。

结尾:Agentic RAG 不是终点,是生产化的起点

必须诚实地说:Agentic RAG 的具体设计没有标准答案,完全取决于你的业务场景。 内部客服机器人可能只需要「路由 + 评估」,多源事实核对可能要「子问题拆解 + 混合检索」,而带时效主张的问题则必须「联网兜底」。理解这套 「路由 → 拆解 → 评估 → 兜底」的闭环思路,再根据业务场景裁剪组合,才是正确的打开方式。