Skip to content

异常处理与重试:模型会挂,这是常态不是例外 ​

1. 本节产出 ​

一个不会把异常裸奔给用户的应用:区分可重试与不可重试错误、对限流做指数退避、对超时单独设阈值,并且重试次数与 Token 消耗都进日志,不会出现「用户点一次,后台烧了三倍的钱」。

2. 前置依赖 ​

3. 为什么 AI 调用的异常处理比普通 HTTP 复杂 ​

你调一个内部微服务,错误无非超时、500、业务码。调模型时多出这些:

错误类型表现能重试吗
401 鉴权失败Key 无效不能,重试一百次也是 401
429 限流并发或配额超限能,但要退避,且要遵守 Retry-After
5xx 服务端错误厂商故障能,但要看是否幂等
超时迟迟不返回能,但要确定服务端是否已扣费
上下文超限输入太长不能,重试必失败,必须压缩输入
内容审核拦截触发安全策略不能,重试只会再被拦一次
输出截断回答半句话能,但要加大 maxTokens

核心原则:重试前先问「再试一次结果会不同吗」。 不会不同的(鉴权、超限、审核)一律不重试,直接失败并给明确提示。这一点做错,会把一次失败放大成三次失败,还多花三倍的钱。

3.1 一个真实的账单事故 ​

某项目给所有异常统一加了「重试 3 次」。上线后遇到厂商配额超限,429 持续了两小时。结果是:

  • 原本 10 万次失败请求 → 变成 40 万次;
  • 其中大部分是无效重试,全部计入限流统计,进一步延长了超限时间;
  • 账单多出约 3 万元,且故障恢复时间被自己的重试拉长。

重试是把双刃剑,它会放大故障。 这就是为什么必须有退避、上限和熔断(熔断见 03B-09)。

4. 核心原理 ​

4.1 重试三要素 ​

重试策略 = 判定(哪些错误重试)
        × 退避(间隔多久)
        × 上限(最多几次 / 总时长上限)

三要素缺一不可。只有上限没有退避 = 雪崩放大器;只有退避没有上限 = 慢请求堆积。

4.2 退避策略对比 ​

策略间隔优点缺点
固定间隔1s, 1s, 1s简单并发恢复时同时重试,二次冲击
指数退避1s, 2s, 4s分散压力最坏情况等待久
指数 + 抖动1±0.3s, 2±0.5s, 4±1s推荐,避免重试同步实现稍复杂
遵守 Retry-After按服务端建议最礼貌不是所有厂商都返回

必须加抖动。没有抖动时,一批被同时限流的请求会同时重试,形成周期性尖峰——这就是「重试风暴」。抖动把重试时间点打散。

4.3 超时的两个层次 ​

连接超时(connect timeout):建立 TCP 连接的时间      → 5s 足够
读取超时(read timeout):等待响应首字节的时间        → 这是关键
整体超时(total timeout):从发起到拿到完整结果        → 流式场景必须设

最常配错的是「读取超时」设得太短。模型思考时间常在 10~30 秒,设 5 秒会导致大量明明能成功的请求被判失败。建议:普通对话 60s,长文本生成 120s,Agent 单步 30s。

4.4 降级:重试都失败之后 ​

主模型失败 → 换备用厂商 → 仍失败 → 返回缓存答案 → 仍没有 → 明确告知用户

降级不是「返回空」,而是给出次优但有用的结果。比如:「AI 服务暂时不可用,这是基于关键词匹配找到的相关文档:……」。有降级和无降级的产品,用户留存差别极大。

5. 代码走查 ​

5.1 错误分类:先建一个判定器 ​

java
// src/main/java/com/example/aibasics/resilience/RetryDecision.java
public enum RetryDecision { RETRY, FAIL_FAST }

public final class ErrorClassifier {

    public static RetryDecision classify(Throwable ex) {
        String msg = ex.getMessage() == null ? "" : ex.getMessage();

        // 不可重试:鉴权、参数、超限、审核
        if (msg.contains("401") || msg.contains("Invalid API key"))  return RetryDecision.FAIL_FAST;
        if (msg.contains("400") || msg.contains("invalid_request"))  return RetryDecision.FAIL_FAST;
        if (msg.contains("context_length") || msg.contains("maximum context")) return RetryDecision.FAIL_FAST;
        if (msg.contains("content_filter") || msg.contains("safety")) return RetryDecision.FAIL_FAST;

        // 可重试:限流、服务端错误、超时
        if (msg.contains("429") || msg.contains("rate_limit")) return RetryDecision.RETRY;
        if (msg.contains("500") || msg.contains("502") || msg.contains("503")) return RetryDecision.RETRY;
        if (ex instanceof java.net.SocketTimeoutException) return RetryDecision.RETRY;

        // 未知错误默认不重试:宁可失败也不要放大故障
        return RetryDecision.FAIL_FAST;
    }
}

最后那条「未知错误默认不重试」是刻意的设计。面对没见过的错误,保守处理更安全。

5.2 指数退避 + 抖动 ​

java
// src/main/java/com/example/aibasics/resilience/Backoff.java
public final class Backoff {

    private static final double BASE_MS = 800;
    private static final double MAX_MS = 20_000;

