🚕 缺料就出门买:给 RAG 装上"信息够不够"的判断力

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

评估节点用布尔开关加缺料清单,把"要不要联网"变成可路由状态;本地与网络上下文分开存储,联网限一次以防死循环。适合需来源可追溯、抗幻觉的 RAG 场景。

> 写在前面:前两篇我们搭了 RAG、打了两个补丁(分诊、拆题)。这篇补最后也是最关键的一块——**让 RAG 知道自己"不知道"。** 朴素 RAG 最要命的地方在于:它搜到什么就用什么。哪怕书里压根没有相关内容,它也会硬着头皮编一个答案。readme 把这个问题直白地写了出来——**"本地知识库没有的内容,不会主动去网络搜索补充,容易编造答案。"** 今天这个 demo 干的就两件事:先评估"手头的资料够不够",不够就出门联网买料,买回来再评估一遍。以下所有代码均来自课堂真实文件。

一、先看场景:一道故意设计的问题

rag-webfallback.mjs 的测试问题,是整篇的题眼:

<span>const</span> question = <span>`请回答《天龙八部》小说里"雁门关事件"的主谋是谁,并说明其儿子的最终结局;
另外请补充:在《天龙八部》2013版电视剧中,这段"雁门关事件"主要出现在哪几集?
请给出可核对的来源链接。
`</span>

这个问题被精心设计成了"一半本地能答、一半本地答不了"。

拆开看:

子问题本地知识库(《天龙八部》EPUB)能答吗
雁门关事件的主谋是谁✅ 能——小说里有明确情节
其儿子的最终结局✅ 能——小说里写了
**2013 版电视剧中出现在哪几集**❌ **不能**——小说原文里没有电视剧信息
**给出可核对的来源链接**❌ **不能**——本地文本给不了链接

而且这些信息在小说里的推理路径也是清晰的:

雁门关事件的主谋 → 慕容博(散布假消息,挑起雁门关伏击)
慕容博的儿子   → 慕容复
慕容复的结局   → 精神失常(在坟头做皇帝梦)

前两问,向量检索能搞定。后两问,本地库里根本没有——这就逼出了"联网兜底"的流程。

好的测试用例不是"随便问一句",而是"精确地卡在能力边界上"。 这个问题正好卡在"本地能答"和"本地答不了"的分界线上。


二、状态设计:把"料"分成三堆

<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>// 召回</span>
    <span>retrievedDocs</span>: <span>Annotation</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 }</span>
    <span>generation</span>: <span>Annotation</span>,
})

跟上篇的多跳版本比,这里的状态设计有个明显变化——上下文被拆成了两堆:

字段装什么
`localContext`本地知识库检索到的内容
`webContext`联网搜索回来的内容

为什么要分开存,而不是合并成一个 context?

三个理由:

  1. 来源可追溯——生成答案时可以标注"这段来自书里,那段来自网络"
  2. 流程可控——webContext 有值就意味着"已经联网过了",可以据此避免重复联网
  3. 评估更精确——评估节点能分别知道"本地料够不够"和"网络料补上没"

那个 evaluation 字段的注释也点明了结构:

<span>evaluation</span>: <span>Annotation</span>,     <span>// { enough, missing, reason }</span>

它存的是一个 JSON 字符串(后面会看到为什么)。


三、备料检查:那个救命的评估节点

这是全篇的核心。EvaluateSchema:

<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>// 可选的 web 搜索的关键词</span>
})

四个字段,各有使命:

字段类型作用
`enough`boolean**总开关**——够不够回答
`missing`string\[\](最多6条)缺料清单
`reason`string判断理由
`web_query`string(可选)**给联网搜索用的查询词**

这个 Schema 的设计非常讲究——它不只是"判断够不够",还顺手把"下一步该干什么"也定好了。

逐个看:

enough 是 boolean(不是字符串)——布尔值让代码判断可以直接用,不用做字符串比较。

missing 用 .max(6) 限长——防止模型长篇大论地罗列缺失点。 缺失点列太多也没意义,抓重点就行。

web_query 标了 .optional()——因为不是每次评估都需要它。 如果料够了,就不需要联网,这个字段也就没意义。

节点实现

<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_WEB_CONTEXT___"</span>);
    <span>// llm 大脑,规划,分析,分步骤</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>)
    <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 判断

<span>const</span> hasWeb = <span>Boolean</span>(state.<span>webContext</span> && <span>String</span>(state.<span>webContext</span>).<span>trim</span>());

三层处理:state.webContext 存在?→ 转字符串 → 去掉空白后还有内容吗?

String(...).trim() 这一步是必要的——防止一个全是空格的字符串被误判成"有内容"。

