外观
Agentic RAG 核心设计
范畴判定层(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)的做法是用一个独立的小模型评估检索质量,分三档触发不同后续动作。本项目把"用小模型"这一步换成了 reranker 单对打分,节省 ~75% 的灰区仲裁延迟。
RRF 融合 → Cross-Encoder 重排 → MMR → 父块展开
│
▼ RetrievalGrader.grade()
│
┌───────────────────────────┼──────────────────────────┐
▼ ▼ ▼
rerank 顶分 ≥ 0.65 灰区(0.25 ~ 0.65) rerank 顶分 ≤ 0.25
HIGH 仲裁(四种模式可切) LOW
直接进入生成 fallback 接管,建议拒答灰区仲裁三种模式(service/rag/RetrievalGrader.java):
| 模式 | 实现 | 延迟 |
|---|---|---|
heuristic(默认) | 用 top-1 与 top-2 的分差(≥0.15 → HIGH)、前三平均分(≤0.30 → LOW)的规则判定 | 0ms(纯本地) |
cross_encoder | 把 top-3 切片拼接成上下文,让 reranker 对 (问题, 上下文) 这一对单独打分;分数为 0 时自动退回 heuristic | ~50ms |
disabled | 直接判 AMBIGUOUS,让 Web 兜底 | 0ms |
评估结果通过 SSE grader 事件发往前端,气泡顶部用绿/黄/红三色徽章直观展示。
自纠错切题度检查(Phase 5)
原 SelfReflection 评估事实一致性、完整性、来源匹配、表达质量四个维度,无法识别"检索到的内容跟问题完全不是一个主题"。Phase 5 在反思提示词加了一项额外检查:判断「参考来源」整体是否在讨论与「用户问题」同一主题。命中跑题时返回 topicMismatch=true,主流程跳过重写直接降级(重写救不回主题不对的引用,反而浪费一次模型调用)。
这是检索置信度评估漏判时的最后一道闸。
PathDecision 规则引擎路由(Phase 5)
范畴判定决定了"要不要查知识库",PathDecision 决定的是"怎么查"——走哪条检索路径、用哪些工具。
路径决策(PathDecision)
PathDecision(agent/PathDecision.java)是 Step 2 路径决策的统一输出对象,把原本散落在 DocMindAgent 和 SupervisorAgent 中的路径判定收敛为显式 record:
| 模式 | 触发条件 | 效果 |
|---|---|---|
SELECTED_DOC | 用户选中 1-8 份文档 + 命中文档/摘要意图 | 直读 DB chunks + 均匀采样,跳过所有 Worker |
DECOMPOSED | hasDecomposition=true 且子问题 ≥ 2 | 并行子问题 → 每个子问题走 RetrievalWorker → SubQueryMerger 合并 |
RULE_PLANNER | 其余(默认 fallback) | RetrievalPlanner 规则引擎生成工具列表,走标准检索路径 |
字段 reason 记录机器可读的判定原因("selected_single_doc" / "decompose_over_direct_read" / "rule_planner_fallback" 等),写入 agent trace 和 SSE routing 事件,方便排查。
suppressedDecomposition 标记本可走拆解但被覆盖的情况——比如多文档直读优先级高于拆解时,这个字段会记录下来,避免路径决策逻辑丢失信息。
RetrievalPlanner 规则引擎(零 LLM 调用)
RetrievalPlanner(service/rag/RetrievalPlanner.java)在 RULE_PLANNER 模式下执行,根据 QueryClassification 的 4 个信号决定调用哪些工具:
始终包含 ─────────────────────── doc_search(向量检索)
isAmbiguous=true 或 PRECISE ──── + keyword_search(BM25 精确匹配兜底)
timeAware + 时效关键词命中 ───── + web_search(联网搜索)
memoryAware ──────────────────── + recall_memory(用户长期记忆)关键设计细节:
- 双源时效性校验:同时检查原始问题和改写后问题是否含时效关键词("最新/近期/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 对比较类问题的 decompose 判断不稳定——"对比 Milvus IVF 和 HNSW 的优缺点"有时返回
needDecompose=false,导致多焦点问题走单次检索,只能召回一方面的切片。
QueryUnderstandingService.understand() 在 LLM 返回 QueryClassification 后,执行 applyDeterministicSignals 确定性后处理:
原始问题命中以下任一模式 → 强制 needDecompose=true:
├─ "对比" / "比较" / "区别" / "差异" / "异同"
├─ "分别" / "各自" / "vs" / "versus"
└─ 多个问号("?.*?" 或 "?.*?")这不是替代 LLM 判断,而是兜底:LLM 判对了不影响;LLM 漏判了,规则补上。
面试话术:
"这是一个'LLM 判断 + 规则兜底'的设计。观察到 LLM 对'对比 A 和 B'这类问题大约 60% 会正确触发 decompose,40% 会漏掉。加了几行正则后处理,对比类问题的 decompose 触发率变成 100%。成本是零——正则匹配不到 1ms。核心原则:LLM 擅长理解语义,规则擅长捕捉表面模式,两者互补。"
Supervisor-Worker 多 Agent 架构(Phase 2)
DocMind 在 Phase 2 完成了从单体 Agent 到 Supervisor-Worker 多 Agent 拓扑的架构升级。核心思想:将检索编排(决策)和检索执行(动作)解耦为独立层。
DocMindAgent (入口 + 前置处理)
│
├─ Query Rewrite / Classification
│
▼
SupervisorAgent (编排决策)
│
├─ 简单/中等查询 → 迭代 ReAct 循环
│ IterationDecider: CONTINUE / SWITCH_STRATEGY / TERMINATE
│ ObservationEvaluator: PROCEED / SWITCH_TO_WEB / TRIGGER_HYDE / INVOKE_ANALYSIS / REPLAN
│
├─ 复杂查询 → Plan-and-Execute
│ PlanGenerator (LLM 生成结构化执行计划)
│ PlanExecutor (按依赖拓扑并行调度 Workers)
│
└─ 拆解查询 → 并行子问题检索 + SubQueryMerger 合并
每个子问题独立走 RetrievalWorker
Workers (执行层,统一接口):
├─ RetrievalWorker: Vector + BM25 + RRF + Rerank + MMR
├─ WebWorker: Tavily 网络搜索
├─ MemoryWorker: Redis 长期记忆召回
└─ AnalysisWorker: LLM 多文档对比分析架构演进动机:
原 DocMindAgent 膨胀至 ~1400 行,检索决策逻辑(什么时候调什么工具、调几次、如何应对低质结果)和执行逻辑(怎么调 Milvus、怎么做 BM25、怎么融合)耦合在一个类里。新增 Worker 或策略需改动核心类,风险高。
重构后:
- DocMindAgent ~1510 行,负责前置处理 + SSE 事件流 + 持久化 + 范畴路由 + 路径决策
- SupervisorAgent ~300 行,只做编排决策
- 4 个 Worker 各 ~80-120 行,可独立测试和替换
- 新增 Worker 只需实现
Worker接口 + 在PlanExecutor.resolveWorker()注册
Worker 统一接口
java
public interface Worker {
WorkerResult execute(WorkerRequest request);
String name();
}
// WorkerRequest: query, originalQuery, kbIds, userId, parameters
// WorkerResult: evidence(chunks + confidence + metadata), success, error, latencyMs所有 Worker 返回 Evidence(chunks + 置信度 + 来源标记),由 SupervisorAgent 累积到 AgentState.accumulatedEvidence 中。这使得多轮迭代中证据可聚合、可回溯。
迭代 ReAct 循环(简单/中等查询)
初始化 AgentState (maxIterations=3, remainingBudget=token预算)
│
▼
┌─ Iteration ────────────────────────────────────┐
│ 1. Worker 执行(RetrievalWorker 默认首选) │
│ 2. ObservationEvaluator 评估质量 │
│ → 空召回 → SWITCH_TO_WEB │
│ → 低分 → TRIGGER_HYDE │
│ → 多文档冲突 → INVOKE_ANALYSIS │
│ 3. IterationDecider 判断 │
│ → confidence ≥ 阈值 → TERMINATE │
│ → 达到 maxIterations → TERMINATE │
│ → 否则 → CONTINUE / SWITCH_STRATEGY │
└─────────────────────────────────────────────────┘
│ TERMINATE
▼
Rerank + MMR → 返回 SupervisorResultIterationDecider 终止条件:
currentConfidence ≥ terminateThreshold(默认 0.7)currentIteration ≥ maxIterations(默认 3)- 置信度轨迹连续 2 轮无提升(停滞检测)
Plan-and-Execute 模式(复杂查询)
当 QueryClassification.complexity == COMPLEX 时,SupervisorAgent 走 Plan-and-Execute 路径:
PlanGenerator:用小模型(qwen-turbo)生成结构化 JSON 执行计划
json{ "reasoning": "规划思路", "steps": [ {"index": 1, "action": "retrieval", "query": "...", "dependsOn": []}, {"index": 2, "action": "web", "query": "...", "dependsOn": [1]} ] }PlanExecutor:按依赖拓扑分层,同层步骤并行执行
buildExecutionLayers()按dependsOn拓扑排序- 每层通过
CompletableFuture.supplyAsync在ragRetrievalExecutor线程池并行 - 步骤间可传递上下文(前序步骤结果写入
AgentState)
Fallback:PlanGenerator LLM 调用失败 → 退化为 2 步默认计划(retrieval + web)
ObservationEvaluator 动态降级链
每轮迭代后 ObservationEvaluator 评估检索质量,推荐下一步动作:
| 条件 | 推荐动作 | 场景 |
|---|---|---|
| 召回为空 | SWITCH_TO_WEB | 知识库无相关内容 |
| 召回分数 < 0.4 | TRIGGER_HYDE | 用户表述和文档用词差异大 |
| Top-4 来自 ≥3 文档且分数差 < 0.1 | INVOKE_ANALYSIS | 多文档信息竞争/冲突 |
| 自省失败 + 信息不足 | REPLAN | 需要补充检索而非仅重写 |
| 正常 | PROCEED | 继续下一步/终止 |
冲突检测算法:取 Top-4 chunks 的来源文档 ID,若来自 ≥3 个不同文档且 top1-top4 rerank 分数差 < 0.1,说明多文档竞争激烈,需要 AnalysisWorker 做综合对比。
ReAct Agent 执行模型
DocMindAgent 作为入口编排器,保留了完整的 ReAct 范式框架,但将检索核心委托给 SupervisorAgent:
Thought → Action → Observation → Thought → ... → Final Answer每一步都记录到 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 / DECOMPOSED / RULE_PLANNER)+ RetrievalPlanner(规则引擎)+ MemoryTool | routing |
| 3 | 多路检索编排(四条路径) | SupervisorAgent.orchestrate | plan? / retrieval |
| 4 | 融合 + 精排 + 多样化 + 早停 | RRFFusion + CrossEncoderReranker + MMRDiversifier + EarlyStopGate | rerank |
| 4.5 | CRAG 置信度分级 | RetrievalGrader(从 Evidence.metadata 读) | grader |
| 5 | Prompt 组装 | PromptAssembler(三模板) | — |
| 6 | LLM 流式生成 | ChatModel.stream(主模型 qwen-plus) | start / token×N |
| 7 | Self-Reflection 自纠错 | SelfReflection.reflect(小模型)+ 高分短路 | reflection_start/token/done |
| 8 | 置信度分级 + 低置信警告 | classifyConfidenceBand | confidence_warning |
| 9 | 语义缓存写入 | SemanticCacheService.putIfFrequent | — |
| 10 | 持久化 + 推荐阅读 | QaMessageMapper + RecommendationGenerator | done |
四条检索路径(Step 3 由 SupervisorAgent 决定):
- 文档直读(用户选中 ≤3 份文档):DB 直读 chunks + 均匀采样,跳过所有 Worker
- 拆解路径(needDecompose=true):并行子问题 → RetrievalWorker × N → SubQueryMerger 合并
- Plan-and-Execute 路径(complexity=COMPLEX):PlanGenerator 生成 JSON 计划 → PlanExecutor 拓扑调度
- 标准迭代路径(其余):迭代 ReAct 循环(最多 3 轮)+ IterationDecider 动态终止
LLM 驱动 vs 规则降级:双轨设计
这是本项目最核心的设计决策。Phase 2 后 LLM 工具选择被 SupervisorAgent 编排取代为默认路径,但仍作为降级可选项保留。
主路径:Supervisor 编排(Phase 2)
SupervisorAgent 根据 QueryClassification 的分类结果自动决策执行策略,不再依赖 LLM Function Calling 选择工具:
java
// SupervisorAgent.orchestrate()
if (classification.complexity() == COMPLEX && !isDecomposed) {
return handlePlanAndExecute(state, classification); // LLM 生成多步计划
} else if (isDecomposed) {
return handleDecomposedRetrieval(state, subQueries); // 并行子问题
} else {
return handleStandardRetrieval(state, classification); // 迭代 ReAct
}标准路径的 Worker 调度也由 Supervisor 根据 ObservationEvaluator 反馈动态决定,而非让 LLM 自由选择工具。
保留路径:LLM 驱动工具选择
LLM 根据 System Prompt 中的调用规则,动态决定:
- 默认调 doc_search(语义检索)——"微服务拆分原则"
- 精确术语/API 名/版本号 → 同时调 keyword_search——"text-embedding-v3 维度"
- 时效性问题 → 同时调 web_search——"Spring Boot 最新 CVE"
- 追问/个性化 → 先调 recall_memory——"上次说的那个配置"
System Prompt 本身也是动态的——QueryProfiler 根据查询画像(Complexity × Specificity)注入自适应 topK 值和工具选择指令,而非写死 topK=15。精确查询的提示词会主动要求调 keyword_search,时效查询会主动要求调 web_search,不再依赖 LLM 自行判断。
双模型策略:主模型 + 小模型分工
工具选择是纯决策类任务(4 选 N + 简单参数),用 qwen-plus 是过度设计。配合迭代 #7 引入的双模型分工:
| 模型 | 用途 | 配置项 |
|---|---|---|
llm.model(默认 qwen-plus) | 主回答流式生成 —— 质量决定项 | 在 AiConfigHolder 持有的 activeModel 上跑 |
llm.small_model(默认 qwen-turbo) | Query 改写 / 工具选择 / Self-Reflection —— 决策与审查类 | 通过 OpenAiChatOptions.builder().model(name).build() per-call 覆盖 |
关键设计:不维护两套 ChatModel 实例——复用主模型的 OpenAiApi 连接,调用时通过 options 临时覆盖 model 名。零运维成本,仍然支持热切换。AiConfigHolder.smallModelOptions() / callSmallModel(prompt) 封装了样板代码。
单查询成本对比(典型场景):
| 步骤 | 改造前(全 qwen-plus) | 改造后(混合) |
|---|---|---|
| Query 改写 | ~$0.0001 | ~$0.000025 |
| 工具选择 | ~$0.0014 | ~$0.00035 |
| Reflection | ~$0.0028 | 70% 短路 + 30% 用小模型 ≈ $0.0002 |
| 主回答 | ~$0.008 | ~$0.008(不变) |
| 合计 | ~$0.012 | ~$0.0086(-28%) |
上表只算单查询的"算账"。整体平均成本还要再叠加:缓存命中率 30%→45%(迭代 #7)使有效 LLM 查询数减少 ~20%,最终月度成本约 -50%。三层贡献:短路 ~30% + 小模型 ~10% + 缓存 ~10%。
降级路径:规则路由
当 LLM Function Calling 失败时(网络超时、模型返回格式异常等),自动降级:
java
// DocMindAgent.java - llmDrivenRetrieve() catch 块
} catch (Exception e) {
log.warn("LLM驱动工具调用失败,降级为规则检索: {}", e.getMessage());
return fallbackMultiRetrieve(state, rewrittenQuery, emitter);
}QueryRouter 用正则匹配 + LLM 意图分类双重判断:
- 正则快速命中("第X条"→精确查询,"最新/2026"→时效查询)
- 正则未命中则用 LLM 分类意图(比正则更准但更慢)
面试话术:
"生产系统不能假设 LLM 100% 可用。双轨设计的价值在于:LLM 正常时享受智能决策的优势,异常时回退到确定性的规则路由,保证每次请求都能拿到结果。"
ThreadLocal 工具上下文
MCP 工具被 Spring AI ChatClient 调用时,结果需要回传给 Agent。问题是 Function Calling 的回调签名固定,无法额外传参。
解决方案:AgentToolContext(ThreadLocal)
java
// 激活上下文
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(); // 必须清理,防止线程池复用时数据泄漏
}面试话术:
"ThreadLocal 解决了一个实际问题:Spring AI 的 Function Calling 机制里,工具方法的返回值是给 LLM 看的(决定下一步),而实际的检索结果需要绕过 LLM 直接传给 Agent 做后续处理。ThreadLocal 让工具方法无需改签名就能实现这个'侧通道'。"
自纠错机制(Self-Reflection)
生成答案后,用独立的 LLM 调用审查答案质量:
审查维度:
1. 事实一致性 —— 答案是否与检索来源矛盾
2. 完整性 —— 是否遗漏了检索来源中的关键信息
3. 来源匹配 —— 引用标记是否对应实际来源
4. 表达质量 —— 结构是否清晰,是否有歧义- 通过 → 直接返回
- 不通过 → 记录问题,最多再审查一轮(MAX_REFLECTION_ROUNDS = 2)
- LLM 审查失败 → 降级到规则检查(答案长度、是否包含置信度低的措辞)
三层成本控制
| 层级 | 措施 | 触发率 | 节省 |
|---|---|---|---|
| 1 | 高分短路:reranker top-1 ≥ reflection.skip_threshold(默认 0.85)且候选 ≥ 3 时直接判通过 | ~70% 查询 | 0 LLM 调用,0 token |
| 2 | 小模型审查:未短路的查询用 llm.small_model(qwen-turbo)做审查,主模型 qwen-plus 只用于核心生成 | 剩余 ~30% 查询 | -75% reflection token 单价 |
| 3 | 轮次上限 + 来源裁剪:最多 2 轮,只传前 5 条来源摘要 | 极少触发 round=2 | 防 token 超支 |
热配项:
reflection.skip_threshold—— 短路阈值,调到 0.95 几乎禁用短路(回归默认每次审)llm.small_model—— 审查模型名,可改回qwen-plus保守起见
短路时 SSE 仍发 reflection 事件,附 shortCircuit: true,前端能展示"高置信,已自动通过"。
面试话术:
"Reflection 是审查类任务,不是生成类任务——用 qwen-plus 是过度设计。但更重要的是发现:当 reranker top-1 分数已经 0.9+ 时,再花 1.5s + 3000 tokens 让模型说一遍'通过'是纯浪费。所以做了高分短路。这两层叠加把反思的有效成本压缩到原来的 1/8(70% 完全跳过 + 30% 用 1/4 单价的小模型)。关键是阈值热配——线上数据如果显示短路误判率高,调到 0.95 立刻退化成原方案。"
MCP 工具双路复用
5 个工具 Bean(DocSearchTool / KeywordSearchTool / WebSearchTool / MemoryTool / KbMetaTool)同时服务内部 Agent 和外部 MCP 客户端,共暴露 6 个端点(MemoryTool 有 store + recall 两个端点):
DocSearchTool (@Tool)
├─ 内部调用:ChatClient Function Calling → 结果写入 AgentToolContext
└─ 外部调用:MCP Server HTTP/SSE → 结果直接返回给外部 Agent判断逻辑:工具内部检查 AgentToolContext.get() != null,存在则写入 ThreadLocal(内部调用),否则直接返回(外部调用)。
MCP 安全边界:内部路径天然安全——AgentToolContext 激活时,kbIds 由用户会话上下文强制覆盖(LLM 提供的 kbIds 被忽略),userId 由系统注入。外部路径当前为 MVP 阶段,无独立鉴权;生产加固需在 /mcp/** 路径加 Spring Security filter(JWT 或 API-Key),并在工具层校验 kbIds 归属 + 禁止外部指定 userId + 接入 rate-limiter。
面试话术:
"MCP 双路复用的安全模型分两层看:内部路径用 AgentToolContext 做了硬隔离——用户勾选的 kbIds 强制覆盖 LLM 参数,防止 prompt injection 越权访问其他知识库。外部路径目前是 MVP 状态,我明确规划了四个生产加固点:端点鉴权、kbIds 归属校验、userId 注入、API 限流。这不是遗漏,而是 MVP 阶段的有意识取舍——先跑通 MCP 协议对接,再按优先级补齐安全层。"
文档直读模式
当用户选中特定文档(传入 kbIds)时,系统判断是否应该跳过搜索、直接读取文档全文:
java
// SelectedDocumentScopeDecider 判断条件
boolean shouldDirectRead = kbIds.size() <= 3 && 问题是关于这些文档的;直读时使用均匀采样策略:
- 按 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) |
topicMismatch | boolean | 自纠错切题度检查结果(Phase 5) |
accumulatedEvidence | List<Evidence> | 多轮迭代累积的检索证据 |
triedStrategies | Set<String> | 已尝试的策略(防重复) |
confidenceTrajectory | List<Double> | 每轮置信度轨迹(停滞检测用) |
currentIteration / maxIterations | int | 迭代计数 / 上限 |
remainingBudget | int | Token 预算(防超支) |
Evidence 记录
java
public record Evidence(
List<RetrievedChunk> chunks, // 检索到的切片(可能为空)
String workerName, // 来源 Worker:"retrieval" / "web" / "memory" / "analysis"
double confidence, // Worker 自评置信度,-1 表示未评估
Map<String, Object> metadata // Worker 特定元数据
)metadata 示例: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, 保守工具集
QueryDecomposition 失败
└─ DecompositionResult.notDecomposed() → 跳过拆解,走标准路径
PathDecision 异常
└─ RULE_PLANNER + conservative plan(doc_search + keyword_search)
PlanGenerator LLM 生成失败
└─ 2 步默认计划(retrieval + web)
RetrievalWorker 向量检索失败
└─ 仅 BM25 结果参与 RRF(单路融合)
CrossEncoderReranker 调用失败
└─ keyword scoring fallback(保留原始 RRF 分数)
RetrievalGrader 灰区仲裁失败
└─ 直接 AMBIGUOUS → 触发 Web 兜底
SelfReflection LLM 审查失败
└─ 降级为规则检查(答案长度 + 置信度措辞检测)面试话术:
"每一层组件都有明确的 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) ← 每个关键步骤
│ └─ OTel Span(自动记录耗时、属性、异常)
│
├─ Spring AI ChatModel.call()
│ └─ ChatModelObservationFilter 自动采集 prompt / completion / token 用量
│
└─ RootNameFilteringSpanProcessor
└─ 只放行白名单 root span 的整棵 trace → BatchSpanProcessor → Langfuse HTTP关键组件
LangfuseOtelConfig(config/LangfuseOtelConfig.java):
- 通过
LangfuseProperties读取配置(langfuse.enabled/langfuse.publicKey/langfuse.secretKey/langfuse.baseUrl) - endpoint:
{baseUrl}/api/public/otel/v1/traces,Basic auth(publicKey:secretKey Base64) - BatchSpanProcessor:2 秒批量导出
RootNameFilteringSpanProcessor:
- Spring Boot tracing autoconfig 会把我们的 OTel Tracer 借走给 HTTP server / actuator / Reactor 等通通起 span,产生大量噪音
- 解决:内部维护白名单
ALLOWED_ROOT_SPAN_NAMES(DocMindAgent.execute / emergency_short_circuit / scope_short_circuit / semantic_cache_replay),只放行白名单 root span 的整棵 trace,其余丢弃 - 为什么按 name 不按 scope 过滤:Spring 桥接生成的 root span 在 startSpan() 时 name 是占位符
<unspecified span name>,跟我们手动创建的具体名字能精确分开
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):
- 桥接 Spring AI 的 Observation 机制 → OTel span 属性
- 自动采集 LLM 调用的 prompt(
gen_ai.prompt)和 completion(gen_ai.completion),截断到 10000 字符防超限
面试话术:
"Agent 系统不可观测就不可优化。Phase 6 接了 Langfuse 的 OTel 协议,Agent 的每一步——query understanding、routing、retrieval、reranking、grading、generation、reflection——都会作为 span 上报,在 Langfuse 里能看到完整的 trace 瀑布图。一个实际的例子:Phase 5 的 CRAG 灰区阈值就是我在 Langfuse 里看 rerank 分数分布后调出来的。技术上有个有意思的点:Spring Boot 会劫持我们的 OTel Tracer 给各种框架组件起 span,产生大量噪音。我写了一个 RootNameFilteringSpanProcessor,只放行我手动创建的几个入口 span 的整棵 trace,其余全丢弃——利用了 Spring 桥接 span 在创建时 name 是占位符这个特征来区分。"
面试 Q&A
Q: Agent 和普通的 RAG Chain 有什么本质区别?
A: Chain 是线性的、预定义的(A→B→C),Agent 是循环的、动态的(Thought→Action→Observation→再思考)。我的 Agent 可以根据检索结果质量决定是否追加调用其他工具,而 Chain 做不到这一点。Phase 2 升级后更明显——IterationDecider 根据置信度轨迹决定继续/切策略/终止,这种动态终止条件是 Chain 无法表达的。
Q: 为什么从单体 Agent 重构成 Supervisor-Worker?
A: 三个原因:①可维护性——原 DocMindAgent 膨胀到 1400 行,检索决策和执行逻辑耦合,新增 Worker 需改核心类。重构后 SupervisorAgent 300 行只做决策,Worker 各 80-120 行可独立测试。②可扩展性——新增 Worker 只需实现接口 + 注册,无需改编排逻辑。③多策略支持——同一个 Supervisor 可以按查询类型走不同策略(迭代/计划/拆解),比单一 ReAct 循环灵活。
Q: Plan-and-Execute 和 Query Decomposition 什么区别?
A: Query Decomposition 是"把一个问题拆成多个子问题,每个子问题走同构的检索链路"——子任务同构、无依赖关系、并行执行。Plan-and-Execute 是"让 LLM 生成一个有依赖关系的执行计划,不同步骤可以调不同工具"——子任务异构、有 DAG 依赖、需拓扑排序。我两个都实现了:简单的多焦点问题用 Decomposition(成本低),复杂的多步推理问题用 Plan-and-Execute(能力强)。
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 微服务。