Spring AI错误处理:重试·降级·熔断生产最佳实践
2026/4/30大约 7 分钟
Spring AI错误处理:重试·降级·熔断生产最佳实践
适读人群:有1-5年Java开发经验,想向AI工程师方向转型的开发者 阅读时长:约16分钟 文章价值:① 彻底搞清楚AI调用的错误类型和应对策略 ② 掌握Spring AI + Resilience4j的重试/熔断配置 ③ 建立一套有"兜底"意识的AI服务生产规范
那是一个周五下午四点半,小刘给我发来一句话:"老张,我们AI客服挂了,用户在投诉,我不知道怎么办。"
我第一反应:OpenAI限流了?还是服务挂了?
打开他的代码一看,整个AI调用就一个try-catch,catch里只有一行log.error,然后直接把异常往上抛。上层Controller接到异常,直接返回500。
"你这哪是容错,这是裸奔。"我说。
AI服务有一个特殊性,普通数据库挂了是小概率事件,但LLM调用失败是高概率事件——限流、超时、内容过滤、服务瞬断,随时可能发生。你不给它建一套完整的容错体系,上线就等着被告警淹没吧。
这篇文章,我把在生产环境踩过的坑全整理出来了。
AI调用的错误分类
先把错误类型搞清楚,不同错误应对方式完全不同:
| 错误类型 | HTTP状态码 | 重试? | 策略 |
|---|---|---|---|
| 限流 | 429 | 是(延迟后) | 指数退避重试 |
| 服务暂时不可用 | 502/503 | 是 | 快速重试2次 |
| 超时 | 读超时 | 是(1次) | 重试一次 |
| 参数错误 | 400 | 否 | 立即失败,检查代码 |
| 认证错误 | 401/403 | 否 | 立即失败,检查密钥 |
| 内容违规 | 400 | 否 | 降级或提示用户 |
| 熔断打开 | 本地 | 否 | 快速失败,走fallback |
整体容错架构
代码实现
第一步:依赖配置
<dependencies>
<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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Spring Cache,用于结果缓存 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies># application.yml
resilience4j:
retry:
instances:
llm-retry:
max-attempts: 3
wait-duration: 1s
# 指数退避:1s -> 2s -> 4s
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
# 只对这些异常重试
retry-exceptions:
- org.springframework.web.client.ResourceAccessException
- java.net.SocketTimeoutException
# 这些异常不重试
ignore-exceptions:
- com.laozhang.ai.exception.ContentFilterException
- com.laozhang.ai.exception.InvalidPromptException
circuitbreaker:
instances:
llm-circuit-breaker:
# 滑动窗口:最近10次调用
sliding-window-size: 10
# 失败率超过50%触发熔断
failure-rate-threshold: 50
# 慢调用(超过10s)也算失败
slow-call-duration-threshold: 10s
slow-call-rate-threshold: 80
# 熔断打开后,等待30s进入半开状态
wait-duration-in-open-state: 30s
# 半开状态允许3个请求通过
permitted-number-of-calls-in-half-open-state: 3
timelimiter:
instances:
llm-timeout:
timeout-duration: 30s # 最长等30秒
cancel-running-future: true第二步:核心容错AI服务
@Service
@RequiredArgsConstructor
@Slf4j
public class ResilientAiService {
private final ChatClient primaryChatClient; // 主模型(OpenAI)
private final ChatClient fallbackChatClient; // 备用模型(通义千问)
private final StringRedisTemplate redisTemplate;
private static final String CACHE_PREFIX = "ai:response:";
private static final Duration CACHE_TTL = Duration.ofHours(1);
/**
* 带完整容错的AI调用
* 顺序:缓存 -> 重试 -> 熔断 -> 降级
*/
@Retry(name = "llm-retry", fallbackMethod = "fallbackToBackupModel")
@CircuitBreaker(name = "llm-circuit-breaker", fallbackMethod = "circuitBreakerFallback")
@TimeLimiter(name = "llm-timeout")
public CompletableFuture<String> chat(String prompt, String cacheKey) {
return CompletableFuture.supplyAsync(() -> {
// 1. 先查缓存
String cached = getCached(cacheKey);
if (cached != null) {
log.debug("命中缓存,cacheKey={}", cacheKey);
return cached;
}
// 2. 调用主模型
log.info("调用主模型,prompt长度={}", prompt.length());
String result = callPrimaryModel(prompt);
// 3. 写缓存
cache(cacheKey, result);
return result;
});
}
private String callPrimaryModel(String prompt) {
try {
return primaryChatClient.prompt()
.user(prompt)
.call()
.content();
} catch (Exception e) {
// 翻译异常:让Resilience4j能正确识别是否应该重试
throw translateException(e);
}
}
/**
* Retry fallback:重试耗尽后,切换到备用模型
*/
public CompletableFuture<String> fallbackToBackupModel(
String prompt, String cacheKey, Throwable t) {
log.warn("主模型重试耗尽,切换备用模型,原因={}", t.getMessage());
return CompletableFuture.supplyAsync(() -> {
try {
String result = fallbackChatClient.prompt()
.user(prompt)
.call()
.content();
// 备用模型结果也缓存,但TTL短一些
redisTemplate.opsForValue().set(
CACHE_PREFIX + cacheKey,
result,
Duration.ofMinutes(10)
);
return result;
} catch (Exception e) {
log.error("备用模型也失败了", e);
throw new RuntimeException("备用模型失败", e);
}
});
}
/**
* CircuitBreaker fallback:熔断触发,直接走兜底
*/
public CompletableFuture<String> circuitBreakerFallback(
String prompt, String cacheKey, Throwable t) {
log.warn("熔断器触发,走兜底策略,原因={}", t.getMessage());
return CompletableFuture.supplyAsync(() -> {
// 1. 先看有没有缓存(哪怕过期的也好)
String staleCache = getStaleCache(cacheKey);
if (staleCache != null) {
log.info("熔断期间返回过期缓存");
return "[缓存数据] " + staleCache;
}
// 2. 返回默认答案
return getDefaultAnswer(prompt);
});
}
/**
* 异常翻译:把HTTP状态码翻译成业务异常
*/
private RuntimeException translateException(Exception e) {
String message = e.getMessage();
if (message == null) return new RuntimeException(e);
if (message.contains("429") || message.contains("rate limit")) {
return new RateLimitException("LLM限流: " + message);
}
if (message.contains("400") && message.contains("content_filter")) {
return new ContentFilterException("内容过滤: " + message);
}
if (message.contains("401") || message.contains("403")) {
return new AuthenticationException("认证失败: " + message);
}
if (e instanceof java.net.SocketTimeoutException) {
return new LlmTimeoutException("调用超时");
}
return new LlmUnavailableException("LLM不可用: " + message);
}
private String getCached(String key) {
return redisTemplate.opsForValue().get(CACHE_PREFIX + key);
}
private String getStaleCache(String key) {
// 使用单独的Redis key存储"宽松缓存"(TTL更长)
return redisTemplate.opsForValue().get(CACHE_PREFIX + "stale:" + key);
}
private void cache(String key, String value) {
redisTemplate.opsForValue().set(CACHE_PREFIX + key, value, CACHE_TTL);
// 同时写一份宽松缓存(48小时),熔断期间使用
redisTemplate.opsForValue().set(
CACHE_PREFIX + "stale:" + key, value, Duration.ofHours(48));
}
private String getDefaultAnswer(String prompt) {
// 根据问题类型返回不同的默认答案
if (prompt.contains("客服") || prompt.contains("帮助")) {
return "非常抱歉,AI服务暂时不可用。请拨打客服热线 400-xxx-xxxx 或发送邮件至 support@example.com,我们会尽快为您处理。";
}
return "抱歉,AI服务暂时繁忙,请稍后再试。";
}
}第三步:限流保护(防止被打垮)
@Configuration
public class RateLimiterConfig {
@Bean
public RateLimiterRegistry rateLimiterRegistry() {
RateLimiterConfig config = RateLimiterConfig.custom()
.limitForPeriod(100) // 每秒最多100次LLM调用
.limitRefreshPeriod(Duration.ofSeconds(1))
.timeoutDuration(Duration.ofMillis(500)) // 等待超过500ms直接失败
.build();
return RateLimiterRegistry.of(Map.of("llm-rate-limiter", config));
}
}
// 在Service上使用
@RateLimiter(name = "llm-rate-limiter", fallbackMethod = "rateLimitFallback")
public String chat(String prompt) {
// ...
}
public String rateLimitFallback(String prompt, RequestNotPermitted e) {
log.warn("触发本地限流,请求被丢弃");
return "当前请求量较大,请稍后重试。";
}第四步:统一告警监控
@Component
@RequiredArgsConstructor
@Slf4j
public class AiResilienceMonitor {
private final CircuitBreakerRegistry circuitBreakerRegistry;
private final MeterRegistry meterRegistry;
@PostConstruct
public void registerEventListeners() {
CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("llm-circuit-breaker");
// 监听熔断状态变化
cb.getEventPublisher()
.onStateTransition(event -> {
log.warn("熔断状态变化:{} -> {}",
event.getStateTransition().getFromState(),
event.getStateTransition().getToState());
// 触发告警(可接钉钉/企微/PagerDuty)
if (event.getStateTransition().getToState() == CircuitBreaker.State.OPEN) {
sendAlert("LLM熔断器打开!主模型不可用,已切换降级模式。");
}
// 上报指标
meterRegistry.counter("ai.circuit_breaker.state_change",
"from", event.getStateTransition().getFromState().name(),
"to", event.getStateTransition().getToState().name()
).increment();
});
cb.getEventPublisher()
.onCallNotPermitted(event ->
meterRegistry.counter("ai.circuit_breaker.rejected").increment());
}
private void sendAlert(String message) {
// 实际项目中接告警平台
log.error("【AI告警】{}", message);
}
}一张图总结容错优先级
小刘按这套方案改完之后,告警从每小时几十条降到几乎没有。用户感知到的只是偶尔"稍后重试",而不是500错误。
这才是生产级AI服务该有的样子。
