臭名昭著aTrust隔离计划1.臭名昭著aTrust隔离计划2.VPN原理与aTrust隔离网络实践3.docker-easyconnect到底做了什么4.TUN(tunnel-隧道-虚拟网卡)模式
个人项目
personal(个人展示)GitHub Actions 自动部署个人展示项目
基于Quartz搭建的个人博客1.Quartz个人博客使用教程2.使用 rsync 增量部署 Quartz 博客3.使用 GitHub Actions 自动部署 Quartz 博客5.域名绑定6.CDN加速7.Google 搜索收录排查:为什么关键词搜得到别人,却搜不到我的网站?Obsidian、双向链接与知识图谱
架构设计
分布式分布式事务RPC原理
旅行
行程攻略青岛三日游行程攻略
前端
NginxNginx配置与反向代理入门
Nodenpm与npx的区别
数据库
mysql01.MySQL基础语法与约束02.MySQL数据库类型03.MySQL多表查询04.MySQL 索引05.MySQL 事务06.MySQL 隔离级别07.MySQL MVCC08.MySQL 锁09.EXPLAIN10.慢查询与 SQL 优化
mysql面试题01.索引问题02.事务与隔离级别03.锁机制04.存储引擎与性能优化05.用户权限、字符集与备份恢复
mysql实战01.EXPLAIN实战02.测试环境菜单误删排查与恢复03.MySQL IN 详解
网络
网络安全1.无线监听网卡苹果M系列虚拟机安装Kali Linux教程3.kali怎么使用RTL8812AU4.kali基础命令
网络基础进程之间怎样通过网络传递数据
网址常用网址
中间键
esElasticsearch 入门
mq
RabbitMQRabbitMQ高级RabbitMQ基础
1. MQ 基础2. MQ 核心角色与代码对应3. MQ Consumer Group 与消费模式4. SOFAMQ 账户变更代码实践5. MQ 可靠性问题6. 消息幂等
redis缓存击穿、穿透与雪崩Redis 分布式锁Redis 缓存问题Redis 数据结构
AI
使用说明
第三方插件&技能简介Archify使用与安装指南geekgeekRun使用OfferFlow使用指南Ponytail使用指南Skills Manager使用指南
CodexCodex CLI与IDE区别及使用指南Codex Hook单独配置与提交通知Codex MCP安装与使用指南Codex第三方插件安装与使用指南
Agent开发0-学习路线1.LLM、Token、上下文窗口与模型参数2.大模型 API、请求参数与响应结构3.Spring AI ChatClient4.Prompt、System Prompt、Prompt 模板6.结构化输出、JSON Schema7.SSE 流式响应8.会话 ID、聊天记录、Redis9.超时、重试、限流、降级10.*完成可运行聊天接口11.Function Calling、Tool Calling 原理12.用 Java 方法定义 Tool13.工具参数设计和校验14.实现订单查询工具15.实现物流查询工具16.实现库存查询工具17.多个工具自动选择18.工具异常、超时、重试
diy主机
主机零件学习1.CPU2.主板3.内存4.显卡5.硬盘6.散热7.风扇8.机箱热门型号价格表
捡垃圾diy-13600kf+5060diy-14600kf+5070
English高频词汇英文基础语法简单句子音标
GitGit常用命令Sublime Merge 使用指南
Java
工具库Caffeine本地缓存Reactor教程
工作实践如何使用@Async线程池使用与排查线上服务 CPU 使用率过高排查压测下RPC接口大量失败排查记录
基础1.HashMap2.ConcurrentHashMap3.HashSet4.final5.static6.volatile7.AtomicInteger8.String动态代理反射锁与线程同步线程注解
面试题1.HashMap 存储在方法内是线程安全的吗2.ArrayList参数传递与引用重新赋值3.== 和 equals 的区别、hashcode4.重载和重写的区别5.String、StringBuffer、StringBuilder区别及使用场景6.接口和抽象类7.ArrayList 与 LinkedList 的区别和扩容机制8.HashMap 和 Hashtable 的区别及底层实现9.Object 和 Objects 的区别10.ThreadLocal的原理和使用场景11.字节码是什么及作用12.Java类加载器有哪些13.双亲委派模型14.Java异常体系15.GC是什么16.GC如何判断对象可以被回收
面试总结0.HR面试模板1.面试一2.面试二3.面试三笔试
网络与 IO(黑马程序员-纯理论)01-网络编程基础02-IO模型与零拷贝03-BIO04-NIO基础与缓冲区05-NIO通道与选择器06-AIO
juc00-目录01-进程与线程02-线程同步与通信03-并发设计模式04-Java内存模型05-CAS与原子类06-ThreadLocal07-阻塞队列08-线程池使用与原理09-任务调度10-ForkJoin与享元模式11-AQS12-ReentrantLock13-读写锁14-并发协作工具15-ConcurrentHashMap16-CopyOnWrite与同步容器17-SkipListMap18-非阻塞队列
juc(工作常见)Java并发编程三个重点拆解
jvm1. JVM运行时数据区
Spring1.Spring IoC、DI与构造方法注入CGLIB、Byte Buddy 与 Spring AOPSpring 常用注解详解Spring Bean 生命周期
Spring MVC异常处理机制
linux常用命令
mac快捷键终端常用操作
python
基础01.Python是什么,为什么要安装Python02.Python项目中.venv是什么03.Python基础语法
基础实战sql替换
HTTPXHRMS员工详情接口调用(Python HTTPX)
windowswindows激活

