Spring Boot 4 与 Spring AI 2.0 深度集成:ChatClient、Advisor 链与 MCP(源码级实战)

文末附「1.1 → 2.0 迁移清单」(必改/建议改/可以不动三档)。文中版本号、构件名、配置前缀、API 签名均给出可复核出处。


写在前面

去年用 Spring AI 1.1 做过一版 demo,跑得挺好。这次直接把依赖版本从 1.1.8 改成 2.0.1,想着"大版本升级,改改包名就行"。

编译直接炸了一屏:

text 复制代码
找不到符号: 方法 defaultToolCallbacks(ToolCallback...)
找不到符号: 类 ChatClientCustomizer
找不到符号: 类 QuestionAnswerAdvisor
找不到符号: 类 OpenAiApi

更麻烦的是那种能编译过、但启动后行为变了 的:ToolCallingManager 明明注入了,模型却不再自动执行工具。

这批问题的根因,都不是"改了个名字",而是 Spring AI 2.0 做了一次架构级的职责重划:

  • 工具调用循环(tool calling loop)从 ChatModel 内部搬到了 Advisor 链上;
  • ChatClient 明确拆成阻塞与响应式两条路径;
  • 每个模型适配层改为委托厂商官方 SDK;
  • 模块按 Spring Boot 4 的方式重新切分

这篇就沿着这四条线,把 Spring AI 2.0 拆开看。

内容速览

  • 版本别被网上说法带偏:Spring AI 2.0 按 Boot 4.1.1 / Framework 7.0.9 / Java 17 构建
  • 两个常见误传:starter 改名发生在 1.0 不是 2.0;chat-memory"改名"两处都错
  • BOM 从 179 个构件变成 169 个,且两个版本都有"悬空条目"(BOM 声明但仓库 404)
  • ChatClient 阻塞/响应式在 API 签名层面强制分离,返回 Flux 的方法阻塞路径上彻底没有
  • 工具调用循环从模型内部搬到 Advisor 链上:可拦截 = 可治理,次数上限由此才做得出来
  • 没有 ChatClient Bean,只有 ChatClient.Builder(prototype);@Tool Bean 不会被自动扫描
  • 模型底层换成厂商官方 SDK(OpenAI 用 OkHttp)------上一篇的 InetAddressFilter 管不到 LLM 流量
  • 与 Boot 4 内核四处咬合:JSpecify、Jackson 3、Framework 7 Retry、AOT
  • MCP 核心在外部 SDK,Spring AI 只做集成层
  • 实战:AI 代码审查助手的完整落地
  • 1.1 → 2.0 迁移清单(必改/建议改/可以不动)

一、版本先对齐:Spring AI 2.0 到底配哪个 Boot

网上关于"Spring AI 2.x"的说法很乱,这一节全部用可复核的硬证据。

1.1 发布时间线(Maven Central 文件时间戳)

text 复制代码
2.0.0-M1   2025-12-11
2.0.0 GA   2026-06-12
1.1.8      2026-06-12   ← 与 2.0.0 GA 同一天发布
2.0.1      2026-08-20   ← 当前最新

(数据来自 repo1.maven.orgspring-ai-bom 各版本 POM 的 last-modified 响应头。)

这里有个关键、但容易被忽略的事实:1.1.82.0.0 GA 是同一天发布的 。2.0 不是"取代 1.1",而是双轨并行------两条线服务的是不同的 Boot 代际。

1.2 版本对应关系

Spring AI 2.0.1 的 starter POM 里,Boot 版本是硬编码 写死的(Spring AI 用 flatten-maven-plugin 打平了 POM,没有 <parent>、也没有 <properties>),直接看依赖:

xml 复制代码
<!-- spring-ai-starter-model-openai-2.0.1.pom -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-restclient</artifactId>
  <version>4.1.1</version>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-webclient</artifactId>
  <version>4.1.1</version>
</dependency>

spring-ai-starter-mcp-clientspring-ai-starter-model-chat-memory 则依赖 spring-boot-starter:4.1.1spring-ai-autoconfigure-mcp-server-common 里能看到 spring-web:7.0.9;spring-ai-model 的 sources jar 中 META-INF/MANIFEST.MF 写着 Java-Version: 17,字节码 major version 也是 61(Java 17)。

汇总:

维度 Spring AI 2.0.1 说明
Spring Boot 4.1.1 硬编码在各 starter POM 中
Spring Framework 7.0.9 见 MCP autoconfigure POM
Java 基线 17 MANIFEST + 字节码版本双向确认
坐标 org.springframework.ai:spring-ai-bom:2.0.1 用 BOM 统一管版本

注意一个 patch 版本差异:Spring AI 2.0.1 依赖的是 Boot 4.1.1 ,本系列仓库是 4.1.0 。实际使用不会有问题,但如果你在 dependencyManagement 里锁死 4.1.0,要知道 Spring AI 是按 4.1.1 构建的。

选型结论(修正一个常见误解):不存在"1.1 稳定版 vs 2.x 前瞻版"的选择题了。截至 2026-09,正确的说法是:

  • 还在 Spring Boot 3.5 上的项目 → 留在 Spring AI 1.1.x(仍在维护,1.1.8 与 2.0.0 同日发布);
  • 已经上 Spring Boot 4.x 的项目 → 直接上 Spring AI 2.0.x,这是 GA。

二、模块重构:BOM 从 179 个构件变成 169 个

Spring AI 2.0 跟 Boot 4 一样做了模块化拆分。把两个 BOM 拉下来做集合差集,结论一目了然。

下面清单每一行,我都用 repo1.maven.org 的 POM 请求核过:能拉到的标"已发布";只在 BOM 里有声明、中央仓库却查不到的,标 ⚠️------两个 BOM 都有这种"悬空条目",完整清单与验证方法见 2.4。

2.1 只在 1.1.8 中存在(2.0 已不存在)

text 复制代码
spring-ai-advisors-vector-store              ← 最后由 2.0.0-M8 发布,未进入 GA
spring-ai-model-chat-memory                  ← 1.1.8 的 BOM 里是"悬空条目"(见下)
spring-ai-openai-sdk                         ← 最后由 2.0.0-M4 发布,未进入 GA
spring-ai-spring-cloud-bindings              ← 最后由 2.0.0-M6 发布,未进入 GA
spring-ai-autoconfigure-model-azure-openai   ← 未进入 GA
spring-ai-autoconfigure-model-zhipuai        ← 未进入 GA
spring-ai-autoconfigure-model-minimax        ← 未进入 GA
spring-ai-autoconfigure-model-huggingface    ← 未进入 GA
spring-ai-autoconfigure-model-oci-genai      ← 未进入 GA
spring-ai-starter-model-vertex-ai-gemini     ← 未进入 GA

关于这张表,有两个流传很广但都不准确的说法:

  • "spring-ai-model-chat-memory 在 2.0 改名成了 spring-ai-starter-model-chat-memory" ---两处都错。① spring-ai-starter-model-chat-memory1.1.8 就已存在 (两个版本都能 200 拉到),是并存而非替代;② spring-ai-model-chat-memory 压根没有可用构件------1.1.8 的 BOM 以 ${project.version} 声明了它,但中央仓库 404(见 2.4)。
  • "这批厂商模块迁出到了厂商自己的仓库" ---无法证实。能确证的只是"它们不在 2.0.0 GA 的 BOM 里":spring-ai-azure-openaispring-ai-zhipuai 其实都发布到了 2.0.0-M4 ,只是没进 GA;而 com.azure.spring.ai 下目前只有 Cosmos DB 构件,并没有 azure-openai。稳妥的表述是:它们在 GA 前被移出了 Spring AI 的统一发布范围。想用的话,先去 Maven Central 查目标构件在 2.0.x 上还有没有版本。

