跳到主要内容

数据截至 (上游 commit c988e72ab728)

落到 Spring Boot:starter、可观测性、MCP 与工具检索

30 秒导读: 前五章讲的是抽象——ChatClient、Advisor、工具调用、可移植模型层、RAG。 这一章讲这些抽象怎么变成产品:Maven 模块怎么切、starter 怎么让你"加一个依赖 + 三行 yaml 就能跑", 以及上生产前必须补的两块——看得见(Micrometer 可观测性)和接得进(MCP 与工具检索)。


1. 这是什么(零基础也能懂)

一句话定义: 这是 Spring AI 的交付层——把散在几十个 Maven 模块里的能力,打包成 Spring Boot 用户熟悉的两样东西:一个 starter 依赖,和一串 spring.ai.* 配置项。

解决什么问题: 你要接一个大模型,原本得自己创建 HTTP client、拼 API key、装配 ChatModel、再包一层 ChatClient、再挂上 tool calling manager。这些代码每个项目都一样,而且写错一处就跑不起来。

给谁用: 用 Spring Boot 的 Java 后端工程师。你不需要理解 OpenAiChatModel 的构造参数,只要声明"我要 OpenAI",剩下的由自动装配补齐。

用起来什么样

第一步,一个依赖:

<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>

第二步,三行 yaml(属性名对应 AbstractOpenAiProperties 的字段,auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/AbstractOpenAiProperties.java:46-51):

spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
model: gpt-4o-mini

第三步,注入就用(注意注入的是 ChatClient.Builder,它是 prototype scope,ChatClientAutoConfiguration.java:112-115):

// 示意,非源码
@RestController
class ChatController {
private final ChatClient chatClient;
ChatController(ChatClient.Builder builder) { // builder 由自动装配提供
this.chatClient = builder.build();
}
@GetMapping("/ask")
String ask(String q) { return chatClient.prompt().user(q).call().content(); }
}

一句话直觉: 把 starter 想成购物清单——它自己不含代码,只负责"把该带的都带上";真正干活的是 autoconfigure 模块里那些"如果 classpath 上有 X 且属性写了 Y,就造一个 bean"的条件规则。


2. 顶层全景(它大概怎么转)

2.1 模块拓扑:三层同心圆

Spring AI 2.0 的根 pom.xml 列了 100+ 个 module(pom.xml:16-200,共 168 个)。它们不是平铺的,而是分三层——怎么读这张图:越靠内越通用,越靠外越具体;箭头是依赖方向,只能由外指内

┌──────────────────────────────────────────┐
│ starters/ —— 只有 pom,零代码 │
│ 「购物清单」,把下面两层拉齐 │
└───────────────┬──────────────────────────┘
│ 依赖
┌───────────────▼──────────────────────────┐
│ auto-configurations/ —— 条件装配 │
│ 唯一允许 depend on Spring Boot 的地方 │
└───────────────┬──────────────────────────┘
│ 依赖
┌────────────────────▼─────────────────────────────┐
│ 内核(零 Boot 依赖) │
│ commons / model / client-chat / rag / │
│ vector-store ← 抽象 │
│ models/ · vector-stores/ · mcp/ ← 具体实现 │
└───────────────────────────────────────────────────┘

各层职责与"能不能碰 Boot":

代表模块里面有什么能依赖 Spring Boot 吗
内核抽象spring-ai-commonsspring-ai-modelspring-ai-client-chatspring-ai-ragspring-ai-vector-store接口 + 默认实现,纯 Spring Framework不能
内核实现models/spring-ai-openaivector-stores/…mcp/common各家供应商 / 各种向量库的适配不能
自动装配auto-configurations/models/spring-ai-autoconfigure-model-openai*AutoConfiguration + *Properties,但所有非 autoconfigure 依赖必须 optional=true
starterstarters/spring-ai-starter-model-openai只有 pom.xml(本来就是给 Boot 用户的)

这四条规则不是口头约定,写在 design/02-boot-modularity.adoc 里,并由 maven-enforcer-pluginbannedDependencies 规则在构建期强制(pom.xml:393-414)。

2.2 一次启动,装配走一遍

怎么读这张图:从左到右是时间顺序,每一步的输出是下一步的输入。

① classpath 上出现 starter jar


② Boot 扫描每个 jar 的
META-INF/spring/…AutoConfiguration.imports
│ (得到候选 @AutoConfiguration 类清单)

③ 逐个跑条件:@ConditionalOnClass / @ConditionalOnProperty
/ @ConditionalOnMissingBean
│ (不满足的整类跳过,零副作用)

④ 满足的类里 @Bean 方法执行:
*Properties → 供应商 SDK client → ChatModel


⑤ ChatClientAutoConfiguration 拿到 ChatModel,
产出 ChatClient.Builder(prototype)

第 ⑤ 步的 ChatModel 从哪来、ChatClient 拿到后怎么组装请求,见 ChatClient:从流式 API 到一个 ChatClientRequest


3. 模块拓扑与自动装配

这节讲 starter 的"魔法"具体由哪几个文件实现。

3.1 内核不许碰 Boot:把设计文档写成构建规则

要解决的小问题: Spring AI 想让不用 Boot 的人(纯 Spring Framework、甚至纯 Java)也能用 ChatModelVectorStoreChatClient。可只要有一个内核类不小心 import 了 Boot,这个承诺就破了,而且没人会发现。

