Spring AI异常处理与重试机制

Spring AI异常处理与重试机制

前置知识

  • Spring AI基础调用
  • Spring Retry机制
  • 了解LLM API限流与错误码
  • 熔断器模式基础

核心概念

LLM调用可能因网络、限流、错误输入等多种原因失败。Spring AI使用Spring Retry和自定义错误处理机制保证系统健壮性。生产环境需处理: API超时、Rate Limited(429)、认证失败、内容审核拒绝等多种异常类型。

异常类型分类

类型 HTTP状态码 可重试 建议策略
Rate Limited 429 指数退避
服务器错误 500/502/503 固定间隔重试
请求超时 408 增加超时时间后重试
认证失败 401/403 立即告警
内容过滤 400 返回给用户
上下文超长 400 截断上下文后重试

完整实现

1. Spring AI统一异常处理

java 复制代码
/**
 * Spring AI异常基类层次
 * - AiException
 *   - RuntimeException
 *     - 最终用户可见异常
 */
@RestControllerAdvice
@Slf4j
public class AiExceptionHandler {

    @ExceptionHandler(RateLimitExceededException.class)
    public ResponseEntity<ErrorResponse> handleRateLimit(
            RateLimitExceededException e) {
        log.warn("LLM限流: {}", e.getMessage());
        return ResponseEntity.status(429)
                .body(new ErrorResponse(
                    "RATE_LIMITED",
                    "请求频繁,请稍后重试",
                    Map.of("retry_after", e.getRetryAfterSeconds())
                ));
    }

    @ExceptionHandler(AiException.class)
    public ResponseEntity<ErrorResponse> handleAiException(AiException e) {
        log.error("AI调用异常", e);
        return ResponseEntity.status(502)
                .body(new ErrorResponse(
                    "AI_SERVICE_ERROR",
                    "AI服务暂时不可用",
                    Map.of("detail", e.getMessage())
                ));
    }

    @ExceptionHandler(ContentFilterException.class)
    public ResponseEntity<ErrorResponse> handleContentFilter(
            ContentFilterException e) {
        return ResponseEntity.status(400)
                .body(new ErrorResponse(
                    "CONTENT_FILTERED",
                    "输入内容不符合安全规范",
                    Map.of("reason", e.getFilterReason())
                ));
    }

    @ExceptionHandler(ContextTooLongException.class)
    public ResponseEntity<ErrorResponse> handleContextTooLong(
            ContextTooLongException e) {
        return ResponseEntity.status(400)
                .body(new ErrorResponse(
                    "CONTEXT_TOO_LONG",
                    "输入内容过长",
                    Map.of("max_tokens", e.getMaxTokens())
                ));
    }

    @ExceptionHandler(TimeoutException.class)
    public ResponseEntity<ErrorResponse> handleTimeout(TimeoutException e) {
        log.warn("LLM调用超时: {}", e.getMessage());
        return ResponseEntity.status(504)
                .body(new ErrorResponse(
                    "TIMEOUT",
                    "AI响应超时",
                    Map.of("timeout_ms", e.getTimeoutMs())
                ));
    }
}

2. 智能重试配置

java 复制代码
@Configuration
@EnableRetry
public class RetryConfig {