一、💡 一句话理解

第 9 天核心结论

调用大模型和调用任何不稳定的远程服务一样:超时负责不无限等,重试负责给暂时性失败一次恢复机会,限流负责不让请求压垮自己和上游,降级负责失败时仍返回诚实、可用的结果。

本篇严格对应 0-学习路线 第 9 天:超时、重试、限流、降级。它建立在 3.Spring AI ChatClient、7.SSE 流式响应 与 8.会话 ID、聊天记录、Redis 的普通聊天接口之上。

今天的验收标准是:模型服务慢、暂时失败或请求过多时,接口会在可控时间内结束,且不会把错误伪装成模型答案。本文不提前展开熔断、分布式限流、Tool Calling 或 RAG。

二、🧭 理论:它是什么

2.1 四个保护措施分别解决什么问题

措施白话解释触发场景本篇的结果
超时(Timeout)等太久就停止等待上游模型迟迟未返回在 8 秒内结束并返回降级提示。
重试(Retry)针对“可能马上恢复”的错误再试网络瞬断、429、部分 5xxSpring AI 按退避间隔再次请求。
限流(Rate limiting)给入口设置通行速度上限同一用户短时间连续提问立即返回 429 Too Many Requests。
降级(Fallback)主路径失败时换成安全的次要结果重试后仍失败、发生超时不编造回答,提示稍后重试。

它们的关系不是“谁替代谁”。可以把聊天接口想成医院分诊:限流先控制进门人数;进入后设定最长等待时间;偶发故障才重试;最终无法完成时,给用户明确的替代说明。

2.2 哪些错误能重试,哪些绝不能重试

重试的前提是:同一请求再次发送,成功概率会提高,且重复调用没有额外副作用。聊天生成是只读调用,通常满足第二点;后续创建售后工单等写操作则不能盲目重试,必须先做幂等控制。

错误类型是否应重试原因
网络连接失败、连接重置可以,次数有限多数是暂时性故障。
429 Too Many Requests可以,必须退避立即连发只会让限额更严重。
500、502、503、504可以,次数有限可能是上游暂时不可用。
400 Bad Request、参数校验失败不可以请求本身有问题,重试仍会失败。
401、403不可以API Key 或权限错误,必须人工修复。
已执行的写操作默认不可以重复请求可能产生重复订单或工单。

2.3 退避:为什么不能连续按同一频率重试

退避(backoff)指每次重试前等待一段时间。指数退避会逐渐拉长等待间隔,例如 1 秒、2 秒、4 秒。这样可以给上游恢复时间,也避免大量失败请求在同一时刻再次涌入。

重试不是“提高成功率的免费按钮”

重试会增加请求数、延迟和 Token 成本。聊天接口建议只保留少量尝试;真正的参数错误、权限错误和内容安全拒绝应直接返回,不能重试。

2.4 降级的底线:可用,但不假装知道

