外观
工程化实践
并发与线程安全
ThreadLocal Agent 上下文
问题:Spring AI Function Calling 中,多个工具的执行结果需要汇总,但工具方法签名固定,无法额外传参。
方案:AgentToolContext 使用 ThreadLocal 作为"侧通道",工具执行时写入,Agent 读取后清理。
java
AgentToolContext.activate(kbIds, userId);
try {
chatClient.prompt().user(query).call().content();
List<RetrievedChunk> chunks = AgentToolContext.get().getChunks();
} finally {
AgentToolContext.clear(); // 防止线程池复用时数据泄漏
}关键点:
- chunks 列表使用
Collections.synchronizedList防并发修改 finally块保证清理,即使 LLM 调用抛异常
SSE 流式 + Security Context 传递
问题:SSE 推送在异步线程池执行,Spring Security 的 SecurityContext 默认绑定在请求线程。
方案:使用 DelegatingSecurityContextExecutorService 包装线程池,自动传递 SecurityContext。
java
// DocMindChatController.java
ExecutorService executor = Executors.newCachedThreadPool();
ExecutorService secureExecutor = new DelegatingSecurityContextExecutorService(executor);
secureExecutor.submit(() -> agent.execute(...)); // 子线程继承认证信息缓存策略
热点查询缓存
问题:同一个问题被频繁问到(如"请假流程"),每次都走完整 RAG 链路浪费资源。
方案:
1. Query 归一化(去空格、统一大小写)
2. Redis 记录每个归一化 query 的访问频次
3. 频次 ≥ 阈值(默认 2,迭代 #7 从 3 调低)→ 查缓存
4. 缓存命中 → 模拟流式回放(每次推 30 字符)
5. 缓存未命中 → 正常执行,执行完写入缓存频次阈值的取舍:
| 阈值 | 缓存命中率 | 副作用 |
|---|---|---|
| 1(每条都缓存) | ~80% | 冷 query 也占空间,可能命中陈旧答案 |
| 2(默认,二次问答即缓存) | ~45% | 平衡点:常见追问场景能复用 |
| 3(旧默认,三次才缓存) | ~30% | 二次问答仍重跑全链路,浪费 |
热配项 cache.freq_threshold + cache.ttl_hours,发现命中率不健康时可在 admin panel 实时调。
面试话术:
"不是所有 query 都缓存,只缓存高频 query。这避免了缓存穿透(冷 query 不占缓存空间),同时保证了高频场景的响应速度。阈值原本是 3,迭代 #7 调到 2——观察发现很多用户会换种说法重问同一个问题,2 次就缓存能多吃一档命中率,从 30% 提升到 45%。模拟流式回放是为了前端体验一致——用户看不出来是缓存还是实时生成。"
用户长期记忆
Redis 同时存储用户偏好和历史上下文(TTL 30天):
recall_memory:检索时读取用户历史偏好store_memory:从用户消息中提取显式偏好(如"叫我小明")
配置热更新
无重启参数调整
问题:RAG 参数(topK、阈值、模型名称)需要频繁调试,每次改 application.yml 重启太慢。
方案:AiConfigHolder + sys_ai_config 数据库表
java
// AiConfigHolder.java
// ConcurrentHashMap 存储配置,AtomicReference 存储 ChatModel
private final ConcurrentHashMap<String, String> configMap;
private final AtomicReference<OpenAiChatModel> chatModelRef;
// 更新时原子替换,正在进行的请求持有旧引用不受影响
public void refreshModel() {
OpenAiChatModel newModel = buildChatModel(getConfig("llm.model"));
chatModelRef.set(newModel); // CAS 替换,无锁
}双模型策略(迭代 #7)
主回答用 llm.model(默认 qwen-plus),轻决策类任务(Query 改写 / 工具选择 / Self-Reflection)用 llm.small_model(默认 qwen-turbo)。关键:不维护两套 ChatModel 实例——复用主模型的 OpenAiApi 连接,调用时通过 OpenAiChatOptions.builder().model(name).build() per-call 覆盖 model 名:
java
// AiConfigHolder.java(迭代 #7 新增)
public OpenAiChatOptions smallModelOptions() {
return OpenAiChatOptions.builder()
.model(getString("llm.small_model")) // 默认 qwen-turbo
.temperature(getFloat("llm.chat_temperature"))
.build();
}
public String callSmallModel(String prompt) {
return activeModel.get()
.call(new Prompt(prompt, smallModelOptions()))
.getResult().getOutput().getText().trim();
}调用方:
QueryRewriter、SelfReflection:直接aiConfigHolder.callSmallModel(prompt)DocMindAgent.llmDrivenRetrieve:ChatClient 链路.options(aiConfigHolder.smallModelOptions())
面试话术:
"用 AtomicReference 实现 LLM 模型的热替换。旧模型引用被正在进行的 SSE 流持有,自然完成后被 GC 回收,不需要额外的引用计数或锁机制。这是一个轻量级的配置热更新方案,避免了引入 Spring Cloud Config 或 Nacos 的复杂性。
后来又叠加了一个双模型策略——主回答必须用 qwen-plus 保质量,但改写、工具选择、反思这些决策类任务用 qwen-turbo 就够了。实现上没有引入第二个 ChatModel Bean,而是复用同一个 OpenAiApi 连接,通过 OpenAiChatOptions per-call 覆盖 model 名。这样小模型同样支持热切换(改
llm.small_model配置即可),而且零运维成本。"
SSE 事件协议
chat 接口通过 SSE 推送多种命名事件,前端逐步渲染。事件时序:
scope → understanding → [routing] → [plan] → retrieval → [grader] → rerank
→ start → token × N
→ [reflection_start → reflection_token × N → reflection_done]
→ [confidence_warning]
→ done| 事件 | 时机 | 内容 |
|---|---|---|
scope | 范畴判定完成(Phase 5) | 范畴类型 + 置信度 + 是否 fastPath |
understanding | Query 理解完成 | 原始/改写 query + intent/complexity/specificity + subQueries |
routing | 路径决策完成(Phase 5) | PathDecision mode + reason + 工具列表 |
plan | Plan-and-Execute 计划生成 | steps + reasoning + fallback |
retrieval | 召回完成 | 总 chunk 数 + 各路来源明细 |
grader | CRAG 评分完成(Phase 5) | tier(HIGH/AMBIGUOUS/LOW) + topScore + avgScore + reason |
rerank | 重排完成 | topK 数 + 压缩后数量 |
start | 开始生成 | — |
token | 每个 token | 生成的文本片段 |
confidence_warning | 低置信度警告 | score + band + message |
reflection_start | 重写开始 | 清空旧答案 |
reflection_token | 重写 token | 重写文本片段 |
reflection_done | 重写完成 | 重写后置信度 |
done | 全部完成 | 来源 + 耗时 + Agent trace + 推荐阅读 + 置信度 |
面试话术:
"SSE 事件协议是 Agent 透明度的基础。Phase 5/6 新增了 scope / routing / grader 三种事件,前端能实时展示范畴判定、路径决策、检索评分的结果——用户看到的不是黑盒,而是系统在做什么、进展到哪步。"
可观测性(Langfuse + OpenTelemetry,Phase 6 增强)
为什么选 Langfuse 而不是 Prometheus
| 维度 | Prometheus + Grafana | Langfuse |
|---|---|---|
| 定位 | 通用应用监控 | LLM 应用专用可观测性 |
| 原生指标 | QPS、延迟、错误率 | prompt/completion、token 消耗、模型成本 |
| 追踪粒度 | 请求级 | LLM 调用级(含输入输出内容) |
| RAG 支持 | 需全部手动埋点 | 检索 + 生成自动关联 |
面试话术:
"对于 RAG 系统,Langfuse 比 Prometheus 更合适——它理解 AI 应用的语义。比如我可以在 Langfuse 面板上直接看到某次问答的 prompt 是什么、检索到了哪些 chunk、LLM 回答了什么、花了多少 token,而不是只看到一个延迟数字。"
集成架构(Phase 6 升级)
DocMindAgent.execute() / 短路路径
│
├─ TracedOp.run("span_name", attrs, body) ← 每个关键步骤
│ └─ OTel Span(自动记录耗时、属性、异常)
│
├─ Spring AI ChatModel.call() / stream()
│ └─ ChatModelObservationFilter 自动采集 prompt / completion / token 用量
│
└─ RootNameFilteringSpanProcessor(白名单过滤)
└─ 只放行 Agent 关键 trace → BatchSpanProcessor(2秒批量)→ Langfuse HTTP三层追踪:
- 自动层:Spring AI 为 ChatModel.call()/stream() 自动生成 generation span,含 model name、token usage
- 桥接层(Phase 6 新增):
ChatModelObservationFilter(config/ChatModelObservationFilter.java)桥接 Spring AI Observation → OTel span 属性,自动采集 prompt(gen_ai.prompt)和 completion(gen_ai.completion),截断到 10000 字符防超限 - 手动层:
TracedOp.run()(support/TracedOp.java)封装 span 创建/属性/异常记录,消除tracer.spanBuilder().startSpan()样板代码
TracedOp 设计(Phase 6)
问题:每个 RAG 步骤都需要手动写 tracer.spanBuilder(name).startSpan() + try { ... } catch { span.setStatus(ERROR) } finally { span.end() },7 个步骤就是 7 份几乎相同的样板代码。
方案:TracedOp 工具类封装 span 生命周期:
java
// 替代前:14 行样板代码
Span span = tracer.spanBuilder("rrf_fusion").setAttribute("count", size).startSpan();
try (Scope ignored = span.makeCurrent()) {
var result = rrfFusion.fuse(vector, bm25, topN);
span.setAttribute("output_count", result.size());
return result;
} catch (Exception e) {
span.setStatus(StatusCode.ERROR); span.recordException(e); throw e;
} finally { span.end(); }
// 替代后:4 行
TracedOp.run(tracer, "rrf_fusion", Map.of("rag.fusion.vector_count", size), span -> {
var result = rrfFusion.fuse(vector, bm25, topN);
span.setAttribute("rag.fusion.output_count", result.size());
return result;
});三个方法签名:run(tracer, name, attrs, body) 有返回值、run(tracer, name, body) 无属性、exec(tracer, name, attrs, body) 无返回值。类型安全的属性设置(String/Long/Double/Boolean 自动分发)。
RootNameFilteringSpanProcessor 白名单(Phase 6)
问题:Spring Boot tracing autoconfig 会劫持 OTel Tracer 给 HTTP server / actuator / Reactor 等起 span,产生大量噪音。这些 span 的 instrumentation scope 跟 RAG span 相同(都是 docmind-rag),scope 维度无法区分。
解决:利用 Spring 桥接生成的 root span 在 startSpan() 时 name 是占位符 <unspecified span name>,而我们手动创建的 span 有具体名字(DocMindAgent.execute 等)这一特征精确分开。
白名单:DocMindAgent.execute / emergency_short_circuit / scope_short_circuit / semantic_cache_replay / startup-verification
实现:onStart 检查 root span name → 命中白名单则 traceId 加入 allowed 集合 → onEnd 只放行 allowed 集合中的 span。ConcurrentHashMap.newKeySet() 线程安全,到 2000 上限时 clear 防止内存泄漏。
条件化启用
langfuse.enabled=false(默认):OpenTelemetry.noop(),span 创建开销为零langfuse.enabled=true:构建完整 OTel SDK + BatchSpanProcessor + OTLP HTTP exporter- 启动验证:
verifyExport()发测试 span +forceFlush等待 10 秒确认连通 @PreDestroy关闭时 forceFlush 5 秒刷出剩余 span
面试 Q&A
Q: OTel span 创建有性能开销吗?
A: 有但极小。span 创建是内存操作(纳秒级),导出是异步批量的(2 秒 schedule)。
langfuse.enabled=false时回退到OpenTelemetry.noop(),连内存操作都省了。
Q: 为什么不直接用 Langfuse Java SDK?
A: Langfuse 官方 Java SDK 只是 API 的薄封装,没有自动 batching 和 context 传播。官方推荐 Java 项目走 OTel 路线——Spring AI 已经内置了 Micrometer Observation,加个 OTLP exporter 就能对接 Langfuse,不需要侵入业务代码。
Q: 白名单过滤为什么不按 scope 维度?
A: Spring Boot 把容器里注册的 Tracer Bean 借走给各种框架组件,它们的 scope 跟我们 RAG span 撞成同一个
docmind-rag。但 Spring 桥接生成的 root span 创建时 name 是占位符,我们手动建的有具体名字,这个差异比 scope 更精确。
用户体验增强
置信度标注
背景:LLM 生成的答案质量参差不齐,用户无法判断答案可信度。系统内部有 Self-Reflection 评分,但未对外暴露。
方案:在 SSE done 事件中输出三级置信度标注(高/中/低),前端通过彩色徽章直观展示。
java
// 分级逻辑(DocMindAgent.classifyConfidenceBand)
if (needsFallback) return "LOW";
if (confidence >= 0.85) return "HIGH"; // 反思高分或 rerank top-1 短路
if (confidence >= 0.60) return "MEDIUM";
return "LOW";前端呈现:
- 高:绿色徽章
置信度: 高 85% - 中:黄色徽章 + 辅助文案
- 低:红色徽章 + 警告文案"内容仅供参考,建议结合原始文档验证"
推荐阅读(RecommendationGenerator)
背景:用户阅读完答案后,往往需要进一步探索相关文档,但不知道知识库里还有什么。
方案:基于已检索 chunk 的 tags 和 category,反查同主题的其他文档作为延伸阅读推荐。
推荐策略(双路径):
- 同类优先:chunk 所属文档的
category→ 查同 category 其他文档 - 标签补充:chunk 的
tagsJSON 数组 → LIKE 匹配其他文档的 chunk → 反查 kb
java
// RecommendationGenerator 核心逻辑
Set<String> categories = chunks.stream().map(c -> c.getCategory()).collect(toSet());
Set<String> tags = extractTags(chunks); // JSON array 解析
// 策略 1: 同 category 排除已用文档
List<KB> candidates = findByCategoryExcluding(categories, usedKbIds);
// 策略 2: tags LIKE 匹配补充
if (candidates.size() < 3) candidates.addAll(findBySharedTags(tags, excluded));限制:最多返回 3 条推荐,避免信息过载。
RetrievalPlanner 规则引擎的工程价值(Phase 5)
PathDecision + RetrievalPlanner 将路径决策和工具选择从 LLM 调用替换为纯规则引擎,这不只是"省延迟"——从工程化角度有四层价值:
| 维度 | LLM Function Calling | 规则引擎(RetrievalPlanner) |
|---|---|---|
| 确定性 | 同一输入可能产出不同结果(temperature > 0) | 同一 QueryClassification 永远产出相同工具组合 |
| 可测试性 | 需 mock LLM,断言不稳定 | 直接 assertEquals(expected, planner.plan(input)) |
| 延迟 | ~300ms(网络往返 + 推理) | <1ms(4 个 if 判断) |
| 可解释性 | LLM 决策不可追溯 | PathDecision.reason 字段记录判定路径,写入 trace |
| 可维护性 | 改 prompt 影响面不可控 | 改规则 = 改 if 条件,影响面明确 |
面试话术:
"不是所有决策都适合交给 LLM。工具选择的规则空间很小——4 个信号映射到 4 个工具的子集——用 LLM 是过度设计。规则引擎的价值不只是省 300ms 延迟,更重要的是确定性(同一输入永远同一输出)、可测试性(单元测试直接断言)、可解释性(PathDecision.reason 写入 trace 可追溯)。这是 LLM 做不到的。"
降级策略汇总
| 组件 | 正常路径 | 降级路径 | 触发条件 |
|---|---|---|---|
| 工具选择 | LLM Function Calling | QueryRouter 规则路由 | LLM 调用异常 |
| 自适应参数 | QueryProfiler 查询画像 | 全局默认参数 | rag.adaptive.enabled=false 或文档直读模式 |
| 向量检索 | Milvus COSINE | 跳过,只用 BM25 | embedding API 异常 |
| BM25 | MySQL FULLTEXT | 应用内分词 + 模糊匹配 | FULLTEXT 无结果 |
| RRF 融合 | 加权融合(自适应 weight) | 等权融合(weight=1.0) | 无 QueryProfile 时 |
| 重排序 | Cross-Encoder API | 关键词覆盖度打分 | rerank API 超时/异常 |
| 自纠错 | LLM 审查 | 规则检查(长度/措辞) | LLM 审查调用异常 |
| 缓存 | Redis 缓存 | 跳过缓存,正常执行 | Redis 连接异常 |
面试话术:
"每个关键组件都有降级方案,这是生产级系统的基本要求。降级不是简单地返回错误,而是用次优但可用的方案继续服务。用户可能感知到质量下降,但不会看到 500 错误。"
安全体系
JWT 双 Token 认证
| Token | 有效期 | 用途 |
|---|---|---|
| Access Token | 24 小时 | 请求认证,放在 Authorization: Bearer 头 |
| Refresh Token | 7 天 | 刷新 Access Token,减少重新登录频率 |
认证流程:
登录 → 返回 accessToken + refreshToken
→ 每次请求 Authorization: Bearer <accessToken>
→ JwtAuthenticationFilter 解析 JWT,设置 SecurityContext
→ accessToken 过期 → 用 refreshToken 换新 accessToken
→ refreshToken 过期 → 重新登录关键实现:
JwtAuthenticationFilter:OncePerRequestFilter,从 header 解析 token → JwtUtils 验证 → 构建 UsernamePasswordAuthenticationToken → 设置 SecurityContextSecurityConfig:stateless session(不存 session),白名单路径(/api/users/login, /api/users/register),所有其他路径需认证- 密码加密:BCrypt(
$2a$10$...),Spring Security PasswordEncoder - RBAC:admin 和 user 两个角色,
@PreAuthorize控制接口权限
SSE 异步线程安全
SSE 推送在 DelegatingSecurityContextExecutorService 包装的线程池中执行,子线程自动继承 SecurityContext:
java
ExecutorService secureExecutor = new DelegatingSecurityContextExecutorService(
Executors.newCachedThreadPool()
);
secureExecutor.submit(() -> agent.execute(...));如果不包装,子线程 SecurityContextHolder.getContext() 返回空,导致认证信息丢失。
面试话术:
"JWT 双 token 是标准实践——短期 access token 保证安全性(泄露影响窗口小),长期 refresh token 保证体验(不需要频繁登录)。SSE 的安全上下文传递是个容易忽略的点——Spring Security 默认 ThreadLocal 模式,异步线程拿不到认证信息,需要用
DelegatingSecurityContextExecutorService包装线程池。"
文档处理链路
端到端流程
用户上传 PDF/DOCX/PPT/图片/URL
│
▼
KnowledgeBaseController — 保存元信息到 kb_knowledge_base(status=uploading)
│
▼
MinioService — 原件持久化到 MinIO(S3 兼容)
│
▼
DocumentProcessTask(@Async 异步线程池)
│
├── DocumentExtractor — 格式路由
│ PDF → MinerU 云端 API(layout-aware)+ PDFBox 降级
│ DOCX → Apache POI
│ PPT/图片 → MinerU(必经,fail-fast)
│ URL → MinerU-HTML 正文抽取
│ TXT/MD → 直读 UTF-8
│ ↓
│ 统一输出 Markdown 字符串
│
├── TextChunker — Markdown-Aware 双层切块
│ 5 种 block 识别(HEADING/TABLE/CODE/IMAGE/PARAGRAPH)
│ ├─ 子块 ~400 字 → MySQL + Milvus(embedding 1024 维)
│ └─ 父块 ~1500 字 → MySQL only(不入 Milvus,省向量化成本)
│ 每块计算 SHA-256 content_hash + heading 栈 breadcrumb
│
├── MilvusService — 向量入库(COSINE, HNSW)
│ vector_id 由 task 端预生成 UUID,同步到 MySQL 和 Milvus
│
└── BM25Retriever — Lucene 全文索引增量追加
status → ready增量索引核心
文档更新时走 processIncremental() 而非全量重建:
- 新切块计算 content_hash
- 按 (kb_id, content_hash) 查老 chunk 的 MultiMap
- 同 hash → 复用 vector_id,跳过 embedding(核心收益点)
- 新 hash → embedding + 写库
- 旧 hash 未命中 → 按 vector_id 精准删 Milvus
面试话术:
"文档处理链路的核心设计有三个:第一是 MinerU 统一所有格式为 Markdown,下游检索链路零行改动。第二是 Markdown-Aware 切块——表格和代码块原子保留不切碎,heading 切换强制 flush 防跨章节合并。第三是 SHA-256 增量索引——100 页文档改 1 字仅需 ~1 次 embedding,核心是通过 content_hash diff 跳过未变更 chunk 的向量化。"
Prompt 工程
模板架构
8 个 Prompt 模板存放在 src/main/resources/prompts/,通过 PromptAssembler 在运行时动态注入变量:
| 模板 | 触发场景 | 注入变量 |
|---|---|---|
knowledge_qa.txt | 标准回答生成 | question, context(编号+来源注解的 chunk 列表), history(最近 6 条), memoryContext, userProfile |
knowledge_qa_decomposed.txt | 子问题拆解模式 | 同上 + subQueries + 每条 chunk 标注 (子问题 #1, #2) |
query_understanding.txt | Phase 1a 分类 | query + history → 输出 9 字段 JSON |
query_decompose.txt | Phase 1b 子问题拆解 | query → 输出 JSON 数组 |
hyde_generation.txt | 假设文档生成 | query → 输出 ~200 字假设回答 |
knowledge_qa_low_confidence.txt | 低置信度降级回答 | 同上,显式声明"检索结果不足以充分回答" |
memory_extract.txt | 用户记忆提取 | conversation → 输出结构化记忆点 |
safety_check.txt | 答案安全审查 | query + context + answer → 输出安全评估 |
PromptAssembler 三模式
java
assemble() — 标准 QA,context 每条格式:[1] 知识库《DocName》 - Chapter 5 第8页 [v2.1] 标签:technical
assembleDecomposed() — 拆解模式,每条 chunk 额外标注 "(子问题 #1, #2)" 归属
assembleFallback() — 低置信度降级,显式声明"当前检索结果不足以充分回答"对话历史裁剪
从 qa_message 表取最近 6 条消息(3 轮对话),格式化为 "用户:...\n助手:..." 注入 。6 条是 token 预算和上下文质量的平衡点。
面试话术:
"Prompt 工程不是写一个 prompt 然后调参。我有 8 个模板,PromptAssembler 根据场景选择不同模式组装——标准、拆解、降级三种。关键设计是 context 的注解格式:每条 chunk 带编号、文档名、章节、页码、版本号、标签,让 LLM 能做来源引用。降级模式会显式告诉 LLM '检索结果不足',避免在证据不充分时编造答案。"
前端架构
ChatView.vue — SSE 事件状态机
前端核心是一个 SSE 事件驱动的状态机,处理 12+ 种事件类型(DocMind-frontend/src/views/chat/ChatView.vue):
EventSource 连接
├── scope → Phase 5: 范畴判定结果(META/CHITCHAT/KNOWLEDGE/KB_META/OOB)
├── understanding → Phase 5: 改写 + 分类结果(intent/complexity/specificity/subQueries)
├── routing → Phase 5: 路径决策(SELECTED_DOC/DECOMPOSED/RULE_PLANNER + 工具列表)
├── plan → Plan-and-Execute 计划(steps + reasoning)
├── retrieval → 多路检索结果(vector/BM25/web 各路数量)
├── grader → Phase 5: CRAG 三档评分(tier + topScore + avgScore)
├── rerank → 精排结果(topK + 压缩后数量)
├── start → 开始流式生成
├── token → 逐 token 追加到 Markdown 渲染区
├── confidence_warning → 低置信度警告
├── reflection_start → 清空答案,准备替换
├── reflection_token → 流式渲染重写答案
├── reflection_done → 标记重写完成
└── done → 渲染来源卡片 + 置信度徽章 + 推荐阅读 + Agent trace思考时间线(Phase 6 新增)
Phase 6 将 Agent 内部推理过程以时间线 UI 呈现给用户:
- 实时展示:每收到一个 SSE 事件就追加一个时间线节点(scope → understand → rewrite → routing → plan → retrieval → grader → rerank → warning → reflection → generating)
- 自动折叠:生成完成后折叠为一行摘要(如"理解 → 路由 → 检索 → 重排 → 生成"),可点击展开详情
- 路由徽章:scope 决策 + grader 评分以绿/黄/红三色徽章展示在气泡顶部,鼠标悬停看判定理由和置信度百分比
- 检索日志弹窗:点击 retrieval 节点可查看 vector/BM25/RRF 各路详细指标
关键 UI 组件
- 流式 Markdown 渲染:token 事件逐字追加,实时渲染 Markdown(代码块高亮、表格、列表)
- 来源卡片:横向卡片列表,显示文档名 + 章节 + 页码 + 相关性分数
- 思考时间线(Phase 6):可展开的步骤时间线,11 种步骤类型,完成后自动折叠
- 路由/评分徽章(Phase 5):scope 范畴 + grader 三档 + confidence band 三色徽章
- 检索日志弹窗(Phase 5):vector/BM25/RRF 各路指标透明展示
面试话术:
"前端不是简单的聊天界面。Phase 6 做了思考时间线——Agent 的每一步(理解、路由、检索、评分、生成、反思)都实时展示给用户,完成后自动折叠成一行摘要不干扰阅读。检索置信度用绿/黄/红三色徽章直观展示。这是可解释 AI 的前端实践——让用户知道答案是怎么来的,建立信任。"
面试 Q&A
Q: ThreadLocal 在异步场景下会有什么问题?
A: ThreadLocal 不跨线程传递。当前的工具调用是同步的(ChatClient.call() 阻塞),所以没问题。如果改成异步(CompletableFuture、WebFlux),需要用 InheritableThreadLocal 或手动传递 Context。但 InheritableThreadLocal 在线程池场景也有问题(线程复用时不会重新继承),需要用 TransmittableThreadLocal(阿里开源)。
Q: 热配置更新怎么保证一致性?
A: 不保证强一致。更新时原子替换 ChatModel 引用,旧请求用旧模型跑完,新请求用新模型。在 RAG 系统中这是可接受的——参数微调不需要所有请求瞬间切换。如果需要强一致,可以加版本号 + 全局屏障,但没必要。
Q: SSE 连接断开怎么处理?
A: SseEmitter 有超时机制(默认 180s),超时后自动关闭。Agent 的 sendSseEvent 方法 catch 了发送异常,不会因为客户端断开导致服务端线程泄漏。但当前没有做断点续传——如果连接断开,用户需要重新提问。