Spring AI 2.0.1 深度解析-第 24 章 生产实践与 2.x 迁移:陷阱清单、AOT 与调优

Spring AI 2.0.1 深度解析-第 24 章 生产实践与 2.x 迁移:陷阱清单、AOT 与调优

前 23 章讲的是"它是什么、它怎么实现"。这一章讲的是你把它搬到自己系统里会发生什么。

迁移的难点从来不是编译不过------编译器会替你检查。真正杀死你的是三类静默变化:默认值变了、集合从空变 null 了、异常被换了类型。它们都能编译、能启动、能跑通冒烟测试,然后在生产环境的第三周出问题。

本章的迁移事实全部来自仓库自带的 spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc(3234 行)与源码本身。凡是文档没写、只有源码能证明的,我都会标出文件与行号。


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

  1. ★ 2.0.x 的破坏性变更分九层 :坐标层、类层、属性层、默认值层、集合语义层、行为层、枚举层、异常层、schema 层。其中只有前三层会编译/启动报错,后六层全是静默的------这就是"升级顺利、上线出事"的根因。
  2. ★ 最容易被漏掉的五条静默变化 :① 默认 temperature 0.7 被移除(改用各家原生默认,有的供应商是 1.0);② ChatOptions 的集合从"空集合"改成 null (getStopSequences() 现在是 @Nullable);③ 工具解析 fallback 默认关闭 (模型点名一个未挂载的工具,直接失败);④ ToolCallingAdvisor 的 Usage 变成跨轮累加 (token 统计一夜之间变大);⑤ OpenAI 工具 schema 的 strict 默认从 true 变 false。
  3. ★ ChatClient 与 ChatModel 在 options 上是不对称的 :ChatClient.prompt().options(...) 只接受 ChatOptions.Builder (泛型约束 <B extends ChatOptions.Builder<?>>),并在第一个 advisor 之前与模型默认值合并 ;而 ChatModel.call(Prompt) 必须拿到已 build 的完整实例 ,且完全不合并(非空则原样使用)。
  4. ★ BaseAdvisor 会把流式链上的任何异常包成 IllegalStateException("Stream processing failed", ...) (BaseAdvisor:72-73)。这意味着你在最外层 catch (WebClientResponseException) 抓不到 429/401------它已经变成 IllegalStateException,getCause() 里才是原始异常。
  5. ★ 默认所有 Advisor 都在 Schedulers.boundedElastic() 上跑 (BaseAdvisor.DEFAULT_SCHEDULER,:44),ChatModelStreamAdvisor:58 还是硬编码 publishOn(boundedElastic()) 并留着 // TODO add option to disable。流式高并发时,这是第一个被打满的东西。
  6. ★ Spring AI 一个 Actuator HealthIndicator 都没有 (全仓库 implements HealthIndicator 命中数 0 )。"模型连不上"不会体现在 /actuator/health 里,你必须自己写。
  7. ★ AOT 不是"开了就行" :全仓库 19 个 RuntimeHintsRegistrar 、14 个 aot.factories ,但核心 hints 用 MemberCategory.values() 全量注册 (SpringAiCoreRuntimeHints:45-53),而 @Tool 的 AOT 探测只看 getDeclaredMethods(beanClass) (ToolBeanRegistrationAotProcessor:48-51)------父类上的 @Tool 不会被发现 ,原生镜像里必然 NoSuchMethodException。
  8. ★ AiRuntimeHints 在 AOT 构建期做 classpath 扫描 (ClassPathScanningCandidateComponentProvider + Class.forName),扫的是"带 Jackson 注解"的类。这是"构建期反射",在 native 构建机上缺类会直接抛 RuntimeException------和运行期反射失败是两种不同的故障。
  9. ★ 超时与重试是三层叠加 :Spring RetryTemplate(10 次、最坏约 19 分钟)+ SDK 自带重试(OpenAI 3 次 / Anthropic 2 次)+ AWS SDK 重试。三层都开时,一次故障的实际耗时是三层相乘------这是"网关都超时了,你的服务还在重试"的原因。
  10. ★ MessageWindowChatMemory 默认只保留 20 条消息 (MessageWindowChatMemory:45),而 Redis 仓储默认 maxMessagesPerConversation=100(RedisChatMemoryRepository:1008)------内存策略 与存储上限是两个不同的数,长对话会被静默截断两次。
  11. 1.x 时代的"历史包袱"已经被清干净 :spring-ai-azure-openai、spring-ai-openai-sdk、spring-ai-oci-genai、spring-ai-hanadb-store 全部移除或迁出,PromptChatMemoryAdvisor、ChatMemory.DEFAULT_CONVERSATION_ID、copy()/fromOptions()、postProcessSchema() 全部删除。2.0.1 是一个"减法版本"。
  12. 本章最终交付一张可执行的生产检查清单(24.11,18 项),覆盖观测、超时、重试、线程、内存、Schema、安全六个面。

24.1 迁移地图:把 2.0.x 的破坏性变更分成九层

24.1.1 九层清单

层 典型变更 失败时机 可发现性
① 坐标层 模块改名(spring-ai-autoconfigure-model-chat-memory-redis → ...-repository-redis)、模块移除(azure-openai / openai-sdk / hanadb-store)、模块迁出(oci-genai → Oracle 仓库) Maven/Gradle 解析 编译期,最好发现
② 类层 OpenAiApi 不存在;PromptChatMemoryAdvisor 移除;ChatOptions#copy() / fromOptions() 移除;BeanOutputConverter#postProcessSchema 移除;ChatMemory.DEFAULT_CONVERSATION_ID 移除;buildRequestPrompt 私有化 javac 编译期
③ 属性层 所有非 chat 模型的 .options 段被扁平化(spring.ai.openai.embedding.options.model → ...embedding.model);Redis 前缀改 ...chat.memory.repository.redis.*;spring.ai.ollama.chat.think-option → ...chat.think;265 个 @DeprecatedConfigurationProperty(22.12) 启动时绑定(部分只 warn) 启动期,需读日志
④ 默认值层 默认 temperature 0.7 移除;OpenAI strict 默认 false;工具解析 fallback 默认 false;26 个 spring.ai.model.* 默认 true(22.2) 永不报错 ★ 只能靠对比输出
⑤ 集合语义层 options 内集合改为不可变;可空集合取代空集合 (getStopSequences() 现在可能返回 null) 运行期 NPE ★ 只在空值分支暴露
⑥ 行为层 ToolCallingAdvisor usage 跨轮累加;工具调用上限 40/150;OpenAiAudioSpeechModel 真流式;TranscriptionModel 默认 Flux.error(UnsupportedOperationException) 永不报错 ★ 靠指标/断言发现
⑦ 枚举层 Mistral moderation 的 dangerousAndCriminalContent 拆成 dangerous + criminal;Mistral chat model 枚举更新 编译期(若引用了旧常量)或运行期 400 中
⑧ 异常层 conversationId 缺失 → IllegalArgumentException;BaseAdvisor 把流式异常包成 IllegalStateException;RetryUtils 拆包 RetryException(23.10.4 #28) 运行期 ★ catch 块静默失效
⑨ schema 层 BeanOutputConverter 改为委托 JsonSchemaGenerator:Kotlin 可选参数不再进 required、@JsonProperty 未写 required 即视为非必需、新增 OpenAPI format 提示 运行期(模型输出不合 schema) ★ 最难发现

★ 这张表最重要的一列是最后一列:九层里有六层不会给你任何异常 。所以迁移的验收标准不能是"能启动",必须是"关键路径的输出与 1.x 逐字节对比"。

24.1.2 一张"最容易被漏掉"的表

下面十条来自 upgrade-notes.adoc 的 2.0.0 / 2.0.1 两节,按"漏掉后的后果严重度"排序:

# 变更 文档位置 漏掉的后果
1 默认 temperature 移除 upgrade-notes.adoc:2184-2223 ★ 输出风格、结构化输出的稳定性、甚至 JSON 合法性全都变了,但没有任何报错
2 工具解析 fallback 默认关闭 :305-330 模型点名一个"全局注册但没挂到本次请求"的工具 → ToolCallbackResolver 不再兜底 → 工具执行失败
3 ChatOptions 集合变 null :739-770;ChatOptions.java:59 你写 options.getStopSequences().isEmpty() → NPE
4 BaseAdvisor 包异常 BaseAdvisor.java:72-73 你的 catch (WebClientResponseException) 永远不进,监控里的错误类型全变成 IllegalStateException
5 conversationId 必需 :1324-1410 老代码没传 → 每次调用都 IllegalArgumentException(这个是"好朋友",至少会炸)
6 工具上限 40/150 :198-208;DefaultToolCallingManager:100/107 长任务在第 41 次同名工具调用时被拒
7 usage 跨轮累加 :210-216 计费看板突然翻倍;按 token 做的限流全部偏保守
8 OpenAI strict 默认 false :218-242 之前在 strict 下能过的 schema,现在靠模型自觉;结构化输出偶发不合规
9 .options(Builder) 而非实例 :1437-1469 编译不过(好事),但只对 ChatClient 生效 ,ChatModel 侧不合并------两套语义容易混
10 BeanOutputConverter schema 规则变化 :1205-1250 必填项变少 → 模型漏字段 → 反序列化得到一堆 null

24.1.3 迁移的顺序:先让编译器替你干活

正确的迁移顺序不是"从文档第一页读到最后一页",而是按失败时机从早到晚:

scss 复制代码
① 换 BOM / 版本号
   ↓ 编译错(大量)
② 删旧类引用:OpenAiApi → OpenAiSetup、PromptChatMemoryAdvisor → MessageChatMemoryAdvisor、
   copy()/fromOptions() → mutate()、postProcessSchema() → generateSchema()
   ↓ 编译错(少量)
③ 改属性:去 .options、Redis 前缀、think-option → think
   ↓ 启动错 / warn
④ 补 conversationId(否则第一次调用就 IllegalArgumentException)
   ↓ 运行错(只在调用时)
⑤ 对比"默认值层":temperature、strict、fallback、model 开关
   ↓ 静默
⑥ 对比"行为层":usage 累加、工具上限、音频流式
   ↓ 静默
⑦ 对比"schema 层":BeanOutputConverter 的 required 与 format
   ↓ 静默
⑧ 最后才做 native image(AOT 会把你前面所有"运行期才暴露"的问题提前到构建期)

★ 第 ⑧ 步放最后有额外好处:AOT 构建是最严格的静态检查 。ToolBeanRegistrationAotProcessor 扫不到父类 @Tool、AiRuntimeHints 扫不到某个带 Jackson 注解的类,都会在 native 构建或首次调用时暴露------比在生产环境暴露好得多。


24.2 坐标层:模块、artifact 与 starter

24.2.1 被移除 / 迁出的模块

upgrade-notes.adoc:1318-1323(Removed Modules)与 :1470-1492(OpenAI Java SDK Transition)里的清单,加上仓库现状核对:

旧模块 现状 替代 文档行
spring-ai-azure-openai 移除(含 starter 与 autoconfigure) 用 spring-ai-openai,类名去掉 Azure 前缀,属性改为 spring.ai.openai.*(Azure 相关走 isMicrosoftFoundry/microsoftDeploymentName) :1479-1488
spring-ai-openai-sdk 移除 用 spring-ai-openai,类名去掉 Sdk 后缀 :1484-1489
spring-ai-oci-genai 迁出 Oracle 的 spring-cloud-oracle 仓库 :1490-1492
spring-ai-hanadb-store 移除 无(HANA 向量存储不再内置) :1318-1323
spring-ai-autoconfigure-model-chat-memory-redis 改名 spring-ai-autoconfigure-model-chat-memory-repository-redis :7-56

★ 注意 azure-openai 与 openai-sdk 的移除不是"功能消失"而是"合并" :Azure 的 credential / microsoftDeploymentName / microsoftFoundryServiceVersion / isMicrosoftFoundry 能力被吸收进 AbstractOpenAiOptions(字段见 23.3.2 的 17 参数构造器),OpenAiSetup.detectModelProvider 会按 isMicrosoftFoundry / isGitHubModels / baseUrl 四级判定(23.3.4)。所以迁移时不要去找新类名,而要把旧属性映射到新前缀。

★ oci-genai 迁出意味着:AiProvider 里没有 OCI 的值(10 个值见 23.9),如果你原来用 OCI,2.x 之后你得自己写模块或者留在 1.x。

仓库现状可以直接验证:ls models/ 之后只有 15 个模块,没有 azure-openai、没有 openai-sdk、没有 oci-genai。

24.2.2 Redis chat memory 改名:全仓库唯一"平滑过渡"的案例

这一条值得单独看,因为它是唯一一个官方做了兼容层 的坐标变更(upgrade-notes.adoc:7-56):

xml 复制代码
<!-- Before -->
<artifactId>spring-ai-autoconfigure-model-chat-memory-redis</artifactId>
<!-- After -->
<artifactId>spring-ai-autoconfigure-model-chat-memory-repository-redis</artifactId>
properties 复制代码
# Before
spring.ai.chat.memory.redis.*
# After
spring.ai.chat.memory.repository.redis.*

文档明确写了两层兼容(:44-54):

  1. 旧 artifact 仍然存在 ,通过传递依赖指向新模块------所以你什么都不改也能跑。
  2. 旧 RedisChatMemoryProperties(spring.ai.chat.memory.redis)把每个属性委托给新的 RedisChatMemoryRepositoryProperties(spring.ai.chat.memory.repository.redis) ,用的是和 .options 废弃同一套委托机制。

★ 这条的启示是反过来的:只有这一处做了兼容,说明其它变更都不打算兼容 。不要指望别的地方也有"旧属性还能用"的兜底。而且文档同时给了期限:NOTE: Both the deprecated module and the legacy configuration prefix will be removed in a future release.

24.2.3 15 个模型模块的最终形态

2.0.1 的 models/ 只剩 15 个(ls models/):

arduino 复制代码
spring-ai-anthropic                  spring-ai-google-genai-embedding     spring-ai-ollama
spring-ai-bedrock                    spring-ai-google-genai-image         spring-ai-openai
spring-ai-bedrock-converse           spring-ai-mistral-ai                 spring-ai-postgresml
spring-ai-deepseek                   spring-ai-stability-ai               spring-ai-transformers
spring-ai-elevenlabs                 spring-ai-vertex-ai-embedding
spring-ai-google-genai

★ 对照第 23 章的"只有 7 个 ChatModel"(23.1.2):15 个模块里 8 个没有 chat 。所以"我引了 spring-ai-stability-ai,为什么注入不到 ChatModel"是一个高频问题------它只有 image。

★ 另一个高频问题来自 22.2:26 个 spring.ai.model.<capability> 开关默认全开 ,而 @ConditionalOnSingleCandidate 使用数为 0。所以"引了两个 chat starter"不会有任何警告,只会在注入时 NoUniqueBeanDefinitionException。迁移时如果旧项目同时引了 azure-openai 和 openai,合并到 openai 之后这个冲突反而消失了------这是移除 azure 模块的一个隐性收益。


24.3 配置层:Options 不可变与默认值搬家

这一节是整章最"不像迁移"的部分:它不涉及包名、不涉及类名,全是语义。但它导致的线上问题最多。

24.3.1 copy() / fromOptions() 移除 → mutate()

upgrade-notes.adoc:739-770:

  • options 类现在是严格不可变的;
  • 内部集合(toolCallbacks、stopSequences、customHeaders)存成不可修改集合 ,改会抛 UnsupportedOperationException;
  • ChatOptions#copy() 与 *Options#fromOptions(*Options) 被移除(因为不可变了,复制没有意义);
  • 替代品是 mutate()。
java 复制代码
// Before
OllamaChatOptions options = originalOptions.copy();
options.setFoo("...");

// After
OllamaChatOptions options = originalOptions.mutate()
		.foo("...")
		.build();

源码侧确认:ChatOptions.java:86 声明 ChatOptions.Builder<?> mutate();,DefaultChatOptions.java:99 实现它。★ 注意返回类型是 Builder<?> ,不是 ChatOptions------所以 mutate() 之后必须 .build(),少写一步编译不过(这是好事)。

★ 但真正的问题是"不可变"带来的连锁反应:1.x 里很常见的写法是

java 复制代码
// 1.x 常见写法(2.x 会抛 UnsupportedOperationException)
chatOptions.getToolCallbacks().add(myToolCallback);
chatOptions.getStopSequences().add("\n\n");

在 2.x 里,getToolCallbacks() 返回的是 List.copyOf(...) 之类的不可修改视图,add 直接抛 UnsupportedOperationException。这个异常发生在运行期,且只在"你确实想改"的那条分支上 。迁移时全仓库搜 .getToolCallbacks().add、.getStopSequences().add、.getCustomHeaders().put 是必要动作。

24.3.2 ★ 集合从"空"变 null:最阴的一条

文档原话(:745-747):nullable collections are now used instead of empty ones to better represent the absence of a value。

源码证据(spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/ChatOptions.java:59):

java 复制代码
@Nullable List<String> getStopSequences();

DefaultChatOptions.java:79 同样是 public @Nullable List<String> getStopSequences()。

★ 这条的破坏力在于:它把"空集合"和"未设置"区分开了,而你的老代码假设两者一样。

java 复制代码
// 1.x:安全
if (options.getStopSequences().isEmpty()) { ... }

// 2.x:NPE(因为可能是 null)
if (options.getStopSequences().isEmpty()) { ... }

// 正确写法
List<String> stop = options.getStopSequences();
if (stop == null || stop.isEmpty()) { ... }
// 或者
if (CollectionUtils.isEmpty(options.getStopSequences())) { ... }

★★ 更隐蔽的是框架内部 也有依赖这个语义的地方。比如 22.4 讲的"两级合并"(resolveCommonProperties):父级没设置、子级也没设置时,合并结果就是 null 而不是空集合,于是"下游拿到 null 就跳过设置"成了正确行为;反过来,如果你在自定义 Model 里写 for (String s : options.getStopSequences()),在 2.x 里就是 NPE。

★ 为什么 Spring AI 要这么改?因为"空集合"和"没设置"在 HTTP 层是两种语义:"stop": [] 和"不发 stop 字段"。1.x 用空集合表达"没设置",导致请求里多发了一个空数组,某些供应商会因此报 400。2.x 用 null 表达"没设置",请求里就不发这个字段了。这是一个正确的修复,但它破坏了所有假设"getter 不返回 null"的代码。

24.3.3 默认值搬进 Options 构造器

upgrade-notes.adoc:771-800:

  • 默认值(默认模型名、temperature 等)从 Model 实现与 *Properties 类搬到 options 构造器;
  • *Properties 里的默认配置属性被移除(不再需要,要与 options 层默认值一致);
  • ChatModel#getDefaultOptions() 被废弃,改用 ChatModel#getOptions()。
java 复制代码
// Before
ChatOptions options = chatModel.getDefaultOptions();
// After
ChatOptions options = chatModel.getOptions();

★ 这条和第 22 章的 22.12(265 个 @DeprecatedConfigurationProperty)是同一件事的两面:属性类里的默认值没了,默认值只有一个真源------options 构造器。

★ 实践含义:你不能再靠"属性类里写了默认值"来判断实际默认值 。想知道 AnthropicChatOptions 的默认 maxTokens 是多少,必须去看它的 builder 字段初始化,而不是去 AnthropicChatProperties 里找。这也解释了为什么 2.0 的 *Properties 类里大量字段是 @Nullable------它们只表达"用户设没设",不表达"默认是多少"。

24.3.4 .options 前缀扁平化与 265 个废弃属性

upgrade-notes.adoc:802-840:所有非 chat 模型(Embedding / Image / Audio / Moderation / OCR)的属性不再用 .options 段。

properties 复制代码
# Before
spring.ai.openai.embedding.options.model=text-embedding-3-small
# After
spring.ai.openai.embedding.model=text-embedding-3-small

影响(文档原话):

  • *Properties 里的 getOptions() 废弃;
  • *Properties 里嵌套的 Options 类废弃;
  • toOptions() 从嵌套 Options 类移到根 *Properties 类。
java 复制代码
// Before
String model = properties.getOptions().getModel();
OpenAiEmbeddingOptions options = properties.getOptions().toOptions();
// After
String model = properties.getModel();
OpenAiEmbeddingOptions options = properties.toOptions();

★ 注意 chat 模型不在此列 ------chat 的 .options 在 1.x 就已经扁平化过一轮,2.0 处理的是"其它模型"。所以你会看到一种奇怪的不一致:

能力 1.x 前缀 2.0 前缀
chat spring.ai.openai.chat.options.model spring.ai.openai.chat.model(1.x 后期已扁平)
embedding spring.ai.openai.embedding.options.model spring.ai.openai.embedding.model(2.0 才扁平)

★ 加上 22.12 统计的 265 个 @DeprecatedConfigurationProperty(分布在 24 个文件) ,这意味着:你的 application.yml 里每一条以 .options. 开头的非 chat 属性,在 2.x 里都会走废弃委托。它还能用,但每启动一次就多一行 warn;等到未来版本移除,就是启动失败。

24.3.5 N() → n()

upgrade-notes.adoc:842-858:*Options 与配置属性类里的 builder 方法 N() 改名为 n()。

java 复制代码
// Before
OpenAiChatOptions.builder().N(1).build();
// After
OpenAiChatOptions.builder().n(1).build();

★ 这是全章唯一"纯编译期、零风险"的变更------但它揭示了一个风格问题:Spring AI 2.0 在做一轮Java 命名规范清理 (N 是 OpenAI API 的字段名,直接搬进 Java 方法名不符合 camelCase)。同类清理还包括 isMicrosoftFoundry/isGitHubModels 这类布尔命名(23.3.2)。


24.4 调用层:ChatClient / Advisor / ChatMemory

24.4.1 .options() 只收 Builder:ChatClient 与 ChatModel 的不对称

upgrade-notes.adoc:1437-1469:

.options() / .defaultOptions() 现在接受 ChatOptions.Builder(或供应商专属的 builder 子类型),而不是已 build 的 ChatOptions 实例。这个约束由泛型 <B extends ChatOptions.Builder<?>> 在编译期强制。

java 复制代码
// Before(不再编译)
ChatOptions opts = AnthropicChatOptions.builder().maxTokens(100).temperature(0.7).build();
chatClient.prompt("Tell me a joke").options(opts).call().content();

// After
chatClient.prompt("Tell me a joke")
	.options(AnthropicChatOptions.builder().maxTokens(100).temperature(0.7))
	.call().content();

★ 关键的一句在文档的 NOTE 里(:1459-1464):

这个限制只适用于 ChatClient 。直接调用 ChatModel.call(Prompt) 时,prompt 必须携带已 build 的、具体类型的 ChatOptions 实例。ChatModel 不做合并 :Prompt.getOptions() 非空就原样使用,为 null 就用模型自己的默认 options,没有任何部分合并。

这造就了一个非常容易踩的不对称:

入口 接受什么 是否与默认值合并 合并时机
ChatClient.prompt().options(Builder) Builder 是(只覆盖你显式设置的字段) 在第一个 advisor 之前
ChatModel.call(new Prompt(text, options)) 已 build 的实例 否(非空即原样使用) ---

★ 实践后果:同一个 ChatOptions 对象,从 ChatClient 走和从 ChatModel 走,结果可能不同 。从 ChatClient 走时,你没设置的字段会用模型默认值补齐;从 ChatModel 走时,你没设置的字段就是 null,直接发给供应商(供应商再用它自己的默认值)。所以"我明明在 ChatClient 里测通了,直接调 ChatModel 就 400"是完全可以发生的。

★ 另外注意 buildRequestPrompt 被从 public API 移除 (:1466-1469,文档说"它之前是被误暴露的")。如果你在 1.x 里继承过 AbstractChatModel 并调用它,2.x 编译不过------这是"框架收回了一个不该给你的钩子"。

24.4.2 conversationId 变成必需

upgrade-notes.adoc:1324-1410。三件事一起发生:

  1. MessageChatMemoryAdvisor / VectorStoreChatMemoryAdvisor 的 conversationId 不再可选 ;缺失或 null → 立刻 IllegalArgumentException。
  2. ChatMemory.DEFAULT_CONVERSATION_ID(值 "default")被移除。
  3. builder 上的 .conversationId(String) 被移除。
java 复制代码
// Before
ChatClient chatClient = ChatClient.builder(chatModel)
	.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).conversationId("my-session").build())
	.build();
