Skip to content

Agentic RAG 核心设计

范畴判定层(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)的做法是用一个独立的小模型评估检索质量,分三档触发不同后续动作。本项目把"用小模型"这一步换成了 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)

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

模式触发条件效果
SELECTED_DOC用户选中 1-8 份文档 + 命中文档/摘要意图直读 DB chunks + 均匀采样,跳过所有 Worker
DECOMPOSEDhasDecomposition=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 调用)

RetrievalPlannerservice/rag/RetrievalPlanner.java)在 RULE_PLANNER 模式下执行,根据 QueryClassification 的 4 个信号决定调用哪些工具:

始终包含 ─────────────────────── doc_search(向量检索)
isAmbiguous=true 或 PRECISE ──── + keyword_search(BM25 精确匹配兜底)
timeAware + 时效关键词命中 ───── + web_search(联网搜索)
memoryAware ──────────────────── + recall_memory(用户长期记忆)

关键设计细节

  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 对比较类问题的 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 → 返回 SupervisorResult

IterationDecider 终止条件

  • currentConfidence ≥ terminateThreshold(默认 0.7)
  • currentIteration ≥ maxIterations(默认 3)
  • 置信度轨迹连续 2 轮无提升(停滞检测)

Plan-and-Execute 模式(复杂查询)

QueryClassification.complexity == COMPLEX 时,SupervisorAgent 走 Plan-and-Execute 路径:

  1. PlanGenerator:用小模型(qwen-turbo)生成结构化 JSON 执行计划

    json
    &#123;
      "reasoning": "规划思路",
      "steps": [
        &#123;"index": 1, "action": "retrieval", "query": "...", "dependsOn": []&#125;,
        &#123;"index": 2, "action": "web", "query": "...", "dependsOn": [1]&#125;
      ]
    &#125;
  2. PlanExecutor:按依赖拓扑分层,同层步骤并行执行

    • buildExecutionLayers()dependsOn 拓扑排序
    • 每层通过 CompletableFuture.supplyAsyncragRetrievalExecutor 线程池并行
    • 步骤间可传递上下文(前序步骤结果写入 AgentState
  3. Fallback:PlanGenerator LLM 调用失败 → 退化为 2 步默认计划(retrieval + web)

ObservationEvaluator 动态降级链

每轮迭代后 ObservationEvaluator 评估检索质量,推荐下一步动作:

条件推荐动作场景
召回为空SWITCH_TO_WEB知识库无相关内容
召回分数 < 0.4TRIGGER_HYDE用户表述和文档用词差异大
Top-4 来自 ≥3 文档且分数差 < 0.1INVOKE_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.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 / DECOMPOSED / RULE_PLANNER)+ RetrievalPlanner(规则引擎)+ MemoryToolrouting
3多路检索编排(四条路径)SupervisorAgent.orchestrateplan? / retrieval
4融合 + 精排 + 多样化 + 早停RRFFusion + CrossEncoderReranker + MMRDiversifier + EarlyStopGatererank
4.5CRAG 置信度分级RetrievalGrader(从 Evidence.metadata 读)grader
5Prompt 组装PromptAssembler(三模板)
6LLM 流式生成ChatModel.stream(主模型 qwen-plus)start / token×N
7Self-Reflection 自纠错SelfReflection.reflect(小模型)+ 高分短路reflection_start/token/done
8置信度分级 + 低置信警告classifyConfidenceBandconfidence_warning
9语义缓存写入SemanticCacheService.putIfFrequent
10持久化 + 推荐阅读QaMessageMapper + RecommendationGeneratordone

四条检索路径(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.002870% 短路 + 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 执行过程中的所有中间状态都记录在 AgentStateagent/state/AgentState.java)中,每个 Worker 的执行结果封装为 Evidenceagent/state/Evidence.java)。

AgentState 关键字段

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

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 并更新 triedStrategiesgetAllEvidenceChunks() 合并全部 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

关键组件

LangfuseOtelConfigconfig/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 是占位符 &lt;unspecified span name&gt;,跟我们手动创建的具体名字能精确分开

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):

  • 桥接 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 微服务。