本篇的降级结果是固定提示,例如“AI 服务暂时繁忙,请稍后再试”,而不是编一段看似合理的业务答案。

对于企业售后 Agent,这一点尤其重要:不知道订单状态时,宁可请用户稍后重试,也不能猜测“订单已发货”。固定提示还应保留请求 ID,便于用户反馈和后台排查。

三、⚙️ 理论:它是怎么工作的

3.1 一次聊天请求的保护顺序

入口限流最靠前,因为被拒绝的请求不应该再占用线程、模型配额或 Token。重试位于模型客户端内部;超时保护的是本系统等待远程结果的时长;降级是主流程最终不能完成时的出口。

3.2 Spring AI 自带的模型重试

可以把 spring.ai.retry 理解为:模型服务暂时出问题时,Spring AI 帮你自动再试,不需要你在 Java 代码里手写循环。

例如配置了 spring.ai.retry.max-attempts=3:用户只发起 1 次聊天请求。第一次调用模型失败后,Spring AI 会根据配置继续尝试;整次用户请求最多调用模型 3 次。

用户发 1 次 /api/chat
       ↓
第 1 次调用模型:503,模型服务暂时不可用
       ↓ 等待 1 秒
第 2 次调用模型:仍失败
       ↓ 等待 2 秒
第 3 次调用模型:成功,返回回答

指数退避(exponential backoff)就是“每次重试前多等一会儿”,例如等待 1 秒、2 秒、4 秒;它避免失败请求立即连发,把本来很忙的模型服务压得更忙。

模型返回情况是否重试为什么
429:模型服务说“太忙了”可以稍等后可能恢复。
503:模型服务暂时不可用可以常见的短暂服务故障。
400:请求参数不对不可以原请求有问题,再发一次还是错。
401:API Key 错误不可以必须修复配置,重试没有意义。

本篇已经通过 application.properties 配置这件事,因此业务代码只需要正常调用 ChatClient,不要再写一层“三次循环重试”。否则最坏情况下会变成:Spring AI 内部最多尝试 3 次,外部循环又执行 3 次,即一次用户请求可能发出 3 × 3 = 9 次模型调用,既变慢,又额外消耗 Token。

3.3 超时不等于取消远程服务

Future#get(8, TimeUnit.SECONDS) 可以直接理解成:我们的聊天接口最多等模型 8 秒。

用户请求 /api/chat
        ↓
Java 向模型服务发出 HTTP 请求
        ↓
8 秒内拿到回答 → 正常返回
8 秒还没拿到回答 → 本接口返回“超时,请稍后再试”

超时时调用 cancel(true),相当于告诉 Java 本地任务:“不要再等了,尽量停止。”它首先保证的是我们的接口不会一直卡住。

但 HTTP 请求可能已经到达模型提供方:

我们的 Java 服务 ──请求已发出──> 模型服务仍在生成答案
        │
        └─ 等满 8 秒后停止等待,并向用户返回超时

因此,cancel(true) 不一定能让远端模型立即停止计算;远端是否真正取消,取决于底层 HTTP 客户端和模型提供方是否支持取消。这也意味着,模型提供方可能仍然记录这次请求。

完整系统还应设置两类更底层的 HTTP 超时:

超时类型白话解释示例
连接超时多久还连不上模型服务器,就放弃网络异常、模型地址不可达。
响应超时已经连上了,但多久没收到模型响应,就放弃模型服务很慢或卡住。

Spring Framework 的 WebClient 官方文档支持配置这两类超时。可以这样理解三层保护:连接与响应超时负责尽早终止底层 HTTP 请求;Future#get(8, ...) 是最外层总兜底,确保不管哪个环节慢,用户最多等待 8 秒。本文先使用最外层兜底,便于第 9 天理解和验证。

远端未必真正停止会带来这些影响:

影响在聊天接口中的表现
费用与配额模型可能继续生成,仍可能消耗 Token 或请求额度;具体以模型提供方的计费规则为准。
用户体验用户已收到“超时”,但模型稍后可能已经生成完;这份答案不会再返回给用户。
重复调用用户或系统再次请求,可能产生两次模型调用。聊天场景通常只是增加成本。
写操作风险后续创建工单、退款等写操作若直接重试,可能重复执行;必须使用幂等键并先查询确认。
排查难度本地记录为超时,远端可能记录为已收到或已完成;需要请求 ID、耗时和日志协助排查。