chatClient.prompt().user("Hello!").call().content();

// After
ChatClient chatClient = ChatClient.builder(chatModel)
	.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
	.build();
chatClient.prompt().user("Hello!")
	.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "my-session"))
	.call().content();

★ 为什么这是好事:1.x 的 DEFAULT_CONVERSATION_ID = "default" 意味着所有没传 conversationId 的请求共享同一个会话 。在多用户系统里,这是一个跨用户串话的严重缺陷(用户 A 的历史会被用户 B 看到)。2.x 把它变成硬失败,是在逼你面对这个问题。

★ 但迁移时要小心两处:

  1. getConversationId(Map, String) 变成了 getConversationId(Map) (:1400-1410)。自定义 advisor 里覆写/调用两参版本的,编译不过;改成一参之后,context 里没有 CONVERSATION_ID 就会抛异常------所以"先确保 context 有值,再调用"。
  2. ★ VectorStoreChatMemoryAdvisor 也吃这条规则。它常被用在 RAG 的"长期记忆"里,而 RAG 场景经常是"无会话"的后台任务------这些任务现在必须显式传一个 conversationId(比如任务 ID),不能再靠默认值。

24.4.3 PromptChatMemoryAdvisor 移除

upgrade-notes.adoc:1411-1436:PromptChatMemoryAdvisor 被移除 ,用 MessageChatMemoryAdvisor 替代,builder API 相同。

★ 但两者语义不同,这是迁移时最容易忽略的一句:

  • PromptChatMemoryAdvisor:把历史拼成纯文本注入 system prompt;
  • MessageChatMemoryAdvisor:把历史作为真正的 chat messages 放进 prompt。

后果:

维度 Prompt 版(旧) Message 版(新)
历史在 prompt 里的形态 一段字符串(在 system 里) 多条 UserMessage/AssistantMessage
模型对"谁说的"的区分 弱(都在 system 里) 强(role 明确)
token 计数 通常更省(无 role 开销) 略多(每条消息有 role 包装)
与 tool 消息的兼容 差(tool 消息无法用纯文本表达) 好(ToolResponseMessage 原样保留)
提示注入风险 更高(历史文本被当成指令) 更低(历史是消息,不是指令)

★ 所以"builder API 相同"不等于"行为相同"。如果你的 system prompt 里有"以下是历史对话:......"这类手写拼接,迁移后要删掉,否则历史会出现两次(一次在 system 的文本里,一次在消息列表里)。

24.4.4 ★ BaseAdvisor 把流式异常包成 IllegalStateException

这是本章最值得单独拎出来的一条,因为它在文档里没有,只有源码能证明。

java 复制代码
// spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/BaseAdvisor.java:57-74
@Override
public Flux<ChatClientResponse> adviseStream(ChatClientRequest chatClientRequest,
		StreamAdvisorChain streamAdvisorChain) {
	...
	Flux<ChatClientResponse> chatClientResponseFlux = Mono.just(chatClientRequest)
		.publishOn(getScheduler())
		.map(request -> this.before(request, streamAdvisorChain))
		.flatMapMany(streamAdvisorChain::nextStream);

	return chatClientResponseFlux.map(response -> {
		if (AdvisorUtils.onFinishReason().test(response)) {
			response = after(response, streamAdvisorChain);
		}
		return response;
	}).onErrorResume(error -> Flux.error(new IllegalStateException("Stream processing failed", error)));
}

★ onErrorResume 把任何 error 包成 IllegalStateException("Stream processing failed", error)。所以:

java 复制代码
// 你以为能抓到
try {
	chatClient.prompt().user(q).stream().content().blockLast();
}
catch (WebClientResponseException.TooManyRequests e) {   // ← 永远不进
	...
}

// 实际必须这样
catch (IllegalStateException e) {
	Throwable cause = e.getCause();
	if (cause instanceof WebClientResponseException.TooManyRequests tooMany) { ... }
}

★ 这条与 23.10.4 #28(RetryUtils.execute 拆包 RetryException)方向相反 :RetryUtils 是"把包装异常拆掉",BaseAdvisor 是"给原始异常套一层"。两件事叠加之后,你在最外层看到的异常类型与真实原因之间隔了两层变换 。生产环境的错误分类必须按 getCause() 递归找根因,不能按最外层类型。

★ 还有一个副作用:onErrorResume 只包 nextStream 这一段(map 之后),before() 里抛的异常也会被包 (因为 map 在链上)。所以自定义 advisor 的 before() 里做参数校验,抛出的 IllegalArgumentException 到了最外层也变成 IllegalStateException。日志里看到"Stream processing failed"时,不要以为是网络问题,先看 cause。

24.4.5 每个 Advisor 都在 boundedElastic 上跑

java 复制代码
// BaseAdvisor.java:44
Scheduler DEFAULT_SCHEDULER = Schedulers.boundedElastic();
java 复制代码
// ChatModelStreamAdvisor.java:58
.publishOn(Schedulers.boundedElastic()); // TODO add option to disable
java 复制代码
// MessageChatMemoryAdvisor.java:158
.publishOn(scheduler)
java 复制代码
// ToolCallingAdvisor.java:403
return toolCallFlux.subscribeOn(Schedulers.boundedElastic());

★ 四处在不同层、同一个调度器。含义是:一次流式调用会多次在 boundedElastic 的 worker 线程之间跳转 (每个 advisor 的 publishOn 都是一次线程切换)。boundedElastic 的默认容量是 10 × CPU 核数(Reactor 的 DEFAULT_BOUNDED_ELASTIC_SIZE),队列 100k。

★ 这不是"配置问题",是架构事实 :Spring AI 的 advisor 链是"同步 before/after + 响应式 nextStream"的混合体,publishOn 是用来把同步的 before() 挪到弹性线程池上,避免阻塞调用方线程。代价是:

  1. 线程本地变量(ThreadLocal)在 advisor 之间会丢 ------publishOn 之后是另一个线程。所以 MDC、SecurityContext、RequestContextHolder 在 advisor 的 after() 里可能读不到。这是 21 章"观测上下文靠 Reactor Context 传,不靠 ThreadLocal"的另一面。
  2. 高并发流式会打满 boundedElastic 。每个流在 subscribeOn/publishOn 期间都占一个 worker(尤其 Anthropic 的 subscribeOn(boundedElastic) 在整个流期间不放,见 23.5.2 缺陷二)。
  3. ChatModelStreamAdvisor:58 的 // TODO add option to disable 说明官方也知道这是个问题,但 2.0.1 还没有开关。

★ 生产建议:如果你的流式 QPS 很高,把 reactor.schedulers.defaultBoundedElasticSize 调大不如 把 boundedElastic 换成专用调度器 ------OpenAiAudioSpeechModel 已经这么做了(DEFAULT_STREAM_SCHEDULER = Schedulers.newBoundedElastic(..., "openai-audio-speech"),见 24.10.3),你可以照抄这个模式给自己包一层。


24.5 工具调用:四个会改变行为的开关

工具调用是 2.0.x 行为变更最密集的区域。upgrade-notes.adoc 里 2.0.0 的 Tool Calling 一节从 :389 一直到 :701,共 300 多行;2.0.1 又追加了四条(:198-330)。这一节只挑会改变运行行为、且不报错的部分。