思路: 把承诺变成会让构建失败的规则。

根 pom 里配了 enforcer,把 org.springframework.boot 整个 group 列进 excludes,只对 test scope 开口子(pom.xml:406-411):

<excludes><exclude>org.springframework.boot</exclude></excludes>
<includes><include>org.springframework.boot:*:*:jar:test</include></includes>

设计文档明确列了四个例外:test 代码、autoconfigure 模块、starter 模块、docker-compose / testcontainers 模块(pom.xml:396-405 的注释,以及 design/02-boot-modularity.adoc 的 "Solution" 节)。

关键细节: autoconfigure 模块的所有非 autoconfigure 依赖都要标 optional=true——包括 spring-boot-autoconfigure 本身。后果是:如果你绕过 starter、直接依赖某个 autoconfigure 模块,得自己把 spring-boot-autoconfigure 加回来,否则 @AutoConfiguration 根本不会被触发(design/02-boot-modularity.adoc "Problems" 节)。

3.2 starter 只做依赖聚合

starters/spring-ai-starter-model-openai/pom.xml 全文没有一行 Java,<dependencies> 只有六项:

拉进来的 artifact作用行号
spring-boot-starter-restclient / -webclientHTTP 传输(同步 + 响应式):20:25
spring-ai-autoconfigure-model-openai条件装配规则:30
spring-ai-openai真正的 OpenAiChatModel 实现:36
spring-ai-client-chatChatClient 抽象:42
spring-ai-autoconfigure-model-chat-clientChatClient.Builder 的装配:48
spring-ai-autoconfigure-model-chat-memory记忆的装配:54

注意最后三项:starter 不只带自己那家模型,还顺手带上 ChatClient 和 memory 的装配。这就是"加一个依赖就能写 chatClient.prompt()"的来源。

3.3 autoconfigure 模块的标准三件套(其实是四件)

每个 spring-ai-autoconfigure-* 模块都长一个样。以 OpenAI 为例:

文件角色路径
*AutoConfiguration.java条件 + @Bean 工厂auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatAutoConfiguration.java
*Properties.java把 yaml 映射成对象auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatProperties.java:42-45(前缀 spring.ai.openai.chat
AutoConfiguration.imports告诉 Boot "我这个 jar 有哪些自动配置类"src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
additional-spring-configuration-metadata.json给 IDE 补全用的属性元数据src/main/resources/META-INF/additional-spring-configuration-metadata.json

imports 文件就是一行一个全限定类名,OpenAI 那份列了六个(chat / embedding / image / speech / transcription / moderation),一个模型家族的六种模态各自独立装配。

第四件 additional-spring-configuration-metadata.json 常被忽略:Boot 的 annotation processor 能从 *Properties 的字段自动生成大部分元数据,这个文件补的是处理器推不出来的部分——比如 spring.ai.openai.chat.output-audio.voice 这种嵌套在第三方类型里的属性,得手写 groups + properties 才能在 IDE 里补全。

3.4 同类模型多实现时,按属性择一

要解决的小问题: 你 classpath 上同时有 OpenAI 和 Anthropic 两个 starter,谁来当 ChatModel?两个都造就冲突,都不造就没法用。

思路: 用一个约定俗成的属性 spring.ai.model.chat 做单选开关,各家的 autoconfigure 都检查它、只有值等于自己名字时才装配。

两个常量类支撑这套约定:

// SpringAIModelProperties.java:25-27
public static final String MODEL_PREFIX = "spring.ai.model";
public static final String CHAT_MODEL = MODEL_PREFIX + ".chat";

SpringAIModelProperties 按模态列了七个开关键(chat / embedding / image / audio.transcription / audio.speech / moderation 等,SpringAIModelProperties.java:25-41);SpringAIModels 列了各家的字符串标识(anthropicopenaiollama…,SpringAIModels.java:25-57)。

落到条件注解上就是一行(OpenAiChatAutoConfiguration.java:55-56):

@ConditionalOnProperty(name = SpringAIModelProperties.CHAT_MODEL,
havingValue = SpringAIModels.OPENAI, matchIfMissing = true)

关键细节:matchIfMissing = true 属性不写时所有家的条件都通过——这在只装了一个 starter 时最省事(零配置即可用),但装了两个就会同时装配、然后在 ChatClient.Builder 注入 ChatModel 时报"期望一个却找到两个"。多模型共存时必须显式写 spring.ai.model.chat=openai

@Bean 上还叠了 @ConditionalOnMissingBeanOpenAiChatAutoConfiguration.java:59-60),意思是"你自己声明了 OpenAiChatModel 就用你的"——用户覆盖优先于自动装配,这是 Boot 的通用礼节。

3.5 JSpecify null 规约:为什么 *Properties 的 getter 大多带 @Nullable

Spring AI 2.0 全面接入 JSpecify + NullAway + ErrorProne 做编译期空值检查(design/01-null-safety.adoc)。三条对读源码最有用的规则:

  • 粒度是包,不是类。 每个新包要在 package-info.java 上标 @NullMarked;Java 的包不是层级的,a.b.c 不会继承 a.b 的标注,得逐包标。
  • 注解写在类型上,不是成员上。 正确写法是 private @Nullable Foo foo;public @Nullable Foo something(),不是 @Nullable private Foo foo;。所以你在 OpenAiChatProperties.java:47-79 看到的是清一色的 private @Nullable String model;
  • @ConfigurationProperties 有专门约定: 字段有默认值 → 不标(Boot 不会注入 null);字段没默认值 → getter setter 都标 @Nullable。setter 标注纯粹是为了让 Kotlin 把属性识别成可空类型。

