Administrator
发布于 2026-05-20 / 470 阅读
6

Spring AI 2.0 GA 实战:从 1.x 迁移到 AI 原生运行时

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 artifact2.0 artifact说明
spring-ai-pgvector-storespring-ai-pgvector-store未变,但包名改了
spring-ai-spring-cloud-bindings移除我们没用
spring-ai-model-chat-memory-jdbcspring-ai-jdbc-store合并了多种 JDBC 存储
spring-ai-mcp-server-webmvcspring-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 里的 CallAroundAdvisorStreamAroundAdvisor 是两个接口,同一个逻辑要写两遍。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.x2.0
spring.ai.chat.client.enabledspring.ai.chat.client.observations.enabled
spring.ai.vectorstore.pgvector.dimensionsspring.ai.vectorstore.pgvector.dimensions(未变,但默认值从 1536 改为必填)
spring.ai.retry.max-attemptsspring.ai.chat.observations.include-prompt 拆出来了
spring.ai.mcp.client.request-timeoutspring.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 部分变化不算大,但有两个点:

  1. 默认传输方式改为 STREAMABLE。1.x 默认是 SSE,如果 Client 端没显式指定,升级后会连不上(服务端不支持 SSE 的话)。我们正好前一个月做了无状态化改造(见我之前那篇),服务端已经是 Streamable,所以这块没影响;
  2. @McpToolParamrequired 属性默认值变了。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
) { ... }

迁移清单(我们实际用的)

把两周的工作整理成了清单,下次迁移直接照着走:

  1. 确认 Boot 4 基线。JDK 17+、Jakarta EE 11、第三方 starter 兼容性。这步不通过就不要开始;
  2. 先跑通编译。改 POM、改 import、改 API,不管行为对不对,先让它编译过。我们这步花了 1.5 天(9 个服务);
  3. 逐个核对配置项。把所有 spring.ai.* 配置列出来,对着 2.0 的文档核一遍。别指望启动报错能告诉你;
  4. 重点回归流式接口。Advisor 在流式下的行为变化是最容易出问题的地方,尤其是任何依赖「完整响应」的逻辑(成本统计、审计、token 计量);
  5. 跑评测集。我们有 640 条标注用例,迁移前后各跑一次,逐项对比;
  6. 灰度观察 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 再上核心服务。这是我们的老规矩,任何大版本我们都等第一个补丁版。历史上这个规矩救过我们三次。

还没解决的问题

迁移过程中有两个问题我们还没解决,记录一下:

  1. Advisor 的 isStreamEligible() 语义不够清晰。我们有个 Advisor 在流式下需要拿到完整内容才能工作(内容是安全检查),目前只能绕过框架自己在业务层处理。RC1 的文档里没有好的解法;
  2. 多轮工具调用的可观测性还有缺口。当一次请求里模型连续调了 3 次工具,span 结构是平铺的,看不出「第 2 次工具调用是基于第 1 次的结果」这个因果关系。我们目前靠自己加的 step_index tag 弥补。

这两个都提了 issue,看 GA 版本会不会改。

先到这

《Spring AI 2.0 GA 实战:从 1.x 迁移到 AI 原生运行时》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。

参考