Spring AI 0.8.1 升级到 1.0.0:资本市场估值问答服务的 Starter 迁移与兼容性处理
上周三下午四点,风控组的同事把一张截图甩进群:内部估值问答页面又吐了半句就断流,用户看到的最后一行是「根据您提供的 3 年期 IRS 现金流」。这不是第一次了。
项目背景
我们维护的是一套面向机构客户的利率与外汇风险对冲辅助系统。业务侧高频问题就三类:某笔 IRS 在当前曲线下的 DV01 是多少、当前对冲比例偏离目标多少、某个利率情景下保证金缺口多大。技术栈是 Spring Boot 3.4.5 + JDK 21.0.5,估值引擎是团队自己用 Java 重写的 OIS 折现,检索侧用 PostgreSQL 16.4 加 pgvector 0.7.4。整个后端 7 个人,其中 2 人负责这条 AI 问答链路。
2025 年初第一次接入大模型能力时,选的是当时 GA 的 Spring AI 0.8.1,坐标spring-ai-openai-spring-boot-starter。跑了一年半,2026 年国庆前排期做 1.0.0 的迁移。写这篇文章是 2026-10-06,1.0.0 这条线在我们生产环境已经稳定运行了几个月,数据是真实压测出来的。
旧版本卡在哪
三个痛点,按严重程度倒着说。
最要命的是依赖树冲突。0.8.1 时代spring-ai-openai-spring-boot-starter和spring-ai-spring-boot-starter同时存在,传递依赖里拉进了两个spring-ai-core。mvn dependency:tree一跑,0.8.1 和 0.8.0 并排躺在那里。本地开发机类加载顺序凑巧对,灰度机器启动直接NoSuchMethodError。这种问题排查一次要半天。
其次是工具注册。0.8.1 的函数回调走FunctionCallbackWrapper加@Bean全局注册,本质是单例。我们做的是多租户 SaaS,A 客户不该看到 B 客户的持仓工具,当时靠一段 200 行的 if-else 在调用前过滤工具名白名单。难维护,且容易漏。
第三个是流式响应丢尾包。工具调用完成后,最后一个 chunk 的 finishReason 处理在 0.8.1 的 advisor 链路里存在竞态,1000 次压测丢 37 次,前端 SSE 收到的是半句话。这个只能靠业务侧兜底拼接,很别扭。
选型决策
摆在面前的路有三条。
第一条是留在 0.8.1,只把依赖冲突手工 掉。成本最低,但工具 API 的老化和流式缺陷没法根治,等于把技术债再滚一年。
第二条是绕过 Spring AI,直接用 OpenAI 官方 Java SDK 自己封装一层。控制力最强,但对话记忆、向量检索抽象、Advisor 链路全得自己写,按我们两个人的投入,至少三周起步,而且以后换模型供应商要重写。
第三条是升级到 Spring AI 1.0.0。这个版本 2025 年 5 月 GA,是第一个 API 稳定版,Starter 命名收敛成spring-ai-starter-model-和spring-ai-starter-vector-store-,工具回调统一到ToolCallback,基线要求 Spring Boot 3.4.x。我们主工程本来就在 3.4.5 上,迁移面可控。
![]()
选了第三条。核心判断是:这一年半我们真正依赖的是 ChatClient、ChatMemory、VectorStore、Advisor 这四层抽象,而 1.0.0 恰好把它们全定型了。
1.0.0 改了什么
对比表先放上来,这是迁移时最常翻的一页。
| 维度 | Spring AI 0.8.1 | Spring AI 1.0.0 | 迁移动作 |
|---|---|---|---|
| OpenAI Starter 坐标 | spring-ai-openai-spring-boot-starter | spring-ai-starter-model-openai | 改 artifactId,删掉手写版本号 |
| 版本管理 | 各模块单独声明版本 | spring-ai-bom:1.0.0 | dependencyManagement 中 import |
| 工具回调 | FunctionCallback / FunctionCallbackWrapper | ToolCallback / MethodToolCallbackProvider | 重写注册 Bean |
| 消息取文本 | AssistantMessage#getContent() | AssistantMessage#getText() | 全局替换,靠编译期兜底 |
| 对话记忆 | 直接 new ChatMemory 实现 | MessageWindowChatMemory + ChatMemoryRepository | 补 Repository Bean |
| 向量检索请求 | new SearchRequest(...) | SearchRequest.builder() | 改构造方式 |
| Spring Boot 基线 | 3.2.x | 3.4.x | 同步对齐 |
pgvector 的 Starter 同样改名,从spring-ai-pgvector-store-spring-boot-starter变成spring-ai-starter-vector-store-pgvector。这个点我在迁移时差点漏掉,因为它不在 OpenAI 那条依赖链上。
迁移步骤
第一步:先锁 BOM,再换坐标
顺序很重要。先换坐标再锁 BOM,中间会有一段时间依赖树是脏的,编译报错信息会很难读。
```xml
org.springframework.ai
spring-ai-bom
1.0.0
pom
import
org.springframework.ai
spring-ai-starter-model-openai
org.springframework.ai
spring-ai-starter-vector-store-pgvector
```
顺手把仓库配置里spring-milestones的地址删掉。GA 版本从中央仓库拉,留着 milestone 源反而可能拉到同版本的快照,我们就踩过一次。
第二步:配置属性对齐
配置项主体结构没变,但spring.ai.vectorstore.pgvector下的子键名有几个调整。
```yaml
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: ${CHAT_MODEL_NAME}
temperature: 0.1
embedding:
options:
model: ${EMBEDDING_MODEL_NAME}
vectorstore:
pgvector:
index-type: HNSW
distance-type: COSINE_DISTANCE
dimensions: 1536
initialize-schema: false
```
模型名走环境变量注入,不写死在仓库里。原因是估值问答对模型版本的敏感度很高,换个快照模型回答风格就可能漂,这类变更必须走配置中心而不是发版。
第三步:重写工具注册
这是改动量最大的一块。
```java
@Configuration
class ToolConfig {
@Bean
ToolCallbackProvider riskToolProvider(RiskQueryService svc) {
return MethodToolCallbackProvider.builder()
.toolObjects(new RiskTools(svc))
.build();
}
}
@Component
class RiskTools {
private final RiskQueryService svc;
RiskTools(RiskQueryService svc) {
this.svc = svc;
}
@Tool(description = "查询某笔利率互换在当前曲线下的 DV01,入参为交易编号")
Dv01Result queryDv01(
@ToolParam(description = "交易编号,如 IRS-2026-0001") String tradeId) {
return svc.dv01(tradeId);
}
}
```
调用侧的关键变化是工具可以按请求传入了,不再依赖全局 Bean:
```java
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem("你是利率风险助手,只基于检索到的估值数据作答")
.build();
String answer = client.prompt()
.user(question)
.advisors(a -> a
.param(ChatMemory.CONVERSATION_ID, tenantId + ":" + sessionId)
.param(QuestionAnswerAdvisor.FILTER_EXPRESSION,
"tenant == '" + tenantId + "'"))
.toolCallbacks(tenantToolCallbacks(tenantId))
.call()
.content();
```
流式路径把.call().content()换成.stream().content()即可,返回Flux。我们那 200 行 if-else 白名单,到这里压缩成 30 行的tenantToolCallbacks。
第四步:灰度验证
新旧两条链路并行跑了 5 天。做法是把同一批问题双写,用 0.8.1 的答案和新答案做语义相似度比对,相似度低于 0.85 的捞出来人工看。总共 1200 条问题,人工复核 46 条,其中 3 条确认是新版本工具描述文案变更导致的调用差异,改完描述就对齐了。
![]()
兼容性坑
Advisor 的全局注册这里要单独说。官方文档推荐用ChatClient.builder().defaultAdvisors(...)统一挂载,理由是代码干净。但在我们多租户场景下反而更糟——租户过滤条件必须随请求变化,挂在全局就意味着每个请求进来还得再覆盖一次,白白多一层参数合并,而且容易写出「忘了覆盖」的静默 bug。我们最后放弃了全局 Advisor,改成每次prompt()时显式传。这个方案不算优雅,但可读性明显更好。
另一个坑是AssistantMessage#getContent()换成getText()。方法还在,只是标记了废弃,所以编译不报错,运行时返回 null 的地方才暴露。建议迁移时直接全仓替换,别留着。
效果数据
流式丢尾包:压测 1000 次,迁移前丢 37 次(3.7%),迁移后 0 次。
首 token 延迟 P95(含一次工具调用):1.24s 降到 1.11s。提升不算大,主要来自 advisor 链路少了一层包装。
依赖树:spring-ai-core从重复出现 2 次收敛到 1 次,打进可执行 jar 的第三方 jar 数量从 214 降到 197。
启动时间:8.9s 降到 8.2s,本地 MacBook Pro M3 上测的。
工具白名单代码量:200 行降到 30 行。
感悟
如果重来,两件事会调整。一是 Starter 改名单独开一个 PR,不要和 API 适配混在一起——那次 diff 有 1400 多行,评审的人根本看不出哪里是真正的逻辑改动。二是先写兼容性测试再动代码,我们那 46 条人工复核的问题,如果提前有一组固定回归问题集,能省掉大半时间。
#后端 #Java #SpringBoot #SpringAI #版本迁移
你在实际项目中有遇到类似问题吗?欢迎在评论区分享你的经验和解决方案。