2.2 只在 2.0.1 中存在(2.0 新增)

text 复制代码
mcp-spring-webmvc / mcp-spring-webflux       ← MCP 官方传输层实现(已发布)
spring-ai-tool-search-advisor                ← 工具太多时的检索式工具选择(已发布)
spring-ai-tool-search-tool                   ← 已发布
spring-ai-tool-search-tool-lucene            ← ⚠️ 仅 BOM 条目,未发布
spring-ai-tool-search-tool-vectorstore       ← ⚠️ 仅 BOM 条目,未发布
spring-ai-vector-store-advisor               ← 已发布
spring-ai-redis-semantic-cache               ← 语义缓存(已发布)
spring-ai-s3-vector-store                    ← 已发布
spring-ai-bedrock-knowledgebase-store        ← 已发布
spring-ai-google-genai-image                 ← 已发布

2.3 starter 命名:一个流传很广的误传

网上常见的说法是"2.0 把 spring-ai-openai-spring-boot-starter 改名成了 spring-ai-starter-model-openai"。这个说法在 1.1 → 2.0 这个语境下是错的。

把各版本 BOM 拉出来对比:

版本 openai starter 名称
1.0.0-M6 spring-ai-openai-spring-boot-starter
1.0.0 GA spring-ai-starter-model-openai
1.0.9 spring-ai-starter-model-openai
1.1.0 ... 1.1.8 spring-ai-starter-model-openai
2.0.1 spring-ai-starter-model-openai

改名发生在 1.0.0 GA,不是 2.0。 两个 BOM 里都不存在 任何 *-spring-boot-starter 形式的构件(1.1.8 与 2.0.1 各查出 0 个)。只要你用的是 1.1.x,starter 坐标一个都不用动------别被那些"迁移清单"误导去改这里。

2.0 里真正跟 BOM 增删有关的变更是这两条:

变更 1.1.8 2.0.1
MCP 服务端公共包 spring-ai-starter-mcp-server-common(悬空条目,见下) 已从 BOM 移除
MCP 传输层 (无) 新增 mcp-spring-webmvc / mcp-spring-webflux

最后一行有个细节:mcp-spring-webmvc / mcp-spring-webfluxgroupId 是 org.springframework.ai,但 artifactId 不带 spring-ai- 前缀 ------这是为了让它们跟 MCP 官方 SDK 的 io.modelcontextprotocol.sdk 命名风格保持一致。

2.4 两个 BOM 里的"悬空条目"(附验证方法)

1.1.8 和 2.0.1 都存在"BOM 里正常声明、Maven Central 上却没有构件目录(404)"的坐标:

条目 1.1.8 2.0.1
spring-ai-model-chat-memory BOM 有声明 / 构件 404 条目本身已移除
spring-ai-starter-mcp-server-common BOM 有声明 / 构件 404 条目本身已移除
spring-ai-tool-search-tool-lucene --- BOM 有声明 / 构件 404
spring-ai-tool-search-tool-vectorstore --- BOM 有声明 / 构件 404

验证方法(排除"网络抖动导致误判"):对同一版本、同样方式发 POM 请求,对照组全部返回 200 ------spring-ai-starter-mcp-serverspring-ai-starter-mcp-server-webmvcspring-ai-starter-model-openaispring-ai-tool-search-tool

结论:别把 BOM 里的坐标等同于可用构件。 在做自动化依赖升级(从 BOM 反推坐标写进 pom.xml)时,这会让 pom.xml 引入一个永远解析不到的依赖。另外 2.0 没有根治这个问题 ------它清掉了旧的两个,自己又引入了两个(tool-search-tool-lucene / -vectorstore)。


三、ChatClient:阻塞与响应式,这次真的分开了

3.1 接口签名层面的强制分离

ChatClient(spring-ai-client-chat,@since 1.0.0)里有两个"终点"方法:

java 复制代码
interface ChatClientRequestSpec {
    CallResponseSpec call();      // 阻塞
    StreamResponseSpec stream();  // 响应式
}

两个返回类型的差异是结构性的,不是风格差异:

java 复制代码
interface CallResponseSpec {
    <T> @Nullable T entity(Class<T> type);
    <T> @Nullable T entity(ParameterizedTypeReference<T> type);
    @Nullable ChatResponse chatResponse();
    @Nullable String content();
    <T> ResponseEntity<ChatResponse, T> responseEntity(Class<T> type);
    // ...
}

interface StreamResponseSpec {
    Flux<ChatClientResponse> chatClientResponse();
    Flux<ChatResponse> chatResponse();
    Flux<String> content();
}

关键点:CallResponseSpec 上没有任何一个方法返回 Flux 你不可能"不小心"在阻塞路径上拿到一个流然后忘了订阅------编译器直接拦住你。这是 2.0 在 API 层面做的类型安全加固。

3.2 结构化输出:entity() 的两个新开关

entity() 的多参数重载接受一个 EntityParamSpec 消费者:

java 复制代码
<T> @Nullable T entity(Class<T> type, Consumer<EntityParamSpec> entityParamSpecConsumer);

EntityParamSpec 上有两个新能力(见 ChatClient.javaCallResponseSpec 的 javadoc 引用):

  • useProviderStructuredOutput() ---使用模型厂商原生 的结构化输出能力(比如 OpenAI 的 response_format: json_schema),而不是靠 prompt 里塞 schema 再解析文本;
  • validateSchema() ---对返回的 JSON 做 schema 校验。

源码 javadoc 里对 validateSchema() 的描述值得原样引用(EntityParamSpec 定义在 ChatClient.java 内部):

java 复制代码
/**
 * Validates the model's JSON response against the entity schema and retries with
 * the error feedback on failure, up to {@code maxRepeatAttempts} times (default:
 * 3). Streaming is not supported.
 */
EntityParamSpec validateSchema();

两件事:schema 校验失败会自动带错误反馈重试,默认 3 次 ;并且 schema 校验只在阻塞路径可用,流式路径拿不到。后者是一条必须在设计阶段就知道的约束。

同一段 javadoc 还给了 useProviderStructuredOutput() 的两个已知限制,属于"踩过坑才写得出来"的信息:Ollama 上带推理模式的模型(如 qwen3:8b)可能返回纯文本导致反序列化失败;OpenAI 的 Structured Outputs API 不接受顶层 JSON 数组 schema,所以用它请求 List<T> 会直接失败,得包一层容器 record。

3.3 2.0 废弃清单(升级时会直接编译失败)

这些不是"建议改",是 forRemoval = true:

1.x API 状态 2.0 替代
Builder.defaultToolCallbacks(ToolCallback...) @Deprecated(since="2.0.0", forRemoval=true) Builder.defaultTools(Object...)
Builder.defaultToolCallbacks(List<ToolCallback>) 同上 defaultTools(Object...)
Builder.defaultToolCallbacks(ToolCallbackProvider...) 同上 defaultTools(Object...)
ChatClientCustomizer @Deprecated(since="2.0.0", forRemoval=true) ChatClientBuilderCustomizer
ToolCallAdvisor @Deprecated(since="2.0.0", forRemoval=true) ToolCallingAdvisor
OpenAiApi(整个类) 已删除 官方 OpenAI Java SDK 的 OpenAIClient
QuestionAnswerAdvisor 已删除 RetrievalAugmentationAdvisor

为什么是 defaultTools(Object...) 而不是 defaultToolCallbacks(...)? 看它的 javadoc 就明白了------这是一个"异构装箱"方法,参数里可以有五种东西:

java 复制代码
Builder defaultTools(Object... tools);
  1. ToolCallback ---直接注册;
  2. ToolCallbackProvider ---直接注册,回调在请求时惰性解析;
  3. ToolCallback[] / ToolCallbackProvider[] ---逐个按上面规则展开;
  4. Collection ---遍历后按同样规则派发;
  5. 任何其他对象 ---当作 @Tool 注解的 POJO,为每个 @Tool 方法生成一个 ToolCallback

参数名从 toolCallbacks 改成 tools,正是因为它的语义已经不止是"回调"了。注意第 2 条的"惰性解析"------这正是给 MCP 这类工具列表可能在运行期变化的场景留的口子。


四、Advisor 链成为一等公民(2.0 最大的架构变化)

只读一段源码,读 ToolCallingAdvisor 的类注释就够了:

java 复制代码
/**
 * Recursive Advisor that disables the internal tool execution flow and instead implements
 * the tool calling loop as part of the advisor chain.
 * <p>
 * It uses the CallAdvisorChainUtil to implement looping advisor chain calls.
 * <p>
 * This enables intercepting the tool calling loop by the rest of the advisors next in
 * the chain.
 * ...
 * @since 2.0.0
 */
public class ToolCallingAdvisor implements CallAdvisor, StreamAdvisor, ToolAdvisor {

4.1 从"模型内部行为"到"链上的一环"

1.x 里,工具调用循环发生在 ChatModel 内部:模型返回 tool_calls → ChatModel 自己执行 → 把结果再喂回去 → 循环。外部完全插不进去,你没法在"每一次工具调用之间"加日志、加权限校验、加缓存。

2.0 把它整个搬到了 advisor 链上。最有说服力的是 OpenAiChatModel.Builder 里这个方法的废弃说明:

java 复制代码
/**
 * Sets the tool calling manager used for internal tool execution.
 * @deprecated since 2.0.0 for removal in 3.0.0 --- internal tool execution in
 * {@link OpenAiChatModel} is superseded by {@code ToolCallingAdvisor} used via
 * {@code ChatClient}.
 */
@Deprecated(since = "2.0.0", forRemoval = true)
public Builder toolCallingManager(ToolCallingManager toolCallingManager) { ... }

"superseded by ToolCallingAdvisor used via ChatClient" ---跟开头钩子里"注入了 ToolCallingManager 但工具不执行"的现象完全对上了。

4.2 Advisor 的顺序是设计过的

java 复制代码
// Advisor 接口
int DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER = Ordered.HIGHEST_PRECEDENCE + 200;

// ToolCallingAdvisor
public static final int DEFAULT_ORDER = Ordered.HIGHEST_PRECEDENCE + 300;

ToolCallingAdvisor 的 javadoc 解释了为什么是 +300:

"Higher than Advisor#DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER (+200) so that memory advisors at their default order are placed outside this advisor and do not participate in each tool-call iteration."

翻译一下:记忆 Advisor 在工具调用循环之外 。这个顺序至关重要------如果记忆 Advisor 在循环内部,模型每调用一次工具,对话历史就被重复追加一次,token 消耗直接翻倍。Spring AI 用 +200 / +300 这两个常量把这个语义固化下来了。

ToolCallingAdvisor.Builder 的公开方法(全部实测自源码):

java 复制代码
public static Builder<?> builder();
public T toolCallingManager(ToolCallingManager toolCallingManager);
public T toolExecutionEligibilityChecker(ToolExecutionEligibilityChecker checker);
public T advisorOrder(int advisorOrder);
public T conversationHistoryEnabled(boolean conversationHistoryEnabled);
public T disableInternalConversationHistory();
public ToolCallingAdvisor build();

4.3 StructuredOutputValidationAdvisor:让模型自己修 JSON

java 复制代码
/**
 * Advisor that validates the structured JSON output of a chat client response against a
 * JSON schema derived from the configured output type or a pre-supplied schema string.
 * <p>
 * When validation fails, the advisor appends the validation error to the user message and
 * re-invokes the model, repeating up to {@code maxRepeatAttempts} times.
 * <p>
 * Streaming responses are not supported.
 */
public final class StructuredOutputValidationAdvisor implements CallAdvisor, StreamAdvisor {

这是一个"自纠正"环:校验失败 → 把校验错误追加到 user message → 重新调用模型 → 最多重试 maxRepeatAttempts

它的 import 里有两处值得单独指出:

java 复制代码
import com.networknt.schema.Schema;          // JSON Schema 校验器
import tools.jackson.databind.json.JsonMapper; // ← Jackson 3

第一,schema 校验用的是 networknt/json-schema-validator;第二,它用的是 tools.jackson(Jackson 3) ,不是 com.fasterxml.jackson。这印证了系列第八篇讲的 Boot 4 的 Jackson 3 迁移------Spring AI 2.0 是跟着一起迁的。

4.4 RAG:QuestionAnswerAdvisor 已删除

2.0.1 的 spring-ai-rag 模块里,advisor 只有 RetrievalAugmentationAdvisor 一个;承载 1.x QuestionAnswerAdvisorspring-ai-advisors-vector-store 模块也已从 BOM 移除。

java 复制代码
public final class RetrievalAugmentationAdvisor implements BaseAdvisor {

    public static final String DOCUMENT_CONTEXT = "rag_document_context";

    private final List<QueryTransformer> queryTransformers;
    private final @Nullable QueryExpander queryExpander;
    private final DocumentRetriever documentRetriever;
    private final DocumentJoiner documentJoiner;
    private final List<DocumentPostProcessor> documentPostProcessors;
    private final QueryAugmenter queryAugmenter;
    private final TaskExecutor taskExecutor;
    private final Scheduler scheduler;

这个类内部有一个细节值得单独看------它的默认 TaskExecutor 是这么建的:

java 复制代码
private static TaskExecutor buildDefaultTaskExecutor() {
    ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
    taskExecutor.setThreadNamePrefix("ai-advisor-");
    taskExecutor.setCorePoolSize(4);
    taskExecutor.setMaxPoolSize(16);
    taskExecutor.setTaskDecorator(new ContextPropagatingTaskDecorator());
    taskExecutor.initialize();
    return taskExecutor;
}

(ContextPropagatingTaskDecorator 来自 org.springframework.core.task.support。)

这个 4~16 线程的池子是用来做多查询并行检索的:

java 复制代码
.map(query -> CompletableFuture.supplyAsync(() -> getDocumentsForQuery(query), this.taskExecutor))

关键在于 setTaskDecorator(new ContextPropagatingTaskDecorator()) ---它把调用线程的上下文(Micrometer 的 observation、ThreadLocal 里的 trace 信息)传播到池里的工作线程 。否则并行检索的每个子任务都会丢失 trace 上下文,RAG 链路的 span 就断在这里了。这正是系列第十六篇讲的可观测性上下文传播,在 Spring AI 里被复用的一处实例。


五、Spring Boot 自动配置到底替你做了什么

这一节是本文的核心。Spring AI 的 Boot 集成在 spring-ai-autoconfigure-* 模块里,spring-ai-starter-* 只是把 autoconfigure + 实现模块打包。

5.1 ChatClientAutoConfiguration

java 复制代码
@AutoConfiguration(after = ToolCallingAutoConfiguration.class)
@ConditionalOnClass(ChatClient.class)
@EnableConfigurationProperties(ChatClientBuilderProperties.class)
@ConditionalOnProperty(prefix = ChatClientBuilderProperties.CONFIG_PREFIX, name = "enabled",
        havingValue = "true", matchIfMissing = true)
public class ChatClientAutoConfiguration {

它定义三个 Bean:

java 复制代码
@Bean @ConditionalOnMissingBean
ChatClientBuilderConfigurer chatClientBuilderConfigurer(
        ObjectProvider<ChatClientCustomizer> customizerProvider,
        ObjectProvider<ChatClientBuilderCustomizer> builderCustomizerProvider)

@Bean @ConditionalOnMissingBean @ConditionalOnBean(ToolCallingManager.class)
ToolCallingAdvisor.Builder<?> toolCallingAdvisorBuilder(ChatClientBuilderProperties properties,
        ToolCallingManager toolCallingManager,
        ObjectProvider<ToolExecutionEligibilityChecker> toolExecutionEligibilityChecker)

@Bean @Scope("prototype") @ConditionalOnMissingBean
ChatClient.Builder chatClientBuilder(ChatClientBuilderProperties properties,
        ChatClientBuilderConfigurer chatClientBuilderConfigurer, ChatModel chatModel,
        ObjectProvider<ObservationRegistry> observationRegistry,
        ObjectProvider<ChatClientObservationConvention> chatClientObservationConvention,
        ObjectProvider<AdvisorObservationConvention> advisorObservationConvention,
        ObjectProvider<ToolCallingAdvisor.Builder<?>> toolCallingAdvisorBuilder)

这里有三个必须知道的点:

第一,没有 ChatClient Bean,只有 ChatClient.Builder 业务代码里应该注入 ChatClient.Builder 而不是 ChatClient:

java 复制代码
@Service
public class TicketService {
    private final ChatClient chatClient;

