AI 系统的故障演练——主动制造混乱找脆弱点
AI 系统的故障演练——主动制造混乱找脆弱点
我们的 AI 系统在测试环境里完全正常,上了生产环境跑了两个月,然后在某个不起眼的下午崩了。
崩溃的原因很蠢:OpenAI 的 API 响应突然变慢,P99 延迟从平时的 3 秒涨到了 25 秒。我们的请求池在 20 分钟内被打满,后续请求开始排队,然后超时,然后用户开始收到错误。
复盘时我们发现,这个场景在测试阶段从来没有模拟过。我们做了功能测试、性能测试,但从来没有测试「如果 AI API 突然变得非常慢会怎样」。
这就是混沌工程存在的意义:在生产故障到来之前,我们自己先制造这些故障,找出系统的脆弱点。
一、普通混沌工程 vs AI 系统混沌工程
传统的混沌工程场景(Netflix 的 Chaos Monkey 那套)主要关注:
- 随机 Kill 服务实例
- 注入网络延迟/丢包
- 模拟磁盘 I/O 慢
这些在 AI 系统里依然有效,但不够。AI 系统有一套独特的故障模式,需要专门的故障注入:
| 普通应用故障 | AI 应用特有故障 |
|---|---|
| 服务宕机 | 模型 API 响应极慢但不超时 |
| 网络超时 | Token 限制触发(429 Too Many Requests) |
| 数据库连接池打满 | 内容安全过滤(安全策略拦截正常请求) |
| 依赖服务返回 500 | 模型返回乱码(不是 JSON、含控制字符) |
| - | 流式响应中途断流(SSE 连接中断) |
| - | 向量检索返回空结果(知识库故障模拟) |
| - | 模型版本突然变更(行为不一致) |
AI 特有的故障往往更隐蔽,系统不一定会报错,但会悄悄变得不可用或输出质量下降。
二、故障演练的目标和边界
在开始之前,要明确两件事:演练什么,不演练什么。
演练的目标:
- 验证降级策略是否真正生效(理论上有备用模型,实际切换是否正常)
- 验证超时和熔断器配置是否合理(太松会让故障扩散,太紧会误杀)
- 找到没有容错保护的代码路径
- 测量故障恢复时间(MTTR)
不应该演练的场景:
- 真实用户数据的毁坏
- 生产环境的核心数据库
- 在业务高峰期进行演练(应该在低峰期)
演练的原则:
- 可以随时停止(有一个「停止开关」)
- 影响范围可控(先在单个实例或特定用户群上注入)
- 有明确的成功/失败判断标准
三、故障演练流程设计
四、AI 故障注入的 Spring Filter 实现
核心思路:在 AI 调用链路上加一个 Filter,在特定条件下注入故障行为。
4.1 故障注入配置
@Data
@Configuration
@ConfigurationProperties(prefix = "chaos.ai")
public class AiChaosConfig {
// 是否开启故障注入(生产环境默认关闭)
private boolean enabled = false;
// 故障规则列表
private List<ChaosRule> rules = new ArrayList<>();
@Data
public static class ChaosRule {
private String id;
private ChaosType type;
private double probability; // 触发概率 0.0-1.0
private String targetScene; // 针对的业务场景(为空则全部场景)
private Map<String, String> params = new HashMap<>(); // 参数
public enum ChaosType {
DELAY, // 注入延迟
TIMEOUT, // 模拟超时
TOKEN_EXCEEDED, // 模拟 Token 超限(429)
CONTENT_FILTERED, // 模拟内容被安全过滤
TRUNCATED_RESPONSE, // 模拟流式响应中途截断
GARBLED_RESPONSE, // 模拟乱码响应
EMPTY_RETRIEVAL, // 模拟向量检索返回空结果
SLOW_STREAMING // 模拟流式输出极度缓慢
}
}
}对应配置文件(仅在演练时开启):
chaos:
ai:
enabled: true
rules:
# 规则1:20% 概率注入 5 秒延迟(模拟 API 变慢)
- id: "slow-api"
type: DELAY
probability: 0.2
params:
delay-ms: "5000"
# 规则2:5% 概率模拟 Token 超限
- id: "token-limit"
type: TOKEN_EXCEEDED
probability: 0.05
target-scene: "long_document_analysis"
# 规则3:10% 概率模拟内容过滤
- id: "content-filter"
type: CONTENT_FILTERED
probability: 0.14.2 AI 故障注入 Filter
@Component
@Slf4j
@ConditionalOnProperty(name = "chaos.ai.enabled", havingValue = "true")
public class AiChaosFilter {
@Autowired
private AiChaosConfig chaosConfig;
private final Random random = new Random();
/**
* 在 AI 调用前检查是否需要注入故障
* 在 AiService 层的 AOP 切面中调用
*/
public void beforeAiCall(AiCallContext context) {
if (!chaosConfig.isEnabled()) return;
for (AiChaosConfig.ChaosRule rule : chaosConfig.getRules()) {
if (!shouldApplyRule(rule, context)) continue;
log.warn("[CHAOS] Injecting fault: rule={}, scene={}, requestId={}",
rule.getId(), context.getScene(), context.getRequestId());
applyFault(rule, context);
break; // 一次请求只应用一个规则
}
}
private boolean shouldApplyRule(AiChaosConfig.ChaosRule rule, AiCallContext context) {
// 检查场景匹配
if (rule.getTargetScene() != null && !rule.getTargetScene().isEmpty()) {
if (!rule.getTargetScene().equals(context.getScene())) return false;
}
// 按概率决定是否触发
return random.nextDouble() < rule.getProbability();
}
private void applyFault(AiChaosConfig.ChaosRule rule, AiCallContext context) {
switch (rule.getType()) {
case DELAY -> injectDelay(rule);
case TIMEOUT -> injectTimeout(rule);
case TOKEN_EXCEEDED -> injectTokenExceeded(rule);
case CONTENT_FILTERED -> injectContentFiltered(rule);
case GARBLED_RESPONSE -> context.setForcedResponse(generateGarbledResponse());
case EMPTY_RETRIEVAL -> context.setForceEmptyRetrieval(true);
default -> log.warn("[CHAOS] Unknown fault type: {}", rule.getType());
}
}
private void injectDelay(AiChaosConfig.ChaosRule rule) {
long delayMs = Long.parseLong(rule.getParams().getOrDefault("delay-ms", "3000"));
log.warn("[CHAOS] Injecting {}ms delay", delayMs);
try {
Thread.sleep(delayMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private void injectTimeout(AiChaosConfig.ChaosRule rule) {
long timeoutMs = Long.parseLong(rule.getParams().getOrDefault("timeout-ms", "60000"));
log.warn("[CHAOS] Injecting timeout simulation ({}ms)", timeoutMs);
// 睡眠超过系统超时阈值,使请求超时
try {
Thread.sleep(timeoutMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private void injectTokenExceeded(AiChaosConfig.ChaosRule rule) {
log.warn("[CHAOS] Injecting token exceeded error");
// 抛出模拟的 Token 超限异常
throw new AiTokenExceededException(
"chaos-inject",
"This model's maximum context length is 128000 tokens. [CHAOS INJECTED]"
);
}
private void injectContentFiltered(AiChaosConfig.ChaosRule rule) {
log.warn("[CHAOS] Injecting content filter response");
throw new AiContentFilteredException(
"chaos-inject",
"I'm sorry, but I can't assist with that request. [CHAOS INJECTED]"
);
}
private String generateGarbledResponse() {
// 生成乱码响应,模拟编码错误或模型输出异常
String garbled = "<?xml version\u0000\u0001\ufffd\ufffd " +
"ERROR_CODE=0x8004FF" +
"\u0002\u0003\u001f\u007f";
log.warn("[CHAOS] Injecting garbled response");
return garbled;
}
}4.3 故障注入 AOP 切面
@Aspect
@Component
@ConditionalOnProperty(name = "chaos.ai.enabled", havingValue = "true")
@Slf4j
public class AiChaosAspect {
@Autowired
private AiChaosFilter chaosFilter;
@Around("@annotation(aiTracked)")
public Object injectChaos(ProceedingJoinPoint pjp, AiTracked aiTracked) throws Throwable {
AiCallContext context = AiCallContext.builder()
.requestId(MDC.get("requestId"))
.scene(aiTracked.scene())
.startTime(System.currentTimeMillis())
.build();
// 故障注入点(在实际 AI 调用之前)
chaosFilter.beforeAiCall(context);
// 如果注入了强制响应(乱码等),直接返回
if (context.getForcedResponse() != null) {
return AiResponse.builder()
.content(context.getForcedResponse())
.chaosInjected(true)
.build();
}
return pjp.proceed();
}
}4.4 流式响应中途截断注入
流式截断是 AI 特有的故障,单独实现:
@Component
@Slf4j
public class StreamChaosWrapper {
@Autowired
private AiChaosConfig chaosConfig;
private final Random random = new Random();
/**
* 包装流式响应,注入截断故障
*/
public Flux<String> wrapWithChaosTruncation(Flux<String> originalStream, String scene) {
if (!chaosConfig.isEnabled()) return originalStream;
// 查找是否有截断规则
Optional<AiChaosConfig.ChaosRule> truncateRule = chaosConfig.getRules().stream()
.filter(r -> r.getType() == AiChaosConfig.ChaosRule.ChaosType.TRUNCATED_RESPONSE)
.filter(r -> r.getTargetScene() == null || r.getTargetScene().equals(scene))
.filter(r -> random.nextDouble() < r.getProbability())
.findFirst();
if (truncateRule.isEmpty()) return originalStream;
AiChaosConfig.ChaosRule rule = truncateRule.get();
int truncateAfterTokens = Integer.parseInt(
rule.getParams().getOrDefault("truncate-after-tokens", "50")
);
log.warn("[CHAOS] Will truncate stream after {} tokens", truncateAfterTokens);
AtomicInteger tokenCount = new AtomicInteger(0);
return originalStream
.takeWhile(token -> {
int count = tokenCount.incrementAndGet();
if (count >= truncateAfterTokens) {
log.warn("[CHAOS] Truncating stream at token {}", count);
return false;
}
return true;
})
.concatWith(Flux.error(new StreamTruncatedException("[CHAOS] Stream truncated")));
}
}五、ChaosBlade 集成
对于基础设施层面的故障注入(网络延迟、进程崩溃),可以结合 ChaosBlade:
# 安装 ChaosBlade
brew install chaosblade # macOS
# 或下载对应平台的二进制
# 为 AI 服务的出站网络注入 3 秒延迟
# 针对发往 OpenAI API 的请求
blade create network delay \
--time 3000 \
--offset 500 \
--interface eth0 \
--destination-ip api.openai.com
# 注入 10% 丢包率
blade create network loss \
--percent 10 \
--interface eth0 \
--destination-ip api.openai.com
# 停止故障注入
blade destroy <experiment-id>
# 查看当前活跃的实验
blade status对应的 Java 管理接口(可以通过运维后台触发):
@RestController
@RequestMapping("/internal/chaos")
@Profile("!prod") // 严格限制:不在生产环境暴露
@Slf4j
public class ChaosController {
@Autowired
private AiChaosConfig chaosConfig;
@PostMapping("/enable")
public ResponseEntity<String> enableChaos(@RequestBody EnableChaosRequest request) {
log.warn("[CHAOS] Chaos engineering enabled by {}: {}",
request.getOperator(), request.getReason());
chaosConfig.setEnabled(true);
return ResponseEntity.ok("Chaos injection enabled");
}
@PostMapping("/disable")
public ResponseEntity<String> disableChaos(@RequestBody DisableChaosRequest request) {
log.warn("[CHAOS] Chaos engineering disabled by {}",
request.getOperator());
chaosConfig.setEnabled(false);
return ResponseEntity.ok("Chaos injection disabled");
}
@GetMapping("/status")
public ResponseEntity<Map<String, Object>> getStatus() {
return ResponseEntity.ok(Map.of(
"enabled", chaosConfig.isEnabled(),
"activeRules", chaosConfig.getRules().size(),
"rules", chaosConfig.getRules().stream()
.map(r -> Map.of("id", r.getId(), "type", r.getType(), "probability", r.getProbability()))
.collect(Collectors.toList())
));
}
}六、故障演练记录模板
每次演练后要有记录,这是积累团队「故障认知」的方式:
故障演练报告
============
演练日期:2024-12-15
演练人员:老张
演练环境:Staging 环境
演练时长:45 分钟
演练场景
--------
1. 主模型 API 延迟突增(注入 8 秒延迟,概率 100%)
2. Token 超限(注入 429 错误,概率 30%)
期望行为
--------
1. 延迟注入 → 熔断器在 10 次失败后触发 → 自动切换备用模型
2. Token 超限 → 触发 Prompt 压缩策略 → 重试成功
实际观察
--------
1. [通过] 熔断器在第 7 次失败时触发(期望 10 次以内)
切换到备用模型耗时 2.3 秒,用户感知到轻微卡顿
2. [失败] Token 超限时,Prompt 压缩策略没有生效
根因:压缩策略只在 context_length 超限时触发,
没有覆盖 token_count 超限的场景
发现的问题
----------
1. Token 超限和 Context 超限的异常处理路径不统一,需要合并
后续改进
--------
1. 统一 Token 相关异常处理,下周三前完成
2. 增加备用模型切换耗时的监控指标七、演练频率建议
不同类型的故障演练频率不同:
- 每次大版本发布前:做核心降级路径验证(主模型 → 备用模型)
- 每月一次:全套故障场景演练,覆盖 Token 超限、流式截断等
- 每季度一次:极端场景演练,所有 AI 服务同时不可用
演练不是越频繁越好,关键是每次演练后都要有改进行动。如果每次演练都发现同样的问题,说明没有真正在修。
总结
AI 系统的混沌工程和传统应用最大的区别是:需要注入 AI 特有的故障类型——响应慢但不超时、Token 超限、内容过滤、流式截断、乱码响应。
这些故障在普通的混沌工程框架里没有内置支持,需要自己实现。Spring Filter + AOP 的组合是最干净的实现方式,不侵入业务代码,可以动态开关。
记住,混沌工程的目的不是证明系统很健壮,而是主动找到系统的脆弱点,在生产故障之前修复它。每次演练发现问题都是好事。
