读完这篇你会得到什么
一套可以直接抄走的生产级 Chat 接口骨架:六个开关(超时 / 重试 / 限流 / 脱敏 / 流式 / 换模型),每个都有对应的测试。
以及 13 个踩坑的报错原文与修法 ------ 包括 Spring AI 2.0 移除 TransientAiException、chat.options.* 配置被弃用、上游 401 被吞成 500 这些文档里不会明说、但一定会遇到的事。
更值钱的是最后三个:配置项存在、类型正确、绑定测试全绿 ------ 但根本没接到调用链上。超时和重试两个开关就死在这条缝里。
实测环境(所有结论都基于这个组合,别跨版本照搬):
| 项 | 版本 |
|---|---|
| JDK | 21.0.10 |
| Maven | 3.8.2 |
| Spring Boot | 4.1.1 |
| Spring AI | 2.0.1 |
| 模型 | deepseek-flash |
一、ChatClient 的入门代码,上生产一定出事
Spring AI 的入门代码只有两行:
java
ChatClient client = ChatClient.create(chatModel);
String answer = client.prompt().user("你好").call().content();
这段代码本地跑一定成功。 但生产环境会问它六个问题:
| 问题 | 不处理的后果 |
|---|---|
| 模型侧卡住不返回怎么办? | 请求挂着不放,连接池被占满,整站雪崩 |
| 偶发 5xx / 429 要不要重试? | 不重试 → 抖动直接变成用户可见的错误 |
| 有人刷接口怎么办? | 一次刷爆当月预算 |
| prompt 里带内部表名/工单号,日志怎么落? | 合规事故。日志进 ELK 就收不回来了 |
| 用户等 5 秒才看到第一个字? | P99 被首 token 延迟直接吃掉 |
| 某家供应商抖动要临时切走? | 硬编码模型名 → 每次都要发版 |
这篇就是这六个问题的最小完整答案。
二、第一个生产决策:版本
动手之前先定版本,因为它不是你能选的。
Spring AI 2.0.1 的 starter,<dependencies> 里写死:
makefile
org.springframework.ai:spring-ai-starter-model-openai:2.0.1
└─ org.springframework.boot:spring-boot-starter-webclient:4.1.1
└─ org.springframework.boot:spring-boot-starter-restclient:4.1.1
Spring AI 2.0.x 硬依赖 Spring Boot 4.1.x。 这不是建议,是 Maven 依赖树的客观约束。
| 版本线 | Spring AI | Spring Boot | 适用 |
|---|---|---|---|
| 新线 | 2.0.1 |
4.1.1 |
新项目 |
| 存量线 | 1.1.8 |
3.5.15 |
存量 Spring Boot 3 系统 |
好消息 :starter 命名两代相同(spring-ai-starter-model-{provider}),改 BOM 与 Boot parent 版本就能切。
但「Java 代码不用动」这句我不敢打包票。 本文里那几处接线代码 (
OpenAiChatOptions.timeout(...)写在哪个.options(...)里、spring.ai.openai.max-retries还是spring.ai.retry.*)吃的都是 2.0 的 openai-java SDK 行为。
所以存量系统的路径是:先在 Boot 3 + Spring AI 1.1.x 上落地,等 Boot 4 升级窗口再跟进。不必为了用 AI 强行升 Boot 4。
顺带一个工程细节:
spring-boot.version要提到父 POM 作单一来源。两个版本必须同步改 ------ 只改一个会造成「starter 要 4.1.1、BOM 钉在 4.0.x」的错配。我踩过这个坑,见坑⑤。
三、六个开关
① 超时 ------ 这一节我原来写的是错的
核心认知仍然成立 :LLM 场景下 connect 与 read 的合理取值差一个量级。
connect:连不上就是连不上,5 秒足够read:要等模型把话说完,慢的时候很久,60 秒 是常见的保守值 ------ 设短了会误杀正常请求
但我按这个认知写的接线是空的。前后写了两版,都被端到端测试证伪。
第一版 :既然 Spring AI 的 spring.ai.openai.timeout 只有一个 Duration,那就下沉到 HTTP 客户端层分开配。
java
// ❌ 一个字节都传不到线上
@Bean
RestClientCustomizer chatTimeoutCustomizer(ChatProperties props) {
HttpClientSettings settings = HttpClientSettings.defaults()
.withTimeouts(props.timeout().connect(), props.timeout().read());
return builder -> builder.requestFactory(
ClientHttpRequestFactoryBuilder.detect().build(settings));
}
这里保留的是当时那一版的原文 (所以还是
connect()/read()两个字段)。最后修完时它们已经合成一项request了,见下面。
为什么不生效 :Spring AI 2.0 的 chat 调用底层是 OkHttp (openai-java SDK),不走 Spring 的 RestClient。改一个没人用的客户端,当然没反应。
第二版 :换成 OkHttp 的定制点 OpenAiHttpClientBuilderCustomizer。这次我用反射确认了基础 OkHttp 客户端上 readTimeout 真的 被设成了期望值 ------ 但请求依然不超时。
追到根因(字节码 + 运行时取证):
scss
OpenAiChatModel.buildRequestOptions()
→ RequestOptions.timeout = OpenAiChatOptions.timeout(默认硬编码 60s)
→ SpringAiOpenAiHttpClient.newCall() 用它的四个访问器
connectTimeout(t.connect()) / readTimeout(t.read()) / ... / callTimeout(t.request())
【把基础 OkHttp 客户端上的超时全部覆盖】
而 com.openai.core.Timeout 的 null 字段会被访问器兜底。实测:
request 设为 |
connect() |
read() |
|---|---|---|
| 5 秒 | 1 分钟(固定) | 5 秒 |
| 1 分钟 | 1 分钟 | 1 分钟 |
所以:connect 恒为 1 分钟且配不了,read 跟随整请求超时。
三种「看着能配」的写法,实测全部无效:
| 写法 | 为什么无效 |
|---|---|
spring.ai.openai.timeout |
只进基础客户端 → 被每请求覆盖 |
spring.ai.openai.chat.timeout |
OpenAiChatProperties.toOptions() 根本不设 timeout |
ChatClient.defaultOptions(...) |
被请求级 .options(...) 整体替换 |
唯一有效的写法 ------ 把 timeout 设进请求级 OpenAiChatOptions ,且必须在同一次 .options(...) 里:
java
private OpenAiChatOptions.Builder optionsFor(String model) {
return OpenAiChatOptions.builder()
.model(model)
.timeout(props.timeout().request()); // ★ 整请求超时
}
代价要说清楚 :connect / read 分开配这个设计做不到 。我最后把两项合成了一项 request ------ 与其留一个不生效的配置项,不如删掉,并在注释里写清楚为什么。
② 重试 ------ 配置写在了不起作用的地方
1.x 时代靠捕获 TransientAiException / NonTransientAiException 判定;2.0 移除了这两个类 ,改成按 HTTP 状态码声明式配置。
于是我按最像官方的写法配了:
yaml
# ❌ 对 OpenAI starter 完全无效
spring.ai.retry:
max-attempts: 3
on-http-codes: [ 429, 500, 502, 503, 504 ]
exclude-on-http-codes: [ 400, 401, 403, 404, 422 ]
这组属性名字最像官方、元数据齐全、spring-ai-autoconfigure-retry 也在 classpath 上。
但它没接到调用链上。 证据在字节码里:
java
OpenAiChatAutoConfiguration.openAiChatModel(
OpenAiCommonProperties, OpenAiChatProperties, ToolCallingManager,
ObjectProvider<ObservationRegistry>, ObjectProvider<MeterRegistry>,
ObjectProvider<ChatModelObservationConvention>,
ObjectProvider<OpenAiHttpClientBuilderCustomizer>)
↑ 参数表里没有 RetryTemplate
spring.ai.retry.* 配的 RetryTemplate bean 是给自己实现 HTTP 调用的模型客户端 用的。而 OpenAI 走 openai-java SDK ------ SDK 内部有自己的一层重试 (RetryingHttpClient),读的是 spring.ai.openai.max-retries。
实测(假上游 + 数上游收到几个请求):
| 配置 | 500 → 500 → 成功 | 400 |
|---|---|---|
只配 spring.ai.retry.* |
上游收到 1 个请求(没重试) | 1 |
配 spring.ai.openai.max-retries=3 |
上游收到 3 个请求 ✓ | 1 ✓ |
这个坑的可怕之处 :配置全对、单元测试全绿、日志无异常 ------ 只是一次都没重试。出事时你会以为自己已经配了。
另一个仍然成立的硬约束 :既然框架(这次是 SDK)已经重试,代码里绝不能再写重试循环 。否则变成 3 × 3 = 9 次,一次抖动被放大成一次小事故。所以 ChatService 里没有一行重试代码。
exclude-on-http-codes 的用意也仍然成立:对 400(参数写错)重试,只是把同一个错误犯 N 遍,还白花 N 倍 token ------ 实测 SDK 自己就不会重试 400。
③ 限流 ------ 为什么放在 Advisor 而不是 Controller
放在 Controller 只能挡 HTTP 入口,挡不住服务间互调。放在 Advisor 则无论谁调用都先过闸。
执行顺序:限流必须最早。
java
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE + 100;
}
理由:限流的意义是在花掉 token 之前挡住。排在后面的话,被限流的请求已经付过费了。
一个容易漏的点 ------ 缺 userId 时不能绕过:
java
Object raw = request.context().get(KEY_USER);
String key = (raw == null || String.valueOf(raw).isBlank()) ? KEY_ANONYMOUS : String.valueOf(raw);
否则调用方只要不传 userId 就绕过了限流。
注意这里用的是常量 KEY_USER 而不是字面量 "userId" ------ 塞值方和取值方必须共用一个来源 。两边各写一遍,拼写一漂移(userId / user-id / uid)就静默退化成一个匿名桶,不报错、测试也全绿。
生产注意两条:
- 本地令牌桶在多实例部署下会各自计数,等于把限流放大 N 倍 → 桶要放 Redis
- 限流异常必须显式映射成 HTTP 429 ,否则变 500,调用方以为"服务挂了"从而触发它自己的重试风暴 ------ 把一次可预期的限流放大成雪崩
④ 脱敏 ------ 默认就该是「不打原文」
LLM 调用最容易犯的合规事故,是把用户原始输入整段打进日志。
技术场景下这段输入里可能有:内部接口路径、表名、SQL 片段、工单号、客户数据。而日志往往进 ELK / 云厂商日志服务 ------ 一旦落盘就收不回来了。
策略三条:
- 默认只记 SHA-256 前 12 位 + 前 N 字符预览 + 长度
- 排障要打原文时,
app.chat.sanitize.log-raw-prompt=true且 日志级别为 DEBUG ------双条件 - 指纹可用于「同一条 prompt 被重复调用几次」的统计,而不泄露内容
测试写法是反向断言 :断言日志里没有那个敏感串,比断言"有某个串"更能防漏:
java
@Test
void doesNotLeakRawPromptByDefault() {
var advisor = new LogSanitizeAdvisor(props(false));
var appender = attach();
advisor.before(req(), null);
assertThat(allMessages(appender)).doesNotContain(SECRET);
}
合规事故的形式恰恰是「本该没有的东西出现了」,所以测试要反过来写。
⑤ 流式
非流式接口要等模型把整段话生成完才返回,首字节延迟等于整段生成时间;流式把首字节提前到第一个 token 生成时。
java
return chatClient.prompt()
.options(optionsFor(model)) // 与同步路径共用,timeout 就在那个方法里
.user(request.message())
// 流式也必须传分桶 key,漏了限流就退化成全站单桶(见 ③)
.advisors(a -> a.param(RateLimitAdvisor.KEY_USER, request.userIdOrDefault()))
.stream()
.content(); // Flux<String>
Controller 侧用 SSE:
java
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> stream(@RequestBody ChatRequest request) {
return chatService.stream(request);
}
两个容易搞错的点:
/stream的入参仍是 JSON ,只是出参 是text/event-stream。很多人以为流式接口要用特殊 Content-Type ------ 不用。但 Postman 里 Body 必须选raw → JSON,选默认的form-data会得到 415(见坑⑦)。- curl 要加
-N------ 关掉输出缓冲,否则 SSE 会被攒着一次性吐出来,看不出流式效果。
一个必须知道的代价 :流式下 usage 往往拿不到(最后一个 chunk 才带,很多实现会丢掉)。所以做成本核算时 流式与非流式必须分开统计 ------ 混在一起会让"平均 token"这个数失去意义。
我的 TokenUsageAdvisor 专门统计 missingUsage 就是为了暴露这一点。
⑥ 换模型
两级设计:
- 默认模型走配置 ------ 改配置即可切,不用发版
- 单次请求可覆盖 ------ 便于灰度
yaml
app.chat.routing:
default-model: deepseek-flash
fallback-model: deepseek-v4-pro
allow-client-override: false # 生产默认关
为什么 allow-client-override 默认关 :开着的话,调用方可以指定任意模型(包括最贵的那个)。这是成本失控的常见入口。
四、Advisor 执行顺序(这段值得单独看)
ini
[客户端]
↓
RateLimitAdvisor order = HIGHEST_PRECEDENCE + 100 ← 最小,最外层
↓
LogSanitizeAdvisor order = LOWEST_PRECEDENCE - 100
↓
TokenUsageAdvisor order = LOWEST_PRECEDENCE - 50 ← 最大,最内层
↓
[ChatModel → HTTP → 供应商]
| 位置 | 谁 | 为什么 |
|---|---|---|
| 最外 | 限流 | 要在花 token 之前挡住。放最外层,被拒的请求连后面两层都不用走 |
| 中间 | 脱敏 | 记录最终发出的 prompt(只留摘要与预览,不打原文) |
| 最内 | 计量 | 它只关心"实际发生了多少次调用",贴得越近越准 |
注意 :执行顺序由各自的 getOrder() 决定,与 Bean 注册顺序无关。
而且 order 小 = 靠外,这个方向容易反:
ini
HIGHEST_PRECEDENCE = Integer.MIN_VALUE ← 取这个方向 = 最外层
LOWEST_PRECEDENCE = Integer.MAX_VALUE ← 取这个方向 = 最内层
名字叫「HIGHEST_PRECEDENCE」容易让人以为它在里层,其实相反。
这个方向配错了也不会报错 ------ 只会让限流排在花钱之后,或者让日志记到别的 Advisor 改过之后的请求。 要确认的话,拿几个不同 order 的假 Advisor 把顺序打出来,比读文档快。
五、13 个踩坑
速览表如下,后面挑几个展开。
| # | 坑 | 类别 |
|---|---|---|
| ① | spring-boot-dependencies BOM ≠ spring-boot-starter-parent |
构建 |
| ② | options() 收 Builder 不收 Options |
API |
| ③ | Spring AI 2.0 移除 TransientAiException,重试改按 HTTP 状态码 |
迁移 |
| ④ | Spring Boot 4 改包名(web.client → restclient / http.client) |
迁移 |
| ⑤ | Spring AI 2.0.1 硬依赖 Boot 4.1.1,两个版本必须同步改 | 版本 |
| ⑥ | 换模型时 temperature 可能被拒绝 |
参数 |
| ⑦ | Postman 默认 form-data → 415 | 工具 |
| ⑧ | 上游 401 默认变 500,把排障方向带偏 | 可观测 |
| ⑨ | 流式路径异常多包一层 + 日志被栈帧淹没 | 可观测 |
| ⑩ | 配置属性被弃用:chat.options.* → 扁平 chat.* |
配置 |
| ⑪ | 超时接线两层都是空的:配置对、Bean 在、反射查也对,就是不超时 | 配置 |
| ⑫ | 限流只接了一半:Advisor 会分桶,但没人告诉它「按谁分」 | 接线 |
| ⑬ | 重试配到了没人消费的 bean 上:spring.ai.retry.* 对 OpenAI starter 无效 |
配置 |
⑪⑫⑬ 是同一类:配置项存在、类型正确、默认值正确、绑定测试全绿 ------ 但就是没接到调用链上。 同一个项目里出现了三种形态:接错层 (⑪ 第一版)、接到空 bean (⑬)、压根没接(⑫)。
坑① spring-boot-dependencies BOM ≠ spring-boot-starter-parent
ruby
[ERROR] 不再支持源选项 5。请使用 8 或更高版本。
[ERROR] Failed to execute goal ...:maven-compiler-plugin:3.1:compile
根因 :spring-boot-dependencies 只是 BOM ------ 它管 <dependencies> 的版本,不管 <pluginManagement> 。插件配置在 spring-boot-starter-parent 里。
用 BOM 而不用 parent 时,插件版本会回退到 Maven super-POM 的远古默认值 (Maven 3.8.2 → compiler-plugin 3.1,不认识 <release>)。
修法(多模块仓库推荐,比继承 starter-parent 更灵活):
xml
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration><release>21</release><parameters>true</parameters></configuration>
</plugin>
</plugins>
</pluginManagement>
同一个根因的第二面 :spring-boot-maven-plugin 也一样 ------ 不声明版本会警告 'build.plugins.plugin.version' ... is missing。
坑⑧ 上游 401 默认变 500
现象 :DEEPSEEK_API_KEY 没配 → 返回 HTTP 500,响应体里一个字都没有。
为什么这是真问题:
- 调用方看到 500 会以为"服务挂了",触发自己的重试风暴 ------ 把一次"key 没配"放大成一次小事故
- 排障方向被带到服务端日志,而问题在环境变量上
日志里能看出真相,但得知道去看:
vbnet
com.openai.errors.UnauthorizedException: 401: Authentication Fails,
Your api key: ****E_ME is invalid
↑ 掩码尾巴 ------ E_ME 就是 sk-REPLACE_ME 占位符
修法 :加 @RestControllerAdvice,按性质分类映射:
| 异常 | HTTP | 为什么 |
|---|---|---|
| Content-Type 不支持 | 415 | 客户端写错 |
| 本地限流 | 429 | 可预期的拒绝 |
| 上游 401 | 401 | 配置问题,不是服务故障 |
| 上游 429 | 429 | 透传 |
| 上游请求非法 | 422 | 如模型不接受某参数 |
| 上游 IO/超时 | 504 | 与"上游返回错误"是两种故障 |
| 其余上游异常 | 502 | 502 = 下游坏了;500 = 我坏了 |
最后一条最容易被忽略:告警系统对 500 和 502 的反应完全不同 ------ 前者要重启我,后者要去看上游。
坑⑨ 日志被栈帧淹没(这条有一半是我自己的锅)
修前 那行写的是 log.debug("...", e) ------ 传了异常对象,logback 就把完整堆栈展开。
实测一次失败(1 次同步 + 1 次流式,假上游一律返 401):
| 修前 | 修后 | |
|---|---|---|
| 日志总行数 | 777 | 578 |
其中栈帧行(at ...) |
676 | 496 |
| 栈里属于本项目的帧 | 2 | 0 |
这组数字是本机复现的,环境同上。重跑方式:把
base-url指向一个一律返 401 的本地端口, 同步 + 流式各打一次,数两段日志的行数。
修法:
java
// 可读诊断用 ERROR,不打异常对象(不传 e 就没有栈)
log.error("上游失败 [{}][{}] {}", phase, model, hint);
// 全栈降到 TRACE ------ 需要时才开
log.trace("上游失败原始栈 [{}][{}]", phase, model, e);
诊断内容形如:
css
上游失败 [stream][deepseek-flash] 检查 DEEPSEEK_API_KEY 是否已设置且有效
(上游回显的 key 掩码若是 ****E_ME,说明用的是配置文件里的占位符 sk-REPLACE_ME)
一行三个信息:哪个路径、哪个模型、下一步做什么。
坑⑩ 配置属性被弃用:chat.options.* → 扁平 chat.*
这是最容易"悄悄写错"的一类坑 ------ 因为旧写法仍然能跑,不报错、只是慢慢失效。
我最初写的是 1.x 的嵌套形式:
yaml
spring.ai.openai.chat.options.model: deepseek-flash # ✗ 已弃用
正确写法(2.0 的扁平形式):
yaml
spring.ai.openai:
api-key: ${DEEPSEEK_API_KEY}
base-url: https://api.deepseek.com
timeout: 60s
chat:
model: deepseek-flash # ← 不是 chat.options.model
temperature: 0.7
怎么自己查(别照抄网上的写法 ------ 大多是 1.x 的):
bash
unzip -p ~/.m2/repository/org/springframework/ai/spring-ai-autoconfigure-model-openai/2.0.1/\
spring-ai-autoconfigure-model-openai-2.0.1.jar META-INF/spring-configuration-metadata.json \
| python3 -c "
import sys,json
for p in json.load(sys.stdin)['properties']:
d = p.get('deprecation')
if d and p['name'].startswith('spring.ai.openai.chat.options.'):
print(p['name'],'→',d.get('replacement'))"
去掉最后那个 startswith 过滤会看到65 个弃用属性(还有 image / embedding / audio / moderation 各一组);这里只关心 chat 的 39 个。
实测输出 39 个 chat.options.* 全部带 @Deprecated + replacement:
erlang
spring.ai.openai.chat.options.model → spring.ai.openai.chat.model
spring.ai.openai.chat.options.temperature → spring.ai.openai.chat.temperature
spring.ai.openai.chat.options.timeout → spring.ai.openai.timeout
...
我用一个测试把它变成构建时断言:
java
@Test
void noDeprecatedKeysInApplicationYml() throws IOException {
Map<String, String> deprecated = deprecatedProperties(); // 扫 classpath 所有元数据
Set<String> used = new HashSet<>(applicationYmlKeys()); // 展平 application.yml
// 求交集,必须为空
}
失败时的输出:
发现 1 个已弃用属性
spring.ai.openai.chat.options.model → 应改为 spring.ai.openai.chat.model
这个测试的价值 :把「记得用新写法」从人的记忆 变成构建时断言。
坑⑪ ⑫ ⑬ ------ 同一类:「配对了,但没接上」
这三个不单独展开了(⑪ 和 ⑬ 已在第三节写透,⑫ 是限流分桶 key 没传下去),但值得把它们的共同形态单独拎出来:
| # | 形态 | 具体表现 |
|---|---|---|
| ⑪ | 接错层 | 用 RestClientCustomizer 去配一条走 OkHttp 的调用链 |
| ⑫ | 压根没接 | Advisor 会按 key 分桶,但没人把 key 塞进去 ------ 全部落进 anonymous 单桶 |
| ⑬ | 接到空 bean | spring.ai.retry.* 配了一个没人消费的 RetryTemplate |
三者的共同点:
- 配置项存在、类型正确、默认值正确
spring-configuration-metadata.json里查得到- 配置绑定测试全绿
- 不报错、不警告、日志干净
为什么它们比「配错了一个值」危险得多:
配错值至少还有症状(行为不对,而且你心里知道该去哪查)。 这三种是静默兼容 ------ 代码看起来完全正确,注释还能写得头头是道, 只有在压测、或真的出事时才会暴露。
唯一的对策 :对每一个「决定行为的开关」,写一条断言实际发生了什么的端到端测试。 不是断言「配置对象上的值」------ 那证明不了任何事。
六、适用边界:什么时候不要用这套骨架
技术方案讲清"不适用"比讲清"适用"更有价值。
| 场景 | 为什么不适用 |
|---|---|
| 只是内部工具、无外部调用方 | 限流/脱敏是过度设计,ChatClient.create() 两行够了 |
| 单实例部署、无并发压力 | 本地令牌桶够用,不必上 Redis |
| 模型调用不在关键路径(如异步批处理) | 流式没有意义,用批处理 API 更合适 |
| 需要严格审计留痕的金融/医疗场景 | 本骨架的脱敏是"不打原文";审计场景需要的是"加密可回溯",方案不同 |
| 存量 Spring Boot 3 系统且短期不能升级 | 走 Spring AI 1.1.x,业务代码能搬,但接线层(超时/重试的写法)要重新看,2.0 的部分能力也用不上 |
最后一条尤其要说:不要为了用 AI 强行升级 Spring Boot 大版本。1.1.x 完全可用,只是少了 2.0 的一些新特性。
七、工程结构与快速开始
bash
01-chat-baseline/
├── src/main/java/com/javalabs/chat/
│ ├── config/
│ │ ├── ChatProperties.java # 六个开关的配置绑定
│ │ ├── ModelRoutingConfig.java # ⑥ 换模型 + ChatClient 装配
│ │ └── AdvisorConfig.java # Advisor 注册
│ ├── advisor/
│ │ ├── RateLimitAdvisor.java # ③
│ │ ├── LogSanitizeAdvisor.java # ④
│ │ └── TokenUsageAdvisor.java # 附赠:成本计量
│ ├── service/ChatService.java # ① 超时下发 + ②⑤⑥
│ └── web/
│ ├── ChatController.java # 同步 + SSE
│ └── ApiExceptionHandler.java # 异常映射(坑⑧)
└── src/test/java/ # 39 个测试(27 纯逻辑 + 12 端到端)
跑起来:
bash
# 39 个测试,约 16 秒,不需要 API Key
mvn test
# 起服务(密钥走环境变量)
export DEEPSEEK_API_KEY=sk-xxx
mvn -pl 01-chat-baseline spring-boot:run
# 同步
curl -X POST localhost:8081/api/chat \
-H 'Content-Type: application/json' \
-d '{"userId":"u1","message":"用一句话解释什么是 Agent 循环"}'
# 流式(-N 关掉 curl 的缓冲,否则看不出流式效果)
curl -N -X POST localhost:8081/api/chat/stream \
-H 'Content-Type: application/json' \
-d '{"userId":"u1","message":"写一段 200 字的说明"}'
测试设计的一个取舍 :Advisor 的 before / after 是纯函数式 的(入参出参都是 record),所以不需要启动 Spring 上下文 就能测 ------ 这部分用例毫秒级跑完,比 @SpringBootTest 快两个数量级。只有「开关是否真的接到调用链上」这一类才需要启容器(见第八节)。
八、验证状态:我说必须真跑的那 4 项,跑完了
按我给自己定的约定,未验证的要标明。
写这篇的过程中我列了 4 项「只做了静态检查、没真跑」的,并写明:
这几项的特点是配错了不报错,只是行为不对 ------ 和坑⑩ 同源。单元测试覆盖不到......必须真跑。
现在真跑了。结果是:4 项里有 2 项是坏的。
| 项 | 怎么验的 | 结果 |
|---|---|---|
app.chat.timeout.* 真的会超时 |
假上游拖 5s,看是否在 1s 被切断 | ❌ 配置对、就是不超时 → 坑⑪ |
spring.ai.retry.* 真的会重试 |
假上游:连续 500 再成功,数上游收到几个请求 | ❌ 一次都没重试 → 改用 spring.ai.openai.max-retries(坑⑬) |
| 限流端到端效果 | 真实 HTTP,看第 N+1 次的状态码 | ✅ 429,且按调用方隔离(坑⑫ 已修) |
| Python 对照的真实调用 | 仍需配 key | ⚠️ 仍未验 |
怎么验的:一个进程内的假上游
用 OkHttp 官方的 MockWebServer:零成本、可造异常(永不响应 / 连续 5xx / 半途断流)、还能读到上游实际收到了什么。
这是本文最值钱的工程实践:那些纯逻辑单元测试为什么全绿却没发现这两个坑?
因为它们验的是「配置对象上的值对不对」:
java
assertThat(props.timeout().request()).isEqualTo(Duration.ofSeconds(1)); // 绿
而这两个坑要问的是「这个值有没有接到真正的调用链上」。中间隔着 options 合并、model 解析、SDK 序列化三层 ------ 每一层都能把它悄悄换掉。
测试数:39 个(新增 12 个端到端),仍然不需要 API Key、约 16 秒跑完。纯逻辑的 Advisor 测试依然不启 Spring,保持毫秒级。
每个端到端测试都配了一条反向断言
否则测试可能「永远绿、永远什么都没证明」------ 比如「原文不入日志」的断言,在 appender 挂错 logger 时也会绿。
| 主断言 | 反向断言(守卫) |
|---|---|
| 上游拖 5s → ~1s 失败 | 上游拖 0.3s → 正常返回 |
| 5xx → 重试 3 次 | 400 → 只打 1 次 |
| 脱敏后日志无密钥 | canary:确实收到了 [sanitized] 行 |
| 流式 → Flux 分 3 次发出 | 非流式 → 只出 1 个结果 |
| 上游报文的 model = 默认值 | 禁覆盖时,客户端传别的模型也被忽略 |
而且我验证了这些测试真的会红 :把 .timeout(...) 拆掉、把流式改成「攒完一次吐」、把 allow-client-override 改掉、把重试配置换回 spring.ai.retry.* ------ 四种破坏各自对应一个测试变红。
能红的守卫才是守卫。
九、写在最后
这一篇里真正的"知识"其实不多 ------ 超时、重试、限流、脱敏、流式、换模型,都是后端的老问题。
新的是它们和 LLM 结合后的具体形态:
- 超时在 LLM 场景下看起来该分开配 (首 token 延迟让
read的合理值比connect大一个量级)------ 但这个版本做不到 :connect恒为 1 分钟且配不了,而三种「看起来能配」的写法全是空转的 - 重试不能再手写 ,因为 SDK 已经做了,重复会放大事故 ------ 但要先确认你配的那组属性真的被消费了
- 脱敏默认就得关,因为 prompt 里可能带着你没意识到的敏感信息
- 500 和 502 的区分突然变得重要,因为上游是不可控的第三方
以及那 13 个坑 ------ 它们不是"技巧",是在你之前有人踩过、并且愿意把报错原文贴出来的记录。
最后三个(⑪⑫⑬)形态一样 :配置是对的,只是没接到调用链上。 这类错误不会报错、不会把测试搞红、日志干净 ------ 只有一条断言「实际发生了什么」的端到端测试能抓住它。
下一篇写 Function Calling 的权限边界 ------ 话题是:
@Tool一加就等于把内部 API 交给了模型,调错了怎么办。