    public TicketService(ChatClient.Builder builder) {   // ← 注入 Builder
        this.chatClient = builder.defaultSystem("你是工单摘要助手").build();
    }
}

这是有意为之 的:ChatClient 是不可变的、按用途定制的(摘要用一套 prompt、分类用另一套),框架无法替你决定该建几个。

第二,ChatClient.Builder@Scope("prototype") 源码 javadoc 说得很清楚:

"This will produce a ChatClient.Builder bean with the prototype scope, meaning each injection point will receive a newly cloned instance of the builder."

这解决了"多个 Service 各自定制默认值、互相污染"的问题------每个注入点拿到的是独立副本。但也要注意:在单例里注入一次,那个副本就固定了,不要以为每次调用都能拿到新的。

第三,ToolCallingAdvisor 是"自动挂载"的,而且有一套明确的退让与互斥规则。 上面的 toolCallingAdvisorBuilder 是条件 Bean(@ConditionalOnBean(ToolCallingManager.class));真正决定它挂不挂到链上的是 DefaultChatClient.buildAdvisorChain():

java 复制代码
private BaseAdvisorChain buildAdvisorChain() {
    autoRegisterToolCallingAdvisor();
    validateSingleToolAdvisor();
    // ... 之后追加 ChatModelCallAdvisor / ChatModelStreamAdvisor
}

autoRegisterToolCallingAdvisor() 的源码(javadoc 与实现都很值得读):

java 复制代码
/**
 * Auto-registers a {@link ToolCallingAdvisor} unless auto-registration is
 * disabled or a {@link ToolAdvisor} is already present in the chain. The advisor
 * is always registered so that tools injected dynamically at runtime (e.g. by
 * another advisor) are handled correctly even when no static tools are configured
 * on the call.
 * ...
 */
private void autoRegisterToolCallingAdvisor() {
    boolean autoRegisterDisabled = Boolean.FALSE
        .equals(this.advisorParams.get(ChatClientAttributes.TOOL_CALLING_ADVISOR_AUTO_REGISTER.getKey()));
    if (autoRegisterDisabled) {
        return;
    }
    boolean hasToolCallingAdvisor = this.advisors.stream().anyMatch(a -> a instanceof ToolAdvisor);
    if (hasToolCallingAdvisor) {
        return;
    }
    int configuredOrder = this.toolCallingAdvisorBuilder.getAdvisorOrder();
    boolean hasDownstreamMemoryAdvisor = this.advisors.stream()
        .anyMatch(a -> a instanceof MemoryAdvisor && a.getOrder() > configuredOrder);
    this.advisors.add(this.toolCallingAdvisorBuilder.copy()
        .conversationHistoryEnabled(!hasDownstreamMemoryAdvisor)
        .build());
}

从这段实现能读出三条对排查问题极有价值的规则:

  1. 不挂的两种情况 :显式设了 AdvisorParams.toolCallingAdvisorAutoRegister(false),或者链上已经有一个 ToolAdvisor。后者是"升级后工具突然不执行"的一个典型原因------如果 1.x 里手动往链上放过一个工具相关 advisor,2.0 会认为"你已经自己管了"而不再自动挂载。
  2. 即使一个静态工具都没配,它也会挂上。因为工具可能在运行期由别的 advisor 动态注入。
  3. conversationHistoryEnabled 会自动让位 :如果链上已有一个 order 更大(请求方向上更靠后)的 MemoryAdvisor,工具 advisor 就关掉自己的内部对话历史,避免历史被重复累积。这和 §4.2 讲的 +200/+300 是同一件事的两面。

紧跟着的 validateSingleToolAdvisor() 做了互斥校验------链上超过一个 ToolAdvisor 就直接抛:

java 复制代码
throw new IllegalStateException("At most one ToolAdvisor is allowed in the advisor chain, but found "
        + toolAdvisors.size() + ": [" + names + "]");

所以"工具不执行"通常不是 ToolCallingManager 注入错了 (它只是个 Bean),而是上面这套自动挂载规则被触发了退让,或者工具压根没以 ToolCallbackProvider 形式暴露给 §5.2 那个 resolver。

5.2 ToolCallingAutoConfiguration:工具是怎么被发现的

java 复制代码
@AutoConfiguration
@ConditionalOnClass(ChatModel.class)
@EnableConfigurationProperties(ToolCallingProperties.class)
public class ToolCallingAutoConfiguration {