    /**
     * LLM调用重试配置
     */
    @Bean
    public RetryTemplate llmCallRetryTemplate() {
        // 重试策略 - 针对可重试异常
        Map<Class<? extends Throwable>, Boolean> retryable = Map.of(
            ResourceAccessException.class, true,     // 网络异常
            HttpServerErrorException.class, true,    // 服务端错误
            TimeoutException.class, true,           // 超时
            RateLimitException.class, true,         // 限流
            AiException.class, false,               // AI内部异常不重试
            AuthenticationException.class, false    // 认证异常不重试
        );

        SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy(
            3, retryable, true);

        // 退避策略 - 指数退避
        ExponentialBackOffPolicy backOff = new ExponentialBackOffPolicy();
        backOff.setInitialInterval(1000);    // 初始间隔1秒
        backOff.setMultiplier(2);            // 倍数2
        backOff.setMaxInterval(30000);       // 最大间隔30秒

        RetryTemplate template = new RetryTemplate();
        template.setRetryPolicy(retryPolicy);
        template.setBackOffPolicy(backOff);
        
        // 注册监听器
        template.registerListener(new RetryListener() {
            @Override
            public <T> boolean open(RetryContext context, RetryCallback<T> callback) {
                log.info("开始LLM调用");
                return true;
            }

            @Override
            public <T> void close(RetryContext context, RetryCallback<T> callback,
                                   Throwable throwable) {
                if (throwable != null) {
                    log.error("LLM调用最终失败, 重试{}次后放弃", 
                        context.getRetryCount());
                } else if (context.getRetryCount() > 0) {
                    log.info("LLM调用在第{}次重试后成功", 
                        context.getRetryCount());
                }
            }

            @Override
            public <T> void onError(RetryContext context, RetryCallback<T> callback,
                                     Throwable throwable) {
                log.warn("LLM调用失败 (第{}次): {}", 
                    context.getRetryCount(), throwable.getMessage());
            }
        });

        return template;
    }
}

3. 带重试的调用服务

java 复制代码
@Service
@Slf4j
public class ResilientChatService {

    private final ChatClient chatClient;
    private final RetryTemplate retryTemplate;
    private final CircuitBreaker circuitBreaker;

    public ResilientChatService(ChatClient chatClient, 
                                 RetryTemplate retryTemplate,
                                 CircuitBreaker circuitBreaker) {
        this.chatClient = chatClient;
        this.retryTemplate = retryTemplate;
        this.circuitBreaker = circuitBreaker;
    }

    /**
     * 带重试和熔断的调用
     */
    public String chatWithResilience(String message) {
        return circuitBreaker.executeSupplier(() ->
            retryTemplate.execute(context -> {
                if (context.getRetryCount() > 0) {
                    log.info("第{}次重试调用LLM", context.getRetryCount());
                }
                return callLlm(message);
            }, context -> {
                log.error("LLM调用在{}次重试后均失败", context.getRetryCount());
                return "抱歉,AI服务暂时不可用,请稍后重试";
            })
        );
    }

    /**
     * 带分区重试 - 不同异常使用不同重试策略
     */
    public String chatWithPartitionedRetry(String message) {
        try {
            // 第一次尝试 - 快速失败
            return callLlm(message);
        } catch (RateLimitException e) {
            // 限流 - 等待后重试
            return handleRateLimitRetry(message, e);
        } catch (TimeoutException e) {
            // 超时 - 增加超时时间后重试一次
            return handleTimeoutRetry(message);
        } catch (ResourceAccessException e) {
            // 网络异常 - 最多重试2次
            return handleNetworkRetry(message, 2);
        } catch (ContentFilterException e) {
            // 内容审核失败 - 不重试
            throw e;
        } catch (AuthenticationException e) {
            // 认证失败 - 不重试,告警
            alertService.sendAlert("LLM认证失败", e.getMessage());
            throw e;
        }
    }

    private String handleRateLimitRetry(String message, RateLimitException e) {
        long waitSeconds = e.getRetryAfterSeconds();
        if (waitSeconds > 30) {
            throw new BusinessException("限流时间过长: " + waitSeconds + "秒");
        }
        
        log.info("限流等待{}秒后重试", waitSeconds);
        try {
            Thread.sleep(waitSeconds * 1000);
        } catch (InterruptedException ie) {
            Thread.currentThread().interrupt();
        }
        
        return callLlm(message);
    }

    private String handleTimeoutRetry(String message) {
        log.warn("LLM调用超时,增加超时时间后重试");
        return chatClient.prompt()
                .user(message)
                .options(DashScopeChatOptions.builder()
                        .withTimeout(Duration.ofSeconds(120)) // 增加到120秒
                        .build())
                .call()
                .content();
    }