第二,日志名字随状态变化

<span>console</span>.<span>log</span>(hasWeb ? <span>"___EVALUATE_CONTEXT_WITH_WEB___"</span> : <span>"___EVALUATE_WEB_CONTEXT___"</span>);

一个日志名,隐含了"这是第几次评估"。调试时一眼能看出来。

第三,prompt 是动态拼装的

${hasWeb ? <span>`联网搜索结果:\n <span>${state.webContext || <span>" (空) "</span>}</span>`</span> : <span>""</span>}

没联网时,这段内容压根不出现。 而不是"联网结果:(空)"——少一段噪音就少一分干扰。

第四,web_query 的说明只在第一次出现

${hasWeb ? <span>""</span> :
    <span>"- web_query: 若不够,给出一个适合互联网搜索的中文查询句(完整句,不用代码:为空也可)"</span>}

第二次评估(已经联网过了)就不再要求 web_query ——因为不需要再搜了。这个细节说明设计者很清楚流程走到哪一步。

第五,web_query 的措辞要求

<span>"给出一个适合互联网搜索的中文查询句(完整句,不用代码:为空也可)"</span>

"完整句,不用代码" ——这个约束很实在。因为本地知识库是《天龙八部》,模型可能习惯性地给出小说式的查询词。但在互联网上搜"2013版电视剧 雁门关 集数",需要的是一句自然的中文查询。

一个值得注意的存储细节

<span>return</span> {
    <span>evaluation</span>: <span>JSON</span>.<span>stringify</span>(out),
}

把结构化对象转成 JSON 字符串再存。

为什么要这样?因为 LangGraph 的状态字段是共享的——Annotation 里声明的是通用类型,存字符串最安全、最不容易出岔子。取用的时候再 JSON.parse 回来:

<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> {};
    }
})();

这段"立即执行函数"(IIFE)配合 try/catch,是一个很稳的防御式写法:

  • state.evaluation || "{}" —— 空值兜底
  • try/catch —— 解析失败兜底
  • 返回 {} —— 保证后面访问 .enough 不会崩

因为 evaluation 是字符串,任何解析失误都会导致 JSON.parse 抛异常。 这两层兜底,是把"可能崩的地方"都焊死了。


四、路由与本地检索

路由复用:还是那个分诊台

<span>const</span> <span>routeQuestionNode</span> = <span>async</span> (<span>state</span>) => {
    <span>console</span>.<span>log</span>(<span>'___ROUTE-QUESTION___'</span>);
    <span>// 结构化输出</span>
    <span>const</span> router = llm.<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>// 可选的,不需要全部state的设置</span>
    <span>// 为后面的节点提供服务的</span>
    <span>return</span> {
        <span>strategy</span>: route.<span>strategy</span>,
        <span>routeReason</span>: route.<span>reason</span>,
        <span>retrievedDocs</span>: [],
        <span>localContext</span>: <span>""</span>,
        <span>webContext</span>: <span>""</span>,
        <span>evaluation</span>: <span>""</span>,
        <span>generation</span>: <span>""</span>,
    }
}

RouteSchema 和上篇一模一样——分诊台被完整复用了。 这是模块化设计的好处:一个可靠的判断节点,可以在不同流程里搬运。

注意这段注释:

<span>// 可选的,不需要全部state的设置</span>
<span>// 为后面的节点提供服务的</span>

这个节点把后面要用的字段全部初始化了一遍(retrievedDocs、localContext、webContext、evaluation、generation)。

这是个好习惯——在流程入口把状态"归零",后面的节点就不用到处写"如果为空就……"的兜底判断。 相当于每次启动前先把桌面擦干净。

而这个测试问题(雁门关 + 电视剧集数)会被判为 complex——因为它明确要求"具体情节、原文细节或证据支持"。

本地检索:只取内容

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

这个节点返回两个东西:

返回值内容
`retrievedDocs`完整的文档对象(含分数、章节等元数据)
`localContext`纯文本拼接的上下文

为什么要两个都存?

  • localContext 直接喂给 prompt——模板字符串里需要的就是一段纯文本
  • retrievedDocs 保留结构化数据——评估节点和调试输出可能需要看分数、章节号

一份数据,两种形态。 朴素 RAG 里只有 documents,这里拆成了两个字段——流程变复杂后,数据形态也要跟着分化。

注意检索用的是原始问题(state.question),而不是拆解后的子问题——这个 demo 没有引入多跳拆解,它专注于"评估 + 兜底"这一件事。 每个 demo 只演一个能力,这是很清醒的做法。


五、出门采购:联网搜索节点