    @Bean @ConditionalOnMissingBean
    ToolCallbackResolver toolCallbackResolver(GenericApplicationContext applicationContext,
            List<ToolCallback> toolCallbacks,
            ObjectProvider<List<ToolCallbackProvider>> tcbProviderList,
            ObjectProvider<ToolCallbackProvider> tcbProviders) {
        // ...
        var staticToolCallbackResolver = new StaticToolCallbackResolver(allFunctionAndToolCallbacks);
        return new DelegatingToolCallbackResolver(List.of(staticToolCallbackResolver));
    }
}

划重点:它不会扫描 @Tool 注解的 Bean。

这个 resolver 只收集容器里的 ToolCallback Bean 和 ToolCallbackProvider Bean。没有 getBeansWithAnnotation(Tool.class) 这类逻辑。所以下面这种写法在 2.0 里不会自动生效:

java 复制代码
@Component
public class CodeReviewTools {
    @Tool(description = "读取指定文件的完整内容")
    public String readFile(@ToolParam(description = "文件路径") String path) { ... }
}

必须显式暴露一个 ToolCallbackProvider:

java 复制代码
@Bean
ToolCallbackProvider reviewToolCallbackProvider(CodeReviewTools tools) {
    return MethodToolCallbackProvider.builder()
        .toolObjects(tools)
        .build();
}

MethodToolCallbackProvider.Builder.toolObjects(Object...) 就是干这个的(源码签名已核对)。这是 1.x 升级过来时最容易漏、又最难 debug 的一处:注解还在,代码能编译,但模型根本不知道有这个工具。

顺带一提,ToolCallingAutoConfiguration 构建 resolver 时会刻意跳过 MCP 的 provider (按类型名 SyncMcpToolCallbackProvider / AsyncMcpToolCallbackProvider 过滤),理由是避免过早调用 #listTools()。这是 MCP 工具"惰性解析"的实现细节。

5.3 配置属性全表

ChatClientBuilderProperties(前缀 spring.ai.chat.client):

java 复制代码
public static final String CONFIG_PREFIX = "spring.ai.chat.client";
private boolean enabled = true;
private final Observations observations = new Observations();
private final ToolCalling toolCalling = new ToolCalling();

public static class ToolCalling {
    private boolean enabled = true;
    private int advisorOrder = ToolCallingAdvisor.DEFAULT_ORDER;   // ← 默认就是 +300
}
属性 默认值 说明
spring.ai.chat.client.enabled true ChatClient 自动配置开关
spring.ai.chat.client.observations.log-prompt false 记录 prompt 内容到 observation
spring.ai.chat.client.observations.log-completion false 记录 completion 内容
spring.ai.chat.client.tool-calling.enabled true 工具调用开关
spring.ai.chat.client.tool-calling.advisor-order ToolCallingAdvisor.DEFAULT_ORDER Advisor 顺序

工具相关(ToolCallingProperties,前缀 spring.ai.tools):throw-exception-on-error(false)、observations.include-content(false)、resolution.fallback.enabled(false)、以及一组 limits.*(max-calls-per-tool-defaultmax-calls-per-tool.*excluded-toolsmax-total-tool-callson-limit-exceeded)。

spring.ai.tools.limits.* 值得单独说。 工具调用循环从模型内部搬到 Advisor 链上之后,框架获得了"在循环外面计次"的能力------于是工具调用次数上限 成了可以配置的东西。这在 1.x 做不到(循环在模型里,外面看不见)。这正好印证了 §4 说的"把循环搬到链上"带来的实际收益:可拦截 = 可治理

想彻底关掉某个上限也有办法,ToolCallingAutoConfiguration 里定义了这个约定:

java 复制代码
/**
 * Value a {@code spring.ai.tools.limits.*} property can be set to in order to disable
 * that limit entirely, translated into the corresponding
 * {@code unlimited*}/{@code excludeToolFromLimit} builder call below.
 */
private static final int UNLIMITED = -1;

也就是说 spring.ai.tools.limits.max-total-tool-calls=-1 表示不限制。生产环境慎用------工具调用循环没有次数上限,等于把一个可控的 token 成本变成了不可控的。

5.4 模型选择:多模型共存靠 spring.ai.model.*

java 复制代码
public static final String MODEL_PREFIX = "spring.ai.model";
public static final String CHAT_MODEL = MODEL_PREFIX + ".chat";
public static final String EMBEDDING_MODEL = MODEL_PREFIX + ".embedding";
public static final String IMAGE_MODEL = MODEL_PREFIX + ".image";
// ... audio.transcription / audio.speech / moderation

OpenAiChatAutoConfiguration 上的条件就是:

java 复制代码
@ConditionalOnProperty(name = SpringAIModelProperties.CHAT_MODEL, havingValue = SpringAIModels.OPENAI,
        matchIfMissing = true)

注意 matchIfMissing = true ---不配就是 OpenAI。这个设计的意义在于:当 classpath 上同时有 OpenAI 和 Ollama 时,用一行配置切换:

yaml 复制代码
spring:
  ai:
    model:
      chat: ollama    # 取值见 SpringAIModels:openai / ollama / anthropic / deepseek / ...

配套的 spring.ai.openai.*(OpenAiCommonProperties)、spring.ai.openai.chat.*(OpenAiChatProperties)是分层的,OpenAiAutoConfigurationUtil.resolveCommonProperties(...) 负责把通用配置合并进具体模型配置,具体模型配置优先


六、厂商 SDK 化:一个必须知道的安全含义

6.1 OpenAiApi 没了,换成官方 SDK

spring-ai-openai 2.0.1 的 import 是这样的:

java 复制代码
import com.openai.client.OpenAIClient;
import com.openai.client.OpenAIClientAsync;
import com.openai.models.chat.completions.ChatCompletion;

也就是说,Spring AI 不再手写 HTTP 客户端去调 OpenAI,而是委托官方 openai-java SDK。构建方式也随之改变:

java 复制代码
OpenAiChatModel.builder()
    .openAiClient(openAIClient)            // 1.x 是 openAiApi(OpenAiApi)
    .openAiClientAsync(openAiClientAsync)
    .options(chatProperties.toOptions())   // 1.x 是 defaultOptions(...)
    .build();

依赖关系也印证了这一点:spring-ai-openai 的直接依赖是 com.openai:openai-java-core + com.squareup.okhttp3:okhttp

6.2 由此带来的 SSRF 防护边界(承接上一篇)

这是本文最想强调的交叉点,它是真实的、有安全后果的:

第一,Spring AI 的模型 starter 会把 Boot 4 的 HTTP 客户端装进 classpath。 spring-ai-starter-model-openai 依赖 spring-boot-starter-restclient:4.1.1spring-boot-starter-webclient:4.1.1

第二,但你为 LLM 流量配的 InetAddressFilter 管不到厂商 SDK 的调用。 因为 spring-ai-openai 底层走的是官方 SDK 的 OkHttp ,不是 Boot 的 ClientHttpRequestFactoryBuilder,所以第十七篇里那个全局 InetAddressFilter Bean 不会作用在 OpenAI 的 HTTP 请求上

Spring AI 给 OkHttp 留的扩展点是另一个(注意包名,它明确落在 http.okhttp 下,也说明底层确实是 OkHttp):

java 复制代码
// org.springframework.ai.openai.http.okhttp.OpenAiHttpClientBuilderCustomizer
@FunctionalInterface
public interface OpenAiHttpClientBuilderCustomizer {
    /**
     * Customize the {@link SpringAiOpenAiHttpClient.Builder} prior to building the
     * underlying OkHttpClient.
     * Implement this interface to register OkHttp interceptors (for example, a Spring
     * Security OAuth2 client-credentials interceptor), swap the dispatcher
     * {@code ExecutorService}, or tweak any other OkHttp setting exposed by the builder.
     * @since 2.0.0
     */
    void customize(SpringAiOpenAiHttpClient.Builder builder);
}

实践结论 :如果 LLM 网关地址需要受限(比如只允许走内网代理、或禁止访问元数据服务),不能指望 InetAddressFilter ,得用 OkHttp 的 Interceptor 或网络层 egress 策略。反过来,如果同一应用里还有普通的 RestClient/WebClient 出站调用,上一篇文章讲的那套防护依然生效------两套出站链路,两套防护手段


七、与 Boot 4 内核的四处咬合

Spring AI 2.0 是跟着 Boot 4 一起演进的,有四处能直接在本仓库里对上。

7.1 JSpecify 空安全(对应第十三篇)

对 2.0.1 的四个核心模块 (spring-ai-client-chatspring-ai-modelspring-ai-commonsspring-ai-rag)源码做注解统计:

text 复制代码
 139  org.jspecify.annotations.Nullable
  65  org.jspecify.annotations.NullMarked
   1  org.jspecify.annotations.NullUnmarked

(这里必须说明统计范围:注解总量与模块集强相关。若把 openai / mcp / 各 autoconfigure 模块也算进来,同一版本的计数会变成 199 / 92 / 1。引用这类数字时一定要连模块范围一起说,否则没有意义。)

65 个 package-info.java 打了 @NullMarked,包括 org.springframework.ai.aotorg.springframework.ai.chat.client 这些核心包。"默认非空、例外标注"的约定在 Spring AI 里一致执行------所以 .entity(Class<T>) 的返回值才老老实实标成 @Nullable T,因为模型返回空内容时它真的是 null。

7.2 Jackson 3(对应第八篇)

两处硬证据:

java 复制代码
// StructuredOutputValidationAdvisor:用 tools.jackson
import tools.jackson.databind.json.JsonMapper;

// AiRuntimeHints:注解仍在 com.fasterxml
import com.fasterxml.jackson.annotation.JsonInclude;

序列化 API 在 tools.jackson,注解仍在 com.fasterxml.jackson ---跟第八篇讲的 Boot 4 的迁移策略完全一致。现有 @JsonProperty 注解一个都不用改,但 ObjectMapper 的构造方式要改。

顺带一个有意思的细节:MCP 官方 SDK 也跟进了,mcp-json-jackson3:2.0.0 这个构件的名字本身就说明了问题------它在用 Jackson 3

7.3 Spring Framework 7 的 Retry(对应第九篇)

spring-ai-autoconfigure-retry 用的是 Framework 7 新的 org.springframework.core.retry ,不是老的 org.springframework.retry(Spring Retry 项目):

java 复制代码
import org.springframework.core.retry.RetryListener;
import org.springframework.core.retry.RetryPolicy;
import org.springframework.core.retry.RetryTemplate;
import org.springframework.core.retry.Retryable;

@Bean
@ConditionalOnMissingBean
public RetryTemplate retryTemplate(SpringAiRetryProperties properties) {
    RetryPolicy retryPolicy = RetryPolicy.builder()
        .maxRetries(properties.getMaxAttempts())
        .includes(TransientAiException.class)
        .includes(ResourceAccessException.class)
        .delay(properties.getBackoff().getInitialInterval())
        .multiplier(properties.getBackoff().getMultiplier())
        .maxDelay(properties.getBackoff().getMaxInterval())
        .build();
    RetryTemplate retryTemplate = new RetryTemplate(retryPolicy);
    retryTemplate.setRetryListener(new RetryListener() { ... });
    return retryTemplate;
}

这段拿到本机 Maven 缓存里的 spring-core-7.0.8.jar 做了一次反向核对:

text 复制代码
$ javap -cp spring-core-7.0.8.jar org.springframework.core.retry.RetryPolicy\$Builder
  public org.springframework.core.retry.RetryPolicy$Builder maxRetries(long);
  public org.springframework.core.retry.RetryPolicy$Builder delay(java.time.Duration);
  public org.springframework.core.retry.RetryPolicy$Builder multiplier(double);
  public org.springframework.core.retry.RetryPolicy$Builder maxDelay(java.time.Duration);
  public org.springframework.core.retry.RetryPolicy$Builder jitter(java.time.Duration);
  public org.springframework.core.retry.RetryPolicy$Builder backOff(org.springframework.util.backoff.BackOff);
  public final org.springframework.core.retry.RetryPolicy$Builder includes(java.lang.Class<? extends java.lang.Throwable>...);
  public org.springframework.core.retry.RetryPolicy build();

$ javap -cp spring-core-7.0.8.jar org.springframework.core.retry.RetryTemplate
  public org.springframework.core.retry.RetryTemplate(org.springframework.core.retry.RetryPolicy);
  public void setRetryListener(org.springframework.core.retry.RetryListener);

两边完全对上。 这是"Framework 7 的容错能力进 core"(第九篇)在真实下游项目里的第一个重量级消费者。

配置前缀 spring.ai.retry,默认值:max-attempts=10backoff.initial-interval=2sbackoff.multiplier=5backoff.max-interval=3mon-client-errors=false,另有 on-http-codes / exclude-on-http-codes 做精细控制。

max-attempts=10 + multiplier=5 这个默认组合相当激进:2s → 10s → 50s → ...... 而且注意 RetryPolicy.Builder.maxRetries(long) 的语义是重试次数。接大模型 API 时建议按自己的 SLA 显式收敛。

7.4 AOT(对应第十篇、第十九篇)

spring-ai-modelMETA-INF/spring/aot.factories:

properties 复制代码
org.springframework.aot.hint.RuntimeHintsRegistrar=\
	org.springframework.ai.aot.SpringAiCoreRuntimeHints,\
	org.springframework.ai.aot.KnuddelsRuntimeHints,\
    org.springframework.ai.aot.ToolRuntimeHints

org.springframework.beans.factory.aot.BeanRegistrationAotProcessor=\
    org.springframework.ai.aot.ToolBeanRegistrationAotProcessor

四个类各管一摊:

作用
SpringAiCoreRuntimeHints AbstractMessage/AssistantMessage/ToolResponseMessage/ToolCallback/ToolDefinition 等注册全部 MemberCategory(含嵌套类),并注册 embedding/embedding-model-dimensions.properties 资源
ToolRuntimeHints 注册 DefaultToolCallResultConverter
KnuddelsRuntimeHints 注册 jtokkit/com/knuddels/jtokkit/cl100k_base.tiktoken 资源(tokenizer 词表)
ToolBeanRegistrationAotProcessor @Tool 在 native image 下能工作的关键

第四个最有信息量。它是一个 BeanRegistrationAotProcessor(Framework 的 AOT SPI),构建期为每个 Bean 检查有没有 @Tool 方法:

java 复制代码
boolean hasAnyToolAnnotatedMethods = Stream.of(ReflectionUtils.getDeclaredMethods(beanClass))
    .anyMatch(method -> search.from(method).isPresent(Tool.class));

if (hasAnyToolAnnotatedMethods) {
    return new AotContribution(beanClass);
}

命中就注册 INVOKE_DECLARED_METHODS + INVOKE_PUBLIC_METHODS 两个反射类别。

这解释了一个经典 native image 故障 :如果你的 @Tool 方法是通过接口/父类继承来的,或者 Bean 不是通过标准 BeanDefinition 注册的,这个 processor 可能扫不到,native 镜像里工具就会在调用时 才抛 NoSuchMethodException。所以 @Tool 尽量写在声明类本身的方法上。

MCP 侧有 org.springframework.ai.mcp.aot.McpHints,它用 AiRuntimeHints.findInnerClassesFor(McpSchema.class) 把整个 McpSchema 的嵌套类型全注册一遍------因为 MCP 的 JSON-RPC 报文类型非常深。


八、MCP:核心在外部 SDK,Spring AI 做集成层

这一节需要先破除一个误解:McpClient / McpServer 不在 Spring AI 里。

spring-ai-mcp:2.0.1 的 sources jar 只有 31KB(31485 字节)、18 个 Java 文件 ,全部在 org.springframework.ai.mcp 下:

text 复制代码
{Async,Sync}McpToolCallback.java / {Async,Sync}McpToolCallbackProvider.java
DefaultMcpToolNamePrefixGenerator / McpConnectionInfo / McpToolFilter
McpToolNamePrefixGenerator / McpToolUtils / McpToolsChangedEvent
ToolContextToMcpMetaConverter
aot/{McpHints, package-info}
customizer/{McpAsyncServerCustomizer, McpClientCustomizer, McpSyncServerCustomizer}

真正的核心在 io.modelcontextprotocol.sdk:mcp-core:2.0.0(Anthropic 的官方 Java SDK,MIT 协议):

能力 全限定名
客户端入口 io.modelcontextprotocol.client.McpClient(工厂 sync(transport) / async(transport))
服务端入口 io.modelcontextprotocol.server.McpServer(6 个工厂,含 sync/async × transportProvider/streamable/stateless)
同步客户端 io.modelcontextprotocol.client.McpSyncClient
同步服务端 io.modelcontextprotocol.server.McpSyncServer

传输层(两组,别搞混):

