Spring AI 2.0.1 深度解析-第 22 章 Spring Boot 集成:自动配置、Properties 与条件装配

Spring AI 2.0.1 深度解析-第 22 章 Spring Boot 集成:自动配置、Properties 与条件装配

22.0 本章总纲(先给结论,后面逐条拆到源码)

  1. 2.0.1 的 Spring Boot 集成面是 54 个 autoconfigure 模块 / 89 个 @AutoConfiguration 类 / 48 个 starter / 85 个 @ConfigurationProperties 文件 ,四者不是一一对应------一个 starter 往往拖进三四个 autoconfigure 模块。
  2. 存在两套互相独立的「选型开关」 :spring.ai.model.<capability>(模型侧,9 个常量)与 spring.ai.vectorstore.type(向量库侧,19 个常量)。二者都是 matchIfMissing = true------不是"默认关",而是"默认全开"。
  3. ★ 因为默认全开,同时引入两个 chat starter 会得到两个 ChatModel bean,且全仓库 @ConditionalOnSingleCandidate 的使用数为 0 ,没有任何兜底,注入点直接 NoUniqueBeanDefinitionException。
  4. ★ ChatClient.Builder 是 @Scope("prototype"),但它没有 @ConditionalOnBean(ChatModel.class)------没有模型时启动不报错,错误被推迟到第一次注入;而注入进 singleton 又会让 prototype 失效,只拿到一个实例。
  5. ★ OpenAiAutoConfigurationUtil.resolveCommonProperties() 的两级合并有六种不同的语义 :timeout 与 maxRetries 用"是否等于默认值"判断,所以显式写默认值无法覆盖父级 ;isMicrosoftFoundry / isGitHubModels 用 OR,所以子级关不掉父级。
  6. ★ 项目里有两套重试 :SpringAiRetryAutoConfiguration(org.springframework.core.retry.RetryTemplate,默认 10 次 / 2s / ×5 / 上限 180s)与供应商 SDK 内建重试(OpenAI spring.ai.openai.max-retries,默认 3)。OpenAI 与 Anthropic 走后者,spring.ai.retry.* 对它们完全无效 ,但 openai starter 依然传递依赖了 retry 模块,于是建出一个没人用的 RetryTemplate bean。
  7. ToolCallingAutoConfiguration 排除 MCP provider 用的是字符串比较类全名 (isMcpToolCallbackProvider),不是 instanceof------因为 MCP 是可选依赖。
  8. 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 判断的是返回类型 OpenAiChatModel
  • AnthropicChatAutoConfiguration 的 @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";

★ 两个异常:

  1. S3 = "S3" 是大写 ,其余 18 个全是小写。所以 spring.ai.vectorstore.type=S3 能命中,而习惯性写成 spring.ai.vectorstore.type=s3 不命中 (havingValue 是大小写敏感的)→ S3 store 被静默关掉。
  2. 没有 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(也没有自己的 ChatModel bean)。
  • 结果:应用正常启动 ,/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。用字符串比较绕开了这个约束。

代价是:

  1. 类全名写死在字符串里 ,重命名会静默失效(条件永远 false → MCP provider 被当成普通 provider → 提前触发 listTools())。
  2. 只递归 getSuperType()(父类),不看接口。如果某个 MCP provider 是"实现接口"而非"继承基类"的自定义类型,且类名不同,就漏判。
  3. 与第 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 ★ 两处命名不一致

  1. 模块叫 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)。
  2. MongoChatMemoryAutoConfiguration 少了 Repository 一词 :其余四个都叫 XxxChatMemoryRepositoryAutoConfiguration,只有它叫 MongoChatMemoryAutoConfiguration。

★ 后果:凭直觉写 spring.ai.chat.memory.repository.mongodb.* 是无效配置(静默忽略,不会报错,因为 Boot 对未知前缀不做校验)。

  1. 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();
}

