Spring AI 生产级 Chat 接口:6 个开关、13 个坑,其中 2 个开关根本没生效

259 阅读20分钟

读完这篇你会得到什么

一套可以直接抄走的生产级 Chat 接口骨架:六个开关(超时 / 重试 / 限流 / 脱敏 / 流式 / 换模型),每个都有对应的测试。

以及 13 个踩坑的报错原文与修法 —— 包括 Spring AI 2.0 移除 TransientAiException、chat.options.* 配置被弃用、上游 401 被吞成 500 这些文档里不会明说、但一定会遇到的事。

更值钱的是最后三个:配置项存在、类型正确、绑定测试全绿 —— 但根本没接到调用链上。超时和重试两个开关就死在这条缝里。

实测环境(所有结论都基于这个组合,别跨版本照搬):

项版本
JDK21.0.10
Maven3.8.2
Spring Boot4.1.1
Spring AI2.0.1
模型deepseek-flash

一、ChatClient 的入门代码,上生产一定出事

Spring AI 的入门代码只有两行:

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> 里写死:

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 AISpring Boot适用
新线2.0.14.1.1新项目
存量线1.1.83.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 客户端层分开配。

// ❌ 一个字节都传不到线上
@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 真的被设成了期望值 —— 但请求依然不超时。

追到根因(字节码 + 运行时取证):

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.timeoutOpenAiChatProperties.toOptions() 根本不设 timeout
ChatClient.defaultOptions(...)被请求级 .options(...) 整体替换

唯一有效的写法 —— 把 timeout 设进请求级 OpenAiChatOptions,且必须在同一次 .options(...) 里:

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 状态码声明式配置。

于是我按最像官方的写法配了:

# ❌ 对 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 上。

但它没接到调用链上。 证据在字节码里:

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 则无论谁调用都先过闸。

执行顺序:限流必须最早。

@Override
public int getOrder() {
    return Ordered.HIGHEST_PRECEDENCE + 100;
}

理由:限流的意义是在花掉 token 之前挡住。排在后面的话,被限流的请求已经付过费了。

一个容易漏的点 —— 缺 userId 时不能绕过:

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)就静默退化成一个匿名桶,不报错、测试也全绿。

生产注意两条:

  1. 本地令牌桶在多实例部署下会各自计数,等于把限流放大 N 倍 → 桶要放 Redis
  2. 限流异常必须显式映射成 HTTP 429,否则变 500,调用方以为"服务挂了"从而触发它自己的重试风暴 —— 把一次可预期的限流放大成雪崩

④ 脱敏 —— 默认就该是「不打原文」

LLM 调用最容易犯的合规事故,是把用户原始输入整段打进日志。

技术场景下这段输入里可能有:内部接口路径、表名、SQL 片段、工单号、客户数据。而日志往往进 ELK / 云厂商日志服务 —— 一旦落盘就收不回来了。

策略三条:

  1. 默认只记 SHA-256 前 12 位 + 前 N 字符预览 + 长度
  2. 排障要打原文时,app.chat.sanitize.log-raw-prompt=true 且日志级别为 DEBUG ——双条件
  3. 指纹可用于「同一条 prompt 被重复调用几次」的统计,而不泄露内容

测试写法是反向断言:断言日志里没有那个敏感串,比断言"有某个串"更能防漏:

@Test
void doesNotLeakRawPromptByDefault() {
    var advisor = new LogSanitizeAdvisor(props(false));
    var appender = attach();
    advisor.before(req(), null);
    assertThat(allMessages(appender)).doesNotContain(SECRET);
}

合规事故的形式恰恰是「本该没有的东西出现了」,所以测试要反过来写。

⑤ 流式

非流式接口要等模型把整段话生成完才返回,首字节延迟等于整段生成时间;流式把首字节提前到第一个 token 生成时。

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:

@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> stream(@RequestBody ChatRequest request) {
    return chatService.stream(request);
}

两个容易搞错的点:

  1. /stream 的入参仍是 JSON,只是出参是 text/event-stream。很多人以为流式接口要用特殊 Content-Type —— 不用。但 Postman 里 Body 必须选 raw → JSON,选默认的 form-data 会得到 415(见坑⑦)。
  2. curl 要加 -N —— 关掉输出缓冲,否则 SSE 会被攒着一次性吐出来,看不出流式效果。