评估说"料不够",该出门买料了。

决策逻辑:先看有没有买过

<span>const</span> <span>afterEvaluateLocal</span> = (<span>state</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>; 
}

三行判断,逻辑很严密:

已经联网过了?(webContext 有内容)
  → 是 → 直接 generate(防止无限联网!)
  → 否 → 看评估结果够不够
          → <span>enough</span> === <span>true</span> → generate
          → 否则 → web_search

第一行的判断是防死循环的关键。

想一下:web_search 之后会回到 evaluate_local 做二次评估。如果二次评估又说"不够",会怎么样?→ 又去联网 → 又评估 → 死循环。

所以这里设了一道硬闸:只要 webContext 已经有内容,无论如何都去 generate。

"最多联网一次" ——这不是能力限制,是安全边界。

parsed.enough === true 用 === 严格比较而不是 ==——因为 enough 必须真的是布尔 true,字符串 "true" 不算数。

博查搜索:一个完整的 API 封装

<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 Web Search 的 API Key 未配置(环境变量 BOCHA_API_KEY)"</span>);
    }
    <span>const</span> url = process.<span>env</span>.<span>BOCHA_API_URL</span>;
    <span>const</span> body = {
        query,
        <span>freshness</span>:<span>"noLimit"</span>,
        <span>summary</span>: <span>true</span>, <span>// 返回的内容,做个总结</span>
        <span>count</span>: count ?? <span>10</span>
    }

用的是博查(Bocha)搜索 API——国内一个面向 AI 应用的搜索服务。配置全部走环境变量,这是标准做法(前面 FastAPI 那篇讲过)。

请求参数:

参数值含义
`query`查询句来自评估节点的 `web_query`
`freshness``"noLimit"`不限制时间范围
`summary``true`**返回内容做摘要**(注释说明了)
`count`传进来的数量要几条结果

summary: true 这个参数很关键——搜索引擎直接返回摘要,省掉了自己抓网页正文的麻烦。

这一点体现了"选对工具"的价值:直接调一个"为 AI 设计的搜索 API",比自己写爬虫去抓网页、解析 HTML、提取正文要省太多事。

然后是请求发送:

    <span>let</span> response;
    <span>try</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>catch</span> {
        <span>throw</span> <span>new</span> <span>Error</span>(<span>`搜索API 请求失败(网络错误):<span>${error.message}</span>`</span>);
    }

标准的 fetch POST 请求——Bearer Token 认证、JSON 请求体。

错误处理分了两层,这是个很专业的写法:

    <span>// 先处理失败</span>
    <span>// ok 200 语义化更好</span>
    <span>if</span> (!response.<span>ok</span>) {
        <span>// 二进制 json() text()</span>
        <span>// 出错,打印出错信息</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>let</span> json;
    <span>try</span> {
        json = <span>await</span> response.<span>json</span>();
    } <span>catch</span> {
        <span>throw</span> <span>new</span> <span>Error</span>(<span>`搜索结果解析失败:<span>${error.message}</span>`</span>);
    }

层次检查什么怎么报错
HTTP 层`response.ok`(状态码 2xx)带状态码 + 错误文本
解析层`response.json()` 能不能解析单独报"解析失败"

为什么要把这两层分开? 因为它们是完全不同的故障:

  • HTTP 层失败 = 请求没成功(Key 错了、欠费了、服务挂了)→ 去查账号
  • 解析层失败 = 请求成功了但返回格式不对(接口变了)→ 去查接口文档

如果混在一起报"搜索失败了",你根本不知道该往哪个方向查。

注释里那句"ok 200 语义化更好"——说的是 response.ok 这个属性比自己写 response.status === 200 更直观,而且它能覆盖所有 2xx 状态码。

.catch(() => "") 也是个细节——读错误信息本身也可能失败,再套一层兜底防止报错时报错。

结果解析与格式化

<span>console</span>.<span>log</span>(json, <span>"///////////"</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>return</span> webpages
        .<span>map</span>(<span>(<span>page, idx</span>) =></span> <span>`引用:<span>${idx+<span>1</span>}</span>
        标题:<span>${page.name}</span>
        URL:<span>${page.url}</span>
        摘要:<span>${page.summary}</span>
        网站名称:<span>${page.siteName}</span>
        网站图标:<span>${page.siteIcon}</span>
        发布时间:<span>${page.dateLastCrawled}</span>
        `</span>)
        .<span>join</span>(<span>"\n\n"</span>)
}

结果格式化成带出处的结构——每条结果包含序号、标题、URL、摘要、网站名、发布时间。

这个格式化直接影响最终答案的质量。

还记得测试问题的最后一句吗?——"请给出可核对的来源链接"。为什么这里要把 URL 和网站名都带上?因为生成节点要靠这些信息给出引用。

如果只返回摘要文本,模型没法给出链接。格式化的字段,决定了最终答案能不能满足需求。

这是一个"下游需求倒推上游格式"的经典例子——不是"搜索返回什么就存什么",而是"最终答案需要什么,就格式化出什么"。

搜索节点:拿到采购清单

<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>// 封装</span>
    <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
    }
}