24.5.1 ★ 上限:40 次/工具、150 次/轮

upgrade-notes.adoc:200-208:

DefaultToolCallingManager 现在限制每轮的工具调用次数:DEFAULT_MAX_CALLS_PER_TOOL(40)针对单个工具,DEFAULT_MAX_TOTAL_TOOL_CALLS(150)针对整体。之前完全没有限制。

源码确认(spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java):

java 复制代码
// :100
public static final int DEFAULT_MAX_CALLS_PER_TOOL = 40;
// :107
public static final int DEFAULT_MAX_TOTAL_TOOL_CALLS = 150;

配置入口在 ToolCallingProperties(auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/.../ToolCallingProperties.java):

java 复制代码
// :40
public static final String CONFIG_PREFIX = "spring.ai.tools";
// :98-101
private @Nullable Integer maxCallsPerToolDefault = DefaultToolCallingManager.DEFAULT_MAX_CALLS_PER_TOOL;
// :118-121
private @Nullable Integer maxTotalToolCalls = DefaultToolCallingManager.DEFAULT_MAX_TOTAL_TOOL_CALLS;

★ 三个必须知道的点:

  1. 默认值很宽松,但不是无限 。150 次总调用在"批量处理 + 每行一次工具"的场景里是够呛的。迁移时如果你的 agent 会循环处理 N 条数据,N > 150 就会在第 151 次被拒。
  2. -1 表示无限 (ToolCallingProperties:98-99 的 Javadoc:"Set to -1 ...")。编程式入口是 ToolCallingManager.builder().maxCallsPerTool(...) / .unlimitedCallsPerTool()。
  3. ★ 超限不会抛异常,而是返回一条错误结果给模型 ------模型看到的是"工具调用被拒绝",于是它可能换个方式重试,形成"被拒 → 再试 → 再被拒"的循环,直到 150 次总上限。日志里不会有一条"你超限了"的 ERROR,只有模型的输出变得很奇怪。

24.5.2 ★ 工具解析 fallback 默认关闭

upgrade-notes.adoc:305-330:

之前,当模型返回一个没有挂到本次请求 上的工具调用时,DefaultToolCallingManager 会回退到应用级 ToolCallbackResolver(背后是容器里所有 ToolCallback bean)去解析并执行。从 2.0.1 开始,只有挂到本次请求上的工具 (通过 .tools(...)、.defaultTools(...) 或 ToolCallingChatOptions.toolCallbacks(...))才能被执行。

源码确认(DefaultToolCallingManager.java:289):

java 复制代码
.orElseGet(() -> this.resolutionFallbackEnabled ? this.toolCallbackResolver.resolve(toolName) : null);
java 复制代码
// :382
private boolean resolutionFallbackEnabled = DEFAULT_RESOLUTION_FALLBACK_ENABLED;

配置:

properties 复制代码
spring.ai.tools.resolution.fallback.enabled=true
java 复制代码
ToolCallingManager toolCallingManager = ToolCallingManager.builder()
	.toolCallbackResolver(toolCallbackResolver)
	.resolutionFallbackEnabled(true) // default is false
	.build();

★ 这条为什么危险?因为它只在"模型点名了一个你没挂载的工具"时生效 。而这种情况在 1.x 里恰恰是被容忍的:

  • 1.x:所有 @Tool bean 都在容器里,模型随便点一个都能执行 → "工具没挂载也能跑"成了隐性契约。
  • 2.0.1:模型点了一个没挂载的 → resolver 返回 null → 工具执行失败 → 错误结果回给模型 → 模型再试另一个。

★ 更隐蔽的是安全含义变了 :1.x 的 fallback 意味着"模型可以调用应用里任意一个 ToolCallback bean",包括你没打算在这次对话里暴露的(比如 deleteUser)。2.0.1 默认关闭 fallback 其实是一个安全修复 ------工具的白名单从"容器里有什么"收窄到"这次请求挂了什么"。所以迁移时不要无脑打开 fallback,先确认你的工具里没有"不该被模型自由调用"的。

