AI应用的混沌工程:主动发现AI系统的脆弱性
AI应用的混沌工程:主动发现AI系统的脆弱性
那次让全公司停摆的故障
孙超是一家在线教育公司的技术负责人,工作了 6 年,带着一个 15 人的后端团队。
2025年10月,他们上线了一个核心功能:AI 学习规划助手。用户输入自己的学习目标,AI 帮他们制定详细的课程学习路径。这个功能上线后反响极好,DAU 三个月内从 2 万增长到 11 万。
问题在 11 月的一个工作日早上 9:02 发生了。
监控第一个告警:AI 规划助手响应时间飙升到了 45 秒。
9:04:用户请求开始大量超时,AI 功能返回 500。
9:07:因为 AI 功能的线程池满了,请求堆积,整个服务开始不响应——包括不依赖 AI 的课程播放、用户登录等基础功能。
9:15:整个平台全面不可用。11 万 DAU 的学习计划全部中断。
根本原因很简单:他们的 AI Provider(一个国内的大模型服务)在早高峰因为自身机房问题,响应时间突然从正常的 3 秒变成了 60 秒以上。他们的代码里没有设置超时,没有熔断,没有降级。AI 请求的线程长时间占用,线程池耗尽,整个服务雪崩了。
更让孙超无奈的是:这个问题在两个月前的压测中根本没有出现过。压测环境里 AI 响应一直很快,没人想到要测"AI 变慢了会怎样"。
修复花了 3 小时。但等平台恢复,那天的早高峰已经过了,大量用户流失。
"如果我们能提前主动测试这个场景就好了。"
这,就是混沌工程要解决的问题。
混沌工程原理:主动注入故障,提前发现弱点
传统测试问的是:"系统在正常条件下是否工作正确?"
混沌工程问的是:"系统在出故障时是否能优雅降级?"
混沌工程的核心方法论(Netflix 提出的"稳态假设"):
- 定义稳态:系统在正常情况下的行为基线(如:95% 请求在 5 秒内完成)
- 提出假设:当 XX 发生故障时,系统仍能保持稳态
- 设计实验:注入故障,观察系统行为
- 验证假设:系统是否按预期降级,而不是完全崩溃
- 修复弱点:如果假设不成立,找到并修复薄弱环节
AI 系统特有的混沌实验
与传统微服务相比,AI 系统有独特的故障模式:
| 故障类型 | 传统服务 | AI 服务特有 |
|---|---|---|
| 延迟增加 | 数据库慢查询(毫秒级) | LLM 推理慢(秒到分钟级) |
| 限速 | QPS 限制 | Token/分钟 限制(LLM 特有) |
| 错误响应 | HTTP 500 | Content-Filter 拦截、hallucination |
| 部分失败 | 服务不可达 | 流式输出中途中断 |
| 资源耗尽 | CPU/内存 | Token 配额耗尽 |
Chaos Monkey for Spring Boot:随机注入延迟和异常
Chaos Monkey for Spring Boot 是 Spring 生态最成熟的混沌工程工具,可以无侵入地在 Spring 组件上注入延迟和异常。
依赖配置
<dependencies>
<!-- Chaos Monkey for Spring Boot -->
<dependency>
<groupId>de.codecentric</groupId>
<artifactId>chaos-monkey-spring-boot</artifactId>
<version>3.1.0</version>
</dependency>
<!-- Spring Boot Actuator(用于运行时控制混沌实验)-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Spring AI -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
<!-- Resilience4j(熔断、重试、超时)-->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.2.0</version>
</dependency>
<!-- Testcontainers(集成测试)-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
</dependencies>应用配置
# application.yml
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o
# Resilience4j 配置
resilience4j:
timelimiter:
instances:
aiService:
timeout-duration: 30s # AI 请求超时 30 秒
cancel-running-future: true
circuitbreaker:
instances:
aiService:
failure-rate-threshold: 50 # 50% 失败率触发熔断
wait-duration-in-open-state: 30s # 熔断 30 秒后尝试恢复
sliding-window-size: 20 # 20 个请求的滑动窗口
minimum-number-of-calls: 5 # 至少 5 次调用才计算失败率
permitted-number-of-calls-in-half-open-state: 3
retry:
instances:
aiService:
max-attempts: 2 # 最多重试 1 次(共 2 次)
wait-duration: 2s
retry-exceptions:
- java.util.concurrent.TimeoutException
- org.springframework.web.client.HttpServerErrorException
management:
endpoints:
web:
exposure:
include: chaosmonkey,health,metrics,prometheus
endpoint:
chaosmonkey:
enabled: true
---
# chaos profile 配置(只在非生产环境启用)
spring:
config:
activate:
on-profile: chaos
chaos:
monkey:
enabled: true
watcher:
service: true # 监控 @Service 注解的 Bean
rest-controller: false
component: false
assaults:
level: 3 # 强度:1-10
latency-active: true
exceptions-active: false
latency-range-start: 1000
latency-range-end: 5000实验一:LLM API 完全不可用时系统行为
假设:当 LLM API 不可用时,系统应该:
- 在 5 秒内向用户返回友好的降级响应(而不是等待 30 秒超时)
- AI 功能降级不影响其他非 AI 功能
- 熔断器正确触发,防止雪崩
// src/test/java/com/company/chaos/experiment/LlmUnavailableExperiment.java
package com.company.chaos.experiment;
import com.company.ai.service.AiPlanningService;
import com.company.course.service.CourseService;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.ai.chat.client.ChatClient;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@SpringBootTest
@DisplayName("混沌实验一:LLM API 完全不可用")
class LlmUnavailableExperiment {
@Autowired
private AiPlanningService aiPlanningService;
@Autowired
private CourseService courseService; // 非AI功能,应该不受影响
@Autowired
private CircuitBreakerRegistry circuitBreakerRegistry;
@MockBean
private ChatClient chatClient;
@BeforeEach
void setup() {
// 模拟 LLM 完全不可用(直接抛出连接异常)
when(chatClient.prompt()).thenThrow(
new RuntimeException("Connection refused: LLM API unavailable")
);
}
@Test
@DisplayName("验证:LLM不可用时,AI功能快速降级(不超过5秒)")
void whenLlmUnavailable_shouldFallbackWithin5Seconds() {
long startTime = System.currentTimeMillis();
AiPlanningResponse response = aiPlanningService.createPlan(
"我想学习 Java 微服务开发,请制定学习计划",
"user-001"
);
long elapsed = System.currentTimeMillis() - startTime;
// 关键验证点1:降级在 5 秒内返回
assertThat(elapsed).isLessThan(5000L)
.as("降级响应应该在 5 秒内返回,实际耗时: %d ms", elapsed);
// 关键验证点2:降级响应有意义(不是 null 或空白)
assertThat(response).isNotNull();
assertThat(response.isFallback()).isTrue();
assertThat(response.getContent()).isNotBlank()
.as("降级响应应该包含有意义的内容");
System.out.printf("实验结果:降级耗时 %d ms,降级内容长度 %d 字符%n",
elapsed, response.getContent().length());
}
@Test
@DisplayName("验证:AI功能降级不影响课程播放等基础功能")
void whenLlmUnavailable_shouldNotAffectNonAiFeatures() {
// 先触发 AI 功能降级
aiPlanningService.createPlan("任意问题", "user-001");
// 验证非 AI 功能仍然正常
long startTime = System.currentTimeMillis();
CourseDetail course = courseService.getCourseDetail("course-001");
long elapsed = System.currentTimeMillis() - startTime;
assertThat(course).isNotNull();
assertThat(elapsed).isLessThan(1000L)
.as("非AI功能响应时间应该正常,不受AI故障影响");
System.out.printf("非AI功能正常,响应时间: %d ms%n", elapsed);
}
@Test
@DisplayName("验证:熔断器在持续失败后正确触发")
void whenLlmContinuouslyFails_shouldTriggerCircuitBreaker() throws InterruptedException {
CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("aiService");
AtomicInteger fallbackCount = new AtomicInteger(0);
AtomicInteger circuitBreakerOpenCount = new AtomicInteger(0);
// 发送 25 个请求(超过滑动窗口大小 20)
for (int i = 0; i < 25; i++) {
try {
AiPlanningResponse response = aiPlanningService.createPlan("问题" + i, "user-" + i);
if (response.isFallback()) {
fallbackCount.incrementAndGet();
}
} catch (Exception e) {
// 记录熔断器 OPEN 状态导致的异常
circuitBreakerOpenCount.incrementAndGet();
}
// 短暂间隔,模拟真实请求节奏
Thread.sleep(100);
}
System.out.printf("总请求: 25, 降级次数: %d, 熔断拒绝次数: %d%n",
fallbackCount.get(), circuitBreakerOpenCount.get());
System.out.printf("熔断器状态: %s%n", cb.getState());
// 关键验证:熔断器应该在高失败率后触发
assertThat(cb.getState()).isIn(CircuitBreaker.State.OPEN, CircuitBreaker.State.HALF_OPEN)
.as("持续失败后,熔断器应该触发OPEN状态");
}
@Test
@DisplayName("验证:并发 100 个 AI 请求时,不导致线程池耗尽")
void whenHighConcurrencyWithLlmDown_shouldNotExhaustThreadPool() throws InterruptedException {
int concurrency = 100;
CountDownLatch latch = new CountDownLatch(concurrency);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failCount = new AtomicInteger(0);
AtomicInteger timeoutCount = new AtomicInteger(0);
ExecutorService executor = Executors.newFixedThreadPool(concurrency);
long startTime = System.currentTimeMillis();
for (int i = 0; i < concurrency; i++) {
final int index = i;
executor.submit(() -> {
try {
AiPlanningResponse response = aiPlanningService.createPlan(
"并发请求" + index, "user-" + index
);
if (response != null) successCount.incrementAndGet();
} catch (Exception e) {
if (e.getMessage().contains("timeout")) {
timeoutCount.incrementAndGet();
} else {
failCount.incrementAndGet();
}
} finally {
latch.countDown();
}
});
}
boolean completed = latch.await(60, TimeUnit.SECONDS);
long elapsed = System.currentTimeMillis() - startTime;
System.out.printf("""
并发实验结果(%d并发, LLM不可用):
总耗时: %d ms
成功降级: %d
超时: %d
其他失败: %d
完成率: %.1f%%
""",
concurrency, elapsed, successCount.get(),
timeoutCount.get(), failCount.get(),
(double) (successCount.get() + failCount.get() + timeoutCount.get()) / concurrency * 100);
// 关键验证:所有请求必须在 60 秒内完成(不能死锁或线程池耗尽)
assertThat(completed).isTrue()
.as("100个并发请求应该在60秒内全部完成");
// 超时请求不能超过 10%(大部分应该通过熔断快速失败)
assertThat(timeoutCount.get()).isLessThanOrEqualTo(10)
.as("超时请求不应超过 10%,熔断器应该快速拒绝后续请求");
executor.shutdown();
}
}实验二:LLM 响应时间突然增加 10 倍
// src/test/java/com/company/chaos/experiment/LlmHighLatencyExperiment.java
package com.company.chaos.experiment;
import com.company.ai.service.AiPlanningService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.ai.chat.client.ChatClient;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@SpringBootTest
@DisplayName("混沌实验二:LLM 响应延迟突增 10 倍")
class LlmHighLatencyExperiment {
@Autowired
private AiPlanningService aiPlanningService;
@MockBean
private ChatClient chatClient;
// 正常延迟:3 秒;异常延迟:30 秒(10倍)
private static final long NORMAL_LATENCY_MS = 3_000;
private static final long HIGH_LATENCY_MS = 30_000;
@Test
@DisplayName("验证:超时配置生效,30秒延迟不导致请求无限等待")
void whenLlmLatencySpikes_timeoutShouldKickIn() throws Exception {
// 模拟高延迟响应
when(chatClient.prompt()).thenAnswer(inv -> {
Thread.sleep(HIGH_LATENCY_MS);
return createMockChatClientSpec();
});
long startTime = System.currentTimeMillis();
AiPlanningResponse response = aiPlanningService.createPlan(
"制定学习计划", "user-001"
);
long elapsed = System.currentTimeMillis() - startTime;
System.out.printf("高延迟实验结果: 实际等待 %d ms%n", elapsed);
// 超时应该在 30 秒内生效(Resilience4j TimeLimiter 配置的 30s)
assertThat(elapsed).isLessThan(35_000L)
.as("超时机制应该在配置的时间内生效");
// 超时后应该有降级响应,而不是 null
assertThat(response).isNotNull();
assertThat(response.isFallback()).isTrue();
}
@Test
@DisplayName("验证:P99 延迟超时后,用户体验降级而非崩溃")
void whenP99LatencySpiked_userExperienceShouldDegradeGracefully() throws InterruptedException {
AtomicLong maxResponseTime = new AtomicLong(0);
int requestCount = 50;
CountDownLatch latch = new CountDownLatch(requestCount);
java.util.List<Long> responseTimes = new java.util.concurrent.CopyOnWriteArrayList<>();
// 模拟 P99 延迟(1% 的请求延迟 30 秒,其他正常)
java.util.concurrent.atomic.AtomicInteger callCount = new java.util.concurrent.atomic.AtomicInteger(0);
when(chatClient.prompt()).thenAnswer(inv -> {
int count = callCount.incrementAndGet();
if (count % 100 < 1) { // 1% 高延迟
Thread.sleep(HIGH_LATENCY_MS);
} else {
Thread.sleep(NORMAL_LATENCY_MS);
}
return createMockChatClientSpec();
});
ExecutorService executor = Executors.newFixedThreadPool(20);
for (int i = 0; i < requestCount; i++) {
executor.submit(() -> {
long start = System.currentTimeMillis();
try {
aiPlanningService.createPlan("学习计划", "user");
} catch (Exception ignored) {}
long elapsed = System.currentTimeMillis() - start;
responseTimes.add(elapsed);
maxResponseTime.updateAndGet(max -> Math.max(max, elapsed));
latch.countDown();
});
}
latch.await(120, TimeUnit.SECONDS);
// 计算 P95, P99
responseTimes.sort(Long::compare);
int p95Index = (int) (responseTimes.size() * 0.95);
int p99Index = (int) (responseTimes.size() * 0.99);
long p95 = responseTimes.get(Math.min(p95Index, responseTimes.size() - 1));
long p99 = responseTimes.get(Math.min(p99Index, responseTimes.size() - 1));
System.out.printf("""
延迟突增实验结果:
P95 响应时间: %d ms
P99 响应时间: %d ms
最大响应时间: %d ms
""", p95, p99, maxResponseTime.get());
// 即使 P99 有高延迟,也应该被超时机制控制在合理范围
assertThat(p99).isLessThan(35_000L)
.as("P99 响应时间应该被超时机制控制");
executor.shutdown();
}
private Object createMockChatClientSpec() {
// 返回 mock 的 ChatClient.ChatClientRequestSpec
return null; // 实际测试中使用 Mockito 完整 mock
}
}实验三:向量数据库连接中断
// src/test/java/com/company/chaos/experiment/VectorDbOutageExperiment.java
package com.company.chaos.experiment;
import com.company.ai.service.RagQueryService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.ai.vectorstore.VectorStore;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@SpringBootTest
@DisplayName("混沌实验三:向量数据库连接中断")
class VectorDbOutageExperiment {
@Autowired
private RagQueryService ragQueryService;
@MockBean
private VectorStore vectorStore;
@Test
@DisplayName("验证:向量DB中断时,RAG服务降级为普通LLM对话")
void whenVectorDbDown_shouldFallbackToDirectLlm() {
// 模拟向量库不可用
when(vectorStore.similaritySearch(any())).thenThrow(
new RuntimeException("Connection to vector database timed out")
);
long startTime = System.currentTimeMillis();
RagQueryResponse response = ragQueryService.query(
"什么是 Spring Boot?", "user-001"
);
long elapsed = System.currentTimeMillis() - startTime;
System.out.printf("""
向量DB中断实验:
响应时间: %d ms
是否降级: %s
响应内容: %s
引用来源数: %d
""",
elapsed,
response.isFallback(),
response.getContent().substring(0, Math.min(100, response.getContent().length())),
response.getSources() != null ? response.getSources().size() : 0
);
// 关键验证:向量DB中断后的行为
assertThat(response).isNotNull()
.as("向量DB中断时,服务应该返回降级响应而不是抛出异常");
assertThat(response.getContent()).isNotBlank()
.as("降级响应应该包含有意义的内容(直接LLM回答)");
assertThat(response.getSources()).isEmpty()
.as("无法访问向量DB时,引用来源应为空");
assertThat(response.isFallback()).isTrue()
.as("应该标记为降级响应,供前端显示提示信息");
}
@Test
@DisplayName("验证:向量DB恢复后,服务自动恢复RAG能力")
void whenVectorDbRecovered_shouldResumeRagCapability() throws InterruptedException {
// 第一阶段:向量DB不可用
when(vectorStore.similaritySearch(any())).thenThrow(
new RuntimeException("Connection refused")
);
RagQueryResponse fallbackResponse = ragQueryService.query("问题1", "user-001");
assertThat(fallbackResponse.isFallback()).isTrue();
// 模拟向量DB恢复(30秒后)
Thread.sleep(2000); // 简化为 2 秒
when(vectorStore.similaritySearch(any())).thenReturn(
java.util.List.of(
new org.springframework.ai.document.Document("向量DB恢复了,这是检索到的文档内容")
)
);
// 第二阶段:向量DB已恢复
RagQueryResponse recoveredResponse = ragQueryService.query("问题2", "user-001");
System.out.printf("恢复实验: 降级=%s -> 恢复=%s%n",
fallbackResponse.isFallback(), !recoveredResponse.isFallback());
// 恢复后应该重新有 RAG 能力
assertThat(recoveredResponse.isFallback()).isFalse()
.as("向量DB恢复后,服务应该自动恢复RAG能力");
assertThat(recoveredResponse.getSources()).isNotEmpty()
.as("向量DB恢复后,应该能返回引用来源");
}
}实验四:Token 限速达到上限
// src/test/java/com/company/chaos/experiment/TokenRateLimitExperiment.java
package com.company.chaos.experiment;
import com.company.ai.service.AiPlanningService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.http.HttpStatus;
import org.springframework.web.client.HttpClientErrorException;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@SpringBootTest
@DisplayName("混沌实验四:Token 限速达到上限(429 Too Many Requests)")
class TokenRateLimitExperiment {
@Autowired
private AiPlanningService aiPlanningService;
@MockBean
private ChatClient chatClient;
@Test
@DisplayName("验证:收到 429 限速错误时,系统使用指数退避重试")
void whenRateLimited_shouldRetryWithExponentialBackoff() {
AtomicInteger callCount = new AtomicInteger(0);
// 前 2 次调用返回 429,第 3 次成功
when(chatClient.prompt()).thenAnswer(inv -> {
int count = callCount.incrementAndGet();
if (count <= 2) {
throw HttpClientErrorException.create(
HttpStatus.TOO_MANY_REQUESTS,
"Rate limit exceeded",
org.springframework.http.HttpHeaders.EMPTY,
"{\"error\":{\"type\":\"rate_limit_error\"}}".getBytes(),
StandardCharsets.UTF_8
);
}
return createSuccessfulResponse();
});
long startTime = System.currentTimeMillis();
AiPlanningResponse response = aiPlanningService.createPlan("学习计划", "user-001");
long elapsed = System.currentTimeMillis() - startTime;
System.out.printf("""
429限速实验:
总调用次数: %d(含重试)
总耗时: %d ms
最终是否成功: %s
""", callCount.get(), elapsed, !response.isFallback());
assertThat(callCount.get()).isGreaterThanOrEqualTo(2)
.as("应该至少重试了一次");
assertThat(response.isFallback()).isFalse()
.as("重试后应该成功,不应该降级");
}
@Test
@DisplayName("验证:持续 429 时,不无限重试,最终优雅降级")
void whenPersistentRateLimit_shouldNotRetryInfinitely() {
AtomicInteger callCount = new AtomicInteger(0);
// 所有调用都返回 429
when(chatClient.prompt()).thenAnswer(inv -> {
callCount.incrementAndGet();
throw HttpClientErrorException.create(
HttpStatus.TOO_MANY_REQUESTS,
"Rate limit exceeded",
org.springframework.http.HttpHeaders.EMPTY,
"{}".getBytes(),
StandardCharsets.UTF_8
);
});
long startTime = System.currentTimeMillis();
AiPlanningResponse response = aiPlanningService.createPlan("学习计划", "user-001");
long elapsed = System.currentTimeMillis() - startTime;
System.out.printf("""
持续429实验:
总调用次数: %d(不应该无限重试)
总耗时: %d ms
最终降级: %s
""", callCount.get(), elapsed, response.isFallback());
// 关键验证:不能无限重试
assertThat(callCount.get()).isLessThanOrEqualTo(3)
.as("持续 429 时,重试次数应该有上限(配置为最多2次)");
// 最终应该降级而不是抛出异常
assertThat(response.isFallback()).isTrue()
.as("重试耗尽后,应该优雅降级");
// 不应该等太久
assertThat(elapsed).isLessThan(15_000L)
.as("即使持续 429,也应该在合理时间内完成(不超过15秒)");
}
private Object createSuccessfulResponse() {
return null; // Mockito mock 实现
}
}AI 系统的降级实现
上面的实验验证降级行为,这里是降级的实际实现:
// src/main/java/com/company/ai/service/AiPlanningService.java
package com.company.ai.service;
import com.company.ai.dto.AiPlanningResponse;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.retry.annotation.Retry;
import io.github.resilience4j.timelimiter.annotation.TimeLimiter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
import java.util.concurrent.CompletableFuture;
@Service
@RequiredArgsConstructor
@Slf4j
public class AiPlanningService {
private final ChatClient chatClient;
private final FallbackPlanningService fallbackService;
/**
* AI 学习规划,带完整的弹性保护
* 执行顺序:TimeLimiter -> CircuitBreaker -> Retry -> fallback
*/
@TimeLimiter(name = "aiService", fallbackMethod = "fallback")
@CircuitBreaker(name = "aiService", fallbackMethod = "fallback")
@Retry(name = "aiService")
public CompletableFuture<AiPlanningResponse> createPlanAsync(String goal, String userId) {
return CompletableFuture.supplyAsync(() -> {
log.info("调用LLM生成学习规划: userId={}", userId);
String prompt = String.format("""
你是一位专业的学习规划顾问。
请为以下学习目标制定详细的学习计划:
目标:%s
要求:
1. 按周拆解,共12周
2. 每周包含具体的学习内容和实践任务
3. 推荐相关学习资源
""", goal);
String content = chatClient.prompt()
.user(prompt)
.call()
.content();
return AiPlanningResponse.builder()
.content(content)
.userId(userId)
.isFallback(false)
.build();
});
}
/**
* 同步版本(内部调用 async 版本)
*/
public AiPlanningResponse createPlan(String goal, String userId) {
try {
return createPlanAsync(goal, userId).get();
} catch (Exception e) {
log.warn("AI规划服务失败,触发降级: userId={}, error={}", userId, e.getMessage());
return fallback(goal, userId, e);
}
}
/**
* 降级方法:当所有重试耗尽、熔断触发、或超时时调用
* 注意:fallback 方法签名必须与原方法相同,再加一个 Throwable 参数
*/
public CompletableFuture<AiPlanningResponse> fallback(String goal, String userId, Throwable t) {
log.warn("AI规划降级: userId={}, reason={}", userId, t.getMessage());
return CompletableFuture.completedFuture(buildFallbackResponse(goal, userId, t));
}
private AiPlanningResponse fallback(String goal, String userId, Exception e) {
return buildFallbackResponse(goal, userId, e);
}
private AiPlanningResponse buildFallbackResponse(String goal, String userId, Throwable t) {
// 根据目标关键词返回预设的学习路径模板
String fallbackContent = fallbackService.getTemplatePlan(goal);
return AiPlanningResponse.builder()
.content(fallbackContent)
.userId(userId)
.isFallback(true)
.fallbackReason(classifyFailure(t))
.build();
}
/**
* 根据异常类型给出不同的用户提示
*/
private String classifyFailure(Throwable t) {
if (t instanceof java.util.concurrent.TimeoutException) {
return "AI服务响应超时,已为您提供基础学习方案,稍后可重新获取个性化规划。";
} else if (t instanceof org.springframework.web.client.HttpClientErrorException e
&& e.getStatusCode().value() == 429) {
return "AI服务请求频率过高,请稍后再试。";
} else if (t instanceof io.github.resilience4j.circuitbreaker.CallNotPermittedException) {
return "AI服务暂时不可用(熔断保护中),已为您提供备选方案。";
}
return "AI服务暂时异常,已为您提供基础学习方案。";
}
}混沌实验文档:记录和分享实验结果
每次混沌实验都应该写一份标准的实验报告:
# 混沌实验报告
## 实验基本信息
- 实验ID: CHAOS-2025-042
- 实验名称: LLM API 完全不可用时的系统行为验证
- 执行人: 孙超
- 执行时间: 2025-11-15 14:00-15:30(测试环境)
- 系统版本: v2.3.1
## 稳态定义
正常情况下,AI 学习规划服务的基准指标:
- P95 响应时间 < 8 秒
- 错误率 < 0.1%
- 非AI功能(课程播放、用户登录)响应时间 < 500ms
## 假设
当 LLM API 完全不可用时,系统应该:
1. AI 功能在 5 秒内返回降级响应
2. 非AI功能完全不受影响
3. 熔断器在 20 次失败后触发,后续请求快速失败
## 实验设计
- 故障类型:模拟 LLM API 连接超时(Connection refused)
- 注入方式:Mock ChatClient 抛出 RuntimeException
- 并发级别:100 并发请求
- 持续时间:5 分钟
## 实验结果
### 假设1 验证结果:✅ 通过
- AI 功能降级响应时间:平均 234ms(远小于 5 秒阈值)
- 降级内容质量:用户反馈满意度 3.8/5(vs 正常 4.2/5)
### 假设2 验证结果:✅ 通过
- 课程播放响应时间:420ms(正常基线 380ms,影响可接受)
- 用户登录功能:完全正常
### 假设3 验证结果:⚠️ 部分通过
- 熔断器触发时间:预期 20 次失败后,实际需要 28 次
- 原因:并发请求时部分请求在熔断器状态更新前已进入
## 发现的问题
1. 熔断器配置 sliding-window-size=20 在高并发下可能多放入约 20-30% 的请求
2. 降级响应内容过于通用,不够个性化
## 改进措施
1. 将 minimum-number-of-calls 从 5 调整为 10,减少误触发
2. 为降级内容增加基于用户历史学习记录的个性化(需要 2 周开发)
3. 下一次实验:验证改进后的熔断器配置
## 下次实验计划
- CHAOS-2025-043: LLM 响应延迟 20 秒(慢响应而非不可用)恢复力验证框架
把混沌实验固化为自动化验证套件:
// src/test/java/com/company/chaos/ResilienceVerificationSuite.java
package com.company.chaos;
import org.junit.jupiter.api.*;
import org.junit.platform.suite.api.SelectClasses;
import org.junit.platform.suite.api.Suite;
/**
* AI 系统恢复力验证套件
* 在每次重要发布前运行,确保系统弹性没有退化
*/
@Suite
@SelectClasses({
LlmUnavailableExperiment.class,
LlmHighLatencyExperiment.class,
VectorDbOutageExperiment.class,
TokenRateLimitExperiment.class
})
@DisplayName("AI 系统混沌工程验证套件")
class ResilienceVerificationSuite {
/**
* 验证矩阵:所有关键故障场景
*
* | 故障 | 期望行为 | 验证状态 |
* |------|---------|---------|
* | LLM 不可用 | 5秒内降级,非AI功能不受影响 | ✅ |
* | LLM 高延迟 | 超时后降级,线程池不耗尽 | ✅ |
* | 向量DB中断 | 降级为直接LLM,恢复后自动重连 | ✅ |
* | Token限速(429) | 退避重试,重试耗尽后降级 | ✅ |
*/
}FAQ
Q1:混沌工程实验应该在哪个环境运行?
优先在生产环境运行(这才能发现真实问题),但需要分阶段:先在开发环境验证实验设计 -> 测试环境压力测试 -> 预生产环境完整验证 -> 生产环境低峰期小流量实验。初期建议从测试环境开始,积累经验和信心后再到生产。
Q2:混沌实验会不会影响用户?
可控的混沌实验影响极小。关键是"爆炸半径"控制:只在非高峰期运行,只影响少量流量(如 1%),设置自动中止条件(如:错误率超过阈值自动停止实验)。Netflix 的 Chaos Monkey 就是在生产环境全天候运行的。
Q3:AI 服务的降级内容怎么设计?
降级分三层:1)服务端降级——返回预设模板内容(无需调用 LLM);2)客户端降级——前端显示友好提示,引导用户稍后重试;3)功能降级——AI 辅助功能降级为手动模式。对于核心流程,第一层降级内容要精心设计,不能只是"服务不可用,请稍后再试"。
Q4:Chaos Monkey for Spring Boot 和自己写 Mock 有什么区别?
自己写 Mock(如本文实验代码)是白盒测试,可以精确控制故障场景;Chaos Monkey 是黑盒工具,可以在不修改代码的情况下随机注入故障。两者互补:自己写测试用于验证具体降级逻辑,Chaos Monkey 用于发现意料之外的弱点。
Q5:如何衡量混沌工程的 ROI?
计算方式:通过混沌实验发现的每个问题,估算如果在生产爆发的损失(故障时长 × 用户量 × 单位时间价值)。孙超团队引入混沌工程后 6 个月,发现并修复了 7 个潜在问题,预防了估算约 150 万元的生产故障损失,远超混沌工程的投入成本。
总结
混沌工程不是制造破坏,而是在可控条件下主动寻找系统的脆弱性,在它们在生产环境以不可控的方式爆发之前就修复它们。
孙超的那次事故发生后的 6 个月,他们的 AI 服务经历了两次 LLM Provider 故障,一次向量数据库抖动,一次 Token 配额耗尽。每一次,系统都按预期降级,用户没有感知到服务中断。
混沌工程,让他终于睡了个踏实觉。
附录:生产环境混沌实验配置
使用 Chaos Monkey Runtime API 控制实验
Chaos Monkey for Spring Boot 提供了 HTTP API,可以在运行时动态开启和关闭混沌实验:
# 查看当前混沌实验状态
curl http://localhost:8080/actuator/chaosmonkey
# 开启混沌实验(注入延迟)
curl -X POST http://localhost:8080/actuator/chaosmonkey/assaults \
-H "Content-Type: application/json" \
-d '{
"level": 3,
"latencyActive": true,
"latencyRangeStart": 5000,
"latencyRangeEnd": 15000,
"exceptionsActive": false
}'
# 针对特定 Service Bean 注入异常
curl -X POST http://localhost:8080/actuator/chaosmonkey/assaults \
-H "Content-Type: application/json" \
-d '{
"level": 5,
"latencyActive": false,
"exceptionsActive": true,
"exception": {
"type": "java.lang.RuntimeException",
"arguments": [{"className": "java.lang.String", "value": "Chaos: LLM simulated failure"}]
},
"watchedCustomServices": ["com.company.ai.service.AiPlanningService"]
}'
# 关闭混沌实验
curl -X POST http://localhost:8080/actuator/chaosmonkey/enable \
-H "Content-Type: application/json" \
-d '{"enableChaosMonkey": false}'自动化混沌实验调度器
// src/main/java/com/company/chaos/scheduler/ChaosExperimentScheduler.java
package com.company.chaos.scheduler;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.time.LocalTime;
import java.util.Map;
/**
* 定期自动运行混沌实验
* 只在非高峰期(凌晨 2-4 点)运行,避免影响真实用户
*
* 注意:这个调度器只在 staging 环境激活,生产环境需要手动触发
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class ChaosExperimentScheduler {
private final RestTemplate restTemplate;
private final ChaosExperimentReporter reporter;
// 只在凌晨 2:00 触发(非高峰期)
@Scheduled(cron = "0 0 2 * * MON-FRI")
public void runWeeklyResilienceCheck() {
log.info("开始自动混沌实验(每周稳定性验证)");
ExperimentResult result = new ExperimentResult();
try {
// 实验1:LLM 延迟注入(持续 5 分钟)
injectLatency(5000, 15000);
Thread.sleep(5 * 60 * 1000L);
ExperimentMetrics metrics1 = collectMetrics("latency_spike");
result.addExperiment("LLM延迟突增", metrics1);
// 实验结束,恢复正常
disableChaos();
Thread.sleep(60 * 1000L); // 等待 1 分钟稳定
// 实验2:LLM 异常注入(持续 3 分钟)
injectException("java.lang.RuntimeException", "Chaos: simulated LLM failure");
Thread.sleep(3 * 60 * 1000L);
ExperimentMetrics metrics2 = collectMetrics("exception_injection");
result.addExperiment("LLM异常注入", metrics2);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("混沌实验被中断", e);
} finally {
disableChaos();
reporter.generateReport(result);
log.info("自动混沌实验完成,报告已生成");
}
}
private void injectLatency(int minMs, int maxMs) {
log.info("注入延迟: {}-{}ms", minMs, maxMs);
restTemplate.postForObject(
"http://localhost:8080/actuator/chaosmonkey/assaults",
Map.of(
"level", 3,
"latencyActive", true,
"latencyRangeStart", minMs,
"latencyRangeEnd", maxMs,
"exceptionsActive", false
),
Void.class
);
}
private void injectException(String exceptionClass, String message) {
log.info("注入异常: {}", exceptionClass);
// 通过 Chaos Monkey API 注入异常
}
private void disableChaos() {
log.info("关闭混沌实验");
restTemplate.postForObject(
"http://localhost:8080/actuator/chaosmonkey/enable",
Map.of("enableChaosMonkey", false),
Void.class
);
}
private ExperimentMetrics collectMetrics(String experimentName) {
// 从 Prometheus 拉取实验期间的关键指标
return new ExperimentMetrics(experimentName);
}
record ExperimentMetrics(String name) {}
record ExperimentResult() {
void addExperiment(String name, ExperimentMetrics metrics) {
// 记录实验结果
}
}
}混沌实验的告警规则
# prometheus-rules-chaos.yml
groups:
- name: chaos_experiment_safety
rules:
# 混沌实验期间,错误率过高时自动告警(防止实验失控)
- alert: ChaosExperimentErrorRateTooHigh
expr: |
rate(http_server_requests_seconds_count{status=~"5.."}[1m]) /
rate(http_server_requests_seconds_count[1m]) > 0.5
for: 2m
labels:
severity: critical
chaos: "true"
annotations:
summary: "混沌实验期间错误率超过50%,立即停止实验!"
description: "当前错误率: {{ $value | humanizePercentage }}"
runbook: "立即执行: curl -X POST http://localhost:8080/actuator/chaosmonkey/enable -d '{\"enableChaosMonkey\": false}'"
# 混沌实验期间线程池耗尽告警
- alert: ThreadPoolExhaustionDuringChaos
expr: |
hikaricp_connections_active / hikaricp_connections_max > 0.95
for: 1m
labels:
severity: critical
annotations:
summary: "数据库连接池几乎耗尽,检查是否有线程泄漏"混沌实验检查清单
在运行每次混沌实验前,确认以下事项:
## 混沌实验前置检查清单
### 基础确认
- [ ] 当前时间在允许的实验窗口内(非高峰期)
- [ ] 已通知相关团队(运维、产品)
- [ ] 有专人值守,随时可以停止实验
- [ ] 监控看板已打开,关键指标可见
- [ ] 回滚方案已准备好
### 稳态基线确认
- [ ] 过去 30 分钟错误率 < 0.5%
- [ ] P99 响应时间 < 10 秒
- [ ] 所有熔断器处于 CLOSED 状态
### 实验配置确认
- [ ] 故障注入范围限制在目标服务(不影响所有服务)
- [ ] 设置了实验持续时间上限(最长 10 分钟)
- [ ] 自动停止条件已配置(错误率 > 20% 时自动关闭)
### 实验结束后
- [ ] 混沌实验已关闭(chaos.enabled = false)
- [ ] 系统已恢复到稳态
- [ ] 实验报告已记录
- [ ] 发现的问题已创建 ticket