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 本章总纲(先给结论,后面逐条拆到源码)
- ★ 2.0.x 的破坏性变更分九层 :坐标层、类层、属性层、默认值层、集合语义层、行为层、枚举层、异常层、schema 层。其中只有前三层会编译/启动报错,后六层全是静默的------这就是"升级顺利、上线出事"的根因。
- ★ 最容易被漏掉的五条静默变化 :① 默认 temperature
0.7被移除(改用各家原生默认,有的供应商是1.0);②ChatOptions的集合从"空集合"改成null(getStopSequences()现在是@Nullable);③ 工具解析 fallback 默认关闭 (模型点名一个未挂载的工具,直接失败);④ToolCallingAdvisor的Usage变成跨轮累加 (token 统计一夜之间变大);⑤ OpenAI 工具 schema 的strict默认从true变false。 - ★
ChatClient与ChatModel在 options 上是不对称的 :ChatClient.prompt().options(...)只接受ChatOptions.Builder(泛型约束<B extends ChatOptions.Builder<?>>),并在第一个 advisor 之前与模型默认值合并 ;而ChatModel.call(Prompt)必须拿到已 build 的完整实例 ,且完全不合并(非空则原样使用)。 - ★
BaseAdvisor会把流式链上的任何异常包成IllegalStateException("Stream processing failed", ...)(BaseAdvisor:72-73)。这意味着你在最外层catch (WebClientResponseException)抓不到 429/401------它已经变成IllegalStateException,getCause()里才是原始异常。 - ★ 默认所有 Advisor 都在
Schedulers.boundedElastic()上跑 (BaseAdvisor.DEFAULT_SCHEDULER,:44),ChatModelStreamAdvisor:58还是硬编码publishOn(boundedElastic())并留着// TODO add option to disable。流式高并发时,这是第一个被打满的东西。 - ★ Spring AI 一个 Actuator
HealthIndicator都没有 (全仓库implements HealthIndicator命中数 0 )。"模型连不上"不会体现在/actuator/health里,你必须自己写。 - ★ AOT 不是"开了就行" :全仓库 19 个
RuntimeHintsRegistrar、14 个aot.factories,但核心 hints 用MemberCategory.values()全量注册 (SpringAiCoreRuntimeHints:45-53),而@Tool的 AOT 探测只看getDeclaredMethods(beanClass)(ToolBeanRegistrationAotProcessor:48-51)------父类上的@Tool不会被发现 ,原生镜像里必然NoSuchMethodException。 - ★
AiRuntimeHints在 AOT 构建期做 classpath 扫描 (ClassPathScanningCandidateComponentProvider+Class.forName),扫的是"带 Jackson 注解"的类。这是"构建期反射",在 native 构建机上缺类会直接抛RuntimeException------和运行期反射失败是两种不同的故障。 - ★ 超时与重试是三层叠加 :Spring
RetryTemplate(10 次、最坏约 19 分钟)+ SDK 自带重试(OpenAI 3 次 / Anthropic 2 次)+ AWS SDK 重试。三层都开时,一次故障的实际耗时是三层相乘------这是"网关都超时了,你的服务还在重试"的原因。 - ★
MessageWindowChatMemory默认只保留 20 条消息 (MessageWindowChatMemory:45),而 Redis 仓储默认maxMessagesPerConversation=100(RedisChatMemoryRepository:1008)------内存策略 与存储上限是两个不同的数,长对话会被静默截断两次。 - 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 是一个"减法版本"。 - 本章最终交付一张可执行的生产检查清单(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):
- 旧 artifact 仍然存在 ,通过传递依赖指向新模块------所以你什么都不改也能跑。
- 旧
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。三件事一起发生:
MessageChatMemoryAdvisor/VectorStoreChatMemoryAdvisor的 conversationId 不再可选 ;缺失或null→ 立刻IllegalArgumentException。ChatMemory.DEFAULT_CONVERSATION_ID(值"default")被移除。- 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 把它变成硬失败,是在逼你面对这个问题。
★ 但迁移时要小心两处:
getConversationId(Map, String)变成了getConversationId(Map)(:1400-1410)。自定义 advisor 里覆写/调用两参版本的,编译不过;改成一参之后,context 里没有CONVERSATION_ID就会抛异常------所以"先确保 context 有值,再调用"。- ★
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() 挪到弹性线程池上,避免阻塞调用方线程。代价是:
- 线程本地变量(ThreadLocal)在 advisor 之间会丢 ------
publishOn之后是另一个线程。所以 MDC、SecurityContext、RequestContextHolder在 advisor 的after()里可能读不到。这是 21 章"观测上下文靠 Reactor Context 传,不靠 ThreadLocal"的另一面。 - 高并发流式会打满
boundedElastic。每个流在subscribeOn/publishOn期间都占一个 worker(尤其 Anthropic 的subscribeOn(boundedElastic)在整个流期间不放,见 23.5.2 缺陷二)。 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;
★ 三个必须知道的点:
- 默认值很宽松,但不是无限 。150 次总调用在"批量处理 + 每行一次工具"的场景里是够呛的。迁移时如果你的 agent 会循环处理 N 条数据,
N > 150就会在第 151 次被拒。 -1表示无限 (ToolCallingProperties:98-99的 Javadoc:"Set to-1...")。编程式入口是ToolCallingManager.builder().maxCallsPerTool(...)/.unlimitedCallsPerTool()。- ★ 超限不会抛异常,而是返回一条错误结果给模型 ------模型看到的是"工具调用被拒绝",于是它可能换个方式重试,形成"被拒 → 再试 → 再被拒"的循环,直到 150 次总上限。日志里不会有一条"你超限了"的 ERROR,只有模型的输出变得很奇怪。
24.5.2 ★ 工具解析 fallback 默认关闭
upgrade-notes.adoc:305-330:
之前,当模型返回一个没有挂到本次请求 上的工具调用时,
DefaultToolCallingManager会回退到应用级ToolCallbackResolver(背后是容器里所有ToolCallbackbean)去解析并执行。从 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:所有
@Toolbean 都在容器里,模型随便点一个都能执行 → "工具没挂载也能跑"成了隐性契约。 - 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 结果回给模型,模型"自己想办法"。这在生产里有两个后果:
- 监控看不到工具失败率 。你的
try/catch不会触发,指标里也没有 error,只有模型输出变得"奇怪"(它开始道歉、换个工具、或者编一个答案)。 - 模型可能把错误当成数据 。比如
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-javaSDK。迁移应当无缝,不期待重大破坏性变更 。所有属性(spring.ai.openai.*前缀)、builder、options 完全保留。现有的extraBody配置参数透明地映射 到底层openai-javaSDK 的additionalBodyProperties。
★ "透明映射"是这一节唯一需要记住的技术点:
| Spring AI 侧 | SDK 侧 |
|---|---|
extraBody(OpenAiChatOptions / 属性) |
additionalBodyProperties |
★ 但"透明"有三个边界,文档没说,源码能看出来:
OpenAiApi不存在了 (23.3.1)。所有new OpenAiApi(...)的代码必须改成OpenAiSetup.setupSyncClient(...)(17 参数)或走自动配置。这是"编译不过"的硬边界。OpenAiSetup的 no-auth 模式 (23.3.3):apiKey 为空时不再报错,而是用占位 key + 摘掉Authorization头。1.x 里"忘了配 key"会启动失败,2.x 里会一路跑到 401。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 而消失,反而更明确了:
- HTTP span 挂不上
gen_ai.client.operation(AnthropicChatModel:266-285的 Javadoc 自己写着); 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)
★★ 这是本章唯一一处官方明确建议用虚拟线程的地方。注意三个细节:
- executor 的生命周期归你 ------Spring AI 不会 shutdown 它。所以你要么用 Spring 管理的
ExecutorServicebean,要么在@PreDestroy里自己关。 - 它影响的是 OkHttp dispatcher (同步 + 异步两个客户端),不是 Reactor 的调度器。所以它不能 解决 23.5.2 缺陷二(
boundedElastic被占),只能让"HTTP 分发"这一层不再吃平台线程。 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;
}
}
★ 两个细节:
- 扩展点从
void postProcessSchema(JsonNode)变成String generateSchema()------你要自己解析/序列化,或者纯字符串处理。 - 覆盖
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 请求/响应类型 |
★ 两个反直觉的观察:
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 的代码里看到。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 冒烟测试如果没覆盖到"父类工具",就发现不了。
三种补救方式,按侵入性从低到高:
- 在子类里覆写该方法 (哪怕只写
super.xxx())------getDeclaredMethods就能看到它,于是整类被注册; - 在子类上加
@RegisterReflectionForBinding(ParentToolClass.class)(Spring 的注解,直接注册反射); - 自己的
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);
}
★ 三个关键点:
Class.forName在构建期执行 。这不是"读字节码",是真的把类加载进 JVM ------所以构建机的 classpath 必须完整(缺一个传递依赖就RuntimeException)。- 扫描用的是
ClassPathScanningCandidateComponentProvider(false)(findClassesInPackage:96-108,false= 不注册默认过滤器),只加一个 include filter。也就是说它只扫packageName下的类 ,不含子包?------findCandidateComponents默认包含子包(Spring 的resolveBasePackage+**/*.class语义),所以是递归的。 - 过滤条件是"有
@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" 零命中。含义有三层:
- 所有 hints 都是"活代码" ,随版本演进、可被 IDE 索引、可被单测覆盖(
RuntimeHints有RuntimeHintsPredicates可断言); - 你不能"打补丁" ------想加一条反射注册,只能改代码重新构建,不能丢一个 JSON 进
META-INF/native-image/; - 第三方库的 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 |
★ 三个必须自己做的事:
- 没有统一的
spring.ai.timeout。每个供应商的超时要分别配(OpenAI/Anthropic 走spring.ai.<provider>.chat.timeout之类,Bedrock 走 Builder 的五个字段)。 spring.ai.retry.*只管 Spring Retry 那一层 ,对 OpenAI/Anthropic/Bedrock 完全无效(22.18 的结论)。- ★ 超时要配在"最外层" :你的 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)。这意味着:
- 长对话会被截断两次:先进 prompt 时被窗口截到 20 条;存储里再被截到 100(或 1000)条。
- "为什么我的 AI 忘了昨天说的话" 有两个可能原因:窗口 20 条太小,或者存储 100 条被裁了。
- 两个 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):
- prompt caching(Anthropic 4 断点 / Bedrock 最后一条 user / Google cachedContent):对"长 system prompt + 短 user 输入"的场景,能省 80%+ 的输入 token;
- 窗口裁剪(24.10.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 小结
- ★ 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全部删除。 - ★ 破坏性变更分九层,六层静默:坐标/类/属性三层会报错;默认值/集合语义/行为/枚举/异常/schema 六层不会。迁移验收不能是"能启动",必须是"关键路径输出与 1.x 对比"。
- ★ 五条最容易漏的静默变化 :temperature
0.7移除、strict默认false、工具 fallback 默认关闭、usage 跨轮累加、options 集合变null。 - ★
ChatClient与ChatModel在 options 上不对称 :前者收Builder并与默认值合并(在第一个 advisor 之前),后者收完整实例且完全不合并。 - ★
BaseAdvisor把所有流式异常包成IllegalStateException("Stream processing failed")(:72-73),与RetryUtils的"拆包"方向相反------生产错误分类必须按getCause()递归找根因。 - ★ 默认所有 advisor 都在
boundedElastic上 (BaseAdvisor:44),一次带记忆+工具的流式调用至少 5 次线程切换;ChatModelStreamAdvisor:58的// TODO add option to disable说明官方已知这个问题。 - ThreadLocal 在 advisor 之间会丢(MDC / SecurityContext / RequestContextHolder),只能靠 Reactor Context 或 Micrometer Context Propagation。
- ★ 工具调用有三个新上限/开关 :40 次/工具、150 次/轮(
DefaultToolCallingManager:100/107)、fallback 默认关(:289)、throwExceptionOnError默认false(ToolCallingProperties:69)。 - ★
@McpTool异常分派改为类型驱动 :RuntimeException→ 回给模型;checked →UndeclaredThrowableException;Error/McpError→ 原样冒泡;toolCallExceptionClass被忽略。 - ★ JSON 层被
JsonHelper统一 :JsonParser废弃、McpJsonParser删除、ModelOptionsUtils的 8 个 JSON 方法移除;getJsonSchema搬到JsonSchemaUtils。 - ★
BeanOutputConverter的required规则变了三处 :Kotlin 可选参数、@JsonProperty(required=false)、@JsonProperty未写required------ 三者都不再进required。 - ★ 15 个模型模块只有 6 个有 AOT hints (bedrock/deepseek/elevenlabs/google/mistral/ollama),
spring-ai-openai与spring-ai-anthropic零 registrar------native 兼容性取决于官方 SDK。 - ★
@Tool的 AOT 探测只看getDeclaredMethods(beanClass),父类/接口 default 方法上的@Tool不会被发现 → native 里NoSuchMethodException。 - ★
AiRuntimeHints在构建期Class.forName+ 包扫描 ,且hasJacksonAnnotations不递归父类 → "继承来的 Jackson 注解"是盲区。 - ★ 资源 hints 比反射 hints 更容易漏 :tokenizer 词表(
cl100k_base.tiktoken)缺失的报错是一个莫名的 NPE;维度表缺失甚至不报错,只是变慢+花钱。 - ★ 超时没有全局配置 :OpenAI 60s/3、Anthropic 60s/2、Bedrock 5m+5s+30s+30s+30s;
spring.ai.retry.*只管 Spring Retry 层。 - ★ 三层重试叠加最坏 40 次 HTTP、19 分钟以上;推荐"只留 Spring 层",因为只有那层可被 Micrometer 观测。
- ★ 三个记忆数字 :窗口 20(
MessageWindowChatMemory:45)、Redis 仓储 100(:1008)、Redis 配置 1000(RedisChatMemoryConfig:45/165)------两个 Redis builder 默认值不一致。 - ★ 零 HealthIndicator :模型可用性不进
/actuator/health;自建时要防"探针变成计费调用"。 - ★ 缓存 token 有了统一接口 :
Usage.getCacheReadInputTokens()/getCacheWriteInputTokens()(:78/:90,默认null),Anthropic/Bedrock 双向、OpenAI/Google 只读。
24.14 思考题
- 为什么 2.0.x 的九层破坏性变更里,只有三层会报错?请从"Java 的类型系统能检查什么、不能检查什么"的角度解释,并说明为什么"能启动"是一个不合格的迁移验收标准。
- 默认 temperature 从
0.7变成"供应商原生默认",对以下三类应用分别意味着什么:① 结构化输出;② 创意写作;③ 分类/打标。哪一类最危险,为什么? ChatClient.prompt().options(Builder)会与默认值合并,而ChatModel.call(Prompt)不会。请设计一个实验证明这个差异,并说明为什么 Spring AI 要保留这种不对称(提示:ChatModel是最底层接口,ChatClient是门面)。- ★
BaseAdvisor用onErrorResume把异常包成IllegalStateException。请写出一个能正确区分 429、401、超时三种情况的catch结构,并说明为什么"按最外层异常类型分类"在监控里会产生误导。 - 为什么
BaseAdvisor要在流式链上publishOn(boundedElastic())?如果不这么做,before()里的阻塞代码会跑在哪个线程上?这个设计对"响应式"意味着什么? - ★ 工具解析 fallback 默认关闭,从"安全"与"兼容"两个角度看分别是对是错?请给出一个必须打开 fallback 的合法场景,并说明如何在不打开 fallback 的前提下实现同样的效果。
ToolCallingAdvisor的 usage 从"最后一次"变成"累加",为什么文档说"no API changes are required"却仍是一个破坏性变更?请举出两个会被它影响的系统(提示:计费、限流)。- ★
@Tool的 AOT 探测只看getDeclaredMethods(beanClass)。请构造一个"在 JVM 上完全正常、在 native image 里必然失败"的最小例子,并给出三种修法及其代价。 AiRuntimeHints.hasJacksonAnnotations不递归父类。请说明这会造成什么后果,并设计一个测试用例来暴露它(提示:DTO 继承 + nativeTest)。- 为什么 tokenizer 词表要以"资源 hint"注册,而不是打成 Java 类常量?如果你忘了注册,会在哪个调用路径上炸、报什么错?
- ★ 三层重试叠加后最坏是 40 次 HTTP。请计算"只留 Spring 层"与"只留 SDK 层"两种方案下的最坏 HTTP 次数与最坏耗时,并说明为什么"可观测性"应该成为选型的决定因素。
MessageWindowChatMemory的 20 与 Redis 的 100/1000 是两次独立截断。请设计一个"摘要 + 窗口"的组合策略,使得长对话既不丢关键信息、又不让 token 爆炸。- ★ 零
HealthIndicator意味着/actuator/health永远 UP。请写一个"不花钱"的健康检查(不发模型请求),并说明它能覆盖什么、不能覆盖什么。 - 综合本章:如果你要把一个 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 章,如果只记十二条:
- ★
ChatModel只有 7 个实现 (第 23 章),AiProvider有 10 个值但SPRING_AI零引用。 - ★
OpenAiApi不存在 (第 23 章),被OpenAiSetup取代;spring-ai-azure-openai/openai-sdk/oci-genai/hanadb-store全部移除或迁出(第 24 章)。 - ★ 26 个
spring.ai.model.*默认全开 (第 22 章),@ConditionalOnSingleCandidate使用数为 0 → 多 starter 必然冲突。 - ★ 只有 5 处
matchIfMissing=false(第 22 章),全在"多选一"场景;只有 1 处@ConditionalOnWebApplication(MCP stateful server)。 - ★
ChatClient与ChatModel的 options 语义不对称(第 24 章):前者收 Builder 并合并,后者收实例且不合并。 - ★
BaseAdvisor把流式异常包成IllegalStateException(第 24 章),RetryUtils则拆包RetryException------异常类型在两个方向上被变换。 - ★ 默认所有 advisor 在
boundedElastic上(第 24 章),一次流式至少 5 次线程切换;Anthropic 每个流整段占一个 worker。 - ★
$defs三家三种命运 (第 23 章):OpenAI 透传+strict 修正、Anthropic 静默剥离致$ref断链、Google 直接抛异常。 - ★ 重试是三层(第 22/23/24 章):Spring 10 次 × SDK 3 次 = 最坏 40 次 HTTP、19 分钟以上。
- ★ 零 HealthIndicator (第 24 章),模型可用性不进
/actuator/health;总 token 也不进 span/指标(第 21 章)。 - ★
@Tool的 AOT 探测只看 declared methods(第 24 章),父类/接口 default 方法上的工具在 native image 里必然失败。 - ★ 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 到底是怎么工作的,以及它在什么情况下会骗你。
答案可以压缩成三句话:
- 它的抽象层很薄 ------
ChatModel只有call/stream两个方法,所有复杂度都在"供应商差异"与"advisor 编排"里。 - 它的默认值很"友好",但友好不等于安全------26 个开关默认全开、工具 fallback 曾经默认开、temperature 曾经默认 0.7、异常默认不抛。每一个"帮你省事"的默认值,在生产里都是一个需要显式覆盖的决策点。
- 它的静默失败比显式失败多 ------
$defs断链、集合变 null、usage 少算、span 断链、AOT hints 漏注册。能编译、能启动、能跑通冒烟测试,从来不是"正确"的证据。
如果这 24 章只能留下一句话,那就是:
在 Spring AI 里,"没有报错"和"做对了"是两件独立的事。
前者由框架保证,后者只能由你的复核命令、你的断言、你的 nativeTest、你的对比测试保证。