3.6 AOT 与 native image

要解决的小问题: GraalVM native image 会把没被静态分析到的反射路径裁掉。而 Spring AI 到处是反射——消息类型的 JSON 序列化、@Tool 方法的动态调用、MCP schema 的解析。

Spring AI 的做法分两类:

第一类,静态清单 RuntimeHintsRegistrar SpringAiCoreRuntimeHints 把消息体系(AbstractMessageUserMessageToolResponseMessageToolCallbackToolDefinition 等 12 个类型)连同它们的内部类一并注册反射,再注册一个资源文件(SpringAiCoreRuntimeHints.java:44-60)。MCP 侧有对应的 McpHints,把 McpSchema 的全部嵌套类注册进去(mcp/common/src/main/java/org/springframework/ai/mcp/aot/McpHints.java:62-68)。

第二类,动态扫描 BeanRegistrationAotProcessor 静态清单管不了用户自己写的 @Tool 方法。ToolBeanRegistrationAotProcessor 在构建期遍历每个 bean 的方法,只要有一个带 @Tool,就给整个 bean 类注册 INVOKE_DECLARED_METHODS + INVOKE_PUBLIC_METHODS 反射提示(spring-ai-model/src/main/java/org/springframework/ai/aot/ToolBeanRegistrationAotProcessor.java:44-57):

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

两类都在 spring-ai-model/src/main/resources/META-INF/spring/aot.factories:1-7 注册。@Tool 注解本身怎么变成可调用的工具,见 工具调用:从 @Tool 注解到多轮循环


4. 可观测性:五个 observation,四层嵌套

这节讲你把 Spring AI 上线后,怎么知道"这次请求花了多久、烧了多少 token、模型说了什么"。

4.1 为什么要分这么多层

要解决的小问题: 一次 chatClient.prompt().call() 里,慢的可能是模型、可能是 RAG 检索、可能是某个工具在等外部 HTTP。只测最外层等于什么都没测。

Spring AI 在每一个"可能慢"的边界都埋了一个 Micrometer observation。怎么读这张图:缩进表示嵌套,内层的 span 是外层的 child。

spring.ai.chat.client ← ChatClient 一次完整调用
└─ spring.ai.advisor ← 链上每个 advisor 各一个
└─ db.vector.client.operation ← RAG advisor 触发的检索(旁挂)
└─ gen_ai.client.operation ← 真正打给供应商的那一次
└─ spring.ai.tool ← 模型要求执行的每个工具

五个 observation 的埋点位置和默认名:

observation默认名埋点位置
ChatClientspring.ai.chat.clientDefaultChatClient.java:652
Advisorspring.ai.advisorDefaultAroundAdvisorChain.java:113
ChatModelgen_ai.client.operation各模型实现,如 models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java:213
ToolCallspring.ai.toolDefaultToolCallingManager.java:290
VectorStoredb.vector.client.operationAbstractObservationVectorStore.java:81

前两个的 advisor 链结构见 Advisor 责任链,最后一个的检索语义见 记忆、RAG 与向量存储

4.2 统一的三件套模式

每一层都由三个类构成,模式完全一致:

角色干什么ChatModel 层的例子
*ObservationContext承载这次操作的请求/响应/元数据ChatModelObservationContext
*ObservationConvention决定 observation 叫什么、打哪些标签DefaultChatModelObservationConvention
*ObservationDocumentation枚举合法的低/高基数键名,供文档与校验ChatModelObservationDocumentation

*Documentation 是个 enum,实现 Micrometer 的 ObservationDocumentation,同时声明默认 convention 和两组键名(ChatModelObservationDocumentation.java:34-52)。这样"有哪些标签"是代码里可枚举的,而不是散落在字符串里。

替换约定不用改代码:往容器里放一个 ChatModelObservationConvention bean,自动装配会把它 set 进模型(OpenAiChatAutoConfiguration.java:89observationConvention.ifAvailable(chatModel::setObservationConvention))。

4.3 对齐 OTel GenAI 语义约定

思路: 不自创一套指标名,直接用 OpenTelemetry 的 GenAI semantic conventions,这样 Grafana / Datadog 的现成看板能直接用。

命名上,ChatModel 层的 observation 名就是 OTel 的 gen_ai.client.operationDefaultChatModelObservationConvention.java:40),contextual name 拼成 "<操作类型> <模型名>":54-60),比如 chat gpt-4o-mini

标签分两组,分组标准是"这个值的取值空间有多大"——低基数进指标维度,高基数只进 trace:

分组OTel 属性名为什么归这组
低基数AI_OPERATION_TYPEgen_ai.operation.name取值只有 chat/embedding/image 等六个
低基数AI_PROVIDERgen_ai.system供应商数量有限
低基数REQUEST_MODEL / RESPONSE_MODELgen_ai.request.model / gen_ai.response.model模型名有限,且缺失时回落到 KeyValue.NONE_VALUE 常量
高基数temperature / max_tokens / top_p / stop_sequences…gen_ai.request.*连续值或用户自定义,做维度会指标爆炸
高基数finish_reasons / response.id / 各类 token 数gen_ai.response.*gen_ai.usage.*每次请求都不同

