传统 RAG 通常把所有问题都塞进同一条“检索再生成”的流水线:简单问题浪费资源,复杂问题又可能一次查不全,检索结果是否可靠也缺少判断。要改善这些限制,需要把决策能力引入流程,让系统能够路由问题、拆分任务、检查上下文,并在本地知识不足时选择外部检索。
一句话导读:上一篇文章我们把《天龙八部》灌进了向量库,跑通了一个 Naive RAG。但它什么问题都硬走检索、没有判断、不会多步推理、也不会联网补知识。这篇我们把 RAG 升级成「会思考的 Agent」:问题路由、多跳检索、上下文评估、联网兜底,四招在手,跑出一个真正的 Agentic RAG 闭环。
先把朴素 RAG 的短板摊开看。它的流水线固定是 检索 → 生成,所有问题一视同仁,于是暴露出一堆问题:
| 需求场景 | Naive RAG 的表现 | 问题本质 |
|---|---|---|
用户问 1+1=? 这种常识 | 也傻乎乎地走一遍向量检索 | 简单问题白白浪费 token 和时间 |
| 检索回的片段对不对、够不够 | 不校验就直接喂给模型 | 没有评估/纠错机制 |
| 「四大恶人第二的是谁?他儿子的生父公开身份是什么?」 | 一次性检索,拆不出因果链 | 处理不了需要多步检索的复杂问题 |
| 问「高血糖」这种专有术语 | 纯语义检索可能捞回「低血糖」 | 实体/术语更适合关键词精准匹配 |
| 本地知识库没有的东西 | 硬编或干脆不答 | 不会主动联网补充 |
这些单点问题的本质是同一个:流水线太死板了,缺一个「大脑」来做判断。
Agentic RAG(智能体化 RAG) 要做的,就是把这条方程式的控制权交给 LLM + 图编排:让它自主决定要不要检索、用什么检索、信息够不够、要不要重新检索。LangGraph 就是承载这套「思考流程」的最佳载体——节点是动作,边是流向,State 是共享的海绵板。
LLM 的
withStructuredOutput(结构化输出)会是本篇反复用到的魔术,后面每招都会遇到。
第一个优化:别让简单问题也硬走检索。
先用 LLM 判断问题是 simple 还是 complex,然后走不同的分支:
simple(常识问答、简短定义)→ 直接让模型作答,不进检索;complex(需要具体情节、事实、原文证据)→ 才进入检索链路。import "dotenv/config";
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { Annotation, END, START, StateGraph } from '@langchain/langgraph';
import { Milvus } from '@langchain/community/vectorstores/milvus';
import { z } from 'zod';
const GraphState = Annotation.Root({
question: Annotation,
k: Annotation,
strategy: Annotation, // simple | complex
routeReason: Annotation,
documents: Annotation,
generation: Annotation
});
const model = new ChatOpenAI({
model: process.env.MODEL_NAME,
temperature: 0,
configuration: { baseURL: process.env.OPENAI_BASE_URL },
apiKey: process.env.OPENAI_API_KEY
});
const embeddings = new OpenAIEmbeddings({
model: "text-embedding-v3",
dimensions: 1024
});
let vectorStore;
// 用 zod 声明一个结构化的输出契约(枚举 + 理由)
const RouteSchema = z.object({
strategy: z.enum(["simple", "complex"]),
reason: z.string()
});
const routeQuestionNode = async (state) => {
console.log('___ROUTE-QUESTION___');
// withStructuredOutput:逼着模型严格按 schema 输出 JSON
const router = model.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
};
};
withStructuredOutput 的意义在于:路由结果必须是 simple / complex 这种可被代码枚举判断的字符串,而不是模型胡诌的一段散文。这样下游才能用字符串相等来做分支。
const directAnswerNode = async (state) => {
console.log('----DIRECT_ANSWER----');
let generation = "";
const stream = await model.stream(
`你是一个中文回答助手,请直接简洁回答问题。
问题:${state.question}`
);
for await (const chunk of stream) {
const text = typeof chunk.content === 'string' ? chunk.content : "";
if (!text) continue;
generation += text;
process.stdout.write(text);
}
return { question: state.question, k: state.k, strategy: state.strategy,
routeReason: state.routeReason, documents: [], generation };
};
async function retrieveRelevantContent(question, k = 5) {
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 (err) { console.error("检索出错:", err.message); return []; }
}
const retrieveNode = async (state) => {
const documents = await retrieveRelevantContent(state.question, state.k);
return { question: state.question, k: state.k, documents };
};
const generateNode = async (state) => {
const context = state.documents
.map((item, i) =>
`[片段 ${i+1}]
章节: 第 ${item.chapter_num}章
内容:${item.content}`)
.join("nn----------nn");
let generation = "";
const stream = await model.stream(`
你是一个专业的《天龙八部》小说助手,基于小说片段回答问题。
${context}
用户问题:${state.question}
回答要求:有信息就详细准确回答;可综合多片段;没有就如实告知;可引用原文。
AI 助手的回答:`);
for await (const chunk of stream) {
const text = typeof chunk.content === "string" ? chunk.content : "";
if (!text) continue;
generation += text; process.stdout.write(text);
}
return { question: state.question, k: state.k, documents: state.documents, generation };
};
关键来了:LangGraph 的 条件边(conditional edges) 能根据 State 内容动态决定下一步。
const decideNext = (state) =>
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", generateNode)
.addEdge(START, "route_question")
.addConditionalEdges("route_question", decideNext, {
direct_answer: "direct_answer", // simple -> 直接答
retrieve: "retrieve" // complex -> 进检索
})
.addEdge("retrieve", "rag_generate")
.addEdge("direct_answer", END)
.addEdge("rag_generate", END)
.compile();
现在问 1+1=? 会走直答分支,只有复杂问题才去检索。每一块钱的 token 都花在刀刃上。
路由解决了「要不要检索」,但没解决「检索一次够不够」。
有些问题天生需要多步推理,例如:
《天龙八部》中「四大恶人」排行第二的是谁?此人之子在身世揭晓前,其生父在武林中的公开身份是什么?
要答这道题,必须先查出「四大恶人第二 = 叶二娘」,再查「叶二娘的儿子(虚竹)的生父,其武林中的公开身份(玄慈方丈)」。一次性向量化整个长问题,匹配必然发散(这就是为什么这个 repo 注释里感叹「直接把 query 向量化匹配不够准确」)。
解决思路:子问题拆解(Decompose)。让 LLM 把大问题拆成一串有序、可独立检索的子问题,然后循环执行 检索一个子问题 → 汇总去重 → 判断够不够 → 继续下一个。
要在上面那套 GraphState 上做多跳,得先给状态补充几个新字段:子问题列表、当前进度、累计检索次数与预算、以及「规划器」的裁决结果。
const GraphState = Annotation.Root({
question: Annotation,
k: Annotation,
strategy: Annotation,
routeReason: Annotation,
subQuestions: Annotation, // 拆解出的有序子问题列表
nextSubIdx: Annotation, // 下一个要检索的子问题下标(做循环推进)
currentQuery: Annotation, // 当前这一轮正在检索的子问题
retrievalCount: Annotation,// 已检索轮数
maxRetrievals: Annotation, // 最大检索轮数(预算/护栏)
plannedNext: Annotation, // 规划器裁决:下一步 retrieve | generate
documents: Annotation,
generation: Annotation
});
const DecomposeSchema = z.object({
sub_questions: z.array(z.string()).min(1).max(8),
reason: z.string()
});
const decomposeQuestionNode = async (state) => {
console.log("---DECOMPOSE_QUESTION---");
const decomposer = model.withStructuredOutput(DecomposeSchema);
const out = await decomposer.invoke(`
你是《天龙八部》多跳问答的【子问题拆解器】。
用户原始问题:
${state.question}
任务:将问题拆成**有序**子问题列表,用于**依次向量检索**。要求:
1. 链式推理、多层关系、先后因果的问题必须拆成多条;单跳即可答的也可只输出 1 条。
2. 每条子问题必须是**可独立检索**的完整中文问句,禁止使用「他/她/此人/上文」等指代,要写全人物名与事件名。
3. 顺序必须符合推理链:先查前置实体/事实,再查后续结论。
4. 不要把整句原题原样复制成唯一一条(除非确实无法拆分);也不要拆成过碎的关键词列表。
5. 输出 1~8 条即可。
`);
const subQuestions = out.sub_questions.map(s => s.trim()).filter(Boolean);
if (subQuestions.length === 0) throw new Error("拆解结果为空");
console.log(`拆解出 ${subQuestions.length} 条子问题(${out.reason})`);
subQuestions.forEach((q, i) => console.log(`[${i+1}] ${q}`));
return { subQuestions, nextSubIdx: 0, currentQuery: subQuestions[0] };
};
多轮检索容易重复捞回同一片段,既浪费又可能让模型产生「重复 = 强调」的错觉。用 Map 按文档 id 去重,留相似度更高版本。
const mergeUnique = (existing, fresh) => {
const map = new Map(); // ES6 的 HashMap,key: value
for (const d of [...existing, ...fresh]) {
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} 无有效文本`);
const round = state.retrievalCount + 1;
console.log(`----第 ${round} 轮,子问题 ${idx+1}/${subs.length}:${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
};
};
每检索完一个子问题,让 LLM 看当前已召回的证据,决定下一步 retrieve(继续)还是 generate(收尾作答)。同时用 硬性规则护栏:子问题搜完、或轮数到达上限,必须强制 generate,防止死循环。
const NextStepSchema = z.object({
nextAction: z.enum(["retrieve", "generate"]),
reason: z.string()
});
const planNextStepNode = async (state) => {
console.log("---PLAN_NEXT_STEP---");
const subs = state.subQuestions ?? [];
const nextIdx = state.nextSubIdx ?? 0;
const remaining = subs.length - nextIdx;
const subList = subs.map((s, i) =>
`${i+1}.${s} ${i < nextIdx ? "已检索" : i === nextIdx ? "(下一轮将检索)" : "未检索"}`).join("n");
const docStr = state.documents.length === 0
? "(尚无检索结果)"
: state.documents.slice(0, 6)
.map((d, i) => `[${i+1}] score=${Number(d.score).toFixed(4)} 第${d.chapter_num}章:${d.content.slice(0, 200)}`)
.join("nn");
const prompt = `
你是多跳 RAG 规划器。检索查询已由前置步骤拆解为**有序子问题**,若需继续检索,下一轮将自动使用【下一条子问题】检索,你不要自拟新检索句。
用户原始问题:${state.question}
子问题序列:
${subList || "无"}
已检索轮次:${state.retrievalCount};剩余未检索子问题:${remaining};最大轮数:${state.maxRetrievals}
已召回文档摘要:
${docStr}
请判断下一步:
1)已有足够依据回答原问题 -> nextAction=generate
2)仍缺关键事实、且仍有未检索子问题、且未超过上限 -> nextAction=retrieve
硬性规则:剩余子问题为 0 必须 generate;已达/超最大轮数必须 generate。`;
const planModel = model.withStructuredOutput(NextStepSchema);
const { nextAction, reason } = await planModel.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 };
};
注意这里出现了一个回环:retrieve -> plan_next_step -> retrieve(条件边)。这就是 Agentic 的标志——图允许「走一步、看一眼、决定要不要再走一步」。
const afterRoute = (state) =>
state.strategy === 'simple' ? "direct_answer" : "decompose_question";
// 条件边依据注入的 plannedNext 裁决,而不是字符串 strategy
const afterPlan = (state) =>
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 中可判定的字段,千万别拿语义不稳定的字段去猜分支——否则在分支判定处会出现永远走不到的分支。这也是多跳图里最常见的隐性 bug。
前两招升级了「检索的智能」,但还有一个致命场景没处理:本地知识库里根本没有的东西,模型只能编。
比如用户要「可核对的来源链接」,或者问本地向量库之外的时效信息。正确的姿势是:检索后先让模型评估信息够不够,不够就去联网搜索补充,再加一道「二次评估」把「联网补充的上下文」也纳入判断,最后才生成。
const GraphState = Annotation.Root({
question: Annotation,
k: Annotation,
strategy: Annotation,
routeReason: Annotation,
retrievedDocs: Annotation, // 召回的本地片段
localContext: Annotation, // RAG 上下文
webContext: Annotation, // 联网搜索补充
evaluation: Annotation, // { enough, missing, reason, web_query? }
generation: Annotation
});
const EvaluateSchema = z.object({
enough: z.boolean(), // 上下文是否足够回答
missing: z.array(z.string()).max(6), // 缺失的信息点
reason: z.string(),
web_query: z.string().optional() // 不足时,给一条联网搜索句
});
沿用前面定义的聊天模型(这里记为 llm),并补一个「本地检索」节点,把召回片段拼成 localContext:
const llm = model; // 复用第一招定义的 ChatOpenAI 实例
const retrieveLocalNode = async (state) => {
console.log("---LOCAL_RETRIEVE---");
const retrievedDocs = await retrieveRelevantContent(state.question, state.k);
console.log(`本地检索命中 ${retrievedDocs.length} 条`);
const localContext = (retrievedDocs ?? []).map((d) => d.content).join("nn");
return { retrievedDocs, localContext };
};
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 || "(空)"}` : ""}
输出字段:
- enough: 是否足够回答(true/false)
- missing: 若不够,列出缺失信息点(最多 6 条)
- reason: 简短原因
${hasWeb ? "" : "- web_query: 若不够,给出一个适合互联网搜索的中文查询句(完整句,可为空)"}
`);
console.log(`${hasWeb ? "二次评估" : "评估"}:enough=${out.enough}(${out.reason})`);
if (!out.enough && out.missing?.length) out.missing.forEach((m, i) => console.log(`缺失 ${i+1}: ${m}`));
return { evaluation: JSON.stringify(out) };
};
注意 hasWeb 这个开关:第一次评估(只有本地上下文)时,LLM 可以输出 web_query 告诉系统「去网上搜什么」;但经过联网后进入第二次评估时,强制模型不要再发起联网,而是基于「本地 + 联网」一起判断。这个开关是后面防死循环的一半关键。
async function bochaWebSearch(query, count) {
const apiKey = process.env.BOCHA_API_KEY;
if (!apiKey) throw new Error("未配置环境变量 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();
const webpages = json.data.webPages?.value ?? [];
if (!webpages.length) return "未找到相关结果。"; // 注意:没结果才返回这句
return webpages
.map((p, i) => `引用 ${i+1}
标题:${p.name}
URL: ${p.url}
摘要:${p.summary}
发布时间:${p.dateLastCrawled}`)
.join("nn");
}
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 };
};
包一层 bochaWebSearch 是很有价值的工程习惯——把搜索厂商隔离成一个函数,将来要换 Brave、Serper、周泊查等任何服务商,只改这一个函数即可,流程图的 State 完全不用动。
这是整篇最容易踩坑的地方。看这条条件边:
const afterEvaluateLocal = (state) => {
// 死循环护栏:一旦有联网上下文就放行到 generate,绝不再评估(避免 本地⇄联网 无限循环)
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("generate", generateNode)
.addNode("web_search", webSearchNode)
.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();
推演一遍完整流程:
route_question 判定 simple → 直接答结束;local_retrieve 在本地向量库里召回片段,拼成 localContext;evaluate_local 第一次评估:本地上下文够不够?
enough=true → generate 结束;enough=false → 输出 web_query → web_search;web_search 联网,把结果写进 webContext;web_search -> evaluate_local:但此时 hasWeb=true,第二次评估不再允许触发联网;afterEvaluateLocal 看到 webContext 非空 → 直接 generate,不管够不够都收尾。这条护栏的存在保证了:联网最多发生一次,永不陷入「本地→网络→本地→网络」的死循环。这就是 Agentic RAG 里「可以自由,但不能失控」的经典设计。
const generateNode = async (state) => {
console.log("---GENERATE---");
// 增强 prompt:本地知识库 + 可选联网补充,一起喂给模型
const context = [state.localContext, state.webContext].filter(Boolean).join("nn==联网补充==nn");
let generation = "";
const stream = await llm.stream(`
你是一个严谨的中文问答助手,优先依据上下文回答,不要编造。
上下文(本地知识库 + 可选联网补充):
${context || "(空)"}
用户问题:${state.question}
回答要求:
1. 如果上下文足够,给出清晰、可核对的回答,需要时引用来源 / 链接。
2. 如果上下文仍不足以确认关键事实,明确说明"不确定/无法从上下文确认",并说明缺失点。
3. 不要输出表情符号。
回答:`);
for await (const chunk of stream) {
const text = typeof chunk.content === "string" ? chunk.content : "";
if (!text) continue;
generation += text; process.stdout.write(text);
}
return { generation };
};
到这里,一个完整的 Agentic RAG 闭环成型了:
问题 →
↗ simple → 直接回答
路由判断 →
↘ complex → 本地检索 → 评估
├ enough → 生成
└ 不足 → 联网搜索 → 二次评估(不再联网) → 生成
A[普通 RAG 固定管线] -->|加问题路由| B[简单直接答,复杂才检索]
B -->|加子问题拆解| C[多跳 RAG 循环检索]
C -->|加上下文评估| D[信息不足自动联网补]
D -->|加二次评估护栏| E[闭环 Agentic RAG 收尾生成]
withStructuredOutput 让 LLM 输出可枚举的 strategy,简单与复杂问题各走各的,省 token。retrieve → plan → retrieve 回环,每轮回溯去重。enough / missing / web_query。必须诚实地说:Agentic RAG 的具体设计没有标准答案,完全取决于你的业务场景。 内部客服机器人可能只需要「路由 + 评估」,多源事实核对可能要「子问题拆解 + 混合检索」,而带时效主张的问题则必须「联网兜底」。理解这套 「路由 → 拆解 → 评估 → 兜底」的闭环思路,再根据业务场景裁剪组合,才是正确的打开方式。
GPT-5.6 Sol 如何通过 OpenAI Responses API 创建 AI 应用?
GPT-5.6 Sol 的 service_tier 参数如何选择 fast、priority 或 ultrafast?
GPT-5.6 Sol 为什么无法在 Chat Completions 中同时使用函数工具和 reasoning_effort?
GPT-5.6 Sol 如何用于真实的 Agent 和应用开发任务?
云原生可观测性实战:用 MCP ToolSets 提升 Agent 复杂排障的安全性与协作效率
GPT-5.6 Sol 如何用于 AI 编程和自动化应用?