★ 注意三点:

  1. bean 类型是 BatchingStrategy(全局类型),不是 pgvector 专属 。而 @ConditionalOnMissingBean 无参 → 按返回类型判断。
  2. 每个 store 的 autoconfigure 都这么写一份(bean 名不同:pgVectorStoreBatchingStrategy / chromaVectorStoreBatchingStrategy / ...),但类型相同。
  3. 结果:同时引入两个 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; }
	}
}

★ 两个细节:

  1. super("ollama/ollama") 是镜像名匹配 。你的 compose.yml 里必须写 image: ollama/ollama(或以其为前缀),否则匹配不上。
  2. 类是包级私有 (class 而非 public class),构造器是 protected------这是 Boot DockerComposeConnectionDetailsFactory 的约定。

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 内部类

★ 三个观察:

  1. replacement = "spring.ai.openai.chat" ------ 只给到前缀,不给到具体属性。所以 IDE 会提示"已废弃",但不会自动帮你改。
  2. public class Options 不是 public static class ------ 对比 ChatClientBuilderProperties.Observations(public static class)与 ToolCalling(public static class),只有它是非 static。作为 @ConfigurationProperties 的嵌套绑定类型,这是异类。
  3. 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));
}

★ 三个点:

  1. MemberCategory.values() ------ 注册全部成员类别 (INVOKE_PUBLIC_CONSTRUCTORS、INVOKE_PUBLIC_METHODS、DECLARED_FIELDS ...)。这是最宽松的注册,native image 体积会变大,但不会漏。
  2. AiRuntimeHints.findInnerClassesFor(c) ------ 递归注册内部类,因为 AssistantMessage.ToolCall 这种嵌套类型 Jackson 要用到。
  3. 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 + ". ...");
	}
}

