AI 应用的 Circuit Breaker——熔断器在 AI 场景的正确配置
AI 应用的 Circuit Breaker——熔断器在 AI 场景的正确配置
"我们的 AI 服务挂了,但整个应用还在跑,用户根本不知道。"
这是我一个朋友在聊他们系统时说的一句话。他们的情况是:AI 推荐功能的上游 API 响应时间从正常的 2 秒涨到了 60 秒,导致所有依赖这个功能的请求线程全部阻塞。线程池耗尽之后,整个 API 服务开始超时。最终是一个看起来跟 AI 完全不相关的接口——用户登录——也开始失败了。
这就是经典的级联故障,熔断器的价值正是为了应对这种场景:在依赖服务出现问题时,快速失败,防止故障蔓延。
但 AI 场景下,熔断器的配置远比"加个 Resilience4j 注解"复杂得多。这篇文章我来把这些细节讲清楚。
熔断器的基本原理
先简单回顾一下熔断器的状态机,后面的 AI 特化配置都建立在这个基础上:
Resilience4j 的熔断器有两个触发条件:
- 失败率(failure rate):在滑动窗口内,失败请求的占比超过阈值
- 慢调用率(slow call rate):在滑动窗口内,超过阈值响应时间的请求占比超过阈值
这两个维度在 AI 场景下都需要特别调整。
AI 特有的熔断条件:这些算不算失败?
这是 AI 熔断配置里最复杂的问题,也是最容易配错的地方。
问题一:内容安全拒绝算不算失败?
LLM 提供商有内容安全过滤机制,当用户输入触发了安全策略,API 会返回一个特殊的错误响应(比如 Claude 返回 {"error": {"type": "invalid_request_error", "message": "..."}} 并带有安全拒绝的原因)。
这不是 AI 服务不可用,是正常的业务拒绝。
如果把这个当做失败来统计,当某类用户输入批量触发安全过滤时,熔断器会误触,把健康的 AI 服务给熔断掉——而实际上 AI 服务完全正常。
结论:内容安全拒绝不应该计入熔断的失败统计。
问题二:限流(429)算不算失败?
429 是暂时性的流量控制,不代表服务不健康,只代表你调太快了。
结论:429 不应该计入熔断失败统计,但可以触发专门的限流熔断器(与服务健康熔断器分开)。
问题三:响应超时的界定
AI 的响应时间跟普通 API 完全不同:
- 正常响应:2-30 秒(取决于 prompt 长度和生成长度)
- 异常慢响应:60 秒以上
- 超时:可能需要设置到 120 秒才算真的超时
如果把慢调用阈值设成 5 秒(普通 API 的标准),AI 服务几乎所有请求都会被标记为"慢调用",熔断器永远是触发状态。
结论:AI 场景的慢调用阈值需要根据实际 P95 响应时间来设置,通常是 30-60 秒。
问题四:Token 超限错误
用户传入了超过模型上下文窗口的内容,API 返回了 400 错误。这是客户端错误,不是 AI 服务的问题。
结论:Token 超限等 400 客户端错误不应该计入熔断失败。
与限流的区别和协同
熔断和限流是两个不同维度的保护机制,经常被混淆:
| 维度 | 熔断器 | 限流器 |
|---|---|---|
| 保护对象 | 防止调用失败的下游把上游拖垮 | 防止上游流量把下游压垮 |
| 触发条件 | 下游服务失败率/响应时间 | 上游请求速率超过阈值 |
| 触发后行为 | 快速失败,不调用下游 | 排队等待或立即拒绝 |
| AI 场景重点 | 检测 LLM API 是否健康 | 控制对 LLM API 的调用速率 |
在 AI 应用里,这两个机制应该协同工作:
代码:Spring AI + Resilience4j 的熔断配置
Maven 依赖
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-micrometer</artifactId>
<version>2.2.0</version>
</dependency>application.yml 配置
resilience4j:
circuitbreaker:
instances:
# LLM 健康熔断器(针对 500/503 等服务错误)
llm-health:
sliding-window-type: COUNT_BASED
sliding-window-size: 20 # 统计最近20次调用
minimum-number-of-calls: 10 # 至少10次调用才开始统计
failure-rate-threshold: 50 # 失败率超过50%触发熔断
slow-call-rate-threshold: 60 # 慢调用率超过60%触发熔断
slow-call-duration-threshold: 45s # 超过45s算慢调用(AI场景专用)
wait-duration-in-open-state: 60s # 熔断后等待60s再进入半开
permitted-number-of-calls-in-half-open-state: 3 # 半开时允许3个探测请求
automatic-transition-from-open-to-half-open-enabled: true
# LLM 限流熔断器(专门针对429)
llm-rate-limit:
sliding-window-type: TIME_BASED
sliding-window-size: 60 # 60秒的时间窗口
minimum-number-of-calls: 5
failure-rate-threshold: 80 # 429占比超80%触发(说明严重超速)
wait-duration-in-open-state: 120s # 等待2分钟(限速窗口通常是每分钟)
ratelimiter:
instances:
# 控制对 LLM API 的调用速率,避免主动触发限流
llm-calls:
limit-for-period: 50 # 每个刷新周期最多50次
limit-refresh-period: 1m # 1分钟刷新
timeout-duration: 5s # 等待令牌超时时间自定义异常判断逻辑
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
import java.util.function.Predicate;
@Configuration
public class AICircuitBreakerConfig {
@Bean
public CircuitBreakerRegistry circuitBreakerRegistry() {
// 自定义哪些异常算失败,哪些算忽略
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
.slidingWindowSize(20)
.minimumNumberOfCalls(10)
.failureRateThreshold(50)
.slowCallRateThreshold(60)
.slowCallDurationThreshold(Duration.ofSeconds(45))
.waitDurationInOpenState(Duration.ofSeconds(60))
.permittedNumberOfCallsInHalfOpenState(3)
// 这些异常记录为失败(触发熔断统计)
.recordExceptions(
java.io.IOException.class,
java.net.SocketTimeoutException.class
)
// 自定义失败判断:HTTP 500/503 才算失败
.recordException(throwable -> {
if (throwable instanceof org.springframework.web.client.HttpServerErrorException e) {
int code = e.getStatusCode().value();
// 只有服务端错误才算熔断失败
return code == 500 || code == 502 || code == 503 || code == 504;
}
if (throwable instanceof org.springframework.web.client.HttpClientErrorException e) {
int code = e.getStatusCode().value();
// 429 不计入健康熔断失败(单独处理)
// 400/401/403 是客户端问题,不代表服务不健康
return false;
}
return false;
})
// 这些异常忽略(既不记录为失败,也不记录为成功)
.ignoreExceptions(
AIContentSafetyException.class, // 内容安全拒绝
AITokenLimitException.class, // Token 超限
AIRateLimitException.class // 限流(单独处理)
)
.build();
return CircuitBreakerRegistry.of(config);
}
// 自定义 AI 异常类
public static class AIContentSafetyException extends RuntimeException {
public AIContentSafetyException(String message) { super(message); }
}
public static class AITokenLimitException extends RuntimeException {
public AITokenLimitException(String message) { super(message); }
}
public static class AIRateLimitException extends RuntimeException {
private final long retryAfterMs;
public AIRateLimitException(String message, long retryAfterMs) {
super(message);
this.retryAfterMs = retryAfterMs;
}
public long getRetryAfterMs() { return retryAfterMs; }
}
}服务层集成
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.function.Supplier;
@Service
public class ResilientAIService {
private static final Logger log = LoggerFactory.getLogger(ResilientAIService.class);
@Autowired
private ChatModel chatModel;
private final CircuitBreaker healthCircuitBreaker;
private final RateLimiter llmRateLimiter;
public ResilientAIService(CircuitBreakerRegistry cbRegistry,
RateLimiterRegistry rlRegistry) {
this.healthCircuitBreaker = cbRegistry.circuitBreaker("llm-health");
this.llmRateLimiter = rlRegistry.rateLimiter("llm-calls");
// 注册状态变更监听
this.healthCircuitBreaker.getEventPublisher()
.onStateTransition(event -> {
log.warn("AI熔断器状态变更: {} -> {}",
event.getStateTransition().getFromState(),
event.getStateTransition().getToState());
// 触发告警
});
}
/**
* 带熔断和限流的 AI 调用
*/
public String callAI(String prompt) {
// 先限流,再熔断
Supplier<String> aiCallSupplier = CircuitBreaker.decorateSupplier(
healthCircuitBreaker,
() -> doCallAI(prompt)
);
Supplier<String> rateLimitedSupplier = RateLimiter.decorateSupplier(
llmRateLimiter,
aiCallSupplier
);
try {
return rateLimitedSupplier.get();
} catch (RequestNotPermitted e) {
// 限流器拒绝
log.warn("AI调用被限流器拒绝,当前请求过多");
return getFallbackResponse("服务繁忙,请稍后再试");
} catch (io.github.resilience4j.circuitbreaker.CallNotPermittedException e) {
// 熔断器打开,快速失败
log.warn("AI熔断器当前处于打开状态,快速失败");
return getFallbackResponse("AI服务暂时不可用,请稍后重试");
} catch (Exception e) {
log.error("AI调用失败: {}", e.getMessage());
throw e;
}
}
private String doCallAI(String prompt) {
try {
return chatModel.call(prompt);
} catch (Exception e) {
// 将 HTTP 异常翻译成业务异常
String errorMsg = e.getMessage();
if (errorMsg != null) {
if (errorMsg.contains("content_policy_violation") ||
errorMsg.contains("safety")) {
throw new AICircuitBreakerConfig.AIContentSafetyException(
"内容安全策略拒绝: " + errorMsg);
}
if (errorMsg.contains("429") || errorMsg.contains("rate_limit")) {
throw new AICircuitBreakerConfig.AIRateLimitException(
"API限流: " + errorMsg, 60000L);
}
if (errorMsg.contains("context_length_exceeded") ||
errorMsg.contains("token")) {
throw new AICircuitBreakerConfig.AITokenLimitException(
"Token超限: " + errorMsg);
}
}
throw e;
}
}
private String getFallbackResponse(String reason) {
return "抱歉," + reason + "。如有紧急需求,请联系客服。";
}
}熔断状态监控端点
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
@Component
@Endpoint(id = "ai-circuit-breakers")
public class AICircuitBreakerEndpoint {
@Autowired
private CircuitBreakerRegistry circuitBreakerRegistry;
@ReadOperation
public Map<String, Object> getStatus() {
Map<String, Object> result = new HashMap<>();
circuitBreakerRegistry.getAllCircuitBreakers().forEach(cb -> {
Map<String, Object> cbInfo = new HashMap<>();
CircuitBreaker.Metrics metrics = cb.getMetrics();
cbInfo.put("state", cb.getState().name());
cbInfo.put("failureRate", metrics.getFailureRate());
cbInfo.put("slowCallRate", metrics.getSlowCallRate());
cbInfo.put("numberOfSuccessfulCalls", metrics.getNumberOfSuccessfulCalls());
cbInfo.put("numberOfFailedCalls", metrics.getNumberOfFailedCalls());
cbInfo.put("numberOfSlowCalls", metrics.getNumberOfSlowCalls());
result.put(cb.getName(), cbInfo);
});
return result;
}
}熔断器的配置调优建议
实际上线前,你需要回答这几个问题:
1. 滑动窗口大小设多少?
窗口太小,熔断器对瞬时抖动过于敏感,可能误触;窗口太大,检测到故障的延迟太高。
AI 场景建议:COUNT_BASED 窗口大小 10-20 次,TIME_BASED 窗口 30-60 秒。
2. 失败率阈值设多少?
AI API 本身就有一定的偶发失败率(网络抖动等),阈值不能设太低。
建议:50% 作为起点,根据实际监控数据调整。
3. 慢调用阈值设多少?
先测出你的 AI 调用 P95 和 P99 响应时间,把慢调用阈值设在 P99 的 1.5-2 倍。
比如 P99 = 30 秒,慢调用阈值设 45-60 秒。
4. 熔断等待时间设多少?
这取决于 LLM 提供商的故障恢复时间。大型提供商通常几分钟内恢复,设 60-120 秒是合理的。
总结
Resilience4j 熔断器在 AI 场景下的核心配置原则:
区分"服务不健康"和"业务拒绝":内容安全拒绝、Token 超限、429 限流都不应该计入服务健康熔断的失败统计。
慢调用阈值要适应 AI 响应时间:不能用普通 API 的 5 秒标准,AI 正常响应可能就需要 20-30 秒。
限流和熔断分开:两者保护的不是同一个东西,职责不能混。
降级响应要有实际意义:熔断打开时,不能只返回一个"系统错误",要给用户有意义的提示,并记录好日志供排查。