    private String handleNetworkRetry(String message, int maxRetries) {
        for (int i = 0; i <= maxRetries; i++) {
            try {
                return callLlm(message);
            } catch (ResourceAccessException e) {
                if (i == maxRetries) throw e;
                try {
                    Thread.sleep((long) Math.pow(2, i) * 1000);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                }
            }
        }
        throw new RuntimeException("网络重试失败");
    }

    /**
     * 带降级策略的调用
     */
    public String chatWithFallback(String message) {
        try {
            String response = callLlm(message);
            // 清除失败计数
            failureCount.set(0);
            return response;
        } catch (Exception e) {
            int failures = failureCount.incrementAndGet();
            
            if (failures >= 3) {
                // 连续失败, 切换到简单回复模式
                log.warn("连续{}次失败,使用降级响应", failures);
                return generateDegradedResponse(message);
            }
            
            throw e;
        }
    }

    /**
     * 降级响应 - 不调用LLM的简单回复
     */
    private String generateDegradedResponse(String message) {
        // 简单的关键词匹配回复
        if (message.contains("天气")) {
            return "抱歉,天气服务暂时不可用,请稍后查询。";
        }
        if (message.contains("订单")) {
            return "抱歉,订单系统暂时不可用,请稍后联系客服。";
        }
        return "抱歉,服务暂时繁忙,请稍后再试。我们的工程师正在紧急处理。";
    }

    private final AtomicInteger failureCount = new AtomicInteger(0);

    private String callLlm(String message) {
        return chatClient.prompt()
                .user(message)
                .call()
                .content();
    }
}

4. 自定义错误重试回调

java 复制代码
/**
 * 重试回调 - 记录和监控
 */
@Component
public class LlmRetryCallback extends RetryListenerSupport {

    private final MeterRegistry meterRegistry;
    private final NotificationService notificationService;

    public LlmRetryCallback(MeterRegistry meterRegistry, 
                             NotificationService notificationService) {
        this.meterRegistry = meterRegistry;
        this.notificationService = notificationService;
    }

    @Override
    public <T> void onError(RetryContext context, RetryCallback<T> callback,
                             Throwable throwable) {
        int attempt = context.getRetryCount() + 1;
        String exceptionType = throwable.getClass().getSimpleName();
        
        // 记录指标
        meterRegistry.counter("llm.retry")
                .tag("attempt", String.valueOf(attempt))
                .tag("error", exceptionType)
                .increment();
        
        // 记录详细日志
        log.warn("LLM调用第{}次失败: {} - {}", 
                attempt, exceptionType, throwable.getMessage());
        
        // 第3次重试时发送告警
        if (attempt >= 3) {
            notificationService.sendWarning(
                "LLM连续失败",
                String.format("模型调用已连续失败%d次, 最后错误: %s", 
                    attempt, throwable.getMessage())
            );
        }
        
        // 如果是限流, 记录详细限流信息
        if (throwable instanceof HttpServerErrorException httpError) {
            if (httpError.getStatusCode() == HttpStatus.TOO_MANY_REQUESTS) {
                String retryAfter = httpError.getHeaders()
                        .getFirst("Retry-After");
                log.warn("限流响应, Retry-After: {}秒", retryAfter);
                meterRegistry.counter("llm.rate_limited").increment();
            }
        }
    }

    @Override
    public <T> void close(RetryContext context, RetryCallback<T> callback,
                           Throwable throwable) {
        if (context.getRetryCount() > 0) {
            meterRegistry.timer("llm.call.with_retry")
                    .record(context.getRetryCount(), TimeUnit.SECONDS);
        }
    }
}

5. 上下文截断自动重试

java 复制代码
@Service
public class ContextAwareRetryService {

    private final ChatClient chatClient;
    private final TokenCounter tokenCounter;
    private final int maxContextTokens;

    public ContextAwareRetryService(ChatClient chatClient,
                                     TokenCounter tokenCounter,
                                     @Value("${llm.max-context-tokens:4000}") 
                                     int maxContextTokens) {
        this.chatClient = chatClient;
        this.tokenCounter = tokenCounter;
        this.maxContextTokens = maxContextTokens;
    }