★ 三点:

  1. 校验用 toLowerCase(Locale.ROOT),大小写不敏感 (REGEX 也过)------与 22.2.5 的 SpringAIVectorStoreTypes.S3 = "S3"(写 s3 不命中)正好相反。
  2. 未知值只打 warn 。此时三个嵌套配置的条件全落空,ToolIndex 无 bean → @ConditionalOnBean({..., ToolIndex.class}) 失败 → 整个 advisor 静默消失,只剩一行 warn。这是全仓库最典型的"配错了但不报错"。
  3. 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 小结

  1. 集成面是 54 autoconfigure 模块 / 89 @AutoConfiguration / 48 starter / 85 @ConfigurationProperties / 75 个前缀 ,且不是一一对应------openai starter 会顺带拉进 chat-client、chat-memory 两个自动配置。
  2. 两套选型开关 (spring.ai.model.<capability> 与 spring.ai.vectorstore.type)都是 matchIfMissing = true:不是"默认关",是"默认全开"。
  3. 默认全开 + @ConditionalOnSingleCandidate 使用数为 0 + 没有 @Primary = 多 starter 共存必然注入歧义。
  4. ChatClient.Builder 是 @Scope("prototype"),为的是避免 default 设置跨注入点泄漏 ;代价是没有 ChatModel 时错误推迟到注入点 ,且注入 singleton 会让 prototype 失效。
  5. OpenAiAutoConfigurationUtil.resolveCommonProperties() 的两级合并混了六种语义 ,其中 timeout / maxRetries 的"是否等于默认值"判断导致显式写默认值无法覆盖父级 ,两个布尔开关用 OR 导致子级关不掉父级。
  6. 两套重试并存 :spring.ai.retry.*(10 次 / 2s / ×5 / 180s 封顶)只对 DeepSeek/ElevenLabs/Google/Mistral/Ollama/Vertex 有效;OpenAI/Anthropic/Bedrock 走 SDK 内建(spring.ai.openai.max-retries,默认 3)。
  7. ToolCallingAutoConfiguration 排除 MCP provider 用的是字符串比较类全名(可选依赖约束),代价是重命名静默失效。
  8. 六个 memory repository 全部 before = ChatMemoryAutoConfiguration,靠顺序保证 @ConditionalOnMissingBean 能正确退让。
  9. BatchingStrategy 是全局类型被第一个 store 抢注,且 89 个自动配置里只有 17 处显式排序,结果不可预测。
  10. Docker Compose / Testcontainers 走 spring.factories(不是 .imports) ,只覆盖 9 个可本地容器化的服务,没有 pgvector。
  11. 265 个 @DeprecatedConfigurationProperty 是 1.x options.* → 2.x 扁平化的迁移面,OpenAI 一个模块就占 6 个文件。
  12. 4 个 additional-spring-configuration-metadata.json 中,ChatClient 那份手工补写了 8 个 spring.ai.model.* (因为它们是 @ConditionalOnProperty 参数、不是字段,注解处理器扫不到)。
  13. native 侧有 19 个 RuntimeHintsRegistrar,其中 KnuddelsRuntimeHints 负责注册 jtokkit 的 cl100k_base.tiktoken------漏了它在 native image 下会 ResourceNotFoundException。
  14. MCP 客户端 type 用大写 SYNC/ASYNC,向量库 type 用小写 pgvector------两套开关的大小写约定相反。
  15. spring.ai.model.ocr、spring.ai.chat.memory.repository.mongo、S3 这三个"孤儿"说明:常量体系、元数据、模块命名三者并不同步。
  16. spring.ai.ollama.init 是全仓库唯一 的"启动时拉模型"配置面,也是唯一 用 ConnectionDetails 三源收口 base-url 的供应商;★ Ollama 才是 Spring AI 自动配置的"最佳实践样本" ------OpenAI 换成官方 SDK 后反而丢掉了 ConnectionDetails 与 RetryTemplate 两条 Boot 化路径。
  17. ToolSearchAdvisorAutoConfiguration 是唯一同时用 after 与 before 双向定位 的自动配置,并靠"把返回类型声明成父类型 ToolCallingAdvisor.Builder<?>"抢占 ChatClientAutoConfiguration 的 @ConditionalOnMissingBean------不需要 @Primary,也不需要 @ConditionalOnSingleCandidate。
  18. 全仓库只有 5 处 matchIfMissing = false (2 处 ToolSearch 索引、3 处 MCP 协议),且全落在"同一维度多选一"的场景;对比 26 个默认全开的 spring.ai.model.*,★ Spring AI 的自动配置哲学就是"默认全开"。

