Skip to content

工程化实践

并发与线程安全

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();
}

调用方:

  • QueryRewriterSelfReflection:直接 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
understandingQuery 理解完成原始/改写 query + intent/complexity/specificity + subQueries
routing路径决策完成(Phase 5)PathDecision mode + reason + 工具列表
planPlan-and-Execute 计划生成steps + reasoning + fallback
retrieval召回完成总 chunk 数 + 各路来源明细
graderCRAG 评分完成(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 + GrafanaLangfuse
定位通用应用监控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

三层追踪

  1. 自动层:Spring AI 为 ChatModel.call()/stream() 自动生成 generation span,含 model name、token usage
  2. 桥接层(Phase 6 新增):ChatModelObservationFilterconfig/ChatModelObservationFilter.java)桥接 Spring AI Observation → OTel span 属性,自动采集 prompt(gen_ai.prompt)和 completion(gen_ai.completion),截断到 10000 字符防超限
  3. 手动层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 是占位符 &lt;unspecified span name&gt;,而我们手动创建的 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 的 tagscategory,反查同主题的其他文档作为延伸阅读推荐。

推荐策略(双路径)

  1. 同类优先:chunk 所属文档的 category → 查同 category 其他文档
  2. 标签补充:chunk 的 tags JSON 数组 → 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 CallingQueryRouter 规则路由LLM 调用异常
自适应参数QueryProfiler 查询画像全局默认参数rag.adaptive.enabled=false 或文档直读模式
向量检索Milvus COSINE跳过,只用 BM25embedding API 异常
BM25MySQL FULLTEXT应用内分词 + 模糊匹配FULLTEXT 无结果
RRF 融合加权融合(自适应 weight)等权融合(weight=1.0)无 QueryProfile 时
重排序Cross-Encoder API关键词覆盖度打分rerank API 超时/异常
自纠错LLM 审查规则检查(长度/措辞)LLM 审查调用异常
缓存Redis 缓存跳过缓存,正常执行Redis 连接异常

面试话术

"每个关键组件都有降级方案,这是生产级系统的基本要求。降级不是简单地返回错误,而是用次优但可用的方案继续服务。用户可能感知到质量下降,但不会看到 500 错误。"

安全体系

JWT 双 Token 认证

Token有效期用途
Access Token24 小时请求认证,放在 Authorization: Bearer 头
Refresh Token7 天刷新 Access Token,减少重新登录频率

认证流程

登录 → 返回 accessToken + refreshToken
  → 每次请求 Authorization: Bearer <accessToken>
  → JwtAuthenticationFilter 解析 JWT,设置 SecurityContext
  → accessToken 过期 → 用 refreshToken 换新 accessToken
  → refreshToken 过期 → 重新登录

关键实现

  • JwtAuthenticationFilter:OncePerRequestFilter,从 header 解析 token → JwtUtils 验证 → 构建 UsernamePasswordAuthenticationToken → 设置 SecurityContext
  • SecurityConfig: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() 而非全量重建:

  1. 新切块计算 content_hash
  2. 按 (kb_id, content_hash) 查老 chunk 的 MultiMap
  3. 同 hash → 复用 vector_id,跳过 embedding(核心收益点)
  4. 新 hash → embedding + 写库
  5. 旧 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.txtPhase 1a 分类query + history → 输出 9 字段 JSON
query_decompose.txtPhase 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 了发送异常,不会因为客户端断开导致服务端线程泄漏。但当前没有做断点续传——如果连接断开,用户需要重新提问。