主题 8 · 全链路降级#
定位:RAG 系统从请求入口到响应出口,管线每一层都有独立降级路径——保证”宁可降质回答,不让用户看到白屏”。
一、通用知识#
1.1 核心概念与原理#
Circuit Breaker(熔断器)#
熔断器是防止级联故障的经典模式,状态机包含三个状态:
- Closed(关闭/正常态):请求正常放行,内部计数器跟踪失败次数。当连续失败达到阈值时状态翻转为 Open。
- Open(打开/熔断态):所有请求直接被拦截,返回 fallback 结果,不再调用下游。持续一段时间(如 30s)后进入 Half-Open。
- Half-Open(半开/探测态):放行少量探测请求;如果成功率恢复到阈值则回到 Closed,否则重新 Open。
在 LLM 系统中的变体:传统微服务的熔断器看 HTTP 状态码/异常率;LLM 系统需要额外监控两个维度——API 限流(429 Too Many Requests) 和模型响应质量下降(延迟突增但不报错)。例如 DashScope 在高峰期可能不返回 5xx 而是响应延迟从 2s 飙到 20s,纯异常率计数器完全感知不到,需要加延迟百分位熔断规则。
面试怎么讲:“熔断器三态——关闭正常、打开拒绝、半开探测。LLM 场景的特殊之处是除了异常率还要监控延迟百分位和 429 限流,因为模型 API 性能退化时通常不报错而是变慢。“
Bulkhead(隔舱)#
隔舱模式借鉴船体水密舱设计:将系统资源按功能域隔离,一个舱进水不拖垮全船。两种实现方式:
- 线程池隔离:为每个下游服务分配独立线程池。优点是强隔离、超时可控;缺点是线程上下文切换开销大、线程数受 OS 限制。
- 信号量隔离:用计数器限制并发调用数。优点是轻量无切换;缺点是无法主动超时(依赖下游超时)。
在 RAG 系统中的应用:检索 Workers 之间采用隔舱隔离——向量检索 Worker 挂了不影响 BM25 Worker 和 Web Worker,实现部分降级。用 CompletableFuture + 独立线程池 + orTimeout 让每个 Worker 有自己的超时边界,超时后返回 WorkerResult.failure() 而非抛异常阻塞全局。
面试怎么讲:“隔舱是资源隔离——线程池隔离强但重,信号量轻但不能主动超时。我们的 Worker 用线程池 + CompletableFuture.orTimeout 实现:一个 Worker 超时直接返回 failure,其他 Worker 的结果照常融合,不会因为单路挂掉而阻塞整体。“
Timeout + Retry with Exponential Backoff#
超时设置原则:
- P99 x 1.5 法则:将超时设为下游服务 P99 延迟的 1.5 倍,既给慢请求留余量又不过度等待。
- 分层超时:外层总超时 > 内层子步骤超时之和,避免子步骤超时被外层提前中断而掩盖真正的瓶颈。
重试退避策略:
- 指数退避(Exponential Backoff):
delay = base * 2^attempt,每次翻倍。防止所有重试请求在同一时刻涌入(thundering herd)。 - 加抖动(Jitter):
delay = base * 2^attempt * random(0.5, 1.5),进一步打散重试时间点。 - 幂等性要求:只有幂等操作才能安全重试。LLM 的 chat completion 调用是幂等的(同一 prompt 重试不会产生副作用),但如果涉及工具调用(如写入记忆),需要确保重试时不会重复写入。
LLM 系统超时策略更复杂的原因:模型响应时间方差极大(同一模型同一 prompt 可能 800ms 也可能 15s),固定超时要么太短截断正常长回答,要么太长浪费等待。实际方案是对 first-token 和 full-response 分别设超时——先等 first-token 确认模型在工作,再给 streaming 充足时间。
面试怎么讲:“超时按 P99 x 1.5 设置,重试用指数退避加抖动防雪崩。LLM 的特殊点是响应时间方差大,我们对 first-token 和完整响应分别设超时——先确认模型在工作,再给流式传输时间。“
Fallback(降级)#
降级策略分三个层级:
- 静态降级:返回预设的固定文案。成本为零、延迟为零,适用于紧急情况(安全词命中、系统完全不可用)。DocMind 的紧急词短路就是典型静态降级。
- 缓存降级:返回历史缓存的结果。成本极低,但存在时效性风险。DocMind 的语义缓存命中是一种缓存降级——KB 版本变更后缓存自动失效,确保不返回过时数据。
- 简化功能降级:跳过部分链路环节,用更简单/廉价的方式完成核心功能。例如 Cross-Encoder 挂了退回关键词排序,agentic 循环异常退回一次性检索。保留了核心功能但精度下降。
面试怎么讲:“降级分三级:静态返回固定文案(零成本零延迟),缓存返回历史结果(低成本有时效风险),简化功能跳过非核心环节(保留核心但精度降)。好的降级设计是每一层都有自己的 fallback,不依赖上层兜底。“
Rate Limiting / Throttling#
三种经典算法:
- 令牌桶(Token Bucket):桶内按固定速率填充令牌,请求消耗令牌。桶满时多余令牌丢弃,桶空时请求被拒。允许短时突发(桶内有存量),长期不超过填充速率。适合 LLM API 调用场景——短时多并发可以接受,长期要控额。
- 漏桶(Leaky Bucket):请求先入队,以固定速率出队处理。严格恒速,不允许突发,适合需要平滑流量的场景。
- 滑动窗口(Sliding Window):在时间窗口内计数请求数。比固定窗口更精确,避免窗口边界处的突刺。
LLM 系统的特殊限流需求:LLM API 通常按 token/分钟(TPM)而非请求/秒(RPS)计费。限流器需要预估请求的 token 消耗(input prompt tokens + 预期 output tokens),在 token 维度做限流,否则一个超长 prompt 就能耗尽整分钟的配额。
面试怎么讲:“令牌桶允许突发、漏桶严格恒速、滑动窗口按时间计数。LLM 的特殊点是按 token 而非请求计费,限流器要在 token 维度做控制,不能只看 RPS。“
LLM 系统的特殊降级需求#
相比传统微服务,LLM 系统的降级设计有四个独特挑战:
-
不确定性:同一输入不同输出。传统系统
f(x) = y确定性高,降级只需重试即可恢复;LLM 的f(x) = y1, y2, ...不确定性意味着需要更多确定性兜底——当 LLM 不可用时,用规则/模板/缓存这些确定性组件接管,而非指望重试出好结果。 -
成本敏感:每次 LLM 调用有显式 token 费用。降级路径应该减少 LLM 调用而非增加——比如检索质量差时直接走缓存降级模板,而非追加一轮 LLM 反思。这与传统微服务降级(多重试几次)的思路恰好相反。
-
延迟不可预测:模型响应时间方差大(500ms-30s),且与 prompt 长度、并发负载、模型状态都相关。超时策略要比传统微服务更复杂——不能一刀切 3s 超时,否则长回答全被截断。
-
“静默变差”比”报错”更危险:传统服务要么正常要么报错,状态清晰。LLM 系统可能检索到垃圾内容但模型照样言之凿凿地回答——没有异常、没有错误码,但答案质量灾难性下降。这要求降级体系不仅有异常捕获,还要有质量闸(CRAG 评分等) 来检测”看起来正常但实质变差”的情形。
面试怎么讲:“LLM 系统降级有四个特殊点:不确定性要求更多确定性兜底而非重试;成本敏感要求降级减少而非增加 LLM 调用;延迟不可预测要求更复杂的超时策略;最关键的是’静默变差’——检索到垃圾但模型照样回答,需要质量闸而非纯异常捕获。“
Graceful Degradation 设计原则#
-
宁可少返回结果,不返回错误结果(fail-safe > fail-fast for user-facing):面向用户的系统,静默降级到安全的保守回答(“未找到匹配内容”)比返回自信但错误的幻觉回答好得多。fail-fast 适合开发者 API,fail-safe 适合终端用户。
-
部分降级优于整体失败(partial degradation):向量检索挂了还有 BM25,BM25 也挂了还有 Web 搜索——三路中任何一路可用都能给出有参考价值的回答。只有三路全空才触发 0-chunk 兜底模板。
-
降级状态必须可追溯:如果降级是静默的,团队永远不知道系统在”偷偷变差”。每次降级必须在 trace 中标记(Langfuse span level=WARNING、
degraded=trueSSE 字段),让监控告警能感知。 -
每一层降级路径必须独立测试:降级路径平时不走,上线后第一次被触发时发现 bug 就是生产事故。需要在集成测试中显式 mock 各层失败场景,验证降级行为符合预期。
面试怎么讲:“四个原则:宁少不错(fail-safe),部分优于全挂(partial),降级必须可追溯(每次标 WARNING),降级路径独立测试。最容易被忽视的是第三点——静默降级等于系统在偷偷变差,不追溯就永远不知道。“
可观测性在降级中的角色#
降级系统必须与可观测性系统联动,否则降级就是”掩盖问题”:
- 降级指标:每层降级的触发次数/频率,需要独立 metric。如
rag.degradation.reranker_fallback、rag.degradation.worker_timeout。 - 告警阈值:降级比例超过阈值时触发告警。例如 reranker fallback 比例持续 > 5% 说明 DashScope 服务有问题。
- Trace 标记:每次降级在 Langfuse trace 上标记
level=WARNING+status_message说明降级原因,让事后分析能从 trace 维度筛选所有降级回答。 - 质量监控:降级回答的 confidence score 分布与正常回答做对比,如果降级回答的 confidence 持续偏低,说明降级策略需要优化。
面试怎么讲:“降级不是把问题藏起来——每次降级都要在 trace 标 WARNING、metric 计数、告警联动。Langfuse 可以按 level 筛 WARNING 看所有降级回答,confidence 分布对比能发现降级策略是否有效。“
1.2 业界主流方案对比(表格形式)#
| 维度 | Resilience4j | Sentinel | Hystrix(已停更) | Polly (.NET) | DocMind 手写降级 |
|---|---|---|---|---|---|
| 语言生态 | Java | Java | Java | .NET | Java(Spring Boot) |
| 熔断 | 支持(滑动窗口/计数器) | 支持(信号量/线程) | 支持(线程池) | 支持 | 未引入(MVP 阶段) |
| 隔舱 | 线程池 + 信号量 | 线程隔离 | 线程池 | Bulkhead policy | CompletableFuture + 独立线程池 |
| 限流 | 令牌桶 | 滑动窗口/令牌桶/集群 | 无内置 | RateLimit policy | 未引入(依赖 DashScope 服务端限流) |
| 降级 | Fallback 装饰器 | Fallback rule | @HystrixCommand fallback | Fallback policy | 每层手写 try-catch + fallback |
| 与 LLM 的适配度 | 需扩展(无 token 维度) | 需扩展 | 已停更 | 需扩展 | 原生为 LLM 链路设计 |
| 工程复杂度 | 低(注解驱动) | 中(Dashboard 运维) | 低 | 低 | 低(无额外依赖) |
| 适用场景 | 通用微服务 | 大规模高并发 | 历史项目 | .NET 生态 | LLM RAG 管线 |
| 维度 | 全局熔断 | 逐层手写降级 | 混合方案 |
|---|---|---|---|
| 颗粒度 | 粗(整个服务级) | 细(每个组件级) | 关键路径细粒度 + 非关键路径粗粒度 |
| 实现成本 | 低 | 高(每层都要写) | 中 |
| 降级精度 | 一刀切 | 最优(每层最佳 fallback) | 接近最优 |
| 维护成本 | 低 | 高(每加一层要加降级) | 中 |
| 适合场景 | 简单微服务 | LLM RAG 管线(每层降级行为不同) | 生产级系统 |
1.3 关键论文与技术要点#
| 论文/技术 | 核心要点 | 与降级设计的关联 |
|---|---|---|
| CRAG (Yan et al. 2024, arXiv:2401.15884) | 检索后三档评估 + 条件补偿(Correct/Ambiguous/Incorrect) | 质量闸本身就是降级触发器——LOW 档触发兜底模板或 Web 补强 |
| Adaptive-RAG (Jeong et al. 2024, arXiv:2403.14403) | 路由器按查询复杂度决定检索策略(无/单次/多次) | 路由决策的降级:分类器失败时退回保守策略 |
| Circuit Breaker Pattern (Nygard, Release It!) | 状态机三态模型,防级联故障 | 传统容错到 LLM 系统的迁移:从异常率到延迟百分位 |
| Bulkhead Pattern (Nygard, Release It!) | 资源隔离,故障域限定 | Worker 级隔离:单路超时不阻塞全局 |
| The Tail at Scale (Dean & Barroso, 2013) | 大规模系统尾延迟问题 + hedged request 方案 | LLM 延迟尾部问题的参考框架 |
| Self-RAG (Asai et al. 2023, NeurIPS) | 模型内反思 token 自驱质量评估 | DocMind 选择外部评估器(CRAG)而非模型内反思——降级路径更可控 |
1.4 常见面试问答#
Q1:Circuit Breaker 怎么实现?三个状态怎么转换?
A:三态状态机——Closed 正常放行、Open 直接拒绝返回 fallback、Half-Open 放少量探测。Closed→Open 由失败计数/失败率触发(如连续 5 次失败或 10s 内失败率 > 50%);Open→Half-Open 由计时器触发(如 30s 后);Half-Open→Closed 由探测成功率决定。实现上 Resilience4j 提供滑动窗口(基于计数或基于时间)两种实现,状态转换线程安全靠 AtomicReference CAS。LLM 场景还要加延迟百分位触发——模型 API 不报错但 P99 飙到 20s 也得熔断。
Q2:LLM API 限流怎么处理?
A:三层处理:第一层是客户端预估 token 消耗做本地限流(令牌桶,桶容量按 TPM 配额设),避免无谓发送;第二层是收到 429 后指数退避重试,Retry-After header 有值就用,没有就 2^n 秒退避;第三层是持续 429 时触发降级——走缓存、走更小模型、或直接返回降级回答。关键是限流维度不能只看 RPS,要看 TPM(tokens per minute),一个超长 prompt 就能吃掉大量配额。
Q3:降级后用户体验怎么保证?
A:核心是透明降级——不偷偷降质。三个做法:一是 SSE 事件里带 degraded=true 让前端可以展示降级提示条;二是 confidence score 用覆盖率/rerank 分数量化回答质量,前端按 band(HIGH/MEDIUM/LOW)展示不同的可信度标签;三是降级模板的文案设计——不说”系统异常”而说”当前知识库未匹配到相关内容,以下基于通用常识仅供参考”,让用户知道回答的局限性。
Q4:怎么测试降级路径?
A:降级路径是”暗路径”——平时不走,出事才走,所以必须专项测试。方法:一是 Mock 注入——在集成测试中用 Mockito/WireMock 模拟各层失败(embedding API 500、reranker 超时、Milvus 断连),验证降级行为;二是 Chaos Engineering——生产 staging 环境随机注入故障(Netflix Chaos Monkey 风格),观察降级是否按预期触发;三是 Trace 回溯——定期从 Langfuse 筛选 level=WARNING 的降级 trace,人工审核降级回答质量。
Q5:怎么监控系统在降级状态?
A:三层监控:一是降级计数 metric,每层降级有独立 counter(如 reranker.fallback.count),Grafana 看趋势和突刺;二是降级比例告警,某层降级比例持续 > 阈值(如 reranker fallback > 5%)触发 PagerDuty;三是 Langfuse trace 按 level=WARNING 过滤,可以看到每条降级回答的完整链路。降级比例的 baseline 要在正常时期建立——知道”正常是什么样”才能发现”不正常”。
Q6:部分降级和整体降级的权衡?
A:部分降级(partial degradation)是核心原则——三路检索中两路挂了一路可用,就用一路的结果生成回答,好过整体报错。但部分降级有隐性成本:用户拿到的结果质量下降了但自己可能感知不到。解决方案是质量量化(confidence score)+ 透明度(前端标签),让部分降级可见而非隐蔽。只有完全无证据时才做整体降级——走 0-chunk 兜底模板,明确告知”知识库未找到相关内容”。
Q7:超时值怎么定?LLM 场景有什么特殊考量?
A:两步走:先收集各环节 P99 延迟作为 baseline(embedding ~200ms、rerank ~800ms、LLM generation ~5s),然后按 P99 x 1.5 设超时。LLM 的特殊点:一是 generation 延迟与 output token 数正相关——简单问题 1s、复杂问题 15s,单一超时不够,要对 first-token 和 full-response 分别设;二是 streaming 场景超时应该从 last-token 开始计——只要 token 还在流就不该超时。Worker 级别的超时(如 15s)通过 CompletableFuture.orTimeout 实现,超时后该路返回 failure,不阻塞其他 Worker。
二、DocMind 实践#
30 秒口述版#
“DocMind 的 RAG 管线串联了 6+ 个外部服务,我的设计原则是管线每一层独立兜底,不依赖上层 catch。重点讲三个最有故事性的降级。第一个是 Agentic 循环异常回退一次性检索——
agenticWithFallback()包裹整个循环,异常或空结果时无缝降级到oneShotRetrieval,用户无感知,这是 agentic 架构能上生产的前提。第二个是 Reranker API 超时降级到关键词排序——DashScope 评测期间真的触发了 429 限流,fallbackRerank用rerankScore = originalScore * 0.6 + matchScore * 0.4加权排序接管,评测未中断。第三个是 CRAG LOW 分级处理——trace 复盘发现有 Web 证据但 rerank 分偏低导致 CRAG 判 LOW,原来走零证据兜底模型退回过时基座记忆回答’不存在’,修复后有证据的 LOW 走assembleLowConfidence保留引用。全程降级在 Langfuse trace 标 WARNING,可追溯可聚合。“
详细展开#
背景/痛点(Situation)#
DocMind 的 RAG 管线串联了 6+ 个外部服务(DashScope LLM API、DashScope Embedding API、DashScope Reranker API、Milvus 向量库、Redis 缓存、Tavily Web 搜索),加上内部 BM25 检索和多个处理阶段。在实际运行中面临三类典型故障场景:
- 单点 API 抖动/不可用:DashScope 在高峰期出现限流或延迟飙升(Reranker P99 从 800ms 飙到 5s),导致整条管线阻塞超时。
- 检索质量”静默变差”:Milvus 向量索引未及时 flush、KB 版本变更导致语义缓存失效等——系统不报错但回答质量灾难性下降,比报错更危险。
- LLM 输出不可预测:QueryUnderstanding 分类器偶尔输出非法 JSON、scope 字段越界、rewritten 超长——任何一个解析失败如果没有兜底就是链路中断。
项目初期只在最外层做了一个 try-catch + 通用错误提示,结果一次 Reranker API 超时就导致整个请求 500,用户看到白屏。
做了什么(Action)#
管线每一层植入独立降级路径,不是自上而下设计出来的,而是从真实故障驱动出来的——每个降级点对应一个独立的失败模式。
三条设计原则
- 每层自治:降级行为由该层自己决定,不依赖上层 catch。例如
CrossEncoderReranker.rerank()自己 catch 异常后调用fallbackRerank(),返回降级排序结果;上层SupervisorAgent拿到的始终是一个有效的排序列表,无需感知 reranker 是否降级了。 - 保守优先:降级时宁可过度触发也不漏判。
QueryClassification.fallback()设isAmbiguous=true,触发保守混合工具集,而非冒险走精简工具集。 - 降级可追溯:每次降级在 SSE 事件中带
degraded=true,在 Langfuse trace 标level=WARNING+ 原因描述。没有 trace 标记的降级等于系统在偷偷变差。
重点故事 1:Agentic 循环异常 → 无缝回退一次性检索
DocMindAgent.agenticWithFallback() 包裹整个 agentic 循环。这是 agentic 架构能上生产的前提——模型驱动的循环天然不可靠(可能空转、可能超时、可能抛异常),必须有确定性路径兜底:
- 循环异常(LLM API 限流/网络超时/JSON 解析失败)→ catch 后调
RetrievalPlanner重新选工具集,走SupervisorAgent.oneShotRetrieval - 循环正常结束但 chunks 为空(KB 里确实没有相关内容)→ 同样回退一次性检索,给 BM25 和 Web 搜索一次兜底机会
- 用户完全无感知——SSE 流不中断,只是 trace 里标了 WARNING
循环内部还有三层防线:max_iterations=4 硬上限、空转护栏(本轮零增益且已有资料→提前收尾)、temperature=0.0 保确定性。
重点故事 2:Reranker API 超时 → 关键词排序接管(真实触发过)
CrossEncoderReranker.rerank() 的降级路径被真实验证过——评测期间 DashScope 限流返回 429:
- 超时防护:
@PostConstruct初始化带超时的RestTemplate(connect 2s, read 5s)。裸new RestTemplate()无超时,DashScope 限流时会无限挂起占 worker 线程 fallbackRerank()接管:rerankScore = originalScore * 0.6 + matchScore * 0.4——保留原始检索分数的相对排序(60% 权重,向量/BM25/RRF 已有语义信号),叠加查询词在 chunk 中的匹配频度(40%)- 实测精确查询场景 Top-3 重合度约 60-70%(vs Cross-Encoder),模糊语义查询约 30-40%——降质但可用
- 评测期间触发后自动降级,评测未中断,这条降级路径在上生产前就被真实验证了
重点故事 3:CRAG LOW 分级处理——有证据不丢弃(trace 驱动的关键修正)
这是一个从 trace 复盘中驱动出来的设计决策(#28):
- 问题:用户问一个新发布的工具(如”Claude Code”),KB 里没有但 Web 搜到了 Anthropic 官网介绍,rerank 分偏低(KB 和 Web 分数量纲不一致)→ CRAG 判 LOW → 原逻辑
needsFallback=true清空证据走零证据兜底模板 → 模型退回基座记忆回答”Claude Code 未发布/不存在”——比有 Web 证据更差 - 修复:区分两种 LOW:
compressed.isEmpty()走assembleFallback(真正无证据);compressed非空走assembleLowConfidence(带证据但强化约束),模板开头加”基于有限参考内容”提示,让模型据实引用[n] - 观测信号:零引用覆盖 + CRAG LOW =
ungrounded信号,Langfuse span WARNING,不翻 needsFallback 但运维可监控
其他降级层概览
管线从入口到出口的其他降级点简述(各自独立、逻辑简单):
| 故障域 | 降级行为 | 代码位置 |
|---|---|---|
| 紧急词命中 | 跳过全部检索/生成,返回固定安全模板 | SafetyGuard.isEmergency() |
| Scope 路由异常 | Tier-0 规则失败退 Tier-1 LLM;两级均失败退 KNOWLEDGE_QUERY | DocMindAgent.decideScope() |
| QueryUnderstanding 失败 | fallback(isAmbiguous=true) 走保守工具集 | QueryUnderstandingService.understand() |
| Worker 超时 | orTimeout(15s) + exceptionally → 单路 failure 不阻塞全局 | SupervisorAgent.dispatchWorkers() |
| Embedding API 失败 | 向量路返回空列表,BM25 结果仍可用 | VectorRetriever.retrieve() |
| CitationParser 无实质句 | confidence 退回纯 rerank-top1 | DocMindAgent.stageGenerateAndPersist() |
| Redis 异常 | 缓存操作跳过,正常走检索流程 | SemanticCacheService.lookup() |
量化结果(Result)#
| 指标 | 数值 | 基线 | 来源 |
|---|---|---|---|
| 单点故障传播率 | 0%(每层独立兜底) | 100%(初期 Reranker 超时 → 全链路 500) | 手工注入故障测试 |
| Reranker fallback 下答案可用率 | 100%(降质但可用) | 0%(初期直接报错) | 评测期间真实触发验证 |
| CRAG LOW + 非空证据保留率 | 100%(改造后走 lowConfidence 模板) | 0%(改造前清空证据走 0-chunk 兜底) | trace 分析 #28 |
| Worker 超时部分降级成功率 | 100%(单路超时不影响其他路) | N/A | orTimeout 机制测试 |
| 降级 trace 可追溯 | 每层均标 WARNING/degraded | 初期无降级标记 | Langfuse level=WARNING 筛选 |
代码锚点#
| 类/方法 | 路径 | 职责 |
|---|---|---|
DocMindAgent.agenticWithFallback() | agent/DocMindAgent.java | 重点:agentic 异常 → 退回 oneShotRetrieval |
CrossEncoderReranker.rerank() | service/rag/CrossEncoderReranker.java | 重点:API 失败 → fallbackRerank |
CrossEncoderReranker.fallbackRerank() | service/rag/CrossEncoderReranker.java | 重点:关键词匹配 + 原始分数加权排序 |
DocMindAgent.stagePromptAssembly() | agent/DocMindAgent.java | 重点:CRAG 分级 → 三模板路由 |
PromptAssembler.assembleLowConfidence() | service/rag/PromptAssembler.java | 重点:有证据低置信度模板 |
PromptAssembler.assembleFallback() | service/rag/PromptAssembler.java | 0-chunk 兜底模板(含反幻觉约束) |
DocMindAgent.execute() | agent/DocMindAgent.java | 紧急词短路 + 全局异常兜底 |
DocMindAgent.decideScope() | agent/DocMindAgent.java | 两级 scope 路由级联 + fallback |
QueryUnderstandingService.understand() | service/rag/QueryUnderstandingService.java | 分类失败 → fallback(isAmbiguous=true) |
SupervisorAgent.dispatchWorkers() | agent/supervisor/SupervisorAgent.java | Worker 并行派发 + orTimeout 部分降级 |
VectorRetriever.retrieve() | service/rag/VectorRetriever.java | Embedding 失败 → 返回空列表,BM25 仍可用 |
AgenticSearchOrchestrator.search() | agent/supervisor/AgenticSearchOrchestrator.java | Agentic 循环 + 空轮护栏 |
RetrievalGrader.grade() | service/rag/RetrievalGrader.java | CRAG 三档评估 + 灰区仲裁降级 |
SafetyGuard.isEmergency() | service/rag/SafetyGuard.java | 紧急词双层判定 |
量化数据#
| 指标 | 数值 | 基线 | 来源 |
|---|---|---|---|
| 降级路径覆盖 | 管线每层独立兜底 | 仅顶层 try-catch | 故障驱动逐层加固 |
| Reranker 超时阈值 | connect 2s + read 5s | 无超时(默认无限等待) | CrossEncoderReranker @Value 配置 |
| Worker 超时阈值 | 15s(retrieval.worker_timeout_ms 动态配置) | 无超时 | SupervisorAgent.withWorkerTimeout() |
| CRAG 快路径 HIGH 阈值 | rerank top score >= 0.65 | N/A | RetrievalGrader 动态配置(grader.high_threshold) |
| CRAG 快路径 LOW 阈值 | rerank top score <= 0.25 | N/A | RetrievalGrader 动态配置 |
| fallback 分类 complexity | MEDIUM(保守策略) | N/A | QueryClassification.fallback() |
| Agentic 最大迭代 | 4 轮(agentic.max_iterations) | N/A | AgenticSearchOrchestrator 动态配置 |
| 语义缓存 TTL | 24h | N/A | SemanticCacheService Redis TTL |
三、追问应对#
面试官想听到的信号#
- 能区分”LLM 系统降级”与”传统微服务降级”的本质差异——不确定性要求更多确定性兜底、成本敏感要求降级减少而非增加 LLM 调用、最关键的是”静默变差”比报错更危险
- 降级不是单层 try-catch,而是每层独立 fallback——每个组件对上层暴露的始终是有效结果,上层不需要感知是否降级了
- 有真实故障验证的降级路径(Reranker 429 限流、CRAG LOW 丢证据),不是纸上谈兵
- CRAG LOW 的分级处理是 trace 驱动的结果——不是提前设计的,而是从真实 case 中发现”有证据不该丢弃”后修正的
- 可观测性是降级的必要组成部分——没有 trace 标记的降级等于系统在偷偷变差
追问预判与应答#
Q1:降级路径这么多,是怎么确定每层该不该加的?
A:不是自上而下设计出来的,是自下而上从故障驱动出来的。每个降级点对应管线中一个独立的失败模式——比如项目初期一次 Reranker API 超时就导致整个请求 500 白屏,这直接驱动了 reranker 层加 fallback。后来评测期间真的触发了 DashScope 429 限流,验证了这条降级路径。CRAG LOW 丢证据也是 trace 复盘中发现的。原则是每个能独立失败的组件就应该有独立的降级,但不是为了追求数量——有的层降级逻辑就是一行 try-catch 返回空列表(如 Embedding 失败),有的需要精心设计替代策略(如 CRAG 分级处理)。
Q2:CRAG LOW + 非空证据为什么不走 fallback?这不是在低质量的基础上硬生成吗?
A:这是个关键设计决策,来自真实 trace 的教训(#28 复盘)。场景:用户问一个新发布的工具(如”Claude Code”),KB 里没有但 Web 搜到了 Anthropic 官网介绍,rerank 分偏低(因为 KB 和 Web 分数量纲不一致)导致 CRAG 判 LOW。如果走 fallback 清空证据,模型退回基座记忆,反而回答”Claude Code 未发布/不存在”——这比 Web 搜到的真实内容更差。所以改为:有证据的 LOW 走 assembleLowConfidence 模板,模板开头加”以下基于有限参考内容”的提示,让模型据实引用 Web 结果,用 [n] 标记让用户能点击溯源验证。
Q3:Embedding 失败只用 BM25,效果不会差很多吗?
A:会差,但不是灾难性差。BM25 在精确关键词匹配场景(如”Spring Boot 3.4 配置项”)效果其实不比向量差。效果严重退化的是语义查询(如”怎么让接口更快”→ 应匹配”性能优化”但 BM25 匹配不上)。降级的核心是 fail-safe——让用户拿到”不完美但可用”的结果,好过白屏。同时 Embedding 降级在 trace 里标 WARNING,告警系统会在 Embedding 降级比例超阈值时通知运维排查根因。
Q4:Worker 超时 15s 的值怎么定的?会不会太长/太短?
A:15s 是基于 P99 测量的经验值。检索 Worker 里包含 embedding + Milvus 检索 + BM25 三步,正常 P50 约 1.5s,P99 约 6s,15s 约 P99 x 2.5 留了充足余量。Web Worker 依赖 Tavily API,P99 约 4s,15s 同样足够。太短(如 5s)会在正常偏慢请求时误触发降级,太长(如 30s)用户等待体验差。这个值放在 sys_ai_config 动态配置表里,可以不重启调整——如果发现某段时间超时频繁可以临时调大。
Q5:Reranker 的 fallback 排序(关键词匹配)和正常 Cross-Encoder 排序差距多大?
A:差距主要在语义理解层面。Cross-Encoder 能理解”如何加速”与”性能优化”的语义关联,fallback 的关键词匹配做不到。但 fallback 用了加权策略:rerankScore = originalScore * 0.6 + matchScore * 0.4,结合了原始检索分数(向量/BM25/RRF 已有语义信号)和关键词匹配,不是纯关键词排序。实测在精确查询场景下两者差距不大(Top-3 重合度约 60-70%),在模糊语义查询下差距明显(重合度约 30-40%)。这是可接受的——降级的目标不是零损失,而是可用。
Q6:agentic 循环有空轮护栏,但如果模型一直在空转怎么办?
A:三层防御:第一层是 agentic.max_iterations(默认 4)硬上限,无论如何不超过 4 轮;第二层是空轮护栏(early_stop_on_empty_round)——如果某轮工具调用没有带来任何新 chunk,且此前已有累积资料,提前收尾,避免模型换个措辞重复搜索;第三层是 agentic 整体异常/空结果时的 agenticWithFallback(),退回一次性检索确保有结果返回。系统提示中也引导”某来源已搜不到时换思路或直接停止”,但 prompt 约束不够可靠,所以代码侧有硬兜底。
Q7:如果 Redis 完全不可用,影响多大?长期记忆会丢吗?
A:影响可控,长期记忆不会丢——这正是 #34 把记忆 SoR 从 Redis 下沉到 MySQL 的价值。Redis 现在只承担两件事:语义缓存、以及长期记忆的 cache-aside 读缓存。Redis 挂了:①语义缓存不可用 → 每次都走完整 RAG 管线,延迟增加但功能完整;②记忆读缓存 miss → loadActive 回源 MySQL SoR(user_memory),记忆照常召回、只是少了缓存提速。所有 Redis 操作都在 try-catch 中,异常 log.warn 跳过、正常继续。真正让长期记忆”召回不到”得 MySQL(SoR)也同时不可用——而 SoR 读失败时 loadActive 吞异常返回空、不抛错阻断主流程。这是”缓存是加速器不是真相源”的设计——把持久性押在单节点 Redis 上才是过去的错配。
Q8:降级路径怎么和可观测系统联动?降级了但没人看到怎么办?
A:三层联动:第一层是 SSE 事件带 degraded=true,前端可以展示降级提示条,让用户知情;第二层是 Langfuse trace 上标 level=WARNING + status_message 描述降级原因,运营可以按 WARNING 过滤查看所有降级回答;第三层是 Langfuse Scores API 推送 confidence/coverage/crag_grade 作为结构化评分指标,可以跨 trace 聚合分析降级趋势。实际操作中每周复盘时从 Langfuse 拉 WARNING trace 做采样审核,确保降级回答质量在可接受范围。
Q9:如果 DashScope 整体不可用(LLM + Embedding + Reranker 全挂),系统还能提供什么?
A:极端情况下还能提供三类服务:一是 BM25 全文检索(纯本地 Lucene,不依赖任何外部 API)+ fallbackRerank(关键词排序)→ 组装原始检索结果列表给前端,虽然没有 LLM 生成的自然语言回答但用户可以直接阅读 chunk 原文;二是语义缓存命中时直接回放(Redis 里有历史回答),热门问题不受影响;三是紧急词/闲聊/KB_META 等短路路径的静态/简单模板回复。当然这是最坏情况,实际中 DashScope 全挂是极低概率事件。
反击引导#
“降级设计的前提是可观测性——不知道系统在哪里变差就无法设计有针对性的降级。CRAG LOW 丢证据的修复就是从一条降级 trace 的复盘中驱动出来的——Langfuse Score 聚合发现 crag_grade=LOW + citation_coverage=0 的异常模式,trace 定位根因后才有了 assembleLowConfidence 的设计。这个’可观测 → 定位 → 修复’的闭环,和我们的线上反馈闭环(→ 06)是一套体系。另外,agentic 循环的异常回退只是降级的一个点,agentic 循环本身的设计——手动控环、白名单、空转护栏——可以展开讲架构收敛(→ 07)。”