Spring AI 的异常处理体系——别让 AI 调用把你的系统搞崩
Spring AI 的异常处理体系——别让 AI 调用把你的系统搞崩
去年 11 月底,我们刚上线了一个 AI 写作助手功能,是给内部员工用的。上线第三天,运维给我发了一条消息:「你们的服务 500 率飙到 30% 了,要不要看一下?」
30%。三成的请求都在报错。
我打开日志,全是这个:
org.springframework.ai.retry.NonRetryableAiException:
[400] {
"error": {
"message": "Your request was rejected as a result of our safety system.",
"type": "invalid_request_error",
"code": "content_policy_violation"
}
}内容安全拦截。
我当时第一反应是:用户在提交什么奇怪的内容?
结果查了一下,是一批员工在用 AI 帮他们写年终总结,有人的年终总结模板里包含了一些绩效考核相关的措辞,触发了 OpenAI 的内容安全策略。这些请求本来是完全正常的业务请求,但模型侧认为有问题,全部返回了 400 错误。
我们的代码没有处理这个异常,直接穿透到全局异常处理器,返回了 500。
这就是 AI 调用的典型异常场景:你永远不知道模型侧会在什么时候返回什么错误。
AI 调用的异常分类
在写处理代码之前,先把异常分类搞清楚,不同类型的异常处理策略完全不同:
类型一:限流异常(Rate Limit)
模型 API 有调用频率限制,超了就 429。这是可重试的,等一段时间再试就好。
特征:HTTP 429,错误信息含 "rate_limit" 或 "quota"。
类型二:超时异常(Timeout)
网络超时或模型响应超时。可重试,但要加最大重试次数限制。
特征:SocketTimeoutException、TimeoutException。
类型三:内容安全拦截(Content Filter)
模型认为请求内容违反了安全策略,拒绝回答。不可重试,重试也没用,需要走降级逻辑(告知用户或请求改写)。
特征:HTTP 400,错误码含 "content_policy_violation" 或 "content_filter"。
类型四:格式错误(Parse Error)
Structured Output 解析失败,或者模型返回了不符合预期格式的内容。可以有限重试(重试可能得到不同格式的输出),或者走降级。
特征:JsonParseException、OutputParseException。
类型五:token 超限(Context Length Exceeded)
请求内容太长,超过模型的 context window。不可重试(换个更短的 prompt 才有用)。
特征:HTTP 400,错误信息含 "context_length_exceeded" 或 "maximum context length"。
类型六:服务不可用(Service Unavailable)
模型 API 故障、维护中。可重试,但要加熔断。
特征:HTTP 500、502、503。
异常分类和处理策略的决策树
Spring AI 的异常体系
先了解 Spring AI 自己定义了哪些异常:
// 顶层异常
AiException (RuntimeException)
├── AiClientException // 客户端错误(4xx)
│ ├── NonRetryableAiException // 不可重试的
│ └── RetryableAiException // 可重试的
└── AiServerException // 服务器错误(5xx),可重试Spring AI 内置了重试机制(基于 Spring Retry),默认配置:
spring:
ai:
retry:
max-attempts: 10 # 最大重试次数
on-client-errors: false # 4xx 默认不重试(因为 NonRetryableAiException)
exclude-on-http-codes: 400,401,403,404 # 这些状态码不重试
on-http-codes: 429,500,503 # 这些状态码重试但默认配置太简单,不够应对复杂的生产场景。我们需要更细粒度的控制。
统一的 AI 异常处理器
核心思路:把 Spring AI 的异常统一拦截,根据类型分配处理策略:
@Component
public class AIExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(AIExceptionHandler.class);
/**
* 解析异常类型
*/
public AIErrorType classify(Exception e) {
if (e instanceof NonRetryableAiException nre) {
String message = nre.getMessage();
if (message != null) {
if (message.contains("content_policy_violation")
|| message.contains("content_filter")
|| message.contains("safety system")) {
return AIErrorType.CONTENT_FILTER;
}
if (message.contains("context_length_exceeded")
|| message.contains("maximum context length")
|| message.contains("too long")) {
return AIErrorType.CONTEXT_TOO_LONG;
}
if (message.contains("Unauthorized") || message.contains("401")) {
return AIErrorType.AUTH_ERROR;
}
}
return AIErrorType.CLIENT_ERROR;
}
if (e instanceof RetryableAiException rae) {
String message = rae.getMessage();
if (message != null && (message.contains("429")
|| message.contains("rate_limit")
|| message.contains("quota"))) {
return AIErrorType.RATE_LIMIT;
}
return AIErrorType.SERVER_ERROR;
}
if (e instanceof java.net.SocketTimeoutException
|| e instanceof java.util.concurrent.TimeoutException) {
return AIErrorType.TIMEOUT;
}
if (e instanceof com.fasterxml.jackson.core.JsonProcessingException) {
return AIErrorType.PARSE_ERROR;
}
return AIErrorType.UNKNOWN;
}
}
public enum AIErrorType {
RATE_LIMIT, // 限流
TIMEOUT, // 超时
CONTENT_FILTER, // 内容安全拦截
CONTEXT_TOO_LONG, // token 超限
PARSE_ERROR, // 格式解析失败
AUTH_ERROR, // 认证失败
SERVER_ERROR, // 服务器错误
CLIENT_ERROR, // 客户端错误
UNKNOWN // 未知
}降级策略的实现
@Service
public class AIFallbackService {
/**
* 根据错误类型返回合适的降级响应
*/
public AIResponse fallback(AIErrorType errorType, String originalRequest) {
return switch (errorType) {
case CONTENT_FILTER -> AIResponse.error(
"content_filter",
"您的请求包含了一些敏感内容,AI 助手无法回答。请修改请求内容后重试。",
false // 不建议重试
);
case CONTEXT_TOO_LONG -> AIResponse.error(
"context_too_long",
"您的输入内容太长,请精简后重试。建议将长文本分段处理。",
false
);
case RATE_LIMIT -> AIResponse.error(
"rate_limit",
"AI 服务当前请求量较大,请稍后重试(约 30 秒后)。",
true // 建议重试
);
case TIMEOUT -> AIResponse.error(
"timeout",
"AI 服务响应超时,请稍后重试。",
true
);
case SERVER_ERROR -> AIResponse.error(
"server_error",
"AI 服务暂时不可用,请稍后重试。",
true
);
default -> AIResponse.error(
"unknown",
"AI 服务出现异常,请稍后重试。如问题持续请联系技术支持。",
false
);
};
}
}
@Data
@AllArgsConstructor
public class AIResponse {
private boolean success;
private String content;
private String errorCode;
private String errorMessage;
private boolean retryable;
public static AIResponse ok(String content) {
AIResponse r = new AIResponse();
r.setSuccess(true);
r.setContent(content);
return r;
}
public static AIResponse error(String code, String message, boolean retryable) {
AIResponse r = new AIResponse();
r.setSuccess(false);
r.setErrorCode(code);
r.setErrorMessage(message);
r.setRetryable(retryable);
return r;
}
}重试 + 熔断器的完整实现
针对可重试的错误,加上退避重试。针对服务整体不可用,加熔断器:
@Service
public class ResilientChatService {
private static final Logger log = LoggerFactory.getLogger(ResilientChatService.class);
private final ChatClient chatClient;
private final AIExceptionHandler exceptionHandler;
private final AIFallbackService fallbackService;
// 用 Resilience4j 做熔断器
private final CircuitBreaker circuitBreaker;
public ResilientChatService(ChatClient chatClient,
AIExceptionHandler exceptionHandler,
AIFallbackService fallbackService,
CircuitBreakerRegistry circuitBreakerRegistry) {
this.chatClient = chatClient;
this.exceptionHandler = exceptionHandler;
this.fallbackService = fallbackService;
this.circuitBreaker = circuitBreakerRegistry.circuitBreaker("ai-chat",
CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 50% 失败率触发熔断
.waitDurationInOpenState(Duration.ofSeconds(30)) // 熔断 30 秒
.slidingWindowSize(20) // 统计窗口 20 次请求
.permittedNumberOfCallsInHalfOpenState(3)
.build()
);
}
/**
* 带完整异常处理的 AI 调用
*/
public AIResponse chat(String message) {
// 熔断器包装
Supplier<AIResponse> decorated = CircuitBreaker.decorateSupplier(
circuitBreaker,
() -> doChat(message)
);
try {
return decorated.get();
} catch (CallNotPermittedException e) {
// 熔断器打开,直接降级
log.warn("熔断器打开,AI 服务调用被熔断");
return fallbackService.fallback(AIErrorType.SERVER_ERROR, message);
}
}
private AIResponse doChat(String message) {
int maxRetries = 3;
long baseDelayMs = 1000;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
try {
String content = chatClient.prompt()
.user(message)
.call()
.content();
return AIResponse.ok(content);
} catch (Exception e) {
AIErrorType errorType = exceptionHandler.classify(e);
log.warn("AI 调用失败 (attempt {}/{}): type={}, message={}",
attempt, maxRetries, errorType, e.getMessage());
// 不可重试的错误,直接降级
if (!isRetryable(errorType)) {
return fallbackService.fallback(errorType, message);
}
// 最后一次重试也失败了
if (attempt == maxRetries) {
log.error("AI 调用达到最大重试次数,触发降级", e);
return fallbackService.fallback(errorType, message);
}
// 指数退避等待
long delayMs = baseDelayMs * (long) Math.pow(2, attempt - 1);
// 限流错误,额外等待更长时间
if (errorType == AIErrorType.RATE_LIMIT) {
delayMs = Math.max(delayMs, 5000); // 至少等 5 秒
}
log.info("等待 {}ms 后重试...", delayMs);
try {
Thread.sleep(delayMs);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return fallbackService.fallback(AIErrorType.UNKNOWN, message);
}
}
}
// 不应该到这里
return fallbackService.fallback(AIErrorType.UNKNOWN, message);
}
private boolean isRetryable(AIErrorType errorType) {
return switch (errorType) {
case RATE_LIMIT, TIMEOUT, SERVER_ERROR -> true;
case CONTENT_FILTER, CONTEXT_TOO_LONG, AUTH_ERROR, CLIENT_ERROR -> false;
case PARSE_ERROR -> false; // 格式错误也不重试,需要改 prompt
default -> false;
};
}
}内容安全拦截的专项处理
回到开头那个事故,内容安全拦截是最让人头疼的,因为:
- 用户的请求是完全合理的业务请求
- 但模型侧基于自己的安全策略拒绝了
- 你不能重试,因为重试结果一样
我们后来的处理方案:
@Service
public class ContentFilterHandler {
private final ChatClient chatClient;
/**
* 当主请求被内容安全拦截时,尝试改写请求后重试
* 用一个"内容改写"的小模型,把敏感措辞替换掉
*/
public AIResponse handleWithRewrite(String originalMessage) {
try {
// 先尝试原始请求
return AIResponse.ok(chatClient.prompt()
.user(originalMessage)
.call()
.content());
} catch (NonRetryableAiException e) {
if (!isContentFilterError(e)) {
throw e; // 不是内容安全问题,继续往上抛
}
log.warn("原始请求被内容安全拦截,尝试改写: {}",
originalMessage.substring(0, Math.min(100, originalMessage.length())));
// 尝试用 prompt 改写规避内容安全
try {
String safeMessage = rewriteForSafety(originalMessage);
String content = chatClient.prompt()
.user(safeMessage)
.call()
.content();
return AIResponse.ok(content);
} catch (Exception retryEx) {
// 改写后还是被拦截,告知用户
return AIResponse.error(
"content_filter",
"您的请求包含了敏感内容,无法处理。请尝试换一种表达方式。",
false
);
}
}
}
private String rewriteForSafety(String originalMessage) {
// 用另一个模型或规则把可能触发内容安全的措辞改写成中性表达
return chatClient.prompt()
.system("""
你是一个文本改写助手,帮助将可能触发内容安全策略的措辞改写成中性、专业的表达。
保持原意不变,只修改措辞。只输出改写后的内容,不要解释。
""")
.user("请改写以下内容:\n" + originalMessage)
.call()
.content();
}
private boolean isContentFilterError(NonRetryableAiException e) {
String msg = e.getMessage();
return msg != null && (
msg.contains("content_policy_violation") ||
msg.contains("content_filter") ||
msg.contains("safety system")
);
}
}全局异常处理器
把上面这些整合到 Spring MVC 的全局异常处理器里:
@RestControllerAdvice
public class GlobalAIExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalAIExceptionHandler.class);
private final AIExceptionHandler exceptionHandler;
private final AIFallbackService fallbackService;
@ExceptionHandler(NonRetryableAiException.class)
public ResponseEntity<AIResponse> handleNonRetryable(NonRetryableAiException e) {
AIErrorType type = exceptionHandler.classify(e);
log.warn("不可重试 AI 异常: type={}", type, e);
AIResponse response = fallbackService.fallback(type, "");
// 内容安全拦截返回 400,让前端知道是用户输入问题
if (type == AIErrorType.CONTENT_FILTER || type == AIErrorType.CONTEXT_TOO_LONG) {
return ResponseEntity.badRequest().body(response);
}
return ResponseEntity.internalServerError().body(response);
}
@ExceptionHandler(RetryableAiException.class)
public ResponseEntity<AIResponse> handleRetryable(RetryableAiException e) {
AIErrorType type = exceptionHandler.classify(e);
log.error("可重试 AI 异常(已重试耗尽): type={}", type, e);
AIResponse response = fallbackService.fallback(type, "");
return ResponseEntity.status(503).body(response); // Service Unavailable
}
@ExceptionHandler(AiException.class)
public ResponseEntity<AIResponse> handleGeneral(AiException e) {
log.error("通用 AI 异常", e);
AIResponse response = AIResponse.error("ai_error", "AI 服务异常,请稍后重试", true);
return ResponseEntity.internalServerError().body(response);
}
}监控报警
这些异常处理配置好了之后,还需要监控:
@Aspect
@Component
public class AICallMonitoringAspect {
private final MeterRegistry meterRegistry;
@Around("execution(* com.example.service.*ChatService.*(..))")
public Object monitor(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
String methodName = pjp.getSignature().getName();
try {
Object result = pjp.proceed();
// 记录成功调用
Counter.builder("ai.calls")
.tag("method", methodName)
.tag("status", "success")
.register(meterRegistry)
.increment();
return result;
} catch (Exception e) {
// 记录失败调用,带异常类型标签
String errorType = e.getClass().getSimpleName();
Counter.builder("ai.calls")
.tag("method", methodName)
.tag("status", "error")
.tag("error_type", errorType)
.register(meterRegistry)
.increment();
throw e;
} finally {
long latency = System.currentTimeMillis() - start;
Timer.builder("ai.call.latency")
.tag("method", methodName)
.register(meterRegistry)
.record(latency, TimeUnit.MILLISECONDS);
}
}
}报警规则建议:
- 内容安全拦截率 > 5%:触发告警,可能有用户在批量测试边界
- 限流率 > 10%:触发告警,考虑增加 API 配额或加更强的限流
- 超时率 > 2%:触发告警,检查网络或模型响应时间
- 服务不可用(5xx)率 > 1%:触发告警,检查模型 API 状态
事后复盘:那次 30% 500 率事故
回到开头的那次事故,复盘一下如果当时有这套处理机制会怎样:
- 内容安全拦截 →
NonRetryableAiException→ 被识别为CONTENT_FILTER类型 → 返回 400 + 友好提示,而不是 500 - 前端收到 400 和提示信息 → 展示给用户「请修改内容后重试」
- 监控里
content_filter指标飙升 → 触发告警 - 我们排查发现是年终总结场景的特定措辞问题 → 可以针对性优化
整个过程用户体验有损(收到了错误提示),但不会是 500,系统整体没问题,我也能快速定位根因。
AI 调用的异常处理,核心原则只有一个:永远不要让模型侧的问题变成你系统的 500。