    /** 第 attempt 次(从 1 开始)的等待毫秒数,含 ±30% 抖动 */
    public static long nextDelay(int attempt) {
        double raw = Math.min(MAX_MS, BASE_MS * Math.pow(2, attempt - 1));
        double jitter = raw * 0.3 * (ThreadLocalRandom.current().nextDouble() * 2 - 1);
        return (long) Math.max(0, raw + jitter);
    }
}

5.3 带判定的重试封装 ​

java
// src/main/java/com/example/aibasics/resilience/ResilientChatService.java
@Service
public class ResilientChatService {

    private final ChatClient chatClient;
    private static final int MAX_ATTEMPTS = 3;

    public String chat(String question) {
        for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
            try {
                String answer = chatClient.prompt().user(question).call().content();
                Metrics.recordAttempt(question, attempt);      // 记录实际尝试次数
                return answer;
            } catch (Exception ex) {
                if (ErrorClassifier.classify(ex) == RetryDecision.FAIL_FAST) {
                    throw new ChatBizException("AI 调用失败,无需重试:" + rootCause(ex), ex);
                }
                log.warn("第 {}/{} 次调用失败,准备重试", attempt, MAX_ATTEMPTS, ex);
                if (attempt == MAX_ATTEMPTS) break;
                sleep(Backoff.nextDelay(attempt));
            }
        }
        return fallback(question);                             // 降级,不抛异常
    }

    private String fallback(String question) {
        log.warn("全部重试失败,走降级:{}", question);
        return "AI 服务暂时不可用,请稍后再试。已记录你的问题,恢复后可通过历史记录查看。";
    }
}

5.4 用 Spring Retry 的等价写法 ​

xml
<!-- pom.xml -->
<dependency>
  <groupId>org.springframework.retry</groupId>
  <artifactId>spring-retry</artifactId>
</dependency>
java
@Retryable(
    retryFor = { transientAiException.class },   // 只重试瞬时异常
    maxAttempts = 3,
    backoff = @Backoff(delay = 800, multiplier = 2, maxDelay = 20000, random = true)
)
public String chat(String q) { ... }

@Recover
public String recover(RuntimeException ex, String q) {
    return fallback(q);
}

random = true 就是加抖动。用框架的好处是退避和抖动不用自己写,坏处是异常分类要自己做(Spring Retry 认的是异常类型,不是 HTTP 状态码),所以 ErrorClassifier 仍然是必需的。

5.5 统一异常出口 ​

java
// src/main/java/com/example/aibasics/web/AiExceptionHandler.java
@RestControllerAdvice
public class AiExceptionHandler {

    @ExceptionHandler(ChatBizException.class)
    public ResponseEntity<ApiError> handle(ChatBizException ex) {
        return ResponseEntity.status(503).body(new ApiError(
                "AI_UNAVAILABLE", ex.getMessage()));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiError> handle(Exception ex) {
        log.error("unexpected", ex);
        return ResponseEntity.status(500).body(new ApiError("INTERNAL", "服务内部错误"));
    }

    public record ApiError(String code, String message) {}
}

绝不能把厂商的原始错误直接返给前端。里面可能包含 base-url、模型名、甚至部分请求内容。统一包装成业务错误码。

6. 跑起来 ​

bash
git checkout ch01-10-error-handling
mvn spring-boot:run

用内置故障开关模拟各类错误:

bash
# 1. 模拟 401:配置一个错误 Key
curl -X POST "http://localhost:8080/api/chat?q=你好&fault=auth"
# 期望:立刻失败,日志显示 FAIL_FAST,无重试

# 2. 模拟 429
curl -X POST "http://localhost:8080/api/chat?q=你好&fault=ratelimit"
# 期望:重试 3 次,间隔约 0.8s / 1.6s,最终降级返回

# 3. 模拟超时
curl -X POST "http://localhost:8080/api/chat?q=你好&fault=timeout"
# 期望:重试后降级

# 4. 模拟上下文超限
curl -X POST "http://localhost:8080/api/chat?q=$(cat big.txt)&fault=context"
# 期望:FAIL_FAST,提示「内容过长」而不是重试
检查项通过标准
401 不重试日志只有一次调用记录
429 退避三次重试间隔递增且带抖动
Token 记账重试 3 次时,日志记录的尝试次数为 3,成本可追溯
降级返回最终返回友好文案,前端不报错
错误脱敏响应体里不含 base-url、Key 片段

7. 生产避坑 ​

  1. 重试会成倍放大 Token 成本,必须有账单视角的监控。建议指标:按 conversationId 统计实际调用次数,超过「用户请求数 × 1.3」就告警。没有这个监控,重试就是个看不见的吞钱黑洞。
  2. 对超时错误要格外谨慎。超时不代表服务端没处理——它可能已经生成完了并扣了费,只是你没收到。盲目重试等于重复付费。可行的做法是对写操作类任务(会改数据的)不做超时重试,只对只读生成重试。
  3. 不要在重试逻辑里吞掉原始异常。最后失败时必须把第一次和最后一次的异常都记下来,否则排查时只能看到「重试 3 次失败」,不知道到底是什么原因。这点在小故障时无所谓,大故障时决定排查速度。

8. 延伸与锚点 ​