    /**
     * 上下文超限后自动截断后重试
     */
    public String chatWithAutoTruncate(String message, List<Message> history) {
        try {
            return chatClient.prompt()
                    .messages(history)
                    .user(message)
                    .call()
                    .content();
        } catch (ContextTooLongException | HttpServerErrorException e) {
            if (isContextTooLongError(e)) {
                log.warn("上下文超长, 自动截断后重试");
                List<Message> truncated = truncateHistory(
                    history, message, 0.5); // 截断50%
                return chatClient.prompt()
                        .messages(truncated)
                        .user(message)
                        .call()
                        .content();
            }
            throw e;
        }
    }

    /**
     * 智能截断 - 保留关键信息
     */
    private List<Message> truncateHistory(List<Message> history, 
                                           String currentMessage,
                                           double keepRatio) {
        int totalTokens = history.stream()
                .mapToInt(m -> tokenCounter.count(m.getContent()))
                .sum() + tokenCounter.count(currentMessage);
        
        int targetTokens = (int) (totalTokens * keepRatio);
        List<Message> result = new ArrayList<>();
        
        // 保留system消息
        history.stream()
                .filter(m -> m.getMessageType() == MessageType.SYSTEM)
                .forEach(result::add);
        
        int currentTokens = result.stream()
                .mapToInt(m -> tokenCounter.count(m.getContent()))
                .sum();
        
        // 从尾部尽可能保留
        for (int i = history.size() - 1; i >= 0; i--) {
            Message msg = history.get(i);
            if (msg.getMessageType() == MessageType.SYSTEM) continue;
            
            int msgTokens = tokenCounter.count(msg.getContent());
            if (currentTokens + msgTokens > targetTokens) {
                break;
            }
            
            result.add(0, msg);
            currentTokens += msgTokens;
        }
        
        return result;
    }

    /**
     * 带压缩的重试
     */
    public String chatWithCompressedRetry(String message, 
                                           List<Message> history) {
        try {
            return chatClient.prompt()
                    .messages(history)
                    .user(message)
                    .call()
                    .content();
        } catch (ContextTooLongException e) {
            // 压缩历史消息
            List<Message> compressed = compressHistory(history);
            return chatClient.prompt()
                    .messages(compressed)
                    .user(message)
                    .call()
                    .content();
        }
    }

    private List<Message> compressHistory(List<Message> history) {
        // 将旧消息压缩为摘要
        if (history.size() <= 4) return history;
        
        List<Message> old = history.subList(0, history.size() - 4);
        Message summary = new SystemMessage("[历史] " + 
            summarizeMessages(old));
        
        List<Message> result = new ArrayList<>(List.of(summary));
        result.addAll(history.subList(history.size() - 4, history.size()));
        return result;
    }

    private boolean isContextTooLongError(Exception e) {
        if (e instanceof ContextTooLongException) return true;
        if (e instanceof HttpServerErrorException httpError) {
            return httpError.getMessage() != null && 
                   (httpError.getMessage().contains("context_length_exceeded") ||
                    httpError.getMessage().contains("maximum context length"));
        }
        return false;
    }
}

6. 流式调用的异常处理

java 复制代码
@Service
public class StreamingExceptionService {

    private final ChatClient chatClient;
    private final MeterRegistry meterRegistry;