  • 客户端 (SDK 侧):StdioClientTransport(标准输入输出)、HttpClientSseClientTransport(SSE)、HttpClientStreamableHttpTransport(Streamable HTTP);Spring AI 额外提供 WebClientStreamableHttpTransport(WebFlux)。
  • 服务端 (Spring AI 侧,注意 artifactId 是 mcp-spring-webmvc / mcp-spring-webflux):WebMvcStreamableServerTransportProviderWebMvcSseServerTransportProviderWebMvcStatelessServerTransport,以及 WebFlux 三兄弟。

协议版本 (io.modelcontextprotocol.spec.ProtocolVersions):

java 复制代码
String MCP_2024_11_05 = "2024-11-05";
String MCP_2025_03_26 = "2025-03-26";
String MCP_2025_06_18 = "2025-06-18";
String MCP_2025_11_25 = "2025-11-25";   // 最新

默认支持全部四个。两个细节值得注意:SSE 传输被钉死在 2024-11-05 (HttpClientSseClientTransport 里写死),而 stateless 传输只支持 2025-03-26 及以后。所以"从 SSE 迁到 Streamable HTTP"不只是换个类名,协议基线也变了。

8.1 注解式开发

@McpTool / @McpResource / @McpPrompt 在一个独立构件 spring-ai-mcp-annotations:2.0.1 里,包名 org.springframework.ai.mcp.annotation:

java 复制代码
@Target({METHOD, ANNOTATION_TYPE})
@Retention(RUNTIME)
public @interface McpTool {
    String name() default "";
    String description() default "";
    McpAnnotations annotations() default @McpAnnotations;
    boolean generateOutputSchema() default false;
    String title() default "";
    Class<? extends MetaProvider> metaProvider() default DefaultMetaProvider.class;
}

注意 McpToolTool两套不同的注解 :org.springframework.ai.mcp.annotation.McpTool 用于把 Java 方法暴露成 MCP 服务器能力,org.springframework.ai.tool.annotation.Tool 用于把 Java 方法暴露成本地模型工具。桥接靠 McpToolUtils

8.2 MCP 的自动配置

spring-ai-autoconfigure-mcp-client-commonAutoConfiguration.imports:

text 复制代码
org.springframework.ai.mcp.client.common.autoconfigure.StdioTransportAutoConfiguration
org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration
org.springframework.ai.mcp.client.common.autoconfigure.McpToolCallbackAutoConfiguration
org.springframework.ai.mcp.client.common.autoconfigure.annotations.McpClientAnnotationScannerAutoConfiguration

8.3 "MCP 与 AOT 融合"到底融合了什么

spring-ai-mcp-annotations 里有一个 AbstractAnnotatedMethodBeanFactoryInitializationAotProcessor,它是 BeanFactoryInitializationAotProcessor(Framework 的构建期 SPI,注意跟 §7.4 那个 BeanRegistrationAotProcessor 不是同一个扩展点):

java 复制代码
@Override
public BeanFactoryInitializationAotContribution processAheadOfTime(ConfigurableListableBeanFactory beanFactory) {
    List<Class<?>> types = new ArrayList<>();
    Set<Class<?>> metaProviderTypes = new LinkedHashSet<>();
    for (String beanName : beanFactory.getBeanDefinitionNames()) {
        Class<?> beanClass = beanFactory.getType(beanName);
        if (beanClass == null) { continue; }
        Set<Class<? extends Annotation>> classes = this.scan(beanClass);
        if (!classes.isEmpty()) {
            types.add(beanClass);
            collectMetaProviderTypes(beanClass, metaProviderTypes);
        }
    }
    return (generationContext, beanFactoryInitializationCode) -> {
        RuntimeHints runtimeHints = generationContext.getRuntimeHints();
        for (Class<?> typeReference : types) {
            runtimeHints.reflection().registerType(typeReference, MemberCategory.values());
        }
        for (Class<?> metaProviderType : metaProviderTypes) {
            runtimeHints.reflection().registerType(metaProviderType,
                    MemberCategory.INVOKE_DECLARED_CONSTRUCTORS);
        }
    };
}

它做的是"构建期为注解扫描结果预注册反射提示",不是"把注解扫描整个搬到构建期"。 运行期的注解扫描(McpClientAnnotationScannerAutoConfiguration / AbstractAnnotatedMethodBeanPostProcessor)依然存在------AOT 处理器只是保证 native image 里这些被扫到的类能被反射访问。

有一个容易漏的细节:它还专门处理了 @McpTool(metaProvider = ...) 这个属性------metaProvider 指向的类是在运行期通过无参构造反射实例化 的,所以只给它注册了 INVOKE_DECLARED_CONSTRUCTORS(而不是全部 MemberCategory)。如果自定义了 MetaProvider 又在 native image 里遇到实例化失败,问题多半在这里。


九、实战:一个 AI 代码审查助手

需求:给定一个 Git 仓库路径,让模型审查改动、查项目规范、输出结构化报告。

9.1 依赖

xml 复制代码
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.ai</groupId>
      <artifactId>spring-ai-bom</artifactId>
      <version>2.0.1</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-openai</artifactId>   <!-- 注意新命名 -->
  </dependency>
</dependencies>

9.2 配置

yaml 复制代码
spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        model: gpt-4o-mini
    chat:
      client:
        observations:
          log-prompt: true          # 开发期打开,生产建议关闭
      tool-calling:
        enabled: true               # advisor-order 默认已是 ToolCallingAdvisor.DEFAULT_ORDER
    tools:
      limits:
        max-total-tool-calls: 12    # 循环搬到 Advisor 链上之后才有的治理能力
        excluded-tools: []

关于 limits 的默认值:源码里是 DefaultToolCallingManager.DEFAULT_MAX_CALLS_PER_TOOL = 40(单个工具最多调 40 次)和 DEFAULT_MAX_TOTAL_TOOL_CALLS = 150(整轮最多 150 次),超限行为由 on-limit-exceeded 控制,默认是 THROW也就是说这个护栏默认就是开着的------上面把总量收紧到 12 只是按"代码审查"这个场景做的保守配置,不是从"无限制"改成了"有限制"。

9.3 工具:@Tool POJO + ToolCallbackProvider

java 复制代码
package com.example.review;

import java.nio.file.Files;
import java.nio.file.Path;

import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;

public class CodeReviewTools {

