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

读完这篇你会得到什么

一套可以直接抄走的生产级 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)就静默退化成一个匿名桶,不报错、测试也全绿。

生产注意两条:

  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 被重复调用几次」的统计,而不泄露内容

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

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);
}

两个容易搞错的点:

  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. 单次请求可覆盖 ------ 便于灰度
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 交给了模型,调错了怎么办。


相关推荐
雷一言说1 小时前
Spring AI 2.0 RAG:用 Metadata Filter 做多租户检索隔离
java·spring
程序员大狐狸1 小时前
Ultra X7 358H 笔记本电脑,本地三个大模型接入 DeepSeek Harness 桌面版的过程与方法
人工智能·llm
W***25921 小时前
深度解读Work Agent长程任务拆解与执行机制
人工智能
付威20231 小时前
Provider 不属于 Agent 核心:rpi 的分层理由与收益
人工智能
用户70988330008491 小时前
Anthropic 官方插件仓库登上 GitHub Trending,聊聊 Claude Code 插件体系
人工智能
WeiXin_DZbishe2 小时前
基于springboot装修风格选择系统的设计与开发-计算机毕设【课程设计】77964
java·vue.js·spring boot·后端·python·php·课程设计
草上飞95272 小时前
零知识证明能证明算对了,却证明不了没看见
人工智能·深度学习·llm
生石花2 小时前
《我和AI的28天》Day 3-6:让AI学会点菜,我学会了不被它骗
人工智能
W***25922 小时前
2026企业AI办公工具选型指南:适合团队使用的AI办公产品有哪些
大数据·人工智能