当前聊天示例的直接风险

对只读聊天问答,最直接的影响是浪费 Token 和用户重复提问,不会直接产生重复业务数据。但进入 Tool Calling 后,任何会写入订单、工单或库存的操作都不能因为“本地超时”就盲目重试。

3.4 限流粒度:为什么不能只按 IP

限流键就是“按谁来计数”。本篇示例从请求头读取 X-User-Id,例如:

X-User-Id: user-1001

它表示“user-1001 这个用户一分钟只能请求 5 次”。但这只适合本地学习,因为客户端能随意伪造请求头:

X-User-Id: user-1001
X-User-Id: user-1002
X-User-Id: admin

真实项目应从 JWT 或登录态读取已经过服务端校验的用户 ID;未登录场景再结合 IP。不要把客户端自己填写的 X-User-Id 当作真实身份。

本篇的计数保存在当前 Java 应用内存中,因此叫作单实例、内存固定窗口限流:

同一用户在 1 分钟内请求 6 次
        ↓
前 5 次放行,进入 ChatController
        ↓
第 6 次返回 HTTP 429:请求过于频繁

它的局限也很直观:

场景会发生什么原因
应用重启计数立刻归零数据只在内存里。
部署两台应用每台各记各的次数用户可能向两台机器各请求 5 次,实际发出 10 次。
用户不断改 X-User-Id可绕过学习示例该请求头不是可信身份。

429 Too Many Requests 就是 HTTP 状态码,意思是“请求太多,请稍后再试”。本篇的内存实现只用于理解限流入口和 429 语义;生产环境通常把计数放入 Redis,或由网关统一限流,让所有应用服务器使用同一份计数。

四、🚀 实践:从准备到验证

4.1 前置准备

4.1.1 沿用现有项目和模型配置

沿用 3.Spring AI ChatClient 中的 Spring Boot 项目、spring-ai-starter-model-openai、DeepSeek OpenAI 兼容地址及 DEEPSEEK_API_KEY 环境变量。本篇不新增模型账号,也不把 API Key 写进代码。

示例以路线已使用的 Spring AI 2.0.1 为准。开始前确认普通的 POST /api/chat 已能返回回答;若没有,先完成第 3 天内容。

4.1.2 配置 Spring AI 的重试策略

文件位置:src/main/resources/application.properties。保留已有模型配置,追加以下内容:

# 最多尝试 3 次;仅对明确列出的暂时性状态码重试
spring.ai.retry.max-attempts=3
# 第一次重试前等待 1 秒,后续等待时间按 2 倍增长,最长不超过 4 秒
spring.ai.retry.backoff.initial-interval=1s
spring.ai.retry.backoff.multiplier=2
spring.ai.retry.backoff.max-interval=4s
# 4xx 默认不重试,下面只单独放行 429
spring.ai.retry.on-client-errors=false
# 模型限额或服务端暂时故障,才允许重试
spring.ai.retry.on-http-codes=429,500,502,503,504
# 请求参数、认证或权限错误必须直接暴露,不能靠重试掩盖
spring.ai.retry.exclude-on-http-codes=400,401,403

这里的 429 表示模型提供方限额,不是稍后示例中我们自己接口返回的 429。两者状态码相同,但位置不同:前者在调用上游模型时发生,后者在用户进入本服务时发生。

4.1.3 保留现有依赖

完整示例只使用 Spring Boot、Spring AI 和 JDK 并发工具,不需要新增限流框架。pom.xml 至少应保留已有的 Web 与 Spring AI 依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>

若项目同时保留第 7 天的 spring-boot-starter-webflux 用于 SSE,可以继续保留;本篇的普通 POST 接口使用 Spring MVC 的 OncePerRequestFilter。

4.2 可以拿来干什么

4.2.1 让暂时性的模型故障自动恢复

用途:模型提供方短暂返回 503 时,Spring AI 根据 application.properties 退避并再次尝试;调用方不需要在每个 Controller 手写循环。

前置:已完成 4.1.2 的 spring.ai.retry 配置。业务代码仍是普通的 ChatClient 调用:

String answer = chatClient.prompt()
        .user(message)
        .call()
        .content();

输入是用户问题;Spring AI 在需要时按配置处理模型请求重试;最终输出是回答,或在不可恢复时抛出异常给下面的降级分支处理。不要捕获所有异常后无条件重试。

4.2.2 避免单个慢请求长期占住资源

用途:当模型一直不返回时,聊天接口在 8 秒后结束等待,把资源还给系统;同时限制“正在调用模型”的任务数量。

前置:先理解 ExecutorService。它是 Java 自带的线程池管理器接口,可以理解为“任务调度主管”:业务代码把任务交给它,它安排线程池中的工作线程执行。

Controller:提交“调用模型”任务
        ↓
ExecutorService:安排一个 AI 工作线程执行
        ↓
Future<String>:返回一张结果回执,之后可等待结果或尝试取消

在本篇完整代码中,ExecutorService aiExecutor 就是专门执行模型调用的 AI 线程池;executor.submit(...) 是“提交任务”,不是立即在当前 Web 请求线程中直接调用模型。若想系统学习线程、线程池和 Future,可阅读 ../../Java/基础/线程。

下面的核心逻辑会在完整示例中出现:

// 把可能阻塞的模型调用交给 AI 线程池,立即取得可跟踪结果的 Future 回执。
Future<String> task = executor.submit(() -> chatClient.prompt().user(message).call().content());
// 当前 Web 请求线程最多等待 8 秒;任务完成就取得模型回答。
String answer = task.get(8, TimeUnit.SECONDS);

先理解为什么这里会出现线程。用户请求 /api/chat 后,Spring MVC 会分配一个 Web 请求线程 来执行 Controller;而 ChatClient.call() 是阻塞调用,模型未回答时,执行它的线程就会一直等待。

没有线程池:
用户请求 → Web 请求线程 → ChatClient.call() → 一直等模型回答
 
本篇示例:
用户请求 → Web 请求线程 → 提交任务 → AI 线程池中的工作线程调用模型
                         ↓
                  最多等待 8 秒结果

executor.submit(...) 的作用是把真正的模型调用交给专门的 AI 线程池。示例只创建 8 个工作线程,因此最多同时有 8 个模型调用在执行,慢请求不会无限创建新线程。

task.get(8, TimeUnit.SECONDS) 的作用是“最多等 8 秒”。这里的 Web 请求线程仍会等待,但最多只等待 8 秒,并不是完全不占用 Web 请求线程。8 秒内正常回答就返回模型结果;超时会抛出 TimeoutException,代码取消本地任务并返回降级结果,避免无限等待。

两个措施分别解决什么

AI 线程池负责限制“同时有多少个模型调用正在执行”;get(8, ...) 负责限制“单个用户请求最多等待多久”。两者一起才是本篇的资源保护。

4.2.3 保护接口和模型配额

用途:同一用户一分钟最多发送 5 次聊天请求。第 6 次直接返回 429,不会再调用模型。

前置:请求带学习用的 X-User-Id 请求头;完整示例中的过滤器只拦截 /api/chat。

前 5 次:进入 ChatController,可能调用模型
第 6 次:HTTP 429 + {"code":"RATE_LIMITED", ...}

这能避免明显的误操作或简单脚本耗尽模型配额。生产环境不要把这个内存实现当作多实例限流方案。

4.3 完整实践:受保护的聊天接口

目标:实现 POST /api/chat。同一 X-User-Id 在一分钟内可请求 5 次;模型暂时性错误由 Spring AI 重试;最终失败或超过 8 秒时,返回明确的降级内容。

4.3.1 配置执行模型调用的线程池

文件位置:src/main/java/com/afyke/ai/config/AiExecutorConfig.java。如果项目基础包不是 com.afyke.ai,改成自己的包名。

package com.afyke.ai.config;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
 
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
 
// 告诉 Spring:这个类负责声明配置 Bean,应用启动时会读取它。
@Configuration
public class AiExecutorConfig {
 