低基数四个键在 DefaultChatModelObservationConvention.java:63-66,高基数十几个键在 :96-117,属性名到 OTel 字符串的映射集中在 spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiObservationAttributes.java:37-140

一处不对齐的地方(值得注意): 工具名用的是 spring.ai.model.request.tool.namesAiObservationAttributes.java:76),不是 gen_ai.*——因为 OTel 当时没有对应约定,Spring AI 自己开了个命名空间。ToolCall / Advisor / ChatClient / VectorStore 这四层同理,都额外打一个 spring.ai.kind 标签区分种类(取值 advisor / chat_client / tool_call / vector_store,见 SpringAiKind.java:32-47)。

4.4 记内容是开关,而且默认关

要解决的小问题: prompt 和 completion 里全是用户数据。默认打出去等于把隐私写进日志。

Spring AI 的处理很直白:内容记录是两个独立的 ObservationHandler,默认不注册,打开时还要在启动日志里喊一句

// ChatObservationProperties.java:36-41,两个开关默认 false
private boolean logCompletion = false;
private boolean logPrompt = false;

对应 spring.ai.chat.observations.log-prompt / log-completion(前缀见 ChatObservationProperties.java:31)。打开后装配 handler,并调用 logPromptContentWarning() 打一条 warn:内容是"你开启了 prompt 内容日志,有暴露敏感信息的风险,请小心"(ChatObservationAutoConfiguration.java:65-73)。

handler 本身只做一件事——在 onStop 时把 prompt 各条消息的文本拼起来 logger.info 出来(ChatModelPromptContentObservationHandler.java:41-54)。

巧妙之处:同一个 handler 有两套装配路径。 classpath 上有 Micrometer Tracing 时,handler 被包进 TracingAwareLoggingObservationHandler,日志里就带 traceId;没有 Tracer 时直接裸用。两条路径分别放在两个内部 @Configuration 里,用 @ConditionalOnBean(Tracer.class)@ConditionalOnMissingClass("io.micrometer.tracing.Tracer") 互斥(ChatObservationAutoConfiguration.java:82-144)。

4.5 token 指标

ChatModelMeterObservationHandler 挂在 MeterRegistry 上,条件是容器里真有 MeterRegistryChatObservationAutoConfiguration.java:75-80)。它只在 onStop 触发,且响应带 usage 才动作(ChatModelMeterObservationHandler.java:39-46)。

真正的计数逻辑在 ModelUsageMetricsGenerator.generate():往同一个 counter gen_ai.client.token.usage 打三条,用 gen_ai.token.type 标签区分 input / output / total(ModelUsageMetricsGenerator.java:46-73,指标名见 AiObservationMetricNames.java:35-39)。

关键细节: counter 的 tag 只取 observation 的低基数键(ModelUsageMetricsGenerator.java:77-83)。这正是 4.3 那张表的实际用途——分组不是文档洁癖,是防止 temperature 这种连续值变成指标维度、把时序库撑爆。

// ModelUsageMetricsGenerator.java:79-81
for (KeyValue keyValue : context.getLowCardinalityKeyValues()) {
tags.add(Tag.of(keyValue.getKey(), keyValue.getValue()));
}

5. MCP:把别人的工具接进来,把自己的工具发出去

这节讲 Spring AI 怎么和 Model Context Protocol(模型上下文协议,一套让 AI 应用发现/调用外部工具的标准)互通。

MCP 支持切成三个模块(pom.xml:100-103):

模块干什么
mcp/common双向适配:MCP tool ↔ Spring AI ToolCallback
mcp/mcp-annotations服务端注解(@McpTool / @McpResource / @McpPrompt)与方法回调生成
mcp/transport/mcp-spring-webmvcmcp-spring-webflux两套传输实现(Servlet 阻塞 / Reactor 响应式)

5.1 客户端方向:MCP tool → ToolCallback

思路: Spring AI 内部只认 ToolCallback 这一个接口(见 工具调用)。所以接 MCP 只需要写一个适配器,让远程工具"看起来像本地工具"。

SyncMcpToolCallback implements ToolCallbackmcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallback.java:47)就是这个适配器,三步:

  1. getToolDefinition() 把 MCP 的 Tool 转成 Spring AI 的 ToolDefinition,用的是加前缀后的名字(:94-97)。
  2. call() 把模型给的 JSON 字符串反序列化成 map,包成 CallToolRequest 发给远端(:126-135)。
  3. 把结果 content 序列化回 JSON 字符串返回(:157)。

关键细节:发请求时用的是原始工具名,不是前缀名。 源码里连着写了两遍注释强调这点(:129-133):

// Use the original tool name, not the prefixed one from getToolDefinition
var request = CallToolRequest.builder(this.tool.name()).arguments(arguments).meta(mcpMeta).build();

错误处理分两档(这个区分很讲究):

