工程化实践#
并发与线程安全#
ThreadLocal Agent 上下文#
问题:Spring AI Function Calling 中,多个工具的执行结果需要汇总,但工具方法签名固定,无法额外传参。
方案:AgentToolContext 使用 ThreadLocal 作为”侧通道”,工具执行时写入,Agent 读取后清理。
AgentToolContext.activate(kbIds, userId);
try {
chatClient.prompt().user(query).call().content();
List<RetrievedChunk> chunks = AgentToolContext.get().getChunks();
} finally {
AgentToolContext.clear(); // 防止线程池复用时数据泄漏
}java关键点:
- chunks 列表使用
Collections.synchronizedList防并发修改 finally块保证清理,即使 LLM 调用抛异常
SSE 流式 + Security Context 传递#
问题:SSE 推送在异步线程池执行,Spring Security 的 SecurityContext 默认绑定在请求线程。
方案:使用 DelegatingSecurityContextExecutorService 包装线程池,自动传递 SecurityContext。
// DocMindChatController.java
ExecutorService executor = Executors.newCachedThreadPool();
ExecutorService secureExecutor = new DelegatingSecurityContextExecutorService(executor);
secureExecutor.submit(() -> agent.execute(...)); // 子线程继承认证信息java缓存策略#
热点查询缓存#
问题:同一个问题被频繁问到(如”请假流程”),每次都走完整 RAG 链路浪费资源。
方案:
1. Query 归一化(去空格、统一大小写)
2. Redis 记录每个归一化 query 的访问频次
3. 频次 ≥ 阈值(默认 2,迭代 #7 从 3 调低)→ 查缓存
4. 缓存命中 → 模拟流式回放(每次推 30 字符)
5. 缓存未命中 → 正常执行,执行完写入缓存plaintext频次阈值的取舍:
| 阈值 | 缓存命中率 | 副作用 |
|---|---|---|
| 1(每条都缓存) | ~80% | 冷 query 也占空间,可能命中陈旧答案 |
| 2(默认,二次问答即缓存) | ~45% | 平衡点:常见追问场景能复用 |
| 3(旧默认,三次才缓存) | ~30% | 二次问答仍重跑全链路,浪费 |
热配项 cache.freq_threshold + cache.ttl_hours,发现命中率不健康时可在 admin panel 实时调。
面试话术:
“不是所有 query 都缓存,只缓存高频 query。这避免了缓存穿透(冷 query 不占缓存空间),同时保证了高频场景的响应速度。阈值原本是 3,迭代 #7 调到 2——观察发现很多用户会换种说法重问同一个问题,2 次就缓存能多吃一档命中率,从 30% 提升到 45%。模拟流式回放是为了前端体验一致——用户看不出来是缓存还是实时生成。“
用户长期记忆(三层存储,#34 ②)#
长期记忆下沉为三层(mcp/MemoryStore,详见 04 #34):MySQL user_memory 是系统级真相源(SoR)——读写以此为准、软删保审计、持久层不设 TTL(长期记忆不应因闲置蒸发);Redis 退为 cache-aside 读缓存(TTL 7d,成员变更失效整键);Milvus 退为纯向量索引(语义召回 + 冲突检测,命中回 SoR 取全文)。
recall_memory:Milvus 向量召回 → 回 MySQL SoR 取全文(访问计数 DB 原子自增)store_memory:从用户消息中提取显式偏好(如”叫我小明”),写 SoR → 写向量 → 失效缓存 → recency×frequency 淘汰
并发与一致性三个工程要点(记忆存储层 code review 修复):
- C1 — 缓存逐条原子写防丢更新:Redis 缓存用 Hash(
field = memoryId)而非”整用户一个 JSON blob”。旧 blob 模式下并发召回两条不同记忆各自”读整块→改→写回”会互相覆盖丢更新;改 Hash 后访问计数只put命中那一个 field,互不干扰。 - C2 — 访问计数 DB 端原子自增:
touchAccessCountAsync用access_count = access_count + 1(setSql)替代”读 JSON→改字段→写回”,并异步 best-effort、脱离召回关键路径,彻底消除读-改-写竞态,也删掉了旧的全量写+死遥测。 - C3 — 双层删除语义 + 召回对账:SoR 失效走软删(
invalidated_at,保审计);Milvus 向量则物理删除(deleteVectors,避免陈旧向量在索引里堆积、被语义召回命中)。两层之外再加一道召回期对账——semanticRecall回 SoR 取全文时,DB 健康但该 id 已非有效的命中按”陈旧向量”跳过(兜底物理删除可能的滞后/失败)。写序固定 DB(提交点,失败即中止不写向量,杜绝”有向量无 SoR”孤儿)→ Milvus → 失效缓存。
早期把 Redis 当 SoR(单节点 + 仅 RDB + 30d TTL)是架构错配——崩溃/淘汰/过期即全量蒸发;本次下沉修正了它。
配置热更新#
无重启参数调整#
问题:RAG 参数(topK、阈值、模型名称)需要频繁调试,每次改 application.yml 重启太慢。
方案:AiConfigHolder + sys_ai_config 数据库表
// 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 替换,无锁
}java模型成本分层(迭代 #7,后补记忆提取层)#
模型按任务成本分层(同一 OpenAiApi 连接,per-call 覆盖 model 名,不维护多套 ChatModel 实例):
- 主回答
llm.model(默认 qwen-plus)——保质量 - 轻决策(Query 改写 / 工具选择 / 范畴路由)
llm.small_model(默认 qwen-turbo)——够用即可 - 记忆提取
memory.extract_model(默认 qwen-flash)——「LLM 即 Gate」下每轮都触发提取(无规则预筛),用最廉价的模型承接这部分高频成本
复用主模型连接、调用时通过 OpenAiChatOptions.builder().model(name).build() per-call 覆盖 model 名:
// 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();
}java调用方:
QueryRewriter:直接aiConfigHolder.callSmallModel(prompt)DocMindAgent.llmDrivenRetrieve:ChatClient 链路.options(aiConfigHolder.smallModelOptions())MemoryExtractor.extract:.call(new Prompt(prompt, aiConfigHolder.extractModelOptions()))(记忆提取专用 qwen-flash 层)
面试话术:
“用 AtomicReference 实现 LLM 模型的热替换。旧模型引用被正在进行的 SSE 流持有,自然完成后被 GC 回收,不需要额外的引用计数或锁机制。这是一个轻量级的配置热更新方案,避免了引入 Spring Cloud Config 或 Nacos 的复杂性。
后来又叠加了一个模型成本分层——主回答必须用 qwen-plus 保质量,改写/工具选择这些决策类任务用 qwen-turbo 就够了,而记忆提取因为改成了「LLM 即 Gate」每轮都触发、量最大,单独配了一个
memory.extract_model(默认 qwen-flash)用最便宜的模型扛。实现上没有引入额外的 ChatModel Bean,而是复用同一个 OpenAiApi 连接,通过 OpenAiChatOptions per-call 覆盖 model 名。三层都支持热切换(改对应配置即可),零运维成本。这是『把成本花在该花的地方』——质量敏感的主回答不省,高频低价值的提取往死里省。“
SSE 事件协议#
chat 接口通过 SSE 推送多种命名事件,前端逐步渲染。事件时序:
understanding → scope → routing
→ [agentic × N → code_exec × N] (AGENTIC 路径;code_exec 仅当沙箱启用且模型调用)
→ retrieval → rerank → grader → [confidence_warning]
→ start → token × N → doneplaintext注:
reflection_*(自反思)与plan事件已随四刀改造删除;当前 12 个命名事件,前端契约类型在DocMind-frontend/src/types/。
| 事件 | 时机 | 内容 |
|---|---|---|
understanding | Query 理解完成 | 原始/改写 query + intent/complexity/specificity + 记忆提示 |
scope | 范畴判定完成 | 范畴类型(META/CHITCHAT/KNOWLEDGE/KB_META/OUT_OF_SCOPE)+ 置信度 + 是否 fastPath |
routing | 路径决策完成 | PathDecision mode(SELECTED_DOC/RULE_PLANNER/AGENTIC)+ reason + 工具列表 |
agentic | agentic 检索循环每轮一次 | iteration + maxIterations + toolCalls:[{name, args}] |
code_exec | 每次沙箱代码执行 | 代码 + stdout/结果(agentic 路径,executeCode) |
retrieval | 召回完成 | 总 chunk 数 + 各路来源明细 |
rerank | 重排 + 压缩完成 | topK 数 + 压缩后数量 |
grader | CRAG 评分完成 | tier(HIGH/AMBIGUOUS/LOW) + topScore + avgScore + reason |
confidence_warning | 低置信度警告 | score + band + message |
start | 开始生成 | — |
token | 每个 token | 生成的文本片段 |
done | 全部完成 | citations(结构化引用)+ citationCoverage + invalidRefs + confidenceScore/Band + 来源 + 推荐阅读 |
面试话术:
“SSE 事件协议是 Agent 透明度的基础。understanding / scope / routing / grader 让前端实时展示范畴判定、路径决策、检索评分——用户看到的不是黑盒,而是系统在做什么、进展到哪步。生成即终态(自反思已删),引用改由
done里的结构化 citations 下发,前端[n]可点击定位。“
可观测性(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,而不是只看到一个延迟数字。“
集成架构(原生 OTLP autoconfig 标准化)#
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,head-based 采样)
→ 命中才上报 → OTLP HTTP(OkHttp sender)→ Langfuseplaintext本次重构(观测系统标准化 Part 1):删掉了旧的手写 OpenTelemetry SDK——旧
LangfuseOtelConfig(228 行)手动new OtlpHttpSpanExporter+SdkTracerProvider+BatchSpanProcessor+RootNameFilteringSpanProcessor+micrometerTracer桥接 +verifyExport()启动验证。现改用 Spring Boot 3.4 原生 OTLP tracing autoconfig(spring-boot-starter-actuator+micrometer-tracing-bridge-otel+opentelemetry-exporter-otlp,由management.otlp.tracing.*/management.tracing.*配置驱动)。LangfuseOtelConfig从 228 行瘦到约 90 行,只剩两个 Bean:langfuseTracer(业务 Tracer,tracing 关闭时退 noop)+businessRootSpanSampler。
三层追踪:
- 原生 gen_ai 层:
AiConfigHolder.refreshLlmModel()给热切换的OpenAiChatModel.builder()接上ObservationRegistry,Spring AI 自动为ChatModel.call()/stream()产生gen_ai.*子 span,含 model name、gen_ai.usage.input/output_tokens——不再需要在业务 span 上手动setAttribute("gen_ai.usage.*") - 桥接层:
ChatModelObservationFilter(config/ChatModelObservationFilter.java)给原生 gen_ai 观测补上 prompt(gen_ai.prompt)和 completion(gen_ai.completion),截断到 10000 字符防超限。它本就存在,但因热切换模型丢了ObservationRegistry而一直休眠;本次接上ObservationRegistry后被激活,无需改动 - 手动层:
TracedOp.run()(support/TracedOp.java)封装 RAG 各步骤的 span 创建/属性/异常记录,消除tracer.spanBuilder().startSpan()样板代码——这层是 Langfuse trace 的实际价值,本次重构保持不变
TracedOp 设计(Phase 6)#
问题:每个 RAG 步骤都需要手动写 tracer.spanBuilder(name).startSpan() + try { ... } catch { span.setStatus(ERROR) } finally { span.end() },7 个步骤就是 7 份几乎相同的样板代码。
方案:TracedOp 工具类封装 span 生命周期:
// 替代前: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;
});java三个方法签名:run(tracer, name, attrs, body) 有返回值、run(tracer, name, body) 无属性、exec(tracer, name, attrs, body) 无返回值。类型安全的属性设置(String/Long/Double/Boolean 自动分发)。
噪声过滤:自定义 Sampler(替代 RootNameFilteringSpanProcessor)#
问题:Spring Boot tracing autoconfig 会借容器里的 OTel Tracer 给 HTTP server / actuator / Reactor 等框架组件起 span,产生大量噪音。这些 span 的 instrumentation scope 跟 RAG span 相同(都是 docmind-rag),scope 维度无法区分。
旧方案(已删):RootNameFilteringSpanProcessor 在 onEnd 阶段事后过滤——靠「Spring 桥接 span 在 startSpan() 时 name 是占位符 <unspecified span name>」这一脆弱特征区分,维护一个 traceId allowed 集合(ConcurrentHashMap.newKeySet()),到 2000 上限时 clear 防内存泄漏。
新方案:换成 head-based 的自定义 OTel Sampler,在 span 生成阶段就决策,无 traceId Set、无 2000 上限 hack:
@Bean
Sampler businessRootSpanSampler(
@Value("${management.tracing.sampling.probability:1.0}") double p) {
Set<String> allowed = Set.of(
"DocMindAgent.execute", "emergency_short_circuit",
"scope_short_circuit", "semantic_cache_replay");
Sampler ratio = Sampler.traceIdRatioBased(p);
// root: name 命中白名单 → 按采样率;否则 SamplingResult.drop()
Sampler root = (ctx, traceId, name, kind, attrs, links) -> allowed.contains(name)
? ratio.shouldSample(ctx, traceId, name, kind, attrs, links)
: SamplingResult.drop();
return Sampler.parentBased(root); // 子 span 由 parentBased 继承根决策
}javaSpring Boot OpenTelemetryAutoConfiguration 的 otelSampler 是 @ConditionalOnMissingBean,提供本 Bean 即覆盖默认 ParentBased(ratio)。HTTP/actuator/Reactor 噪声 root span 的 name 不等于这几个业务字面量 → 一律 DROP;业务手建 span 是白名单真名 → 命中;其 TracedOp 子 span 和原生 gen_ai 子 span 有合法 parent → parentBased 继承采样。比旧方案「占位符 name」假设更稳、更优雅。白名单:DocMindAgent.execute / emergency_short_circuit / scope_short_circuit / semantic_cache_replay(旧 startup-verification 随 verifyExport() 一并删除)。
⚠️ 这套「root span name 白名单 DROP」有个陷阱(#24 修复):head-based Sampler 只对有合法 parent 的子 span友好——任何在业务 root span 作用域之外发起的 LLM 调用会自成无名 root,直接被白名单 DROP。复盘真实 trace 时发现
QueryUnderstanding的 LLM 调用(合并模式 scope+分类一次出)发生在decideScope(),而旧 root span 在runReActLoop内才建——QU 调用因此没有 parent、被静默丢掉,整条 trace 的 generation 从应有的 ~10 个塌成 1 个(只剩最终答案),token/成本严重少报。修法:把DocMindAgent.executeroot span 上提到execute()顶端,try-with-resources 包住 emergency/scope/cache/主流程全部分支;decideScope再包一层scope_routingspan 给 QU 一个语义化 parent。改完单条 trace 的 generation 恢复到 10(QUqwen-turbo+ agenticqwen-plus×4 + embedding×5 + 最终qwen-plus)。经验:可观测系统建好后必须用真实 trace 回测覆盖率——「管道通了」≠「数据全了」。
条件化启用#
management.tracing.enabled=false(默认):整条 tracing autoconfig 不装配,业务Tracer注入退OpenTelemetry.noop(),span 创建开销为零、无导出尝试management.tracing.enabled=true+ Langfuse key:autoconfig 构建OtlpHttpSpanExporter(OkHttp sender)+ 异步批量导出,按上述 Sampler 白名单上报- Langfuse 认证:新增
LangfuseOtelAuthInitializer(ApplicationListener<ApplicationPreparedEvent>),用 public/secret 双 key 算 base64 注入management.otlp.tracing.headers.Authorization。⚠️ 不能用EnvironmentPostProcessor——spring-dotenv 在 EPP 之后才加载.env,ApplicationPreparedEvent才是 .env-safe 的注入时机。旧的手算 base64 + 手建 exporter +LangfuseProperties.java已删 - OkHttp sender 硬约束:pom 显式声明
opentelemetry-exporter-sender-okhttp——Langfuse 的 Next.js 网关静默关闭 keep-alive,默认 JDK HttpClient sender 会 EOF 报错
成本计费:注册 DashScope 模型价表(#24)#
Langfuse 按 generation 的 model 字段匹配内置价表算成本,但自带的 161 个模型不含阿里云 qwen 系列与 text-embedding-v3 → 匹配不到 → totalCost 恒为 0,做不了成本看板/告警/按会话归集。解决办法是经 Langfuse Public API 注册自定义价表,固化成幂等脚本 scripts/langfuse-register-models.sh(按 modelName 删旧自定义定义再重建、分页 limit=100):
| 模型 | input(¥/token) | output | 用途 |
|---|---|---|---|
| qwen-plus | 8e-7 | 2e-6 | agentic 循环 + 最终生成 |
| qwen-turbo | 3e-7 | 6e-7 | QueryUnderstanding / 闲聊 |
| qwen-max | 2.4e-6 | 9.6e-6 | 可选高质量档 |
| text-embedding-v3 | 7e-7 | 0 | 向量化(仅 input 计费) |
单价取 DashScope「标准版」¥/token(= 官网 ¥/千 token ÷ 1000)。⚠️ Langfuse UI 用 $ 作成本符号,这里录的是 ¥ 数值——相对成本/趋势准确,绝对金额按 ¥ 解读。只对新 trace 生效,历史不回填。实跑验证:注册后单条 trace totalCost 0 → 0.00816,每条 generation 的成本落在 calculatedTotalCost / costDetails(observation 级 totalCost 字段为 null,别被它误导)。
质量信号:降级标记 + Langfuse Score(#24)#
光有 span 属性还不够——rag.citation.coverage/rag.grader.tier 只能逐条 trace 看,无法跨 trace 过滤聚合,且降级回答(兜底/低置信)在 trace 列表里与正常回答长得一样。两手补齐:
- 降级标记(OTel 原生):
needsFallback || confidenceBand=LOW时给llm_generation设langfuse.observation.level=WARNING+langfuse.observation.status_message;emergency / OUT_OF_SCOPE 在 root span 标 WARNING。降级回答可按 level 过滤/告警。 - 质量分(Scores API):
LangfuseScoreClient把confidence(NUMERIC) /citation_coverage(NUMERIC) /rerank_top1(NUMERIC) /crag_grade(CATEGORICAL) 按 traceId 推成 Langfuse Score——可做分数分布、低分过滤、质量趋势。- 为何走 Scores API 而非 span 属性:Langfuse 的 OTLP 摄取没有「span 属性→score」映射约定(只认
langfuse.observation.*/gen_ai.*等固定键),score 必须POST /api/public/scores用 traceId 关联(trace 可后到、最终一致挂上)。 - 非侵入:守护线程 fire-and-forget,失败只记日志、不影响问答;
langfuse.enabled=false退 no-op。 - 坑:JDK
HttpClient默认 HTTP/2 打 Langfuse 的 Next.js 网关回header parser received no bytes(与 OTLP 当初被迫换 OkHttp sender 同源)——强制 HTTP/1.1 + 失败重试一次 解决。
- 为何走 Scores API 而非 span 属性:Langfuse 的 OTLP 摄取没有「span 属性→score」映射约定(只认
三套埋点统一:StageEmitter 发射门面(Part 2)#
观测三套埋点过去并行手写:SSE 事件(前端时间线,sendSseEvent 复制 3 份)、agentTrace(DB 持久化 + 回放,addTrace/updateTrace 复制 2 份)、OTel span 属性(散落 100+ 处)。同一步要发三次、改一处忘两处即漂移。
方案:新增 agent/emit/StageEmitter.java(每请求一个普通对象,持 (SseEmitter, AgentState)):
- 收口单一
sse()(合并 3 份sendSseEvent)+trace()/traceObs()(合并 2 份addTrace/updateTrace); - 每个 SSE 事件的 payload 形状在门面集中定义一次(understanding/routing/retrieval(三形状,可空参)/rerank/grader/confidenceWarning(两形状)/scope/agenticStep/start/token/done/error)——契约从 34 个散落调用点收敛成单一事实源;
- 三个编排类各处
new StageEmitter(emitter, state)局部包装后调用,旧 helper 全删。
诚实的边界:OTel span 是「时长」(需 TracedOp 的 start/scope/end 生命周期),不属点事件,仍由 TracedOp 收口、setAttribute 留原位——门面只统一两类点事件(SSE + agentTrace);done(20+ 字段、按路径定制) 由调用方构造、门面只发送;rerank.topScore 用 float(与 RetrievedChunk.rerankScore 一致)防 JSON 值漂移。
验证(golden SSE 契约 diff):SSE 契约 byte-identical 是硬约束(前端按事件名 + key 硬编码消费)。改前抓 6 路径基线 {scenario → {event → key 集合} + 事件序列},分阶段(主路径 → 全量)重抓比对:主路径迁移后 per-scenario 9/9 全等;全量后 per-scenario 仅缓存路径报差——经查是运行时非确定性(LLM 路由 one-shot↔agentic 漂移 + citationCoverage 是 put(...,null) 被 fastjson2 省略的数据依赖键),改用 global-union 契约(跨场景池化 event→key)证明逐事件零回归。教训:观测重构的验证必须能区分「契约漂移」与「运行时非确定性」。
面试 Q&A#
Q: 为什么从手写 OTel SDK 改成原生 autoconfig?
A: 手写 SDK(228 行)是历史包袱。当初的理由是「Spring Boot 3.4 的 OTel autoconfig 与 spring-ai 版本冲突」,但实测
mvn dependency:tree显示 OTel core 全部统一解析为 1.43.0、与micrometer-tracing-bridge-otel 1.4.13完全对齐,冲突前提为伪。真正的隐患是 pom 多 import 了一个未使用的opentelemetry-instrumentation-bom:2.17.0(潜在版本偏斜源)。删掉它、改用原生 autoconfig 后,代码从 228 行瘦到约 90 行,行为不变。
Q: 噪声 span 怎么过滤?为什么不按 scope 维度?
A: Spring Boot 把容器里的 Tracer Bean 借给各种框架组件,它们的 scope 跟 RAG span 撞成同一个
docmind-rag,scope 区分不了。我用一个自定义Sampler按 root span name 白名单做 head-based 采样(ParentBased让子 span 继承根决策),覆盖 autoconfig 默认的otelSampler。比旧的RootNameFilteringSpanProcessor(事后过滤 + 靠占位符 name 区分 + traceId Set 2000 上限)更优雅:决策发生在 span 生成阶段,不命中直接不建,也没有内存上限 hack。
Q: OTel span 创建有性能开销吗?
A: 有但极小。span 创建是内存操作(纳秒级),导出是异步批量的。
management.tracing.enabled=false时整条 autoconfig 不装配、业务 Tracer 退OpenTelemetry.noop(),连内存操作都省了。
Q: 为什么不直接用 Langfuse Java SDK?
A: Langfuse 官方 Java SDK 只是 API 的薄封装,没有自动 batching 和 context 传播。官方推荐 Java 项目走 OTel 路线——Spring Boot actuator 的 OTLP tracing autoconfig 加 Spring AI 内置的 Micrometer Observation,配好
management.otlp.tracing.*就能对接 Langfuse,不需要侵入业务代码。
用户体验增强#
置信度标注#
背景:LLM 生成的答案质量参差不齐,用户无法判断答案可信度。系统内部有 Self-Reflection 评分,但未对外暴露。
方案:在 SSE done 事件中输出三级置信度标注(高/中/低),前端通过彩色徽章直观展示。
// 分级逻辑(DocMindAgent.classifyConfidenceBand)
if (needsFallback) return "LOW";
if (confidence >= 0.85) return "HIGH"; // 反思高分或 rerank top-1 短路
if (confidence >= 0.60) return "MEDIUM";
return "LOW";java前端呈现:
- 高:绿色徽章
置信度: 高 85% - 中:黄色徽章 + 辅助文案
- 低:红色徽章 + 警告文案”内容仅供参考,建议结合原始文档验证”
推荐阅读(RecommendationGenerator)#
背景:用户阅读完答案后,往往需要进一步探索相关文档,但不知道知识库里还有什么。
方案:基于已检索 chunk 的 tags 和 category,反查同主题的其他文档作为延伸阅读推荐。
推荐策略(双路径):
- 同类优先:chunk 所属文档的
category→ 查同 category 其他文档 - 标签补充:chunk 的
tagsJSON 数组 → LIKE 匹配其他文档的 chunk → 反查 kb
// 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));java限制:最多返回 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 工程实践(生产视角)#
这一节把”调 LLM API”上升到”在生产里稳定、省钱、可信地用 LLM”。面试里一旦被问”你这套东西上线会出什么问题”,答案都在这里。每条都标注了现状与生产加固方向——能讲清楚边界比假装没有边界更有说服力。
一、稳定性:LLM 是网络依赖,必须当成”会挂的下游”#
LLM/Embedding/Rerank 本质都是第三方 HTTP 调用,会超时、会限速、会偶发 5xx。设计时把它们当成”随时可能挂的下游”,而不是本地函数。
1. 超时分层#
| 调用 | 现状 | 文件 |
|---|---|---|
| Web Search(Tavily) | 显式 Duration 超时,默认 30s | WebSearchConfig.java / application.yml |
| MinerU 文档解析 | connect/read 60s + 轮询 poll-timeout 10m(异步长任务) | MinerUConfig.java |
| LLM 主调用 | 依赖 Spring AI / 底层 HTTP 客户端默认值,llm.timeout_seconds 配置项已读出但尚未注入 OpenAiApi 的 RestClient | AiConfigHolder.java |
| Reranker | 裸 new RestTemplate(),无显式超时——rerank API 卡住会拖慢整条链路 | CrossEncoderReranker.java |
生产加固方向:给 OpenAiApi.builder() 注入带超时的 RestClient(ClientHttpRequestFactory 设 connect/read timeout),Reranker 同理。这是一个已知的、可量化的改进点——面试时主动点出来,比被问出来强。
关键认知:流式调用的超时语义不同于普通请求——不能用”整体响应时间”做超时,要用首 token 超时 + token 间隔超时(idle timeout)。一个慢但持续吐字的流是健康的,一个”连上了但 30s 不吐字”的流才该掐断。
2. 重试与退避#
| 维度 | 现状 |
|---|---|
| LLM 调用 | 依赖 Spring AI 内置 RetryTemplate(默认指数退避,对 5xx/超时重试),未自定义 |
| 幂等性 | RAG 查询天然幂等(无副作用),重试安全 |
| 不该重试的情况 | 4xx(参数错、配额超)重试无意义;流式已吐出部分 token 后失败不能简单重试,否则用户看到重复内容 |
面试话术:
“重试要分清楚可重试和不可重试。超时、5xx、限流(429)可以退避重试;4xx 参数错重试只是浪费配额。最坑的是流式——已经吐了一半 token 再失败,重试会让前端出现重复段落,正确做法是要么整段重来并通知前端清空重渲染,要么记录断点。“
3. 限流与配额#
现状:内部调用路径未做限流。并发的天然上限来自 ragRetrievalExecutor 线程池(core=8 / max=16 / queue=20 / CallerRunsPolicy)——满了之后 caller-runs 自然反压,不会无限堆积压垮下游。
生产加固方向(与 CLAUDE.md MCP 安全边界表一致):
- 对外 MCP 端点接 Bucket4j / Redis 令牌桶,按用户/租户限 QPS 和 TPM
- LLM 厂商侧有 TPM(tokens-per-minute)配额,高并发下要做客户端侧令牌预算,而不是等 429 再被动退避
面试话术:
“我现在的并发控制是线程池兜底——
ragRetrievalExecutor用有界队列 + CallerRunsPolicy 实现自然反压,打满了就让上游线程自己跑,不丢任务也不雪崩。但这只是被动防护。生产级要主动限流:LLM 厂商按 TPM 计费和限速,光限请求数不够,得估算每个请求的 token 量做令牌桶。对外暴露的 MCP 工具更要按租户限流,否则一个客户端能把整个 Tavily 配额吃光。“
4. 全链路降级(已落地)#
LLM 链路每一环都有次优但可用的退路,详见文末「降级策略汇总」表。核心几条:
- Embedding API 挂 → 跳过向量检索,只用 BM25
- Rerank API 超时/异常 →
fallbackRerank()退化为关键词覆盖度打分(CrossEncoderReranker.java:211) - Query 理解 LLM 失败 →
QueryClassification.fallback():isAmbiguous=true+ 保守工具集,宁可多召回也不漏 - Redis 缓存挂 → 跳过缓存正常执行
没有模型级 fallback(主模型挂了不会自动切备用模型)——这是诚实的现状。双模型策略(qwen-plus / qwen-turbo)是成本分层而非故障转移。生产加固可加:主模型连续失败 → 熔断 → 切备用 endpoint 或降级模型。
二、成本与性能:token 是钱,延迟是体验#
1. 上下文压缩 = 直接省 token#
CrossEncoderReranker.compress() 在喂给 LLM 前做 query-aware 四步压缩(#35,原独立 ContextCompressor 已下沉进 reranker):
1. 精确去重 — 按 dedupeKey()(id 优先,缺失退化内容指纹)判重
2. 近重复去除 — 包含关系 + 字符 bigram Jaccard ≥ compress.near_dup_jaccard(默认0.85),抓父/子块重叠、web boilerplate
3. query-aware 截断 — 单条超 800 字时【以命中段为中心】取窗口而非保头:命中段优先用展开前子块原文(getChildContent),
其次 query 词命中位,都没有才退回保头;窗口做句界吸附(snapStart/snapEnd)避免从句中切
4. 总量控制 — 按 token 预算贪心累加,至少留 1 条;【超预算的块 skip 而非 break】,让后续更短的高分块继续装箱plaintext为什么截断不再无脑保头:做了父块展开后,真正命中的子块正文常落在父块的中后段——保头一刀就把命中段砍掉,表现为”检索到却答不出”。query-aware 命中段居中截断修掉这个回归(#35 ③)。
token 估算的工程取舍:CrossEncoderReranker.estimateTokens 用语种感知的系数粗估,不引入 tiktoken 之类的精确分词器:
| 文本类型 | 系数 | 说明 |
|---|---|---|
| 中文(CJK) | 0.6 token/字 | qwen BPE 实测约 0.6,不用保守的 1.0(会高估 60%) |
| 英文/拉丁 | 4 字符/token | 经典经验值 |
| 代码型(codeLike,computations 计算结果通道) | 2.5 字符/token | 密集标点/缩进 BPE 切分更碎,宁可高估 |
关键修复(#33):旧版完全不计空白、对代码按英文 4 字符估——预算层低估方向最致命(“以为没超、其实撑爆”)。现改为①空白计入非 CJK 桶;②新增 codeLike 重载走更保守系数;③两桶均 Math.ceil 向上取整。原则”宁可高估不可低估”。
默认 rag.context_max_tokens=3000,文档直读模式放宽到 rag.document_scope_max_tokens=5000。chunks 与三条辅助流(computations/memory/history)再共同受 prompt.budget.total_max_tokens(默认 6000)全局天花板约束,chunks 优先、aux 让位(见 00 事实卡 #32/#33)。都是热配项。
面试话术:
“压缩 context 是 RAG 最直接的省钱点,但我这版重点是’压缩别把答案压没了’。最早是无脑保前 800 字——可我们做了父块展开,真正命中的子块正文往往落在父块中后段,保头一刀就砍掉了命中段。所以改成 query-aware 的命中段居中截断,优先按展开前子块原文定位,还做句界吸附。token 估算我没用精确分词器——按语种系数粗估足够做预算控制,但踩过一个坑:旧版不计空白、把代码也按英文估,结果预算层低估、prompt 撑爆 window,所以我加了空白计入 + 代码走更保守系数 + 向上取整,原则是预算层宁可高估不可低估。“
2. 语义缓存:从”精确命中”到”语义命中”#
SemanticCacheService(service/rag/SemanticCacheService.java)的缓存命中是向量相似度而非字符串精确匹配:
查询 → embed → 对 Redis Hash 里所有缓存条目暴力算 cosine 相似度
→ 最高分 ≥ 阈值(默认 0.92) 且 kbIds 范围匹配 → 命中plaintext| 设计点 | 取值 | 理由 |
|---|---|---|
| 匹配方式 | cosine 相似度 | ”请假怎么请” vs “如何申请休假” 字符串不同但语义同,精确匹配会漏 |
| 相似度阈值 | 0.92(热配 cache.semantic.distance_threshold) | 太低会命中不相关问题答错,0.92 偏保守保正确性 |
| 检索方式 | 全量 HVALS + Java 内暴力 cosine | 100-500 条 × 1024 维 ≈ 5ms,规模小没必要上向量索引 |
| 写入门控 | 频次 ≥ 阈值(默认 3)才写 | 避免冷 query 占缓存(缓存穿透防护) |
| TTL | 24h 兜底 | 防陈旧答案长期驻留 |
关键认知:语义缓存的最大风险是误命中——两个相近但不同的问题被判为同一个,返回错答案。所以阈值宁高勿低(0.92),且要带 kbIds 范围校验——不同知识库的相同问题答案不同,不能跨库命中。
⚠️ 注:本节是
SemanticCacheService的语义缓存(向量匹配,门控阈值 3);前文「热点查询缓存」描述的是更上层的归一化精确匹配 + 流式回放路径(门控阈值 2)。两者门控值不同是各自独立调出来的,不是笔误。
3. Embedding 复用:增量索引跳过向量化#
文档处理链路用 SHA-256 content_hash 做增量索引(详见「文档处理链路」节):同 hash 复用旧 vector_id,跳过 embedding。100 页文档改 1 字 ≈ 1 次 embedding,而非全量重算。Embedding 是按 token 计费的,这一步直接省钱。
生产加固方向:运行时的 query embedding 目前每次 lookup 都实时算(语义缓存里),高频相同 query 可加一层 query→embedding 的短 TTL 缓存。
4. 流式首 token 延迟(TTFT)#
SSE 流式(Spring AI Reactor)的核心体验指标是 TTFT(time-to-first-token),不是总时长。用户感知”卡”是因为首 token 迟迟不来——而 RAG 的检索+重排都在首 token 之前,所以检索链路的延迟直接转嫁成 TTFT。
这也是规则引擎替代 LLM 做路由(省 ~300ms)、缓存命中走流式回放的真正价值:砍的都是首 token 之前的时间。
streamUsage(true)(AiConfigHolder.java)是个易踩的坑——DashScope/OpenAI 兼容模式下,流式调用必须显式开启才会在末尾 chunk 返回 token usage,否则 Langfuse 的 token/成本统计全是 0。
5. 并发:有界线程池 + 反压#
Supervisor-Worker 的并行召回跑在 ragRetrievalExecutor(RagExecutorConfig.java):
new ThreadPoolExecutor(
8, 16, // core=8, max=16
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(20), // 有界队列,给突发流量缓冲
daemonThreadFactory("rag-retrieval-"),
new ThreadPoolExecutor.CallerRunsPolicy() // 满了让调用线程自己跑 → 自然反压
);java为什么不用 Executors.newCachedThreadPool():无界线程池在 LLM 异常堆积时会无限建线程打爆 JVM。有界队列 + CallerRunsPolicy 是生产级线程池的标准姿势——可控、不丢任务、自然反压。
三、质量与安全:让 LLM “可信”#
1. 幻觉控制:生成即终态 + 三层防护#
当前实现:生成即终态,自反思子系统已于四刀改造整类删除——不再有生成后的二次审查 / 条件重写 / 四维打分 / 切题度检查。幻觉控制改由生成前后的三层规则化机制承担:
检索完成 → (a) CRAG 质量闸 RetrievalGrader.grade → 生成 → (b) CitationParser 解析覆盖率 + (c) SafetyGuard 规则校验
├─ (a) CRAG 三档(HIGH / AMBIGUOUS / LOW):生成前质量闸,LOW → 切换降级 prompt(显式声明"检索不足")
├─ (b) 结构化引用:纯 Java 解析答案里的 [n] → 句子级覆盖率,越界编号丢弃计入 invalidRefs
│ confidenceScore = clamp(coverage) × rerank-top1(coverage 不可算时退回 rerank-top1)
└─ (c) SafetyGuard:规则化安全校验(应急/兜底场景标注)plaintext- (a) CRAG
RetrievalGrader:三档评分作为生成前质量闸,LOW 时直接降级 prompt,从源头压住”无据生成”。 - (b) 结构化引用
CitationParser:纯 Java 解析最终答案里的[n]标记,算出句子级覆盖率(含 ≥1 个有效[n]的实质陈述句占比),越界编号进invalidRefs;置信度由覆盖率派生,替代了原 SelfReflection 的置信度来源。 - (c)
SafetyGuard:规则化安全校验,对应急/兜底场景做标注。
历史背景:早期做过 Self-Reflection 小模型四维打分 + 规则否定矛盾校验(忠实度 +0.037),后经评测发现流式下收益有限 + 对标主流而移除。
面试话术:
“幻觉控制现在是三层,全部规则化、生成即终态,没有 LLM 二次自评:第一层 CRAG 检索质量闸——生成前给检索结果打三档分,LOW 就切降级 prompt 显式声明检索不足,从源头不让模型硬编;第二层结构化引用覆盖率——纯 Java 解析答案里的 [n] 标记,算句子级覆盖率,越界编号丢进 invalidRefs,置信度直接由覆盖率 × rerank-top1 给出;第三层 SafetyGuard 规则安全校验。早期我做过 LLM 自反思小模型四维打分,忠实度涨了约 0.037,但后来发现流式场景下收益有限,又要对标主流产品,就整体删掉了。“
2. Prompt 工程的版本管理#
现状:7 个 prompt 模板放在 src/main/resources/prompts/,靠 Git 做版本追踪,没有独立的版本号字段或灰度机制。改 prompt = 改文件 + 提交。
生产加固方向:
- prompt 是”代码”,改它和改代码一样会引入回归——理想做法是 prompt 进评测集回归(见文档 09「评测体系」),改完跑离线评测看指标有没有掉
- 多版本灰度:给模板加版本号,按流量百分比分配,A/B 对比指标后再全量
- 把 prompt 从 jar 里挪到配置中心(类似现在 RAG 参数走
sys_ai_config),改 prompt 不重新打包
3. 输出校验:LLM 的 JSON 不可信#
LLM 输出的”JSON”经常带 markdown 围栏(```json)、前后废话、字段缺失。QueryUnderstandingService 的容错(QueryUnderstandingService.java):
LLM raw 输出
→ extractJsonObject() 剥掉 ```json 围栏和前后噪音
→ fastjson2 解析
→ 白名单校验枚举值,非法值置 null(不抛异常)
→ 整体解析失败 catch → QueryClassification.fallback()plaintext关键认知:永远不要假设 LLM 会严格遵守输出格式。哪怕 prompt 里写了”只输出 JSON”,也要做剥壳 + 解析容错 + 字段白名单 + 整体降级。结构化输出(function calling / JSON mode)能降低概率但不能 100% 保证。
面试话术:
“我对 LLM 输出 JSON 的态度是’不信任’。它会给你包 markdown 围栏、加解释性废话、漏字段。所以解析是四层防御:先正则剥壳提取 JSON 主体、再 fastjson 解析、字段按枚举白名单校验非法值置 null、整体失败兜底到 fallback 分类。这种容错让一次 LLM 抽风不会让整个请求 500。“
4. 注入防护与权限隔离#
| 风险 | 防护 |
|---|---|
| kbIds 权限穿透 | AgentToolContext.isActive() 时,kbIds 由用户会话上下文强制覆盖,LLM 无法通过参数篡改去访问别的知识库(CLAUDE.md 安全边界) |
| Memory 读写隔离 | 内部记忆写入走 DocMindAgent.stageMemoryWrite() 直接 Java 调用,不走 LLM function calling,防止 LLM 被诱导误触发写入 |
| Prompt 注入 | 检索到的文档内容会被拼进 prompt,理论上存在”文档里藏指令”的注入面——当前靠模板结构(明确区分指令区/上下文区)缓解,未做专门的注入检测 |
面试话术:
“Agent 调工具时,最危险的是 LLM 篡改参数越权。我的防线是:kbIds 这种权限相关参数绝不信任 LLM 传的值——
AgentToolContext激活时强制用会话上下文里的真实 kbIds 覆盖。记忆写入更敏感,干脆不暴露给 LLM 的 function calling,走 Java 直接调用,从根上杜绝误触发。Prompt 注入(文档里藏’忽略以上指令’)是已知但还没专门处理的面,目前靠模板把指令和上下文分区缓解。“
5. 敏感信息处理#
trace 里会记录完整 prompt/completion 内容(ChatModelObservationFilter.java):
- 截断:prompt 和 completion 都截到 10000 字符(防 span 属性超限)
- 未做 PII 脱敏:这是诚实的现状——可观测性记录了原文,生产环境若涉及个人/敏感数据,需在写 span 前加脱敏(手机号/身份证/邮箱正则掩码)
四、可观测性:看得见才调得动#
详细的 Langfuse + OpenTelemetry 集成架构见前文「可观测性」节,这里只补 LLM 特有的”该看什么指标”。
LLM 应用必看的几类指标#
| 指标 | 怎么来 | 为什么重要 |
|---|---|---|
| token usage(input/output) | streamUsage(true) → Spring AI Observation 自动采集 → Langfuse | 直接对应成本;input 暴涨说明 context 没压好 |
| 每次调用的 prompt / completion 原文 | ChatModelObservationFilter 写入 span 属性 | 出了幻觉能回溯到底喂了什么、答了什么 |
| TTFT / 各阶段耗时 | TracedOp 给每个 RAG 步骤建 span | 定位是检索慢还是生成慢 |
| 缓存命中率 | Redis ZSet 频次 + 缓存命中日志 | 命中率不健康(太低浪费、太高可能误命中)要调阈值 |
| reflection confidence 分布 | span 属性 rag.reflection.confidence | confidence 普遍偏低说明检索质量差,不是生成问题 |
面试话术:
“监控 LLM 应用和监控普通服务不一样——普通服务看 QPS/延迟/错误率就够了,LLM 还要看 token 消耗(这是钱)、prompt/completion 原文(这是排查幻觉的唯一线索)、每个 RAG 阶段的耗时(定位是检索慢还是生成慢)。我用 OTel + Langfuse,因为 Langfuse 原生理解这些语义,能在一个 trace 里把’检索了哪些 chunk → 拼了什么 prompt → LLM 答了什么 → 花了多少 token’串起来。有个坑是 token 统计——DashScope 流式必须显式
streamUsage(true)才返回 usage,不然成本数据全是 0。“
降级策略汇总#
| 组件 | 正常路径 | 降级路径 | 触发条件 |
|---|---|---|---|
| 工具选择 | 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 过期 → 重新登录plaintext关键实现:
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:
ExecutorService secureExecutor = new DelegatingSecurityContextExecutorService(
Executors.newCachedThreadPool()
);
secureExecutor.submit(() -> agent.execute(...));java如果不包装,子线程 SecurityContextHolder.getContext() 返回空,导致认证信息丢失。
面试话术:
“JWT 双 token 是标准实践——短期 access token 保证安全性(泄露影响窗口小),长期 refresh token 保证体验(不需要频繁登录)。SSE 的安全上下文传递是个容易忽略的点——Spring Security 默认 ThreadLocal 模式,异步线程拿不到认证信息,需要用
DelegatingSecurityContextExecutorService包装线程池。“
文档处理链路#
端到端流程#
用户上传 PDF/DOCX/PPT/图片/URL
│
▼
KnowledgeBaseController — 保存元信息到 kb_knowledge_base(status=uploading)
│
▼
原件持久化到本地磁盘(`uploads/`,不再用对象存储;原 MinioService 已删)
│
▼
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 → readyplaintext增量索引核心#
文档更新时走 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 工程#
模板架构#
Prompt 模板存放在 src/main/resources/prompts/,通过 PromptAssembler 在运行时动态注入变量(knowledge_qa_decomposed.txt / query_decompose.txt 已随四刀改造删除拆解链路一并移除):
| 模板 | 触发场景 | 注入变量 |
|---|---|---|
knowledge_qa.txt | 标准回答生成 | question, context(编号+来源注解的 chunk 列表), history(最近 6 条), memoryContext, userProfile |
query_understanding.txt | Query 理解(分类 + 改写) | query + history → 输出 JSON(intent/complexity/specificity/scope 等字段) |
hyde_generation.txt | 假设文档生成 | query → 输出 ~200 字假设回答 |
knowledge_qa_low_confidence.txt | 低置信度作答(CRAG LOW 但有证据) | 同上,仍注入 KB/Web chunk,提示”相关性偏低、基于有限参考内容”,要求据实作答并 [n] 引用(#28 主通路) |
safety_check.txt | 答案安全审查 | query + context + answer → 输出安全评估 |
PromptAssembler 三模式#
assemble() — 标准 QA,context 每条格式:[1] 知识库《DocName》 - Chapter 5 第8页 [v2.1] 标签:technical
assembleLowConfidence() — 低置信度(CRAG LOW 但有证据):仍注入 KB/Web chunk 据实作答并 [n] 引用(#28 主通路,不丢弃证据)
assembleFallback() — 真·无证据兜底(compressed 空,0 chunk):声明"知识库与联网检索均未返回相关内容",并加防陈旧幻觉护栏(严禁断言实体"不存在/未发布/查无此项"、保持简洁,#28 P1-A)java四刀改造删除拆解链路后,原
assembleDecomposed()(拆解模式,每条 chunk 标注”(子问题 #1, #2)“归属)已移除——多焦点/多跳改由 agentic 检索循环在运行时自驱,不再走预拆解模板。
对话历史裁剪#
从 qa_message 表取最近 6 条消息(3 轮对话),格式化为 “用户:…\n助手:…” 注入 {{history}}。6 条是 token 预算和上下文质量的平衡点。
通用方法论:few-shot / CoT / 输出约束#
上面是 DocMind 的模板落地,这里补一层通用方法论——把「写 prompt」当成接口设计而非写咒语(地图见 17-AI-Agent通用知识地图 L2)。
- 角色设定 + 清晰指令:给模型明确身份和任务边界,比模糊指令稳定得多。
knowledge_qa.txt开头即固定角色。 - Few-shot:给 1–N 个输入输出范例让模型照着做,对格式约束尤其有效。本项目
query_understanding.txt用 few-shot 例子把分类/改写的 JSON 字段格式遵循率拉满。 - CoT(思维链):让模型「先推理再给答案」提升多步推理准确率,代价是更多 token + 延迟——简单决策类任务(如工具选择)反而不该用,那是规则引擎的活。
- 输出约束(正向,事前):除了下文「LLM 的 JSON 不可信」的事后容错,还应在 prompt 侧做事前约束:
- JSON mode / 结构化输出:API 层强制只吐合法 JSON,降低解析失败率。
- XML 标签分隔:用
<context>...</context>/<question>...</question>把指令区和数据区分开,既提升遵循度,也是抗 prompt 注入的缓解手段。 - 关键认知:正向约束降低出错概率但不能 100% 保证,必须配合事后剥壳 + 白名单 + 降级——「事前约束 + 事后兜底」缺一不可。
推理参数补注:
llm.chat_temperature决定采样随机性——决策/抽取类任务用 0 或接近 0 保可复现,创作类才调高。top-p(核采样)与 temperature 通常二选一为主。temperature>0 正是 RAG 系统不确定性的来源之一,也是工具选择/路由这类决策 DocMind 用规则引擎而非 LLM 的原因之一。
面试话术:
“Prompt 工程不是写一个 prompt 然后调参。我有 7 个模板,PromptAssembler 根据场景选择不同模式组装——标准、拆解、降级三种。关键设计是 context 的注解格式:每条 chunk 带编号、文档名、章节、页码、版本号、标签,让 LLM 能做来源引用。降级模式会显式告诉 LLM ‘检索结果不足’,避免在证据不充分时编造答案。“
前端架构#
ChatPage + useChat — SSE 事件状态机#
2026-06 前端已从 Vue 3 重构为 React 18。聊天核心拆为
features/chat/ChatPage.tsx+useChat.tshook(原生EventSource,40ms token 缓冲刷新)+ThinkingTimeline/SourcePanel/MessageBubble等子组件 +rehypeCitations插件(可点击[n])。
useChat 是一个 SSE 事件驱动的状态机,处理 12 个命名事件(顺序见下;reflection_* 自反思事件已随四刀改造删除,新增 code_exec 沙箱执行事件):
EventSource 连接(lib/sse.ts openChatStream)
├── understanding → 改写 + 分类结果(intent/complexity/specificity/scope)
├── routing → 路径决策(SELECTED_DOC/RULE_PLANNER/AGENTIC + 工具列表)
├── agentic* → agentic 循环每轮一条(iteration + maxIterations + toolCalls)
├── code_exec* → 每次沙箱代码执行一条(agentic 路径,executeCode)
├── retrieval → 多路检索结果(vector/BM25/web 各路数量)
├── rerank → 精排结果(topK + 压缩后数量)
├── grader → CRAG 三档评分(tier + topScore + avgScore)
├── start → 开始流式生成
├── token* → 逐 token 追加到 Markdown 渲染区
└── done → citations(结构化引用)+ citationCoverage + invalidRefs
+ confidenceScore/confidenceBand + 来源卡片 + 推荐阅读plaintext思考时间线(ThinkingTimeline.tsx)#
将 Agent 内部推理过程以时间线 UI 呈现给用户:
- 实时展示:每收到一个 SSE 事件就追加一个时间线节点(understand → routing → agentic*/code_exec* → retrieval → rerank → grader → generating)
- 自动折叠:生成完成后折叠为一行摘要(如”理解 → 路由 → 检索 → 重排 → 生成”),可点击展开详情
- 路由徽章:路径决策 + grader 评分以绿/黄/红三色徽章展示在气泡顶部,鼠标悬停看判定理由和置信度百分比
- 检索日志弹窗(
RetrievalProcessDialog.tsx):点击 retrieval 节点可查看 vector/BM25/web 各路详细指标
关键 UI 组件#
- 流式 Markdown 渲染(
MarkdownMessage.tsx):token 事件逐字追加,实时渲染 Markdown(代码块高亮、表格、列表),rehypeCitations把[n]渲染为可点击引用 - 来源卡片(
SourcePanel.tsx):显示文档名 + 章节 + 页码 + 相关性分数,点击[n]定位 - 思考时间线(
ThinkingTimeline.tsx):可展开的步骤时间线,完成后自动折叠 - 路由/评分徽章:路径决策 + grader 三档 + confidence band 三色徽章
- 检索日志弹窗:vector/BM25/web 各路指标透明展示
面试话术:
“前端不是简单的聊天界面。思考时间线把 Agent 的每一步(理解、路由、agentic 循环、检索、评分、生成)实时展示给用户,完成后自动折叠成一行摘要不干扰阅读。引用用结构化 citations + 句子级覆盖率算置信度,绿/黄/红三色徽章直观展示,
[n]可点击定位原文。这是可解释 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 了发送异常,不会因为客户端断开导致服务端线程泄漏。但当前没有做断点续传——如果连接断开,用户需要重新提问。