    private final Path repoRoot;

    public CodeReviewTools(Path repoRoot) {
        this.repoRoot = repoRoot;
    }

    @Tool(description = "读取仓库中指定文件的完整内容,path 为相对仓库根目录的路径")
    public String readFile(@ToolParam(description = "相对路径,如 src/main/java/Foo.java") String path) {
        try {
            return Files.readString(this.repoRoot.resolve(path));
        }
        catch (Exception ex) {
            return "读取失败: " + ex.getMessage();
        }
    }

    @Tool(description = "读取项目编码规范文档")
    public String readConventions() {
        try {
            return Files.readString(this.repoRoot.resolve("CONTRIBUTING.md"));
        }
        catch (Exception ex) {
            return "未找到规范文档";
        }
    }
}

注意这个类上没有任何 Spring 注解。 因为 §5.2 说过,@Tool Bean 不会被自动扫描------注册必须显式:

java 复制代码
package com.example.review;

import java.nio.file.Path;

import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
public class ReviewToolConfiguration {

    @Bean
    ToolCallbackProvider codeReviewToolCallbackProvider() {
        return MethodToolCallbackProvider.builder()
            .toolObjects(new CodeReviewTools(Path.of("/srv/repos/demo")))
            .build();
    }
}

这一步漏了,模型就永远不知道有这两个工具。 这是升级到 2.0 时最高频的坑。

9.4 ChatClient:全局定制 + 结构化输出

java 复制代码
package com.example.review;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.ChatClientBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
public class ChatClientConfiguration {

