一、先看场景:一道故意设计的问题
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?
三个理由:
- 来源可追溯——生成答案时可以标注"这段来自书里,那段来自网络"
- 流程可控——
webContext有值就意味着"已经联网过了",可以据此避免重复联网 - 评估更精确——评估节点能分别知道"本地料够不够"和"网络料补上没"
那个 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>;
三步:
parsed.web_query ?? ""—— 取评估节点给的查询词,没有就空字符串.trim()—— 去空格|| 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 怎么救场。
评估节点用布尔开关加缺料清单,把"要不要联网"变成可路由状态;本地与网络上下文分开存储,联网限一次以防死循环。适合需来源可追溯、抗幻觉的 RAG 场景。