22.22 思考题

  1. 你同时引入了 openai 与 anthropic 两个 starter,启动报 NoUniqueBeanDefinitionException: ChatModel。请列出三种解法,并说明哪一种不需要改代码。
  2. spring.ai.model.chat=none 为什么能关闭所有 chat 自动配置?它和框架里写死的 none 关键字有什么关系?写 spring.ai.model.chat=openai-2 会发生什么?
  3. 你想让 chat 用 60s 超时、embedding 用 120s 超时,父级 spring.ai.openai.timeout 不设。请写出配置;再说明为什么"父级 120s + chat 60s"这个组合做不到。
  4. ChatClient.Builder 是 prototype。请说明:为什么必须是 prototype?如果改成 singleton 会泄漏什么?以及 prototype 注入 singleton 字段后会发生什么、怎么修?
  5. spring.ai.retry.max-attempts=3 在你的 OpenAI 项目里没有任何效果。请给出两个原因,并写出真正生效的配置项。
  6. SpringAiRetryAutoConfiguration 的 RetryTemplate bean 在你的 OpenAI 项目里被创建了但没人用。请说明它是怎么被引入 classpath 的,以及你如何验证"确实没人消费它"。
  7. spring.ai.vectorstore.type=S3 与 spring.ai.mcp.client.type=SYNC 的大小写约定相反。请再找出本章提到的第三处"大小写敏感"陷阱,并说明三者的共同根因。
  8. 你的项目同时引入 pgvector 与 chroma 两个 starter。请说明会有几个 BatchingStrategy bean、几个 VectorStore bean,以及为什么这个组合在生产上是危险的。
  9. CouchbaseSearchVectorStoreAutoConfiguration 是唯一没有 type 门的 store。请说明:在什么场景下这会成为问题?以及除了移除依赖之外,你还能怎么关掉它?
  10. 引入 S3 starter 后应用启动失败,堆栈指向 S3VectorStoreAutoConfiguration 的构造器。请说明根因、正确的修复方式,以及为什么"用 spring.ai.vectorstore.type=s3"是错误答案。
  11. 你要把 1.x 的 spring.ai.openai.chat.options.* 迁移到 2.x。请说明 options.model 与 options.timeout 的 replacement 分别指向哪里,以及为什么两者不同(提示:与 22.4 的两级合并有关)。
  12. 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 里到底装的是什么":

  1. OpenAI 换成官方 SDK 之后,OpenAiApi 去哪了? 2.x 里 com.openai.client.OpenAIClient 取代了自研的 OpenAiApi + RestClient 调用层。要拆清楚 OpenAiSetup.setupSyncClient() 到底配了什么(连接池、超时、重试、代理、自定义 header),以及 spring.ai.openai.base-url 是怎么变成 OpenAIClient 的。
  2. Anthropic 的 AnthropicChatModel 与 OpenAI 的埋点位置差在哪? 第 21 章讲过两家都有 ChatModelObservationContext 埋点,本章要对齐到具体行号,并解释为什么 Anthropic 剥掉 $schema / $defs(第 20 章的伏笔在这里收口)。
  3. Ollama 为什么是"最不像供应商"的一家? 它有 spring.ai.ollama.init(22.11.2 的孤儿前缀)、有 Docker Compose 工厂、有 OllamaConnectionDetails,还有模型拉取初始化------它更像"本地服务"而非"远端 API"。
  4. DeepSeek / Mistral 的 RetryTemplate 路径 :它们是少数还在用 spring.ai.retry.* 的模型(22.5.4),要看 RetryTemplate 是怎么包住 RestClient 调用的,以及这条路径与 OpenAI 的 SDK 内建重试在行为上差多少。
  5. Bedrock 是唯一需要 @ConditionalOnBean 三连的 :AwsCredentialsProvider + AwsRegionProvider + CohereEmbeddingBedrockApi/TitanEmbeddingBedrockApi(22.10.1 里 11 处 @ConditionalOnBean 有 5 处在 Bedrock)。要解释为什么 AWS 侧的装配条件与其余供应商完全不同。

核心问题一句话:同样是 ChatModel,六家供应商的实现在"重试、超时、观测埋点、结构化输出、流式聚合"五件事上各有一套做法,2.x 的统一只统一了接口,没有统一实现。

相关推荐
zhangyy04231 小时前
agent 说"做完了",它真做完了吗:16 个任务、四种判据的一次实测
人工智能
程序员如山石1 小时前
为什么要用AI开发
人工智能
音符犹如代码1 小时前
DeepSeek-V4.1-Flash-1017 上手实测:3D 直出,效果到底如何
人工智能·3d·ai·ai编程
SEO_juper1 小时前
hreflang 审计工具:外贸多语言站谷歌SEO的隐性杀手
开发语言·人工智能·爬虫·python·seo
QYRdata1 小时前
机器人3D视觉感知赛道爆发 2026-2032年复合增长率达81.7%
人工智能·3d·机器人·服务发现·恒州博智
WJ.Polar1 小时前
final、单例类、枚举类、抽象类、接口
java·开发语言
江湖有缘2 小时前
Docker实战 | 使用Docker部署Imagor Studio图片编辑工具
java·docker·eureka
内容营销专家刘鑫炜2 小时前
主流 AI 大模型眼中的“刘鑫炜”之千问篇
人工智能
打工仔折腾 AI2 小时前
从零开始大模型微调:基于 PyTorch 与 ChatGLM 的 LoRA 实践指南
人工智能·pytorch·后端·python·深度学习·性能优化·ai agent 实战