    // 注册名为 aiExecutor 的线程池 Bean;Spring 关闭时自动调用 shutdown(),不再接收新任务。
    @Bean(destroyMethod = "shutdown")
    ExecutorService aiExecutor() {
        // ChatClient.call() 是阻塞调用。这里最多同时运行 8 个模型调用任务。
        // 超过 8 个的任务会先在该工厂默认的等待队列中排队;本篇用于学习,生产应改为有界队列。
        return Executors.newFixedThreadPool(8);
    }
}

输入是 Spring 创建的 Bean;处理过程是限制同时执行的阻塞模型任务最多为 8 个;输出是供聊天服务注入的线程池。真实生产环境应结合机器规格、连接池与监控调整这个数值,不能无限创建线程。

4.3.2 编写单机固定窗口限流器和过滤器

文件位置:src/main/java/com/afyke/ai/ratelimit/FixedWindowRateLimiter.java。

package com.afyke.ai.ratelimit;
 
import org.springframework.stereotype.Component;
 
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
 
// 交给 Spring 管理为单例组件;Filter 会注入并复用这一份限流计数器。
@Component
public class FixedWindowRateLimiter {
 
    // 一个用户在一个 60 秒固定窗口内最多可通过 5 次请求。
    private static final int LIMIT_PER_MINUTE = 5;
    private static final long WINDOW_MILLIS = 60_000L;
 
    // key 是用户标识,value 保存“窗口从何时开始”和“本窗口已用了几次”。
    // ConcurrentHashMap 允许多个 HTTP 请求线程同时访问不同用户的计数。
    private final ConcurrentHashMap<String, Counter> counters = new ConcurrentHashMap<>();
 
    public boolean allow(String key) {
        long now = System.currentTimeMillis();
        // compute 让同一用户键的“创建窗口 / 增加计数”成为一次原子更新。
        Counter counter = counters.compute(key, (ignored, old) -> {
            if (old == null || now - old.windowStartedAt() >= WINDOW_MILLIS) {
                // 没有旧窗口或窗口已过期:从本次请求开始新的计数。
                return new Counter(now, new AtomicInteger(1));
            }
            // 仍在当前窗口:记录这一次访问。
            old.used().incrementAndGet();
            return old;
        });
        // 第 1 至第 5 次允许;第 6 次及以后拒绝。
        return counter.used().get() <= LIMIT_PER_MINUTE;
    }
 
    // windowStartedAt 用于判断 60 秒是否结束;AtomicInteger 避免并发 ++ 时丢失计数。
    private record Counter(long windowStartedAt, AtomicInteger used) {
    }
}

文件位置:src/main/java/com/afyke/ai/ratelimit/AiRateLimitFilter.java。

package com.afyke.ai.ratelimit;
 
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
 
import java.io.IOException;
 
// OncePerRequestFilter 保证一次 HTTP 请求只执行一次这段限流逻辑。
@Component
public class AiRateLimitFilter extends OncePerRequestFilter {
 
    // Spring 会自动注入上面注册的单例限流器。
    private final FixedWindowRateLimiter rateLimiter;
 
    public AiRateLimitFilter(FixedWindowRateLimiter rateLimiter) {
        this.rateLimiter = rateLimiter;
    }
 
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        // request.getRequestURI() 是请求路径;只保护 /api/chat,不影响健康检查和其他业务接口。
        return !"/api/chat".equals(request.getRequestURI());
    }
 
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        // 学习示例从请求头读取;真实系统必须改为从 JWT / 登录态取得可信用户 ID。
        String userId = request.getHeader("X-User-Id");
        // 没有用户标识的请求共用 anonymous 配额,避免匿名请求完全不受限制。
        String limitKey = (userId == null || userId.isBlank()) ? "anonymous" : userId;
 
        if (!rateLimiter.allow(limitKey)) {
            // 被拒绝的请求不进入 Controller,因此不会消耗模型调用配额。
            response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            // 明确按 JSON 返回,让前端可以识别 code 并提示用户稍后重试。
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write("{\"code\":\"RATE_LIMITED\",\"message\":\"请求过于频繁,请 1 分钟后再试\"}");
            return;
        }
        // 限流通过后才交给后续过滤器与 Controller。
        filterChain.doFilter(request, response);
    }
}

限流器的输入是用户键,输出是“允许/拒绝”;过滤器在拒绝时直接写入 429 JSON,放行才进入 Controller。固定窗口边界可能产生短暂突发,这是该简单算法的特性;此处的目标是学习边界与验证方式。