★ 迁移检查:搜 .defaultTools(、.tools(,确认每个 ChatClient 都显式挂载了它可能用到的全部工具;如果依赖了 fallback,要么打开配置,要么补挂载。

24.5.3 ★ ToolCallingAdvisor 的 usage 变成跨轮累加

upgrade-notes.adoc:210-216:

ToolCallingAdvisor 现在把工具调用循环里每一次内部模型调用 的 token usage 累加,而不是只暴露最后一次调用的 Usage。断言精确 token 数的代码/测试会看到变大的值。无需 API 变更。

★ 这条在文档里被写成"no API changes are required",语气很轻,但它是计费与限流的分水岭:

场景 1.x 看到的 usage 2.0.1 看到的 usage
一次问答、无工具 1 次调用的 token 相同
一次问答、模型调 3 次工具 最后一次调用的 token(很小) 4 次调用之和(可能是 5--10 倍)
按 token 做配额 严重低估 正确
按 token 做成本看板 严重低估 正确

★ 所以迁移后如果发现"成本突然涨了 5 倍",先别怀疑供应商涨价------很可能是 1.x 一直少算了,2.0.1 把它算对了。反过来,如果你有基于 Usage.getTotalTokens() 的硬阈值告警,迁移后会被真实值触发,这是"告警终于对了"。

★ 与第 21 章的一个呼应:UsageAccumulator(177 行)在 1.x 里"全类零 Observation 引用",跨轮总 token 只存在于返回值。2.0.1 把 ToolCallingAdvisor 的累加补上了,但**"总 token 不进 span/指标"这个问题并没有变**(21 章结论仍然成立)------所以你在 Micrometer 里看到的仍然是单次调用的量,只有在 ChatClientResponse 的返回值里才能拿到累加值。

24.5.4 throwExceptionOnError:默认 false 的含义

ToolCallingProperties(:63-70):

java 复制代码
/**
 * If true, tool calling errors are thrown as exceptions for the caller to handle. If
 * false, errors are converted to messages and sent back to the AI model, allowing it
 * to process and respond to the error.
 */
private boolean throwExceptionOnError = false;

★ 默认 false 意味着:工具抛异常不会让你的调用失败,而是变成一条 tool 结果回给模型,模型"自己想办法"。这在生产里有两个后果:

  1. 监控看不到工具失败率 。你的 try/catch 不会触发,指标里也没有 error,只有模型输出变得"奇怪"(它开始道歉、换个工具、或者编一个答案)。
  2. 模型可能把错误当成数据 。比如 getOrderStatus 抛了 TimeoutException,错误文本回给模型,模型对用户说"您的订单状态是 TimeoutException"。

★ 迁移建议:生产环境把它设成 true ,然后在调用方统一处理;或者保留 false 但在工具实现内部自己打点(Micrometer counter + 结构化日志),不要指望框架替你报错。

24.5.5 ★ @McpTool 异常分派对齐 @Tool:四类异常四种命运

upgrade-notes.adoc:130-197。这是 2.0.1 里最"细"的一条变更,因为它把"异常类型"直接映射成了"是否硬失败":

异常类型 行为
RuntimeException(非 McpError) 转成错误 CallToolResult,回给模型
声明的受检异常(checked) 冒泡成 UndeclaredThrowableException ------硬失败,永远不到模型
Error 子类型 原样冒泡------硬失败
McpError 原样冒泡------硬失败(协议级信号,比如 URL elicitation)

★ 这条的迁移风险在于"故意的错误信号被降级/升级":

java 复制代码
// Before:受检异常被静默转成错误结果,模型能看到 "Order not found"
@McpTool(description = "Look up an order")
public String lookupOrder(String orderId) throws NotFoundException {
	throw new NotFoundException("Order not found: " + orderId);
}

// After:受检异常变成 UndeclaredThrowableException(硬失败,模型看不到)
@McpTool(description = "Look up an order")
public String lookupOrder(String orderId) {
	throw new RuntimeException("Order not found: " + orderId); // 才会回给模型
}

★ 另外 toolCallExceptionClass 构造器参数被废弃且在运行期被忽略 (upgrade-notes.adoc:170-197):

  • SyncMcpToolMethodCallback(ReturnMode, Method, Object, Class<? extends Throwable>)
  • AsyncMcpToolMethodCallback(...)
  • SyncStatelessMcpToolMethodCallback(...)
  • AsyncStatelessMcpToolMethodCallback(...)
  • AbstractMcpToolProvider.doGetToolCallException()

文档明确说"exception dispatch is now type-based, not class-based",并给了 2.1.0 移除的时间点。★ 这意味着:你以前用 toolCallExceptionClass 指定的"哪些异常算业务错误"现在完全失效 ,框架只认异常类型。迁移时必须把"想让模型看到的错误"统一包成 RuntimeException。

24.5.6 工具调用迁移检查清单

less 复制代码
□ 搜 .tools( / .defaultTools( ------ 每个 ChatClient 是否显式挂载了全部所需工具
□ 搜 spring.ai.tools.resolution.fallback ------ 是否依赖了 fallback
□ 估算单轮工具调用次数 ------ 是否可能超过 40(单工具)/ 150(总)
□ 搜 throwExceptionOnError ------ 生产是否要改成 true
□ 搜 @McpTool 方法签名 throws ------ 受检异常是否还想回给模型(要改成 RuntimeException)
□ 搜 toolCallExceptionClass ------ 构造器参数已失效,删掉
□ 检查 usage 断言/告警阈值 ------ 工具场景下 token 会变成累加值

24.6 SDK 迁移:"透明"的边界在哪

2.0 把 OpenAI 与 Anthropic 都换成了官方 SDK。文档反复用"seamless"、"no major breaking changes"、"transparent"这些词(upgrade-notes.adoc:1470-1492、:1616-1625)。这一节要回答的是:哪些地方其实不透明。

24.6.1 OpenAI:属性全保留,但 extraBody 换了名字

文档原话(:1474-1478):

Spring AI 现在在所有 OpenAI 模型(Chat、Embeddings、Image、Audio Speech、Audio Transcription、Moderation)下使用官方 openai-java SDK。迁移应当无缝,不期待重大破坏性变更 。所有属性(spring.ai.openai.* 前缀)、builder、options 完全保留。现有的 extraBody 配置参数透明地映射 到底层 openai-java SDK 的 additionalBodyProperties。

★ "透明映射"是这一节唯一需要记住的技术点:

Spring AI 侧 SDK 侧
extraBody(OpenAiChatOptions / 属性) additionalBodyProperties

★ 但"透明"有三个边界,文档没说,源码能看出来:

  1. OpenAiApi 不存在了 (23.3.1)。所有 new OpenAiApi(...) 的代码必须改成 OpenAiSetup.setupSyncClient(...)(17 参数)或走自动配置。这是"编译不过"的硬边界。
  2. OpenAiSetup 的 no-auth 模式 (23.3.3):apiKey 为空时不再报错,而是用占位 key + 摘掉 Authorization 头。1.x 里"忘了配 key"会启动失败,2.x 里会一路跑到 401。
  3. calculateBaseUrl 的 GITHUB_MODELS 分支会丢弃你的 baseUrl (23.3.4)。如果你的 1.x 配置里同时有 spring.ai.openai.base-url 和 GitHub Models 的 key,迁移后 baseUrl 会失效。

★ 还有一条 2.0.1 的行为变更容易被忽略(upgrade-notes.adoc:244-276):OpenAiAudioSpeechModel.stream(TextToSpeechPrompt) 以前把整个响应缓冲成一个 Flux 元素 ,现在会真的请求 stream_format=audio 并逐块发出。所以:

java 复制代码
// 1.x:拿到的是完整音频
byte[] audio = speechModel.stream(prompt).blockFirst().getResult().getOutput();

// 2.0.1:blockFirst() 只拿到第一块
// 正确做法:把所有块拼起来
byte[] audio = speechModel.stream(prompt)
	.map(r -> r.getResult().getOutput())
	.reduce(new ByteArrayOutputStream(), (out, chunk) -> { out.writeBytes(chunk); return out; })
	.block()
	.toByteArray();

★ 这条属于"API 没变、语义变了"的典型:stream() 的签名一样,但元素的数量与含义变了 。任何用 blockFirst()/take(1) 消费音频流的代码都会拿到一段截断的音频------而且不会报错,只是听起来"只有前 0.2 秒"。

24.6.2 Anthropic:SDK 迁移与 dispatcherExecutor

文档(:1616-1625):spring-ai-anthropic 现在基于 com.anthropic:anthropic-java,不再是手写的 RestClient/WebClient。Maven artifact、spring.ai.anthropic.* 配置、AnthropicChatModel/AnthropicChatOptions/ChatClient API 都保留,走 ChatClient 或 ChatModel.call(Prompt) 的代码不需要改。

★ 但第 23 章 23.5 讲的两个流式缺陷没有因为换 SDK 而消失,反而更明确了:

  1. HTTP span 挂不上 gen_ai.client.operation(AnthropicChatModel:266-285 的 Javadoc 自己写着);
  2. withRawResponse() 拿到阻塞 StreamResponse,只能 subscribeOn(Schedulers.boundedElastic())(:336-346),每个流占一个 worker 线程整个流期间。

★ 2.0 新增的一个"生产级"入口是 AnthropicChatModel.Builder.dispatcherExecutor(ExecutorService)(:1897-1905,@since 2.0.0):

java 复制代码
/**
 * Sets the executor used by the underlying OkHttp dispatcher for both the sync
 * and async clients. The caller owns the executor's lifecycle --- Spring AI will
 * not shut it down. Typical use: pass
 * Executors.newVirtualThreadPerTaskExecutor() on Java 21+ to back HTTP
 * dispatch with virtual threads. When omitted, an internal platform-thread
 * executor is created and managed by the HTTP client.
 */
public Builder dispatcherExecutor(@Nullable ExecutorService dispatcherExecutor)

★★ 这是本章唯一一处官方明确建议用虚拟线程的地方。注意三个细节:

  1. executor 的生命周期归你 ------Spring AI 不会 shutdown 它。所以你要么用 Spring 管理的 ExecutorService bean,要么在 @PreDestroy 里自己关。
  2. 它影响的是 OkHttp dispatcher (同步 + 异步两个客户端),不是 Reactor 的调度器。所以它不能 解决 23.5.2 缺陷二(boundedElastic 被占),只能让"HTTP 分发"这一层不再吃平台线程。
  3. null 会恢复默认(内部平台线程 executor)。

★ 实践组合:dispatcherExecutor(newVirtualThreadPerTaskExecutor()) + 提高 reactor.schedulers.defaultBoundedElasticSize,是两个不同层面的缓解;要彻底解决"一个流占一个 worker",只能改源码或用 OpenAI/Google (它们不需要 withRawResponse)。

24.6.3 媒体:data(Object) 被类型化重载取代

upgrade-notes.adoc:277-304:Media.Builder.data(Object) 被替换成 data(byte[])、data(String)、data(URI)、data(URL)、data(Resource)。

java 复制代码
// Before
Object data = getMediaData();
Media.builder().mimeType(mimeType).data(data).build();

// After
Object data = getMediaData();
if (data instanceof byte[] bytes) {
	Media.builder().mimeType(mimeType).data(bytes).build();
}
else if (data instanceof String s) {
	Media.builder().mimeType(mimeType).data(s).build();
}

★ 文档说"静态类型是 Object 的调用不再编译------没有匹配的重载"。这条本身是好的(消除歧义),但迁移时要注意一个隐性行为 :data(String) 到底把字符串当"文本内容"还是"URL/路径"?在 1.x 的 data(Object) 里,String 会被当成资源位置 去解析;类型化之后 data(String) 与 data(URI)/data(Resource) 并存,语义更清晰但你必须显式选 。如果原来的字符串是 base64 或内联文本,现在要走 data(String) 的文本语义------读一下 Media 的 Javadoc 再改,别靠猜。

24.6.4 转录:TranscriptionModel 继承 StreamingTranscriptionModel

upgrade-notes.adoc:243-276:

  • TranscriptionModel 现在 extends StreamingTranscriptionModel;
  • StreamingTranscriptionModel 提供默认 stream(...),返回 Flux.error(new UnsupportedOperationException(...));
  • 所以老的自定义实现能编译 ,但 stream() 会发出一个 UnsupportedOperationException。
java 复制代码
// 自定义实现如果要支持流式
@Override
public Flux<AudioTranscriptionResponse> stream(AudioTranscriptionPrompt prompt) {
	return Flux.just(call(prompt));   // 或者真正的流式实现
}

★ 这是典型的"默认实现让编译通过、但把失败推迟到调用时"。迁移检查:grep -rn "implements TranscriptionModel",对每个实现决定"要不要覆写 stream"。

★ 同样的模式在 23 章的 ChatModel extends StreamingChatModel 也出现过(23.10.1 #6):接口继承让"能流式"变成类型层面的既成事实,但默认实现是抛 UnsupportedOperationException。Spring AI 2.0 在这个方向上是一致的:能力上移到接口,默认实现报"不支持"。


24.7 JSON 层迁移:JsonHelper 接管一切

upgrade-notes.adoc:874-975。这一节是纯机械迁移,但量很大,而且有一个"删了不报错"的坑。

24.7.1 新 API:JsonHelper + JacksonUtils.getDefaultJsonMapper()

  • JsonHelper 在 spring-ai-commons,可实例化 ,接受自定义 JsonMapper;
  • JacksonUtils.getDefaultJsonMapper() 返回一个共享的、预配置的 JsonMapper;
  • JsonHelper 把原先散在 JsonParser、ModelOptionsUtils、McpJsonParser 里的 JSON 操作集中了。
java 复制代码
// 默认
JsonHelper jsonHelper = new JsonHelper();
// 自定义 mapper
JsonMapper myMapper = JsonMapper.builder().addModule(new JavaTimeModule()).build();
JsonHelper customHelper = new JsonHelper(myMapper);

★ "可实例化"是这一节的设计要点:1.x 的 JsonParser 是全静态 的,你没法换 mapper;2.x 把 mapper 变成构造参数,于是"每个用例用自己的 JSON 配置"成为可能。这也解释了为什么第 23 章里各模型都要自己 JacksonUtils.getDefaultJsonMapper().rebuild()...(比如 GoogleGenAiChatModel 加了 SchemaMixin,23.8.4)。

24.7.2 ModelOptionsUtils 的 JSON 方法被移除(编译不过,但是好事)

被移除的成员 替代
ModelOptionsUtils.JSON_MAPPER JacksonUtils.getDefaultJsonMapper()
ModelOptionsUtils.jsonToMap(String) jsonHelper.fromJsonToMap(json)
ModelOptionsUtils.jsonToObject(String, Class<T>) jsonHelper.fromJson(json, type)
ModelOptionsUtils.toJsonString(Object) jsonHelper.toJson(object)
ModelOptionsUtils.toJsonStringPrettyPrinter(Object) JacksonUtils.getDefaultJsonMapper().writerWithDefaultPrettyPrinter().writeValueAsString(object)
ModelOptionsUtils.getJsonSchema(Type, boolean) JsonSchemaUtils.getJsonSchema(type, toUpperCase)
ModelOptionsUtils.getJsonSchema(Type) JsonSchemaUtils.getJsonSchema(type)
ModelOptionsUtils.toUpperCaseTypeValues(ObjectNode) JsonSchemaUtils.toUpperCaseTypeValues(node)

★ 注意 getJsonSchema 被搬到了 JsonSchemaUtils------这正是 24.8 要讲的"BeanOutputConverter 改委托 JsonSchemaGenerator"的同一场重构。2.0 把"JSON 序列化"(JsonHelper)与"JSON Schema 生成"(JsonSchemaUtils / JsonSchemaGenerator)彻底分家了。

★ 迁移建议:不要写兼容层 。这些方法全是静态工具,直接全局替换最快;写一个 @Deprecated 门面只会让你在新代码里继续用旧习惯。

24.7.3 JsonParser 废弃、McpJsonParser 删除

  • JsonParser(org.springframework.ai.util.json)废弃待移除 ,所有方法委托给 JsonHelper。所以它还能用,只是编译告警。映射表:
Before After
JsonParser.getJsonMapper() JacksonUtils.getDefaultJsonMapper()
JsonParser.fromJson(json, MyType.class) jsonHelper.fromJson(json, MyType.class)
JsonParser.fromJson(json, type) jsonHelper.fromJson(json, type)
JsonParser.toJson(object) jsonHelper.toJson(object)
JsonParser.toTypedObject(value, type) jsonHelper.convertToTypedObject(value, type)
  • McpJsonParser(org.springframework.ai.mcp.annotation.method.tool.utils)直接删除。映射表:
Before After
McpJsonParser.toMap(object) jsonHelper.convertToMap(object)
McpJsonParser.fromMap(map, MyType.class) jsonHelper.convertFromMap(map, MyType.class)
McpJsonParser.fromMap(map, typeReference) jsonHelper.convertFromMap(map, parameterizedTypeReference)

★ 注意最后一行的变化:TypeReference → ParameterizedTypeReference 。这不是随手改的------它和同一版里"MCP Elicitation API: TypeReference Replaced by ParameterizedTypeReference"(:976-1026)是同一场清理:Spring 的 ParameterizedTypeReference 能保留泛型信息,而 Jackson 的 TypeReference 在 AOT/原生镜像下更难处理。

★★ 这里有一个"删了不报错"的坑:McpJsonParser 是内部工具类 ,如果你在自定义 MCP 工具里反射调用它(比如按类名 Class.forName("org.springframework.ai.mcp.annotation.method.tool.utils.McpJsonParser")),编译期完全发现不了,运行期 ClassNotFoundException。迁移时搜一下字符串常量里的类名。


24.8 结构化输出迁移:BeanOutputConverter 的四处语义变化

upgrade-notes.adoc:1205-1250。这一节只有 45 行,但它改的是**"什么算必填"**------直接影响模型输出能不能反序列化。

24.8.1 从"自己生成"到"委托 JsonSchemaGenerator"

BeanOutputConverter 现在把 JSON Schema 生成委托给 JsonSchemaGenerator,与工具调用使用的 JSON Schema 行为对齐。

★ "对齐"这个词是关键:1.x 里结构化输出 与工具调用 是两套 schema 生成逻辑(第 20 章讲过"schema 生成有四套独立配置");2.0 把 BeanOutputConverter 并到了工具调用那一套。于是工具调用的所有 schema 规则自动作用到结构化输出上------包括下面三条。

24.8.2 required 规则变了三处

场景 1.x 2.0
Kotlin 主构造器里的可选参数(nullable 或有默认值) 进 required 不进 required
@JsonProperty(required = false) 进 required(被忽略) 不进
@JsonProperty 没写 required 进 required 不进 (因为 required 默认是 false)
普通字段(无注解) 进 required 进 required

★ 第三行是最容易踩的:很多人写 @JsonProperty("user_name") 只是为了改名,从没想过它同时改变了"必填"语义。 在 2.0 里,这个注解一加,字段就不再是必填了。

java 复制代码
public record UserProfile(
	@JsonProperty("user_name") String userName,   // 2.0:不再必填!
	String email                                   // 2.0:仍然必填
) {}

★ 后果链条:required 变少 → 模型认为可以省略 → 返回的 JSON 缺字段 → Jackson 反序列化把 record 的缺失字段填 null(record 的规范构造器不会报错)→ 你的业务代码拿到 null → NPE 或者静默的错误数据。整个过程没有一条异常。

★ 这也解释了 2.0.1 为什么要同时把 OpenAI 的 strict 默认改成 false(24.5 / :218-242):JsonSchemaGenerator 生成的 schema 会把"可选参数"从 required 里省略,而 OpenAI 的 strict 模式要求"所有 property 都在 required 里,可空的用 nullable 类型表达"。两者冲突,所以旧默认(strict=true)会让任何带可选参数的工具 被 OpenAI 400 拒绝。修复方式是:strict 默认关;显式开 strict 时,applyStrictModeRequirements(23.4.3)会把可选参数 widen 成 nullable 并回填进 required。

24.8.3 新增 OpenAPI 风格 format 提示

生成的 schema 现在包含原始类型的 OpenAPI 风格 format 提示(例如 int → int32、long → int64、LocalDateTime → date-time),与工具调用的 format 约定一致。

★ 这是好事但会改变模型行为 :有了 format,模型更倾向于输出 2026-10-19T10:00:00 而不是 2026/10/19。如果你的 ObjectMapper 没配 JavaTimeModule,以前"偶尔能解析"的字符串现在可能变成"格式对了但反序列化器不认"。迁移时确认 JsonMapper 注册了 JavaTimeModule(JsonHelper 的默认 mapper 是注册了的,你自己的不一定)。

24.8.4 postProcessSchema(JsonNode) 移除

java 复制代码
// Before
class CustomConverter extends BeanOutputConverter<MyType> {
	@Override
	protected void postProcessSchema(JsonNode schema) { /* mutate */ }
}

// After
class CustomConverter extends BeanOutputConverter<MyType> {
	@Override
	protected String generateSchema() {
		String schema = super.generateSchema();
		// post-process(注意:现在是 String,不是 JsonNode)
		return schema;
	}
}

★ 两个细节:

  1. 扩展点从 void postProcessSchema(JsonNode) 变成 String generateSchema()------你要自己解析/序列化,或者纯字符串处理。
  2. 覆盖 generateSchema() 后必须调用 super.generateSchema() ,否则你会把父类的全部逻辑(包括 24.8.2 的三条规则、24.8.3 的 format)都丢掉。文档的示例里写了 String schema = super.generateSchema();,很容易被忽略。

24.8.5 跨供应商:迁移到 2.x 时顺手把 $defs 问题解决掉

第 23 章讲了 $defs 的三家三种命运(23.5.3 / 23.8.3)。迁移到 2.x 是一个重新设计 schema 的好时机,因为:

  • OpenAI 在 strict 模式下会递归修正 $defs(23.4.3),但默认 strict=false;
  • Anthropic 会把 $defs 剥掉、$ref 断链(23.5.3);
  • Google 见 $defs 直接抛 IllegalArgumentException(23.8.3)。

★ 所以"一份 schema 跑七家"的唯一可行做法是:在生成 schema 之后、发给供应商之前,把 $defs 内联展开 (把 $ref 替换成 $defs 里的定义,递归直到没有 $ref)。2.0 把 JsonSchemaUtils 独立出来(24.7.2)正好给了你这个插入点------你可以在自己的 OutputConverter/ToolCallback 包装层做一次"扁平化",再交给框架。

★ 内联展开的代价:schema 体积会指数级膨胀 (同一类型被引用 N 次就复制 N 份)。所以实践上是"先内联、再按供应商裁剪":给 Google 内联、给 OpenAI 保留 $defs(strict 会处理)、给 Anthropic 内联(否则断链)。


24.9 AOT 与 Native Image:19 个 hints 的全景

Spring AI 对 GraalVM 的支持全部是代码化的 :find . -path "*native-image*" -name "*.json" 零命中 ,也就是说没有 reflect-config.json / resource-config.json 这类手写元数据,一切都靠 RuntimeHintsRegistrar 在构建期生成。

24.9.1 盘点:19 个 registrar、14 个 aot.factories

grep -rln "implements RuntimeHintsRegistrar" --include=*.java . | grep -v /test/ | wc -l = 19 ;find . -name aot.factories = 14。逐个体检:

# 模块 Registrar 注册内容
1 spring-ai-model SpringAiCoreRuntimeHints 12 个消息/工具类型 + 它们的内部类,全量 MemberCategory ;embedding-model-dimensions.properties 资源
2 spring-ai-model KnuddelsRuntimeHints /com/knuddels/jtokkit/cl100k_base.tiktoken 资源(tokenizer 词表)
3 spring-ai-model ToolRuntimeHints DefaultToolCallResultConverter,全量 MemberCategory
4 spring-ai-model AbstractEmbeddingModel.Hints(静态内部类) EMBEDDING_MODEL_DIMENSIONS_PROPERTIES 资源
5 models/spring-ai-bedrock BedrockRuntimeHints 包扫描(Bedrock 相关 JSON 类)
6 models/spring-ai-deepseek DeepSeekRuntimeHints 包扫描(DeepSeekApi 所在包)
7 models/spring-ai-elevenlabs ElevenLabsRuntimeHints 包扫描
8 models/spring-ai-google-genai GoogleGenAiRuntimeHints 包扫描(45 行,模块内最小)
9 models/spring-ai-mistral-ai MistralAiRuntimeHints 包扫描
10 models/spring-ai-ollama OllamaRuntimeHints 包扫描(48 行)
11 mcp/common McpHints McpSchema 的所有嵌套类 ,全量 MemberCategory
12 auto-configurations/mcp/client-common McpClientAutoConfigurationRuntimeHints MCP 客户端配置类
13 auto-configurations/mcp/client-httpclient McpClientAutoConfigurationRuntimeHints HTTP client 传输
14 auto-configurations/mcp/client-webflux McpClientAutoConfigurationRuntimeHints WebFlux 传输
15 auto-configurations/mcp/client-common(注解扫描) McpClientAnnotationScannerAutoConfiguration 本身实现 registrar 扫描到的 @McpTool 等类
16 auto-configurations/mcp/server-common(注解扫描) McpServerAnnotationScannerAutoConfiguration 本身实现 registrar 同上(服务端)
17 document-readers/spring-ai-pdf-document-reader PdfReaderRuntimeHints PDFBox 反射
18 memory-repositories/...-jdbc JdbcChatMemoryRepositoryRuntimeHints JDBC 方言/行映射
19 vector-stores/spring-ai-pinecone-store PineconeVectorStoreHints Pinecone 请求/响应类型

★ 两个反直觉的观察:

  1. spring-ai-openai 与 spring-ai-anthropic 没有自己的 RuntimeHintsRegistrar (grep 零命中)。它们用官方 SDK(openai-java / anthropic-java),反射需求由 SDK 自带的 native 元数据 承担,Spring AI 侧不注册。这意味着:这两个模块的 native 兼容性取决于 SDK 版本,不取决于 Spring AI------升级 SDK 可能悄悄改变 native 行为,而你没法在 Spring AI 的代码里看到。
  2. spring-ai-model 一个模块贡献了 4 个 registrar (1--4),核心 hints 全在这里;models/ 下 15 个模块只有 6 个有 hints(bedrock/deepseek/elevenlabs/google/mistral/ollama),剩下 9 个(含 openai、anthropic、transformers)都没有。

24.9.2 三种注册方式

① 全量 MemberCategory(最粗暴、最安全、最贵)

java 复制代码
// spring-ai-model/src/main/java/org/springframework/ai/aot/SpringAiCoreRuntimeHints.java:41-56
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();   // ★ 全部类别

for (var c : chatTypes) {
	hints.reflection().registerType(c, memberCategories);
	var innerClassesFor = AiRuntimeHints.findInnerClassesFor(c);
	for (var cc : innerClassesFor) {
		hints.reflection().registerType(cc, memberCategories);
	}
}

★ MemberCategory.values() 包含 INVOKE_DECLARED_METHODS、INVOKE_PUBLIC_METHODS、ACCESS_DECLARED_FIELDS、INVOKE_PUBLIC_CONSTRUCTORS、INVOKE_DECLARED_CONSTRUCTORS 等全部 类别。代价是二进制体积与构建时间 (每个类的方法/字段元数据都进镜像),收益是不会漏。

★ 这是一种"用镜像体积换正确性"的取舍。对消息类型这种"会被 Jackson/反射到处摸"的类,这样做是对的;但对整个包都这么干(见 DeepSeekRuntimeHints)就偏重了。

findInnerClassesFor 的实现也值得看一眼(AiRuntimeHints:170-195):

java 复制代码
public static Set<TypeReference> findInnerClassesFor(Class<?> clazz) {
	var indent = new HashSet<String>();
	findNestedClasses(clazz, indent);
	return indent.stream().map(TypeReference::of).collect(Collectors.toSet());
}

private static void findNestedClasses(Class<?> clazz, Set<String> indent) {
	var classes = new ArrayList<Class<?>>();
	classes.addAll(Arrays.asList(clazz.getDeclaredClasses()));
	classes.addAll(Arrays.asList(clazz.getClasses()));
	for (var nestedClass : classes) {
		findNestedClasses(nestedClass, indent);
	}
	indent.addAll(classes.stream().map(Class::getName).toList());
}

★ 它用 getDeclaredClasses() + getClasses() 递归 ,所以 AssistantMessage.ToolCall 这种二级嵌套也能覆盖。但它不含匿名类与局部类 (getDeclaredClasses 不返回它们),也不含别的类里的嵌套类。

② 包扫描(覆盖广、构建期开销大)

java 复制代码
// models/spring-ai-deepseek/.../aot/DeepSeekRuntimeHints.java:36-43
public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
	var mcs = MemberCategory.values();
	for (var tr : findJsonAnnotatedClassesInPackage(DeepSeekApi.class)) {
		hints.reflection().registerType(tr, mcs);
	}
}

★ 一个模块一行,把整个包"带 Jackson 注解的类"全部注册。省事,但扫描发生在构建期(见 24.9.4),而且注册粒度是"全量 MemberCategory"。

③ 资源(最容易被漏)

java 复制代码
// spring-ai-model/src/main/java/org/springframework/ai/aot/KnuddelsRuntimeHints.java:28-31
public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
	hints.resources().registerResource(new ClassPathResource("/com/knuddels/jtokkit/cl100k_base.tiktoken"));
}

★ 为什么 tokenizer 词表要注册成资源 ?因为 jtokkit 在运行期用 ClassLoader.getResourceAsStream("...tiktoken") 读它。native image 默认不打包未声明的资源 ,所以不注册就会在第一次 TokenTextSplitter.split() 或 token 计数时抛 NullPointerException/FileNotFoundException------而且是在运行期,构建时一切正常。

★ 这也是"资源 hints 比反射 hints 更容易漏"的原因:反射失败通常有 ClassNotFoundException 指向类名,资源失败往往是一个莫名其妙的 NPE。

24.9.3 ★ @Tool 的 AOT 探测只看 declared methods

java 复制代码
// spring-ai-model/src/main/java/org/springframework/ai/aot/ToolBeanRegistrationAotProcessor.java:44-56
public @Nullable BeanRegistrationAotContribution processAheadOfTime(RegisteredBean registeredBean) {
	Class<?> beanClass = registeredBean.getBeanClass();
	MergedAnnotations.Search search = MergedAnnotations
		.search(org.springframework.core.annotation.MergedAnnotations.SearchStrategy.TYPE_HIERARCHY);

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

	if (hasAnyToolAnnotatedMethods) {
		return new AotContribution(beanClass);
	}
	return null;
}
java 复制代码
// :66-72
private final MemberCategory[] memberCategories = new MemberCategory[] {
		MemberCategory.INVOKE_DECLARED_METHODS, MemberCategory.INVOKE_PUBLIC_METHODS };

public void applyTo(GenerationContext generationContext, BeanRegistrationCode beanRegistrationCode) {
	generationContext.getRuntimeHints().reflection().registerType(this.toolClass, this.memberCategories);
}

★★ 这里有一个明确的缺口,必须自己补:

  • ReflectionUtils.getDeclaredMethods(beanClass) 只返回本类声明的方法,不含继承来的;
  • MergedAnnotations.SearchStrategy.TYPE_HIERARCHY 只影响注解的元注解搜索 (比如 @Tool 是否被另一个注解标注),不会帮你去找父类的方法;
  • 结论:@Tool 写在父类方法上,ToolBeanRegistrationAotProcessor 不会发现 ,于是父类不会被注册 INVOKE_DECLARED_METHODS,native image 里调用该工具 → NoSuchMethodException / IllegalAccessException。

★ 这个坑在 JVM 上完全不存在(JVM 反射不受 hints 限制),只在 native image 里出现,而且只在那个工具被真正调用时出现------你的 native 冒烟测试如果没覆盖到"父类工具",就发现不了。

三种补救方式,按侵入性从低到高:

  1. 在子类里覆写该方法 (哪怕只写 super.xxx())------getDeclaredMethods 就能看到它,于是整类被注册;
  2. 在子类上加 @RegisterReflectionForBinding(ParentToolClass.class)(Spring 的注解,直接注册反射);
  3. 自己的 RuntimeHintsRegistrar 里手动 hints.reflection().registerType(ParentToolClass.class, MemberCategory.INVOKE_DECLARED_METHODS, MemberCategory.INVOKE_PUBLIC_METHODS)。

★ 顺带一个相关问题:@Tool 若写在接口的 default 方法 上,getDeclaredMethods(beanClass) 也看不到(实现类没有声明它)。同样需要覆写或手动注册。

24.9.4 ★ AiRuntimeHints 在构建期做 classpath 扫描

java 复制代码
// spring-ai-model/src/main/java/org/springframework/ai/aot/AiRuntimeHints.java:68-91
public static Set<TypeReference> findJsonAnnotatedClassesInPackage(String packageName) {
	var annotationTypeFilter = new AnnotationTypeFilter(JsonInclude.class);
	TypeFilter typeFilter = (metadataReader, metadataReaderFactory) -> {
		try {
			var clazz = Class.forName(metadataReader.getClassMetadata().getClassName());
			return annotationTypeFilter.match(metadataReader, metadataReaderFactory)
					|| !discoverJacksonAnnotatedTypesFromRootType(clazz).isEmpty();
		}
		catch (ClassNotFoundException e) {
			throw new RuntimeException(e);
		}
	};
	return findClassesInPackage(packageName, typeFilter);
}

★ 三个关键点:

  1. Class.forName 在构建期执行 。这不是"读字节码",是真的把类加载进 JVM ------所以构建机的 classpath 必须完整(缺一个传递依赖就 RuntimeException)。
  2. 扫描用的是 ClassPathScanningCandidateComponentProvider(false) (findClassesInPackage:96-108,false = 不注册默认过滤器),只加一个 include filter。也就是说它只扫 packageName 下的类 ,不含子包?------findCandidateComponents 默认包含子包(Spring 的 resolveBasePackage + **/*.class 语义),所以是递归的。
  3. 过滤条件是"有 @JsonInclude 或任意 Jackson 注解" (hasJacksonAnnotations:110-146):检查类型本身、所有方法、所有构造器与声明构造器、构造器参数、record components、字段。★ 注意它不检查父类的注解 ------hasJacksonAnnotations 只看传入的 type,不做 getSuperclass() 递归。

★ 第 3 点意味着:如果你的 DTO 通过继承父类获得 Jackson 注解(父类有 @JsonProperty,子类没有),子类可能不会被扫描到 。除非子类自己也带一个注解(哪怕是 @JsonInclude)。这在 native image 里表现为"父类的字段反序列化不了"。

★ 还有一个性能副作用:findJsonAnnotatedClassesInPackage 会对包内每一个类 做 Class.forName + 全方法扫描。包大(比如 org.springframework.ai.openai 有几十个类)时,native 构建会明显变慢。这是"用构建时间换运行期正确性"的又一笔账。

24.9.5 资源 hints 的两处重复

embedding-model-dimensions.properties 被注册了两次:

  • SpringAiCoreRuntimeHints(:56-58):hints.resources().registerResource(new ClassPathResource("embedding/embedding-model-dimensions.properties"));
  • AbstractEmbeddingModel.Hints(:102-108):hints.resources().registerResource(EMBEDDING_MODEL_DIMENSIONS_PROPERTIES)。

★ 重复注册无害 (hints 是集合语义),但它透露了一个事实:这条资源是维度查表 的关键(第 18 章讲过 AbstractEmbeddingModel 的维度查表会因为 modelName 不在表里而退化为真实 embed() 调用)。在 native image 里如果没有这个资源,每一次维度查询都会变成一次真实的 embedding 调用 ------不是报错,是变慢 + 花钱。

★ 这也是本章唯一一个"资源缺失不报错、只让成本上升"的例子。

24.9.6 没有 native-image/*.json 的含义

全仓库 find . -path "*native-image*" -name "*.json" 零命中。含义有三层:

  1. 所有 hints 都是"活代码" ,随版本演进、可被 IDE 索引、可被单测覆盖(RuntimeHints 有 RuntimeHintsPredicates 可断言);
  2. 你不能"打补丁" ------想加一条反射注册,只能改代码重新构建,不能丢一个 JSON 进 META-INF/native-image/;
  3. 第三方库的 JSON 元数据不会出现在这里 ,它们在自己的 jar 里(openai-java、anthropic-java、jtokkit、pdfbox 都各自带)。

★ 实践含义:做 native image 时,先看第三方 jar 的 META-INF/native-image/,再看 Spring AI 的 aot.factories。两处都缺的类,才需要你自己写 registrar。

24.9.7 自建模块的 AOT 清单

如果你要为自己的模型/工具写 AOT 支持,按这个顺序做:

scss 复制代码
① src/main/java/<pkg>/aot/YourRuntimeHints.java  implements RuntimeHintsRegistrar
② 在 registerHints 里:
   - 反射:findJsonAnnotatedClassesInPackage(YourApi.class)(复用 Spring AI 的工具)
   - 或精确:hints.reflection().registerType(X.class, MemberCategory.values())
   - 资源:hints.resources().registerResource(new ClassPathResource("..."))
③ src/main/resources/META-INF/spring/aot.factories
   org.springframework.aot.hint.RuntimeHintsRegistrar=\
       <pkg>.aot.YourRuntimeHints
④ 如果有 @Tool 且方法在父类/接口上:额外 registerType(Parent.class, INVOKE_DECLARED_METHODS)
⑤ 本地验证:
   - JVM + -Dspring.aot.enabled=true 跑一遍(验证 AOT 生成的 bean 定义)
   - native 构建后跑 nativeTest,且测试要覆盖"父类工具"与"tokenizer 词表"

★ 第 ⑤ 步的两个测试用例是本章最值得抄的两条:父类 @Tool (24.9.3)与 tokenizer 词表(24.9.2 ③)。它们分别代表"反射 hints 漏"与"资源 hints 漏"两类故障。


24.10 生产调优:线程、超时、重试、内存

24.10.1 线程模型全景:一次流式调用会跳几次线程

位置 代码 调度行为
Advisor 基类 BaseAdvisor.java:44/64 DEFAULT_SCHEDULER = Schedulers.boundedElastic();publishOn(getScheduler())
流式 Advisor ChatModelStreamAdvisor.java:58 publishOn(Schedulers.boundedElastic()); // TODO add option to disable
记忆 Advisor MessageChatMemoryAdvisor.java:158 publishOn(scheduler)
工具循环 ToolCallingAdvisor.java:403 subscribeOn(Schedulers.boundedElastic())
Anthropic 流式 AnthropicChatModel.java:345 subscribeOn(Schedulers.boundedElastic())(整段流占一个 worker)
OpenAI 音频流 OpenAiAudioSpeechModel.java:78-80/173 专用 newBoundedElastic(..., "openai-audio-speech")
Anthropic HTTP 分发 AnthropicChatModel.Builder#dispatcherExecutor(:1897-1905) 可传 newVirtualThreadPerTaskExecutor()

★ 一次"带记忆 + 带工具"的流式调用,线程路径大致是:

scss 复制代码
调用方线程
  → BaseAdvisor.publishOn(boundedElastic#1)        // 进 before()
  → ChatModelStreamAdvisor.publishOn(boundedElastic#2)
  → MessageChatMemoryAdvisor.publishOn(boundedElastic#3)
  → ToolCallingAdvisor.subscribeOn(boundedElastic#4)
  → AnthropicChatModel.subscribeOn(boundedElastic#5)  // 整段流期间持有
  → OkHttp dispatcher(平台线程池,或你传的虚拟线程 executor)

★ 结论:一次流式调用至少 5 次线程切换、最多同时占用 2 个 boundedElastic worker (advisor 的 publishOn 是"用完即还",Anthropic 的 subscribeOn 是"整段持有")。boundedElastic 默认容量 10 × CPU,所以在 8 核机器上是 80 个并发流就满------这是 Spring AI 生产部署最硬的一个数字。

★★ 更麻烦的是 ThreadLocal 丢失 :publishOn 之后是另一个线程,所以

  • MDC(日志 traceId)在 advisor 的 after() 里读不到;
  • SecurityContextHolder(默认 MODE_THREADLOCAL)在 advisor 里读不到;
  • RequestContextHolder 同理。

★ 这不是 bug,是响应式编程的固有约束。解法只有三个:把上下文放进 Reactor Context (第 21 章的观测就是这么做的)、用 Hooks.enableAutomaticContextPropagation()(Micrometer Context Propagation)、或者不要依赖 ThreadLocal。

24.10.2 超时表:没有"全局超时"

供应商 默认超时 默认重试 出处
OpenAI 60s 3 AbstractOpenAiOptions:35 DEFAULT_TIMEOUT、:40 DEFAULT_MAX_RETRIES
Anthropic 60s 2 AnthropicSetup:98 DEFAULT_TIMEOUT、:100 DEFAULT_MAX_RETRIES(都是 private,只能通过参数覆盖)
Bedrock 5 分钟(请求)/ 5s(连接)/ 30s(asyncRead)/ 30s(连接获取)/ 30s(socket) AWS SDK 自己 BedrockProxyChatModel.Builder:865-873
Google GenAI SDK 默认 Spring RetryTemplate(10 次) 23.8.2
DeepSeek / Mistral / Ollama 各自 *Api 的 client 默认 Spring RetryTemplate(10 次) 23.6

★ 三个必须自己做的事:

  1. 没有统一的 spring.ai.timeout 。每个供应商的超时要分别配(OpenAI/Anthropic 走 spring.ai.<provider>.chat.timeout 之类,Bedrock 走 Builder 的五个字段)。
  2. spring.ai.retry.* 只管 Spring Retry 那一层 ,对 OpenAI/Anthropic/Bedrock 完全无效(22.18 的结论)。
  3. ★ 超时要配在"最外层" :你的 HTTP 网关/负载均衡的超时必须 大于 供应商超时 × 重试次数,否则网关先断,你的重试全白做。

24.10.3 三层重试叠加的算术:最坏 40 次 HTTP 请求

三层:

层 默认 出处
Spring RetryTemplate 10 次、初始 2s、×5、单次间隔上限 3 分钟 RetryUtils(22.18)
SDK 重试(OpenAI) 3 次(即 1 次原始 + 3 次重试 = 4 次 HTTP) AbstractOpenAiOptions:40
SDK 重试(Anthropic) 2 次(= 3 次 HTTP) AnthropicSetup:100

★★ 叠加后的最坏情况(以 OpenAI 为例):

ini 复制代码
Spring 第 1 次 attempt
  └─ SDK 内部 4 次 HTTP(1 + 3 retry)
Spring 第 2 次 attempt
  └─ SDK 内部 4 次 HTTP
...
Spring 第 10 次 attempt
  └─ SDK 内部 4 次 HTTP
= 10 × 4 = 40 次 HTTP 请求

★ 时间上更吓人:Spring 的 backoff 是 2s、10s、50s、250s、750s...(×5,上限 180s),10 次累计约 19 分钟 (22.18 已算过)。而每一次 attempt 内部还有 4 次 HTTP + SDK 自己的退避。所以"一次故障打 19 分钟"是保守估计。

★ 三种"只留一层"的方案:

方案 做法 适用
只留 SDK 层 spring.ai.retry.max-attempts=1(Spring 不重试),让 SDK 重试 用 OpenAI/Anthropic,且信任 SDK 的退避
只留 Spring 层 maxRetries=0(关 SDK 重试),让 Spring 统一重试 需要统一退避策略、统一指标
只留网关层 两层都关,重试交给 API Gateway / Service Mesh 有多副本、需要跨实例重试

★ 推荐"只留 Spring 层":因为只有 Spring 层的重试能被 Micrometer 观测(第 21 章),SDK 内部的重试对你完全不可见------你只看到"一次调用花了 3 秒",不知道里面试了 4 次。

★ 但要小心:关 SDK 重试要改 ClientOptions.maxRetries,而 OpenAI 的 maxRetries 是 AbstractOpenAiOptions 的字段(23.8.2 的表),Anthropic 的是 AnthropicSetup 的参数------两个的配置入口不一样。

24.10.4 内存与截断:20 / 100 / 1000 三个数

位置 默认值 出处 含义
MessageWindowChatMemory 20 条消息 MessageWindowChatMemory.java:45 DEFAULT_MAX_MESSAGES 策略层窗口:进 prompt 的消息数
RedisChatMemoryRepository.Builder 100 条/会话 RedisChatMemoryRepository.java:1008 存储层上限:Redis 里留多少
RedisChatMemoryConfig.Builder 1000 条/会话 RedisChatMemoryConfig.java:45/165 DEFAULT_MAX_RESULTS ★ 另一个存储层上限,与上面不一致

★★ 三个数,两次截断,而且两个 Redis builder 的默认值不一样(100 vs 1000)。这意味着:

  1. 长对话会被截断两次:先进 prompt 时被窗口截到 20 条;存储里再被截到 100(或 1000)条。
  2. "为什么我的 AI 忘了昨天说的话" 有两个可能原因:窗口 20 条太小,或者存储 100 条被裁了。
  3. 两个 Redis builder 的默认值不同,取决于你用哪个构建(自动配置走哪个?------这本身就是一个需要核对的点)。★ 这是本章唯一一处"同一个语义、两个默认值"的内部不一致,迁移时如果发现 Redis 里的消息数不对,先确认你用的是哪个 builder。

★ 调优建议:

  • 窗口不要盲目调大:20 条已经可能几千 token,调到 50 条会显著增加每次调用的成本;
  • 真正该做的是摘要式记忆 (把老消息压成摘要)或向量检索式记忆 (VectorStoreChatMemoryAdvisor),而不是把窗口开到 200;
  • 存储上限要大于 窗口,否则窗口还没满,存储先被裁了------100 > 20 是合理的,1000 > 20 更宽松。

24.10.5 观测缺口:一个 HealthIndicator 都没有

grep -rln "implements HealthIndicator\|extends AbstractHealthIndicator" 在全仓库零命中。所以:

你可能以为有的 实际
/actuator/health 里有 openai 健康项 没有
模型连不上会被 health 探针发现 不会(除非你自己写)
工具失败率有指标 没有 (throwExceptionOnError=false 时连异常都没有)
总 token 有指标 没有 (21 章:UsageAccumulator 零 Observation 引用)
Anthropic 流式有完整链路 断的(23.5.2 缺陷一)

★ 生产必须自建的最小观测集(4 个):

java 复制代码
// 1) 一个 HealthIndicator:真正发一次最小请求(注意别用 maxTokens=1 的贵模型)
@Component
class ChatModelHealthIndicator implements HealthIndicator {
	private final ChatModel chatModel;
	public Health health() {
		try {
			chatModel.call(new Prompt("ping", ChatOptions.builder().maxTokens(1).build()));
			return Health.up().build();
		}
		catch (Exception e) {
			return Health.down(e).build();
		}
	}
}
java 复制代码
// 2) 三个指标:调用耗时、token、工具失败
//    - gen_ai.client.operation 已有(框架自带),但要把 provider/model/tag 控制成低基数
//    - token:从 ChatResponse.getMetadata().getUsage() 自己记(框架不进指标)
//    - 工具失败:在工具实现内部 catch + counter,别依赖框架

★ 关于 HealthIndicator 的一个陷阱:健康检查本身要花钱 。每次 /actuator/health 被调用都发一次模型请求,K8s 的探针 10 秒一次 = 每分钟 6 次计费调用。所以要么用 @ConditionalOnProperty 默认关闭,要么缓存结果(比如 60 秒),要么只检查"配置是否齐全"而不真发请求。

24.10.6 缓存与成本:Usage 新增的两个方法

upgrade-notes.adoc:1608-1615:Usage 接口新增两个 default 方法,都返回 null 表示"供应商不支持/没有命中":

java 复制代码
// spring-ai-model/src/main/java/org/springframework/ai/chat/metadata/Usage.java:78 / :90(@since 2.0.0)
default @Nullable Long getCacheReadInputTokens() { return null; }
default @Nullable Long getCacheWriteInputTokens() { return null; }

填充情况:

供应商 读缓存 写缓存
Anthropic ✅ ✅
Bedrock Converse ✅ ✅
OpenAI ✅ ❌
Google GenAI ✅ ❌

★ 迁移含义:以前你要 cast 到各家原生类型、或读 provider-specific metadata key 才能拿到缓存 token(23.5.4 的 CacheBreakpointTracker、23.7.4 的 Bedrock 缓存),现在统一走接口。但默认返回 null,所以:

java 复制代码
Long read = usage.getCacheReadInputTokens();
if (read != null) { /* 只有支持缓存的供应商才进 */ }

★ 别写成 usage.getCacheReadInputTokens() + 0------null 会 NPE。这也是 24.3.2"nullable 取代 empty"的同一套设计哲学在 metrics 上的延伸。

★ 成本优化的优先级(按 ROI):

  1. prompt caching(Anthropic 4 断点 / Bedrock 最后一条 user / Google cachedContent):对"长 system prompt + 短 user 输入"的场景,能省 80%+ 的输入 token;
  2. 窗口裁剪(24.10.4):省的是"历史"那部分;
  3. 模型降级(小模型做分类、大模型做生成):省的是单价;
  4. 减少工具调用轮次:省的是"每轮都要重发一遍完整上下文"。

24.10.7 一个可落地的配置样例

yaml 复制代码
spring:
  ai:
    # ① 只留一层重试:Spring 层(可观测)
    retry:
      max-attempts: 3          # 默认 10 太长
      backoff:
        initial-interval: 1s
        multiplier: 2
        max-interval: 10s
    openai:
      chat:
        timeout: 30s
        max-retries: 0         # 关掉 SDK 重试,避免三层叠加
    anthropic:
      chat:
        timeout: 30s
        max-retries: 0
    # ② 工具:显式上限 + 生产要抛异常
    tools:
      throw-exception-on-error: true
      limits:
        max-calls-per-tool: 20
        max-total-tool-calls: 60
      resolution:
        fallback:
          enabled: false       # 保持默认(安全)
  threads:
    virtual:
      enabled: true            # 只影响 Web 层;Anthropic 还要 dispatcherExecutor

logging:
  level:
    org.springframework.ai: INFO

★ 这份配置里的每一个数字都不是"官方推荐",而是本章推导出来的:

  • max-attempts: 3 + multiplier: 2 + max-interval: 10s → 最坏约 1+2+4 = 7 秒退避,而不是 19 分钟;
  • max-retries: 0 → 避免 10×4=40 次 HTTP;
  • timeout: 30s → 小于常见网关的 60s;
  • max-calls-per-tool: 20 / max-total-tool-calls: 60 → 比默认的 40/150 更早失败(fail fast),因为超限之后模型会开始乱试,越早停越省钱;
  • throw-exception-on-error: true → 让工具失败可见。

★ spring.threads.virtual.enabled=true 只影响 Spring 的 Web 层(Tomcat 用虚拟线程),不影响 Reactor 的 boundedElastic,也不影响 OkHttp 的 dispatcher。Anthropic 要真正用上虚拟线程,还得显式配 dispatcherExecutor(24.6.2)。


24.11 生产检查清单(18 项)

按"上线前 / 上线后"两组,每项都给可执行命令。

24.11.1 上线前(12 项)

# 面 检查项 命令 / 动作 期望
1 坐标 没有引用已移除模块 `grep -rn "azure-openai openai-sdk
2 坐标 没有引用 OpenAiApi grep -rn "OpenAiApi" src/ 零命中(改用 OpenAiSetup)
3 类 没有 copy() / fromOptions() / postProcessSchema `grep -rn ".copy() fromOptions(
4 属性 没有非 chat 的 .options. 前缀 grep -rn "\.options\." src/main/resources/ 只剩 chat 的(或已迁移)
5 默认值 temperature 显式配置 grep -rn "temperature" src/main/resources/ 关键链路都有显式值(默认 0.7 已移除)
6 默认值 工具 fallback 决策 grep -rn "resolution.fallback" src/ 显式 true 或显式 false
7 空值 没有对 options 集合直接 .isEmpty() `grep -rn "getStopSequences(). getToolCallbacks()." src/`
8 异常 流式调用的 catch 是否按 getCause() 找根因 代码审查 不依赖最外层异常类型
9 记忆 conversationId 全部显式传入 grep -rn "CONVERSATION_ID" src/ 每个记忆 advisor 调用点都有
10 线程 boundedElastic 容量 vs 预期并发 估算:10 × CPU 并发流 < 容量
11 超时 网关超时 > 供应商超时 × 重试次数 对比配置 成立
12 native 父类 @Tool 与 tokenizer 词表已覆盖 nativeTest 两个用例都过

24.11.2 上线后(6 项)

# 面 检查项 信号
13 成本 token 是否"突然变大" 对比升级前后的 Usage;工具场景应为累加值(24.5.3)
14 成本 缓存命中率 Usage.getCacheReadInputTokens() 非 null 且占比上升
15 质量 结构化输出是否缺字段 统计反序列化后为 null 的必填字段(24.8.2)
16 质量 工具失败率 自建 counter(24.10.5),不是框架指标
17 稳定性 429/5xx 的 P99 耗时 若接近 19 分钟 → 三层重试没关(24.10.3)
18 稳定性 流式并发上限 boundedElastic 的 reactor.scheduler.boundedElastic.queuedTaskCount 指标

★ 这 18 项里,第 5、6、7、13、15 项是"不检查就一定出事、出事后又查不出来"的那五条。如果只能做五项,做这五项。


24.12 陷阱总表

按"你会怎么发现它"分六组。编号只在本章内有效。

24.12.1 编译期能发现,但容易改错(1--8)

# 坑 证据 正确改法
1 OpenAiApi 不存在,但你以为只是"包名变了" find models -name OpenAiApi.java 无输出 用 OpenAiSetup.setupSyncClient(...)(17 参数)或自动配置;不是换个 import
2 copy() / fromOptions() 移除后,用 mutate() 却忘了 .build() ChatOptions.java:86 返回 Builder<?> original.mutate().foo(...).build()
3 postProcessSchema 改成 generateSchema 后忘了 super.generateSchema() upgrade-notes.adoc:1230-1245 第一行必须 String schema = super.generateSchema();
4 .options(builtInstance) 改成 .options(builder) 时,把 ChatModel 侧也改了 upgrade-notes.adoc:1459-1464 只有 ChatClient 收 Builder;ChatModel.call(Prompt) 仍要完整实例
5 N() → n() 只改了 chat options,漏了 embedding/audio 的 builder upgrade-notes.adoc:842-858 全局搜 .N(
6 Media.Builder.data(Object) 改成 data(String) 时,把"资源路径"当成了"文本内容" upgrade-notes.adoc:277-304 路径用 data(URI)/data(Resource),内联文本才用 data(String)
7 以为 PromptChatMemoryAdvisor 只是"改名",直接替换 upgrade-notes.adoc:1411-1436 语义从"纯文本注入 system"变成"真消息列表",要删掉手写的历史拼接
8 toolCallExceptionClass 构造器参数"还能传",就以为还有效 upgrade-notes.adoc:170-197 运行期被忽略;用三参构造器,按异常类型分派

24.12.2 编译通过,但启动或首次调用就失败(9--16)

# 坑 证据 后果
9 Redis chat memory 改了 artifact 但没改属性前缀(或反之) upgrade-notes.adoc:7-56 旧前缀有委托,能跑;但升级到未来版本就断
10 非 chat 的 .options. 没去掉 upgrade-notes.adoc:802-840 走 @DeprecatedConfigurationProperty 委托,启动一堆 warn;未来版本启动失败
11 没传 conversationId upgrade-notes.adoc:1324-1338 每次调用 IllegalArgumentException(这个是好朋友)
12 自定义 advisor 覆写了 getConversationId(Map, String) upgrade-notes.adoc:1400-1410 编译不过;改一参版本后 context 缺值会抛异常
13 spring.ai.openai.base-url 配了但用的是 GitHub Models OpenAiSetup.calculateBaseUrl(23.3.4) baseUrl 被丢弃,请求打到 models.github.ai
14 apiKey 为空(以为会启动失败) OpenAiSetup no-auth 模式(23.3.3) 启动成功,运行到 401
15 同时引了两个 chat starter 22.2(@ConditionalOnSingleCandidate = 0) 启动成功,注入时 NoUniqueBeanDefinitionException
16 引了 spring-ai-stability-ai 却想注入 ChatModel 23.1.2(15 模块只有 7 个 chat) NoSuchBeanDefinitionException

24.12.3 静默行为变化:能跑、能过冒烟、上线才出事(17--27)

# 坑 证据 后果
17 ★ 默认 temperature 0.7 被移除 upgrade-notes.adoc:2184-2223 输出风格/JSON 稳定性全变;供应商原生默认可能是 1.0
18 ★ OpenAI strict 默认变 false upgrade-notes.adoc:218-242 工具 schema 不再被强制满足,结构化输出偶发不合规
19 ★ 工具解析 fallback 默认关闭 upgrade-notes.adoc:305-330 模型点名未挂载工具 → 执行失败 → 模型开始乱试
20 ★ ToolCallingAdvisor usage 跨轮累加 upgrade-notes.adoc:210-216 计费看板翻倍;token 配额告警被真实值触发
21 ★ options 集合从 empty 变 null ChatOptions.java:59 getStopSequences().isEmpty() → NPE,只在空值分支暴露
22 ★ BaseAdvisor 把流式异常包成 IllegalStateException BaseAdvisor.java:72-73 catch (WebClientResponseException) 永不命中
23 OpenAiAudioSpeechModel.stream 变真流式 upgrade-notes.adoc:244-276 blockFirst() 只拿到第一块音频(截断,不报错)
24 TranscriptionModel.stream 默认抛 UnsupportedOperationException upgrade-notes.adoc:243-276 编译通过,调用才炸
25 @McpTool 受检异常不再回给模型 upgrade-notes.adoc:130-169 变成 UndeclaredThrowableException 硬失败
26 BeanOutputConverter 的 required 规则变化 upgrade-notes.adoc:1205-1250 必填项变少 → 模型漏字段 → 反序列化得 null
27 @JsonProperty 不写 required 就视为非必需 同上 只想改名的注解,顺带取消了必填

24.12.4 AOT 与 native image:JVM 上永远复现不了(28--35)

# 坑 证据 后果
28 ★★ @Tool 在父类/接口 default 方法上 ToolBeanRegistrationAotProcessor:48-51(只 getDeclaredMethods) native 里调用该工具 → NoSuchMethodException
29 ★ tokenizer 词表未注册 KnuddelsRuntimeHints:28-31 首次 token 计数/切分 → NPE(不是 ClassNotFound)
30 ★ DTO 通过父类继承 Jackson 注解 AiRuntimeHints.hasJacksonAnnotations(不做父类递归) 子类不被扫描 → 字段反序列化不了
31 以为有 native-image/*.json 可以打补丁 全仓库零命中(24.9.6) 只能改代码重构建
32 以为 OpenAI/Anthropic 有 Spring AI 侧 hints 两模块零 registrar(24.9.1) native 行为取决于 SDK 版本,不在你掌控
33 维度表资源缺失 SpringAiCoreRuntimeHints:56-58 + AbstractEmbeddingModel.Hints:102-108 不报错,但每次维度查询退化成真实 embed()(花钱)
34 包扫描在构建期 Class.forName AiRuntimeHints:68-91 构建机 classpath 不全 → RuntimeException(构建失败,不是运行失败)
35 McpHints 只覆盖 McpSchema 嵌套类 McpHints(24.9.1 #11) 自定义 MCP 类型不在内 → 需自己注册

24.12.5 线程、超时与重试:数字算错就全错(36--43)

# 坑 证据 后果
36 ★ 以为 boundedElastic 很大 BaseAdvisor:44;默认 10 × CPU 8 核 = 80 并发流;Anthropic 每流整段占一个
37 ★ 以为 spring.ai.retry.* 对所有供应商生效 22.18 + 23.8.2 OpenAI/Anthropic/Bedrock 完全无效
38 ★ 三层重试全开 10 × 4 = 40 次 HTTP(24.10.3) 一次故障打 19 分钟以上
39 只改 Bedrock 五个超时里的一个 BedrockProxyChatModel.Builder:865-873 其它四个仍是默认(5m/5s/30s/30s)
40 网关超时 < 供应商超时 × 重试次数 --- 网关先断,重试全白做
41 在 advisor 里读 MDC / SecurityContext publishOn 换线程(24.10.1) 读不到,且不报错
42 spring.threads.virtual.enabled=true 就以为 Anthropic 用了虚拟线程 只影响 Web 层 还要 dispatcherExecutor(24.6.2)
43 健康检查每次都真发模型请求 自建 HealthIndicator K8s 探针 10s 一次 = 每分钟 6 次计费

24.12.6 观测、成本与内部不一致(44--48)

# 坑 证据 后果
44 以为 /actuator/health 会告诉你模型挂了 全仓库零 HealthIndicator 探针永远 UP
45 以为 token 总量进了指标 21 章:UsageAccumulator 零 Observation 指标里只有单次量
46 工具失败率没有指标 throwExceptionOnError=false(默认) 失败变成模型输出,监控无感
47 两个 Redis builder 默认值不同 RedisChatMemoryRepository:1008=100 vs RedisChatMemoryConfig:45/165=1000 消息数对不上,取决于用哪个 builder
48 usage.getCacheReadInputTokens() 直接参与算术 Usage.java:78 默认返回 null NPE

24.13 小结

  1. ★ 2.0.x 是"减法版本" :spring-ai-azure-openai、spring-ai-openai-sdk、spring-ai-oci-genai、spring-ai-hanadb-store 全部移除/迁出;PromptChatMemoryAdvisor、ChatMemory.DEFAULT_CONVERSATION_ID、copy()/fromOptions()、postProcessSchema()、ModelOptionsUtils 的 8 个 JSON 方法、McpJsonParser 全部删除。
  2. ★ 破坏性变更分九层,六层静默:坐标/类/属性三层会报错;默认值/集合语义/行为/枚举/异常/schema 六层不会。迁移验收不能是"能启动",必须是"关键路径输出与 1.x 对比"。
  3. ★ 五条最容易漏的静默变化 :temperature 0.7 移除、strict 默认 false、工具 fallback 默认关闭、usage 跨轮累加、options 集合变 null。
  4. ★ ChatClient 与 ChatModel 在 options 上不对称 :前者收 Builder 并与默认值合并(在第一个 advisor 之前),后者收完整实例且完全不合并。
  5. ★ BaseAdvisor 把所有流式异常包成 IllegalStateException("Stream processing failed") (:72-73),与 RetryUtils 的"拆包"方向相反------生产错误分类必须按 getCause() 递归找根因。
  6. ★ 默认所有 advisor 都在 boundedElastic 上 (BaseAdvisor:44),一次带记忆+工具的流式调用至少 5 次线程切换;ChatModelStreamAdvisor:58 的 // TODO add option to disable 说明官方已知这个问题。
  7. ThreadLocal 在 advisor 之间会丢(MDC / SecurityContext / RequestContextHolder),只能靠 Reactor Context 或 Micrometer Context Propagation。
  8. ★ 工具调用有三个新上限/开关 :40 次/工具、150 次/轮(DefaultToolCallingManager:100/107)、fallback 默认关(:289)、throwExceptionOnError 默认 false(ToolCallingProperties:69)。
  9. ★ @McpTool 异常分派改为类型驱动 :RuntimeException → 回给模型;checked → UndeclaredThrowableException;Error/McpError → 原样冒泡;toolCallExceptionClass 被忽略。
  10. ★ JSON 层被 JsonHelper 统一 :JsonParser 废弃、McpJsonParser 删除、ModelOptionsUtils 的 8 个 JSON 方法移除;getJsonSchema 搬到 JsonSchemaUtils。
  11. ★ BeanOutputConverter 的 required 规则变了三处 :Kotlin 可选参数、@JsonProperty(required=false)、@JsonProperty 未写 required ------ 三者都不再进 required。
  12. ★ 15 个模型模块只有 6 个有 AOT hints (bedrock/deepseek/elevenlabs/google/mistral/ollama),spring-ai-openai 与 spring-ai-anthropic 零 registrar------native 兼容性取决于官方 SDK。
  13. ★ @Tool 的 AOT 探测只看 getDeclaredMethods(beanClass) ,父类/接口 default 方法上的 @Tool 不会被发现 → native 里 NoSuchMethodException。
  14. ★ AiRuntimeHints 在构建期 Class.forName + 包扫描 ,且 hasJacksonAnnotations 不递归父类 → "继承来的 Jackson 注解"是盲区。
  15. ★ 资源 hints 比反射 hints 更容易漏 :tokenizer 词表(cl100k_base.tiktoken)缺失的报错是一个莫名的 NPE;维度表缺失甚至不报错,只是变慢+花钱。
  16. ★ 超时没有全局配置 :OpenAI 60s/3、Anthropic 60s/2、Bedrock 5m+5s+30s+30s+30s;spring.ai.retry.* 只管 Spring Retry 层。
  17. ★ 三层重试叠加最坏 40 次 HTTP、19 分钟以上;推荐"只留 Spring 层",因为只有那层可被 Micrometer 观测。
  18. ★ 三个记忆数字 :窗口 20(MessageWindowChatMemory:45)、Redis 仓储 100(:1008)、Redis 配置 1000(RedisChatMemoryConfig:45/165)------两个 Redis builder 默认值不一致。
  19. ★ 零 HealthIndicator :模型可用性不进 /actuator/health;自建时要防"探针变成计费调用"。
  20. ★ 缓存 token 有了统一接口 :Usage.getCacheReadInputTokens() / getCacheWriteInputTokens()(:78/:90,默认 null),Anthropic/Bedrock 双向、OpenAI/Google 只读。

24.14 思考题

  1. 为什么 2.0.x 的九层破坏性变更里,只有三层会报错?请从"Java 的类型系统能检查什么、不能检查什么"的角度解释,并说明为什么"能启动"是一个不合格的迁移验收标准。
  2. 默认 temperature 从 0.7 变成"供应商原生默认",对以下三类应用分别意味着什么:① 结构化输出;② 创意写作;③ 分类/打标。哪一类最危险,为什么?
  3. ChatClient.prompt().options(Builder) 会与默认值合并,而 ChatModel.call(Prompt) 不会。请设计一个实验证明这个差异,并说明为什么 Spring AI 要保留这种不对称(提示:ChatModel 是最底层接口,ChatClient 是门面)。
  4. ★ BaseAdvisor 用 onErrorResume 把异常包成 IllegalStateException。请写出一个能正确区分 429、401、超时三种情况的 catch 结构,并说明为什么"按最外层异常类型分类"在监控里会产生误导。
  5. 为什么 BaseAdvisor 要在流式链上 publishOn(boundedElastic())?如果不这么做,before() 里的阻塞代码会跑在哪个线程上?这个设计对"响应式"意味着什么?
  6. ★ 工具解析 fallback 默认关闭,从"安全"与"兼容"两个角度看分别是对是错?请给出一个必须打开 fallback 的合法场景,并说明如何在不打开 fallback 的前提下实现同样的效果。
  7. ToolCallingAdvisor 的 usage 从"最后一次"变成"累加",为什么文档说"no API changes are required"却仍是一个破坏性变更?请举出两个会被它影响的系统(提示:计费、限流)。
  8. ★ @Tool 的 AOT 探测只看 getDeclaredMethods(beanClass)。请构造一个"在 JVM 上完全正常、在 native image 里必然失败"的最小例子,并给出三种修法及其代价。
  9. AiRuntimeHints.hasJacksonAnnotations 不递归父类。请说明这会造成什么后果,并设计一个测试用例来暴露它(提示:DTO 继承 + nativeTest)。
  10. 为什么 tokenizer 词表要以"资源 hint"注册,而不是打成 Java 类常量?如果你忘了注册,会在哪个调用路径上炸、报什么错?
  11. ★ 三层重试叠加后最坏是 40 次 HTTP。请计算"只留 Spring 层"与"只留 SDK 层"两种方案下的最坏 HTTP 次数与最坏耗时,并说明为什么"可观测性"应该成为选型的决定因素。
  12. MessageWindowChatMemory 的 20 与 Redis 的 100/1000 是两次独立截断。请设计一个"摘要 + 窗口"的组合策略,使得长对话既不丢关键信息、又不让 token 爆炸。
  13. ★ 零 HealthIndicator 意味着 /actuator/health 永远 UP。请写一个"不花钱"的健康检查(不发模型请求),并说明它能覆盖什么、不能覆盖什么。
  14. 综合本章:如果你要把一个 1.x 的生产系统迁到 2.0.1,请给出一个"两周上线"的计划,按 24.1.3 的八步顺序,标出每一步的验收标准与回滚点。

24.15 文件索引

24.15.1 迁移事实的唯一权威:upgrade-notes.adoc

路径:spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc(3234 行)。本章引用的行号段:

行号 主题 本章对应
7-56 Redis chat memory 模块改名(唯一平滑过渡) 24.2.2
57-103 Mistral moderation 分类拆分 24.1.1(⑦ 枚举层)
104-129 Mistral chat model 枚举更新(4 个移除、3 个改指向) 24.1.1 / 24.12
130-197 @McpTool 异常分派对齐 @Tool;toolCallExceptionClass 废弃 24.5.5
198-208 工具调用上限 40/150 24.5.1
210-216 ToolCallingAdvisor usage 跨轮累加 24.5.3
218-242 OpenAI strict 默认 false 24.8.2
243-276 TranscriptionModel extends StreamingTranscriptionModel 24.6.4
277-304 Media.Builder 类型化 data() 24.6.3
305-330 工具解析 fallback 默认关闭 24.5.2
335-388 Advisors 迁移 24.4
389-701 Tool Calling 迁移(300+ 行) 24.5
702-738 JDBC chat memory sequence_id 24.10.4
739-849 Options 不可变 + 默认值搬家 + .options 扁平化 + N()→n() 24.3
850-866 Ollama think-option → think 24.1.1(③)
874-975 JSON Utilities 重构(JsonHelper / ModelOptionsUtils 移除 / McpJsonParser 删除) 24.7
976-1026 MCP Elicitation:TypeReference → ParameterizedTypeReference 24.7.3
1027-1048 GoogleGenAiEmbeddingConnectionDetails 包变更 24.2
1049-1200 ChatClient Tool Calling 迁移 24.5
1205-1250 BeanOutputConverter 委托 JsonSchemaGenerator 24.8
1251-1257 Azure Cosmos DB 支持 24.2
1258-1306 MCP SDK 必填字段破坏性变更 24.12
1307-1317 Observability 变更 21 章 / 24.10.5
1318-1323 移除 spring-ai-hanadb-store 24.2.1
1324-1410 conversationId 必需 / DEFAULT_CONVERSATION_ID 移除 / builder 方法移除 / getConversationId(Map) 24.4.2
1411-1436 移除 PromptChatMemoryAdvisor 24.4.3
1437-1469 .options() 只收 Builder;buildRequestPrompt 私有化 24.4.1
1470-1492 OpenAI SDK 迁移 + azure-openai / openai-sdk 移除 + oci-genai 迁出 24.6.1
1493-1607 MCP Java SDK 2.0.0 24.2
1608-1615 Usage 新增缓存 token 方法 24.10.6
1616-1700 Anthropic 模块改 SDK 24.6.2
1701-1767 MCP 注解迁入 Spring AI 24.2
1768-1912 MCP Spring Transport 模块迁移 24.2
1913-1994 MCP Client Customizer API 合并 24.2
1995-2023 MCP WebMvc header 小写化 24.2
2024-2112 Conversation History 从 ToolContext 移除 24.5
2113-2135 OpenSearch 依赖升级 24.2
2136-2154 AbstractFilterExpressionConverter#doSingleValue 变抽象 24.2
2155-2179 MongoDB chat memory 排序修复 24.10.4
2180-2183 开发期服务 24.2
2184-2223 默认 temperature 移除 24.1.2 #1 / 24.12 #17

24.15.2 AOT 落点

文件 行 内容
spring-ai-model/.../aot/SpringAiCoreRuntimeHints.java 41-58 12 个消息/工具类型 × MemberCategory.values();内部类递归;dimensions 资源
spring-ai-model/.../aot/AiRuntimeHints.java 68-91 / 96-108 / 110-146 / 148-166 / 170-195 包扫描(Class.forName)/ 扫描器 / hasJacksonAnnotations / discoverJacksonAnnotatedTypesFromRootType(getNestMembers)/ findInnerClassesFor
spring-ai-model/.../aot/ToolBeanRegistrationAotProcessor.java 44-56 / 66-72 ★ getDeclaredMethods + TYPE_HIERARCHY 搜索;注册 INVOKE_DECLARED_METHODS/INVOKE_PUBLIC_METHODS
spring-ai-model/.../aot/KnuddelsRuntimeHints.java 28-31 cl100k_base.tiktoken 资源
spring-ai-model/.../aot/ToolRuntimeHints.java 34-37 DefaultToolCallResultConverter
spring-ai-model/.../embedding/AbstractEmbeddingModel.java 102-108 静态内部类 Hints 注册 dimensions 资源
spring-ai-model/src/main/resources/META-INF/spring/aot.factories 全文 3 个 registrar + 1 个 BeanRegistrationAotProcessor
mcp/common/.../aot/McpHints.java 44-60 McpSchema 嵌套类 × 全量 MemberCategory
models/spring-ai-deepseek/.../aot/DeepSeekRuntimeHints.java 36-43 包扫描样板(5 个模型模块同构)

24.15.3 线程 / 超时 / 重试常量

文件 行 常量
spring-ai-client-chat/.../advisor/api/BaseAdvisor.java 44 / 57-74 DEFAULT_SCHEDULER = boundedElastic();publishOn + onErrorResume(IllegalStateException)
spring-ai-client-chat/.../advisor/ChatModelStreamAdvisor.java 58 publishOn(boundedElastic()) + // TODO add option to disable
spring-ai-client-chat/.../advisor/MessageChatMemoryAdvisor.java 158 publishOn(scheduler)
spring-ai-client-chat/.../advisor/ToolCallingAdvisor.java 403 subscribeOn(boundedElastic())
models/spring-ai-anthropic/.../AnthropicChatModel.java 345 / 1897-1905 subscribeOn(boundedElastic());dispatcherExecutor(虚拟线程入口)
models/spring-ai-openai/.../OpenAiAudioSpeechModel.java 78-80 / 173-176 专用 newBoundedElastic(..., "openai-audio-speech");doOnCancel 顺序
models/spring-ai-openai/.../AbstractOpenAiOptions.java 35 / 40 DEFAULT_TIMEOUT=60s / DEFAULT_MAX_RETRIES=3
models/spring-ai-anthropic/.../AnthropicSetup.java 98 / 100 DEFAULT_TIMEOUT=60s / DEFAULT_MAX_RETRIES=2(均 private)
models/spring-ai-bedrock-converse/.../BedrockProxyChatModel.java 865-873 / 915-939 五个超时默认值与 setter

24.15.4 记忆、工具上限与缓存

文件 行 常量 / 方法
spring-ai-model/.../chat/memory/MessageWindowChatMemory.java 45 / 140 DEFAULT_MAX_MESSAGES = 20
memory-repositories/...-redis/.../RedisChatMemoryRepository.java 1004-1008 maxConversationIds=10 / maxMessagesPerConversation=100
memory-repositories/...-redis/.../RedisChatMemoryConfig.java 45 / 162-165 DEFAULT_MAX_RESULTS=1000(被两个字段复用)
spring-ai-model/.../model/tool/DefaultToolCallingManager.java 100 / 107 / 289 / 382 40 / 150 / fallback 三元 / builder 默认
auto-configurations/models/tool/.../ToolCallingProperties.java 40 / 63-70 / 98-121 spring.ai.tools / throwExceptionOnError=false / limits 默认
spring-ai-model/.../chat/metadata/Usage.java 78 / 90 getCacheReadInputTokens() / getCacheWriteInputTokens()(默认 null,@since 2.0.0)

24.15.5 JSON 与结构化输出

文件 行 内容
spring-ai-commons/.../util/JsonHelper.java 全文 新统一入口(可实例化、可注入 JsonMapper)
spring-ai-commons/.../util/JacksonUtils.java --- getDefaultJsonMapper()
spring-ai-commons/.../util/json/JsonParser.java 全文 废弃,全部委托 JsonHelper
spring-ai-model/.../model/ModelOptionsUtils.java --- 8 个 JSON 方法已移除
spring-ai-model/.../chat/prompt/ChatOptions.java 59 / 86 @Nullable List<String> getStopSequences() / mutate()
spring-ai-model/.../chat/prompt/DefaultChatOptions.java 79 / 99 同上实现

24.16 复核命令(可直接复制执行)

全部在仓库根执行:cd /d/CODE/spring-ai-2.0.1

bash 复制代码
# 1) 迁移文档的行号速查
sed -n '5,60p;198,330p' spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc
sed -n '739,860p' spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc
sed -n '1205,1250p;1318,1495p' spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc
sed -n '1608,1625p;2184,2223p' spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc

# 2) 已移除模块:应从 models/ 消失
ls models/
find . -maxdepth 3 -name "*azure-openai*" -o -maxdepth 3 -name "*openai-sdk*" -o -maxdepth 3 -name "*oci-genai*" -o -maxdepth 3 -name "*hanadb*"

# 3) ★ OpenAiApi 不存在(应无输出)
find . -name "OpenAiApi.java" -not -path "*/target/*"

# 4) options 不可变:copy()/fromOptions()/postProcessSchema 应无生产代码命中
grep -rn "\.copy()\|fromOptions(\|postProcessSchema" --include=*.java . | grep -v "/test/" | head

# 5) ★ stopSequences 可空
sed -n '50,95p' spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/ChatOptions.java

# 6) ★ BaseAdvisor 包异常 + 默认调度器
sed -n '40,80p' spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/BaseAdvisor.java

# 7) 四处 boundedElastic
grep -rn "boundedElastic" --include=*.java spring-ai-client-chat/src/main/java/ models/ | grep -v "/test/"

# 8) ★ 工具上限 40/150 与 fallback
grep -n "DEFAULT_MAX_CALLS_PER_TOOL\|DEFAULT_MAX_TOTAL_TOOL_CALLS\|resolutionFallbackEnabled" \
  spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java
sed -n '40,125p' auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/main/java/org/springframework/ai/model/tool/autoconfigure/ToolCallingProperties.java

# 9) ★ temperature 默认值确实没了(chat properties 里搜不到 0.7 的默认赋值)
grep -rn "0\.7" --include=*.java auto-configurations/models/ | grep -i temperature | head

# 10) ★ Usage 缓存方法
sed -n '70,95p' spring-ai-model/src/main/java/org/springframework/ai/chat/metadata/Usage.java

# 11) JSON 层:ModelOptionsUtils 还剩什么、JsonHelper 在哪
grep -n "public static" spring-ai-model/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java | head -20
find . -name "JsonHelper.java" -o -name "JsonSchemaUtils.java" | grep -v target

# 12) AOT 盘点:19 个 registrar / 14 个 aot.factories
grep -rln "implements RuntimeHintsRegistrar" --include=*.java . | grep -v "/test/" | sort
find . -name "aot.factories" | sort
cat spring-ai-model/src/main/resources/META-INF/spring/aot.factories

# 13) ★ @Tool 的 AOT 探测只看 declared methods
sed -n '40,75p' spring-ai-model/src/main/java/org/springframework/ai/aot/ToolBeanRegistrationAotProcessor.java

# 14) ★ 构建期 packet 扫描(Class.forName)
sed -n '60,150p' spring-ai-model/src/main/java/org/springframework/ai/aot/AiRuntimeHints.java

# 15) ★ tokenizer 与 dimensions 资源 hints
sed -n '25,35p' spring-ai-model/src/main/java/org/springframework/ai/aot/KnuddelsRuntimeHints.java
sed -n '95,112p' spring-ai-model/src/main/java/org/springframework/ai/embedding/AbstractEmbeddingModel.java

# 16) 没有 native-image/*.json(应无输出)
find . -path "*native-image*" -name "*.json"

# 17) ★ 零 HealthIndicator(应无输出)
grep -rln "implements HealthIndicator\|extends AbstractHealthIndicator" --include=*.java . | grep -v "/test/"

# 18) ★ 超时与重试常量
grep -n "DEFAULT_TIMEOUT\|DEFAULT_MAX_RETRIES" \
  models/spring-ai-openai/src/main/java/org/springframework/ai/openai/AbstractOpenAiOptions.java \
  models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicSetup.java
sed -n '859,880p' models/spring-ai-bedrock-converse/src/main/java/org/springframework/ai/bedrock/converse/BedrockProxyChatModel.java

# 19) ★ 三个记忆数字
grep -n "DEFAULT_MAX_MESSAGES" spring-ai-model/src/main/java/org/springframework/ai/chat/memory/MessageWindowChatMemory.java
grep -n "maxMessagesPerConversation\|maxConversationIds" \
  memory-repositories/spring-ai-model-chat-memory-repository-redis/src/main/java/org/springframework/ai/chat/memory/repository/redis/RedisChatMemoryRepository.java \
  memory-repositories/spring-ai-model-chat-memory-repository-redis/src/main/java/org/springframework/ai/chat/memory/repository/redis/RedisChatMemoryConfig.java

# 20) ★ Anthropic dispatcherExecutor(虚拟线程入口)
sed -n '1888,1912p' models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java

# 21) ★ OpenAI 音频专用调度器
sed -n '68,82p;160,178p' models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiAudioSpeechModel.java

# 22) 全部章节字数与总量的复核(用于验收 8--10 万/章)
cd spring-ai-deep-dive && for f in 第*.md; do printf "%s\t%s\n" "$(wc -m < "$f")" "$f"; done
cd spring-ai-deep-dive && cat 第*.md | wc -m

24.17 全系列收尾

24.17.1 24 章总表

章 主题 字符数
01 全景:模块拓扑、依赖层次与设计哲学 82,250
02 核心抽象层:Model、StreamingModel 与 ChatModel 体系 99,993
03 消息体系与 Prompt:不可变模型、ChatOptions 与模板渲染 80,598
04 ChatClient 全流程:Builder、两条链路与结构化输出 80,036
05 Advisor 架构:接口体系、栈式链与 context 传播 83,308
06 内置 Advisor 全景:13 个实现逐个剖到源码级 99,527
07 RAG 模块化架构:RetrievalAugmentationAdvisor 流水线与 Query 体系 84,391
08 RAG 组件逐个拆解:实现细节与八个可投产组件 85,307
09 工具调用(上):@Tool 注解、ToolDefinition 与 JSON Schema 生成 82,510
10 工具调用(中):执行链路、结果转换与工具限制 80,084
11 工具调用(下):ToolCallingManager、ToolCallingAdvisor 与调用预算 99,612
12 工具循环(下):流式形态、Usage 累加与扩展体系 83,672
13 MCP 客户端:传输、工具聚合与命名冲突 80,832
14 MCP 服务端(上):注解式实现与工具来源 87,389
15 MCP 服务端(下):三种协议形态、Streamable 传输与状态管理 87,189
16 向量存储(上):VectorStore 接口体系、BatchingStrategy 与 SearchRequest 95,617
17 向量存储(下):实现剖析,从 PgVector 到 S3 Vectors 99,989
18 ETL 与 Embedding:切分、丰富与批处理 99,923
19 Chat Memory:窗口裁剪、JDBC 方言与序列保证 96,096
20 结构化输出:Converter、Schema 与自纠错 99,997
21 可观测性:Micrometer Observation 全体系 99,926
22 Spring Boot 集成:自动配置、Properties 与条件装配 99,084
23 模型供应商实现:从 OpenAiSetup 到 Bedrock 98,897
24 生产实践与 2.x 迁移:陷阱清单、AOT 与调优 8.7 万

★ 全系列 24 章,每章都在 8--10 万字符 区间内(最短 80,036,最长 99,997),总量约 217 万字符。这不是"注水凑数"的结果------每章都有源码行号、可执行复核命令与"不存在的类/方法"清单。

24.17.2 全系列最硬的十二条结论

跨 24 章,如果只记十二条:

  1. ★ ChatModel 只有 7 个实现 (第 23 章),AiProvider 有 10 个值但 SPRING_AI 零引用。
  2. ★ OpenAiApi 不存在 (第 23 章),被 OpenAiSetup 取代;spring-ai-azure-openai / openai-sdk / oci-genai / hanadb-store 全部移除或迁出(第 24 章)。
  3. ★ 26 个 spring.ai.model.* 默认全开 (第 22 章),@ConditionalOnSingleCandidate 使用数为 0 → 多 starter 必然冲突。
  4. ★ 只有 5 处 matchIfMissing=false (第 22 章),全在"多选一"场景;只有 1 处 @ConditionalOnWebApplication(MCP stateful server)。
  5. ★ ChatClient 与 ChatModel 的 options 语义不对称(第 24 章):前者收 Builder 并合并,后者收实例且不合并。
  6. ★ BaseAdvisor 把流式异常包成 IllegalStateException (第 24 章),RetryUtils 则拆包 RetryException------异常类型在两个方向上被变换。
  7. ★ 默认所有 advisor 在 boundedElastic 上(第 24 章),一次流式至少 5 次线程切换;Anthropic 每个流整段占一个 worker。
  8. ★ $defs 三家三种命运 (第 23 章):OpenAI 透传+strict 修正、Anthropic 静默剥离致 $ref 断链、Google 直接抛异常。
  9. ★ 重试是三层(第 22/23/24 章):Spring 10 次 × SDK 3 次 = 最坏 40 次 HTTP、19 分钟以上。
  10. ★ 零 HealthIndicator (第 24 章),模型可用性不进 /actuator/health;总 token 也不进 span/指标(第 21 章)。
  11. ★ @Tool 的 AOT 探测只看 declared methods(第 24 章),父类/接口 default 方法上的工具在 native image 里必然失败。
  12. ★ 2.0.x 六层静默变更 (第 24 章):temperature、strict、fallback、usage、nullable 集合、schema required------都能编译、能启动、能过冒烟。

24.17.3 三种角色的阅读路径

角色 路径 理由
刚接手项目 01 → 02 → 03 → 04 → 22 → 24 先建立"模型-消息-客户端-装配"的主干,再直接看生产与迁移
要写业务功能 04 → 05 → 06 → 09 → 10 → 11 → 20 ChatClient、Advisor、工具调用、结构化输出是日常四件套
要做 RAG 07 → 08 → 16 → 17 → 18 → 19 架构 → 组件 → 存储 → ETL → 记忆
要做 Agent 09 → 10 → 11 → 12 → 06 → 21 工具循环 + 预算 + 观测
要接新供应商 02 → 23 → 22 → 24 抽象层 → 七家实现 → 自动配置 → AOT/生产
要升级 1.x 24 → 22 → 20 先看迁移九层,再看自动配置差异,最后看 schema 变化

24.17.4 源码升级后的复核方法

这套文档的所有结论都绑定了行号,而 Spring AI 的源码会变。升级后按这个顺序复核:

arduino 复制代码
① 先跑每章末尾的"复核命令"------它们不依赖行号,只依赖符号名(grep/find)
② 行号漂移的,用 grep -n "<符号名>" 重新定位
③ 结论是否仍成立,看三件事:
   - 符号是否还存在(不存在 = 结论要改写)
   - 默认值是否变了(常量值 = 结论要更新)
   - 条件注解是否变了(matchIfMissing / ConditionalOn* = 结论可能要反转)
④ 优先复核带 ★ 的结论------它们是"反直觉"的,最容易在版本演进中被修掉

★ 特别提醒:本系列里最脆弱 的几条结论是"数量型"的------"26 个开关""只有 5 处 matchIfMissing=false""19 个 RuntimeHintsRegistrar""7 个 ChatModel""20 处 .provider("。它们在写的时候都经过 grep | wc -l 核对,但任何一次版本升级都可能让数字变化。复核时先跑数字,再看结论。

24.17.5 结语

这套文档试图回答一个问题:Spring AI 2.0.1 到底是怎么工作的,以及它在什么情况下会骗你。

答案可以压缩成三句话:

  1. 它的抽象层很薄 ------ChatModel 只有 call/stream 两个方法,所有复杂度都在"供应商差异"与"advisor 编排"里。
  2. 它的默认值很"友好",但友好不等于安全------26 个开关默认全开、工具 fallback 曾经默认开、temperature 曾经默认 0.7、异常默认不抛。每一个"帮你省事"的默认值,在生产里都是一个需要显式覆盖的决策点。
  3. 它的静默失败比显式失败多 ------$defs 断链、集合变 null、usage 少算、span 断链、AOT hints 漏注册。能编译、能启动、能跑通冒烟测试,从来不是"正确"的证据。

如果这 24 章只能留下一句话,那就是:

在 Spring AI 里,"没有报错"和"做对了"是两件独立的事。

前者由框架保证,后者只能由你的复核命令、你的断言、你的 nativeTest、你的对比测试保证。


相关推荐
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 10 章 工具调用(中):执行链路、结果转换与工具限制
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 13 章 MCP 客户端:传输、工具聚合与命名冲突
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 11 章 工具调用(下):ToolCallingManager、ToolCallingAdvisor与调用预算
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 17 章 向量存储(下):实现剖析,从 PgVector 到 S3 Vectors
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 07 章 RAG 模块化架构:RetrievalAugmentationAdvisor流水线与 Query体系
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 09 章 工具调用(上):`@Tool` 注解、ToolDefinition 与 JSON Schema 生成
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 02 章 核心抽象层:`Model` / `StreamingModel` 与 ChatModel 体系
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 01 章 全景:模块拓扑、依赖层次与设计哲学
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 05 章 Advisor 架构:接口体系、栈式链与 context 传播
java·人工智能·开源