一个必须知道的代价:流式下 usage 往往拿不到(最后一个 chunk 才带,很多实现会丢掉)。所以做成本核算时 流式与非流式必须分开统计 —— 混在一起会让"平均 token"这个数失去意义。

我的 TokenUsageAdvisor 专门统计 missingUsage 就是为了暴露这一点。

⑥ 换模型

两级设计:

  1. 默认模型走配置 —— 改配置即可切,不用发版
  2. 单次请求可覆盖 —— 便于灰度
app.chat.routing:
  default-model: deepseek-flash
  fallback-model: deepseek-v4-pro
  allow-client-override: false   # 生产默认关

为什么 allow-client-override 默认关:开着的话,调用方可以指定任意模型(包括最贵的那个)。这是成本失控的常见入口。


四、Advisor 执行顺序(这段值得单独看)

[客户端]
   ↓
RateLimitAdvisor     order = HIGHEST_PRECEDENCE + 100   ← 最小,最外层
   ↓
LogSanitizeAdvisor   order = LOWEST_PRECEDENCE - 100
   ↓
TokenUsageAdvisor    order = LOWEST_PRECEDENCE - 50     ← 最大,最内层
   ↓
[ChatModel → HTTP → 供应商]
位置谁为什么
最外限流要在花 token 之前挡住。放最外层,被拒的请求连后面两层都不用走
中间脱敏记录最终发出的 prompt(只留摘要与预览,不打原文)
最内计量它只关心"实际发生了多少次调用",贴得越近越准

注意:执行顺序由各自的 getOrder() 决定,与 Bean 注册顺序无关。

而且 order 小 = 靠外,这个方向容易反:

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 不收 OptionsAPI
③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

[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 更灵活):

<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 没配"放大成一次小事故
  • 排障方向被带到服务端日志,而问题在环境变量上

日志里能看出真相,但得知道去看:

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可预期的拒绝
上游 401401配置问题,不是服务故障
上游 429429透传
上游请求非法422如模型不接受某参数
上游 IO/超时504与"上游返回错误"是两种故障
其余上游异常502502 = 下游坏了;500 = 我坏了

最后一条最容易被忽略:告警系统对 500 和 502 的反应完全不同 —— 前者要重启我,后者要去看上游。

坑⑨ 日志被栈帧淹没(这条有一半是我自己的锅)

修前那行写的是 log.debug("...", e) —— 传了异常对象,logback 就把完整堆栈展开。

实测一次失败(1 次同步 + 1 次流式,假上游一律返 401):

修前修后
日志总行数777578
其中栈帧行(at ...)676496
栈里属于本项目的帧20

这组数字是本机复现的,环境同上。重跑方式:把 base-url 指向一个一律返 401 的本地端口, 同步 + 流式各打一次,数两段日志的行数。

修法:

// 可读诊断用 ERROR,不打异常对象(不传 e 就没有栈)
log.error("上游失败 [{}][{}] {}", phase, model, hint);
// 全栈降到 TRACE —— 需要时才开
log.trace("上游失败原始栈 [{}][{}]", phase, model, e);

诊断内容形如:

上游失败 [stream][deepseek-flash] 检查 DEEPSEEK_API_KEY 是否已设置且有效
  (上游回显的 key 掩码若是 ****E_ME,说明用的是配置文件里的占位符 sk-REPLACE_ME)

一行三个信息:哪个路径、哪个模型、下一步做什么。

坑⑩ 配置属性被弃用:chat.options.* → 扁平 chat.*

这是最容易"悄悄写错"的一类坑 —— 因为旧写法仍然能跑,不报错、只是慢慢失效。

我最初写的是 1.x 的嵌套形式:

spring.ai.openai.chat.options.model: deepseek-flash   # ✗ 已弃用

正确写法(2.0 的扁平形式):

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 的):

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:

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
...

我用一个测试把它变成构建时断言:

@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 单桶
⑬接到空 beanspring.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 的一些新特性。


七、工程结构与快速开始

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 端到端)

跑起来:

# 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 / 半途断流)、还能读到上游实际收到了什么。

这是本文最值钱的工程实践:那些纯逻辑单元测试为什么全绿却没发现这两个坑?

因为它们验的是「配置对象上的值对不对」:

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 交给了模型,调错了怎么办。