面试知识库

工程化实践#

并发与线程安全#

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 缓存用 Hashfield = memoryId)而非”整用户一个 JSON blob”。旧 blob 模式下并发召回两条不同记忆各自”读整块→改→写回”会互相覆盖丢更新;改 Hash 后访问计数只 put 命中那一个 field,互不干扰。
  • C2 — 访问计数 DB 端原子自增touchAccessCountAsyncaccess_count = access_count + 1setSql)替代”读 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 → done
plaintext

注:reflection_*(自反思)与 plan 事件已随四刀改造删除;当前 12 个命名事件,前端契约类型在 DocMind-frontend/src/types/

事件时机内容
understandingQuery 理解完成原始/改写 query + intent/complexity/specificity + 记忆提示
scope范畴判定完成范畴类型(META/CHITCHAT/KNOWLEDGE/KB_META/OUT_OF_SCOPE)+ 置信度 + 是否 fastPath
routing路径决策完成PathDecision mode(SELECTED_DOC/RULE_PLANNER/AGENTIC)+ reason + 工具列表
agenticagentic 检索循环每轮一次iteration + maxIterations + toolCalls:[{name, args}]
code_exec每次沙箱代码执行代码 + stdout/结果(agentic 路径,executeCode)
retrieval召回完成总 chunk 数 + 各路来源明细
rerank重排 + 压缩完成topK 数 + 压缩后数量
graderCRAG 评分完成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 + GrafanaLangfuse
定位通用应用监控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)→ Langfuse
plaintext

本次重构(观测系统标准化 Part 1):删掉了旧的手写 OpenTelemetry SDK——旧 LangfuseOtelConfig(228 行)手动 new OtlpHttpSpanExporter + SdkTracerProvider + BatchSpanProcessor + RootNameFilteringSpanProcessor + micrometerTracer 桥接 + verifyExport() 启动验证。现改用 Spring Boot 3.4 原生 OTLP tracing autoconfigspring-boot-starter-actuator + micrometer-tracing-bridge-otel + opentelemetry-exporter-otlp,由 management.otlp.tracing.* / management.tracing.* 配置驱动)。LangfuseOtelConfig 从 228 行瘦到约 90 行,只剩两个 Bean:langfuseTracer(业务 Tracer,tracing 关闭时退 noop)+ businessRootSpanSampler

三层追踪

  1. 原生 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.*")
  2. 桥接层ChatModelObservationFilterconfig/ChatModelObservationFilter.java)给原生 gen_ai 观测补上 prompt(gen_ai.prompt)和 completion(gen_ai.completion),截断到 10000 字符防超限。它本就存在,但因热切换模型丢了 ObservationRegistry 而一直休眠;本次接上 ObservationRegistry 后被激活,无需改动
  3. 手动层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 生命周期:

三个方法签名: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 维度无法区分。

旧方案(已删)RootNameFilteringSpanProcessoronEnd 阶段事后过滤——靠「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 继承根决策
}
java

Spring Boot OpenTelemetryAutoConfigurationotelSampler@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-verificationverifyExport() 一并删除)。