    @Bean
    ChatClientBuilderCustomizer reviewDefaults() {
        return (builder) -> builder.defaultSystem("""
            你是一名严谨的 Java 代码审查员。
            审查前先用 readConventions 读取项目规范,再逐个 readFile 查看改动文件。
            只报告确实存在的问题,不要臆测。""");
    }
}

注意这里用的是 ChatClientBuilderCustomizer(2.0 新接口) ,不是废弃的 ChatClientCustomizer。两者签名完全一样,都是 void customize(ChatClient.Builder),区别只在于前者不会被移除。

业务侧:

java 复制代码
package com.example.review;

import java.util.List;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

@Service
public class CodeReviewService {

    private final ChatClient chatClient;

    public CodeReviewService(ChatClient.Builder builder) {   // 注入 Builder,不是 ChatClient
        this.chatClient = builder.build();
    }

    public ReviewReport review(String diffSummary) {
        return this.chatClient.prompt()
            .user(u -> u.text("请审查以下改动并给出结构化结论:\n{diff}").param("diff", diffSummary))
            .call()                                   // 阻塞路径
            .entity(ReviewReport.class);              // 结构化输出
    }

    public record ReviewReport(String summary, List<Issue> issues) {
        public record Issue(String file, int line, String severity, String message) { }
    }
}

.call() 是显式的阻塞选择;同样的 prompt 换成 .stream().content() 得到的就是 Flux<String>(流式路径下不能用 schema 校验,见 §3.2)。

9.5 加一层 RAG:让模型先查项目规范

java 复制代码
package com.example.review;

import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor;
import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
public class RagConfiguration {

    @Bean
    RetrievalAugmentationAdvisor conventionsAdvisor(VectorStore vectorStore) {
        return RetrievalAugmentationAdvisor.builder()
            .documentRetriever(VectorStoreDocumentRetriever.builder()
                .vectorStore(vectorStore)
                .topK(4)
                .build())
            .build();
    }
}

然后 .call() 之前挂上:.advisors(conventionsAdvisor)(或全局 builder.defaultAdvisors(...))。

注意 RetrievalAugmentationAdvisor.builder() 上还有几个 1.x 没有的旋钮 (源码签名已核对):queryTransformers(...)queryExpander(...)documentJoiner(...)documentPostProcessors(...)queryAugmenter(...)taskExecutor(...)scheduler(...)order(Integer)。RAG 流程从"一个固定的召回-拼接"变成了可编排的多阶段管线 (Query 改写 → 扩展 → 检索 → 合并 → 后处理 → 增强),这也是 QuestionAnswerAdvisor 被取代的原因------它的硬编码流程撑不住这些编排需求。


十、1.1 → 2.0 迁移清单

按"必改 / 建议改 / 可以不动"三档整理:

必改(不改编译不过)

BOM spring-ai-bom:1.1.x spring-ai-bom:2.0.1
显式工具注册 defaultToolCallbacks(...) defaultTools(...)
自定义器 ChatClientCustomizer ChatClientBuilderCustomizer
OpenAI 客户端 OpenAiApi 官方 SDK 的 OpenAIClient
RAG QuestionAnswerAdvisor RetrievalAugmentationAdvisor
MCP 服务端公共包 spring-ai-starter-mcp-server-common(1.1.8 BOM 中的悬空条目,无实际构件) 2.0 BOM 已移除该条目
Advisor 类名 ToolCallAdvisor ToolCallingAdvisor
厂商模块 spring-ai-starter-model-zhipuai / -minimax / -azure-openai 已不在 2.0 GA 的 BOM 中,需自行确认构件是否仍发布

建议改(行为可能变化)

  1. 检查 ToolCallingManager 注入点 ---如果代码里有 OpenAiChatModel.Builder.toolCallingManager(...),它在 2.0 已废弃且语义变了(工具循环归 Advisor 管)。把工具配置迁到 ToolCallingAdvisor.Builder 上。
  2. 检查 @Tool Bean 是否显式注册了 ToolCallbackProvider---2.0 不会自动扫。
  3. 复查 spring.ai.retry 默认值 ---max-attempts=10 + multiplier=5 很激进。
  4. 复查温度等采样参数 ---OpenAiChatProperties.temperature@Nullable Double,没有默认值,不配就是 null(交给服务端默认)。1.x 若有隐式默认,行为会变。
  5. native image 项目复查 @Tool 方法的声明位置---继承来的方法可能扫不到(见 §7.4)。

可以不动

  • 所有 @Tool / @ToolParam 注解本身(包名 org.springframework.ai.tool.annotation 未变);
  • Jackson 的 @JsonProperty 等注解(仍在 com.fasterxml.jackson);
  • ChatClient 的链式调用风格(prompt().user().call().content() 保持不变)。

十一、总结

四句话记住这篇:

  1. Spring AI 2.0 是 GA,不是"前瞻版"。 它按 Spring Boot 4.1.1 / Framework 7.0.9 / Java 17 构建,1.1.x 与 2.0.x 是双轨并行(1.1.8 与 2.0.0 同日发布),分别服务 Boot 3.5 和 Boot 4.x。
  2. 最大的架构变化是工具调用循环从 ChatModel 内部搬到了 Advisor 链上。 OpenAiChatModel.Builder.toolCallingManager(...)@deprecated 说明里写着"superseded by ToolCallingAdvisor used via ChatClient"。收益是可拦截 = 可治理 :spring.ai.tools.limits.* 这类工具调用次数上限,只有循环在外面才做得出来。
  3. 只有 ChatClient.Builder Bean,没有 ChatClient Bean ,且它是 @Scope("prototype") 的;@Tool 注解的 Bean 不会被自动扫描 ,必须显式暴露 ToolCallbackProvider。这两条是升级时最容易踩的坑。
  4. 别拿上一篇的 InetAddressFilter 当 LLM 流量的防线。 模型 starter 底层走的是厂商官方 SDK(OpenAI 用 OkHttp),不是 Boot 的 ClientHttpRequestFactoryBuilder------Spring AI 给 OkHttp 留的扩展点是 OpenAiHttpClientBuilderCustomizer两套出站链路,两套防护手段。

同时也能看到 Spring Boot 4 的内核能力在 Spring AI 里被系统性地复用:JSpecify 空安全(65 个 @NullMarked 包)、Jackson 3(tools.jackson)、Framework 7 的 org.springframework.core.retry(已在本机 spring-core-7.0.8.jar 上双向核对)、以及基于 aot.factories 的 AOT 支持。

相关推荐
可爱的小小小狼1 小时前
【无标题】
java·算法
用户3126874877201 小时前
Java SPI 到底怎么实现动态扩展的?从 ServiceLoader 到 Dubbo SPI 全链路拆解
java
新时代农民工~1 小时前
【双机高可用部署方案-前后端部署】
java·nginx·springboot
吃饱了得干活1 小时前
一个订单的奇幻漂流:RabbitMQ 五大难题实战
spring boot·后端·rabbitmq
企业数字化笔记1 小时前
固定资产还有借用记录能报废吗?Java前置检查、停止折旧与SQL验收
java·开发语言·sql
飞虹地星海1 小时前
从零到一跑通苍穹外卖:一个大二学生的暑假项目复盘
java
她的男孩1 小时前
账号锁定配了"错 4 次锁 30 分钟",我连错 100 次一次没锁上:扒完 4091 行认证源码,找到 5 个坑
java·后端·架构
Methy1 小时前
一次线上死锁排查:std::list::size () 居然是 O (n)?
c++·后端
程序猿乐锅1 小时前
【黑马点评 | 第二篇】Redis 缓存更新策略与商铺缓存实现
java·网络·redis·spring·mybatis