Administrator
发布于 2025-07-09 / 4231 阅读
31

LLM 应用的混沌工程与容错演练

供应商挂了,我们的降级逻辑一行没生效

六月下旬的一个晚上,模型供应商华东区域故障,持续 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)Toxiproxy3s 超时熔断,不占用线程,P99 可控
限流 429Advisor退避重试,重试失败后降级
返回垃圾内容AdvisorSchema 校验拦截,转人工
流式中途断开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/1110/11
未通过的降级路径3 条0 条
供应商故障时的用户影响全站不可用部分功能降级
故障平均恢复时间23 分钟2 分钟(自动)
容错相关缺陷发现 6 个累计发现 14 个,修复 13 个

唯一没通过的那个场景是"模型返回事实性错误"——这个我们还没找到好的自动检测方法,目前只能靠人工抽检和溯源校验。这算是 LLM 特有的难题,等有进展再写。

几条经验

  • 降级代码不演练等于没写。这是最核心的一条,我们付出过 23 分钟全站不可用的代价;
  • 捕获最宽泛的异常,别猜测外部依赖会抛什么;
  • 降级路径要按故障时的全流量设计,它的性能特征和平时的零流量完全不同;
  • 重试要有预算,否则会放大故障;
  • 熔断状态要可观测,不然运维不知道系统在不在降级;
  • 自动化演练的价值在于防回归,比 Game Day 更持续。

先到这

《LLM 应用的混沌工程与容错演练》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。

参考