Spring AI

文章目录

    • 前言
    • [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 最容易漏掉的五条变化

这五条是我熬夜一条条核对源码总结出来的,每一条都能让你在生产环境踩个大跟头:

  1. 默认 temperature 0.7 被移除了,现在用各家模型自己的默认值。有的是 1.0,好家伙,输出直接放飞自我。
  2. ChatOptions 里的集合从"空集合"改成 null 了。你写个 .isEmpty() 直接 NPE。
  3. 工具解析 fallback 默认关闭了。模型点名一个没挂载的工具,直接报错。
  4. ToolCallingAdvisor 的 token 统计变成跨轮累加了。你的计费看板一夜之间翻倍。
  5. 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 镜像里出现,而且只在那个工具被真正调用的时候才出现。

三种补救方式,按侵入性从低到高:

  1. 在子类里覆写该方法(哪怕只写 super.xxx())
  2. 在子类上加 @RegisterReflectionForBinding(ParentToolClass.class)
  3. 自己写个 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 上线前必查

  1. 没有引用已移除的模块(azure-openai、openai-sdk、hanadb、oci-genai)
  2. 没有引用 OpenAiApi 类
  3. 没有 copy() / fromOptions() / postProcessSchema 的调用
  4. 非 chat 模型的配置属性去掉了 .options. 前缀
  5. 关键链路的 temperature 都有显式配置
  6. 工具 fallback 做了显式决策(开还是关)
  7. 所有 options 集合的 getter 调用都做了 null 判断
  8. 流式调用的 catch 按 getCause() 找根因
  9. 每个记忆 advisor 调用点都显式传了 conversationId
  10. boundedElastic 容量和预期并发流做了匹配
  11. 网关超时大于供应商超时 × 重试次数
  12. native 测试覆盖了"父类工具"和"tokenizer 词表"

11.2 上线后观察

  1. 对比升级前后的 token 用量,工具场景应该是累加值
  2. 缓存命中率是否上升
  3. 结构化输出有没有缺字段
  4. 工具失败率有没有监控(别指望框架)
  5. 429/5xx 的 P99 耗时是不是接近 19 分钟
  6. 流式并发有没有打满 boundedElastic

这 18 项里,第 5、6、7、13、15 项是"不检查就一定出事、出事后又查不出来"的那五条。时间不够的话,优先做这五项。

12. 最后说两句

2.0 这个版本吧,说是大版本升级,其实更像是一次"大扫除"。之前 1.x 留下的那些历史包袱,那些"先这么用着以后再说"的临时方案,这版本一口气全给你清了。

好处是代码干净了,坏处是你得跟着一起大扫除。

九层破坏性变更里,六层是静默的。能编译、能启动、能跑通冒烟测试,从来不是"正确"的证据。

在 Spring AI 这个世界里,"没有报错"和"做对了"是两件独立的事。前者由框架保证,后者只能由你的测试、你的断言、你的对比测试来保证。

祝你迁移顺利。

P.S. 无意间发现了一个巨牛的人工智能教程,非常通俗易懂,对AI感兴趣的朋友强烈推荐去看看,传送门https://blog.csdn.net/HHX_01

相关推荐
个推技术2 小时前
业务语义本体+确定性查询,个推AIBI这样解决ChatBI口径不一致问题
大数据·人工智能
长弓三石2 小时前
钉钉 Stream + 飞书长连接:不暴露公网端口,让 AI 主动连进企业 IM
人工智能·ai编程
IT智慧客07312 小时前
研究数字孪生的这半年:从 Demo 到新想法
人工智能
F&C嘉准传感器2 小时前
国产接近FSCU1808全密封接近开关:CNC油污工况稳定检测,撞机事故减少
人工智能·安全·目标检测·自动化
人工智能AI技术2 小时前
给AI记忆上户口!从零打造AMP开源协议
人工智能
俊哥V2 小时前
每日 AI 研究简报 · 2026-10-20
人工智能·ai
明月_清风2 小时前
拆解 DeepSeek 最新开源推理框架 DeepInfer:万亿级大模型的高效落地实战
人工智能·后端·deepseek