Appearance
异常处理与重试:模型会挂,这是常态不是例外
1. 本节产出
一个不会把异常裸奔给用户的应用:区分可重试与不可重试错误、对限流做指数退避、对超时单独设阈值,并且重试次数与 Token 消耗都进日志,不会出现「用户点一次,后台烧了三倍的钱」。
2. 前置依赖
- 01-02 第一个 ChatClient:已有同步调用
- 01-03 国内多模型统一适配层:已有多厂商可用(降级要用)
- 了解 Spring Retry 或 Resilience4j 的基本概念
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
// ch01-basics/src/main/java/com/aitech/basics/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
// ch01-basics/src/main/java/com/aitech/basics/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
// ch01-basics/src/main/java/com/aitech/basics/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
// ch01-basics/src/main/java/com/aitech/basics/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. 生产避坑
- 重试会成倍放大 Token 成本,必须有账单视角的监控。建议指标:按
conversationId统计实际调用次数,超过「用户请求数 × 1.3」就告警。没有这个监控,重试就是个看不见的吞钱黑洞。 - 对超时错误要格外谨慎。超时不代表服务端没处理——它可能已经生成完了并扣了费,只是你没收到。盲目重试等于重复付费。可行的做法是对写操作类任务(会改数据的)不做超时重试,只对只读生成重试。
- 不要在重试逻辑里吞掉原始异常。最后失败时必须把第一次和最后一次的异常都记下来,否则排查时只能看到「重试 3 次失败」,不知道到底是什么原因。这点在小故障时无所谓,大故障时决定排查速度。
8. 延伸与锚点
- 思考题:如果厂商持续故障两小时,你的重试策略会不会把故障拖得更久?(提示:需要熔断——答案在 03B-09)
- 代码锚点:
git checkout ch01-10-error-handling - 下一课时:01-11 Testcontainers 集成测试
- 对应课件:L01-10 异常处理与重试