第1929篇:混沌工程在AI微服务中的实践——有计划地注入故障测试韧性
第1929篇:混沌工程在AI微服务中的实践——有计划地注入故障测试韧性
大概是2023年年中,我们团队里有一个让人哭笑不得的对话。
产品经理问:"我们AI服务的可靠性怎么样?"
我们的技术负责人答:"应该还行,没出过大故障。"
我在旁边心里嘀咕:没出过故障,是因为我们系统真的稳,还是因为我们还没遇到那个能引爆问题的场景?
这两个是完全不同的事情。前者是真正的韧性,后者是运气。
两周后,我们遇到了一个从没遇到过的场景:第三方Embedding API突然降速,原本200ms的响应变成了8秒。我们的向量检索服务完全没有针对这种场景的降级,线程池被全部打满,整个服务雪崩了,影响了将近40分钟。
事后复盘,我们做了一个决定:把混沌工程引入AI微服务的日常测试流程,主动发现那些平时没想到的薄弱点。
混沌工程的核心思想
混沌工程不是"随机制造故障看会发生什么",那叫破坏实验。
混沌工程的正确姿势是:
- 提出假设:在正常状态下,系统的稳态是什么(比如:P99延迟 < 500ms,成功率 > 99%)
- 设计实验:在受控的方式下注入某种故障(比如:某个AI服务接口延迟增加3秒)
- 观察影响:系统是否如预期一样降级,而不是崩溃
- 修复弱点:如果发现系统崩溃或表现与预期不符,找到原因并修复
- 扩大实验范围:从预发环境到灰度,再到全量
AI微服务有几类特有的薄弱点,特别值得用混沌工程测试:
混沌实验框架搭建
我们用的是Chaos Mesh(Kubernetes原生的混沌工程平台)+ 自研的AI专用故障注入器。先搭基础框架:
@Component
public class ChaosExperimentOrchestrator {
@Autowired
private List<FaultInjector> faultInjectors;
@Autowired
private SteadyStateVerifier steadyStateVerifier;
@Autowired
private MetricsCollector metricsCollector;
@Autowired
private ChaosExperimentRepository experimentRepo;
/**
* 执行一个混沌实验
*/
public ChaosExperimentResult execute(ChaosExperiment experiment) {
log.info("开始混沌实验: {}", experiment.getName());
// 1. 验证当前稳态(实验前的基线)
SteadyStateSnapshot baselineSnapshot = steadyStateVerifier.captureSnapshot();
if (!baselineSnapshot.isHealthy()) {
log.warn("系统当前不健康,跳过混沌实验: {}", experiment.getName());
return ChaosExperimentResult.skipped("系统不健康");
}
// 2. 找到对应的故障注入器
FaultInjector injector = findInjector(experiment.getFaultType());
ChaosExperimentResult result = new ChaosExperimentResult(experiment);
try {
// 3. 注入故障
FaultHandle handle = injector.inject(experiment.getFaultConfig());
result.setFaultInjectedAt(Instant.now());
log.info("故障已注入: type={}", experiment.getFaultType());
// 4. 观察系统行为
Thread.sleep(experiment.getObservationDurationMs());
// 5. 捕获实验中的指标
result.setObservationMetrics(
metricsCollector.collect(
result.getFaultInjectedAt(),
Instant.now()
)
);
// 6. 验证稳态假设是否成立
SteadyStateSnapshot duringSnapshot = steadyStateVerifier.captureSnapshot();
result.setHypothesisHeld(
steadyStateVerifier.verify(experiment.getHypothesis(), duringSnapshot)
);
if (!result.isHypothesisHeld()) {
log.error("混沌实验发现弱点!假设验证失败: {}",
duringSnapshot.getSummary());
// 发送告警
sendWeaknessAlert(experiment, duringSnapshot);
} else {
log.info("混沌实验通过:系统在故障下保持了稳态");
}
} catch (Exception e) {
log.error("混沌实验执行异常: {}", e.getMessage());
result.setError(e.getMessage());
} finally {
// 7. 无论结果如何,撤销故障注入(恢复系统)
injector.rollback(experiment.getFaultConfig());
result.setFaultRolledBackAt(Instant.now());
log.info("故障已撤销,系统恢复正常");
// 8. 验证恢复
SteadyStateSnapshot recoverySnapshot = steadyStateVerifier.captureSnapshot();
result.setFullyRecovered(
steadyStateVerifier.isRecovered(baselineSnapshot, recoverySnapshot)
);
}
// 9. 保存实验结果
experimentRepo.save(result);
return result;
}
}稳态定义:AI服务的基线指标
@Component
public class SteadyStateVerifier {
@Autowired
private MeterRegistry meterRegistry;
@Autowired
private AIServiceClient aiServiceClient;
/**
* 定义AI服务的稳态指标
*/
public SteadyStateSnapshot captureSnapshot() {
SteadyStateSnapshot snapshot = new SteadyStateSnapshot();
// 1. 成功率
double successRate = getSuccessRate(Duration.ofMinutes(2));
snapshot.setSuccessRate(successRate);
// 2. P99延迟
double p99Latency = getP99Latency(Duration.ofMinutes(2));
snapshot.setP99LatencyMs(p99Latency);
// 3. 实际推理一次(端到端探测)
InferenceProbeResult probe = probeInference();
snapshot.setInferenceSuccessful(probe.isSuccess());
snapshot.setInferenceLatencyMs(probe.getLatencyMs());
// 4. 错误率
double errorRate = getErrorRate(Duration.ofMinutes(2));
snapshot.setErrorRate(errorRate);
// 综合判定是否健康
snapshot.setHealthy(
successRate > 0.99 &&
p99Latency < 5000 &&
probe.isSuccess() &&
errorRate < 0.01
);
return snapshot;
}
public boolean verify(SteadyStateHypothesis hypothesis,
SteadyStateSnapshot current) {
// 验证当前状态是否满足稳态假设
// 假设是"在故障下,成功率应该仍然高于95%,不应该完全崩溃"
return switch (hypothesis.getType()) {
case DEGRADED_BUT_FUNCTIONAL ->
// 降级运行:成功率下降但不崩溃
current.getSuccessRate() >= hypothesis.getMinSuccessRate() &&
current.getInferenceSuccessful();
case FULL_FUNCTIONALITY ->
// 完全正常:成功率几乎不变
current.getSuccessRate() >= 0.99 &&
current.getP99LatencyMs() <= hypothesis.getMaxP99LatencyMs();
case GRACEFUL_DEGRADATION ->
// 优雅降级:可以降到更低级别的模型,但要有结果
current.getInferenceSuccessful() &&
current.getErrorRate() < 0.10;
};
}
}AI专用故障注入器
1. 推理超时注入器
@Component
public class InferenceLatencyInjector implements FaultInjector {
@Autowired
private InferenceInterceptorRegistry interceptorRegistry;
@Override
public FaultType getSupportedFaultType() {
return FaultType.INFERENCE_LATENCY;
}
@Override
public FaultHandle inject(FaultConfig config) {
InferenceLatencyConfig latencyConfig = (InferenceLatencyConfig) config;
// 注册一个推理拦截器,在实际推理前加延迟
InferenceInterceptor slowInterceptor = new InferenceInterceptor() {
@Override
public void beforeInference(InferenceContext ctx) {
// 只对一定比例的请求注入延迟(模拟部分节点慢)
if (Math.random() < latencyConfig.getAffectedPercentage()) {
long delayMs = latencyConfig.getAdditionalLatencyMs();
log.debug("注入推理延迟: {}ms", delayMs);
try {
Thread.sleep(delayMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
};
interceptorRegistry.register("chaos-latency", slowInterceptor);
log.info("推理延迟故障已注入: 影响{}%的请求,额外延迟{}ms",
(int)(latencyConfig.getAffectedPercentage() * 100),
latencyConfig.getAdditionalLatencyMs());
return new FaultHandle("chaos-latency");
}
@Override
public void rollback(FaultConfig config) {
interceptorRegistry.unregister("chaos-latency");
log.info("推理延迟故障已撤销");
}
}2. GPU OOM模拟器
@Component
public class GpuOomSimulator implements FaultInjector {
@Autowired
private GpuResourceManager gpuManager;
@Override
public FaultType getSupportedFaultType() {
return FaultType.GPU_OOM;
}
@Override
public FaultHandle inject(FaultConfig config) {
GpuOomConfig oomConfig = (GpuOomConfig) config;
// 通过分配大量显存来模拟OOM
// 注意:这个操作在生产环境要极其谨慎!
// 建议只在隔离的预发环境中使用
log.warn("=== 注入GPU OOM故障 ===");
gpuManager.allocateMemory(oomConfig.getMemoryToAllocateGb());
return new FaultHandle("gpu-oom-allocation");
}
@Override
public void rollback(FaultConfig config) {
gpuManager.releaseAllocatedMemory("gpu-oom-allocation");
log.info("GPU OOM故障已撤销,显存已释放");
}
}3. 向量数据库熔断注入器
@Component
public class VectorDbFaultInjector implements FaultInjector {
@Autowired
private VectorStoreClientWrapper vectorStoreWrapper;
@Override
public FaultType getSupportedFaultType() {
return FaultType.VECTOR_DB_UNAVAILABLE;
}
@Override
public FaultHandle inject(FaultConfig config) {
VectorDbFaultConfig faultConfig = (VectorDbFaultConfig) config;
// 在向量数据库客户端包装器里注入故障
vectorStoreWrapper.setFaultMode(faultConfig.getFaultMode());
// FaultMode: TIMEOUT, CONNECTION_ERROR, SLOW_RESPONSE, RANDOM_ERRORS
log.info("向量数据库故障已注入: mode={}", faultConfig.getFaultMode());
return new FaultHandle("vector-db-fault");
}
@Override
public void rollback(FaultConfig config) {
vectorStoreWrapper.clearFaultMode();
log.info("向量数据库故障已撤销");
}
}具体实验案例
实验1:向量检索超时实验
假设:当向量数据库响应超时(>3秒)时,RAG服务应该降级到关键词检索,成功率应该保持在90%以上。
@Component
public class VectorDbTimeoutExperiment {
@Autowired
private ChaosExperimentOrchestrator orchestrator;
@Scheduled(cron = "0 2 * * 1") // 每周一凌晨2点自动运行
public void run() {
ChaosExperiment experiment = ChaosExperiment.builder()
.name("向量数据库超时降级实验")
.faultType(FaultType.VECTOR_DB_UNAVAILABLE)
.faultConfig(VectorDbFaultConfig.builder()
.faultMode(FaultMode.TIMEOUT)
.timeoutMs(3000)
.affectedPercentage(1.0) // 100%的向量检索请求超时
.build())
.hypothesis(SteadyStateHypothesis.builder()
.type(HypothesisType.GRACEFUL_DEGRADATION)
.minSuccessRate(0.90)
.description("向量检索超时时应降级到关键词检索,成功率≥90%")
.build())
.observationDurationMs(120_000) // 观察2分钟
.build();
ChaosExperimentResult result = orchestrator.execute(experiment);
reportResult(result);
}
}我们跑这个实验的结果:实验失败。
发现向量数据库超时时,所有请求都挂在等待里,没有触发降级。原因是我们的降级逻辑写在了错误的层——我们的CircuitBreaker包的是整个RAG链,而向量检索的超时没有触发CircuitBreaker(因为超时时间设置的比触发阈值短)。
修复:把向量检索的超时设置改短(1秒),并在向量检索层单独加一个降级到关键词检索的逻辑。
修复后再次跑实验:实验通过,成功率维持在93%。
实验2:大模型服务全量不可用
假设:当主模型服务完全不可用时,系统应该在30秒内完成自动降级,降级到小模型继续提供服务,成功率不应该低于80%。
@Component
public class LLMUnavailableExperiment {
@Autowired
private ChaosExperimentOrchestrator orchestrator;
public ChaosExperimentResult run() {
ChaosExperiment experiment = ChaosExperiment.builder()
.name("大模型服务全量不可用实验")
.faultType(FaultType.LLM_SERVICE_DOWN)
.faultConfig(LlmFaultConfig.builder()
.targetModel("qwen-72b")
.faultMode(FaultMode.CONNECTION_ERROR)
.build())
.hypothesis(SteadyStateHypothesis.builder()
.type(HypothesisType.DEGRADED_BUT_FUNCTIONAL)
.minSuccessRate(0.80)
.maxDegradationTimeSeconds(30) // 30秒内完成降级
.description("降级到小模型,30秒内稳定,成功率≥80%")
.build())
.observationDurationMs(300_000) // 观察5分钟
.build();
return orchestrator.execute(experiment);
}
}这个实验的结果也很有意思:降级是生效了,但降级时间是45秒,超过了我们预期的30秒。
原因:CircuitBreaker配置的slidingWindowSize=20,需要20次失败请求才触发熔断。在我们的流量下,20次请求需要约45秒。
修复:把minimumNumberOfCalls改为5,在流量不高时也能快速触发熔断。
实验3:Context长度炸弹
这个是AI服务特有的实验:如果用户传了一个超级长的输入(比如粘贴了整本书的内容),系统会怎样?
@Component
public class LongContextBombExperiment {
@Autowired
private AIServiceClient aiServiceClient;
@Autowired
private SteadyStateVerifier verifier;
public void runContextBombExperiment() {
// 1. 捕获基线
SteadyStateSnapshot baseline = verifier.captureSnapshot();
// 2. 发送超长上下文请求
String ultraLongInput = "A".repeat(100_000); // 10万字符
long start = System.currentTimeMillis();
try {
aiServiceClient.infer(InferenceRequest.builder()
.prompt(ultraLongInput)
.maxTokens(100)
.build());
} catch (Exception e) {
log.info("长上下文请求结果: {}", e.getClass().getSimpleName());
}
long elapsed = System.currentTimeMillis() - start;
log.info("长上下文请求耗时: {}ms", elapsed);
// 3. 检查这个请求是否影响了其他正常请求
// 等待一会,让系统稳定
sleep(5000);
SteadyStateSnapshot afterBomb = verifier.captureSnapshot();
if (!afterBomb.isHealthy()) {
log.error("发现弱点!长上下文请求影响了其他正常请求");
log.error("基线成功率: {:.2f}%, 实验后成功率: {:.2f}%",
baseline.getSuccessRate() * 100,
afterBomb.getSuccessRate() * 100);
}
}
}这个实验发现了一个严重问题:超长输入请求会独占线程池里的线程(因为处理超长输入需要很长时间),导致其他正常请求排队超时。
修复:给"长输入请求"和"正常请求"分配独立的线程池(信号量隔离),避免相互影响。
自动化的混沌实验调度
把混沌实验加入到CI/CD流程,每次发布前自动运行:
# .github/workflows/chaos-test.yml
name: Chaos Engineering Tests
on:
deployment:
environments: [staging]
jobs:
chaos-tests:
runs-on: ubuntu-latest
steps:
- name: 等待服务稳定
run: sleep 120
- name: 运行向量DB超时实验
run: |
curl -X POST $CHAOS_API/experiments/vector-db-timeout \
-H "Authorization: Bearer $CHAOS_TOKEN"
- name: 运行LLM不可用实验
run: |
curl -X POST $CHAOS_API/experiments/llm-unavailable \
-H "Authorization: Bearer $CHAOS_TOKEN"
- name: 运行长上下文实验
run: |
curl -X POST $CHAOS_API/experiments/long-context-bomb \
-H "Authorization: Bearer $CHAOS_TOKEN"
- name: 检查实验结果
run: |
python check_experiment_results.py \
--min-pass-rate 0.8 \
--fail-on-weakness混沌实验的安全控制
混沌工程在生产环境使用需要谨慎。我们有几个安全控制机制:
@Component
public class ChaosExperimentSafetyGuard {
@Autowired
private SteadyStateVerifier verifier;
/**
* 实验前安全检查
*/
public SafetyCheckResult checkSafety(ChaosExperiment experiment) {
SafetyCheckResult result = new SafetyCheckResult();
// 1. 检查当前系统状态
SteadyStateSnapshot current = verifier.captureSnapshot();
if (!current.isHealthy()) {
result.setAllowed(false);
result.setReason("系统当前不健康,拒绝注入故障");
return result;
}
// 2. 检查是否在业务高峰期
if (isBusinessPeakHour()) {
result.setAllowed(false);
result.setReason("当前是业务高峰期,拒绝注入故障");
return result;
}
// 3. 检查是否有人工值守
if (experiment.isHighRisk() && !hasOnCallEngineer()) {
result.setAllowed(false);
result.setReason("高风险实验需要有人工值守");
return result;
}
// 4. 检查本周是否已经有失败的实验且还未修复
if (hasUnresolvedWeakness()) {
result.setAllowed(false);
result.setReason("存在未修复的弱点,请先修复再运行新实验");
return result;
}
result.setAllowed(true);
return result;
}
// 紧急停止:任何时候可以一键停止所有实验并撤销故障
@PostMapping("/chaos/emergency-stop")
public void emergencyStop() {
log.warn("=== 混沌实验紧急停止被触发 ===");
experimentOrchestrator.stopAllExperiments();
faultInjectorRegistry.rollbackAllFaults();
log.warn("=== 所有故障已撤销 ===");
}
}实验结果管理与跟进
每次实验结果都要有人跟进:
@Component
public class WeaknessTracker {
@Autowired
private JiraClient jiraClient;
@EventListener
public void onWeaknessDiscovered(WeaknessDiscoveredEvent event) {
// 自动创建Jira工单
JiraTicket ticket = JiraTicket.builder()
.summary("[混沌工程] " + event.getExperimentName() + " 发现系统弱点")
.description(buildTicketDescription(event))
.priority(event.getSeverity())
.labels(Arrays.asList("chaos-engineering", "reliability"))
.assignee(getOnCallEngineer())
.build();
String ticketId = jiraClient.createTicket(ticket);
// 直到工单修复,同类实验不会再次运行
weeknessRepo.save(new Weakness(
event.getExperimentName(),
event.getDescription(),
ticketId,
WeaknessStatus.OPEN
));
log.info("弱点工单已创建: {}", ticketId);
}
}踩坑与收获
做了大概8个月混沌实验,几个重要收获:
收获一:混沌实验发现的问题,80%是在"正常路径"中永远不会被测试到的。比如"向量库超时后线程池满了"这个问题,功能测试怎么可能测到?
收获二:实验结果要和业务指标挂钩,才有说服力。我们现在把"混沌实验通过率"作为季度OKR的一个指标,推动了很多修复工作。
收获三:从弱环境开始,不要上来就在生产环境搞。我们的顺序是:本地 → 测试环境 → 预发环境 → 生产少量流量。每个环境都要跑稳了再进下一步。
踩坑:实验没有清晰的假设,导致结果无法判断。刚开始做,我们的假设经常写得很模糊,比如"系统应该能处理向量库不可用",什么叫"能处理"?后来强制要求假设必须是可量化的:成功率≥X%,降级时间≤Y秒,才能判定实验结果。
混沌工程不是一次性活动,而是一个持续的工程实践。每个月发现并修复几个弱点,一年后你的AI系统韧性会比没做混沌工程的系统强很多。
那种"没出过故障不知道系统韧性如何"的焦虑,会慢慢变成"我们主动测过这些场景,知道系统能扛住"的自信。
