Spring AI 2.0.1 深度解析-第 22 章 Spring Boot 集成:自动配置、Properties 与条件装配
22.0 本章总纲(先给结论,后面逐条拆到源码)
- 2.0.1 的 Spring Boot 集成面是 54 个 autoconfigure 模块 / 89 个
@AutoConfiguration类 / 48 个 starter / 85 个@ConfigurationProperties文件 ,四者不是一一对应------一个 starter 往往拖进三四个 autoconfigure 模块。 - 存在两套互相独立的「选型开关」 :
spring.ai.model.<capability>(模型侧,9 个常量)与spring.ai.vectorstore.type(向量库侧,19 个常量)。二者都是matchIfMissing = true------不是"默认关",而是"默认全开"。 - ★ 因为默认全开,同时引入两个 chat starter 会得到两个
ChatModelbean,且全仓库@ConditionalOnSingleCandidate的使用数为 0 ,没有任何兜底,注入点直接NoUniqueBeanDefinitionException。 - ★
ChatClient.Builder是@Scope("prototype"),但它没有@ConditionalOnBean(ChatModel.class)------没有模型时启动不报错,错误被推迟到第一次注入;而注入进 singleton 又会让 prototype 失效,只拿到一个实例。 - ★
OpenAiAutoConfigurationUtil.resolveCommonProperties()的两级合并有六种不同的语义 :timeout与maxRetries用"是否等于默认值"判断,所以显式写默认值无法覆盖父级 ;isMicrosoftFoundry/isGitHubModels用 OR,所以子级关不掉父级。 - ★ 项目里有两套重试 :
SpringAiRetryAutoConfiguration(org.springframework.core.retry.RetryTemplate,默认 10 次 / 2s / ×5 / 上限 180s)与供应商 SDK 内建重试(OpenAIspring.ai.openai.max-retries,默认 3)。OpenAI 与 Anthropic 走后者,spring.ai.retry.*对它们完全无效 ,但 openai starter 依然传递依赖了 retry 模块,于是建出一个没人用的RetryTemplatebean。 ToolCallingAutoConfiguration排除 MCP provider 用的是字符串比较类全名 (isMcpToolCallbackProvider),不是instanceof------因为 MCP 是可选依赖。- 265 个
@DeprecatedConfigurationProperty分布在 24 个文件里,几乎全是 1.x 的...options.*→ 2.x 的扁平化迁移;OpenAiChatProperties.Options甚至是个非 static 的内部类。
22.1 集成面全景:五十四、八十九、四十八、八十五
22.1.1 四个数字怎么来的
bash
# 1. autoconfigure 模块数 = 54
find auto-configurations -name pom.xml | wc -l
# 2. AutoConfiguration.imports 文件数 = 54(与模块数一一对应)
find . -name "org.springframework.boot.autoconfigure.AutoConfiguration.imports" | grep -v target | wc -l
# 3. @AutoConfiguration 类数 = 89(全部顶层,无嵌套标注)
grep -rn "^@AutoConfiguration" --include=*.java . | grep -v "/test/" | wc -l
# 4. starter 模块数 = 48
ls -d starters/*/ | wc -l
# 5. @ConfigurationProperties 文件数 = 85
grep -rln "@ConfigurationProperties" --include=*.java . | grep -v "/test/" | wc -l
★ 注意 89 > 54 :一个模块里可以有多个 @AutoConfiguration。例如 spring-ai-autoconfigure-model-openai 一个模块就有 OpenAiChatAutoConfiguration、OpenAiEmbeddingAutoConfiguration、OpenAiImageAutoConfiguration、OpenAiAudioSpeechAutoConfiguration、OpenAiAudioTranscriptionAutoConfiguration、OpenAiModerationAutoConfiguration 六个。
22.1.2 模块按类别分组
| 类别 | 模块数 | 代表 |
|---|---|---|
advisors/ |
1 | spring-ai-autoconfigure-tool-search-advisor |
common/ |
1 | spring-ai-autoconfigure-retry |
mcp/ |
6 | client-common / client-httpclient / client-webflux / server-common / server-webflux / server-webmvc |
models/chat/*(client、memory、memory-redis、observation) |
4 | spring-ai-autoconfigure-model-chat-client |
models/chat/memory/repository/* |
5 | cassandra / jdbc / mongodb / neo4j / redis |
models/{embedding,image}/observation、models/tool |
3 | spring-ai-autoconfigure-model-tool |
models/spring-ai-autoconfigure-model-* |
12 | anthropic / bedrock-ai / deepseek / elevenlabs / google-genai / mistral-ai / ollama / openai / postgresml-embedding / stability-ai / transformers / vertex-ai |
vector-stores/ |
22 | 含 vector-store-observation 与 vector-store-redis-semantic-cache |
合计 1+1+6+4+5+3+12+22 = 54。
22.1.3 48 个 starter 与 54 个 autoconfigure 不是一一对应
这是理解 Spring AI 依赖图的关键。以 spring-ai-starter-model-openai 为例,它直接声明了 7 个依赖:
xml
<!-- starters/spring-ai-starter-model-openai/pom.xml -->
<dependency>spring-boot-starter-webclient</dependency>
<dependency>spring-boot-starter-restclient</dependency>
<dependency>spring-ai-autoconfigure-model-openai</dependency>
<dependency>spring-ai-openai</dependency>
<dependency>spring-ai-client-chat</dependency>
<dependency>spring-ai-autoconfigure-model-chat-client</dependency>
<dependency>spring-ai-autoconfigure-model-chat-memory</dependency>
<dependency>kotlin-reflect</dependency>
★ 也就是说:你只加了一个 openai starter,ChatClient.Builder、ChatMemory、ChatMemoryRepository 的自动配置就全被拉进来了 。这就是为什么"我只想用 OpenAiChatModel,为什么 ChatClient.Builder 也能注入"。
对比 pgvector starter,只有 5 个依赖:
xml
<!-- starters/spring-ai-starter-vector-store-pgvector/pom.xml -->
<dependency>spring-boot-starter-jdbc</dependency>
<dependency>spring-ai-autoconfigure-vector-store-pgvector</dependency>
<dependency>spring-ai-autoconfigure-vector-store-observation</dependency>
<dependency>spring-ai-pgvector-store</dependency>
★ 它没有 spring-ai-autoconfigure-model-chat-client。所以"只加 pgvector starter"时不会有 ChatClient.Builder。两个 starter 的"隐式附带"范围完全不同------这是排查"为什么这个 bean 会存在/不存在"的第一站。
22.2 ★ 两套「选型开关」:模型侧与向量库侧
Spring AI 有两套完全独立、命名风格也不同的开关:一套管模型(chat/embedding/image/audio/moderation),一套管向量库。
22.2.1 spring.ai.model.<capability>(模型侧)
常量定义在 spring-ai-model 里,两个类分工明确:
java
// spring-ai-model/.../model/SpringAIModelProperties.java:26-45
public static final String MODEL_PREFIX = "spring.ai.model";
public static final String CHAT_MODEL = MODEL_PREFIX + ".chat"; // spring.ai.model.chat
public static final String EMBEDDING_MODEL = MODEL_PREFIX + ".embedding"; // spring.ai.model.embedding
public static final String TEXT_EMBEDDING_MODEL = MODEL_PREFIX + ".embedding.text"; // spring.ai.model.embedding.text
public static final String MULTI_MODAL_EMBEDDING_MODEL = MODEL_PREFIX + ".embedding.multimodal";
public static final String IMAGE_MODEL = MODEL_PREFIX + ".image";
public static final String AUDIO_TRANSCRIPTION_MODEL = MODEL_PREFIX + ".audio.transcription";
public static final String AUDIO_SPEECH_MODEL = MODEL_PREFIX + ".audio.speech";
public static final String MODERATION_MODEL = MODEL_PREFIX + ".moderation";
9 个常量(含 MODEL_PREFIX),8 个可用开关。取值来自 SpringAIModels:
java
// spring-ai-model/.../model/SpringAIModels.java:25-57(18 个值,节选)
ANTHROPIC = "anthropic"; AZURE_OPENAI = "azure-openai";
BEDROCK_COHERE = "bedrock-cohere"; BEDROCK_CONVERSE = "bedrock-converse";
BEDROCK_TITAN = "bedrock-titan"; MISTRAL = "mistral";
OCI_GENAI = "oci-genai"; OLLAMA = "ollama";
OPENAI = "openai"; OPENAI_SDK = "openai-sdk";
POSTGRESML = "postgresml"; STABILITY_AI = "stabilityai";
TRANSFORMERS = "transformers"; VERTEX_AI = "vertexai";
GOOGLE_GEN_AI = "google-genai"; DEEPSEEK = "deepseek";
ELEVEN_LABS = "elevenlabs";
★ SpringAIModels 里有 3 个值在主代码里没有任何 @ConditionalOnProperty 用到 :AZURE_OPENAI、OCI_GENAI、OPENAI_SDK(实测 grep -rn "SpringAIModels\." 的 54 处引用里,这三个只出现在常量声明那一行)。它们是 2.x 移除 azure-openai / OCI 独starter 之后留下的僵尸常量。
22.2.2 ★ 全部 matchIfMissing = true ------ "默认全开"
这是本章最容易被误解的一点。spring.ai.model.* 开关一共 26 个,全部 matchIfMissing = true ,一个例外都没有。但这 26 个的写法并不统一 :25 个走 SpringAIModelProperties 常量,剩下 1 个(Mistral OCR)直接写了字符串字面量。这个不一致本身就是个坑------只 grep 常量名会数出 25,漏掉第 26 个:
bash
# 侧一:25 个走常量
grep -rn -A1 "SpringAIModelProperties\." --include=*.java auto-configurations/ \
| grep -c "matchIfMissing = true"
# 25
# 侧二:1 个走字面量(MistralAiOcrAutoConfiguration.java:42)
grep -rn 'name = "spring.ai.model' --include=*.java auto-configurations/ \
| grep "matchIfMissing = true"
# 1
两侧相加才是 26。MistralAiOcrAutoConfiguration:42 那一行是 @ConditionalOnProperty(name = "spring.ai.model.ocr", havingValue = SpringAIModels.MISTRAL, matchIfMissing = true)------havingValue 用了常量,name 却没用,半常量半字面量。
26 个全部是 matchIfMissing = true,一个例外都没有。含义是:
| 你写的配置 | 效果 |
|---|---|
| 什么都不写 | 所有在 classpath 上的模型自动配置都生效 |
spring.ai.model.chat=openai |
只有 havingValue = "openai" 的那个生效,其余 chat 配置全部关闭 |
spring.ai.model.chat=none |
没有任何值等于 none → 所有 chat 自动配置关闭 |
★ 所以官方文档里那句"To disable, spring.ai.model.chat=none"是约定 ,不是框架里写死的 none 关键字------none 只是"一个谁都不匹配的字符串"而已。写 spring.ai.model.chat=xyz 效果完全一样。
22.2.3 ★ 默认全开的直接后果:多 starter 共存 = 注入歧义
假设你同时引入 spring-ai-starter-model-openai 与 spring-ai-starter-model-anthropic:
OpenAiChatAutoConfiguration的@ConditionalOnMissingBean判断的是返回类型OpenAiChatModelAnthropicChatAutoConfiguration的@ConditionalOnMissingBean判断的是AnthropicChatModel- 两个类型不同 → 两个条件都成立 → 两个 bean 都创建
ChatClient.Builder需要注入ChatModel chatModel(ChatClientAutoConfiguration.java:116)→ 两个候选 →NoUniqueBeanDefinitionException
★ 而 ChatModel 注入点没有任何 @Qualifier、@Primary 或 @ConditionalOnSingleCandidate 兜底。实测:
bash
grep -rn "@ConditionalOnSingleCandidate" --include=*.java . | grep -v "/test/"
# 0 处
grep -rn "@Primary" --include=*.java auto-configurations/ | grep -v "/test/"
# 0 处(自动配置里没有任何 @Primary)
全仓库 @ConditionalOnSingleCandidate 的使用数是 0 。这是 Spring Boot 官方自动配置的常规做法(例如 DataSource 就靠它),Spring AI 完全没用。
正确解法只有两条:显式写 spring.ai.model.chat=openai,或者自己声明一个 @Primary 的 ChatModel bean。
22.2.4 ★ spring.ai.model.ocr 是硬编码字符串
SpringAIModelProperties 里没有 OCR 常量 。于是 MistralAiOcrAutoConfiguration 只能写死:
java
// auto-configurations/models/spring-ai-autoconfigure-model-mistral-ai/.../MistralAiOcrAutoConfiguration.java:42
@ConditionalOnProperty(name = "spring.ai.model.ocr", havingValue = SpringAIModels.MISTRAL, matchIfMissing = true)
对比同模块的 MistralAiChatAutoConfiguration:
java
// MistralAiChatAutoConfiguration.java:56-57
@ConditionalOnProperty(name = SpringAIModelProperties.CHAT_MODEL, havingValue = SpringAIModels.MISTRAL,
matchIfMissing = true)
★ 后果:spring.ai.model.ocr 不享受 IDE 自动补全 (它不在 additional-spring-configuration-metadata.json 里,也不是常量),而且它在 SpringAIModelProperties 的 8 个开关之外------是一套"影子开关"。
22.2.5 spring.ai.vectorstore.type(向量库侧)
java
// spring-ai-vector-store/.../vectorstore/SpringAIVectorStoreTypes.java:26-64
public static final String VECTOR_STORE_PREFIX = "spring.ai.vectorstore";
public static final String TYPE = VECTOR_STORE_PREFIX + ".type"; // spring.ai.vectorstore.type
// 19 个类型值(节选)
AZURE="azure"; CASSANDRA="cassandra"; CHROMA="chroma"; ELASTICSEARCH="elasticsearch";
GEMFIRE="gemfire"; MARIADB="mariadb"; MILVUS="milvus"; MONGODB_ATLAS="mongodb-atlas";
NEO4J="neo4j"; OPENSEARCH="opensearch"; ORACLE="oracle"; PGVECTOR="pgvector";
PINECONE="pinecone"; QDRANT="qdrant"; REDIS="redis"; TYPESENSE="typesense";
WEAVIATE="weaviate"; BEDROCK_KNOWLEDGE_BASE="bedrock-knowledge-base"; S3="S3";
★ 两个异常:
S3 = "S3"是大写 ,其余 18 个全是小写。所以spring.ai.vectorstore.type=S3能命中,而习惯性写成spring.ai.vectorstore.type=s3不命中 (havingValue是大小写敏感的)→ S3 store 被静默关掉。- 没有
COUCHBASE常量 。实测 22 个 vector-store autoconfigure 模块里,19 个 用了SpringAIVectorStoreTypes.TYPE做门,剩下的observation、redis-semantic-cache不是 store,而couchbase是唯一一个 store 却完全没有这个门:
java
// auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-couchbase/
// .../CouchbaseSearchVectorStoreAutoConfiguration.java:36-38
@AutoConfiguration
@ConditionalOnClass({ CouchbaseSearchVectorStore.class, EmbeddingModel.class, Cluster.class })
@EnableConfigurationProperties(CouchbaseSearchVectorStoreProperties.class)
public class CouchbaseSearchVectorStoreAutoConfiguration { // ← 没有 @ConditionalOnProperty
★ 后果:你无法通过 spring.ai.vectorstore.type=pgvector 关掉 couchbase 。只要 com.couchbase.client.java.Cluster 在 classpath 上、且有 EmbeddingModel,couchbase store 就一定会创建。多 store 共存时它是一个"关不掉的第三者"。
22.3 ★ ChatClientAutoConfiguration:prototype 的真相
第 1 章埋的问题在这里收口。整个类只有 184 行,但每一行都有话说。
22.3.1 类级条件:三个
java
// ChatClientAutoConfiguration.java:67-72
@AutoConfiguration(after = ToolCallingAutoConfiguration.class)
@ConditionalOnClass(ChatClient.class)
@EnableConfigurationProperties(ChatClientBuilderProperties.class)
@ConditionalOnProperty(prefix = ChatClientBuilderProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true",
matchIfMissing = true)
public class ChatClientAutoConfiguration {
@AutoConfiguration(after = ToolCallingAutoConfiguration.class)------ 因为chatClientBuilder(...)需要一个ToolCallingAdvisor.Builder<?>,而后者又需要ToolCallingManager。这个after是必需的。@ConditionalOnClass(ChatClient.class)------ ★ 恒真条件 。ChatClient在spring-ai-client-chat里,而这个 autoconfigure 模块本身就编译依赖spring-ai-client-chat(starter 里显式声明了)。所以这个条件永远不会为 false。@ConditionalOnProperty(..., name = "enabled", havingValue = "true", matchIfMissing = true)------ ★ 注意havingValue = "true"。这意味着spring.ai.chat.client.enabled=false不能 关闭它!因为havingValue="true"时,Spring Boot 的判断是"值等于true才匹配",而matchIfMissing=true只处理"属性不存在"。写了false→ 值存在且不等于true→ 不匹配 → 关闭。所以false是能关的,但任何非true的拼写(如FALSE、0、no)也都会关掉它 ,而enabled=TRUE同样关不掉。这是一个"只认小写 true"的开关。
22.3.2 ★ chatClientBuilder 的原型作用域
java
// ChatClientAutoConfiguration.java:112-129
@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.Builder builder = ChatClient.builder(chatModel,
observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP),
chatClientObservationConvention.getIfUnique(), advisorObservationConvention.getIfUnique(),
toolCallingAdvisorBuilder.getIfAvailable());
if (!properties.getToolCalling().isEnabled()) {
builder.defaultAdvisors(AdvisorParams.toolCallingAdvisorAutoRegister(false));
}
return chatClientBuilderConfigurer.configure(builder);
}
为什么必须是 prototype? 因为 ChatClient.Builder 是可变 的:.defaultSystem(...)、.defaultAdvisors(...)、.defaultOptions(...) 都是往同一个 builder 上累加。如果它是 singleton,那么 A 处注入后加了 defaultSystem("你是翻译"),B 处再注入拿到的就是同一个 builder,A 的 system prompt 会泄漏到 B 。prototype 保证每个注入点拿到全新的、只经过 configurer 处理的 builder。
类注释也写明了这一点:
java
// ChatClientAutoConfiguration.java:52-58
/**
* This will produce a {@link ChatClient.Builder ChatClient.Builder} bean with the
* {@code prototype} scope, meaning each injection point will receive a newly cloned
* instance of the builder.
*/
22.3.3 ★ 三个真实陷阱
(1)没有 @ConditionalOnBean(ChatModel.class),错误被推迟到注入点
ChatModel chatModel 是直接参数 ,不是 ObjectProvider<ChatModel>。类级条件里也没有 @ConditionalOnBean(ChatModel.class)。而 prototype bean 在容器启动时不会被实例化,所以:
- 场景:你引入了
spring-ai-autoconfigure-model-chat-client,但没有任何模型 starter(也没有自己的ChatModelbean)。 - 结果:应用正常启动 ,
/actuator/beans里能看到chatClientBuilder定义,但第一个@Autowired ChatClient.Builder的地方抛出NoSuchBeanDefinitionException: No qualifying bean of type 'ChatModel'。 - 对比:如果它是 singleton,启动就会失败,错误信息清晰得多。
★ 这是"prototype 掩盖配置错误"的经典代价。
(2)prototype 注入 singleton 时,prototype 失效
java
@Service
public class MyService {
private final ChatClient.Builder builder; // ← 构造器注入,只执行一次
public MyService(ChatClient.Builder builder) { this.builder = builder; }
}
MyService 是 singleton,构造只发生一次 → builder 永远只有那一个实例 。prototype 的"每次都新"完全失效。这就是 Spring 文档里说的 "prototype-in-singleton" 问题。要用就得分成两个 builder 字段分别 ObjectProvider.getObject(),或者用 @Lookup。
(3)@ConditionalOnMissingBean 与 prototype 的组合
@ConditionalOnMissingBean 在bean 定义注册阶段 求值,与作用域无关,所以行为正常:你自己声明一个 ChatClient.Builder bean,自动配置就退让。但注意它是无参 的 @ConditionalOnMissingBean,判断的是方法返回类型 ChatClient.Builder。
22.3.4 ★ ChatClientBuilderConfigurer 实际上不可扩展
java
// ChatClientBuilderConfigurer.java:38-46
@Deprecated(since = "2.0.0", forRemoval = true)
void setChatClientCustomizers(List<ChatClientCustomizer> customizers) { this.customizers = customizers; }
void setChatClientBuilderCustomizers(List<ChatClientBuilderCustomizer> builderCustomizers) {
this.builderCustomizers = builderCustomizers;
}
★ 两个 setter 都是包级私有 (无 public)。类是 public,但你无法在自己的代码里 new 一个并配置它 ------编译不过。它只能被同包的 ChatClientAutoConfiguration 使用。
结果:想定制默认 builder,唯一入口是提供 ChatClientBuilderCustomizer bean(ChatClientCustomizer 已在 2.0.0 标记 @Deprecated(forRemoval = true))。而 applyCustomizers() 的顺序是先跑废弃的 ChatClientCustomizer,再跑 ChatClientBuilderCustomizer:
java
// ChatClientBuilderConfigurer.java:64-76
private void applyCustomizers(ChatClient.Builder builder) {
if (this.customizers != null) {
for (ChatClientCustomizer customizer : this.customizers) { customizer.customize(builder); }
}
if (this.builderCustomizers != null) {
for (ChatClientBuilderCustomizer customizer : this.builderCustomizers) { customizer.customize(builder); }
}
}
★ 如果你两类 customizer 都有,废弃类型的会先执行、然后被新类型的覆盖(同名 default 设置后者胜)。
22.3.5 toolCallingAdvisorBuilder 与 spring.ai.chat.client.tool-calling.*
java
// ChatClientAutoConfiguration.java:97-110
@Bean
@ConditionalOnMissingBean
@ConditionalOnBean(ToolCallingManager.class)
ToolCallingAdvisor.Builder<?> toolCallingAdvisorBuilder(ChatClientBuilderProperties properties,
ToolCallingManager toolCallingManager,
ObjectProvider<ToolExecutionEligibilityChecker> toolExecutionEligibilityChecker) {
var builder = ToolCallingAdvisor.builder()
.toolCallingManager(toolCallingManager)
.advisorOrder(properties.getToolCalling().getAdvisorOrder());
toolExecutionEligibilityChecker.ifAvailable(builder::toolExecutionEligibilityChecker);
return builder;
}
ChatClientBuilderProperties.ToolCalling 两个字段:
java
// ChatClientBuilderProperties.java:63-96
public static class ToolCalling {
private boolean enabled = true; // spring.ai.chat.client.tool-calling.enabled
private int advisorOrder = ToolCallingAdvisor.DEFAULT_ORDER; // spring.ai.chat.client.tool-calling.advisor-order
}
★ 注意 enabled 的用法很绕:它为 false 不是 "不创建 advisor builder",而是 builder.defaultAdvisors(AdvisorParams.toolCallingAdvisorAutoRegister(false))------把"自动注册"标记为关,由 ChatClient 在组装链时读取这个参数。bean 依然被创建 。所以 spring.ai.chat.client.tool-calling.enabled=false 的语义是"关闭自动注册",不是"关闭 bean",与 spring.ai.chat.client.enabled 那种"整个自动配置不生效"是完全不同的两层。
advisorOrder 的默认值来自 ToolCallingAdvisor.DEFAULT_ORDER------第 11 章讲过它是 HIGHEST_PRECEDENCE + 300。
22.4 ★ Properties 的两级合并:六种语义
OpenAI 模块把连接属性抽成 AbstractOpenAiProperties,由 OpenAiCommonProperties(spring.ai.openai)与各模型 Properties(spring.ai.openai.chat 等)共同继承 ,然后在 OpenAiAutoConfigurationUtil.resolveCommonProperties(common, model) 里合并。
这个方法只有 40 行,但六种不同的"谁赢"规则混在一起,是本章最值得逐行读的一段。
java
// OpenAiAutoConfigurationUtil.java:29-77
public static ResolvedConnectionProperties resolveCommonProperties(AbstractOpenAiProperties commonProperties,
AbstractOpenAiProperties modelProperties) {
var resolved = new ResolvedConnectionProperties();
resolved.setBaseUrl(StringUtils.hasText(modelProperties.getBaseUrl()) ? modelProperties.getBaseUrl()
: commonProperties.getBaseUrl());
// An explicit empty string ("") is a deliberate no-auth signal (NoopApiKey
// behaviour) and must be preserved. Only fall back to commonProperties when the
// model-level key is null (i.e. not configured at all).
resolved.setApiKey(
modelProperties.getApiKey() != null ? modelProperties.getApiKey() : commonProperties.getApiKey());
String organizationId = StringUtils.hasText(modelProperties.getOrganizationId())
? modelProperties.getOrganizationId() : commonProperties.getOrganizationId();
resolved.setOrganizationId(organizationId);
resolved.setCredential(modelProperties.getCredential() != null ? modelProperties.getCredential()
: commonProperties.getCredential());
resolved.setTimeout(!modelProperties.getTimeout().equals(OpenAiCommonProperties.DEFAULT_TIMEOUT)
? modelProperties.getTimeout() : commonProperties.getTimeout());
resolved.setModel(StringUtils.hasText(modelProperties.getModel()) ? modelProperties.getModel()
: commonProperties.getModel());
resolved.setMicrosoftDeploymentName(StringUtils.hasText(modelProperties.getMicrosoftDeploymentName())
? modelProperties.getMicrosoftDeploymentName() : commonProperties.getMicrosoftDeploymentName());
resolved.setMicrosoftFoundryServiceVersion(modelProperties.getMicrosoftFoundryServiceVersion() != null
? modelProperties.getMicrosoftFoundryServiceVersion()
: commonProperties.getMicrosoftFoundryServiceVersion());
// For boolean properties, use modelProperties value, defaulting to
// commonProperties if needed
resolved.setMicrosoftFoundry(modelProperties.isMicrosoftFoundry() || commonProperties.isMicrosoftFoundry());
resolved.setGitHubModels(modelProperties.isGitHubModels() || commonProperties.isGitHubModels());
resolved.setMaxRetries(modelProperties.getMaxRetries() != OpenAiCommonProperties.DEFAULT_MAX_RETRIES
? modelProperties.getMaxRetries() : commonProperties.getMaxRetries());
resolved.setProxy(modelProperties.getProxy() != null ? modelProperties.getProxy() : commonProperties.getProxy());
resolved.setCustomHeaders(!modelProperties.getCustomHeaders().isEmpty() ? modelProperties.getCustomHeaders()
: commonProperties.getCustomHeaders());
return resolved;
}
22.4.1 六种语义一张表
| 字段 | 判断方式 | 子级能否"显式写默认值"覆盖父级 | 子级能否"置空"回退父级 |
|---|---|---|---|
baseUrl |
StringUtils.hasText |
不能(空串视为未设) | ✔ 空串 → 用父级 |
apiKey |
!= null |
✔ | ✘ 空串被保留 |
organizationId |
StringUtils.hasText |
不能 | ✔ |
credential |
!= null |
✔ | ✔ null → 用父级 |
timeout |
.equals(DEFAULT_TIMEOUT) |
✘ 不能 | ✘ |
model |
StringUtils.hasText |
不能 | ✔ |
microsoftDeploymentName |
StringUtils.hasText |
不能 | ✔ |
microsoftFoundryServiceVersion |
!= null |
✔ | ✔ |
maxRetries |
!= DEFAULT_MAX_RETRIES |
✘ 不能 | ✘ |
proxy |
!= null |
✔ | ✔ |
customHeaders |
!isEmpty() |
✘ 不能(空 map 视为未设) | ✔ 空 map → 用父级 |
isMicrosoftFoundry / isGitHubModels |
**` | `(布尔或)** |
22.4.2 ★ 三条真实会踩的坑
(1)timeout 的"默认值盲区"
AbstractOpenAiProperties.DEFAULT_TIMEOUT = Duration.ofSeconds(60)。合并逻辑是"子级 != 60s 才用子级"。所以:
properties
spring.ai.openai.timeout=120s
spring.ai.openai.chat.timeout=60s # ← 你想让 chat 用 60s
结果是 120s 。因为子级的 60s 等于默认值,被判定为"没配"。★ 你无法用"显式写默认值"的方式让某个子级回退到 60s。
(2)maxRetries 同理,默认值是 3
properties
spring.ai.openai.max-retries=5
spring.ai.openai.chat.max-retries=3 # ← 无效,chat 依然是 5
(3)isMicrosoftFoundry 一旦在父级开启就关不掉
properties
spring.ai.openai.microsoft-foundry=true
spring.ai.openai.chat.microsoft-foundry=false # ← 无效,依然是 true
因为 resolved.setMicrosoftFoundry(model || common)。★ 源码注释写着 "use modelProperties value, defaulting to commonProperties if needed",但实现是 OR,与注释描述不符------注释讲的是"默认",代码做的是"或"。
(4)apiKey 的空串是刻意设计
源码注释明确写了:空串 "" 是"deliberate no-auth signal"(对应 NoopApiKey 行为,本地 Ollama / vLLM 之类不需要 key 的场景)。所以:
properties
spring.ai.openai.api-key=sk-xxx
spring.ai.openai.chat.api-key= # ← 空串,chat 走无鉴权
✔ 这是唯一一个"子级置空能覆盖父级非空"的字段。其余字段空串都等于"未配置"。
22.4.3 ResolvedConnectionProperties 是个空壳
java
// OpenAiAutoConfigurationUtil.java:79-81
public static class ResolvedConnectionProperties extends OpenAiCommonProperties {
}
它 extends OpenAiCommonProperties(也就是 @ConfigurationProperties("spring.ai.openai") 那个类),只是为了让 OpenAiSetup.setupSyncClient(...) 的签名能接收它 。★ 副作用:ResolvedConnectionProperties 继承了 @ConfigurationProperties 注解的语义,但它从不参与绑定 (没人 @EnableConfigurationProperties 它)。这是一个"为了复用字段而继承 Properties 类"的实用主义写法。
22.5 ★ 两套重试:SpringAiRetryAutoConfiguration vs SDK 内建
22.5.1 spring-ai-retry 模块只有 4 个文件
css
spring-ai-retry/src/main/java/org/springframework/ai/retry/
├── NonTransientAiException.java
├── TransientAiException.java
├── RetryUtils.java
└── package-info.java
★ 没有 RetryTemplate 的实现类 。第 1 章的版本基线提到"RetryTemplate 从 Spring Retry 换成 Spring Framework core"------准确地说,2.0.1 不再自己提供 RetryTemplate 实现,而是直接用 Spring Framework 7 的:
java
// SpringAiRetryAutoConfiguration.java:38-40
import org.springframework.core.retry.RetryListener;
import org.springframework.core.retry.RetryPolicy;
import org.springframework.core.retry.RetryTemplate;
import org.springframework.core.retry.Retryable;
注意包名是 org.springframework.core.retry------不是 org.springframework.retry(Spring Retry 项目),是 Spring Framework 核心新增的 spring-core 子包。
22.5.2 自动配置内容
java
// SpringAiRetryAutoConfiguration.java:57-58
@AutoConfiguration
@ConditionalOnClass(RetryUtils.class)
@EnableConfigurationProperties({ SpringAiRetryProperties.class })
public class SpringAiRetryAutoConfiguration {
★ @ConditionalOnClass(RetryUtils.class) ------ 又是恒真条件 (本模块编译依赖 spring-ai-retry)。
它只造两个 bean,都是 @ConditionalOnMissingBean:
java
// :62-85(节选)
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() {
private final AtomicInteger retryCount = new AtomicInteger(0);
@Override
public void onRetryFailure(RetryPolicy policy, Retryable<?> retryable, Throwable throwable) {
int currentRetries = this.retryCount.incrementAndGet();
if (logger.isWarnEnabled()) {
logger.warn("Retry error. Retry count:" + currentRetries, throwable);
}
}
});
return retryTemplate;
}
★ 注意 RetryListener 是匿名内部类且持有 AtomicInteger ,而 retryTemplate 是 singleton。所以这个计数器是全局累加的------日志里的 "Retry count:N" 是"这个应用启动以来第 N 次重试失败",不是"本次调用的第 N 次"。排查问题时非常容易误读。
responseErrorHandler() 的判定顺序也值得记:
java
// :100-128(节选)
if (properties.getOnHttpCodes().contains(response.getStatusCode().value())) {
throw new TransientAiException(message); // 1. 显式配置的瞬时码
}
if (!properties.isOnClientErrors() && response.getStatusCode().is4xxClientError()) {
throw new NonTransientAiException(message); // 2. 4xx 且没开 on-client-errors
}
if (!CollectionUtils.isEmpty(properties.getExcludeOnHttpCodes())
&& properties.getExcludeOnHttpCodes().contains(response.getStatusCode().value())) {
throw new NonTransientAiException(message); // 3. 显式配置的非瞬时码
}
throw new TransientAiException(message); // 4. 兜底:一律瞬时
★ 兜底是"一律瞬时" 。也就是说一个 500 会被无限重试到 maxAttempts(默认 10)。而 onHttpCodes 与 excludeOnHttpCodes 的优先级高于 4xx 判定------你可以把 429 加进 onHttpCodes 让它重试(默认 429 是 4xx → 非瞬时 → 不重试)。
22.5.3 默认值与 RetryUtils 的重复
java
// RetryUtils.java:47-56
private static final int DEFAULT_MAX_ATTEMPTS = 10;
private static final long DEFAULT_INITIAL_INTERVAL = 2000; // 2s
private static final int DEFAULT_MULTIPLIER = 5;
private static final long DEFAULT_MAX_INTERVAL = 3 * 60000; // 180s = 3min
private static final long SHORT_INITIAL_INTERVAL = 100; // 100ms
退避序列:2s → 10s → 50s → 180s(封顶)→ ... 共 10 次,最坏累计约 25 分钟 。RetryUtils 里还有一份一模一样的常量和一份 DEFAULT_RESPONSE_ERROR_HANDLER------★ 默认策略被写了两遍 (RetryUtils 静态版 + SpringAiRetryProperties 绑定版),改一处不会同步另一处。
22.5.4 ★ 谁真的用了 RetryTemplate
bash
grep -rln "RetryTemplate" --include=*.java . | grep -v "/test/" | wc -l # 22 个文件
22 个文件引用它,但按供应商分组后差异极大:
| 供应商 | 用 RetryTemplate(即 spring.ai.retry.* 有效) |
|---|---|
| DeepSeek / ElevenLabs / Google GenAI / Mistral AI / Ollama / Vertex AI | ✔ 是 |
| OpenAI | ✘ 否 |
| Anthropic | ✘ 否 |
| Bedrock | ✘ 否 |
★ OpenAI 与 Anthropic 完全不走 spring.ai.retry.*。它们改成官方 SDK 后,重试交给 SDK 自己的 HTTP 客户端:
java
// OpenAiChatAutoConfiguration.java:98-104(节选)
return OpenAiSetup.setupSyncClient(commonProperties.getBaseUrl(), commonProperties.getApiKey(),
..., commonProperties.getTimeout(), commonProperties.getMaxRetries(), ...);
所以 OpenAI 的重试参数是 spring.ai.openai.max-retries(默认 3)与 spring.ai.openai.timeout(默认 60s) ,退避策略由 com.openai:openai-java-core 决定。
22.5.5 ★ 但 openai starter 依然传递依赖了 retry 模块
bash
grep -rl "spring-ai-autoconfigure-retry" auto-configurations/ | wc -l # 11 个 autoconfigure 模块
grep -rl "spring-ai-autoconfigure-retry" starters/ | wc -l # 0 个 starter 直接声明
11 个模型 autoconfigure 模块依赖它,包括 spring-ai-autoconfigure-model-openai ;而 starters/ 下没有任何一个直接声明它------全部是传递引入。
★ 结论:使用 OpenAI starter 时,SpringAiRetryAutoConfiguration 依然会执行 ,创建一个 RetryTemplate bean 和一个 ResponseErrorHandler bean,但没有任何 bean 消费它们 。同时 spring.ai.retry.* 会出现在你的 IDE 补全列表里,改了却毫无效果------这是最容易浪费半天的一类坑。
22.6 ToolCallingAutoConfiguration:工具侧的唯一装配点
这一节和第 11 章(工具循环)、第 13 章(MCP 客户端)直接咬合------它是"工具能力"在 Boot 层的唯一入口,也是 ChatClientAutoConfiguration 的 after 依赖目标。
22.6.1 类级条件与三个 bean
java
// ToolCallingAutoConfiguration.java:57-59
@AutoConfiguration
@ConditionalOnClass(ChatModel.class)
@EnableConfigurationProperties(ToolCallingProperties.class)
public class ToolCallingAutoConfiguration {
三个 bean:ToolCallbackResolver、ToolExecutionExceptionProcessor、ToolCallingManager,全部 @ConditionalOnMissingBean;外加一个 @ConditionalOnProperty 的 ToolCallingContentObservationFilter(已在第 21 章讲过)。
22.6.2 ★ MCP provider 用"字符串比较类全名"排除
这是全章最"反直觉"的一段:
java
// ToolCallingAutoConfiguration.java:96-101
private static boolean isMcpToolCallbackProvider(ResolvableType type) {
if (type.getType().getTypeName().equals("org.springframework.ai.mcp.SyncMcpToolCallbackProvider")
|| type.getType().getTypeName().equals("org.springframework.ai.mcp.AsyncMcpToolCallbackProvider")) {
return true;
}
var superType = type.getSuperType();
return superType != ResolvableType.NONE && isMcpToolCallbackProvider(superType);
}
★ 为什么不用 instanceof SyncMcpToolCallbackProvider?因为 MCP 是可选依赖 ------spring-ai-autoconfigure-model-tool 不依赖 MCP 模块,instanceof 会导致编译期就必须有该类,且 classpath 上没有时会 NoClassDefFoundError。用字符串比较绕开了这个约束。
代价是:
- 类全名写死在字符串里 ,重命名会静默失效(条件永远 false → MCP provider 被当成普通 provider → 提前触发
listTools())。 - 只递归
getSuperType()(父类),不看接口。如果某个 MCP provider 是"实现接口"而非"继承基类"的自定义类型,且类名不同,就漏判。 - 与第 13 章的结论咬合:MCP provider 被排除的原因是注释里写明的------
java
// ToolCallingAutoConfiguration.java:79-83
/**
* The default {@link ToolCallbackResolver} resolves tools by name for methods,
* functions, and {@link ToolCallbackProvider} beans.
* <p>
* MCP providers are excluded, to avoid initializing them early with #listTools().
*/
★ 也就是说:这个排除是"性能优化",不是"功能隔离" 。MCP 工具最终仍会进入 DelegatingToolCallbackResolver,只是时机推迟到真正要解析工具名的时候。
22.6.3 UNLIMITED = -1 与四个分支
java
// ToolCallingAutoConfiguration.java:65-68
/**
* 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;
四个 Integer 配置项,每个都写成"null → 不改;-1 → 解除;其它 → 设置"三分支:
java
// :145-175(节选)
Integer maxCallsPerToolDefault = limits.getMaxCallsPerToolDefault();
if (maxCallsPerToolDefault != null) {
if (maxCallsPerToolDefault == UNLIMITED) { builder.unlimitedCallsPerTool(); }
else { builder.maxCallsPerTool(maxCallsPerToolDefault); }
}
limits.getMaxCallsPerTool().forEach((toolName, maxCalls) -> {
if (maxCalls == UNLIMITED) { builder.excludeToolFromLimit(toolName); }
else { builder.maxCallsPerTool(toolName, maxCalls); }
});
limits.getExcludedTools().forEach(builder::excludeToolFromLimit);
Integer maxTotalToolCalls = limits.getMaxTotalToolCalls();
if (maxTotalToolCalls != null) {
if (maxTotalToolCalls == UNLIMITED) { builder.unlimitedTotalToolCalls(); }
else { builder.maxTotalToolCalls(maxTotalToolCalls); }
}
builder.onLimitExceeded(limits.getOnLimitExceeded());
★ 注意 maxCallsPerToolDefault == UNLIMITED 用的是 == 而 maxCallsPerTool 的 maxCalls 是 int(从 Map<String, Integer> 拆箱),也是 ==------都安全,因为 -1 在 Integer 缓存范围内(-128~127)。
★ 三分支语义的坑:null 与 -1 完全不同 。不写 = 用 ToolCallingManager 的默认值(第 11 章的 40 / 150);写 -1 = 解除限制;写 0 = 禁止调用(不是"无限")。
22.6.4 ToolExecutionExceptionProcessor 的反射加载
java
// ToolCallingAutoConfiguration.java:104-121(节选)
ArrayList<Class<? extends RuntimeException>> rethrownExceptions = new ArrayList<>();
// ClientAuthorizationException is used by Spring Security in oauth2 flows, ...
Class<? extends RuntimeException> oauth2Exception = getClassOrNull(
"org.springframework.security.oauth2.client.ClientAuthorizationException");
if (oauth2Exception != null) { rethrownExceptions.add(oauth2Exception); }
return DefaultToolExecutionExceptionProcessor.builder()
.alwaysThrow(properties.isThrowExceptionOnError())
.rethrowExceptions(rethrownExceptions)
.build();
★ 又一个"字符串反射 + 静默失败":getClassOrNull() 在类不存在时只在 debug 级别打日志。所以加了 Spring Security 但版本里没有 ClientAuthorizationException 时,你不会收到任何警告,只是 OAuth2 的鉴权异常不再被重抛(会被当成普通工具错误回喂给模型)。
22.7 Chat Memory 与 Repository:before 排序与前缀不一致
22.7.1 ChatMemoryAutoConfiguration 极简,只有两个 bean
java
// ChatMemoryAutoConfiguration.java:35-49
@AutoConfiguration
@ConditionalOnClass({ ChatMemory.class, ChatMemoryRepository.class })
public class ChatMemoryAutoConfiguration {
@Bean
@ConditionalOnMissingBean
ChatMemoryRepository chatMemoryRepository() {
return new InMemoryChatMemoryRepository();
}
@Bean
@ConditionalOnMissingBean
ChatMemory chatMemory(ChatMemoryRepository chatMemoryRepository) {
return MessageWindowChatMemory.builder().chatMemoryRepository(chatMemoryRepository).build();
}
}
★ 默认 repository 是 InMemoryChatMemoryRepository ,且没有任何 @ConditionalOnProperty 开关 。只要 classpath 上有 ChatMemory(spring-ai-model 必带),这个 bean 就存在。第 19 章讲过 add() 是无锁 read-modify-write------现在叠加一条:你不配任何 repository starter,默认拿到的是进程内、重启即失、且并发不安全的实现。
22.7.2 六个 repository 全部 before = ChatMemoryAutoConfiguration
bash
grep -rn "@AutoConfiguration(before" --include=*.java auto-configurations/models/chat/memory/
# 6 处,全部 before = ChatMemoryAutoConfiguration.class
| 自动配置 | 前缀 |
|---|---|
CassandraChatMemoryRepositoryAutoConfiguration |
spring.ai.chat.memory.repository.cassandra |
JdbcChatMemoryRepositoryAutoConfiguration |
spring.ai.chat.memory.repository.jdbc |
MongoChatMemoryAutoConfiguration |
spring.ai.chat.memory.repository.mongo |
Neo4jChatMemoryRepositoryAutoConfiguration |
spring.ai.chat.memory.repository.neo4j |
RedisChatMemoryRepositoryAutoConfiguration |
spring.ai.chat.memory.repository.redis |
RedisChatMemoryAutoConfiguration |
spring.ai.chat.memory.redis |
★ 为什么必须 before?因为 ChatMemoryAutoConfiguration.chatMemoryRepository() 是 @ConditionalOnMissingBean------它必须后 跑,才能看到 repository starter 已经注册的 ChatMemoryRepository 从而退让。如果顺序反了,就会先注册 InMemory 的,repository starter 再注册就变成两个 ChatMemoryRepository bean,chatMemory(...) 注入歧义。
22.7.3 ★ 两处命名不一致
- 模块叫
mongodb,前缀叫mongo:模块目录是spring-ai-autoconfigure-model-chat-memory-repository-mongodb,starter 是spring-ai-starter-model-chat-memory-repository-mongodb,但MongoChatMemoryProperties.CONFIG_PREFIX = "spring.ai.chat.memory.repository.mongo"(没有db)。 MongoChatMemoryAutoConfiguration少了Repository一词 :其余四个都叫XxxChatMemoryRepositoryAutoConfiguration,只有它叫MongoChatMemoryAutoConfiguration。
★ 后果:凭直觉写 spring.ai.chat.memory.repository.mongodb.* 是无效配置(静默忽略,不会报错,因为 Boot 对未知前缀不做校验)。
- Redis 有两套前缀 :
spring.ai.chat.memory.repository.redis(repository 层)与spring.ai.chat.memory.redis(MessageWindowChatMemory的 Redis 变体)。两个 autoconfigure 模块(...-repository-redis与...-memory-redis)都before = ChatMemoryAutoConfiguration,且都注册ChatMemoryRepository------★ 同时引入两个 redis 模块时,两个@ConditionalOnMissingBean中只有先跑的生效,另一个退让。
22.8 向量存储自动配置:三个共性问题
第 16、17 章讲过 VectorStore 接口与 22 个实现,这里只看 Boot 层。
22.8.1 ★ BatchingStrategy 是全局抢注
以 pgvector 为例:
java
// PgVectorStoreAutoConfiguration.java:56-59
@Bean
@ConditionalOnMissingBean
BatchingStrategy pgVectorStoreBatchingStrategy() {
return new TokenCountBatchingStrategy();
}
★ 注意三点:
- bean 类型是
BatchingStrategy(全局类型),不是 pgvector 专属 。而@ConditionalOnMissingBean无参 → 按返回类型判断。 - 每个 store 的 autoconfigure 都这么写一份(bean 名不同:
pgVectorStoreBatchingStrategy/chromaVectorStoreBatchingStrategy/ ...),但类型相同。 - 结果:同时引入两个 vector store starter 时,只有 classpath 顺序上先被处理的那个注册成功,其余全部退让 。而 store bean 本身(
PgVectorStore/ChromaVectorStore)却是各自独立注册的。
于是出现一个诡异组合:两个 store bean 都活着,但只有一个 BatchingStrategy ------如果这两个 store 的 maxDocumentBatchSize / embedding 模型不同,共享一个策略就会用错参数。第 16 章讲过 TokenCountBatchingStrategy 是有状态的(按 token 累计分批),共享还要考虑线程安全。
22.8.2 ★ S3 store:加 starter 不配参数 → 启动即失败
java
// S3VectorStoreAutoConfiguration.java:53-57
S3VectorStoreAutoConfiguration(S3VectorStoreProperties p) {
Assert.notNull(p.getIndexName(), "Index name cannot be null!");
Assert.notNull(p.getVectorBucketName(), "Bucket name cannot be null");
this.properties = p;
}
★ Assert 写在自动配置类的构造器里。Spring Boot 在处理自动配置时会先实例化这个类再注册 bean 方法,所以:
- 引入
spring-ai-starter-vector-store-s3但没写spring.ai.vectorstore.s3.index-name/vector-bucket-name - → 容器刷新阶段抛
IllegalArgumentException: Index name cannot be null! - → 应用起不来,且堆栈指向自动配置类的构造器,而不是指向你的配置
而它的类级条件是:
java
// :47-51
@AutoConfiguration
@ConditionalOnClass({ S3VectorsClient.class, EmbeddingModel.class })
@EnableConfigurationProperties(S3VectorStoreProperties.class)
@ConditionalOnProperty(name = SpringAIVectorStoreTypes.TYPE, havingValue = SpringAIVectorStoreTypes.S3,
matchIfMissing = true)
matchIfMissing = true ------ 只要 S3VectorsClient 在 classpath 上就尝试装配。★ 唯一干净的关闭方式是 spring.ai.vectorstore.type=<其它值>(注意大小写!写 s3 无效,见 22.2.5)。
顺带:S3VectorStoreProperties 里 indexName / vectorBucketName 是 @Nullable 字段、没有默认值,这也是为什么必须在构造器 Assert。
22.8.3 观测自动配置是独立模块
spring-ai-autoconfigure-vector-store-observation 是 22 个 vector-store 模块里唯一不提供 store 的 。它的 bean 已在第 21 章展开(VectorStoreQueryResponseObservationHandler 等)。★ 注意 pgvector starter 显式声明了它,但很多 store starter 是靠传递依赖------排查"为什么向量检索没有 span"时先看这个模块在不在。
22.9 Docker Compose 与 Testcontainers:用 spring.factories,不是 .imports
Spring AI 有两个 Boot 集成模块专门做"本地容器自动接线":
bash
spring-ai-spring-boot-docker-compose/src/main/resources/META-INF/spring.factories
spring-ai-spring-boot-testcontainers/src/main/resources/META-INF/spring.factories
★ 这里用的是 spring.factories(旧机制),不是 AutoConfiguration.imports 。因为 DockerComposeConnectionDetailsFactory / ContainerConnectionDetailsFactory 不是自动配置类,而是 Boot 的 ConnectionDetailsFactory 扩展点,走的是 spring.factories 的 SPI 列表。
22.9.1 九个工厂,覆盖哪些、不覆盖哪些
properties
# spring-ai-spring-boot-docker-compose/src/main/resources/META-INF/spring.factories
org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory=\
...chroma.ChromaDockerComposeConnectionDetailsFactory,\
...docker.DockerMcpGatewayDockerComposeConnectionDetailsFactory,\
...milvus.MilvusDockerComposeConnectionDetailsFactory,\
...ollama.OllamaDockerComposeConnectionDetailsFactory,\
...opensearch.AwsOpenSearchDockerComposeConnectionDetailsFactory,\
...opensearch.OpenSearchDockerComposeConnectionDetailsFactory,\
...qdrant.QdrantDockerComposeConnectionDetailsFactory,\
...typesense.TypesenseDockerComposeConnectionDetailsFactory,\
...weaviate.WeaviateDockerComposeConnectionDetailsFactory
Testcontainers 侧是完全对称的 9 个 (ChromaContainerConnectionDetailsFactory 等),也是 spring.factories。
★ 没有 pgvector / mariadb / oracle / cassandra / couchbase / neo4j / mongodb-atlas / redis / elasticsearch / pinecone / s3 / bedrock。原因是这些要么走 JDBC(Boot 自带数据源支持即可),要么是托管云服务(没有本地容器镜像)。所以:
- 能"零配置起本地服务"的只有 9 个:chroma、docker-mcp-gateway、milvus、ollama、opensearch、aws-opensearch、qdrant、typesense、weaviate
- pgvector 你得自己写
compose.yml并用 Boot 的 JDBC connection details,Spring AI 不帮你接base-url
22.9.2 一个工厂长什么样
java
// OllamaDockerComposeConnectionDetailsFactory.java:30-58(节选)
class OllamaDockerComposeConnectionDetailsFactory
extends DockerComposeConnectionDetailsFactory<OllamaConnectionDetails> {
private static final int OLLAMA_PORT = 11434;
protected OllamaDockerComposeConnectionDetailsFactory() {
super("ollama/ollama"); // ← 镜像名,用于匹配 compose.yml 里的 image
}
@Override
protected OllamaConnectionDetails getDockerComposeConnectionDetails(DockerComposeConnectionSource source) {
return new OllamaDockerComposeConnectionDetails(source.getRunningService());
}
static class OllamaDockerComposeConnectionDetails extends DockerComposeConnectionDetails
implements OllamaConnectionDetails {
private final String baseUrl;
OllamaDockerComposeConnectionDetails(RunningService service) {
super(service);
this.baseUrl = "http://" + service.host() + ":" + service.ports().get(OLLAMA_PORT);
}
@Override
public String getBaseUrl() { return this.baseUrl; }
}
}
★ 两个细节:
super("ollama/ollama")是镜像名匹配 。你的compose.yml里必须写image: ollama/ollama(或以其为前缀),否则匹配不上。- 类是包级私有 (
class而非public class),构造器是protected------这是 BootDockerComposeConnectionDetailsFactory的约定。
22.9.3 OllamaConnectionDetails 是什么
它是 spring-ai-autoconfigure-model-ollama 里的接口。当容器运行时,Boot 的 ConnectionDetails 机制会把它注册成 bean,Ollama 的自动配置就会优先用它 而不是 spring.ai.ollama.base-url。★ 这种"连接细节优先于属性"的优先级是 Boot 的通用规则,但 Spring AI 里只有这 9 个 store/model 享受。
22.10 ★ 条件注解统计与「恒真条件」
22.10.1 全仓库使用分布(按文件数)
bash
for a in ConditionalOnClass ConditionalOnMissingBean ConditionalOnBean ConditionalOnProperty \
ConditionalOnMissingClass ConditionalOnSingleCandidate ConditionalOnNotWebApplication; do
echo "$a $(grep -rl "@$a" --include=*.java . | grep -v '/test/' | wc -l)"
done
| 注解 | 文件数 | 说明 |
|---|---|---|
@ConditionalOnMissingBean |
82 | 压倒性多数,用于"用户自定义优先" |
@ConditionalOnClass |
73 | 次之,用于"可选依赖" |
@ConditionalOnProperty |
70 | 用于开关(模型选型、日志、观测) |
@ConditionalOnBean |
11 | 主要是 Tracer / MeterRegistry / ToolCallingManager |
@ConditionalOnMissingClass |
4 | 全部是 "io.micrometer.tracing.Tracer" |
@ConditionalOnSingleCandidate |
0 | ★ 完全没用 |
@ConditionalOnNotWebApplication |
0 | --- |
@ConditionalOnWarDeployment |
0 | --- |
22.10.2 四个 @ConditionalOnMissingClass("io.micrometer.tracing.Tracer")
bash
grep -rn "@ConditionalOnMissingClass" --include=*.java . | grep -v "/test/"
# ChatClientAutoConfiguration.java:161
# ChatObservationAutoConfiguration.java:123
# ImageObservationAutoConfiguration.java:75
# VectorStoreObservationAutoConfiguration.java:77
全部用于"无 Tracer 时注册裸 handler"的分支(第 21 章的 TracerNotPresentObservationConfiguration)。★ 四个文件各自硬写了 "io.micrometer.tracing.Tracer" 字符串四次------没有抽成常量。
22.10.3 ★ 三类「恒真条件」
Spring AI 里有大量 @ConditionalOnClass(X),其中 X 就在本模块的编译依赖里 ,因此条件永远为真。它们不是 bug,而是"防御未来拆分"或纯粹沿袭 Boot 惯例,但读源码时不能用它们判断"这个自动配置会不会生效"。
| 恒真条件 | 位置 | 为什么恒真 |
|---|---|---|
@ConditionalOnClass(ChatModel.class) |
ToolCallingAutoConfiguration:58、ChatObservationAutoConfiguration |
ChatModel 在 spring-ai-model,本模块编译依赖它 |
@ConditionalOnClass(ChatClient.class) |
ChatClientAutoConfiguration:68 |
starter 显式声明了 spring-ai-client-chat |
@ConditionalOnClass(RetryUtils.class) |
SpringAiRetryAutoConfiguration:58 |
本模块编译依赖 spring-ai-retry |
@ConditionalOnClass(VectorStore.class) |
VectorStoreObservationAutoConfiguration |
同模块依赖 spring-ai-vector-store |
★ 真正起"是否生效"作用的其实是 @ConditionalOnProperty(模型/向量库选型)与 @ConditionalOnBean(Tracer / MeterRegistry / ToolCallingManager)。
22.10.4 @AutoConfiguration 排序:只有 3 处 after、2 处 afterName、12 处 before
bash
grep -rn "@AutoConfiguration(after" # 3 处
grep -rn "afterName" # 2 处(都是 CompositeMeterRegistryAutoConfiguration)
grep -rn "@AutoConfiguration(before" # 12 处
| 排序声明 | 数量 | 位置 |
|---|---|---|
after = ToolCallingAutoConfiguration.class |
2 | ChatClientAutoConfiguration、ToolSearchAdvisorAutoConfiguration |
after = McpServerAnnotationScannerAutoConfiguration.class |
1 | MCP server 的两个 SpecificationFactory |
afterName = "...CompositeMeterRegistryAutoConfiguration" |
2 | Chat / Embedding 观测自动配置 |
before = ChatMemoryAutoConfiguration.class |
6 | 六个 memory repository |
before = McpServerAutoConfiguration.class / McpServerStatelessAutoConfiguration.class |
6 | MCP server 的 webmvc / webflux 三种传输 |
★ 89 个自动配置里,只有 17 处显式声明了顺序 ,其余 72 个完全依赖字母序/未定义行为。这就是为什么 BatchingStrategy(22.8.1)的抢注结果不可预测 ------它没有被任何 before/after 约束。
ToolSearchAdvisorAutoConfiguration 那处还多一个 before:
java
// ToolSearchAdvisorAutoConfiguration.java:93
@AutoConfiguration(after = ToolCallingAutoConfiguration.class, before = ChatClientAutoConfiguration.class)
★ 它必须夹在两者之间:需要 ToolCallingManager(after),又要让 ChatClient.Builder 能拿到自己的 advisor(before)。
22.11 配置前缀全景:75 个前缀的分类与三个"孤儿"
把所有 CONFIG_PREFIX 常量收集起来排序,可以清楚看到 Spring AI 的配置命名空间是怎么组织的。
bash
grep -rhn 'CONFIG_PREFIX = "' --include=*.java . | grep -v "/test/" | sed 's/.*CONFIG_PREFIX = //' | sort -u | wc -l
# 75
22.11.1 按功能分组(75 个前缀)
| 分组 | 数量 | 前缀 |
|---|---|---|
| 模型供应商(顶层) | 11 | spring.ai.anthropic、spring.ai.openai、spring.ai.ollama、spring.ai.mistralai、spring.ai.deepseek、spring.ai.elevenlabs、spring.ai.stabilityai、spring.ai.google.genai、spring.ai.vertex.ai.embedding、spring.ai.bedrock.aws、spring.ai.postgresml.embedding、spring.ai.embedding.transformer |
| 模型供应商(能力分层) | 20 | spring.ai.openai.chat / .embedding / .image / .moderation / .audio.speech / .audio.transcription、spring.ai.anthropic.chat、spring.ai.ollama.chat / .embedding / .init、spring.ai.mistralai.chat / .embedding / .moderation / .ocr、spring.ai.deepseek.chat、spring.ai.elevenlabs.tts、spring.ai.stabilityai.image、spring.ai.google.genai.chat / .embedding / .embedding.text / .image、spring.ai.vertex.ai.embedding.text / .embedding.multimodal、spring.ai.bedrock.converse.chat / .cohere.embedding / .titan.embedding |
| 向量库 | 24 | spring.ai.vectorstore.{azure,bedrock-knowledge-base,cassandra,chroma,chroma.client,couchbase,elasticsearch,gemfire,mariadb,milvus,milvus.client,mongodb,neo4j,observations,opensearch,oracle,pgvector,pinecone,qdrant,redis,s3,typesense,typesense.client,weaviate} |
| MCP 客户端 | 6 | spring.ai.mcp.client、spring.ai.mcp.client.{sse,stdio,streamable-http,annotation-scanner} |
| MCP 服务端 | 3(+重复) | spring.ai.mcp.server、spring.ai.mcp.server.{streamable-http,annotation-scanner} |
| ChatClient / 记忆 / 工具 / 观测 | 8 | spring.ai.chat.client、spring.ai.chat.client.tool-search-advisor、spring.ai.chat.observations、spring.ai.chat.memory.redis、spring.ai.chat.memory.repository.{cassandra,jdbc,mongo,neo4j,redis}、spring.ai.tools、spring.ai.image.observations、spring.ai.retry |
22.11.2 ★ 三个值得单独记的"孤儿前缀"
(1)spring.ai.ollama.init ------ 只服务一个功能:Ollama 的模型拉取初始化。它是唯一一个 <provider>.init 形态的前缀,其余供应商都没有。
(2)spring.ai.mistralai.ocr ------ 对应 22.2.4 那个硬编码的 spring.ai.model.ocr。★ 注意 MistralAiOcrProperties 的 CONFIG_PREFIX 是 spring.ai.mistralai.ocr(有 常量、有 IDE 提示),而它的开关 spring.ai.model.ocr 没有常量。同一功能的两半,一半纳入了常量体系,一半没有。
(3)spring.ai.vectorstore.mongodb 与 spring.ai.vectorstore.milvus.client / chroma.client / typesense.client ------ ★ 只有这 4 个 store 有 .client 二级前缀(因为它们的 Java 客户端本身有大量参数需要暴露)。其余 16 个 store 没有。而 memory repository 侧却是 mongodb 模块 / mongo 前缀 的不一致(22.7.3)。同一个数据库,两处命名规则不同。
22.11.3 前缀与 @ConfigurationProperties 的两种写法
85 个 @ConfigurationProperties 文件里,两种写法并存:
java
// 写法 A:常量(主流,62+ 处)
@ConfigurationProperties(ChatClientBuilderProperties.CONFIG_PREFIX)
public class ChatClientBuilderProperties {
public static final String CONFIG_PREFIX = "spring.ai.chat.client";
}
// 写法 B:字面量(少数,如 MCP 的注解扫描器)
@ConfigurationProperties(prefix = McpClientAnnotationScannerProperties.CONFIG_PREFIX)
public class McpClientAnnotationScannerProperties {
public static final String CONFIG_PREFIX = "spring.ai.mcp.client.annotation-scanner";
}
★ 注意 @ConfigurationProperties("x") 与 @ConfigurationProperties(prefix = "x") 是等价的(value 与 prefix 互为 alias),但同一个项目里两种写法混用,grep 时要注意。
22.12 ★ 265 个 @DeprecatedConfigurationProperty:1.x → 2.x 的配置迁移面
这是从 1.x 升级时最实际的一节。
bash
grep -rn "@DeprecatedConfigurationProperty" --include=*.java . | grep -v "/test/" | wc -l # 265
grep -rln "@DeprecatedConfigurationProperty" --include=*.java . | grep -v "/test/" | wc -l # 24 个文件
22.12.1 迁移的实质:...options.* → 扁平化
以 OpenAI 为例,1.x 的写法是:
properties
spring.ai.openai.chat.options.model=gpt-4o
spring.ai.openai.chat.options.temperature=0.7
spring.ai.openai.chat.options.max-tokens=2048
2.x 扁平化成:
properties
spring.ai.openai.chat.model=gpt-4o
spring.ai.openai.chat.temperature=0.7
spring.ai.openai.chat.max-tokens=2048
旧的 options.* 依然能绑定,但每个 getter 都标了:
java
// OpenAiChatProperties.java:352-361
@DeprecatedConfigurationProperty(replacement = "spring.ai.openai.chat")
@Deprecated(since = "2.0.0", forRemoval = true)
public Options getOptions() {
return this.options;
}
public void setOptions(Options options) { this.options = options; }
public class Options { // ★ 注意:非 static 内部类
★ 三个观察:
replacement = "spring.ai.openai.chat"------ 只给到前缀,不给到具体属性。所以 IDE 会提示"已废弃",但不会自动帮你改。public class Options不是public static class------ 对比ChatClientBuilderProperties.Observations(public static class)与ToolCalling(public static class),只有它是非 static。作为@ConfigurationProperties的嵌套绑定类型,这是异类。Options内部还有 40+ 个自己的@DeprecatedConfigurationProperty,指向spring.ai.openai.chat.model、spring.ai.openai.chat.frequency-penalty等具体属性------这一层是给到具体属性的。
22.12.2 委托式 getter 的模式
Options 里的每个 getter/setter 都直接委托给外部类:
java
// OpenAiChatProperties.java:517-526
@DeprecatedConfigurationProperty(replacement = "spring.ai.openai.chat.model")
@Deprecated(since = "2.0.0", forRemoval = true)
public @Nullable String getModel() {
return OpenAiChatProperties.this.getModel();
}
public void setModel(@Nullable String model) {
OpenAiChatProperties.this.setModel(model);
}
★ 但不是全部 都委托。Options 自己声明了 baseUrl、apiKey、credential、microsoftDeploymentName、timeout、maxRetries、proxy、customHeaders 等独立字段 ------这些在 2.x 里被移到了 spring.ai.openai.*(连接属性),所以 options.timeout 委托的 replacement 是 spring.ai.openai.timeout(跨前缀 ),而 options.model 委托的是 spring.ai.openai.chat.model。
★ 这就是 22.4 讲的"两级合并"的历史由来:1.x 的 options.* 是"连接参数 + 模型参数混在一起",2.x 把它们拆成了 spring.ai.openai.*(连接)与 spring.ai.openai.chat.*(模型),并保留 options.* 作为兼容层。
22.12.3 24 个文件的分布
bash
grep -rln "@DeprecatedConfigurationProperty" --include=*.java . | grep -v "/test/" | sed 's|.*/autoconfigure/||'
| 供应商 | 文件数 | 说明 |
|---|---|---|
| OpenAI | 6 | Chat / Embedding / Image / Moderation / AudioSpeech / AudioTranscription |
| Mistral AI | 4 | Chat / Embedding / Moderation / Ocr |
| Google GenAI | 2 | Chat / TextEmbedding |
| Vertex AI | 2 | TextEmbedding / MultimodalEmbedding |
| Anthropic、DeepSeek、ElevenLabs、Ollama、Bedrock×2、PostgresML、StabilityAI | 各 1 | --- |
| RedisChatMemoryProperties | 1 | 非模型类,唯一例外 |
★ Anthropic 只有 1 个文件有废弃属性,而 OpenAI 有 6 个------因为 Anthropic 在 2.x 换成了官方 SDK,配置结构变化小;OpenAI 同样换 SDK 但保留了最多的兼容层。
22.12.4 @Deprecated(forRemoval = true) 的三个重灾区
bash
grep -rn "forRemoval = true" --include=*.java . | grep -v "/test/" | wc -l
| 位置 | 例子 |
|---|---|
ChatClientCustomizer 及其 setter |
ChatClientBuilderConfigurer.java:38 |
OpenAI Options 嵌套类全体 |
OpenAiChatProperties.java:352 起 |
| 连接属性的旧 getter | AbstractOpenAiProperties 的 getDeploymentName() / getAzureDeploymentName() |
★ AbstractOpenAiProperties 里还有一组别名:
java
// AbstractOpenAiProperties.java(节选)
/**
* Alias for getAzureDeploymentName()
*/
public @Nullable String getDeploymentName() { return this.microsoftDeploymentName; }
★ getDeploymentName() / setDeploymentName() 是 microsoftDeploymentName 的别名 ,没有 @Deprecated。所以 spring.ai.openai.deployment-name 与 spring.ai.openai.microsoft-deployment-name 都能用且等价 ------但 IDE 只会提示后者(前者没有对应字段,getDeploymentName 返回的字段名是 microsoftDeploymentName,Boot 的元数据生成器会把它映射成 microsoft-deployment-name,导致两个前缀都能绑定同一个字段)。
22.13 配置元数据:4 个 additional-spring-configuration-metadata.json
Spring Boot 的配置元数据主要由注解处理器从 @ConfigurationProperties 自动生成,手工补充的只有 4 处:
bash
find . -name "additional-spring-configuration-metadata.json" | grep -v target
# 1. auto-configurations/models/chat/client/... (ChatClient)
# 2. auto-configurations/models/spring-ai-autoconfigure-model-openai/...
# 3. auto-configurations/models/spring-ai-autoconfigure-model-mistral-ai/...
# 4. spring-ai-vector-store/.../resources/META-INF/
22.13.1 ChatClient 那份:手工补写 spring.ai.model.*
最有意思的是第一份------它把 8 个 spring.ai.model.* 开关手工写进元数据:
json
{
"properties": [
{
"name": "spring.ai.model.chat",
"type": "java.lang.String",
"description": "The primary ChatModel to autoconfigure. If not set, each ChatModel auto-configuration is enabled by default.",
"defaultValue": ""
},
{ "name": "spring.ai.model.embedding", ... },
{ "name": "spring.ai.model.embedding.text", ... },
{ "name": "spring.ai.model.embedding.multimodal", ... },
{ "name": "spring.ai.model.image", ... },
{ "name": "spring.ai.model.audio.transcription", ... },
{ "name": "spring.ai.model.audio.speech", ... },
...
]
}
★ 为什么必须手工写?因为这些属性不是任何 @ConfigurationProperties 类的字段 ------它们只出现在 @ConditionalOnProperty(name = SpringAIModelProperties.CHAT_MODEL, ...) 里,注解处理器扫不到。所以 Spring AI 用 additional-spring-configuration-metadata.json 让 IDE 能补全它们。
★ 而 spring.ai.model.ocr(22.2.4)不在这份文件里 → IDE 不提示 → 印证了它是"影子开关"。
★ 注意描述文字:"If not set, each ChatModel auto-configuration is enabled by default"------这就是 22.2.2 的"默认全开",被官方文档写在了元数据里。
22.13.2 向量库那份:给 spring.ai.vectorstore.type 补元数据
json
// spring-ai-vector-store/src/main/resources/META-INF/additional-spring-configuration-metadata.json
{
"groups": [],
"properties": [
{ "name": "spring.ai.vectorstore.type", "type": "java.lang.String",
"description": "Vector store type. Requires a vector store." }
],
"hints": [],
"ignored": { "properties": [] }
}
★ 描述只有一句 "Requires a vector store.",没有列出 19 个合法取值 。所以 IDE 不会给你下拉提示,你得自己知道要写 pgvector 还是 S3。
22.13.3 OpenAI 那份:给嵌套枚举补类型
json
{
"groups": [
{ "name": "spring.ai.openai.chat.output-audio",
"type": "org.springframework.ai.openai.OpenAiChatOptions$AudioParameters",
"sourceType": "org.springframework.ai.openai.OpenAiChatOptions" }
],
"properties": [
{ "name": "spring.ai.openai.chat.output-audio.voice",
"type": "...OpenAiChatOptions$AudioParameters$Voice", ... },
{ "name": "spring.ai.openai.chat.output-audio.format",
"type": "...OpenAiChatOptions$AudioParameters$AudioResponseFormat", ... }
]
}
★ 只有 OpenAI 和 Mistral 做了这件事(给嵌套枚举补 Voice / AudioResponseFormat 的候选值)。其余供应商的嵌套枚举在 IDE 里就是纯字符串输入框。
22.14 AOT 与 Native:19 个 RuntimeHintsRegistrar
bash
grep -rln "RuntimeHintsRegistrar\|RegisterReflectionForBinding\|ImportRuntimeHints" --include=*.java . | grep -v "/test/" | wc -l
# 19
22.14.1 分布
| 位置 | 数量 | 代表 |
|---|---|---|
spring-ai-model |
3 | SpringAiCoreRuntimeHints、ToolRuntimeHints、KnuddelsRuntimeHints |
| 各模型模块 | 6 | BedrockRuntimeHints、DeepSeekRuntimeHints、ElevenLabsRuntimeHints、GoogleGenAiRuntimeHints、MistralAiRuntimeHints、OllamaRuntimeHints |
| MCP | 4 | McpHints + 3 个 McpClientAutoConfigurationRuntimeHints(common / httpclient / webflux) |
| 向量库 | 1 | PineconeVectorStoreHints |
| 其它 | 5 | PdfReaderRuntimeHints、JdbcChatMemoryRepositoryRuntimeHints、AbstractEmbeddingModel(RegisterReflectionForBinding) |
★ 22 个 vector store 里只有 Pinecone 有 RuntimeHints。其余 21 个在 GraalVM native image 下序列化/反射可能失败。
22.14.2 SpringAiCoreRuntimeHints:反射 + 资源
java
// spring-ai-model/.../aot/SpringAiCoreRuntimeHints.java:40-62(节选)
var chatTypes = Set.of(AbstractMessage.class, AssistantMessage.class, ToolResponseMessage.class, Message.class,
ToolCallback.class, ToolDefinition.class, AssistantMessage.ToolCall.class, MessageType.class,
UserMessage.class, SystemMessage.class, Content.class, MediaContent.class);
var memberCategories = MemberCategory.values(); // ★ 全部 MemberCategory
for (var c : chatTypes) {
hints.reflection().registerType(c, memberCategories);
var innerClassesFor = AiRuntimeHints.findInnerClassesFor(c);
for (var cc : innerClassesFor) {
hints.reflection().registerType(cc, memberCategories);
}
}
for (var r : Set.of("embedding/embedding-model-dimensions.properties")) {
hints.resources().registerResource(new ClassPathResource(r));
}
★ 三个点:
MemberCategory.values()------ 注册全部成员类别 (INVOKE_PUBLIC_CONSTRUCTORS、INVOKE_PUBLIC_METHODS、DECLARED_FIELDS...)。这是最宽松的注册,native image 体积会变大,但不会漏。AiRuntimeHints.findInnerClassesFor(c)------ 递归注册内部类,因为AssistantMessage.ToolCall这种嵌套类型 Jackson 要用到。embedding/embedding-model-dimensions.properties被显式注册为资源------★ 这正是第 18 章讲的维度查表文件。它只在 native image 下需要手动注册,JVM 模式下走 classpath 即可。
22.14.3 ★ KnuddelsRuntimeHints:一个"看起来像拼写错误"的类
java
// spring-ai-model/.../aot/KnuddelsRuntimeHints.java:26-33
public class KnuddelsRuntimeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
hints.resources().registerResource(new ClassPathResource("/com/knuddels/jtokkit/cl100k_base.tiktoken"));
}
}
★ 类名里的 Knuddels 是 jtokkit 库的作者公司名 (com.knuddels:jtokkit),不是拼写错误。它注册的是 cl100k_base.tiktoken------OpenAI tokenizer 的 BPE 词表文件。没有这个 hint,JTokkitTokenCountEstimator 在 native image 下会 ResourceNotFoundException。
★ 这个类不在 spring-ai-model 的 aot 包文档里单独说明,很容易在排查 native 问题时被忽略。
22.14.4 AbstractEmbeddingModel 上的 @RegisterReflectionForBinding
bash
grep -rn "RegisterReflectionForBinding" --include=*.java . | grep -v "/test/"
# spring-ai-model/.../embedding/AbstractEmbeddingModel.java
这是 19 个里唯一一个用在模型类(而非 *RuntimeHints 类)上 的注解------因为 AbstractEmbeddingModel 要把 EmbeddingResponse 反序列化回来。第 18 章讲过它的维度查表失效问题,这里补一条:它在 native 下还额外依赖这个反射注册。
22.15 MCP 自动配置速览:三个 enabled、一个 type
第 13--15 章讲过 MCP 的运行时行为,这里只列 Boot 层的开关。
22.15.1 客户端:spring.ai.mcp.client.enabled + spring.ai.mcp.client.type
java
// McpClientAutoConfiguration.java:100-102
@AutoConfiguration
@EnableConfigurationProperties(McpClientCommonProperties.class)
@ConditionalOnProperty(prefix = McpClientCommonProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true",
matchIfMissing = true)
public class McpClientAutoConfiguration {
★ 又是 havingValue = "true" + matchIfMissing = true 的组合------与 spring.ai.chat.client.enabled 完全同构(22.3.1):false 能关,FALSE/0/no 也能关(因为不等于 true),但不写就是开。
type 决定同步/异步:
java
// McpClientAutoConfiguration.java:117-119
@Bean
@ConditionalOnProperty(prefix = McpClientCommonProperties.CONFIG_PREFIX, name = "type", havingValue = "SYNC",
matchIfMissing = true)
public McpSyncToolsChangeEventEmmiter mcpSyncToolChangeEventEmmiter(ApplicationEventPublisher applicationEventPublisher) {
return new McpSyncToolsChangeEventEmmiter(applicationEventPublisher);
}
★ havingValue = "SYNC" ------ 大写,且大小写敏感 。写 sync 不命中;而 matchIfMissing = true 意味着不写时按 SYNC 处理。所以:
- 不写 → SYNC(默认)
- 写
SYNC→ SYNC - 写
sync→ 两个都不匹配 ,McpSyncToolsChangeEventEmmiter与McpAsyncToolsChangeEventEmmiter都不创建 - 写
ASYNC→ ASYNC
★ 与 spring.ai.vectorstore.type=S3(大写)相反,这里 pgvector 那套是小写------两套开关的大小写约定是相反的。
22.15.2 三个 type 门一共 11 处
bash
grep -rn 'havingValue = "SYNC"' --include=*.java auto-configurations/mcp/ | wc -l # 6
grep -rn 'havingValue = "ASYNC"' --include=*.java auto-configurations/mcp/ | wc -l # 5
分布在 McpClientAutoConfiguration、McpToolCallbackAutoConfiguration、McpClientAnnotationScannerAutoConfiguration 三个类里。
22.15.3 服务端的 protocol 与六个 before
MCP server 侧有 6 个 @AutoConfiguration(before = ...):
bash
# webmvc 与 webflux 各三个传输:
# McpServerSseWebMvcAutoConfiguration before = McpServerAutoConfiguration
# McpServerStreamableHttpWebMvcAutoConfiguration before = McpServerAutoConfiguration
# McpServerStatelessWebMvcAutoConfiguration before = McpServerStatelessAutoConfiguration
# (webflux 同构三份)
★ 与第 15 章的结论咬合:三种协议形态是三个独立的自动配置,靠 @ConditionalOnProperty 互斥,且都必须在核心 McpServerAutoConfiguration 之前跑 。这就是"不写 protocol 时跑的到底是哪个传输"的实现基础------第 15 章讲过默认值(STREAMABLE)与实际生效值(SSE)相反,这里的 before 排序保证了它们的条件判定发生在核心配置之前。
22.16 ★ spring.ai.ollama.init:全仓库唯一的「启动时拉模型」自动配置
22.11.2 把它列为三个"孤儿前缀"之一,但没有展开。spring.ai.ollama.init 是整个 Spring AI 里唯一一个在应用启动时去远端拉模型权重 的配置面,也是 Ollama 这家供应商"最不像供应商"的集中体现。本节把它从 Properties 一路拆到 OllamaModelManager 的构造器。
22.16.1 前缀树:三层的 chat / embedding / 全局
java
// OllamaInitializationProperties.java:31-59(节选)
@ConfigurationProperties(OllamaInitializationProperties.CONFIG_PREFIX)
public class OllamaInitializationProperties {
public static final String CONFIG_PREFIX = "spring.ai.ollama.init";
private final ModelTypeInit chat = new ModelTypeInit(); // spring.ai.ollama.init.chat.*
private final ModelTypeInit embedding = new ModelTypeInit(); // spring.ai.ollama.init.embedding.*
private PullModelStrategy pullModelStrategy = PullModelStrategy.NEVER;
private Duration timeout = Duration.ofMinutes(5);
private int maxRetries = 0;
public static class ModelTypeInit { // :93-122
private boolean include = true;
private List<String> additionalModels = List.of();
}
}
| 键 | 类型 | 默认值 | 作用 |
|---|---|---|---|
spring.ai.ollama.init.pull-model-strategy |
ALWAYS / WHEN_MISSING / NEVER |
NEVER |
拉取策略 |
spring.ai.ollama.init.timeout |
Duration |
5m |
单个模型拉取的等待上限 |
spring.ai.ollama.init.max-retries |
int |
0 |
拉取失败的额外重试次数 |
spring.ai.ollama.init.{chat,embedding}.include |
boolean |
true |
该侧是否参与初始化 |
spring.ai.ollama.init.{chat,embedding}.additional-models |
List<String> |
[] |
额外要拉的模型名 |
★ 注意 pull-model-strategy / timeout / max-retries 是全局共享 的:chat 与 embedding 两侧用的是同一份值,只有 include 与 additionalModels 是分开的。你无法做到"chat 用 WHEN_MISSING、embedding 用 NEVER"。
22.16.2 ★ include 与 strategy 的真值表:默认其实什么都不拉
java
// OllamaChatAutoConfiguration.java:65-66
var chatModelPullStrategy = initProperties.getChat().isInclude() ? initProperties.getPullModelStrategy()
: PullModelStrategy.NEVER;
java
// OllamaEmbeddingAutoConfiguration.java:58-59(完全同构)
var embeddingModelPullStrategy = initProperties.getEmbedding().isInclude()
? initProperties.getPullModelStrategy() : PullModelStrategy.NEVER;
include 默认 true、pullModelStrategy 默认 NEVER,两者相乘得到默认什么都不拉:
include |
pull-model-strategy |
实际生效 |
|---|---|---|
true(默认) |
NEVER(默认) |
NEVER ------ ★ 默认组合,什么都不做 |
true |
WHEN_MISSING |
缺失才拉 |
true |
ALWAYS |
每次启动都拉 |
false |
任意 | NEVER ------ include=false 一票否决 |
★ 这是个很反直觉的默认设计:include=true 看起来像"开启初始化",但它只是不否决 ;真正的开关是 pull-model-strategy,而它默认是 NEVER。所以"我配了 spring.ai.ollama.init.chat.include=true 但启动时模型没被拉下来"是最常见的误读。
22.16.3 ★ 拉取发生在 OllamaModelManager 的构造器 里,而且只拉 additionalModels
java
// OllamaModelManager.java:51-58
public OllamaModelManager(OllamaApi ollamaApi, ModelManagementOptions options) {
this.ollamaApi = ollamaApi;
this.options = options;
if (!CollectionUtils.isEmpty(options.additionalModels())) {
options.additionalModels().forEach(this::pullModel);
}
}
三个必须记住的点:
(1)构造函数里做网络 I/O。 OllamaModelManager 在 OllamaChatModel.builder()...build() 内部创建,而那又发生在 ollamaChatModel(...) 这个 @Bean 方法里------启动阶段就会同步阻塞地访问 Ollama 的 /api/pull ;Ollama 没起来是启动失败,不是"首次调用才失败"。这是 89 个自动配置里唯一一处把远端调用放进 bean 创建路径的设计。
(2)主模型在 另一个构造器里拉。 上面的循环只覆盖 options.additionalModels();主模型名(spring.ai.ollama.chat.options.model)由 OllamaChatModel 自己的构造器负责:
java
// OllamaChatModel.java:146-148
String model = options.getModel();
Assert.state(model != null, "model must not be null");
initializeModel(model, modelManagementOptions.pullModelStrategy());
// OllamaChatModel.java:474-478
private void initializeModel(String model, @Nullable PullModelStrategy pullModelStrategy) {
if (pullModelStrategy != null && !PullModelStrategy.NEVER.equals(pullModelStrategy)) {
this.modelManager.pullModel(model, pullModelStrategy);
}
}
★ 所以一次 ollamaChatModel(...) bean 创建里会发生两轮 拉取:OllamaModelManager 构造器拉 additionalModels,紧接着 OllamaChatModel 构造器拉主模型------都受同一个 pullModelStrategy 与 include 控制(chat 侧看 init.chat.include、embedding 侧看 init.embedding.include)。
(3)WHEN_MISSING 的探测代价。 每次判定都要全量列举:
判定靠 isModelAvailable(:60-68):调 listModels() 拿全量再线性 anyMatch,不做缓存。名字归一化在:
java
// OllamaModelManager.java:75-81(★ 隐式补 ":latest")
private String normalizeModelName(String modelName) {
var modelNameWithoutSpaces = modelName.trim();
if (modelNameWithoutSpaces.contains(":")) {
return modelNameWithoutSpaces;
}
return modelNameWithoutSpaces + ":latest";
}
normalizeModelName 会给不带 : 的名字补 :latest,而 listModels() 返回的 name 是原始字符串 :本地是 ollama pull llama3.2 拉下来的才带 :latest、才匹配得上;写 llama3.2 而本地其实是 llama3.2:3b,就会被补成 llama3.2:latest 从而判定为缺失 并触发一次下载。★ 配 additional-models 时务必写全 tag 。另外 N 个 additional model 配 WHEN_MISSING 会打 N 次 /api/tags,每次全量返回再线性比对,开销 O(N×M)。
22.16.4 与 22.9 的闭环:OllamaConnectionDetails 是三源收口点
java
// OllamaConnectionDetails.java:26-30(★ 整个接口只有一个方法)
public interface OllamaConnectionDetails extends ConnectionDetails {
String getBaseUrl();
}
java
// OllamaApiAutoConfiguration.java:45-49(★ 兜底分支挂在接口类型上,不在实现类上)
@Bean
@ConditionalOnMissingBean(OllamaConnectionDetails.class)
PropertiesOllamaConnectionDetails ollamaConnectionDetails(OllamaConnectionProperties properties) { ... }
// :51-63 ollamaApi(...) 只从 connectionDetails 取一个 baseUrl;
// :61 的 responseErrorHandler 兜底到 RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER(见 22.5)
// :65-78 PropertiesOllamaConnectionDetails 是同文件的包级私有静态内部类
三条来源在这里合流,优先级由 Spring Boot 的 ConnectionDetails 机制决定,而不是 Spring AI 自己写的:
| 来源 | 提供者 | 生效条件 |
|---|---|---|
spring.ai.ollama.base-url |
PropertiesOllamaConnectionDetails(包级私有静态内部类) |
@ConditionalOnMissingBean(OllamaConnectionDetails.class) ------ 兜底 |
| Docker Compose | OllamaDockerComposeConnectionDetailsFactory(22.9.2) |
容器服务名匹配 ollama/ollama 镜像 |
| Testcontainers | OllamaContainerConnectionDetailsFactory |
@Container 或 @ServiceConnection |
★ 这是全 Spring AI 最"Boot 原生"的一处装配:没有自己造 @ConditionalOnProperty 去切换 base-url 来源,而是用 ConnectionDetails + @ConditionalOnMissingBean 让 Boot 决定。反过来说,其余 47 个 starter 都没有这层抽象 ------spring.ai.openai.base-url 写什么就直连什么,没有 Docker Compose 兜底。
22.16.5 为什么 Ollama 是「最不像供应商」的一家
把本节与 22.5、22.9 拼起来,Ollama 在四个维度上与其它供应商不同:
| 维度 | Ollama | 其余供应商 |
|---|---|---|
| 初始化 | 有 spring.ai.ollama.init.*,启动时可拉模型 |
无 |
| 连接信息 | OllamaConnectionDetails 三源收口(Properties / Compose / Testcontainers) |
只有 base-url 一个 Properties 字段 |
| 鉴权 | 没有 apiKey (OllamaConnectionProperties 只有 baseUrl) |
都有 api-key,且 22.4 的空串语义围绕它展开 |
| 重试 | 走 ObjectProvider<RetryTemplate>,spring.ai.retry.* 有效 |
OpenAI / Anthropic / Bedrock 无效(22.5.4) |
★ 结论:如果你要找一个"Spring AI 自动配置的最佳实践样本",是 Ollama 而不是 OpenAI------OpenAI 因为换成了官方 SDK,自动配置退化成了"把 Properties 塞给 OpenAiSetup",反而丢掉了 ConnectionDetails 与 RetryTemplate 两条 Boot 化路径。
22.17 ★ ToolSearchAdvisorAutoConfiguration:夹心排序、嵌套配置与 5 处「默认关闭」
22.10.4 只点名了它的 @AutoConfiguration(after = ..., before = ...)。这里完整拆开,因为它是 89 个自动配置里唯一同时用 after 和 before 双向定位 的,也是**唯一靠"返回父类型"抢占另一个自动配置的 @ConditionalOnMissingBean**的。
22.17.1 ★ 排序为什么必须是「夹心」
java
// ToolSearchAdvisorAutoConfiguration.java:93-97
@AutoConfiguration(after = ToolCallingAutoConfiguration.class, before = ChatClientAutoConfiguration.class)
@ConditionalOnClass(ToolSearchToolCallingAdvisor.class)
@EnableConfigurationProperties(ToolSearchAdvisorProperties.class)
@ConditionalOnProperty(prefix = ToolSearchAdvisorProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true")
public class ToolSearchAdvisorAutoConfiguration implements InitializingBean {
两个方向各有硬理由:
after = ToolCallingAutoConfiguration.class------ 核心 bean 挂着@ConditionalOnBean(ToolCallingManager.class),而ToolCallingManager由ToolCallingAutoConfiguration贡献;本类先跑则条件失败、advisor 永远注册不上。before = ChatClientAutoConfiguration.class------ 那个类的toolCallingAdvisorBuilder挂着@ConditionalOnMissingBean;本类必须先把ToolCallingAdvisor.Builder<?>注册进去,默认的才会被跳过。
★ 注意这里的抢占手法:
java
// ToolSearchAdvisorAutoConfiguration.java:125-130
@Bean
@ConditionalOnMissingBean
@ConditionalOnBean({ ToolCallingManager.class, ToolIndex.class })
ToolCallingAdvisor.Builder<?> toolCallingAdvisorBuilder(ToolSearchAdvisorProperties properties,
ToolCallingManager toolCallingManager, ToolIndex toolIndex,
ObjectProvider<ToolExecutionEligibilityChecker> toolExecutionEligibilityChecker) {
方法返回类型声明成 ToolCallingAdvisor.Builder<?> (父类型),而实际对象是 ToolSearchToolCallingAdvisor.Builder(子类型)。@ConditionalOnMissingBean 在不指定 value 时按方法的返回类型 判定,所以 ChatClientAutoConfiguration 里那个返回 ToolCallingAdvisor.Builder<?> 的方法就被判定为"已有同类型 bean"而跳过。★ 这是 Spring Boot 条件注解按"类型"而非"实例"工作的一个直接后果:只要声明类型相同,就能用一个实现替换另一个实现 ------不需要 @Primary,不需要 @ConditionalOnSingleCandidate。
22.17.2 三个 ToolIndex 是同文件内的嵌套 @Configuration
java
// 三个嵌套静态 @Configuration,互斥靠 havingValue(只列 vector 一支,另两支同构)
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(VectorToolIndex.class)
@ConditionalOnMissingBean(ToolIndex.class)
@ConditionalOnProperty(prefix = ToolSearchAdvisorProperties.CONFIG_PREFIX, name = "tool-index-type",
havingValue = "vector", matchIfMissing = false)
static class VectorToolIndexConfiguration { ... }
// LuceneToolIndexConfiguration:havingValue = "lucene",matchIfMissing = false,加 @ConditionalOnClass(LuceneToolIndex.class)
// RegexToolIndexConfiguration:havingValue = "regex",matchIfMissing = true,无 @ConditionalOnClass
tool-index-type |
实现 | 额外依赖 | 默认 |
|---|---|---|---|
regex(缺省) |
RegexToolIndex |
无 | ✔ matchIfMissing = true |
lucene |
LuceneToolIndex(min-score-threshold 默认 0.25f) |
org.apache.lucene:lucene-core |
✘ |
vector |
VectorToolIndex |
一个 VectorStore bean |
✘ |
★ vector 那一支是唯一一个在 @Bean 方法里 fail-fast 的(:173-184):vectorStoreProvider.getIfAvailable() 为 null 就抛 IllegalStateException,异常信息里直接建议加 spring-ai-starter-vector-store-pgvector。
对比 22.8.2 的 S3VectorStoreAutoConfiguration------那里把 Assert 写在自动配置类的构造器 里,这里写在 @Bean 方法里。失败时机不同:构造器版在配置类实例化 时就炸(更早、更难定位),@Bean 版在该 bean 创建时才炸(更晚、信息更完整)。
22.17.3 InitializingBean 只 warn 不 fail
java
// ToolSearchAdvisorAutoConfiguration.java:101-117
private static final Set<String> KNOWN_TOOL_INDEX_TYPES = Set.of("regex", "lucene", "vector");
@Override
public void afterPropertiesSet() {
String type = this.properties.getToolIndexType();
if (type != null && !KNOWN_TOOL_INDEX_TYPES.contains(type.toLowerCase(Locale.ROOT))) {
logger.warn("Unknown '...tool-index-type' value: '" + type + "'. Known built-in values are: "
+ KNOWN_TOOL_INDEX_TYPES + ". ...");
}
}
★ 三点:
- 校验用
toLowerCase(Locale.ROOT),大小写不敏感 (REGEX也过)------与 22.2.5 的SpringAIVectorStoreTypes.S3 = "S3"(写s3不命中)正好相反。 - 未知值只打 warn 。此时三个嵌套配置的条件全落空,
ToolIndex无 bean →@ConditionalOnBean({..., ToolIndex.class})失败 → 整个 advisor 静默消失,只剩一行 warn。这是全仓库最典型的"配错了但不报错"。 - setter 有
Assert.hasText,所以空串在绑定阶段就抛异常(不会被当 null 走默认):
java
// ToolSearchAdvisorProperties.java:114-117
public void setToolIndexType(String toolIndexType) {
Assert.hasText(toolIndexType, "toolIndexType must not be empty");
this.toolIndexType = toolIndexType;
}
22.17.4 ★ 全仓库只有 5 处 matchIfMissing = false
bash
grep -rn "matchIfMissing = false" --include=*.java . | grep -v "/test/"
| 位置 | 键 | 默认 |
|---|---|---|
ToolSearchAdvisorAutoConfiguration.java:170 |
...tool-index-type=vector |
关 |
ToolSearchAdvisorAutoConfiguration.java:199 |
...tool-index-type=lucene |
关 |
McpServerAutoConfiguration.java:356 |
...protocol=STREAMABLE |
关 |
McpServerAutoConfiguration.java:396 |
...protocol=STREAMABLE |
关 |
McpServerStatelessAutoConfiguration.java:267 |
同上 | 关 |
★ 把这 5 处与 22.2.2 的 26 个 matchIfMissing = true 放在一起看,结论非常清楚:Spring AI 的自动配置哲学是"默认全开" 。显式声明 matchIfMissing = false(即"默认关闭")的开关,全仓库只有 5 个,且全部集中在"同一维度上多选一"的场景(索引实现三选一、MCP 协议三选一)------因为多选一不可能全都开。而"我是否启用这个模型"这类开关一律默认开,代价就是 22.2.3 的多 starter 注入歧义。
22.18 SpringAiRetryProperties 字段全表与判定顺序
22.5 讲了"谁用 RetryTemplate",这一节补上"它到底能配什么、判定顺序是什么"。
22.18.1 六个字段、三个默认值
java
// SpringAiRetryProperties.java:31-63(节选)
@ConfigurationProperties(SpringAiRetryProperties.CONFIG_PREFIX)
public class SpringAiRetryProperties {
public static final String CONFIG_PREFIX = "spring.ai.retry";
private int maxAttempts = 10; // :39
@NestedConfigurationProperty private final Backoff backoff = new Backoff();
private boolean onClientErrors = false; // :52
private List<Integer> excludeOnHttpCodes = new ArrayList<>();
private List<Integer> onHttpCodes = new ArrayList<>();
public static class Backoff { // :104-145
private Duration initialInterval = Duration.ofMillis(2000);
private int multiplier = 5;
private Duration maxInterval = Duration.ofMillis(3 * 60000); // 180s,★ 写成 3*60000 而非 ofMinutes(3)
}
}
| 键 | 默认值 | 语义 |
|---|---|---|
spring.ai.retry.max-attempts |
10 |
含首次在内的总尝试次数(即最多重试 9 次) |
spring.ai.retry.on-client-errors |
false |
true = 4xx 也抛 TransientAiException 并重试 |
spring.ai.retry.on-http-codes |
[] |
白名单:这些状态码一定重试 |
spring.ai.retry.exclude-on-http-codes |
[] |
黑名单:这些状态码一定不重试 |
spring.ai.retry.backoff.initial-interval |
2000ms |
首次退避 |
spring.ai.retry.backoff.multiplier |
5 |
倍数 |
spring.ai.retry.backoff.max-interval |
180000ms(3 分钟) |
退避上限 |
★ maxInterval 写成 Duration.ofMillis(3 * 60000) 而非 Duration.ofMinutes(3)(值相同),且与 22.5.3 的 RetryUtils 常量重复定义。
★ 注意默认值有多激进:第 2 次等 2s、第 3 次 10s、第 4 次 50s、第 5 次起封顶 180s------max-attempts=10 时最坏累计约 19 分钟,远超任何 HTTP 客户端默认超时。这是默认值,不是配错。
22.18.2 判定顺序(按源码顺序)
SpringAiRetryAutoConfiguration 里构造的 ResponseErrorHandler 判定顺序如下(onHttpCodes / excludeOnHttpCodes / onClientErrors 三者的相对优先级是实际踩坑点):
| 次序 | 条件 | 结果 |
|---|---|---|
| 1 | 状态码 ∈ exclude-on-http-codes |
抛 NonTransientAiException,不重试 |
| 2 | 状态码 ∈ on-http-codes |
抛 TransientAiException,重试 |
| 3 | 4xx 且 on-client-errors=false(默认) |
NonTransientAiException,不重试 |
| 4 | 4xx 且 on-client-errors=true |
TransientAiException,重试 |
| 5 | 5xx | TransientAiException,重试 |
★ 关键顺序事实:黑名单(exclude)优先于白名单(on) 。把 429 同时放进两个 list,结果是不重试 。而"白名单 vs on-client-errors"这一对是白名单先判定------on-http-codes=404 + on-client-errors=false 仍然会对 404 重试。
22.18.3 与 RetryUtils 的常量重复
22.5.3 的陷阱 24 已经指出:RetryUtils:47-56 与 SpringAiRetryProperties:39/109/114/119 把同样四个默认值各写了一遍。这里再补第三套:ModelManagementOptions.defaults()(:43)与 OllamaInitializationProperties(:54/:59)是 NEVER / List.of() / 5m / 0。
★ 也就是说,Ollama 的"拉模型超时/重试"(5m / 0)与 spring.ai.retry.*(2s 起 ×5 / 10 次)是两套完全无关的数字 :改 spring.ai.retry.max-attempts 不会影响模型拉取,那是 spring.ai.ollama.init.max-retries。
22.19 MCP 注解扫描自动配置与全仓库唯一的 @ConditionalOnWebApplication
22.19.1 McpServerAnnotationScannerAutoConfiguration:默认开启的扫描器
java
// McpServerAnnotationScannerAutoConfiguration.java:46-55
@AutoConfiguration
@ConditionalOnClass(McpTool.class)
@ConditionalOnProperty(prefix = ...CONFIG_PREFIX, name = "enabled", havingValue = "true", matchIfMissing = true)
@EnableConfigurationProperties(McpServerAnnotationScannerProperties.class)
@ImportRuntimeHints(McpServerAnnotationScannerAutoConfiguration.AnnotationHints.class)
public class McpServerAnnotationScannerAutoConfiguration {
private static final Set<Class<? extends Annotation>> SERVER_MCP_ANNOTATIONS = Set.of(McpTool.class,
McpResource.class, McpPrompt.class, McpComplete.class);
- 前缀
spring.ai.mcp.server.annotation-scanner,默认开启 (matchIfMissing = true),与 22.17.4 的"5 处默认关闭"不冲突------它是"启不启用",不是"多选一"。 - 扫描目标写死为
@McpTool/@McpResource/@McpPrompt/@McpComplete四个注解。★ 这个Set是private static final,没有任何配置可以往里加注解,第 14 章讲的"工具来源"因此是封闭集合。
java
// :57-73(三个 bean,后两个是 static)
@Bean @ConditionalOnMissingBean
public ServerMcpAnnotatedBeans serverAnnotatedBeanRegistry() { ... }
@Bean @ConditionalOnMissingBean
public static ServerAnnotatedMethodBeanPostProcessor serverAnnotatedMethodBeanPostProcessor(...) { ... }
@Bean // ★ 唯一没有 @ConditionalOnMissingBean 的
public static ServerAnnotatedBeanFactoryInitializationAotProcessor serverAnnotatedBeanFactoryInitializationAotProcessor() { ... }
★ 后两个必须是 static:BeanPostProcessor / BeanFactoryInitializationAotProcessor 要在容器早期就绪,static 的 @Bean 方法可以不实例化配置类就被调用。★ 注意第三个 bean 没有 @ConditionalOnMissingBean------它没有"用户自定义优先"的口子。
22.19.2 ★ 全仓库只有 1 处 @ConditionalOnWebApplication
bash
grep -rn "@ConditionalOnWebApplication" --include=*.java . | grep -v "/test/"
# McpServerAutoConfiguration.java:220 @ConditionalOnWebApplication(type = Type.SERVLET)
★ 89 个自动配置里只有 MCP stateful server 关心"是不是 Servlet Web 应用":SSE / STREAMABLE 需要真实 HTTP 会话语义,webflux 走另一套类。其余 88 个对 web 环境完全无感------模型调用、向量库、ETL 都不区分 servlet / reactive / 非 web。
22.19.3 AOT 提示:注解本身也要注册反射
java
// McpServerAnnotationScannerAutoConfiguration.java:98-105
static class AnnotationHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
SERVER_MCP_ANNOTATIONS.forEach(an -> hints.reflection().registerType(an, MemberCategory.values()));
}
}
★ 这是 22.14 的 19 个 RuntimeHintsRegistrar 里特殊的一个:注册的不是业务 DTO 而是四个注解类型本身 。native 下 @McpTool 若不可反射访问,扫描器就找不到它------表现为"native 下工具全部消失",且日志无任何错误。
22.20 陷阱总表
22.20.1 选型开关(7 条)
| # | 陷阱 | 位置 | 后果 |
|---|---|---|---|
| 1 | ★ 26 个 spring.ai.model.* 开关全部 matchIfMissing = true |
各 XxxChatAutoConfiguration |
多 starter 共存 = 多个 ChatModel bean |
| 2 | ★ 全仓库 @ConditionalOnSingleCandidate 使用数为 0 |
--- | 多 ChatModel 时无兜底,直接 NoUniqueBeanDefinitionException |
| 3 | ★ 自动配置里没有任何 @Primary |
auto-configurations/ |
同上,只能自己声明 @Primary 或写 spring.ai.model.chat |
| 4 | ★ spring.ai.model.ocr 是硬编码字符串,无常量、无元数据 |
MistralAiOcrAutoConfiguration:42 |
IDE 不补全,改名静默失效 |
| 5 | ★ SpringAIVectorStoreTypes.S3 = "S3" 大写,其余 18 个小写 |
SpringAIVectorStoreTypes:64 |
写 s3 不命中 → S3 store 静默关闭 |
| 6 | ★ couchbase 是唯一没有 type 门的 store |
CouchbaseSearchVectorStoreAutoConfiguration:36-38 |
无法用 spring.ai.vectorstore.type 关掉 |
| 7 | ★ SpringAIModels 里 AZURE_OPENAI / OCI_GENAI / OPENAI_SDK 无引用 |
SpringAIModels:27/37/43 |
僵尸常量,写了也不会生效 |
22.20.2 prototype 与 bean 创建(6 条)
| # | 陷阱 | 位置 | 后果 |
|---|---|---|---|
| 8 | ★ chatClientBuilder 没有 @ConditionalOnBean(ChatModel.class) |
ChatClientAutoConfiguration:112-120 |
无模型时启动成功,首次注入才抛异常 |
| 9 | ★ prototype 注入 singleton 后 prototype 失效 | Spring 通用 | 所有注入点共享同一个 builder |
| 10 | ★ spring.ai.chat.client.enabled 用 havingValue = "true" |
ChatClientAutoConfiguration:70-71 |
只认小写 true;TRUE 也会关闭它 |
| 11 | ★ ChatClientBuilderConfigurer 的两个 setter 是包级私有 |
ChatClientBuilderConfigurer:40/44 |
无法在外部 new + 配置 |
| 12 | ★ ChatClientCustomizer 已 @Deprecated(forRemoval = true) 但仍先执行 |
ChatClientBuilderConfigurer:64-76 |
与新 ChatClientBuilderCustomizer 冲突时被覆盖 |
| 13 | ★ spring.ai.chat.client.tool-calling.enabled=false 只关"自动注册",不关 bean |
ChatClientAutoConfiguration:125-127 |
与 spring.ai.chat.client.enabled 语义完全不同 |
22.20.3 Properties 合并与覆盖(7 条)
| # | 陷阱 | 位置 | 后果 |
|---|---|---|---|
| 14 | ★ timeout 用"是否等于 60s"判断 → 显式写 60s 无法覆盖父级 |
OpenAiAutoConfigurationUtil:53-55 |
spring.ai.openai.chat.timeout=60s 无效 |
| 15 | ★ maxRetries 用"是否等于 3"判断 → 同理 |
OpenAiAutoConfigurationUtil:70-72 |
spring.ai.openai.chat.max-retries=3 无效 |
| 16 | ★ isMicrosoftFoundry / isGitHubModels 用 OR 合并 |
OpenAiAutoConfigurationUtil:67-69 |
子级 false 关不掉父级 true;源码注释与实现不符 |
| 17 | ★ customHeaders 用"是否非空 map"判断 |
OpenAiAutoConfigurationUtil:75-77 |
无法用空 map 覆盖父级 |
| 18 | ★ apiKey 空串是刻意设计(NoopApiKey),其余字段空串=未配置 |
OpenAiAutoConfigurationUtil:40-42 |
只有 apiKey 能"子级置空覆盖父级" |
| 19 | ★ ResolvedConnectionProperties extends OpenAiCommonProperties 但从不绑定 |
OpenAiAutoConfigurationUtil:79-81 |
继承了 @ConfigurationProperties 语义却不参与绑定 |
| 20 | ★ getDeploymentName() 是 microsoftDeploymentName 的无注解别名 |
AbstractOpenAiProperties |
两个前缀绑定同一字段,IDE 只提示一个 |
22.20.4 重试(4 条)
| # | 陷阱 | 位置 | 后果 |
|---|---|---|---|
| 21 | ★ spring.ai.retry.* 对 OpenAI / Anthropic / Bedrock 完全无效 |
见 22.5.4 | 改了没反应 |
| 22 | ★ openai starter 仍传递依赖 retry 模块 | spring-ai-autoconfigure-model-openai/pom.xml |
造出没人消费的 RetryTemplate / ResponseErrorHandler bean |
| 23 | ★ RetryListener 的 AtomicInteger 是 singleton 全局累加 |
SpringAiRetryAutoConfiguration:75-84 |
日志 "Retry count:N" 不是本次重试次数 |
| 24 | ★ 默认策略在 RetryUtils 与 SpringAiRetryProperties 里各写一份 |
RetryUtils:47-56 |
改一处不同步 |
22.20.5 命名不一致与顺序(6 条)
| # | 陷阱 | 位置 | 后果 |
|---|---|---|---|
| 25 | ★ 模块 mongodb,前缀 mongo |
MongoChatMemoryProperties:32 |
写 ...repository.mongodb.* 静默无效 |
| 26 | ★ Redis 有两套前缀(repository 层 / memory 层) | 见 22.7.3 | 配错层静默无效 |
| 27 | ★ BatchingStrategy 是全局类型,被第一个 store 抢注 |
PgVectorStoreAutoConfiguration:56-59 |
多 store 共用一个分批策略 |
| 28 | ★ 89 个自动配置里只有 17 处显式排序 | 见 22.10.4 | BatchingStrategy 归属不可预测 |
| 29 | ★ S3 store 的 Assert 在自动配置类构造器里 |
S3VectorStoreAutoConfiguration:53-57 |
不配 index-name 启动即失败 |
| 30 | ★ @ConditionalOnMissingClass("io.micrometer.tracing.Tracer") 硬写 4 次 |
见 22.10.2 | 无常量,改名要改 4 处 |
22.20.6 恒真条件与元数据(6 条)
| # | 陷阱 | 位置 | 后果 |
|---|---|---|---|
| 31 | ★ @ConditionalOnClass(ChatModel.class) 等恒真 |
见 22.10.3 | 不能用来判断"是否生效" |
| 32 | ★ spring.ai.mcp.client.type 用大写 SYNC/ASYNC,与 vectorstore.type 小写相反 |
McpClientAutoConfiguration:118 |
写 sync 两个分支都不匹配 |
| 33 | ★ 265 个废弃属性里,getOptions() 的 replacement 只到前缀 |
OpenAiChatProperties:352 |
IDE 提示了但不知改到哪个属性 |
| 34 | ★ OpenAiChatProperties.Options 是非 static 内部类 |
OpenAiChatProperties:361 |
与其余 static class 不一致 |
| 35 | ★ 22 个 store 只有 Pinecone 有 RuntimeHints |
见 22.14.1 | 其余在 native image 下有风险 |
| 36 | ★ 26 个 spring.ai.model.* 开关里,25 个走常量、1 个走字面量(Mistral OCR) |
MistralAiOcrAutoConfiguration:42 |
只 grep SpringAIModelProperties 会数出 25,误判为"少一个" |
22.20.7 init / ToolSearch / retry 字段 / MCP 扫描(6 条)
| # | 陷阱 | 位置 | 后果 |
|---|---|---|---|
| 37 | ★ spring.ai.ollama.init.chat.include=true 不等于开启模型拉取 |
OllamaChatAutoConfiguration:65-66 |
真正的开关是 pull-model-strategy(默认 NEVER),默认什么都不拉 |
| 38 | ★ additional-models 不写 tag 会被补 :latest 从而判定"缺失" |
OllamaModelManager:75-81 |
每次启动都重下一次 |
| 39 | ★ 模型拉取发生在 OllamaModelManager 构造器里 |
OllamaModelManager:51-58 |
Ollama 不可达 → 启动失败,而不是首次调用才失败 |
| 40 | ★ 一次 bean 创建里有两轮 拉取:manager 构造器拉 additionalModels、model 构造器拉主模型 |
OllamaModelManager:55-57 + OllamaChatModel:148/474-478 |
配 N 个 additional model = 至少 N+1 次 pullModel |
| 41 | ★ tool-index-type 写错只 warn ,随后 ToolIndex 无 bean → advisor 静默消失 |
ToolSearchAdvisorAutoConfiguration:110-117 |
配错不报错、功能消失 |
| 42 | ★ exclude-on-http-codes 判定优先于 on-http-codes |
见 22.18.2 | 同一状态码进两个 list = 不重试 |
22.21 小结
- 集成面是 54 autoconfigure 模块 / 89
@AutoConfiguration/ 48 starter / 85@ConfigurationProperties/ 75 个前缀 ,且不是一一对应------openai starter 会顺带拉进 chat-client、chat-memory 两个自动配置。 - 两套选型开关 (
spring.ai.model.<capability>与spring.ai.vectorstore.type)都是matchIfMissing = true:不是"默认关",是"默认全开"。 - 默认全开 +
@ConditionalOnSingleCandidate使用数为 0 + 没有@Primary= 多 starter 共存必然注入歧义。 ChatClient.Builder是@Scope("prototype"),为的是避免 default 设置跨注入点泄漏 ;代价是没有ChatModel时错误推迟到注入点 ,且注入 singleton 会让 prototype 失效。OpenAiAutoConfigurationUtil.resolveCommonProperties()的两级合并混了六种语义 ,其中timeout/maxRetries的"是否等于默认值"判断导致显式写默认值无法覆盖父级 ,两个布尔开关用 OR 导致子级关不掉父级。- 两套重试并存 :
spring.ai.retry.*(10 次 / 2s / ×5 / 180s 封顶)只对 DeepSeek/ElevenLabs/Google/Mistral/Ollama/Vertex 有效;OpenAI/Anthropic/Bedrock 走 SDK 内建(spring.ai.openai.max-retries,默认 3)。 ToolCallingAutoConfiguration排除 MCP provider 用的是字符串比较类全名(可选依赖约束),代价是重命名静默失效。- 六个 memory repository 全部
before = ChatMemoryAutoConfiguration,靠顺序保证@ConditionalOnMissingBean能正确退让。 BatchingStrategy是全局类型被第一个 store 抢注,且 89 个自动配置里只有 17 处显式排序,结果不可预测。- Docker Compose / Testcontainers 走
spring.factories(不是.imports) ,只覆盖 9 个可本地容器化的服务,没有 pgvector。 - 265 个
@DeprecatedConfigurationProperty是 1.xoptions.*→ 2.x 扁平化的迁移面,OpenAI 一个模块就占 6 个文件。 - 4 个
additional-spring-configuration-metadata.json中,ChatClient 那份手工补写了 8 个spring.ai.model.*(因为它们是@ConditionalOnProperty参数、不是字段,注解处理器扫不到)。 - native 侧有 19 个
RuntimeHintsRegistrar,其中KnuddelsRuntimeHints负责注册 jtokkit 的cl100k_base.tiktoken------漏了它在 native image 下会ResourceNotFoundException。 - MCP 客户端
type用大写SYNC/ASYNC,向量库type用小写pgvector------两套开关的大小写约定相反。 spring.ai.model.ocr、spring.ai.chat.memory.repository.mongo、S3这三个"孤儿"说明:常量体系、元数据、模块命名三者并不同步。spring.ai.ollama.init是全仓库唯一 的"启动时拉模型"配置面,也是唯一 用ConnectionDetails三源收口 base-url 的供应商;★ Ollama 才是 Spring AI 自动配置的"最佳实践样本" ------OpenAI 换成官方 SDK 后反而丢掉了ConnectionDetails与RetryTemplate两条 Boot 化路径。ToolSearchAdvisorAutoConfiguration是唯一同时用after与before双向定位 的自动配置,并靠"把返回类型声明成父类型ToolCallingAdvisor.Builder<?>"抢占ChatClientAutoConfiguration的@ConditionalOnMissingBean------不需要@Primary,也不需要@ConditionalOnSingleCandidate。- 全仓库只有 5 处
matchIfMissing = false(2 处 ToolSearch 索引、3 处 MCP 协议),且全落在"同一维度多选一"的场景;对比 26 个默认全开的spring.ai.model.*,★ Spring AI 的自动配置哲学就是"默认全开"。
22.22 思考题
- 你同时引入了 openai 与 anthropic 两个 starter,启动报
NoUniqueBeanDefinitionException: ChatModel。请列出三种解法,并说明哪一种不需要改代码。 spring.ai.model.chat=none为什么能关闭所有 chat 自动配置?它和框架里写死的none关键字有什么关系?写spring.ai.model.chat=openai-2会发生什么?- 你想让 chat 用 60s 超时、embedding 用 120s 超时,父级
spring.ai.openai.timeout不设。请写出配置;再说明为什么"父级 120s + chat 60s"这个组合做不到。 ChatClient.Builder是 prototype。请说明:为什么必须是 prototype?如果改成 singleton 会泄漏什么?以及 prototype 注入 singleton 字段后会发生什么、怎么修?spring.ai.retry.max-attempts=3在你的 OpenAI 项目里没有任何效果。请给出两个原因,并写出真正生效的配置项。SpringAiRetryAutoConfiguration的RetryTemplatebean 在你的 OpenAI 项目里被创建了但没人用。请说明它是怎么被引入 classpath 的,以及你如何验证"确实没人消费它"。spring.ai.vectorstore.type=S3与spring.ai.mcp.client.type=SYNC的大小写约定相反。请再找出本章提到的第三处"大小写敏感"陷阱,并说明三者的共同根因。- 你的项目同时引入 pgvector 与 chroma 两个 starter。请说明会有几个
BatchingStrategybean、几个VectorStorebean,以及为什么这个组合在生产上是危险的。 CouchbaseSearchVectorStoreAutoConfiguration是唯一没有type门的 store。请说明:在什么场景下这会成为问题?以及除了移除依赖之外,你还能怎么关掉它?- 引入 S3 starter 后应用启动失败,堆栈指向
S3VectorStoreAutoConfiguration的构造器。请说明根因、正确的修复方式,以及为什么"用spring.ai.vectorstore.type=s3"是错误答案。 - 你要把 1.x 的
spring.ai.openai.chat.options.*迁移到 2.x。请说明options.model与options.timeout的 replacement 分别指向哪里,以及为什么两者不同(提示:与 22.4 的两级合并有关)。 OpenAiChatProperties.Options是非 static 内部类,而ChatClientBuilderProperties.Observations是 static。请说明这个差异对 Spring Boot 的属性绑定意味着什么,以及为什么 2.0 把它标了@Deprecated(forRemoval = true)。
22.23 文件索引
22.23.1 核心与通用
文件(省略 src/main/java/) |
关键行 |
|---|---|
spring-ai-model/.../model/SpringAIModelProperties.java |
26-45 九个常量 |
spring-ai-model/.../model/SpringAIModels.java |
25-57 十八个值(3 个僵尸) |
spring-ai-vector-store/.../vectorstore/SpringAIVectorStoreTypes.java |
26-64 TYPE + 19 个值(S3="S3") |
spring-ai-retry/.../retry/RetryUtils.java |
47-56 四个默认值;DEFAULT_RESPONSE_ERROR_HANDLER |
auto-configurations/common/.../SpringAiRetryAutoConfiguration.java |
57-59 类级;62-85 retryTemplate;88-130 responseErrorHandler |
auto-configurations/common/.../SpringAiRetryProperties.java |
CONFIG_PREFIX = "spring.ai.retry" |
22.23.2 ChatClient 与工具
| 文件 | 关键行 |
|---|---|
auto-configurations/models/chat/client/.../ChatClientAutoConfiguration.java |
67-72 类级条件;86-95 configurer;97-110 advisor builder;112-129 prototype builder;131-158 Tracer 存在;160-182 Tracer 不存在 |
auto-configurations/models/chat/client/.../ChatClientBuilderProperties.java |
36 前缀;63-96 ToolCalling;98-135 Observations |
auto-configurations/models/chat/client/.../ChatClientBuilderConfigurer.java |
38-46 包级 setter;64-76 applyCustomizers |
auto-configurations/models/chat/client/.../additional-spring-configuration-metadata.json |
全部(8 个 spring.ai.model.*) |
auto-configurations/models/tool/.../ToolCallingAutoConfiguration.java |
57-59 类级;65-68 UNLIMITED;79-101 MCP 排除;103-121 异常处理器;128-175 manager |
auto-configurations/models/tool/.../ToolCallingProperties.java |
40 CONFIG_PREFIX = "spring.ai.tools" |
22.23.3 OpenAI(两级合并)
| 文件 | 关键行 |
|---|---|
auto-configurations/models/spring-ai-autoconfigure-model-openai/.../AbstractOpenAiProperties.java |
32/44 DEFAULT_TIMEOUT / DEFAULT_MAX_RETRIES;getDeploymentName() 别名 |
.../OpenAiCommonProperties.java |
25 CONFIG_PREFIX = "spring.ai.openai" |
.../OpenAiChatProperties.java |
43 类;100-107 getModel/setModel;317-345 toOptions();352-361 Options 声明;517-526 委托 getter |
.../OpenAiAutoConfigurationUtil.java |
29-77 ★ resolveCommonProperties;79-81 ResolvedConnectionProperties |
.../OpenAiChatAutoConfiguration.java |
53-57 类级;59-92 openAiChatModel |
.../additional-spring-configuration-metadata.json |
output-audio.voice / .format |
22.23.4 向量库、记忆、容器与 MCP
| 文件 | 关键行 |
|---|---|
auto-configurations/vector-stores/.../pgvector/.../PgVectorStoreAutoConfiguration.java |
46-49 类级;56-59 BatchingStrategy ;61-84 vectorStore |
auto-configurations/vector-stores/.../s3/.../S3VectorStoreAutoConfiguration.java |
47-51 类级;53-57 构造器 Assert ;59-67 s3VectorStore |
auto-configurations/vector-stores/.../couchbase/.../CouchbaseSearchVectorStoreAutoConfiguration.java |
36-38 类级(无 type 门 );41-56 vectorStore |
auto-configurations/models/chat/memory/.../ChatMemoryAutoConfiguration.java |
34-36 类级;38-49 两个 bean |
auto-configurations/models/chat/memory/repository/mongo/.../MongoChatMemoryProperties.java |
32 CONFIG_PREFIX = "spring.ai.chat.memory.repository.mongo" |
spring-ai-spring-boot-docker-compose/src/main/resources/META-INF/spring.factories |
9 个工厂 |
spring-ai-spring-boot-testcontainers/src/main/resources/META-INF/spring.factories |
9 个工厂 |
.../docker/compose/service/connection/ollama/OllamaDockerComposeConnectionDetailsFactory.java |
30-58 |
auto-configurations/mcp/.../McpClientAutoConfiguration.java |
100-102 类级;117-119 type=SYNC |
spring-ai-model/.../aot/SpringAiCoreRuntimeHints.java |
40-62 |
spring-ai-model/.../aot/KnuddelsRuntimeHints.java |
26-33 |
22.23.5 Ollama init、ToolSearch 与 MCP 注解扫描(22.16 -- 22.19)
| 文件 | 关键行 |
|---|---|
auto-configurations/models/.../ollama/autoconfigure/OllamaInitializationProperties.java |
31-34 前缀;39-59 六个字段;93-122 ModelTypeInit |
auto-configurations/models/.../ollama/autoconfigure/OllamaChatAutoConfiguration.java |
51-55 类级;65-66 include×strategy;68-77 builder |
auto-configurations/models/.../ollama/autoconfigure/OllamaEmbeddingAutoConfiguration.java |
50 类级;58-59 同构判定 |
auto-configurations/models/.../ollama/autoconfigure/OllamaConnectionDetails.java |
26-30 只有 getBaseUrl() |
auto-configurations/models/.../ollama/autoconfigure/OllamaApiAutoConfiguration.java |
45-49 兜底 ConnectionDetails;51-63 ollamaApi;65-78 内部类 |
models/spring-ai-ollama/.../management/OllamaModelManager.java |
51-58 构造器拉模型 ;60-68 isModelAvailable;75-81 normalizeModelName;99-115 pullModel 三分支 |
auto-configurations/advisors/.../toolsearch/autoconfigure/ToolSearchAdvisorAutoConfiguration.java |
93 夹心排序 ;101-117 afterPropertiesSet warn;125-150 builder;166-186 vector;195-207 lucene;216-227 regex |
auto-configurations/common/.../SpringAiRetryProperties.java |
34 前缀;39 maxAttempts=10;52 onClientErrors;104-145 Backoff |
auto-configurations/mcp/.../annotations/McpServerAnnotationScannerAutoConfiguration.java |
46-55 类级(默认开 );54-55 四个注解;57-73 三个 bean;98-105 AnnotationHints |
auto-configurations/mcp/.../McpServerAutoConfiguration.java |
220 全仓库唯一 @ConditionalOnWebApplication ;356 / 396 matchIfMissing = false |
22.24 复核命令
以下命令均在项目根目录 D:/CODE/spring-ai-2.0.1 下执行。
bash
# 1. 四个总数:54 / 54 / 89 / 48 / 85
find auto-configurations -name pom.xml | wc -l
find . -name "org.springframework.boot.autoconfigure.AutoConfiguration.imports" | grep -v target | wc -l
grep -rn "^@AutoConfiguration" --include=*.java . | grep -v "/test/" | wc -l
ls -d starters/*/ | wc -l
grep -rln "@ConfigurationProperties" --include=*.java . | grep -v "/test/" | wc -l
# 2. ★ 26 个 spring.ai.model.* 开关全部 matchIfMissing = true
grep -rn -A1 "SpringAIModelProperties\." --include=*.java auto-configurations/ \
| grep -c "matchIfMissing = true"
# 3. ★ 没有任何 @ConditionalOnSingleCandidate / @Primary
grep -rn "@ConditionalOnSingleCandidate" --include=*.java . | grep -v "/test/"
grep -rn "@Primary" --include=*.java auto-configurations/ | grep -v "/test/"
# 4. ★ spring.ai.model.ocr 硬编码(SpringAIModelProperties 里没有 OCR)
grep -rn "OCR" --include=*.java spring-ai-model/
grep -rn 'name = "spring.ai.model.ocr"' --include=*.java auto-configurations/
# 5. ★ SpringAIModels 三个僵尸常量
grep -rn "SpringAIModels\.\(AZURE_OPENAI\|OCI_GENAI\|OPENAI_SDK\)" --include=*.java . | grep -v "/test/"
# 6. ★ S3 = "S3" 大写;19 个 store 用 TYPE 门
grep -n 'S3 = ' spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/SpringAIVectorStoreTypes.java
grep -rl "SpringAIVectorStoreTypes.TYPE" --include=*.java auto-configurations/ | wc -l
# 7. couchbase 无 type 门
sed -n '36,38p' auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-couchbase/src/main/java/org/springframework/ai/vectorstore/couchbase/autoconfigure/CouchbaseSearchVectorStoreAutoConfiguration.java
# 8. ★ ChatClient.Builder 的 prototype 与缺失的 ChatModel 条件
sed -n '112,120p' auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientAutoConfiguration.java
# 9. ★ resolveCommonProperties 的六种语义
sed -n '29,77p' auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiAutoConfigurationUtil.java
# 10. ★ 谁用 RetryTemplate / 谁不用
grep -rln "RetryTemplate" --include=*.java . | grep -v "/test/" | wc -l
grep -rl "RetryTemplate" --include=*.java models/spring-ai-openai/ models/spring-ai-anthropic/ | grep -v "/test/"
# 11. retry 模块被 11 个 autoconfigure 依赖、0 个 starter 直接声明
grep -rl "spring-ai-autoconfigure-retry" auto-configurations/ | wc -l
grep -rl "spring-ai-autoconfigure-retry" starters/ | wc -l
# 12. 条件注解分布
for a in ConditionalOnClass ConditionalOnMissingBean ConditionalOnBean ConditionalOnProperty ConditionalOnMissingClass; do
echo "$a $(grep -rl "@$a" --include=*.java . | grep -v '/test/' | wc -l)"; done
# 13. @AutoConfiguration 排序:3 after / 2 afterName / 12 before
grep -rn "@AutoConfiguration(after" --include=*.java . | grep -v "/test/" | wc -l
grep -rn "afterName" --include=*.java . | grep -v "/test/" | wc -l
grep -rn "@AutoConfiguration(before" --include=*.java . | grep -v "/test/" | wc -l
# 14. ★ BatchingStrategy 被多个 store 抢注
grep -rn "BatchingStrategy [a-z]" --include=*.java auto-configurations/vector-stores/ | grep -v "/test/"
# 15. S3 构造器 Assert
sed -n '53,57p' auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-s3/src/main/java/org/springframework/ai/vectorstore/s3/autoconfigure/S3VectorStoreAutoConfiguration.java
# 16. 75 个配置前缀
grep -rhn 'CONFIG_PREFIX = "' --include=*.java . | grep -v "/test/" | sed 's/.*CONFIG_PREFIX = //' | sort -u | wc -l
# 17. ★ 265 个废弃属性 / 24 个文件
grep -rn "@DeprecatedConfigurationProperty" --include=*.java . | grep -v "/test/" | wc -l
grep -rln "@DeprecatedConfigurationProperty" --include=*.java . | grep -v "/test/" | wc -l
# 18. ★ Options 是非 static 内部类
grep -n "public class Options" auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatProperties.java
# 19. 4 个 additional metadata
find . -name "additional-spring-configuration-metadata.json" | grep -v target
# 20. 19 个 RuntimeHintsRegistrar
grep -rln "RuntimeHintsRegistrar\|RegisterReflectionForBinding\|ImportRuntimeHints" --include=*.java . | grep -v "/test/" | wc -l
# 21. ★ docker compose / testcontainers 用 spring.factories
cat spring-ai-spring-boot-docker-compose/src/main/resources/META-INF/spring.factories
# 22. memory repository 的 mongo(不是 mongodb)前缀
grep -rn 'CONFIG_PREFIX = "spring.ai.chat.memory.repository' --include=*.java auto-configurations/
# 23. ★ 只有 5 处 matchIfMissing = false(默认关),对比 26 处默认全开
grep -rn "matchIfMissing = false" --include=*.java . | grep -v "/test/"
# 24. ★ 全仓库只有 1 处 @ConditionalOnWebApplication
grep -rn "@ConditionalOnWebApplication" --include=*.java . | grep -v "/test/"
# 25. Ollama init:include × strategy(:65-66)与构造器拉取(OllamaModelManager:51-58)
sed -n '65,66p' auto-configurations/models/spring-ai-autoconfigure-model-ollama/src/main/java/org/springframework/ai/model/ollama/autoconfigure/OllamaChatAutoConfiguration.java
sed -n '51,58p' models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/management/OllamaModelManager.java
# 26. ToolSearch 夹心排序(:93-97)与 tool-index-type 三选一
sed -n '93,97p' auto-configurations/advisors/spring-ai-autoconfigure-tool-search-advisor/src/main/java/org/springframework/ai/chat/client/advisor/toolsearch/autoconfigure/ToolSearchAdvisorAutoConfiguration.java
22.25 第 23 章预告
第 23 章:模型供应商实现------OpenAI / Anthropic / Ollama / DeepSeek / Mistral / Bedrock。
第 22 章把"配置怎么变成 bean"讲完了,第 23 章要回答"bean 里到底装的是什么":
- OpenAI 换成官方 SDK 之后,
OpenAiApi去哪了? 2.x 里com.openai.client.OpenAIClient取代了自研的OpenAiApi+RestClient调用层。要拆清楚OpenAiSetup.setupSyncClient()到底配了什么(连接池、超时、重试、代理、自定义 header),以及spring.ai.openai.base-url是怎么变成OpenAIClient的。 - Anthropic 的
AnthropicChatModel与 OpenAI 的埋点位置差在哪? 第 21 章讲过两家都有ChatModelObservationContext埋点,本章要对齐到具体行号,并解释为什么 Anthropic 剥掉$schema/$defs(第 20 章的伏笔在这里收口)。 - Ollama 为什么是"最不像供应商"的一家? 它有
spring.ai.ollama.init(22.11.2 的孤儿前缀)、有 Docker Compose 工厂、有OllamaConnectionDetails,还有模型拉取初始化------它更像"本地服务"而非"远端 API"。 - DeepSeek / Mistral 的
RetryTemplate路径 :它们是少数还在用spring.ai.retry.*的模型(22.5.4),要看RetryTemplate是怎么包住RestClient调用的,以及这条路径与 OpenAI 的 SDK 内建重试在行为上差多少。 - Bedrock 是唯一需要
@ConditionalOnBean三连的 :AwsCredentialsProvider+AwsRegionProvider+CohereEmbeddingBedrockApi/TitanEmbeddingBedrockApi(22.10.1 里 11 处@ConditionalOnBean有 5 处在 Bedrock)。要解释为什么 AWS 侧的装配条件与其余供应商完全不同。
核心问题一句话:同样是 ChatModel,六家供应商的实现在"重试、超时、观测埋点、结构化输出、流式聚合"五件事上各有一套做法,2.x 的统一只统一了接口,没有统一实现。