查询词的选取逻辑值得细看:

<span>const</span> query = (parsed.<span>web_query</span> ?? <span>""</span>).<span>trim</span>() || state.<span>question</span>;

三步:

  1. parsed.web_query ?? "" —— 取评估节点给的查询词,没有就空字符串
  2. .trim() —— 去空格
  3. || state.question —— 如果还是空的,就用原始问题兜底

这是一个"有则用模型优化过的,无则退回原始问题"的降级策略。 评估节点可能给了更好的查询词(它知道缺什么),也可能没给(模型没填),那就直接用原问题。

bochaWebSearch(query, 8) 传了 8——要 8 条结果。 因为网络信息质量参差不齐,多取几条再让模型自己筛选。

注释里还有一句很有工程意识的话:

<span>// 封装</span>
<span>// 方便切换其他服务</span>

这就是"封装成独立函数"的意义——如果哪天想从博查换成必应、Google,只要改 bochaWebSearch 这一个函数,图的结构完全不用动。


六、生成节点:把两堆料合起来

<span>const</span> <span>generateNode</span> = <span>async</span> (<span>state</span>) => {
    <span>// 增强prompt</span>
    <span>// localContext</span>
    <span>// webContext</span>
    <span>console</span>.<span>log</span>(<span>'___GENERATE___'</span>);
    <span>const</span> context = [state.<span>localContext</span>, state.<span>webContext</span>]
        .<span>filter</span>(<span>Boolean</span>)<span>// Boolean 函数</span>
        .<span>join</span>(<span>"\n\n==联网补充==\n\n"</span>);

两行代码,完成合并:

步骤作用
`.filter(Boolean)`过滤掉空值(本地没检索到 / 没联网)
`.join("\n\n==联网补充==\n\n")`用显式分隔符拼接

.filter(Boolean) 是个很聪明的写法——Boolean("") 是 false,Boolean("内容") 是 true。用它来过滤空字符串,比写 .filter(s => s && s.length > 0) 简洁得多。

分隔符 ==联网补充== 是给模型看的信号,告诉它"从这里开始是网络来的信息"。这样模型回答时能区分两类来源。

然后是 prompt:

    <span>const</span> stream = <span>await</span> llm.<span>stream</span>(<span>`你是一个严谨的中文回答助手,
        优先依据上下文回答,不要编造。
        上下文(本地知识库 + 可选联网补充):
        <span>${context || <span>"(空)"</span>}</span>
        用户问题:<span>${state.question}</span>

        回答要求:
        1.如果上下文足够,给出清晰,可核对的回答:需要时引用:n / URL 
        ”或小说片段来支撑。“
        2. 如果上下文仍不满足已确定关键事实,明确说明”不确定/无法从上下文确认“,
        并说明缺失点
        3. 不要输出表情符号。

        回答:
    `</span>);

三条要求,对应前两篇的教训:

要求解决什么
1. **要求可核对,引用 URL 或原文**提供出处,让用户能验证
2. **不确定就明说,并说明缺失点****防幻觉的最后一道防线**
3. 不要输出表情符号严谨场景的输出规范

第 2 条是整篇的收口。

前面做了那么多——评估够不够、缺什么、联网补充——最终还是要落到"如果还是不够,就老实说不够"。这才是"可纠错"的完整含义:不是保证永远答对,而是保证不会假装答对。

prompt 开头那句"优先依据上下文回答,不要编造"也值得注意——"优先"两个字用得好,它意味着"上下文里有就用,没有才靠模型自身知识",而不是"只能依据上下文"。


七、完整的图:一个会自我检查的闭环

<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>();

流程图:

                 ┌→ direct_answer → <span>END</span>
<span>START</span> → route_question
                 └→ local_retrieve → evaluate_local ─┐
                                          ↑          │
                                          │      ┌───┴───┐
                                          │      ↓       ↓
                                          └─ web_search  generate → <span>END</span>
                                          (联网后回来再评估)

注意 web_search → evaluate_local 这条边——它形成的是一个"评估循环"。

完整走一遍:

<span>1.</span> route_question   → 判定 complex
<span>2.</span> local_retrieve   → 从《天龙八部》里检索(能查到慕容博、慕容复)
<span>3.</span> evaluate_local   → 评估:enough = <span>false</span>
                      missing: [<span>"2013版电视剧具体集数"</span>, <span>"可核对的来源链接"</span>]
                      web_query: <span>"天龙八部 2013版电视剧 雁门关事件 第几集"</span>
<span>4.</span> web_search       → 带着 web_query 出门联网
<span>5.</span> evaluate_local   → 二次评估(现在有 webContext 了)
<span>6.</span> generate         → 合并本地 + 网络,生成最终回答

这个闭环的精妙之处在第 5 步——联网回来不是直接生成,而是再评估一次。

为什么?因为联网搜回来的东西可能:

  • 也没查到(那评估会再判定不够,但因为有防重复机制,直接进 generate 并如实说"无法确认")
  • 查到了但质量差(评估能看到内容,可以指出问题)
  • 完全够了(判定 enough,正常生成)

"检查 → 补料 → 再检查" ——这是任何认真做事的流程都该有的样子。

日志会把这些步骤都打出来:

___ROUTE-QUESTION___
路由策略:complex 需要具体情节和证据
___LOCAL_RETRIEVE___
本地检索命中 8 条
___EVALUATE_WEB_CONTEXT___
评估 : <span>enough</span>=<span>false</span>
缺失 1: 2013版电视剧具体集数
缺失 2: 可核对的来源链接
___WEB_SEARCH___
联网搜索: 天龙八部 2013版电视剧 雁门关事件 第几集
联网搜索结果长度: 1234
___EVALUATE_CONTEXT_WITH_WEB___
二次评估 : <span>enough</span>=<span>true</span>
___GENERATE___

这一串日志,就是整个 Agent 的"思考过程"。 节点名前后的 ___ 下划线让它们在输出流里一眼可辨——又一个调试友好设计。


八、五个硬伤,全部收工

回到第一篇那张表,现在全部打完了:

\#硬伤补丁状态
1所有问题都检索路由分流(simple/complex)✅
2没有评估机制**评估节点(enough/missing/web\_query)**✅
3处理不了多跳问题拆解(子问题 + 顺序检索)✅
4语义匹配不准关键词检索(ES)⬜ 下一篇
5不会联网兜底**网络搜索 + 二次评估闭环**✅

readme 对这套架构的总结只有一句话,但每个字都有分量:

"基于 LangGraph 实现闭环的 Agentic RAG,也就是 Agent 自主决策要不要检索,用什么检索(web search + milvus + es),信息够不够,效果怎么样,要不要重新搜。"

四个决策,全部由 Agent 自己做:

决策对应节点
要不要检索`route_question`
信息够不够`evaluate_local`
要不要补充`afterEvaluateLocal`(web\_search or generate)
补充后够了吗二次评估

这就是"闭环"和"线性"的区别——线性流程是"检索 → 生成 → 结束",闭环是"检索 → 评估 → 补充 → 再评估 → 生成"。

readme 还有一句提醒,很重要:

"具体 Agentic RAG 要根据业务场景设计,理解这个闭环思路就可以。"

意思是:别照抄这个图。

不同业务的闭环长得不一样:

  • 客服系统可能不需要联网,但需要"查订单状态"
  • 代码助手可能不需要多跳,但需要"读文件"
  • 合规场景可能不需要多路召回,但必须"每条结论都要出处"

变的是节点,不变的是那个思想——让 Agent 自己判断"我手上的信息够不够",不够就想办法补。


九、这一篇的三个收获

收获一:评估是"防幻觉"最有效的手段。

prompt 里写一百句"不要编造",不如加一个节点问模型"你手上的资料够回答吗"。前者是嘱咐,后者是流程。 嘱咐靠自觉,流程靠机制。

收获二:好的 Schema 设计是"判断 + 行动"二合一。

EvaluateSchema 不只是判断"够不够",还给出了"缺什么"和"下一步搜什么"。一个结构化输出同时完成了评估、诊断、规划三件事。

收获三:错误处理要分层,防循环要设闸。

bochaWebSearch 里把 HTTP 失败和解析失败分开报错——故障定位的前提是故障分类。 而 afterEvaluateLocal 里那句"已经联网过就直接生成"——任何允许循环的设计,都必须配一个终止条件。


PS:这篇写下来,"Agentic"这个词的含义清晰了很多——它不是"用 AI 做更多事",而是"让 AI 知道自己的边界"。知道自己不知道,才会去查;知道自己查过了,才会停。下一篇我们补最后一个硬伤:为什么"高血糖"和"低血糖"在向量数据库里几乎一样,以及 ES 怎么救场。