4.3.3 编写有超时和降级的聊天服务

文件位置:src/main/java/com/afyke/ai/service/AiChatService.java。

package com.afyke.ai.service;
 
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
 
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
 
// Spring 扫描到 @Service 后会创建一个聊天服务对象,Controller 可通过构造方法注入它。
@Service
public class AiChatService {
 
    // 这是本系统等待模型结果的总上限,不是模型提供方一定停止计算的保证。
    private static final long TIMEOUT_SECONDS = 8;
 
    // 封装 Spring AI 的模型调用入口。
    private final ChatClient chatClient;
    // 来自 AiExecutorConfig 的 Bean,专门执行可能阻塞的模型调用任务。
    private final ExecutorService aiExecutor;
 
    public AiChatService(ChatClient.Builder builder, ExecutorService aiExecutor) {
        // Spring AI 自动提供 Builder;build() 后得到可发起 Prompt 的 ChatClient。
        this.chatClient = builder.build();
        this.aiExecutor = aiExecutor;
    }
 
    public ChatResult chat(String message) {
        // 把“调用模型”作为任务提交给 AI 线程池;submit 立即返回 Future 回执。
        // Spring AI 会在 call() 内部先按 application.properties 的规则重试暂时性错误。
        Future<String> task = aiExecutor.submit(() -> chatClient.prompt()
                // 创建本次调用的 Prompt,并把用户输入放入 user 消息。
                .user(message)
                // 同步向模型提供方发送请求;执行这行的是 AI 工作线程,可能会阻塞。
                .call()
                // 从模型响应中取出最终文本内容,作为 Future<String> 的结果。
                .content());
 
        try {
            // 当前 Web 请求线程在此最多等待 8 秒;成功时取得 String 并标记 degraded=false。
            // 它仍会等待,但不会无限等待;线程池负责限制同时执行的模型调用数量。
            return new ChatResult(task.get(TIMEOUT_SECONDS, TimeUnit.SECONDS), false, null);
        }
        catch (TimeoutException exception) {
            // 超时表示本地等不到结果。true 表示尝试中断正在执行此任务的 AI 工作线程。
            // 中断本地任务只是尽力而为;远程 HTTP 请求是否取消还取决于底层客户端。
            task.cancel(true);
            return fallback("AI 响应超时,请稍后再试", "TIMEOUT");
        }
        catch (InterruptedException exception) {
            // 恢复中断标记,避免吞掉上层的取消信号。
            Thread.currentThread().interrupt();
            task.cancel(true);
            return fallback("请求已被中断,请稍后再试", "INTERRUPTED");
        }
        catch (ExecutionException exception) {
            // 任务内部抛出的异常会被 Future 包装成 ExecutionException。
            // 包含 Spring AI 重试耗尽后的异常;不把失败伪装成正常模型回答。
            return fallback("AI 服务暂时不可用,请稍后再试", "UPSTREAM_UNAVAILABLE");
        }
    }
 
    private ChatResult fallback(String answer, String code) {
        // degraded=true 让 Controller 能区分“模型回答”和“保护机制返回的提示”。
        return new ChatResult(answer, true, code);
    }
 
    // 统一封装服务层输出:answer 是给用户的文本,code 是机器可识别的降级原因。
    public record ChatResult(String answer, boolean degraded, String code) {
    }
}

ChatClient 的异常会先经过 4.1.2 的 Spring AI 重试策略;若重试后仍失败,任务以 ExecutionException 结束并走降级。超时分支只返回安全提示,不声称模型已经成功回答。

4.3.4 编写请求、响应和 Controller

文件位置:src/main/java/com/afyke/ai/controller/ChatController.java。

package com.afyke.ai.controller;
 
import com.afyke.ai.service.AiChatService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
 
// 声明为 REST Controller:方法返回的 Java 对象会自动序列化成 JSON。
@RestController
// 这个类中所有接口都以 /api 开头。
@RequestMapping("/api")
public class ChatController {
 
    // Spring 注入聊天服务;Controller 只负责 HTTP 输入输出,不直接处理线程和模型异常。
    private final AiChatService aiChatService;
 