异常类型处理后果
McpError(协议层错误)原样 rethrow(:136-145硬失败,整次模型交互中断
其它异常、或 response.isError() 为真包成 ToolExecutionException:146-156由 tool calling manager 捕获,错误信息可以回喂给模型

理由写在注释里:协议层错误说明"调用本身没成功",不该被当成工具的业务返回值喂给模型。

5.2 名字冲突:前缀生成器

要解决的小问题: 你连了三个 MCP server,其中两个都提供叫 search 的工具。模型看到两个同名工具就没法选了。

McpToolNamePrefixGenerator 是策略接口,只有一个方法,外加一个"不加前缀"的静态工厂(McpToolNamePrefixGenerator.java:39-48)。

默认实现 DefaultMcpToolNamePrefixGenerator 的策略是懒加前缀——不冲突就不改名(:49-90):

tool.name()
│ format() 洗掉非法字符

uniqueToolName

├─ 这个 (client, server, tool) 组合见过?── 是 ──▶ 直接返回(幂等)
│ 否

├─ 名字没被占用?── 是 ──▶ 直接返回
│ 否

└──▶ 改成 "alt_<N>_<name>",并 warn 一句

两个集合分工:existingConnections 保证同一连接的同一工具只算一次(幂等),allUsedToolNames 记录全局占用的名字。计数器 AtomicInteger 从 1 开始递增。

另有一套基于"客户端名 + title + 工具名"拼接的静态方法 McpToolUtils.prefixedToolName(),超过 64 字符时保留后 64 位McpToolUtils.java:92-113)——因为多数供应商对工具名有长度上限,而后缀(真实工具名)比前缀更有信息量。

5.3 过滤与热更新

McpToolFilter 就是个 BiPredicate<McpConnectionInfo, Tool>McpToolFilter.java:30),让你按连接或工具属性筛掉不想暴露给模型的工具。

McpToolsChangedEvent 是个 Spring ApplicationEvent,带连接名和新工具列表(McpToolsChangedEvent.java:30-40)。MCP server 支持 tools/list_changed 通知,工具集会在运行时变。

两者在 SyncMcpToolCallbackProvider 里汇合。这个类同时实现了 ToolCallbackProviderApplicationListener<McpToolsChangedEvent>:43),用一个"带失效标记的缓存"处理热更新:

  • getToolCallbacks() 命中缓存直接返回;未命中则加锁、listTools、过滤、加前缀、建 callback(:125-155)。
  • 收到 McpToolsChangedEvent 就只把 invalidateCache 置 true(:158-161),不立刻重建——下次真正要用时才重新拉。

自动装配里,前缀生成器和过滤器都是可覆盖的 bean:默认注册 DefaultMcpToolNamePrefixGeneratorMcpToolCallbackAutoConfiguration.java:50-53),但 provider 组装时用的是 getIfUnique(() -> McpToolNamePrefixGenerator.noPrefix()):81-82)——容器里有多个候选时回落到"不加前缀",这点容易踩坑。

5.4 服务端方向:@McpTool 与方法回调

反过来,把你的 Spring bean 方法暴露成 MCP 工具,用 mcp/mcp-annotations 的注解:

注解暴露成 MCP 的什么
@McpTooltool(可调用的动作)
@McpResourceresource(可读取的数据)
@McpPromptprompt(可复用的提示模板)
@McpCompletecompletion(参数自动补全)

@McpTool 的属性覆盖了 MCP 规范里 tool 声明的字段:name / description / title / generateOutputSchema,外加一组 hint(readOnlyHintdestructiveHint 等)和一个 metaProviderMcpTool.java:37-72)。注释里有一句值得抄走:这些 hint 不保证如实描述工具行为,客户端不该基于不可信服务器给的 hint 做决策(:77-82)。

装配链路是"扫描 → 转规格"两步:

应用里的 bean

▼ ServerAnnotatedMethodBeanPostProcessor
(扫 @McpTool/@McpResource/@McpPrompt/@McpComplete 四种注解)

ServerMcpAnnotatedBeans 注册表

▼ McpServerSpecificationFactoryAutoConfiguration
(按 spring.ai.mcp.server.type=SYNC/ASYNC 分支)

McpServerFeatures.SyncToolSpecification 列表 → 交给 MCP server

扫描端在 McpServerAnnotationScannerAutoConfiguration.java:54-73,四种注解写死在一个 Set 里;同一个类还顺手把这四个注解注册进 native image 反射提示(:98-105),并挂了一个 AOT processor。

如果你的工具本来就是 Spring AI 的 ToolCallback(比如 @Tool 方法生成的),不必重写——McpToolUtils.toSyncToolSpecification() 直接把 ToolCallback 转成 MCP 规格(McpToolUtils.java:152-172),同一个工具既能给本地模型用,也能发布给远端 MCP 客户端。

5.5 两套 transport

mcp/transport 下是 webmvc 和 webflux 两份平行实现,每份各提供三种服务端传输:

类名后缀协议形态
*SseServerTransportProviderHTTP + SSE(旧版 MCP 传输)
*StreamableServerTransportProviderStreamable HTTP(新版)
*StatelessServerTransport无会话模式

webflux 侧还多两个客户端传输(WebFluxSseClientTransportWebClientStreamableHttpTransport)。选哪套由 starter 决定:spring-ai-starter-mcp-server-webmvcspring-boot-starter-web + mcp-spring-webmvcstarters/spring-ai-starter-mcp-server-webmvc/pom.xml:22,45),webflux 版同理。


6. 工具检索:用一个工具去找工具(2.0 新增)

