GA 在下个月,我们这个月就已经在迁了
Spring AI 2.0 的 GA 定在 2026 年 6 月。我们在 5 月初就拉了 2.0.0-RC1 做迁移演练,到今天两周,9 个服务里有 6 个跑通了。
为什么不等 GA?因为 1.x 到 2.0 的破坏性变更比想象中多,我们评估过,等 GA 再动手会赶不上业务节奏。而且 RC 阶段 API 基本冻结,风险可控。这篇是完整的迁移清单和踩坑记录。
前提:2.0 强绑 Boot 4,这是最大的隐性成本
这是所有变更里影响最大的一条,但很容易被忽略——大家看发布说明都盯着 API 变化。
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>2.0.0-RC1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
$ mvn dependency:tree | grep -E "spring-core|spring-context"
[INFO] +- org.springframework:spring-core:jar:7.0.1:compile
[INFO] +- org.springframework:spring-context:jar:7.0.1:compile
Spring Framework 7 意味着必须 Boot 4.0,Boot 4 意味着 JDK 17+ 和 Jakarta EE 11。所以「升 Spring AI 2.0」这件事的实际工作量 = Spring AI 迁移 + Boot 4 迁移。
我们 9 个服务里的 6 个本来就是 Boot 4(年初新建 AI 平台时定的基线,见我之前那篇迁移评估),剩下 3 个卡在 Boot 3.5,暂时不动。这也验证了当时分档决策是对的——那 6 个服务现在迁移成本极低。
破坏性变更清单
按「踩到的时间顺序」排列,每条都标了我们实际的处理方式。
一、包名和 artifact 拆分
2.0 把一些模块的 artifact id 改了,尤其是向量存储部分拆得更细。我们用到的:
| 1.x artifact | 2.0 artifact | 说明 |
|---|---|---|
spring-ai-pgvector-store | spring-ai-pgvector-store | 未变,但包名改了 |
spring-ai-spring-cloud-bindings | 移除 | 我们没用 |
spring-ai-model-chat-memory-jdbc | spring-ai-jdbc-store | 合并了多种 JDBC 存储 |
spring-ai-mcp-server-webmvc | spring-ai-mcp-server | 不再按传输方式拆包 |
包名变化:
// 1.x
import org.springframework.ai.chat.memory.ChatMemoryRepository;
import org.springframework.ai.vectorstore.SearchRequest;
// 2.0
import org.springframework.ai.memory.ChatMemoryRepository; // chat.memory → memory
import org.springframework.ai.vectorstore.search.SearchRequest; // 挪到 search 子包
这个改动最烦的是 IDE 的自动导入会在你不知情的情况下引错包。我们的做法是先编译,把报错按文件分组,逐个处理,不要图快用全局替换。
二、Advisor 链重构(改动最大)
这是 2.0 的核心变化。1.x 里的 CallAroundAdvisor 和 StreamAroundAdvisor 是两个接口,同一个逻辑要写两遍。2.0 统一成了一个。
看我们 1.x 的代码:
// 1.x:同一个逻辑要写两份,流式那份还要处理 Flux 操作
public class CostRecordingAdvisor implements CallAroundAdvisor, StreamAroundAdvisor {
@Override
public ChatClientResponse adviseCall(
ChatClientRequest req, CallAroundAdvisorChain chain) {
return record(chain.nextAroundCall(req));
}
@Override
public Flux<ChatClientResponse> adviseStream(
ChatClientRequest req, StreamAroundAdvisorChain chain) {
return chain.nextAroundStream(req)
.doOnNext(this::record) // 流式下只能在最后记
.doOnError(e -> recordError(e));
}
@Override public int getOrder() { return 100; }
@Override public String getName() { return "cost"; }
}
2.0 版本:
// 2.0:一个接口,同时处理同步和流式
public class CostRecordingAdvisor implements CallAdvisor {
@Override
public ChatClientResponse adviseCall(
ChatClientRequest req, CallAdvisorChain chain) {
long start = System.nanoTime();
try {
ChatClientResponse resp = chain.nextCall(req);
record(req, resp, nanos(start));
return resp;
} catch (Exception e) {
recordError(req, e, nanos(start));
throw e;
}
}
@Override public int getOrder() { return 100; }
@Override public String getName() { return "cost"; }
/** 流式是否也走这个 Advisor,默认 true */
@Override public boolean isStreamEligible() { return true; }
}
看起来是简化了,但有个隐藏的坑:2.0 里流式响应下 adviseCall 只会被调用一次(在流开始之前),拿不到完整的 token 用量。因为流式场景下 token 统计是在流结束时才汇总的。
我们第一版迁移完,发现流式请求的 cost 全是 0。排查后改成了这样:
public class CostRecordingAdvisor implements CallAdvisor {
@Override
public ChatClientResponse adviseCall(
ChatClientRequest req, CallAdvisorChain chain) {
ChatClientResponse resp = chain.nextCall(req);
// 流式场景:装饰 Flux,在流结束时才能拿到完整用量
if (resp.isStreaming()) {
return resp.mutate().stream(resp.stream()
.doOnComplete(() -> recordFromStream(ctx))
.doOnError(this::recordError));
}
record(resp);
return resp;
}
}
这个问题在 RC1 的文档里没有明确说明,我们是在测试环境发现的——所有流式接口的成本统计归零,而同步接口正常。这个信号很明显,一查就定位到了。
另外 getOrder() 的语义变了。1.x 里 order 越小越靠内层(越靠近模型),2.0 里统一成了越小越先执行(更像 Filter 链)。我们有个 Advisor 因为 order 语义搞反,导致成本统计漏掉了缓存命中的请求。
三、ChatMemory API 变化
1.x 的 ChatMemory 接口有三个方法(add/conversationId/get/clear),2.0 加了窗口和容量控制,并且 MessageWindowChatMemory 的构造方式变了。
// 1.x
ChatMemory memory = MessageWindowChatMemory.builder()
.chatMemoryRepository(jdbcRepo)
.maxMessages(50)
.build();
// 2.0
ChatMemory memory = MessageWindowChatMemory.builder()
.chatMemoryRepository(jdbcRepo)
.maxMessages(50)
.conversationWindowStrategy(WindowStrategy.TOKEN_AWARE) // 新增
.maxTokens(32_000) // 新增
.build();
新增的 TOKEN_AWARE 策略挺实用——它按 token 数而不是消息条数来裁剪窗口。我们原来按 50 条裁,遇到长文档对话时 50 条就是 6 万 token。改成 TOKEN_AWARE + maxTokens 32000 之后,长对话的 token 消耗降了 34%。
还有个变化:clear(conversationId) 现在返回 boolean,且语义变成了「是否存在并已删除」。
四、结构化输出的 API 调整
// 1.x
OutputConverter<Product> converter = new BeanOutputConverter<>(Product.class);
String fmt = converter.getFormat();
String text = chatClient.prompt().user(u -> u.text(prompt + "\n" + fmt)).call().content();
Product p = converter.convert(text);
// 2.0
Product p = chatClient.prompt()
.user(prompt)
.entity(Product.class) // 直接链式调用
.call()
.entity();
2.0 的写法简洁很多,内置的校验和重试也更完善。但有个行为变化要注意:1.x 里转换失败会抛异常,2.0 默认会重试一次(用错误信息作为反馈让模型修正)。这个重试对我们是好事(成功率从 94.2% 升到 98.1%),但如果你的场景对延迟敏感,要记得关掉:
spring:
ai:
structured-output:
max-retry-attempts: 0 # 默认 1
include-error-in-prompt: true
五、配置项重命名
一批配置 key 改了,我们遇到的:
| 1.x | 2.0 |
|---|---|
spring.ai.chat.client.enabled | spring.ai.chat.client.observations.enabled |
spring.ai.vectorstore.pgvector.dimensions | spring.ai.vectorstore.pgvector.dimensions(未变,但默认值从 1536 改为必填) |
spring.ai.retry.max-attempts | spring.ai.chat.observations.include-prompt 拆出来了 |
spring.ai.mcp.client.request-timeout | spring.ai.mcp.client.timeout.request |
最坑的是 dimensions 变成必填。我们原来依赖默认值,升级后启动直接报错:
Description:
Parameter 2 of method pgVectorStore required a value of type 'int' that could not be found.
Action:
Consider defining a bean or setting spring.ai.vectorstore.pgvector.dimensions
这个报错信息完全没提是哪个配置项,我们查了 20 分钟。建议升级前先把所有 Spring AI 相关的配置列出来逐个核对。
另外,如果有配置没被识别,Boot 4 会直接启动失败(2.x 时代只是警告)。我们加了这个配置让未识别的配置报错更醒目:
spring:
configuration:
on-unrecognized-property: fail # 默认就是 fail,但显式写出来提醒团队
六、MCP 相关
MCP 部分变化不算大,但有两个点:
- 默认传输方式改为 STREAMABLE。1.x 默认是 SSE,如果 Client 端没显式指定,升级后会连不上(服务端不支持 SSE 的话)。我们正好前一个月做了无状态化改造(见我之前那篇),服务端已经是 Streamable,所以这块没影响;
@McpToolParam的required属性默认值变了。1.x 默认 true,2.0 会推断(基本类型和有默认值的为 false)。我们有一个工具因为参数变成非必填,模型开始不传,导致 NPE。
// 显式声明,别依赖推断
@McpTool(name = "applyRefund")
public RefundResult applyRefund(
@McpToolParam(description = "订单号", required = true) String orderNo,
@McpToolParam(description = "退款金额,不填则全额退", required = false) BigDecimal amount
) { ... }
迁移清单(我们实际用的)
把两周的工作整理成了清单,下次迁移直接照着走:
- 确认 Boot 4 基线。JDK 17+、Jakarta EE 11、第三方 starter 兼容性。这步不通过就不要开始;
- 先跑通编译。改 POM、改 import、改 API,不管行为对不对,先让它编译过。我们这步花了 1.5 天(9 个服务);
- 逐个核对配置项。把所有
spring.ai.*配置列出来,对着 2.0 的文档核一遍。别指望启动报错能告诉你; - 重点回归流式接口。Advisor 在流式下的行为变化是最容易出问题的地方,尤其是任何依赖「完整响应」的逻辑(成本统计、审计、token 计量);
- 跑评测集。我们有 640 条标注用例,迁移前后各跑一次,逐项对比;
- 灰度观察 72 小时。重点看成本指标和错误率——API 变了但没报错的情况最危险。
第 5 步救了我们一次。迁移后评测结果显示:整体准确率 88.9% → 88.7%(正常波动),但「工具调用次数」这个指标从平均 3.2 涨到 3.9。没有准确率下降,但有成本上升。查下来是 Advisor 的 order 语义变化,导致一个「工具结果缓存」的 Advisor 执行顺序错了,缓存命中率从 41% 掉到 6%。
这个问题如果只看准确率指标是发现不了的,而它会让成本涨 22%。强烈建议迁移时必须对比成本类指标,不只是质量类指标。
「AI 原生运行时」这个说法是什么意思
2.0 发布材料里反复提到「AI native runtime」。我一开始觉得是营销话术,读了源码之后理解了一些。
核心是观察点(observation)的深度整合。2.0 里每一次模型调用、每一次工具调用、每一次向量检索都会自动产生完整的 OTel span,并且带上一套标准化的语义约定(semantic conventions)。
# 2.0 默认产出的 span 结构
gen_ai.chat 2,840ms
├── gen_ai.chat.prompt.build 12ms
├── gen_ai.tool.execution (queryOrders) 310ms
├── gen_ai.chat.rag.retrieval 84ms
├── gen_ai.chat.rag.rerank 142ms
└── gen_ai.chat.model.call 2,180ms
attributes:
gen_ai.request.model: qwen-max
gen_ai.usage.input_tokens: 4820
gen_ai.usage.output_tokens: 396
gen_ai.response.finish_reasons: stop
1.x 里我们得自己加 Advisor 去埋点,2.0 是内建的。这意味着不需要写代码就能拿到完整的 AI 调用链路。
对我们最大的价值是成本归因。之前我们的成本统计是自己写的 Advisor,每个服务都要配一遍;现在变成了一个配置开关:
management:
observations:
enabled: true
tracing:
sampling:
probability: 0.1 # 采样 10%,成本敏感
metrics:
tags:
tenant: ${TENANT_ID} # 自定义 tag 用于多租户归因
# 自动产出这些指标
gen_ai_client_token_usage_total{model,type,tenant} # 分租户的 token 消耗
gen_ai_client_operation_seconds{model,operation} # 延迟分布
gen_ai_tool_execution_seconds{tool,status} # 工具调用
我们把原来自己写的成本 Advisor 删了,代码少了 180 行。这是迁移里唯一让我觉得「赚到了」的部分。
值不值得现在动
说说我的判断。
如果你的服务已经在 Boot 4 上:值得动。我们 6 个服务的平均迁移成本是 2.5 人日(含回归和灰度),收益是内建的观测能力和更好的结构化输出。风险可控。
如果你还在 Boot 3.x:先算 Boot 4 的迁移账。我们那 3 个留在 3.5 的服务,评估下来 Boot 4 迁移本身要 5~7 人日,加上 Spring AI 迁移总共 8~10 人日。除非你急需 2.0 的特性,否则建议等到有业务需求触碰时一起做。
RC 阶段用不用:我们的做法是「新服务用 RC,存量服务等 GA」。逻辑是存量服务的迁移一旦出问题影响面大,不值得为了早一个月冒这个险;新服务没有包袱,用 RC 可以先积累经验。
还有一条:等 2.0.1 再上核心服务。这是我们的老规矩,任何大版本我们都等第一个补丁版。历史上这个规矩救过我们三次。
还没解决的问题
迁移过程中有两个问题我们还没解决,记录一下:
- Advisor 的
isStreamEligible()语义不够清晰。我们有个 Advisor 在流式下需要拿到完整内容才能工作(内容是安全检查),目前只能绕过框架自己在业务层处理。RC1 的文档里没有好的解法; - 多轮工具调用的可观测性还有缺口。当一次请求里模型连续调了 3 次工具,span 结构是平铺的,看不出「第 2 次工具调用是基于第 1 次的结果」这个因果关系。我们目前靠自己加的
step_indextag 弥补。
这两个都提了 issue,看 GA 版本会不会改。
先到这
《Spring AI 2.0 GA 实战:从 1.x 迁移到 AI 原生运行时》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。