文末附「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 链上:可拦截 = 可治理,次数上限由此才做得出来
- 没有
ChatClientBean,只有ChatClient.Builder(prototype);@ToolBean 不会被自动扫描 - 模型底层换成厂商官方 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.org 上 spring-ai-bom 各版本 POM 的 last-modified 响应头。)
这里有个关键、但容易被忽略的事实:1.1.8 和 2.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-client、spring-ai-starter-model-chat-memory 则依赖 spring-boot-starter:4.1.1。spring-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-memory在 1.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-openai和spring-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-webflux 的 groupId 是 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-server、spring-ai-starter-mcp-server-webmvc、spring-ai-starter-model-openai、spring-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.java 中 CallResponseSpec 的 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);
ToolCallback---直接注册;ToolCallbackProvider---直接注册,回调在请求时惰性解析;ToolCallback[]/ToolCallbackProvider[]---逐个按上面规则展开;Collection---遍历后按同样规则派发;- 任何其他对象 ---当作
@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 QuestionAnswerAdvisor 的 spring-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.Builderbean with theprototypescope, 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());
}
从这段实现能读出三条对排查问题极有价值的规则:
- 不挂的两种情况 :显式设了
AdvisorParams.toolCallingAdvisorAutoRegister(false),或者链上已经有一个ToolAdvisor。后者是"升级后工具突然不执行"的一个典型原因------如果 1.x 里手动往链上放过一个工具相关 advisor,2.0 会认为"你已经自己管了"而不再自动挂载。 - 即使一个静态工具都没配,它也会挂上。因为工具可能在运行期由别的 advisor 动态注入。
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-default、max-calls-per-tool.*、excluded-tools、max-total-tool-calls、on-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.1 和 spring-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-chat、spring-ai-model、spring-ai-commons、spring-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.aot、org.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=10、backoff.initial-interval=2s、backoff.multiplier=5、backoff.max-interval=3m、on-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-model 的 META-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):WebMvcStreamableServerTransportProvider、WebMvcSseServerTransportProvider、WebMvcStatelessServerTransport,以及 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;
}
注意 McpTool 和 Tool 是两套不同的注解 :org.springframework.ai.mcp.annotation.McpTool 用于把 Java 方法暴露成 MCP 服务器能力,org.springframework.ai.tool.annotation.Tool 用于把 Java 方法暴露成本地模型工具。桥接靠 McpToolUtils。
8.2 MCP 的自动配置
spring-ai-autoconfigure-mcp-client-common 的 AutoConfiguration.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 中,需自行确认构件是否仍发布 |
建议改(行为可能变化)
- 检查
ToolCallingManager注入点 ---如果代码里有OpenAiChatModel.Builder.toolCallingManager(...),它在 2.0 已废弃且语义变了(工具循环归 Advisor 管)。把工具配置迁到ToolCallingAdvisor.Builder上。 - 检查
@ToolBean 是否显式注册了ToolCallbackProvider---2.0 不会自动扫。 - 复查
spring.ai.retry默认值 ---max-attempts=10+multiplier=5很激进。 - 复查温度等采样参数 ---
OpenAiChatProperties.temperature是@Nullable Double,没有默认值,不配就是 null(交给服务端默认)。1.x 若有隐式默认,行为会变。 - native image 项目复查
@Tool方法的声明位置---继承来的方法可能扫不到(见 §7.4)。
可以不动
- 所有
@Tool/@ToolParam注解本身(包名org.springframework.ai.tool.annotation未变); - Jackson 的
@JsonProperty等注解(仍在com.fasterxml.jackson); ChatClient的链式调用风格(prompt().user().call().content()保持不变)。
十一、总结
四句话记住这篇:
- 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。
- 最大的架构变化是工具调用循环从 ChatModel 内部搬到了 Advisor 链上。
OpenAiChatModel.Builder.toolCallingManager(...)的@deprecated说明里写着"superseded byToolCallingAdvisorused viaChatClient"。收益是可拦截 = 可治理 :spring.ai.tools.limits.*这类工具调用次数上限,只有循环在外面才做得出来。 - 只有
ChatClient.BuilderBean,没有ChatClientBean ,且它是@Scope("prototype")的;@Tool注解的 Bean 不会被自动扫描 ,必须显式暴露ToolCallbackProvider。这两条是升级时最容易踩的坑。 - 别拿上一篇的
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 支持。