6.1 要解决的小问题

你接了五个 MCP server,一共 300 个工具。全部工具定义塞进 prompt,光工具 schema 就吃掉几万 token,而且模型在 300 个选项里选得更差。

思路(这是 2.0 才有的新招): 一开始只给模型一个工具——toolSearchTool。模型发现自己缺能力时,先调它做一次自然语言检索,拿到几个工具名;下一轮再把这几个工具的完整定义注入 options。工具定义按需加载,而不是一次全给。

第 1 轮 options 里只有 [toolSearchTool]
│ 模型: 「我需要查天气」→ 调 toolSearchTool("weather forecast")

工具返回 ["getWeather", "getForecast"]

第 2 轮 advisor 从历史里读出这两个名字,
│ 把它们的 callback 注入 options
▼ options = [toolSearchTool, getWeather, getForecast]
模型: 正常调用 getWeather

6.2 ToolSearchTool:一个普通的 @Tool

它本身没什么特别,就是个带 @Tool 注解的方法(spring-ai-tool-search-tool/src/main/java/org/springframework/ai/tool/toolsearch/ToolSearchTool.java:42-65),三个参数:query(必填,自然语言)、maxResults(选填,1-10)、categoryFilter(选填)。

两个细节:

  • sessionId 走 ToolContext 而不是参数,从常量键 toolSearchToolSessionId 里取(:30:54-55)。模型不知道 session 的存在,也不该能伪造它。
  • maxResults 的回落顺序是"模型给的 > advisor 配的":57-59):模型没给才用配置默认值。
  • 返回值只是工具名的字符串列表:64),不是完整定义——完整定义由 advisor 在下一轮注入。

6.3 ToolIndex 的三种实现

接口只有四个方法:indexTool / indexTools(批量,默认实现是循环单条)/ search / clearIndex,全部带 sessionId 做隔离(ToolIndex.java:32-70)。

实现检索方式额外依赖特点
RegexToolIndex正则 / 关键词匹配无(默认)最轻,模式长度上限 200 字符(RegexToolIndex.java:62
LuceneToolIndexLucene 全文检索lucene-core有分数阈值,默认 minScoreThreshold 0.25(ToolSearchAdvisorProperties.java:214
VectorToolIndex向量语义检索一个 VectorStore bean默认相似度阈值 0.2(VectorToolIndex.java:67);不支持 categoryFilter,传了会 warn 并忽略:133-136

三者都用 Map<String, SessionIndex>ConcurrentHashMap<String, List<String>> 按 session 分区。VectorToolIndex 重写了 indexTools 做真批量——把所有工具攒成一个 Document 列表,一次 vectorStore.add():106-130),省掉 N 次 embedding API 调用。

6.4 ToolSearchToolCallingAdvisor:靠继承钩子接进 advisor 链

它不是新写一个 advisor,而是继承 ToolCallingAdvisor 覆写四个钩子advisors/spring-ai-tool-search-advisor/src/main/java/org/springframework/ai/chat/client/advisor/toolsearch/ToolSearchToolCallingAdvisor.java:76):

钩子时机干什么
doInitializeLoop / doInitializeLoopStream多轮工具循环开始前,一次initializeSession():建索引 + 改 system message
doBeforeCall / doBeforeStream每一轮请求发出前prepareIteration():挑工具注入 options

四个钩子都先判断 getOptions() instanceof ToolCallingChatOptions,不是就直接走父类逻辑(:146-183)。工具循环本身的结构见 工具调用Advisor 责任链

initializeSession() 做三件事:194-240):

  1. 先跑 eviction,清掉过期 session 的索引(:200)。
  2. toolCallingManager.resolveToolDefinitions() 拿到全部工具,转成 ToolReference(名字 + 描述),算指纹后按需重建索引。
  3. 把工具搜索的说明追加到 system message 末尾(默认文案在 spring-ai-tool-search-tool/src/main/resources/DEFAULT_SYSTEM_PROMPT_SUFFIX.md)。

指纹缓存是这里最值钱的一招。 同一个会话每轮都会走 initializeSession,每次重建索引(尤其向量索引)代价极高。做法是给工具集算一个 SHA-256 指纹,指纹没变就跳过重建(:214-221):

this.indexedSessionFingerprints.compute(sessionId, (id, current) -> {
if (!fingerprint.equals(current)) {
this.toolIndex.clearIndex(id);
this.toolIndex.indexTools(id, toolReferences);
}
return fingerprint;
});

指纹算法有两个细节值得抄(computeFingerprint:338-352):

  • 先按名字排序,让注册顺序不影响结果。
  • 用 SHA-256 而不是字符串拼接,并在字段间插 \0、条目间插 \1 作分隔——注释明说这是为了避免"名字或描述里恰好含分隔符"造成的假命中。
  • ConcurrentHashMap.compute() 而非先查后写,让并发的同 session 请求串行化,只有一个线程执行 clear+reindex。

prepareIteration() 负责按需注入:245-272):先放入 toolSearchTool 自己,再从对话历史里扒出之前 toolSearchTool 返回过的工具名,能在缓存里找到 callback 的就加进去,最后 mutate 出一份新的 options。