    public ChatController(AiChatService aiChatService) {
        this.aiChatService = aiChatService;
    }
 
    // 最终接口路径是 POST /api/chat;请求体 JSON 会自动转换为 ChatRequest。
    @PostMapping("/chat")
    public ResponseEntity<ChatResponse> chat(@RequestBody ChatRequest request) {
        // 参数错误应直接返回 400,不进入模型调用,更不应触发重试。
        if (request.message() == null || request.message().isBlank()) {
            return ResponseEntity.badRequest().build();
        }
 
        // 服务层已经把正常、超时和上游失败统一封装为 ChatResult。
        AiChatService.ChatResult result = aiChatService.chat(request.message());
        // 只把 Controller 对外需要的字段组装成响应对象,不暴露内部 Future 或异常对象。
        ChatResponse response = new ChatResponse(result.answer(), result.degraded(), result.code());
        // 降级不是正常回答:使用 503 让调用方能识别并决定是否稍后重试。
        return ResponseEntity.status(result.degraded() ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.OK)
                .body(response);
    }
 
    // 接收请求 JSON,例如:{ "message": "你好" }。
    public record ChatRequest(String message) {
    }
 
    // 返回响应 JSON:answer 是文本,degraded 标记是否降级,code 说明降级原因。
    public record ChatResponse(String answer, boolean degraded, String code) {
    }
}

输入是 message;正常输出为 200 和模型回答;模型超时或最终失败输出为 503、degraded: true 和固定提示。429 由前面的过滤器返回,Controller 不会被执行。

4.3.5 启动并验证

在 IntelliJ IDEA 启动 Spring Boot 主类,或在项目根目录运行:

./mvnw spring-boot:run

先发一条正常请求:

curl -i -X POST 'http://localhost:8080/api/chat' \
  -H 'Content-Type: application/json' \
  -H 'X-User-Id: user-1001' \
  -d '{"message":"用一句话解释 Java 中的接口"}'

正常时预期为 200 OK:

{"answer":"……","degraded":false,"code":null}

再在一分钟内用同一个 X-User-Id 连续请求 6 次。前 5 次可以进入模型调用;第 6 次预期为:

HTTP/1.1 429
{"code":"RATE_LIMITED","message":"请求过于频繁,请 1 分钟后再试"}

要观察降级,可暂时把 TIMEOUT_SECONDS 改为 1,向模型发送一次请求;若模型未在 1 秒内完成,预期为:

HTTP/1.1 503
{"answer":"AI 响应超时,请稍后再试","degraded":true,"code":"TIMEOUT"}

验证结束后把超时恢复为 8。不要为了测试而关闭重试配置;应通过日志确认 429、5xx 或网络问题才触发重试。

4.3.6 常见问题

4.3.6.1 连续第 6 次请求没有返回 429

检查 6 次请求是否带了相同的 X-User-Id,并确认 URL 是 /api/chat。不同用户键各自拥有独立窗口;超过一分钟也会自动开始新窗口。

4.3.6.2 所有请求一开始就返回降级

先检查 3.Spring AI ChatClient 中的模型 base-url、模型名称、DEEPSEEK_API_KEY 与账户可用性。401、403、400 被本篇明确排除在重试之外,修复配置后再发起新请求。

4.3.6.3 超时后模型提供方仍有请求记录

这是 Future.cancel(true) 只能尝试停止本地等待的表现,不代表远程 HTTP 一定已终止。应继续在实际使用的 Spring AI 模型 HTTP 客户端上配置连接和响应超时;生产系统还应记录请求 ID、耗时、重试次数和降级次数。

五、📌 总结

  • 超时是等待上限:先保证服务不会无限卡住,再讨论体验优化。
  • 重试只针对暂时性、可安全重放的失败;400、401、403 不应重试。
  • 限流应尽可能靠近入口,超额请求直接 429,不消耗模型配额。
  • 降级必须诚实:返回固定提示和状态码,绝不伪造业务或模型结论。
  • 本篇的内存限流适合单机学习;多实例与网关场景是后续工程化内容,不在第 9 天展开。

一句话复述:先限流挡住洪峰,调用时设置超时;遇到短暂故障有限重试,最终失败就诚实降级。

六、🔗 官方资料