供应商挂了,我们的降级逻辑一行没生效
六月下旬的一个晚上,模型供应商华东区域故障,持续 23 分钟。我们有完整的降级设计(规则引擎兜底、熔断、重试),理论上最多影响一部分请求的响应时间。实际结果是:全线报错,客服系统完全不可用。
事后复盘,原因特别打脸:降级逻辑的入口写在了 catch (ModelException e) 里,但供应商故障时抛出的是网络层的 ResourceAccessException,根本没被捕获。这段代码写了大半年,从来没被真正执行过一次。
这次事故之后,我们给 LLM 应用引入了混沌工程演练。这篇记录做法和演练中发现的真实问题。
为什么 LLM 应用特别需要演练
传统服务也有降级逻辑失效的问题,但 LLM 应用的情况更糟,原因有三个:
- 故障模式更多样。不只是"挂了"和"慢了",还有"返回了格式不对的内容"、"返回了内容但事实错误"、"流式中途断开"、"额度用尽"、"内容审核误判"。传统服务的故障基本就前两类;
- 降级路径是冷路径。正常运行时降级代码永远不执行,也就永远不会被验证。我们的降级代码半年没跑过一次,写错了也没人知道;
- 依赖不可控。模型供应商是外部系统,你没法要求它配合你演练,也不能预测它的故障模式。而且各家供应商的故障表现还不一样。
第三点尤其麻烦。我们不能在演练时真的让供应商挂掉,所以必须自己构造故障注入能力。
故障注入的实现
我们的注入点选在 Spring AI 的 Advisor 层(就是拦截器),因为这是所有模型调用的必经之路,而且能拿到完整的请求和响应。
public class ChaosAdvisor implements CallAroundAdvisor, StreamAroundAdvisor {
private final ChaosRule rule; // 从配置中心动态读取
private final Clock clock;
@Override
public AdvisedResponse aroundCall(AdvisedRequest req,
CallAroundAdvisorChain chain) {
Fault fault = rule.match(req);
if (fault == null) return chain.nextAroundCall(req);
return switch (fault.type()) {
case TIMEOUT -> sleepThenThrow(req, fault);
case ERROR_500 -> throw new ModelServerException(500, "chaos");
case RATE_LIMIT -> throw new ModelRateLimitException("chaos");
case GARBAGE -> new AdvisedResponse(
fakeGarbageResponse(req), req.adviseContext());
case EMPTY -> new AdvisedResponse(
emptyResponse(), req.adviseContext());
default -> chain.nextAroundCall(req);
};
}
@Override
public Flux<AdvisedResponse> aroundStream(AdvisedRequest req,
StreamAroundAdvisorChain chain) {
Fault fault = rule.match(req);
if (fault != null && fault.type() == FaultType.STREAM_BREAK) {
// 发一半然后断开,这是最容易被忽略的故障模式
return chain.nextAroundStream(req)
.take(fault.breakAfterChunks())
.concatWith(Flux.error(
new IOException("chaos: stream broken")));
}
return chain.nextAroundStream(req);
}
@Override public String getName() { return "chaos"; }
@Override public int getOrder() { return AdvisorConstants.CHAOS_ORDER; }
}
规则从配置中心读取,可以按请求特征精确匹配:
chaos:
enabled: true
rules:
- name: "模型完全不可用"
match: { model: "qwen-plus", sampleRate: 1.0 }
fault: { type: ERROR_500 }
duration: 300s
- name: "流式响应中途断开"
match: { model: "qwen-max", path: "/api/chat" }
fault: { type: STREAM_BREAK, breakAfterChunks: 12 }
- name: "高频超时"
match: { tenant: "customer-service", sampleRate: 0.3 }
fault: { type: TIMEOUT, delayMs: 8000 }
注入器只在预发环境和演练时段开启,生产环境通过开关严格控制(开关有三重校验:配置项 + 环境变量 + 时间窗口)。这一点必须做死,混沌注入误开到生产是灾难性的。
网络层故障用 Toxiproxy
Advisor 层能模拟应用视角的故障,但有些故障(连接超时、TCP 重置、带宽限制)在应用层模拟不真实。这部分我们用 Toxiproxy,在测试环境部署,通过 API 动态加规则:
# 给模型服务注入 8 秒延迟
curl -X POST http://toxiproxy:8474/proxies/model_api/toxics \
-d '{"name":"latency","type":"latency",
"attributes":{"latency":8000,"jitter":500}}'
# 模拟连接被重置
curl -X POST http://toxiproxy:8474/proxies/model_api/toxics \
-d '{"name":"reset","type":"reset_peer",
"attributes":{"timeout":3000}}'
演练场景清单
我们设计了 11 个场景,每个都定义了"预期表现"和"验证方法"。下面是最有价值的六个:
| 场景 | 注入方式 | 预期表现 |
|---|---|---|
| 模型 500 错误 | Advisor | 熔断生效,请求走规则引擎,用户无感知 |
| 响应超时(8s) | Toxiproxy | 3s 超时熔断,不占用线程,P99 可控 |
| 限流 429 | Advisor | 退避重试,重试失败后降级 |
| 返回垃圾内容 | Advisor | Schema 校验拦截,转人工 |
| 流式中途断开 | Advisor | 前端显示"网络异常,请重试",不白屏 |
| 缓存集体失效 | 清 Redis 前缀 | QPS 全部打到模型,不触发限流、不雪崩 |
最后一个"缓存集体失效"是我在文章里看到别人踩过才加的,结果第一次演练就复现了——清完缓存后瞬时 QPS 涨了 3 倍,模型侧被限流,然后雪崩。这个场景如果不在演练里发现,迟早在生产触发(比如一次大版本更新导致所有 key 前缀变化)。
第一次演练发现的问题
六月末我们做了第一次完整的 Game Day,四个场景,发现了 6 个问题。挑几个有价值的说:
问题一:降级入口的异常类型不匹配
就是开头那次事故的原因。我们的代码是:
try {
return callModel(req);
} catch (ModelException e) { // ← 只捕获了这个
return ruleEngineFallback(req);
}
而实际抛出来的异常链是 ResourceAccessException → I/O error on POST request,压根不是 ModelException 的子类。修复方式是改成捕获所有异常 + 显式声明哪些不降级:
try {
return callModel(req);
} catch (BusinessValidationException e) {
throw e; // 业务异常不降级,直接抛
} catch (Exception e) { // 其余全部降级
log.warn("model failed, fallback", e);
metrics.counter("llm.fallback",
"type", e.getClass().getSimpleName()).increment();
return ruleEngineFallback(req); // 兜底
}
教训:降级逻辑要捕获最宽泛的异常,而不是你"以为"会抛的那个。外部依赖的异常类型你永远猜不全。
问题二:降级路径自己也会超时
规则引擎兜底逻辑里有一次数据库查询,平时 15ms。演练时发现,因为主路径故障导致大量请求同时涌入降级路径,数据库查询涨到 800ms,降级路径的整体耗时反而比正常路径还长。
这个发现很有价值:降级路径必须能承受故障时的全部流量,而它平时只处理零流量,性能特征完全不同。我们的修复是给降级路径加本地缓存(行政区划词典这类静态数据全量缓存到 Caffeine),并且给降级路径单独设更短的超时(1 秒)。
问题三:熔断器的状态没有持久化也没有监控
演练时发现熔断器打开后,我们没有任何地方能看到"当前处于熔断状态"。运维同学问"现在是不是在降级",没人答得上来。而且服务重启后熔断器的统计状态清零,重启瞬间会有大量请求打到已经故障的下游(就是所谓的"重启风暴")。
修复:把熔断状态暴露成 Prometheus 指标,加 Grafana 面板和告警;同时熔断器初次启动的前 20 个请求按 50% 概率放行探测,避免重启瞬间的流量冲击。
问题四:流式中途断开时前端白屏
这个纯前端问题,但很典型。我们的 SSE 断流后,前端的 EventSource 会自动重连(这是 EventSource 的默认行为),导致请求被重复发送,而且重连时没有带上原来的上下文,返回的内容接不上。
修复是改用 fetch + ReadableStream 手动处理 SSE,关闭自动重连,断流时展示已收到的内容 + "网络异常"提示。另外服务端也做了保护:同一个会话 3 秒内重复提交相同内容直接拒绝。
问题五:重试放大了故障
模型 500 故障时,我们的重试配置是 3 次。演练时看到模型侧收到的 QPS 是正常的 4 倍——重试把流量放大了。在一个已经出故障的下游上加重试,等于雪上加霜。
修复:加重试预算(retry budget),重试请求数不超过总请求数的 10%,超过就不再重试直接降级。这个是 Hystrix 时代就有的概念,我们忘了用。
if (retryBudget.tryConsume()) {
return doRetry(req);
}
log.warn("retry budget exhausted, fallback directly");
return fallback(req);
问题六:缓存失效导致的雪崩
上面提过了。修复方案是缓存重建加锁 + 空值缓存:
String lockKey = "rebuild:" + key;
if (redis.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(5))) {
try {
V value = loadFromModel(key);
redis.opsForValue().set(key, serialize(value), ttl);
return value;
} finally {
redis.delete(lockKey);
}
} else {
Thread.sleep(50);
return getOrLoad(key); // 等别人重建
}
演练怎么组织
我们目前的做法:
- 频率:每月一次完整 Game Day(半天),每周一次单场景自动化演练;
- 范围:先在预发环境做,稳定运行一个季度后再考虑生产环境的只读场景;
- 参与人:开发、测试、运维、相关业务方。业务方参与很重要,因为很多降级表现需要业务判断可接受度;
- 前置条件:必须有明确的停止开关和回滚方案,演练负责人要能在 30 秒内停止注入;
- 产出:每个场景记录"预期表现 / 实际表现 / 是否通过 / 待改进项",问题进缺陷系统跟踪。
自动化演练跑在夜间低峰期,通过 Jenkins 定时任务触发,演练完自动生成报告。这套自动化的价值在于防止代码改动破坏已有的容错能力——我们有次重构把降级路径改坏了,是例行演练发现的,而不是等到真正的故障。
# Jenkins 定时任务,每周三凌晨 2 点跑单场景演练
curl -X POST "$CHAOS_API/chaos/run" \
-H "Content-Type: application/json" \
-d '{"scene":"model_500","duration":300,
"env":"staging","autoStop":true}'
# 演练结束后拉取报告
curl "$CHAOS_API/chaos/report/latest?scene=model_500"
演练的效果
| 指标 | 演练前 | 演练后(3 次演练) |
|---|---|---|
| 通过的场景数 | 2/11 | 10/11 |
| 未通过的降级路径 | 3 条 | 0 条 |
| 供应商故障时的用户影响 | 全站不可用 | 部分功能降级 |
| 故障平均恢复时间 | 23 分钟 | 2 分钟(自动) |
| 容错相关缺陷 | 发现 6 个 | 累计发现 14 个,修复 13 个 |
唯一没通过的那个场景是"模型返回事实性错误"——这个我们还没找到好的自动检测方法,目前只能靠人工抽检和溯源校验。这算是 LLM 特有的难题,等有进展再写。
几条经验
- 降级代码不演练等于没写。这是最核心的一条,我们付出过 23 分钟全站不可用的代价;
- 捕获最宽泛的异常,别猜测外部依赖会抛什么;
- 降级路径要按故障时的全流量设计,它的性能特征和平时的零流量完全不同;
- 重试要有预算,否则会放大故障;
- 熔断状态要可观测,不然运维不知道系统在不在降级;
- 自动化演练的价值在于防回归,比 Game Day 更持续。
先到这
《LLM 应用的混沌工程与容错演练》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。