从历史里扒名字的逻辑考虑了并行工具调用(extractToolNameReferences:291-321):先按 ToolResponseMessage 分组——一个助手回合可能并行发起多次 toolSearchTool,它们都落在同一条 tool 消息里。referenceToolNameAccumulation 开关决定"累积所有历史轮次"还是"只认最近一轮",但即使只认最近一轮,那一轮里的多次并行调用都要保留

eviction 策略是可插拔的两方法接口 onAccess / onRemovedToolIndexEvictionStrategy.java:23-57),四个实现:

策略语义
NeverEvictStrategy不主动清,索引留到显式移除
AlwaysEvictStrategy每次请求都重建
LruEvictionStrategy用 access-order 的 LinkedHashMap 封顶 N 个 session(LruEvictionStrategy.java:56),默认 N=1000
TtlEvictionStrategylastAccess 时间戳,惰性判过期(TtlEvictionStrategy.java:45,58
CompositeEvictionStrategy把多个策略的淘汰集合取并集(CompositeEvictionStrategy.java:50-51

默认是 new LruEvictionStrategy(1000)ToolSearchToolCallingAdvisor.java:387)。

6.5 自动装配的"偷梁换柱"

要解决的小问题: 怎么让工具检索版 advisor 顶替掉默认的 ToolCallingAdvisor,而不改 ChatClientAutoConfiguration 一行代码?

招法:利用 @ConditionalOnMissingBean 的类型匹配。 ChatClientAutoConfiguration 里那个默认 builder bean 的声明类型是 ToolCallingAdvisor.Builder<?> 且带 @ConditionalOnMissingBeanChatClientAutoConfiguration.java:97-110)。工具检索的自动配置提前注册一个同样声明成 ToolCallingAdvisor.Builder<?>、实际是子类实例的 bean,默认那个的条件就不成立了(ToolSearchAdvisorAutoConfiguration.java:122-149)。

顺序是关键,两个约束写在注解里(:93):

@AutoConfiguration(after = ToolCallingAutoConfiguration.class, before = ChatClientAutoConfiguration.class)
  • after ToolCallingAutoConfiguration —— 本 bean 有 @ConditionalOnBean(ToolCallingManager.class),manager 还没造出来条件就假。
  • before ChatClientAutoConfiguration —— 替身必须抢在默认 builder 评估条件之前注册。

整个配置由 spring.ai.chat.client.tool-search-advisor.enabled=true 总开关控制(:96,默认 false,见 ToolSearchAdvisorProperties.java:46)。索引类型由 tool-index-type 选,默认 regexToolSearchAdvisorProperties.java:62);写了未知值会 warn 但不失败(ToolSearchAdvisorAutoConfiguration.java:110-117),而选了 vector 却没有 VectorStore bean 则快速失败并给出可操作的报错文案(:172-181)。


7. 巧妙之处(可借鉴的技术)

① 把架构约定编译进构建。 "内核不许依赖 Boot"这种规矩,靠 code review 迟早失守。写成 enforcer 的 bannedDependencies 就变成了会红的构建(pom.xml:393-414),配套设计文档 design/02-boot-modularity.adoc 解释为什么。

② 用属性做"同类多实现"单选,而不是靠 classpath 猜。 SpringAIModelProperties.CHAT_MODEL + SpringAIModels.OPENAI 这对常量,让十几家供应商的 autoconfigure 用同一套条件写法(OpenAiChatAutoConfiguration.java:55-56)。

③ 低/高基数分组不是文档洁癖,是指标基数保护。 token counter 只取低基数键当 tag(ModelUsageMetricsGenerator.java:79-81),这条规矩把"temperature 变成指标维度"这类事故堵死在设计层。

④ 敏感内容记录做成"默认关 + 开了就警告"。 两个 boolean 默认 false,打开时在装配阶段就 logger.warnChatObservationAutoConfiguration.java:65-73)。这比写在文档里管用。

⑤ 缓存失效用"标记 + 惰性重建"。 MCP 工具变更事件只置 invalidateCache = trueSyncMcpToolCallbackProvider.java:158-161),不立即 listTools——避免频繁通知打爆远端。

⑥ 用内容指纹替代"每次都重建"。 工具集 SHA-256 指纹 + ConcurrentHashMap.compute() 串行化,把向量索引的重建次数从"每轮一次"压到"工具集变了才一次"(ToolSearchToolCallingAdvisor.java:214-221, 338-352)。分隔符用 \0/\1 防拼接歧义,是个小而正确的细节。

⑦ 靠 @ConditionalOnMissingBean 的类型匹配做无侵入替换。 注册一个子类实例、声明成父类型、抢在默认配置前,就替换掉了默认 advisor,被替换方零改动(ToolSearchAdvisorAutoConfiguration.java:93, 122-128)。