⚠️ 这套「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.execute root span 上提到 execute() 顶端,try-with-resources 包住 emergency/scope/cache/主流程全部分支;decideScope 再包一层 scope_routing span 给 QU 一个语义化 parent。改完单条 trace 的 generation 恢复到 10(QU qwen-turbo + agentic qwen-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 认证:新增 LangfuseOtelAuthInitializerApplicationListener<ApplicationPreparedEvent>),用 public/secret 双 key 算 base64 注入 management.otlp.tracing.headers.Authorization。⚠️ 不能用 EnvironmentPostProcessor——spring-dotenv 在 EPP 之后才加载 .envApplicationPreparedEvent 才是 .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-plus8e-72e-6agentic 循环 + 最终生成
qwen-turbo3e-76e-7QueryUnderstanding / 闲聊
qwen-max2.4e-69.6e-6可选高质量档
text-embedding-v37e-70向量化(仅 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 列表里与正常回答长得一样。两手补齐:

  1. 降级标记(OTel 原生)needsFallback || confidenceBand=LOW 时给 llm_generationlangfuse.observation.level=WARNING + langfuse.observation.status_message;emergency / OUT_OF_SCOPE 在 root span 标 WARNING。降级回答可按 level 过滤/告警。
  2. 质量分(Scores API)LangfuseScoreClientconfidence(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 + 失败重试一次 解决。

三套埋点统一: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.topScorefloat(与 RetrievedChunk.rerankScore 一致)防 JSON 值漂移。

验证(golden SSE 契约 diff):SSE 契约 byte-identical 是硬约束(前端按事件名 + key 硬编码消费)。改前抓 6 路径基线 {scenario → {event → key 集合} + 事件序列},分阶段(主路径 → 全量)重抓比对:主路径迁移后 per-scenario 9/9 全等;全量后 per-scenario 仅缓存路径报差——经查是运行时非确定性(LLM 路由 one-shot↔agentic 漂移 + citationCoverageput(...,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 的 tagscategory,反查同主题的其他文档作为延伸阅读推荐。

推荐策略(双路径)

  1. 同类优先:chunk 所属文档的 category → 查同 category 其他文档
  2. 标签补充:chunk 的 tags JSON 数组 → 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 超时,默认 30sWebSearchConfig.java / application.yml
MinerU 文档解析connect/read 60s + 轮询 poll-timeout 10m(异步长任务)MinerUConfig.java
LLM 主调用依赖 Spring AI / 底层 HTTP 客户端默认值llm.timeout_seconds 配置项已读出但尚未注入 OpenAiApi 的 RestClientAiConfigHolder.java
Rerankernew RestTemplate()无显式超时——rerank API 卡住会拖慢整条链路CrossEncoderReranker.java

生产加固方向:给 OpenAiApi.builder() 注入带超时的 RestClientClientHttpRequestFactory 设 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. 语义缓存:从”精确命中”到”语义命中”#

SemanticCacheServiceservice/rag/SemanticCacheService.java)的缓存命中是向量相似度而非字符串精确匹配:

查询 → embed → 对 Redis Hash 里所有缓存条目暴力算 cosine 相似度
     → 最高分 ≥ 阈值(默认 0.92) 且 kbIds 范围匹配 → 命中
plaintext
设计点取值理由
匹配方式cosine 相似度”请假怎么请” vs “如何申请休假” 字符串不同但语义同,精确匹配会漏
相似度阈值0.92(热配 cache.semantic.distance_threshold太低会命中不相关问题答错,0.92 偏保守保正确性
检索方式全量 HVALS + Java 内暴力 cosine100-500 条 × 1024 维 ≈ 5ms,规模小没必要上向量索引
写入门控频次 ≥ 阈值(默认 3)才写避免冷 query 占缓存(缓存穿透防护)
TTL24h 兜底防陈旧答案长期驻留

关键认知:语义缓存的最大风险是误命中——两个相近但不同的问题被判为同一个,返回错答案。所以阈值宁高勿低(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 的并行召回跑在 ragRetrievalExecutorRagExecutorConfig.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.confidenceconfidence 普遍偏低说明检索质量差,不是生成问题

面试话术

“监控 LLM 应用和监控普通服务不一样——普通服务看 QPS/延迟/错误率就够了,LLM 还要看 token 消耗(这是钱)、prompt/completion 原文(这是排查幻觉的唯一线索)、每个 RAG 阶段的耗时(定位是检索慢还是生成慢)。我用 OTel + Langfuse,因为 Langfuse 原生理解这些语义,能在一个 trace 里把’检索了哪些 chunk → 拼了什么 prompt → LLM 答了什么 → 花了多少 token’串起来。有个坑是 token 统计——DashScope 流式必须显式 streamUsage(true) 才返回 usage,不然成本数据全是 0。“


降级策略汇总#

组件正常路径降级路径触发条件
工具选择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 过期 → 重新登录
plaintext

关键实现

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

ExecutorService secureExecutor = new DelegatingSecurityContextExecutorService(
    Executors.newCachedThreadPool()
);
secureExecutor.submit(() -> agent.execute(...));
java

如果不包装,子线程 SecurityContextHolder.getContext() 返回空,导致认证信息丢失。

面试话术

“JWT 双 token 是标准实践——短期 access token 保证安全性(泄露影响窗口小),长期 refresh token 保证体验(不需要频繁登录)。SSE 的安全上下文传递是个容易忽略的点——Spring Security 默认 ThreadLocal 模式,异步线程拿不到认证信息,需要用 DelegatingSecurityContextExecutorService 包装线程池。“


文档处理链路#

端到端流程#

增量索引核心#

文档更新时走 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 工程#

模板架构#

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.txtQuery 理解(分类 + 改写)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.ts hook(原生 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 了发送异常,不会因为客户端断开导致服务端线程泄漏。但当前没有做断点续传——如果连接断开,用户需要重新提问。