    /**
     * 流式调用异常处理
     */
    public Flux<ServerSentEvent<String>> streamWithExceptionHandling(
            String message) {
        
        AtomicReference<StringBuilder> buffer = 
            new AtomicReference<>(new StringBuilder());
        AtomicInteger retryCount = new AtomicInteger(0);

        return chatClient.prompt()
                .user(message)
                .stream()
                .content()
                .map(chunk -> {
                    buffer.get().append(chunk);
                    return ServerSentEvent.<String>builder()
                            .data(chunk)
                            .build();
                })
                .timeout(Duration.ofSeconds(30))
                .onErrorResume(TimeoutException.class, e -> {
                    log.warn("流式响应超时,已接收{}字符", 
                        buffer.get().length());
                    return Flux.just(ServerSentEvent.<String>builder()
                            .event("timeout")
                            .data("{\"partial\":\"" + escapeJson(buffer.get().toString()) 
                                + "\",\"message\":\"响应超时,已返回部分内容\"}")
                            .build());
                })
                .onErrorResume(Exception.class, e -> {
                    log.error("流式响应异常", e);
                    return Flux.just(ServerSentEvent.<String>builder()
                            .event("error")
                            .data("{\"error\":\"" + e.getMessage() + "\"}")
                            .build());
                })
                .doOnComplete(() -> {
                    meterRegistry.timer("llm.stream.duration")
                            .record(buffer.get().length() / 100, TimeUnit.SECONDS);
                })
                .doFinally(signalType -> {
                    if (signalType == SignalType.ON_ERROR) {
                        meterRegistry.counter("llm.stream.error").increment();
                    }
                });
    }

    /**
     * 带断点续传的流式响应
     */
    public Flux<String> streamWithResume(String messageId, String message) {
        // 检查是否有已缓存的部分响应
        String cached = streamCache.getIfPresent(messageId);
        Flux<String> cachedPart = cached != null 
            ? Flux.fromStream(cached.lines())
            : Flux.empty();
        
        AtomicLong receivedBytes = new AtomicLong(
            cached != null ? cached.length() : 0);

        return cachedPart.concatWith(
            chatClient.prompt()
                    .user(message)
                    .stream()
                    .content()
                    .doOnNext(chunk -> {
                        receivedBytes.addAndGet(chunk.length());
                        // 实时缓存
                        streamCache.put(messageId, 
                            cached + chunk);
                    })
                    .timeout(Duration.ofSeconds(30))
                    .retryWhen(Retry.backoff(2, Duration.ofSeconds(1))
                            .filter(e -> e instanceof TimeoutException))
        );
    }

    private final Cache<String, String> streamCache = Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(Duration.ofMinutes(10))
            .build();
}

总结

Spring AI异常处理与重试机制的关键技术点:

  1. 异常分类: 可重试/不可重试错误区分处理
  2. 指数退避: 限流场景的标准重试策略
  3. 熔断器: 连续失败时快速失败防止雪崩
  4. 降级响应: 不可用时返回兜底回复
  5. 上下文截断: 超长时自动压缩后重试

参考博客: https://blog.csdn.net/badao_liumang_qizhi

相关推荐
在所不辞兄44 分钟前
【零基础学智能仿真-07】随机森林——用多棵树获得更稳定的力学预测
人工智能·深度学习·神经网络·机器学习·工程技术
Tom·Ge2 小时前
【AI前沿】2026.08.17 SpaceX 600亿收购Cursor正式完成·DeepSeek V4 Pro万亿开源·人形机器人生态全面爆发
人工智能·大模型·ai前沿
郑州光合科技余经理2 小时前
本地生活服务系统:成品模块和定制接口怎么划界
java·前端·人工智能·后端·系统架构·php·ai编程
2603_965148112 小时前
家居百货蓝海:API挖掘高复购率生活小商品
大数据·服务器·人工智能·python·生活
萧瑟余晖3 小时前
Java深入解析篇三十三之密封类与接口(Sealed Classes)详解
java·开发语言
2501_930472443 小时前
踩坑|CodeBuddy权限配置:AI误删文件、乱跑命令、.env泄露怎么防
人工智能·ai编程
小淮AI6 小时前
从“刷题”到“追问”:AI课堂正在重塑哪些学习旧习惯?
人工智能·学习
cui_ruicheng6 小时前
LangChain 应用开发(十四):Agent 上下文与记忆机制
服务器·人工智能·python·langchain
论迹复利6 小时前
FreeRTOS 在 RISC-V 上是如何“点火“的 —— 从 main() 到第一个任务的完整链路
java·开发语言·risc-v