8. 边界与局限

  • matchIfMissing = true 在多 starter 场景会咬人。 不显式写 spring.ai.model.chat,两家的 ChatModel 会同时装配,然后在注入点炸(OpenAiChatAutoConfiguration.java:55-56)。
  • 直接依赖 autoconfigure 模块要自己补依赖。 因为里面的依赖全是 optional=true,包括 spring-boot-autoconfigure。设计文档明说这会让部分用户"加回过去被错误传递进来的依赖"(design/02-boot-modularity.adoc 的 "User Impact")。
  • MCP 前缀生成器的装配有个静默回落。 provider 用 getIfUnique(...),容器里有多个 McpToolNamePrefixGenerator回落到不加前缀McpToolCallbackAutoConfiguration.java:81-82),冲突问题会悄悄回来。
  • VectorToolIndex 不支持 categoryFilter 只 warn 不报错(VectorToolIndex.java:133-136),所以 prompt 里让模型用 category 过滤时,vector 索引下会静默失效。
  • 工具检索多花一次模型往返。 第一轮必然被用来做检索,工具总数不大时(几十个)不划算。
  • JSpecify 检查只在标了 @NullMarked 的包内生效,且消费方不配 NullAway 的话完全没有约束力(design/01-null-safety.adoc 的 "User Impact")。
  • design/01-null-safety.adoc 里 "Nullability and the Builder pattern" 一节写着 TODO——builder 的空值规约尚未成文。

9. 代码地图(导航索引)

主题文件路径关键符号
模块分层与依赖禁令pom.xml<modules>bannedDependencies
模块化设计取舍design/02-boot-modularity.adoc
JSpecify null 规约design/01-null-safety.adoc@NullMarked@Nullable
starter 依赖聚合样板starters/spring-ai-starter-model-openai/pom.xml
自动装配样板auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatAutoConfiguration.javaOpenAiChatAutoConfiguration
自动配置类清单同上 src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
模型择一常量spring-ai-model/src/main/java/org/springframework/ai/model/SpringAIModelProperties.javaspring-ai-model/src/main/java/org/springframework/ai/model/SpringAIModels.javaCHAT_MODELOPENAI
ChatClient 装配auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientAutoConfiguration.javachatClientBuildertoolCallingAdvisorBuilder
工具装配auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/main/java/org/springframework/ai/model/tool/autoconfigure/ToolCallingAutoConfiguration.javatoolCallbackResolverisMcpToolCallbackProvider
AOT 注册入口spring-ai-model/src/main/resources/META-INF/spring/aot.factories
核心反射提示spring-ai-model/src/main/java/org/springframework/ai/aot/SpringAiCoreRuntimeHints.javaregisterHints
@Tool 的 AOT 处理spring-ai-model/src/main/java/org/springframework/ai/aot/ToolBeanRegistrationAotProcessor.javaToolBeanRegistrationAotProcessor
ChatModel 观测约定spring-ai-model/src/main/java/org/springframework/ai/chat/observation/DefaultChatModelObservationConvention.javaDEFAULT_NAMEgetLowCardinalityKeyValues
观测键名枚举spring-ai-model/src/main/java/org/springframework/ai/chat/observation/ChatModelObservationDocumentation.javaCHAT_MODEL_OPERATIONLowCardinalityKeyNames
OTel 属性映射spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiObservationAttributes.javaAI_OPERATION_TYPEUSAGE_INPUT_TOKENS
token 指标生成spring-ai-model/src/main/java/org/springframework/ai/model/observation/ModelUsageMetricsGenerator.javageneratecreateTags
观测自动装配auto-configurations/models/chat/observation/spring-ai-autoconfigure-model-chat-observation/src/main/java/org/springframework/ai/model/chat/observation/autoconfigure/ChatObservationAutoConfiguration.javaTracerPresentObservationConfiguration
观测开关属性同目录 ChatObservationProperties.javaCONFIG_PREFIXlogPrompt
MCP 工具适配mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallback.javacallgetToolDefinition
MCP 工具发现与缓存mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallbackProvider.javagetToolCallbacksonApplicationEvent
MCP 名字防重mcp/common/src/main/java/org/springframework/ai/mcp/DefaultMcpToolNamePrefixGenerator.javaprefixedToolName
MCP 双向转换工具mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.javatoSyncToolSpecificationcreateToolDefinition
MCP 服务端注解mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/McpTool.javaMcpToolMcpAnnotations
MCP 注解扫描装配auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/java/org/springframework/ai/mcp/server/common/autoconfigure/annotations/McpServerAnnotationScannerAutoConfiguration.javaSERVER_MCP_ANNOTATIONS
工具检索接口spring-ai-tool-search-tool/src/main/java/org/springframework/ai/tool/toolsearch/ToolIndex.javaindexToolssearch
「找工具的工具」spring-ai-tool-search-tool/src/main/java/org/springframework/ai/tool/toolsearch/ToolSearchTool.javatoolSearchToolTOOL_SEARCH_TOOL_SESSION_ID_KEY
三种索引实现spring-ai-tool-search-tool/src/main/java/org/springframework/ai/tool/toolsearch/index/{lucene,regex,vectorstore}/LuceneToolIndexRegexToolIndexVectorToolIndex
索引淘汰策略spring-ai-tool-search-tool/src/main/java/org/springframework/ai/tool/toolsearch/eviction/LruEvictionStrategyTtlEvictionStrategy
工具检索 advisoradvisors/spring-ai-tool-search-advisor/src/main/java/org/springframework/ai/chat/client/advisor/toolsearch/ToolSearchToolCallingAdvisor.javainitializeSessionprepareIterationcomputeFingerprint
工具检索装配auto-configurations/advisors/spring-ai-autoconfigure-tool-search-advisor/src/main/java/org/springframework/ai/chat/client/advisor/toolsearch/autoconfigure/ToolSearchAdvisorAutoConfiguration.javatoolCallingAdvisorBuilderbuildEvictionStrategy