Agentic RAG 核心设计#
整套 Agentic RAG 设计的底层动机,是补 LLM 的四个固有短板。后文每个机制都能对应到这张表里的某一条。通用知识地图见 17-AI-Agent通用知识地图 L1。
| LLM 固有局限 | 含义 | DocMind 补偿机制 |
|---|---|---|
| 幻觉 | 一本正经编造不存在的事实 | RAG 注入真实证据 + 结构化引用覆盖率校验(CitationParser) |
| 知识截止 | 训练数据有时间边界 | web_search 联网兜底(时效查询触发) |
| 无状态 | 单次调用不记得历史 | 会话历史拼接(短期)+ 跨会话长期记忆(MySQL SoR + Redis 缓存 + Milvus 向量召回) |
| 数值/逻辑推理不可靠 | 算术、多跳逻辑易错 | 工具选择/路由/时效判断交给规则引擎而非 LLM(见 RetrievalPlanner) |
范畴判定层(Phase 5)#
这是 Phase 5 加上去的最外层处理层,决定一条问题进入哪条路径。原因是 Phase 2 的意图分类(factoid / procedural / comparison / opinion / chitchat)默认每一类都要查知识库,导致”总结上面的对话”这类元对话指令也被送进检索流程,召回完全无关的切片。
六种范畴对应不同处理路径(agent/ScopeDecision.java):
| 范畴 | 含义 | 处理方式 |
|---|---|---|
| 元对话 | 引用、加工、询问之前的对话内容(“总结上面”、“翻译你刚才说的”、“我之前问过什么”) | 仅基于历史对话生成回答,不调用任何 Worker、不查 Milvus / BM25 |
| 闲聊 | 问候、致谢、情绪表达(“你好”、“谢谢”、“在吗”) | 直接小模型短回复 |
| 知识查询 | 询问事实、概念、流程、对比、观点等(默认归类) | 进入下文的 Supervisor-Worker 主流程 |
| 任务执行 | 明确要求执行某项工具任务 | 暂时合并到知识查询主路径 |
| 知识库元信息 | 询问知识库本身的状态(“有哪些知识库""多少份文档""哪些文档已入库”) | KbMetaTool 直接查询元数据,不走向量检索 |
| 越界 | 与知识库领域明显无关(让模型生成代码、写诗等) | 静态拒答文案 |
判定走两级,规则路径优先:
- 规则快路径(
MetaIntentDetector)—— 纯正则识别高置信度模式(“总结上面” + “对话” 共现、显式说”翻译你的”等)。命中即返回,零模型调用。原则是宁可漏判多调一次模型,也不要误判把知识查询路由错 - 模型路径 —— 默认开启的合并模式下,直接复用 QueryUnderstanding 的同一次模型调用(提示词里增加了
scope字段);拆分模式下走独立的ScopeRouter,便于排错
任何一级失败都默认按”知识查询”走原流程,保证向后兼容。
检索结果三档置信度评估(Phase 5)#
检索完成后给一道分。CRAG 论文(Yan et al. 2024)的做法是用一个独立的小模型评估检索质量,分三档触发不同后续动作。本项目把”用独立模型”这一步换成 rerank 分 × 词项覆盖率的二信号一致性裁判(#36)——既不额外调模型(纯 CPU 零延迟),又保住了 CRAG”独立 evaluator”的精髓:词项覆盖率与 rerank 的语义相关性失败模式正交,专抓”语义像但事实不在”(rerank 高、实体却不在证据里)这类 rerank 自己抓不到的坑。
RRF 融合 → Cross-Encoder 重排 → MMR → 父块展开
│
▼ RetrievalGrader.grade()
│
┌───────────────────────────┼──────────────────────────┐
▼ ▼ ▼
rerank 顶分 ≥ 0.65 灰区(0.25 ~ 0.65) rerank 顶分 ≤ 0.25
HIGH 仲裁(多模式可切) LOW
直接进入生成 覆盖率优先一致性判档 有证据→带证据作答;零证据→fallback 拒答plaintext二信号纠偏贯穿所有路径(#36):除灰区仲裁外,快路径也用词项覆盖率纠偏——rerank 顶分 ≥0.65 但覆盖率近零(实体缺失)→ 降级 AMBIGUOUS(“语义像但事实不在”);rerank 顶分 ≤0.25 但稀有实体命中 → 救回 AMBIGUOUS(rerank 漂移兜底)。
灰区仲裁三种模式(service/rag/RetrievalGrader.java):
| 模式 | 实现 | 延迟 |
|---|---|---|
heuristic(默认,#36 升级为二信号) | 覆盖率优先:覆盖强→HIGH、近零→LOW,其余退回 top-1/top-2 分差(≥0.15→HIGH)+ 前三平均(≤0.30→LOW)规则 | 0ms(纯本地) |
cross_encoder(opt-in) | 把 top-3 切片拼接成上下文,让 reranker 对 (问题, 上下文) 单独打分;分数为 0 时自动退回 heuristic | ~50ms(多一次 rerank API) |
disabled | 直接判 AMBIGUOUS,让 Web 兜底 | 0ms |
词项覆盖率 = top≤3 chunk 对查询显著词的长度加权最佳覆盖率(命名实体/术语权重高),复用 BM25 同款 IK 分词器,纯 CPU。
GradeResult透出lexCoverage/kbEntityWeak,驱动 Web 补偿的覆盖率门控(见下)。
评估结果通过 SSE grader 事件发往前端,气泡顶部用绿/黄/红三色徽章直观展示。
CRAG 在当前架构中是生成前的质量闸(HIGH / AMBIGUOUS / LOW),不触发任何生成后的重写或切题度二次审查。注意 CRAG 判 LOW / AMBIGUOUS 本身不再触发兜底(#28/#36)——只要有证据就走 assembleLowConfidence 带证据作答;只有无任何证据(compressed 空)才走 assembleFallback。#36 修了个反直觉 bug:此前 AMBIGUOUS 在 grader.web_fallback_on_ambiguous=true 时被强制翻成 0-chunk 兜底,把已检索到的原文整个丢弃(一条线上 trace:答案明明在库里却回”知识库未匹配到”)——比 AMBIGUOUS 更差的 LOW 反而保留证据,置信度越高越丢证据,语义倒挂,已修正。零覆盖率 + CRAG LOW 仅作为生成后的 ungrounded 观测信号(详见下文「结构化引用 + 置信度」)。
PathDecision 规则引擎路由(Phase 5)#
范畴判定决定了”要不要查知识库”,PathDecision 决定的是”怎么查”——走哪条检索路径、用哪些工具。
路径决策(PathDecision)#
PathDecision(agent/PathDecision.java)是 Step 2 路径决策的统一输出对象,把原本散落在 DocMindAgent 和 SupervisorAgent 中的路径判定收敛为显式 record:
routePath 收敛为三种模式(PathDecision.Mode):
| 模式 | 触发条件 | 效果 |
|---|---|---|
SELECTED_DOC | directRead 命中:用户选中文档 + 命中文档/摘要意图 | DocumentDirectReader 直读 DB chunks + 均匀采样,跳过检索 |
RULE_PLANNER | SIMPLE 且非歧义(!isAmbiguous) | 一次性检索 SupervisorAgent.oneShotRetrieval,工具由 RetrievalPlanner 规则引擎选 |
AGENTIC | 其余(复杂 / 中等 / 歧义 / 多焦点) | 进入 AgenticSearchOrchestrator 的真·LLM 工具调用循环,模型运行时自驱拆解 / 多跳 / web 补充 |
判定优先级:directRead 命中 → SELECTED_DOC;否则 SIMPLE && !isAmbiguous → RULE_PLANNER;其余 → AGENTIC。
字段 reason 记录机器可读的判定原因("selected_single_doc" / "rule_planner" / "agentic" 等),写入 agent trace 和 SSE routing 事件,方便排查。
RetrievalPlanner 规则引擎(零 LLM 调用)#
RetrievalPlanner(service/rag/RetrievalPlanner.java)在 RULE_PLANNER 模式(一次性检索路径)下执行,根据 QueryClassification 的 4 个信号决定调用哪些工具。注意 AGENTIC 模式下工具选择不走 RetrievalPlanner,而是由 LLM 在工具调用循环里自由决定(白名单 4 个读工具 + executeCode 沙箱计算工具):
始终包含 ─────────────────────── doc_search(向量检索)
isAmbiguous=true 或 PRECISE ──── + keyword_search(BM25 精确匹配兜底)
timeAware + 时效关键词命中 ───── + web_search(联网搜索)
memoryAware ──────────────────── + recall_memory(用户长期记忆)plaintext关键设计细节:
- 双源时效性校验:同时检查原始问题和改写后问题是否含时效关键词(“最新/近期/2024/2025/changelog”等)。改写阶段偶尔丢掉”最新”等时间词,仅看改写后问题会漏调 web_search
- ambiguous 保守策略:
QueryClassification.fallback()产出的降级分类isAmbiguous=true,此时强制加 keyword_search 防止向量召回漂移 - 输出标记:ambiguous 时返回
RetrievalPlan.conservative(tools)做标记,让下游知道这是保守方案
为什么用规则引擎不用 LLM 做工具选择:
- 确定性:同一个 QueryClassification 输入永远产出相同的工具组合,零随机性
- 零延迟:4 个 if 判断 <1ms vs LLM Function Calling ~300ms
- 可测试:单元测试直接断言 input→output,不需要 mock LLM
- 可解释:
PathDecision.reason记录决策路径,出问题时一看 trace 就知道为什么选了这组工具 - 规则空间小:工具选择本质是 4 个布尔信号到 N 个工具的映射,用 LLM 是过度设计
面试话术:
“工具选择这件事的本质是:看 4 个信号——是不是模糊查询、是不是精确术语、有没有时效性、需不需要记忆——然后决定调哪几个工具。这是一个确定性的映射关系,用 LLM 去做就像用 GPT-4 算 1+1——不是不行,是没必要。规则引擎 <1ms、100% 可测试、出了问题看 trace 就知道原因。LLM 加 300ms 延迟,还可能随机抽风选错工具。“
QueryUnderstanding 确定性后处理(Phase 5)#
LLM 对多焦点问题的复杂度判断不稳定——“对比 Milvus IVF 和 HNSW 的优缺点”有时被判为
SIMPLE,从而被路由进一次性检索,只能召回一方面的切片。
QueryUnderstandingService.understand() 在 LLM 返回 QueryClassification 后,执行 applyDeterministicSignals 确定性后处理。它现在只做一件事:升级 complexity——原始问题命中以下任一多焦点信号(STRONG_COMPLEX_PATTERN)时,把 SIMPLE 升到 ≥ MEDIUM:
原始问题命中以下任一模式 → complexity 升级(SIMPLE → ≥ MEDIUM):
├─ "对比" / "比较" / "区别" / "差异" / "异同"
├─ "分别" / "各自" / "vs" / "versus"
└─ 多个问号("?.*?" 或 "?.*?")plaintext复杂度被升级后,该查询不再满足 SIMPLE && !isAmbiguous,于是被 routePath 路由进 AGENTIC 循环——拆不拆、跳不跳由模型在工具调用循环里运行时自决。分类器不再有 needDecompose,也不再强制拆解:规则只负责把查询送进 agentic 循环,把”要不要多焦点检索”这一决策交还给模型。
这不是替代 LLM 判断,而是兜底:LLM 判对了不影响;LLM 漏判复杂度了,规则补上。
面试话术:
“这是一个’LLM 判断 + 规则兜底’的设计。观察到 LLM 对’对比 A 和 B’这类多焦点问题的复杂度判断不稳定,约 40% 会误判成 SIMPLE 走单次检索。这些表面信号过去被我用来强制拆解(needDecompose),四刀改造后改成了只升级复杂度——把查询路由进 agentic 循环,让模型自己决定拆不拆、要查几次。成本是零——正则匹配不到 1ms。核心原则:LLM 擅长理解语义和运行时决策,规则擅长捕捉表面模式做路由兜底,两者互补。“
检索编排架构:AGENTIC 循环 + 一次性检索兜底#
DocMind 的检索编排由入口层 DocMindAgent.routePath 收敛为三模式分发。具备 LLM 自主推理的是 AGENTIC 路径的 AgenticSearchOrchestrator(真·LLM 工具调用循环);RULE_PLANNER / 兜底走 SupervisorAgent.oneShotRetrieval(规则编排器,并行 Worker 派发,无自主推理)。Worker 是任务执行器(无自主推理能力)。
DocMindAgent (入口 + 前置处理)
│
├─ Query Rewrite / Classification / 范畴路由 / 路径决策
│
▼
routePath → 三模式
│
├─ SELECTED_DOC → DocumentDirectReader 直读 DB chunks + 均匀采样(跳过检索)
│
├─ AGENTIC → AgenticSearchOrchestrator(真·LLM 工具调用循环)
│ ToolCallingManager 手动控环(internalToolExecutionEnabled(false)),
│ 上限 agentic.max_iterations(默认 4);白名单 4 个读工具 + executeCode 计算工具
│ (searchDocs / keywordSearch / webSearch / recall_memory + executeCode,
│ executeCode 受 sandbox.enabled 默认关;硬排除 store_memory / kb_meta)
│ 模型自驱:拆解 / 多跳 / web 补充 / 计算 / 何时停
│ finalize:并集去重 → Cross-Encoder rerank(统一标尺)→ MMR → 父块展开(#30 与 one-shot 对齐)→ 压缩 → CRAG grade
│ └─ Web 补偿(#36 覆盖率门控):LOW || (AMBIGUOUS && 缺实体) 且整轮未用 web → 追加一轮 webSearch 重排重评(保留 !usedWeb 守卫)
│ 异常 / 空结果 → 回退 oneShotRetrieval
│
└─ RULE_PLANNER / 兜底 → SupervisorAgent.oneShotRetrieval(并行 Worker 派发)
RetrievalPlanner 规则选工具 → 并行 Worker → RRF 融合 → rerank → MMR → 父块展开(RetrievalWorker 内)→ 压缩 → CRAG grade
└─ Web 补偿(#36 覆盖率门控):LOW || (AMBIGUOUS && 缺实体) 且首轮未含 web → 追加一轮 WebWorker 重排重评(GradeResult.webCompensationNeeded,与 AGENTIC 同策略)
Workers (执行层,统一接口,供 oneShotRetrieval 使用):
├─ RetrievalWorker: Vector + BM25 + RRF + Rerank + MMR
├─ WebWorker: Tavily 网络搜索
└─ MemoryWorker: 长期记忆召回(Milvus 向量检索 → MySQL SoR 取全文)plaintext架构演进动机:
原 DocMindAgent 膨胀至 ~1400 行,检索决策逻辑(什么时候调什么工具、调几次、如何应对低质结果)和执行逻辑(怎么调 Milvus、怎么做 BM25、怎么融合)耦合在一个类里。新增 Worker 或策略需改动核心类,风险高。先解耦为 Supervisor-Worker,再在四刀改造中把”决策”这一层升级为真·LLM 工具调用循环。
重构后:
- DocMindAgent 负责前置处理 + SSE 事件流 + 持久化 + 范畴路由 + 路径决策
SupervisorAgent删掉串行多跳 / 拆解 / CRAG-web 回溯等分支后,从 ~1683 行瘦身到 ~595 行,只保留oneShotRetrieval(并行 Worker → RRF → rerank → MMR → 压缩 → CRAG)AgenticSearchOrchestrator承载真·LLM 工具调用循环- 各 Worker 可独立测试和替换;新增 Worker 只需实现
Worker接口 + 注册
Worker 统一接口#
public interface Worker {
WorkerResult execute(WorkerRequest request);
String name();
}
// WorkerRequest: query, originalQuery, kbIds, userId, parameters
// WorkerResult: evidence(chunks + confidence + metadata), success, error, latencyMsjava所有 Worker 返回 Evidence(chunks + 置信度 + 来源标记),由 SupervisorAgent 累积到 AgentState.accumulatedEvidence 中。
一次性检索流水线(RULE_PLANNER / 兜底,oneShotRetrieval)#
SupervisorAgent.oneShotRetrieval(即原 handleStandardRetrieval 改名而来,去掉了 CRAG-web 回溯块)用于 RULE_PLANNER 模式,也作为 AGENTIC 异常 / 空结果时的兜底:
RetrievalPlanner 规则选工具
│
▼
Worker 并行执行(RetrievalWorker + 可选 WebWorker / MemoryWorker)
│ worker→编排层边界做 RetrievedChunk.copy() 防御拷贝
▼
RRF 融合 → Cross-Encoder 重排 → MMR 去重(向量 cosine)→ query-aware 压缩(CrossEncoderReranker.compress,原 ContextCompressor 已下沉)
│
▼
RetrievalGrader CRAG 三档评分(生成前质量闸)
├─ HIGH (≥0.65) → 直接进入 Prompt 组装
├─ LOW / AMBIGUOUS(灰区)+ 有证据 → assembleLowConfidence 带证据作答(#36:AMBIGUOUS 不再被强制兜底丢证据)
│ └─ Web 补偿(#36 覆盖率门控):LOW || (AMBIGUOUS && 缺实体) 且未用 web → 追加一轮 WebWorker 重排重评
└─ 仅 compressed 空(真·无证据) → assembleFallback(0-chunk)
│
▼
PromptAssembler → LLM 生成plaintextCRAG 在 oneShotRetrieval 中是生成前质量闸:三档评分决定走标准 prompt / 带证据的 low-confidence prompt / 兜底 prompt(#36 起 AMBIGUOUS+证据 也走 low-confidence,不再被强制丢证据兜底)。已删除的是旧的”AMBIGUOUS 触发 WebWorker 的多轮迭代回溯块”;保留 / 新增的是 单轮 Web 补偿——#36 起按覆盖率门控触发:tier==LOW || (AMBIGUOUS && kbEntityWeak) 且未用 web 时追加一轮 WebWorker,合并后重排 / 重压缩 / 重评(GradeResult.webCompensationNeeded,LOW 走 retrieval.web_compensation_on_low、AMBIGUOUS 走 grader.web_fallback_on_ambiguous;与 AGENTIC 同策略,AGENTIC 额外保留 !usedWeb 守卫)。除此之外是否调 WebWorker 由 RetrievalPlanner 在选工具阶段按 timeAware 信号决定。注意这是”单轮补偿”不是”迭代回溯循环”,两者机制不同。
多跳与多焦点(已收敛进 AGENTIC 循环)#
多跳(链式依赖)与多焦点(拆解)不再有独立分支:现由 AGENTIC 循环在运行时自驱——模型先检索中间事实,再据此构造下一跳 / 多焦点查询,循环上限 agentic.max_iterations(默认 4)。
历史:自研的串行多跳(
HopAnswerExtractor/handleSequentialMultiHop,设计选型与工程加固见 18-串行多跳检索与RAG链路生产加固)与 Query Decomposition(SubQueryMerger等)均已删除;更早的 Plan-and-Execute(PlanGenerator/PlanExecutor拓扑并行)也已在链路精简中删除。这三者最终统一收敛到真·LLM 工具调用的 agentic 循环(见 04-优化迭代记录)。其中 18 号记录的工程加固(防御性拷贝 /dedupeKey/ 超时 / SSE 生命周期 / 缓存 userId 隔离)作为既存能力保留至今。
CRAG 检索质量闸#
RetrievalGrader 在 Rerank 之后对 topScore 做三档评分,作为生成前的质量闸:
| 评分档位 | 阈值 | 动作 | 场景 |
|---|---|---|---|
| HIGH | topScore ≥ 0.65 | 直接进入 Prompt 组装 | 检索质量充分 |
| AMBIGUOUS | 0.25 < topScore < 0.65 | 灰区仲裁(多模式),仍进入生成 | 知识库证据不够确定 |
| LOW | topScore ≤ 0.25 | 降级 prompt,明确告知证据不足 | 避免幻觉 |
CRAG 只判档位、不触发迭代检索循环,判 LOW 本身也不触发兜底(#28):有证据走 assembleLowConfidence 带证据作答,仅 compressed 空才走 assembleFallback;零覆盖率 + CRAG LOW 仅作为 ungrounded 观测信号。
ReAct Agent 执行模型#
DocMindAgent 作为入口编排器,保留了完整的 ReAct 范式框架,但将检索核心委托给 SupervisorAgent:
Thought → Action → Observation → Thought → ... → Final Answerplaintext每一步都记录到 AgentState.agentTrace,前端通过 SSE 实时展示 Agent 的思考过程。
十个主要 Step(完整分层链路见 03-检索与排序链路.md):
| Step | 职责 | 关键组件 | SSE 事件 |
|---|---|---|---|
| 0(前置) | 紧急词短路 | SafetyGuard.isEmergency | done(emergency=true) |
| 0.5(前置) | 顶层范畴路由 | MetaIntentDetector + QueryUnderstandingService(合并模式)→ META_CONVERSATION / CHITCHAT / KB_META / OUT_OF_SCOPE 短路 | scope |
| 0.7(前置) | 语义缓存查询 | SemanticCacheService.lookup | done(fromCache=true) |
| 1 | Query 理解(改写 + 分类 + 记忆抽取,1 次 LLM) | QueryUnderstandingService.understand | understanding |
| 2 | 路径决策 + 记忆写入 | PathDecision(三路:SELECTED_DOC / RULE_PLANNER / AGENTIC)+ RetrievalPlanner(仅 RULE_PLANNER)+ MemoryTool | routing |
| 3 | 检索编排 | AGENTIC → AgenticSearchOrchestrator(LLM 工具调用循环);RULE_PLANNER → SupervisorAgent.oneShotRetrieval | agentic* / retrieval |
| 4 | 融合 + 精排 + 多样化 + 父块展开 + 压缩 | RRFFusion + CrossEncoderReranker(rerank + query-aware compress)+ MMRDiversifier + ParentChunkResolver | rerank |
| 4.5 | CRAG 置信度分级 | RetrievalGrader(从 Evidence.metadata 读) | grader |
| 5 | Prompt 组装 | PromptAssembler | — |
| 6 | LLM 流式生成 | ChatModel.stream(主模型 qwen-plus) | start / token×N |
| 7 | 结构化引用解析(纯 Java,无 LLM) | CitationParser:解析 [n] → 结构化 citations + 覆盖率,置信度由覆盖率派生 | —(citations 随 done 下发) |
| 8 | 置信度分级 + 低置信警告 | classifyConfidenceBand | confidence_warning |
| 9 | 语义缓存写入 | SemanticCacheService.putIfFrequent | — |
| 10 | 持久化 + 推荐阅读 | QaMessageMapper + RecommendationGenerator | done |
AGENTIC 路径在 Step 3 每轮工具调用各发一次 SSE
agentic事件,循环结束后归并到retrieval。
三条检索路径(Step 2 由 routePath 决定):
- 文档直读(SELECTED_DOC,用户选中文档 + 文档/摘要意图):DB 直读 chunks + 均匀采样,跳过检索
- 一次性检索(RULE_PLANNER,SIMPLE 且非歧义):RetrievalPlanner 规则选工具 → 并行 Worker → RRF → rerank → MMR → 压缩 → CRAG
- agentic 循环(AGENTIC,其余):真·LLM 工具调用循环,模型运行时自驱拆解 / 多跳 / web 补充 / 何时停
LLM 驱动 vs 规则降级:双轨设计#
这是本项目最核心的设计决策。当前有两条并存的检索路径,由 routePath 按查询特征分发:复杂 / 中等 / 歧义 / 多焦点走 AGENTIC(真·LLM 工具调用),SIMPLE 且非歧义走 RULE_PLANNER(规则引擎选工具的一次性检索)。两者互为补充,AGENTIC 异常 / 空结果时还会回退到一次性检索。
AGENTIC 路径:真·LLM 工具调用循环#
AgenticSearchOrchestrator 让模型在 System Prompt 的工具调用规则下自由决策,工具白名单为 4 个读工具 + 1 个计算工具:
- 默认调 searchDocs(语义向量检索)——“微服务拆分原则”
- 精确术语 / 编号 / 型号 / 专有名词 → 加调 keywordSearch(BM25 字面匹配,与 searchDocs 互补)
- 时效性问题 → 调 webSearch——“Spring Boot 最新 CVE”
- 追问 / 个性化 → 调 recall_memory——“上次说的那个配置”
- 精确计算 / 聚合 / 统计 → 调 executeCode(进程外 OpenSandbox 跑 LLM 现写的 Python,受
sandbox.enabled默认关;关时 system prompt 动态剔除其描述,模型根本看不见,不浪费轮次) - 拆解 / 多跳 / 何时停 → 模型运行时自决,循环上限
agentic.max_iterations(默认 4)
控环用 Spring AI ToolCallingManager 手动驱动(internalToolExecutionEnabled(false),因为 Spring AI 内部工具循环无迭代上限,必须自己控环)。store_memory / kb_meta 被硬排除出白名单(防 LLM 误写记忆 / 误查元信息);注意 Spring AI 的 toolNames 是「附加」语义而非「过滤」,所以实现上是过滤 toolCallbacks 列表来排除,而非靠白名单字符串。
RULE_PLANNER 路径:规则引擎选工具的一次性检索#
SupervisorAgent.oneShotRetrieval 不依赖 LLM 选工具——Worker 调度由 RetrievalPlanner 规则引擎基于 QueryClassification 的 4 个信号预先决定(见上文「RetrievalPlanner 规则引擎」),确定性、零延迟、可测试。
System Prompt 本身也是动态的——QueryProfiler 根据查询画像(Complexity × Specificity)注入自适应 topK 值和工具选择指令,而非写死 topK=15。
与「LLM 原生工具调用」的两点边界差异#
标准 Function Calling Agent 还有两个本项目有意没用的机制,面试时主动点出比被问出来强(通用原理见 17-AI-Agent通用知识地图 L3):
- 错误回灌(error feedback):标准做法是工具执行失败时把错误信息作为 observation 回喂 LLM,让它自主重试或换策略。DocMind 不走这条——工具失败走确定性降级链(见「完整降级链」),不让 LLM 看着错误重想。取舍是确定性 > 灵活性,省一次 LLM 往返。
- LLM 原生并行 tool calls:现代模型支持单轮返回多个工具调用意图并行执行。DocMind 的并行是规则引擎层面的 Worker 并行派发(RetrievalPlanner 决定调哪几个 Worker → 线程池并发),不是 LLM 自由决定的并行调用——同样是为了确定性和可测试性。
模型成本分层:主回答 / 轻决策 / 记忆提取三层#
工具选择是纯决策类任务(4 选 N + 简单参数),用 qwen-plus 是过度设计。迭代 #7 引入双模型分工,后续「LLM 即 Gate」改造又补上记忆提取层,形成三层成本分层:
| 模型 | 用途 | 配置项 |
|---|---|---|
llm.model(默认 qwen-plus) | 主回答流式生成 —— 质量决定项 | 在 AiConfigHolder 持有的 activeModel 上跑 |
llm.small_model(默认 qwen-turbo) | Query 改写 / 分类 / 灰区仲裁等 —— 决策类 | 通过 OpenAiChatOptions.builder().model(name).build() per-call 覆盖 |
memory.extract_model(默认 qwen-flash) | 跨会话记忆提取 —— 每轮触发的高频低价值任务 | 同上 per-call 覆盖(extractModelOptions()) |
关键设计:不维护多套 ChatModel 实例——复用主模型的 OpenAiApi 连接,调用时通过 options 临时覆盖 model 名。零运维成本,三层均支持热切换。AiConfigHolder.smallModelOptions() / callSmallModel(prompt) / extractModelOptions() 封装了样板代码。
单查询成本对比(典型场景):
| 步骤 | 改造前(全 qwen-plus) | 改造后(混合) |
|---|---|---|
| Query 改写 | ~$0.0001 | ~$0.000025 |
| 工具选择 | ~$0.0014 | ~$0.00035 |
| 引用解析 | —(原 Reflection ~$0.0028) | CitationParser 纯 Java ≈ $0(删自反思) |
| 主回答 | ~$0.008 | ~$0.008(不变) |
| 合计 | ~$0.012 | ~$0.0084 |
上表只算单查询的”算账”。整体平均成本还要再叠加:缓存命中率 30%→45%(迭代 #7)使有效 LLM 查询数减少 ~20%,最终月度成本约 -50%。三层贡献:短路 ~30% + 小模型 ~10% + 缓存 ~10%。
降级路径:AGENTIC 异常 → 一次性检索兜底#
当 AGENTIC 循环异常或返回空结果时(网络超时、模型返回格式异常、零召回等),自动回退到确定性的一次性检索:
// AgenticSearchOrchestrator —— 异常/空结果回退
needsFallback = compressed.isEmpty(); // 仅"无任何证据"才兜底;CRAG 判 LOW 但 compressed 非空 → 不置位,走 low-confidence 带证据作答(#28)
// 异常或 needsFallback → SupervisorAgent.oneShotRetrieval(...)java兜底路径用 RetrievalPlanner 规则引擎选工具(确定性、零延迟),保证每次请求都能拿到结果。agentic.enabled 是灰度总开关(默认 on),关掉即全量走一次性检索。
面试话术:
“生产系统不能假设 LLM 100% 可用。双轨设计的价值在于:AGENTIC 路径正常时享受模型自驱多跳 / 多焦点检索的智能,异常或零召回时回退到确定性的规则引擎一次性检索,保证每次请求都能拿到结果。还留了
agentic.enabled灰度开关,线上有问题一键切回一次性检索。“
ThreadLocal 工具上下文#
MCP 工具被 Spring AI ChatClient 调用时,结果需要回传给 Agent。问题是 Function Calling 的回调签名固定,无法额外传参。
解决方案:AgentToolContext(ThreadLocal)
// 激活上下文
AgentToolContext.activate(kbIds, userId);
try {
// ChatClient 调用工具时,工具内部写入 ThreadLocal
// DocSearchTool.searchDocs() → AgentToolContext.get().addChunks(chunks)
agentClient.prompt().user(query).call().content();
// 读取所有工具的累积结果
List<RetrievedChunk> chunks = AgentToolContext.get().getChunks();
} finally {
AgentToolContext.clear(); // 必须清理,防止线程池复用时数据泄漏
}java面试话术:
“ThreadLocal 解决了一个实际问题:Spring AI 的 Function Calling 机制里,工具方法的返回值是给 LLM 看的(决定下一步),而实际的检索结果需要绕过 LLM 直接传给 Agent 做后续处理。ThreadLocal 让工具方法无需改签名就能实现这个’侧通道’。“
生成即终态 + 结构化引用置信度#
DocMind 不做生成后的二次审查——生成即终态,没有 Self-Reflection 的事实一致性 / 完整性 / 来源匹配 / 表达质量四维打分,也没有条件重写或切题度检查。答案质量的把关移到了纯 Java 的结构化引用解析(CitationParser,零 LLM 成本)。
LLM 流式生成完毕
│
▼ CitationParser.parse(纯 Java,无 LLM)
├─ 解析答案里的 [n] 标记 → 结构化 citations(含 index/id/name)
├─ 越界编号丢弃并计入 invalidRefs
├─ 句子级 coverage = 含 ≥1 个有效 [n] 的实质陈述句数 / 实质陈述句总数
└─ confidenceScore = clamp(coverage) × rerank-top1
(coverage 不可算时退回 rerank-top1)
│
▼
无任何证据(compressed 空) → assembleFallback 兜底模板
CRAG LOW 但有证据 → assembleLowConfidence 带证据作答(不丢弃 KB/Web)
零覆盖率 + CRAG LOW → 仅记 ungrounded 观测信号,不再翻 needsFallback(#28 P1-B)plaintext要点:
- 置信度来源:
confidenceScore = clamp(coverage) × rerank-top1,由覆盖率派生(替代了原 Self-Reflection 的置信度来源),随done事件下发citations/citationCoverage/invalidRefs/confidenceScore/confidenceBand。 - 可点击引用:
SourcePayloadFactory给每个 source 加id(稳定锚点);前端把[n]渲染为可点击.citation-ref,点击滚动高亮对应来源卡。 - 测试:
CitationParserTest(6 个)。
面试话术:
“四刀改造把生成后的 Self-Reflection 整类删了——生成即终态。原因是它是审查类 LLM 调用,成本高(~$0.0028/次)、延迟大(~1.5s),而真正能拦住幻觉的信号其实更便宜:答案里
[n]引用对句子的覆盖率。所以换成纯 Java 的 CitationParser——解析[n]标记、算句子级覆盖率、confidenceScore = clamp(coverage) × rerank-top1,零 LLM 成本。这里我还踩过一个坑(#28):早期把’CRAG 评分低’等同’没有证据’直接清空上下文走兜底,结果联网搜到的时效内容被整批丢掉、模型凭过时记忆答出’自信的错误’;后来收紧为只有真的一条证据都没有才兜底,CRAG 低分但有证据就走 low-confidence 模板带着证据作答,零覆盖只作为 ungrounded 观测信号。本质是把’让模型自查’换成’用结构化信号客观度量’,同时把’置信度低’和’无证据’彻底拆开。“
MCP 工具双路复用#
5 个工具 Bean(DocSearchTool / KeywordSearchTool / WebSearchTool / MemoryTool / KbMetaTool)同时服务内部 Agent 和外部 MCP 客户端,共暴露 6 个端点(MemoryTool 有 store + recall 两个端点)。另有 CodeExecTool(executeCode)也带 @Tool 注解,但仅供 AGENTIC 循环白名单内部使用,故意不加入 McpToolsConfig 的 MethodToolCallbackProvider、不对外暴露——对外部调用方跑任意代码攻击面过大:
DocSearchTool (@Tool)
├─ 内部调用:ChatClient Function Calling → 结果写入 AgentToolContext
└─ 外部调用:MCP Server HTTP/SSE → 结果直接返回给外部 Agentplaintext判断逻辑:工具内部检查 AgentToolContext.get() != null,存在则写入 ThreadLocal(内部调用),否则直接返回(外部调用)。
MCP 安全边界:内部路径天然安全——AgentToolContext 激活时,kbIds 由用户会话上下文强制覆盖(LLM 提供的 kbIds 被忽略),userId 由系统注入。外部路径已加 API-Key 鉴权层(McpApiKeyAuthFilter,校验 X-API-Key,配置键 docmind.mcp.api-key;/mcp/**、/sse 匿名访问被 401,见 21-权限系统对齐报告)。剩余生产加固点:① 升级为 OAuth2.1 资源服务器;② 工具层校验 kbIds 归属(外部持 API-Key 仍可传任意 kbIds);③ Memory userId 从 auth context 注入、禁止外部指定;④ Web Search 接入 rate-limiter 防配额滥用。
面试话术:
“MCP 双路复用的安全模型分两层看:内部路径用 AgentToolContext 做了硬隔离——用户勾选的 kbIds 强制覆盖 LLM 参数,防止 prompt injection 越权访问其他知识库。外部路径我规划了四个生产加固点:端点鉴权、kbIds 归属校验、userId 注入、API 限流——其中端点鉴权已落地(
McpApiKeyAuthFilter校验X-API-Key,匿名访问 401),剩下三点是按优先级排期的加固。这是 MVP 阶段的有意识取舍——先跑通 MCP 协议对接 + 基础鉴权,再补齐归属校验和限流。“
文档直读模式#
当用户选中特定文档(传入 kbIds)时,系统判断是否应该跳过搜索、直接读取文档全文:
// SelectedDocumentScopeDecider 判断条件
boolean shouldDirectRead = kbIds.size() <= 3 && 问题是关于这些文档的;java直读时使用均匀采样策略:
- 按 chunk_index 顺序排列
- 均匀取样(头部、中间、尾部都有)
- 确保文档各部分都有代表性
面试话术:
“这是一个针对特定场景的优化。用户明确指定了文档时,向量检索反而可能引入噪声(检索到其他文档的相似内容)。直读 + 均匀采样既保证了覆盖度,又避免了无效的向量化开销。“
AgentState 与 Evidence 状态管理#
Agent 执行过程中的所有中间状态都记录在 AgentState(agent/state/AgentState.java)中,每个 Worker 的执行结果封装为 Evidence(agent/state/Evidence.java)。
AgentState 关键字段#
| 字段 | 类型 | 用途 |
|---|---|---|
query / rewrittenQuery | String | 原始问题 / 改写后问题 |
scopeDecision | ScopeDecision | 范畴判定结果(Phase 5) |
cachedUnderstanding | QueryUnderstandingResult | 合并模式缓存的分类结果,避免重复调用 |
gradeResult | GradeResult | CRAG 三档评分结果(Phase 5) |
accumulatedEvidence | List<Evidence> | 多轮迭代累积的检索证据 |
triedStrategies | Set<String> | 已尝试的策略(防重复) |
confidenceTrajectory | List<Double> | 每轮置信度轨迹(停滞检测用) |
currentIteration / maxIterations | int | 迭代计数 / 上限 |
remainingBudget | int | Token 预算(防超支) |
Evidence 记录#
public record Evidence(
List<RetrievedChunk> chunks, // 检索到的切片(可能为空)
String workerName, // 来源 Worker:"retrieval" / "web" / "memory"
double confidence, // Worker 自评置信度,-1 表示未评估
Map<String, Object> metadata // Worker 特定元数据
)javametadata 示例:RetrievalWorker 写入 vectorCount / bm25Count / topScore / hydeUsed;WebWorker 写入 resultCount / searchQuery。
addEvidence() 方法自动追加到 accumulatedEvidence 并更新 triedStrategies,getAllEvidenceChunks() 合并全部 Evidence 的 chunks 返回去重后的切片列表。
完整降级链#
每一层组件都有 fallback,任何一层失败不会比改造前更糟:
Tier-0 MetaIntentDetector 失败
└─ 返回 null → 进入 Tier-1 LLM
Tier-1 QueryUnderstandingService 失败
└─ QueryClassification.fallback() → isAmbiguous=true, 保守工具集
PathDecision 异常
└─ RULE_PLANNER + conservative plan(doc_search + keyword_search)
AGENTIC 循环异常 / 空结果
└─ 回退一次性检索 oneShotRetrieval(agentic.enabled 灰度开关,默认 on)
RetrievalWorker 向量检索失败
└─ 仅 BM25 结果参与 RRF(单路融合)
CrossEncoderReranker 调用失败
└─ keyword scoring fallback(保留原始 RRF 分数)
RetrievalGrader 灰区仲裁失败
└─ 直接 AMBIGUOUS(仍进入生成,作为质量闸)
CitationParser 解析失败 / 覆盖率不可算
└─ confidenceScore 退回 rerank-top1plaintext面试话术:
“每一层组件都有明确的 fallback 路径。设计原则是:加了新组件只可能提升效果,绝不会因为新组件失败而比没加它的时候更差。比如 CRAG 灰区仲裁失败,直接判 AMBIGUOUS 让 Web 兜底——最差情况就是多做一次 Web Search,不会阻塞主流程。“
Langfuse 全链路可观测性(Phase 6)#
Agent 系统最大的运维难点是”不可观测”——LLM 在黑盒里做了什么决策、每步花了多少时间、哪个 Worker 的结果最终被选中,如果看不到就无法优化。Phase 6 通过 OpenTelemetry 协议将 Agent 每步执行上报到 Langfuse。
架构#
DocMindAgent.execute() / 短路路径
│
├─ TracedOp.run("span_name", attrs, body) ← 每个关键步骤(业务 span,语义不变)
│ └─ OTel Span(自动记录耗时、属性、异常)
│
├─ Spring AI ChatModel.call() / stream()
│ └─ 原生 gen_ai instrumentation 自动产 gen_ai.* 子 span(model / token 用量)
│ + ChatModelObservationFilter 补 prompt / completion
│
└─ Spring Boot 原生 OTLP tracing autoconfig
└─ 自定义 Sampler(root span name 白名单 + ParentBased)→ 命中才上报
→ OTLP HTTP(OkHttp sender)→ Langfuseplaintext观测系统重构 Part 1(2026-06-07):早期 Phase 6 是手写 OTel SDK(228 行手动
new OtlpHttpSpanExporter+SdkTracerProvider+BatchSpanProcessor+RootNameFilteringSpanProcessor+micrometerTracer桥接 +verifyExport())。后实测「Spring Boot 3.4 OTel 与 spring-ai 版本冲突」为伪(OTel core 统一 1.43.0),删手写 SDK 改用 Spring Boot 原生 OTLP tracing autoconfig,LangfuseOtelConfig228→约 90 行。下文为重构后实现。
关键组件#
LangfuseOtelConfig(config/LangfuseOtelConfig.java,约 90 行):
- 不再手建 SDK,改由 Spring Boot 原生 OTLP tracing autoconfig(
management.otlp.tracing.*/management.tracing.*)装配 exporter - 只剩 2 个 Bean:
langfuseTracer(业务Tracer,tracing 关闭时退OpenTelemetry.noop())+businessRootSpanSampler
businessRootSpanSampler(替代 RootNameFilteringSpanProcessor):
- Spring Boot tracing autoconfig 会把容器里的 OTel Tracer 借给 HTTP server / actuator / Reactor 等通通起 span,产生大量噪音
- 解决:自定义 head-based
Sampler——root span name 命中白名单(DocMindAgent.execute / emergency_short_circuit / scope_short_circuit / semantic_cache_replay)→ 按采样率;否则SamplingResult.drop();ParentBased让子 span 继承根决策。覆盖 autoconfig 默认otelSampler(@ConditionalOnMissingBean) - 比旧方案优雅:决策在 span 生成阶段(不命中直接不建),无 traceId Set、无 2000 上限清空、无「占位符 name」脆弱假设
LangfuseOtelAuthInitializer(config/LangfuseOtelAuthInitializer.java):
ApplicationListener<ApplicationPreparedEvent>,public/secret 双 key 算 base64 注入management.otlp.tracing.headers.Authorization- 不用
EnvironmentPostProcessor——spring-dotenv 在 EPP 之后才加载.env,ApplicationPreparedEvent才 .env-safe。替代已删的LangfuseProperties
TracedOp(support/TracedOp.java,本次保持不变):
- 消除每个方法手动
tracer.spanBuilder().startSpan()+ try/catch/finally 的样板代码 - 用法:
TracedOp.run(tracer, "rrf_fusion", Map.of("rag.fusion.vector_count", size), span -> { ... }) - 自动:span 开始/结束、属性设置、异常记录 + StatusCode.ERROR
ChatModelObservationFilter(config/ChatModelObservationFilter.java):
- 给原生 gen_ai 观测补上 prompt(
gen_ai.prompt)和 completion(gen_ai.completion),截断到 10000 字符防超限 - 本就存在,但因热切换模型丢了
ObservationRegistry而休眠;本次给AiConfigHolder.refreshLlmModel()接上ObservationRegistry后激活,model/token 用量由原生 gen_ai instrumentation 自动产出(删 DocMindAgent 手动gen_ai.*埋点)
面试话术:
“Agent 系统不可观测就不可优化。我们用 Langfuse 的 OTel 协议,Agent 的每一步——query understanding、routing、retrieval(含 agentic 循环每轮)、reranking、grading、generation——都作为 span 上报,在 Langfuse 里看完整 trace 瀑布图。一个实际例子:CRAG 灰区阈值就是我在 Langfuse 里看 rerank 分数分布后调出来的。这套观测我还做过一次重构:最初是手写 OTel SDK,228 行手动拼 exporter/provider/processor,理由是’Spring Boot autoconfig 跟 spring-ai 冲突’——后来查依赖树发现 OTel 全统一在 1.43.0、根本没冲突,于是删手写 SDK 改原生 autoconfig,噪声过滤也从事后的 SpanProcessor 换成 head-based 的自定义 Sampler,更优雅。“
面试 Q&A#
Q: Agent 和普通的 RAG Chain 有什么本质区别?
A: Chain 是线性的、预定义的(A→B→C),Agent 是循环的、动态的(Thought→Action→Observation→再思考)。最锋利的例子是 AGENTIC 路径的真·LLM 工具调用循环:模型在 4 个读工具 + 1 个沙箱计算工具的白名单里自己决定调哪个、调几次、何时停——简单题查一次就停,对比题自主多焦点检索,多跳题先查中间事实再构下一跳并自我验证(实测各 ≈4 次)。这种”模型根据观察结果自驱下一步动作”是 Chain 无法表达的。生成前还有 CRAG 三档质量闸(HIGH/AMBIGUOUS/LOW)做兜底。
Q: 为什么从单体 Agent 重构成 Supervisor-Worker?后来又怎么演进的?
A: 起点是可维护性——原 DocMindAgent 膨胀到 ~1400 行,检索决策和执行逻辑耦合,新增 Worker 需改核心类。第一步解耦成 Supervisor-Worker:编排决策与执行分层,Worker 实现统一接口可独立测试。但”决策”这一层后来发现规则编排表达力不够,于是四刀改造把它升级为真·LLM 工具调用循环(AgenticSearchOrchestrator),SupervisorAgent 删掉串行多跳 / 拆解 / CRAG-web 回溯后从 ~1683 行瘦身到 ~595 行,只留一次性检索 oneShotRetrieval 作 RULE_PLANNER 与兜底。现在是”AGENTIC 自驱 + 一次性检索兜底”两条路。
Q: Plan-and-Execute、Query Decomposition、串行多跳,这些到底什么区别,现在还在吗?
A: 概念上:Query Decomposition 是把一个问题拆成同构、无依赖、可并行的子问题;Plan-and-Execute 是 LLM 一次性生成有 DAG 依赖、可调不同工具的异构计划;串行多跳是”下一跳 query 依赖上一跳答案”的链式迭代。我 Phase 2 三种都实现过。但它们最终全部被删、统一收敛到真·LLM 工具调用的 agentic 循环:Plan-and-Execute 最先删(规划早于检索、对同构子任务过度抽象);四刀改造里再把自研 Query Decomposition(SubQueryMerger)和自研串行多跳(HopAnswerExtractor)一起删掉——因为”拆不拆、跳不跳”本就该由模型在运行时根据观察自决,手写分支既冗余又难维护。这是一个典型的”加了又删、最终收敛”的取舍——见 04-优化迭代记录。
Q: ThreadLocal 有什么风险?
A: 两个风险:①线程池复用时数据泄漏——用 try-finally 确保清理;②异步场景下 ThreadLocal 不传递——当前架构中工具调用是同步的(Spring AI ChatClient.call()),所以不存在这个问题。如果改成异步,需要用 InheritableThreadLocal 或手动传递 Context。
Q: 为什么不用 LangChain?
A: Spring AI 和 Spring 生态深度集成——Security、MCP Server、依赖注入都是原生的。LangChain 主要面向 Python,在 Java 生态里用 Spring AI 更自然,也不需要额外的 Python 微服务。