文章目录
-
- 前言
- [1. 先给结论:这活儿到底有多坑](#1. 先给结论:这活儿到底有多坑)
-
- [1.1 九层地狱清单](#1.1 九层地狱清单)
- [1.2 最容易漏掉的五条变化](#1.2 最容易漏掉的五条变化)
- [2. 迁移顺序:让编译器先替你打工](#2. 迁移顺序:让编译器先替你打工)
- [3. 坐标层:哪些模块说没就没了](#3. 坐标层:哪些模块说没就没了)
-
- [3.1 被移除的模块清单](#3.1 被移除的模块清单)
- [3.2 Redis 改名:唯一的好消息](#3.2 Redis 改名:唯一的好消息)
- [3.3 十五个模型模块的真相](#3.3 十五个模型模块的真相)
- [4. 配置层:最不像迁移的迁移](#4. 配置层:最不像迁移的迁移)
-
- [4.1 copy() 没了,用 mutate()](#4.1 copy() 没了,用 mutate())
- [4.2 集合从"空"变 null:最阴的一条](#4.2 集合从"空"变 null:最阴的一条)
- [4.3 默认值搬家了](#4.3 默认值搬家了)
- [4.4 .options 前缀扁平化](#4.4 .options 前缀扁平化)
- [5. 调用层:ChatClient 和 ChatModel 的不对称](#5. 调用层:ChatClient 和 ChatModel 的不对称)
-
- [5.1 .options() 只收 Builder](#5.1 .options() 只收 Builder)
- [5.2 conversationId 变成必需的了](#5.2 conversationId 变成必需的了)
- [5.3 BaseAdvisor 把异常包成了 IllegalStateException](#5.3 BaseAdvisor 把异常包成了 IllegalStateException)
- [5.4 所有 Advisor 都在 boundedElastic 上跑](#5.4 所有 Advisor 都在 boundedElastic 上跑)
- [6. 工具调用:行为变更最密集的区域](#6. 工具调用:行为变更最密集的区域)
-
- [6.1 工具调用有上限了](#6.1 工具调用有上限了)
- [6.2 fallback 默认关闭了](#6.2 fallback 默认关闭了)
- [6.3 usage 变成跨轮累加了](#6.3 usage 变成跨轮累加了)
- [6.4 throwExceptionOnError 默认是 false](#6.4 throwExceptionOnError 默认是 false)
- [7. JSON 层迁移:JsonHelper 接管一切](#7. JSON 层迁移:JsonHelper 接管一切)
- [8. 结构化输出:什么算必填变了](#8. 结构化输出:什么算必填变了)
-
- [8.1 required 规则变了三处](#8.1 required 规则变了三处)
- [9. AOT 与 Native Image](#9. AOT 与 Native Image)
-
- [9.1 19 个 Hint 注册器](#9.1 19 个 Hint 注册器)
- [9.2 父类上的 @Tool 不会被发现](#9.2 父类上的 @Tool 不会被发现)
- [9.3 tokenizer 词表资源](#9.3 tokenizer 词表资源)
- [10. 生产调优:线程、超时、重试](#10. 生产调优:线程、超时、重试)
-
- [10.1 一次流式调用跳几次线程](#10.1 一次流式调用跳几次线程)
- [10.2 三层重试叠加的算术](#10.2 三层重试叠加的算术)
- [10.3 记忆截断:三个数](#10.3 记忆截断:三个数)
- [10.4 一个 HealthIndicator 都没有](#10.4 一个 HealthIndicator 都没有)
- [11. 生产检查清单](#11. 生产检查清单)
-
- [11.1 上线前必查](#11.1 上线前必查)
- [11.2 上线后观察](#11.2 上线后观察)
- [12. 最后说两句](#12. 最后说两句)
P.S. 无意间发现了一个巨牛的人工智能教程,非常通俗易懂,对AI感兴趣的朋友强烈推荐去看看, 传送门https://blog.csdn.net/HHX_01
前言
前面的文章我们把 Spring AI 从里到外扒了一遍,今天聊点实在的------你把它搬进自己系统里,到底会发生什么。
说真的,迁移这事儿吧,跟相亲差不多。表面上看条件都还行,真过日子了才发现,对方把牙膏从中间挤,你从尾巴挤,这日子就过不下去了。
迁移 Spring AI 2.x 也是一个道理。编译报错那都是小事,编译器大哥比你负责多了。真正要你命的,是那些不声不响就变了的东西------默认值偷偷换了,集合从空的变成 null 了,异常类型给你换了个马甲。
这些玩意吧,能编译,能启动,冒烟测试也能过。然后呢?然后就在你上线后的第三周,生产环境突然炸给你看。你说上哪说理去?
1. 先给结论:这活儿到底有多坑
我跟你说,2.0 这个版本啊,它不是来给你加功能的,它是来给你"做减法"的。之前 1.x 留的那些历史包袱,这版本一口气全给你清了。
就好比你租了个房子住了两年,房东突然说要重新装修,把你那些"临时凑合用"的家具全扔了。你还不能生气,因为房东说"这是为了你好"。
我把这版的破坏性变更分成了九层,你听听这个数量级:
1.1 九层地狱清单
第一层,坐标层。就是 Maven 依赖名字变了,有的模块直接没了。这个最好办,编译的时候就报错,编译器大哥会喊你。
第二层,类层。什么类没了,什么方法删了,编译也能抓出来。
第三层,属性层。配置文件里的前缀变了,启动的时候多多少少会给你点警告。
到这儿为止,你还觉得挺顺利是吧?
重点来了------从第四层开始,就全是静默的了。
第四层默认值层:默认 temperature 0.7 没了。
第五层集合语义层:之前给你空集合的,现在给你 null。
第六层行为层:token 统计突然翻倍了,工具调用有上限了。
第七层枚举层:枚举常量拆了。
第八层异常层:你 catch 的异常类型根本不对。
第九层 schema 层:什么算必填变了,模型开始漏字段了。
九层里有六层不报错。你品品,你细品。
所以啊,迁移验收标准绝对不能是"能启动就完事了"。那跟考驾照只考了科目一是一样的------你以为你会开车了,上路才发现连转向灯在哪都不知道。
1.2 最容易漏掉的五条变化
这五条是我熬夜一条条核对源码总结出来的,每一条都能让你在生产环境踩个大跟头:
- 默认 temperature 0.7 被移除了,现在用各家模型自己的默认值。有的是 1.0,好家伙,输出直接放飞自我。
ChatOptions里的集合从"空集合"改成 null 了。你写个.isEmpty()直接 NPE。- 工具解析 fallback 默认关闭了。模型点名一个没挂载的工具,直接报错。
ToolCallingAdvisor的 token 统计变成跨轮累加了。你的计费看板一夜之间翻倍。- OpenAI 工具 schema 的
strict默认从 true 变 false 了。结构化输出开始随机抽风。
这五条吧,就像你家水管突然改了走向。你以为水管还在原来的地方,结果一开水龙头,墙里的电线先冒烟了。
2. 迁移顺序:让编译器先替你打工
很多人迁移上来就从文档第一页读到最后一页,读完脑子更乱了。正确做法是啥?按失败时机从早到晚来。
啥意思呢?就是先改那些编译就能报错的,再改那些启动才报错的,最后才处理那些静默变化的。
① 换版本号
↓ 编译错(一大堆)
② 删旧类引用:OpenAiApi → OpenAiSetup、copy() → mutate()
↓ 编译错(少量)
③ 改配置属性:去 .options、改 Redis 前缀
↓ 启动错 / warn
④ 补 conversationId
↓ 运行错
⑤ 对比默认值:temperature、strict、fallback
↓ 静默
⑥ 对比行为:usage 累加、工具上限
↓ 静默
⑦ 对比 schema:required 规则变化
↓ 静默
⑧ 最后才搞 native image(AOT)
为什么把 AOT 放最后?因为 AOT 构建是最严格的静态检查。你前面那些"运行期才暴露"的问题,到 AOT 这一步全给你提前暴露出来。总比上线了再炸强吧?
这就跟体检似的,你先做个最便宜的血常规,再做贵的 CT。别上来就做核磁,那钱花得冤。
3. 坐标层:哪些模块说没就没了
3.1 被移除的模块清单
2.0 这版本砍模块砍得是真狠,我列几个你感受下:
spring-ai-azure-openai------ 直接移除,合并进spring-ai-openai了spring-ai-openai-sdk------ 移除spring-ai-oci-genai------ 迁出到 Oracle 自己仓库了spring-ai-hanadb-store------ 移除,HANA 向量存储不内置了
注意啊,azure-openai 这个不是"功能消失了",是"合并了"。Azure 相关的能力被吸收进 OpenAI 的 Options 里了。所以你别满世界找新类名,把旧属性映射到新前缀就行。
3.2 Redis 改名:唯一的好消息
整个坐标变更里,就 Redis chat memory 这一处官方做了兼容层。旧的 artifact 还能通过传递依赖跑,旧的配置前缀也有委托。
xml
spring-ai-autoconfigure-model-chat-memory-redis
spring-ai-autoconfigure-model-chat-memory-repository-redis
properties
# 之前
spring.ai.chat.memory.redis.*
# 之后
spring.ai.chat.memory.repository.redis.*
但你别高兴太早。为啥只有这一处做了兼容?说明其他的都不打算兼容了。而且文档明说了,未来版本这些废弃的东西都会删掉。
这就像你对象说"我最后一次原谅你"。你以为这是宽容,其实这是最后通牒。
3.3 十五个模型模块的真相
现在 models 目录下就剩 15 个模块了。但我跟你说个有意思的事------15 个模块里,有 8 个是没有 chat 能力的。
所以你引了 spring-ai-stability-ai,然后纳闷为啥注入不到 ChatModel------因为人家根本就不是干这个的,人家是做图像生成的。
4. 配置层:最不像迁移的迁移
这一节是整章最"坑"的部分。为啥?因为它不涉及包名,不涉及类名,全是语义变化。但它导致的线上问题最多。
4.1 copy() 没了,用 mutate()
之前你想改个 options 字段,是这么写的:
java
// 1.x 时代
OllamaChatOptions options = originalOptions.copy();
options.setFoo("...");
现在不行了。options 类变成严格不可变的了,copy() 方法直接删了。替代方案是 mutate():
java
// 2.x 时代
OllamaChatOptions options = originalOptions.mutate()
.foo("...")
.build();
但真正的问题不是这个方法名变了,而是"不可变"带来的连锁反应。1.x 里大家常这么写:
java
// 2.x 会抛 UnsupportedOperationException
chatOptions.getToolCallbacks().add(myToolCallback);
chatOptions.getStopSequences().add("\n\n");
现在 getToolCallbacks() 返回的是不可修改集合,你 add 一下直接抛异常。而且这异常是运行期才发生的,只在你"真的想改"的那条分支上才暴露。
迁移的时候记得全仓库搜一下:
.getToolCallbacks().add、.getStopSequences().add、.getCustomHeaders().put。搜出来的全是雷。
4.2 集合从"空"变 null:最阴的一条
这条我必须单独拎出来说,因为它真的太坑了。
之前 1.x 的时候,你调 getStopSequences(),哪怕没设置过,人家也给你返回一个空集合,不是 null。所以你这么写是安全的:
java
// 1.x:安全
if (options.getStopSequences().isEmpty()) {
...
}
现在呢?现在可能返回 null。你同样的代码,直接 NPE。
java
// 2.x:NPE!因为可能是 null
if (options.getStopSequences().isEmpty()) {
...
}
// 正确写法
List stop = options.getStopSequences();
if (stop == null || stop.isEmpty()) {
...
}
为啥要这么改?因为在 HTTP 请求层,空数组和"不发这个字段"是两回事。有的供应商收到空数组直接报 400。所以用 null 表达"没设置",请求里就不发这个字段了。
这修复本身是对的,但它把所有假设"getter 不返回 null"的代码全给坑了。
就好比你去奶茶店点单,以前店员默认给你加个吸管(空集合),现在店员看你没说要吸管就不给了(null)。你接过杯子就喝,结果烫着了舌头。你说这怨谁?
4.3 默认值搬家了
之前默认值散落在各个 Properties 类里,现在全搬到 Options 构造器里了。Properties 类里的默认配置属性都被移除了。
啥意思呢?就是你不能再靠"属性类里写了默认值"来判断实际默认值了。想知道某个模型的默认 maxTokens 是多少,得去看它的 builder 字段初始化,而不是去 Properties 类里找。
4.4 .options 前缀扁平化
所有非 chat 模型的配置属性,之前用 .options 段包着,现在全拍平了:
properties
# 之前
spring.ai.openai.embedding.options.model=text-embedding-3-small
# 之后
spring.ai.openai.embedding.model=text-embedding-3-small
注意啊,chat 模型不在这个范围内。chat 的 .options 早在 1.x 后期就扁平化过了。所以你会看到一种奇怪的不一致:chat 已经扁平了,embedding 2.0 才扁平。
5. 调用层:ChatClient 和 ChatModel 的不对称
5.1 .options() 只收 Builder
现在 .options() 方法接受的是 ChatOptions.Builder,不是已经 build 好的实例。
java
// 之前(编译不过了)
ChatOptions opts = AnthropicChatOptions.builder()
.maxTokens(100).temperature(0.7).build();
chatClient.prompt("讲个笑话").options(opts).call().content();
// 现在
chatClient.prompt("讲个笑话")
.options(AnthropicChatOptions.builder().maxTokens(100).temperature(0.7))
.call().content();
但最坑的是------这个限制只适用于 ChatClient。你直接调 ChatModel.call(Prompt) 的时候,必须传已经 build 好的完整实例,而且完全不合并默认值。
啥意思呢?就是同一个 Options 对象,走 ChatClient 和走 ChatModel,结果可能不一样。走 ChatClient 的时候,你没设置的字段会用模型默认值补齐;走 ChatModel 的时候,你没设置的字段就是 null,直接发给供应商。
这就好比你去两家同品牌的店,一家会主动给你配围裙,另一家什么都不给。你以为是同一家,服务标准应该一样,结果不是。
5.2 conversationId 变成必需的了
之前你不传 conversationId,框架会给你用一个默认的 "default"。听起来挺贴心是吧?
但你想想,在多用户系统里,所有没传 conversationId 的请求共享同一个会话------用户 A 的历史会被用户 B 看到。这不是贴心,这是串话事故。
所以 2.x 直接把这个默认值删了,不传就报错。这虽然粗暴,但确实是在逼你面对问题。
java
// 之前
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory)
.conversationId("my-session").build())
.build();
// 现在
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.build();
chatClient.prompt().user("你好!")
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "my-session"))
.call().content();
5.3 BaseAdvisor 把异常包成了 IllegalStateException
这条是源码里挖出来的,文档里根本没写。
BaseAdvisor 在处理流式响应的时候,会把任何错误都包成 IllegalStateException("Stream processing failed", ...)。
这意味着啥呢?你之前写的:
java
try {
chatClient.prompt().user(q).stream().content().blockLast();
} catch (WebClientResponseException.TooManyRequests e) {
// ← 永远不进来
}
你以为你能抓到 429 限流异常?人家早就给你套了一层壳,变成 IllegalStateException 了。你得从 getCause() 里才能找到原始异常。
java
// 正确写法
catch (IllegalStateException e) {
Throwable cause = e.getCause();
if (cause instanceof WebClientResponseException.TooManyRequests tooMany) {
// 处理限流
}
}
生产环境做错误分类的时候,必须递归找根因,不能按最外层异常类型判断。不然你监控里全是 "Stream processing failed",根本分不清是限流了还是鉴权失败了。
5.4 所有 Advisor 都在 boundedElastic 上跑
我数了一下,代码里至少四处在不同层级用了同一个调度器:Schedulers.boundedElastic()。
这意味着一次流式调用,会在这个弹性线程池里跳来跳去,跳个五六次。每次跳都是一次线程切换。
boundedElastic 的默认容量是 10 × CPU 核数。8 核机器就是 80 个并发流就满了。
更麻烦的是 ThreadLocal 丢失。你在 advisor 之间传 MDC traceId、传 SecurityContext,传不过去------因为换线程了。
这就像你坐地铁换乘,每换一条线就要安检一次。安检倒是不麻烦,问题是你安检完了发现包落在上一条线了。
6. 工具调用:行为变更最密集的区域
6.1 工具调用有上限了
现在每轮工具调用有限制了:单个工具最多调 40 次,整个轮次最多 150 次。
这意味着啥呢?长任务在第 41 次同名工具调用的时候会被拒绝。之前没这个限制的时候,模型能跟个愣头青似的同一个工具调几百次,调用费哗哗往外流。
6.2 fallback 默认关闭了
之前模型可以调用你容器里注册的任意一个工具 callback bean。哪怕你没在这次请求里显式挂载,它也能找到。
现在呢?默认关了。模型点名一个"全局注册但没挂到本次请求"的工具,直接失败。
这其实是个安全修复。之前的白名单太宽了------你容器里有个 deleteUser 工具,模型是能调到的。这多吓人。
6.3 usage 变成跨轮累加了
之前你看 token 用量,只看到最后一次调用的。现在呢?把工具调用循环里每一次内部调用的 token 都累加起来了。
举个例子:一次问答,模型调了 3 次工具。1.x 你看到的是最后一次调用的 token(很小),2.0 你看到的是 4 次调用的总和(可能是 5 到 10 倍)。
所以迁移后如果发现"成本突然涨了 5 倍",先别怀疑供应商涨价------很可能是之前一直少算了,现在算对了。
这就像你每个月看工资条,以为自己花了三千,年底一算账发现花了三万。不是你突然多花了,是之前记账只记了现金支出,没记信用卡。
6.4 throwExceptionOnError 默认是 false
工具抛异常的时候,默认不会让你的调用失败,而是把错误信息变成一条 tool 结果回给模型,让模型"自己想办法"。
听起来挺智能是吧?但在生产里有两个问题:
第一,监控看不到工具失败率。你的 try/catch 不触发,指标里也没有 error,只有模型输出变得"奇怪"------它开始道歉、换个工具、或者干脆编一个答案。
第二,模型可能把错误当成数据。比如你的 getOrderStatus 抛了 TimeoutException,错误文本回给模型,模型转头就对用户说"您的订单状态是 TimeoutException"。
生产环境建议把它设成 true,让工具失败可见。或者保留 false,但在工具实现内部自己打点记录。
7. JSON 层迁移:JsonHelper 接管一切
2.0 把之前散在各个工具类里的 JSON 操作,全集中到 JsonHelper 里了。
java
// 默认
JsonHelper jsonHelper = new JsonHelper();
// 自定义 mapper
JsonMapper myMapper = JsonMapper.builder().addModule(new JavaTimeModule()).build();
JsonHelper customHelper = new JsonHelper(myMapper);
之前的 ModelOptionsUtils 里那 8 个 JSON 静态方法全删了。JsonParser 废弃了,McpJsonParser 直接删了。
这部分是纯机械迁移,量很大但没什么技术含量。直接全局替换就行,别写兼容层,那只会让你在新代码里继续用旧习惯。
8. 结构化输出:什么算必填变了
这一节虽然只有几十行变更,但它改的是"什么算必填"------直接影响模型输出能不能反序列化。
8.1 required 规则变了三处
| 场景 | 1.x | 2.0 |
|---|---|---|
| Kotlin 可选参数 | 进 required | 不进 |
| @JsonProperty(required=false) | 进 required(被忽略) | 不进 |
| @JsonProperty 没写 required | 进 required | 不进 |
| 普通字段(无注解) | 进 required | 进 |
第三行最容易踩坑。很多人写 @JsonProperty("user_name") 只是为了改个名,从没想过它同时改变了"必填"语义。现在呢,这个注解一加,字段就不是必填了。
java
public record UserProfile(
@JsonProperty("user_name") String userName, // 2.0:不再必填!
String email // 2.0:仍然必填
) {}
后果链条是这样的:required 变少 → 模型认为可以省略 → 返回的 JSON 缺字段 → Jackson 反序列化把缺失字段填 null → 你的业务代码拿到 null → NPE。整个过程没有一条异常。
这就像你点外卖备注"不要香菜",结果店家连菜都不给你放了。你说备注里写的是"不要香菜",没说不要菜啊?店家说:你既然特意提了香菜,说明其他都是可选的。
9. AOT 与 Native Image
这部分是最坑的,因为在 JVM 上完全复现不了。只有打了 native 镜像才会炸。
9.1 19 个 Hint 注册器
Spring AI 对 GraalVM 的支持全是代码化的。没有手写的 reflect-config.json 之类的东西,一切都靠 RuntimeHintsRegistrar 在构建期生成。
我数了一下,全仓库有 19 个 Registrar、14 个 aot.factories 文件。
9.2 父类上的 @Tool 不会被发现
这条是我认为最坑的 AOT 问题,没有之一。
ToolBeanRegistrationAotProcessor 探测 @Tool 注解的时候,用的是 getDeclaredMethods(beanClass)------只返回本类声明的方法,不含继承来的。
啥意思呢?就是你把 @Tool 写在父类方法上,AOT 处理器发现不了。于是父类不会被注册反射信息,native 镜像里调用这个工具的时候,直接 NoSuchMethodException。
这个坑在 JVM 上完全不存在,只在 native 镜像里出现,而且只在那个工具被真正调用的时候才出现。
三种补救方式,按侵入性从低到高:
- 在子类里覆写该方法(哪怕只写 super.xxx())
- 在子类上加
@RegisterReflectionForBinding(ParentToolClass.class) - 自己写个 RuntimeHintsRegistrar 手动注册
9.3 tokenizer 词表资源
jtokkit 的 tokenizer 词表文件(cl100k_base.tiktoken)需要注册成资源。不注册的话,第一次 token 计数的时候会抛 NPE------不是 ClassNotFoundException,是莫名其妙的 NPE。
为啥资源 hints 比反射 hints 更容易漏?因为反射失败通常有类名提示,资源失败往往就是一个莫名其妙的 NPE,你查半天都不知道问题出在哪。
10. 生产调优:线程、超时、重试
10.1 一次流式调用跳几次线程
我给你数一下,一次"带记忆 + 带工具"的流式调用,线程路径大概是:
调用方线程
→ BaseAdvisor.publishOn(boundedElastic#1)
→ ChatModelStreamAdvisor.publishOn(boundedElastic#2)
→ MessageChatMemoryAdvisor.publishOn(boundedElastic#3)
→ ToolCallingAdvisor.subscribeOn(boundedElastic#4)
→ AnthropicChatModel.subscribeOn(boundedElastic#5) // 整段流期间持有
→ OkHttp dispatcher
至少 5 次线程切换,最多同时占 2 个 boundedElastic worker。8 核机器上 80 个并发流就满了。
10.2 三层重试叠加的算术
这个数字我算了一下,挺吓人的:
- Spring RetryTemplate:默认 10 次
- SDK 自带重试(OpenAI):3 次
三层都开着的时候,一次故障的实际 HTTP 请求次数是:10 × 4 = 40 次。
时间上呢?Spring 的退避是 2s、10s、50s、250s...10 次累计约 19 分钟。而每次 attempt 内部还有 4 次 HTTP 请求。所以"一次故障打 19 分钟"都是保守估计。
这就是为啥"网关都超时了,你的服务还在重试"。你网关 30 秒就断了,你的服务这边还在那第三次重试第四次请求呢。
推荐方案:只留 Spring 层的重试。因为只有 Spring 层的重试能被 Micrometer 观测到,SDK 内部的重试对你完全透明------你只看到"一次调用花了 3 秒",不知道里面试了 4 次。
10.3 记忆截断:三个数
关于聊天记忆,有三个不同的默认值:
- MessageWindowChatMemory:默认 20 条消息(进 prompt 的数量)
- RedisChatMemoryRepository:默认 100 条/会话(存储上限)
- RedisChatMemoryConfig:默认 1000 条/会话(另一个存储上限)
三个数,两次截断。长对话先进 prompt 时被窗口截到 20 条,存储里再被截到 100 条。而且两个 Redis builder 的默认值还不一样(100 vs 1000)。
这就跟你公司报销似的:部门经理批了 2000,财务说最多报 1000,你领导说这次最多给你报 500。每层都有自己的规矩,你最后能拿到多少,得一层一层算。
10.4 一个 HealthIndicator 都没有
我全仓库搜了一遍,Spring AI 一个 Actuator HealthIndicator 都没实现。
啥意思呢?就是模型连不上,你的 /actuator/health 依然显示 UP。K8s 探针觉得你服务很健康,流量继续往你这打,然后你就 502 了。
生产环境你必须自己写一个。但写的时候有个坑------健康检查本身要花钱。每次探针调用都发一次模型请求,K8s 探针 10 秒一次,就是每分钟 6 次计费调用。
所以要么缓存结果,要么默认关闭,要么只检查"配置是否齐全"而不真发请求。
11. 生产检查清单
最后给你整理一份可执行的清单,上线前对照着过一遍:
11.1 上线前必查
- 没有引用已移除的模块(azure-openai、openai-sdk、hanadb、oci-genai)
- 没有引用
OpenAiApi类 - 没有
copy()/fromOptions()/postProcessSchema的调用 - 非 chat 模型的配置属性去掉了
.options.前缀 - 关键链路的 temperature 都有显式配置
- 工具 fallback 做了显式决策(开还是关)
- 所有 options 集合的 getter 调用都做了 null 判断
- 流式调用的 catch 按
getCause()找根因 - 每个记忆 advisor 调用点都显式传了 conversationId
- boundedElastic 容量和预期并发流做了匹配
- 网关超时大于供应商超时 × 重试次数
- native 测试覆盖了"父类工具"和"tokenizer 词表"
11.2 上线后观察
- 对比升级前后的 token 用量,工具场景应该是累加值
- 缓存命中率是否上升
- 结构化输出有没有缺字段
- 工具失败率有没有监控(别指望框架)
- 429/5xx 的 P99 耗时是不是接近 19 分钟
- 流式并发有没有打满 boundedElastic
这 18 项里,第 5、6、7、13、15 项是"不检查就一定出事、出事后又查不出来"的那五条。时间不够的话,优先做这五项。
12. 最后说两句
2.0 这个版本吧,说是大版本升级,其实更像是一次"大扫除"。之前 1.x 留下的那些历史包袱,那些"先这么用着以后再说"的临时方案,这版本一口气全给你清了。
好处是代码干净了,坏处是你得跟着一起大扫除。
九层破坏性变更里,六层是静默的。能编译、能启动、能跑通冒烟测试,从来不是"正确"的证据。
在 Spring AI 这个世界里,"没有报错"和"做对了"是两件独立的事。前者由框架保证,后者只能由你的测试、你的断言、你的对比测试来保证。
祝你迁移顺利。
P.S. 无意间发现了一个巨牛的人工智能教程,非常通俗易懂,对AI感兴趣的朋友强烈推荐去看看,传送门https://blog.csdn.net/HHX_01