面试知识库

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 直接查询元数据,不走向量检索
越界与知识库领域明显无关(让模型生成代码、写诗等)静态拒答文案

判定走两级,规则路径优先:

  1. 规则快路径MetaIntentDetector)—— 纯正则识别高置信度模式(“总结上面” + “对话” 共现、显式说”翻译你的”等)。命中即返回,零模型调用。原则是宁可漏判多调一次模型,也不要误判把知识查询路由错
  2. 模型路径 —— 默认开启的合并模式下,直接复用 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)#

PathDecisionagent/PathDecision.java)是 Step 2 路径决策的统一输出对象,把原本散落在 DocMindAgent 和 SupervisorAgent 中的路径判定收敛为显式 record:

routePath 收敛为三种模式(PathDecision.Mode):

模式触发条件效果
SELECTED_DOCdirectRead 命中:用户选中文档 + 命中文档/摘要意图DocumentDirectReader 直读 DB chunks + 均匀采样,跳过检索
RULE_PLANNERSIMPLE 且非歧义(!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 调用)#

RetrievalPlannerservice/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

关键设计细节

  1. 双源时效性校验:同时检查原始问题和改写后问题是否含时效关键词(“最新/近期/2024/2025/changelog”等)。改写阶段偶尔丢掉”最新”等时间词,仅看改写后问题会漏调 web_search
  2. ambiguous 保守策略QueryClassification.fallback() 产出的降级分类 isAmbiguous=true,此时强制加 keyword_search 防止向量召回漂移
  3. 输出标记: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 膨胀至 ~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, latencyMs
java

所有 Worker 返回 Evidence(chunks + 置信度 + 来源标记),由 SupervisorAgent 累积到 AgentState.accumulatedEvidence 中。

一次性检索流水线(RULE_PLANNER / 兜底,oneShotRetrieval)#

SupervisorAgent.oneShotRetrieval(即原 handleStandardRetrieval 改名而来,去掉了 CRAG-web 回溯块)用于 RULE_PLANNER 模式,也作为 AGENTIC 异常 / 空结果时的兜底:

CRAG 在 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 做三档评分,作为生成前的质量闸

评分档位阈值动作场景
HIGHtopScore ≥ 0.65直接进入 Prompt 组装检索质量充分
AMBIGUOUS0.25 < topScore < 0.65灰区仲裁(多模式),仍进入生成知识库证据不够确定
LOWtopScore ≤ 0.25降级 prompt,明确告知证据不足避免幻觉

CRAG 只判档位、不触发迭代检索循环,判 LOW 本身也不触发兜底(#28):有证据走 assembleLowConfidence 带证据作答,仅 compressed 空才走 assembleFallback;零覆盖率 + CRAG LOW 仅作为 ungrounded 观测信号。


ReAct Agent 执行模型#

DocMindAgent 作为入口编排器,保留了完整的 ReAct 范式框架,但将检索核心委托给 SupervisorAgent:

Thought → Action → Observation → Thought → ... → Final Answer
plaintext

每一步都记录到 AgentState.agentTrace,前端通过 SSE 实时展示 Agent 的思考过程。

十个主要 Step(完整分层链路见 03-检索与排序链路.md):

Step职责关键组件SSE 事件
0(前置)紧急词短路SafetyGuard.isEmergencydone(emergency=true)
0.5(前置)顶层范畴路由MetaIntentDetector + QueryUnderstandingService(合并模式)→ META_CONVERSATION / CHITCHAT / KB_META / OUT_OF_SCOPE 短路scope
0.7(前置)语义缓存查询SemanticCacheService.lookupdone(fromCache=true)
1Query 理解(改写 + 分类 + 记忆抽取,1 次 LLM)QueryUnderstandingService.understandunderstanding
2路径决策 + 记忆写入PathDecision(三路:SELECTED_DOC / RULE_PLANNER / AGENTIC)+ RetrievalPlanner(仅 RULE_PLANNER)+ MemoryToolrouting
3检索编排AGENTIC → AgenticSearchOrchestrator(LLM 工具调用循环);RULE_PLANNER → SupervisorAgent.oneShotRetrievalagentic* / retrieval
4融合 + 精排 + 多样化 + 父块展开 + 压缩RRFFusion + CrossEncoderReranker(rerank + query-aware compress)+ MMRDiversifier + ParentChunkResolverrerank
4.5CRAG 置信度分级RetrievalGrader(从 Evidence.metadata 读)grader
5Prompt 组装PromptAssembler
6LLM 流式生成ChatModel.stream(主模型 qwen-plus)start / token×N
7结构化引用解析(纯 Java,无 LLM)CitationParser:解析 [n] → 结构化 citations + 覆盖率,置信度由覆盖率派生—(citations 随 done 下发)
8置信度分级 + 低置信警告classifyConfidenceBandconfidence_warning
9语义缓存写入SemanticCacheService.putIfFrequent
10持久化 + 推荐阅读QaMessageMapper + RecommendationGeneratordone

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 两个端点)。另有 CodeExecToolexecuteCode)也带 @Tool 注解,但仅供 AGENTIC 循环白名单内部使用,故意不加入 McpToolsConfigMethodToolCallbackProvider、不对外暴露——对外部调用方跑任意代码攻击面过大:

DocSearchTool (@Tool)
  ├─ 内部调用:ChatClient Function Calling → 结果写入 AgentToolContext
  └─ 外部调用:MCP Server HTTP/SSE → 结果直接返回给外部 Agent
plaintext

判断逻辑:工具内部检查 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 执行过程中的所有中间状态都记录在 AgentStateagent/state/AgentState.java)中,每个 Worker 的执行结果封装为 Evidenceagent/state/Evidence.java)。

AgentState 关键字段#

字段类型用途
query / rewrittenQueryString原始问题 / 改写后问题
scopeDecisionScopeDecision范畴判定结果(Phase 5)
cachedUnderstandingQueryUnderstandingResult合并模式缓存的分类结果,避免重复调用
gradeResultGradeResultCRAG 三档评分结果(Phase 5)
accumulatedEvidenceList<Evidence>多轮迭代累积的检索证据
triedStrategiesSet<String>已尝试的策略(防重复)
confidenceTrajectoryList<Double>每轮置信度轨迹(停滞检测用)
currentIteration / maxIterationsint迭代计数 / 上限
remainingBudgetintToken 预算(防超支)

Evidence 记录#

public record Evidence(
    List<RetrievedChunk> chunks,    // 检索到的切片(可能为空)
    String workerName,              // 来源 Worker:"retrieval" / "web" / "memory"
    double confidence,              // Worker 自评置信度,-1 表示未评估
    Map<String, Object> metadata    // Worker 特定元数据
)
java

metadata 示例:RetrievalWorker 写入 vectorCount / bm25Count / topScore / hydeUsed;WebWorker 写入 resultCount / searchQuery

addEvidence() 方法自动追加到 accumulatedEvidence 并更新 triedStrategiesgetAllEvidenceChunks() 合并全部 Evidence 的 chunks 返回去重后的切片列表。


完整降级链#

每一层组件都有 fallback,任何一层失败不会比改造前更糟:

面试话术

“每一层组件都有明确的 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)→ Langfuse
plaintext

观测系统重构 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,LangfuseOtelConfig 228→约 90 行。下文为重构后实现。

关键组件#

LangfuseOtelConfigconfig/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」脆弱假设

LangfuseOtelAuthInitializerconfig/LangfuseOtelAuthInitializer.java):

  • ApplicationListener<ApplicationPreparedEvent>,public/secret 双 key 算 base64 注入 management.otlp.tracing.headers.Authorization
  • 不用 EnvironmentPostProcessor——spring-dotenv 在 EPP 之后才加载 .envApplicationPreparedEvent 才 .env-safe。替代已删的 LangfuseProperties

TracedOpsupport/TracedOp.java,本次保持不变):

  • 消除每个方法手动 tracer.spanBuilder().startSpan() + try/catch/finally 的样板代码
  • 用法:TracedOp.run(tracer, "rrf_fusion", Map.of("rag.fusion.vector_count", size), span -> { ... })
  • 自动:span 开始/结束、属性设置、异常记录 + StatusCode.ERROR

ChatModelObservationFilterconfig/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 微服务。