Spring AI 2.0.1 深度解析-第 23 章 模型供应商实现:从 OpenAiSetup 到 Bedrock
本章回答第 22 章留下的问题:bean 里到底装的是什么 。第 22 章讲"配置怎么变成 bean",本章讲"每个供应商的
ChatModel内部到底怎么发请求、怎么处理工具 schema、怎么做重试与埋点"。本章全部结论都在
spring-ai-2.0.1上实测复核过。凡源码中不存在 的类(例如OpenAiApi)会明确点出。
23.0 本章总纲(先给结论,后面逐条拆到源码)
- 全仓库只有 7 个
ChatModel实现 ,但AiProvider枚举有 10 个值------ONNX/SPRING_AI/VERTEX_AI三个没有对应的 ChatModel(只有 embedding)。 - ★
OpenAiApi在 2.0.1 里已经不存在了 。2.x 把 OpenAI 的 HTTP 层整个换成了官方 SDKcom.openai.client.OpenAIClient,取而代之的是一个final class OpenAiSetup。 - 供应商分成两大阵营 :官方 SDK 派 (OpenAI / Anthropic / Google GenAI / Bedrock)与自研 Api 派(DeepSeek / Mistral / Ollama / ElevenLabs / Stability)。
- ★ 两大阵营的重试路径完全不同 :SDK 派用 SDK 自带的
maxRetries(OpenAI 默认 3、Anthropic 默认 2),自研派用 Spring 的RetryTemplate(spring.ai.retry.*,默认 10 次)------第 22 章 22.5 的"两套重试"在这里落到具体代码。 - ★
$defs有三家三种处理 :OpenAI 全量透传 (还能递归修正$defs)、Anthropic 静默剥离 ($ref断链)、Google GenAI 直接抛异常 。这是第 20 章"Anthropic 剥掉$schema/$defs"的完整版。 - ★ provider 标识有三套写法 :
AiProvider.X.value()、XxxConstants.PROVIDER_NAME、MistralAiApi.PROVIDER_NAME;连 Google 自己两个模块都不一致。 - Anthropic 的流式 Javadoc 自己写明了两个缺陷 :HTTP span 不挂在
gen_ai.client.operation下、且会占住一个boundedElastic线程。 - Bedrock 是唯一需要两个 AWS bean 同时存在 才生效的供应商(
@ConditionalOnBean({AwsCredentialsProvider, AwsRegionProvider})),缺一个就静默不注册 ------而且它有两个模块,只有bedrock-converse里有ChatModel。
23.1 全景:7 个 ChatModel、15 个模型模块、10 个 AiProvider
23.1.1 ★ 只有 7 个类实现了 ChatModel
bash
grep -rln "implements ChatModel\b" --include=*.java models/
| 类 | 模块 | 行数 | 客户端 |
|---|---|---|---|
AnthropicChatModel |
spring-ai-anthropic |
1961 | com.anthropic.client.AnthropicClient |
OpenAiChatModel |
spring-ai-openai |
1637 | com.openai.client.OpenAIClient |
GoogleGenAiChatModel |
spring-ai-google-genai |
1230 | com.google.genai.Client |
BedrockProxyChatModel |
spring-ai-bedrock-converse |
1035 | software.amazon.awssdk...BedrockRuntimeClient |
MistralAiChatModel |
spring-ai-mistral-ai |
615 | 自研 MistralAiApi(RestClient + WebClient) |
DeepSeekChatModel |
spring-ai-deepseek |
586 | 自研 DeepSeekApi(RestClient + WebClient) |
OllamaChatModel |
spring-ai-ollama |
570 | 自研 OllamaApi(RestClient + WebClient) |
★ 三个观察:
(1)代码量与"官方 SDK 化"正相关。 换官方 SDK 的两家(Anthropic 1961、OpenAI 1637)是最大的两个类------因为 SDK 的模型类(MessageCreateParams、ChatCompletionCreateParams)字段极多,转换代码爆炸式增长;而自研 Api 的三家都在 600 行上下。
(2)★ 没有 VertexAiChatModel,也没有 ElevenLabsChatModel。 models/ 下有 15 个模块,但只有上面 7 个做了 chat。
(3)spring-ai-bedrock 与 spring-ai-bedrock-converse 是两个模块 ,前者只有 embedding (BedrockCohereEmbeddingModel / BedrockTitanEmbeddingModel + MessageToPromptConverter),chat 在后者。★ 老 bedrock 模块里连一个 ChatModel 都没有------它是 1.x 的 "invoke API" 遗留。
23.1.2 AiProvider 有 10 个值,但只有 7 个有 chat
java
// AiProvider.java(spring-ai-model)
public enum AiProvider {
ANTHROPIC("anthropic"),
BEDROCK_CONVERSE("bedrock_converse"),
DEEPSEEK("deepseek"),
GOOGLE_GENAI_AI("google_genai"),
MISTRAL_AI("mistral_ai"),
OLLAMA("ollama"),
ONNX("onnx"),
OPENAI("openai"),
SPRING_AI("spring_ai"),
VERTEX_AI("vertex_ai");
}
AiProvider 值 |
有 ChatModel? |
实际使用者 |
|---|---|---|
OPENAI |
✔ | OpenAiChatModel / Embedding / Image |
ANTHROPIC |
✔ | AnthropicChatModel |
BEDROCK_CONVERSE |
✔ | BedrockProxyChatModel |
DEEPSEEK |
✔ | DeepSeekChatModel |
GOOGLE_GENAI_AI |
✔ | GoogleGenAiChatModel(+ Embedding / Image 模块) |
MISTRAL_AI |
✔ | MistralAiChatModel |
OLLAMA |
✔ | OllamaChatModel |
ONNX |
✘ | TransformersEmbeddingModel |
VERTEX_AI |
✘ | VertexAiTextEmbeddingModel |
SPRING_AI |
✘ | 全仓库零引用 |
★ SPRING_AI("spring_ai") 与 ONNX 的差别:ONNX 至少被 TransformersEmbeddingModel:320 用了;SPRING_AI 在整个 models/ 下没有任何 .provider(AiProvider.SPRING_AI...) 调用。它是给"用户自己写的模型"预留的枚举值。
23.1.3 15 个模型模块的分组
arduino
models/
├── 官方 SDK 派(4 个 chat + 3 个非 chat)
│ ├── spring-ai-openai OpenAIClient
│ ├── spring-ai-anthropic AnthropicClient
│ ├── spring-ai-google-genai com.google.genai.Client
│ ├── spring-ai-bedrock-converse BedrockRuntimeClient
│ ├── spring-ai-google-genai-embedding
│ ├── spring-ai-google-genai-image
│ └── spring-ai-vertex-ai-embedding
├── 自研 Api 派(3 个 chat + 2 个非 chat)
│ ├── spring-ai-deepseek DeepSeekApi
│ ├── spring-ai-mistral-ai MistralAiApi(+ MistralOcrApi / ModerationApi)
│ ├── spring-ai-ollama OllamaApi
│ ├── spring-ai-elevenlabs ElevenLabsApi(音频)
│ └── spring-ai-stability-ai StabilityAiApi(图像)
└── 其它
├── spring-ai-bedrock AbstractBedrockApi(老 invoke API,仅 embedding)
├── spring-ai-postgresml-embedding
└── spring-ai-transformers ONNX
23.2 ★ 两大阵营:HTTP 层到底是谁的
第 22 章讲过"两套重试",但没解释它为什么存在。根因就是这里的阵营划分。
23.2.1 一眼可辨的三条判据
| 判据 | 官方 SDK 派 | 自研 Api 派 |
|---|---|---|
有无 *Api.java |
0 个 | 1--3 个(DeepSeekApi 1251 行 / MistralAiApi 1775 行 / OllamaApi 836 行) |
是否 import RestClient |
基本没有(OpenAI 0、Google 0) | 有,且同时持有 RestClient 与 WebClient 两份 |
| 重试在哪 | SDK 的 ClientOptions.maxRetries(n) |
Spring RetryTemplate(RetryUtils.execute) |
bash
# OpenAI / Google:完全没有自研 Api,也没有 RestClient
find models/spring-ai-openai/src/main -name '*Api.java' # 0
grep -rl 'RestClient' --include=*.java models/spring-ai-openai/src/main # 0
# Anthropic 唯一一处 "RestClient" 出现在注释里(1.x 实现的遗迹)
grep -rn "RestClient" --include=*.java models/spring-ai-anthropic/src/main
# AnthropicSkillsResponseHelper.java:46
# * Unlike the RestClient module's helper which requires recursive Map/List crawling to
★ Anthropic 那处注释是考古现场 :代码已经全换成 com.anthropic.* SDK 了,但注释还留着"不像 RestClient 模块的 helper"这样的对比描述。
23.2.2 自研 Api 派的双客户端模式
三家自研 Api 的构造完全同构------同时持有 RestClient(同步)与 WebClient(流式):
java
// MistralAiApi.java:110-131
public MistralAiApi(String baseUrl, String apiKey, RestClient.Builder restClientBuilder,
WebClient.Builder webClientBuilder, ResponseErrorHandler responseErrorHandler) {
Consumer<HttpHeaders> defaultHeaders = headers -> {
headers.setBearerAuth(apiKey);
headers.setContentType(MediaType.APPLICATION_JSON);
};
this.restClient = restClientBuilder.clone()
.baseUrl(baseUrl)
.defaultHeaders(defaultHeaders)
.defaultStatusHandler(responseErrorHandler) // ★ 只有 RestClient 装了 error handler
.build();
this.webClient = webClientBuilder.clone()
.baseUrl(baseUrl)
.defaultHeaders(defaultHeaders)
.build(); // ★ WebClient 没有
}
★ 注意 .defaultStatusHandler(responseErrorHandler) 只挂在 RestClient 上。也就是说流式路径的错误处理与同步路径不一致 ------同步走 ResponseErrorHandler(会按 4xx/5xx 抛 NonTransientAiException / TransientAiException,从而驱动 RetryTemplate),流式不装。
java
// DeepSeekApi.java:86-108(同构)
public DeepSeekApi(String baseUrl, ApiKey apiKey, HttpHeaders headers, String completionsPath,
String betaPrefixPath, RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, ...) {
...
this.restClient = restClientBuilder.clone()...build();
this.webClient = webClientBuilder.clone().baseUrl(baseUrl).defaultHeaders(finalHeaders).build();
}
23.2.3 ★ 重试落点:SDK 派 vs 自研派
| 供应商 | 重试载体 | 默认次数 | 配置项 |
|---|---|---|---|
| OpenAI | ClientOptions.maxRetries |
3 | spring.ai.openai.max-retries |
| Anthropic | ClientOptions.maxRetries |
2 | AnthropicSetup.DEFAULT_MAX_RETRIES |
| Google GenAI | SDK 内部 | --- | SDK 决定 |
| Bedrock | AWS SDK 内部 | --- | AWS SDK 决定 |
| DeepSeek | RetryTemplate |
10 | spring.ai.retry.* |
| Mistral | RetryTemplate |
10 | spring.ai.retry.* |
| Ollama | RetryTemplate |
10 | spring.ai.retry.* |
自研派统一走这一个工具方法:
java
// RetryUtils.java:170-178
public static <R extends @Nullable Object> R execute(RetryTemplate retryTemplate, Retryable<R> retryable) {
try {
return retryTemplate.execute(retryable);
}
catch (RetryException e) {
throw (e.getCause() instanceof RuntimeException runtime) ? runtime
: new RuntimeException(e.getMessage(), e.getCause());
}
}
★ 注意这个 catch:重试耗尽后,它把 RetryException 拆包成原始 cause(如果是 RuntimeException)再抛 。这意味着上层(比如 Advisor 或你自己写的 try/catch)看不到"这是重试失败"这一信息 ,只能看到最内层那个异常。第 22 章 22.5 说 RetryListener 的 AtomicInteger 全局累加、日志里 "Retry count:N" 不准------加上这里的拆包,自研派的重试可观测性基本为零。
三个调用点:
java
// OllamaChatModel.java:236-237
OllamaApi.ChatResponse ollamaResponse = RetryUtils.execute(this.retryTemplate,
() -> this.chatApi.chat(request));
// MistralAiChatModel.java:189-190
ResponseEntity<ChatCompletion> completionEntity = RetryUtils.execute(this.retryTemplate,
() -> this.mistralAiApi.chatCompletionEntity(request));
// MistralAiChatModel.java:252(流式也包了一层)
Flux<ChatCompletionChunk> completionChunks = RetryUtils.execute(this.retryTemplate, ...);
★ 第三个尤其值得注意:流式调用也被 RetryTemplate 包住 。但 RetryTemplate 重试的是"建立 Flux"这个动作,而不是"重放整个流"------一旦流已经开始、中途失败,重试只会重新订阅一次(对已经消费过的数据没有保护)。
23.3 ★ OpenAI(一):OpenAiApi 去哪了
23.3.1 ★ 它不存在
bash
find . -name "OpenAiApi.java"
# (无任何输出)
2.0.1 里没有 OpenAiApi 。1.x 里那个手写 RestClient 调用 /v1/chat/completions 的类已经被完全移除,取而代之的是:
arduino
models/spring-ai-openai/src/main/java/org/springframework/ai/openai/
├── setup/
│ ├── OpenAiSetup.java ← 客户端工厂(final class,private 构造器)
│ ├── AzureInternalOpenAiHelper.java
│ └── package-info.java
├── http/okhttp/
│ └── SpringAiOpenAiHttpClient.java ← 带 Micrometer 埋点的 OkHttp 客户端
└── metadata/ ← 5 个响应元数据类
★ 依赖也变了:models/spring-ai-openai/pom.xml 引入了 com.openai:openai-java-core(以及 Azure 变体)。所以第 22 章 22.5.4 说 "OpenAI 不走 spring.ai.retry.*" 的根因在这里------HTTP 客户端根本不是 Spring 的了 ,Spring 的 RetryTemplate / ResponseErrorHandler 自然接不上。
23.3.2 OpenAiSetup:17 个参数的客户端工厂
java
// OpenAiSetup.java:55-62
public final class OpenAiSetup {
static final String OPENAI_URL = "https://api.openai.com/v1";
static final String OPENAI_API_KEY = "OPENAI_API_KEY";
static final String MICROSOFT_FOUNDRY_API_KEY = "MICROSOFT_FOUNDRY_API_KEY";
static final String GITHUB_MODELS_URL = "https://models.github.ai/inference";
static final String GITHUB_TOKEN = "GITHUB_TOKEN";
static final String DEFAULT_USER_AGENT = "spring-ai-openai";
private OpenAiSetup() {
}
java
// OpenAiSetup.java:97-102
public static OpenAIClient setupSyncClient(@Nullable String baseUrl, @Nullable String apiKey,
@Nullable Credential credential, @Nullable String azureDeploymentName,
@Nullable AzureOpenAIServiceVersion azureOpenAiServiceVersion, @Nullable String organizationId,
boolean isAzure, boolean isGitHubModels, @Nullable String modelName, Duration timeout, int maxRetries,
@Nullable Proxy proxy, @Nullable Map<String, String> customHeaders, ObservationRegistry observationRegistry,
@Nullable MeterRegistry meterRegistry, List<OpenAiHttpClientBuilderCustomizer> httpClientCustomizers) {
17 个参数、两个重载(setupSyncClient / setupAsyncClient),两个分支(正常 / no-auth)。
23.3.3 ★ no-auth 模式:一个占位 key + 一个 OkHttp 拦截器
这是整个类里最精巧、也最容易踩坑的一段。
java
// OpenAiSetup.java:64-73
/**
* Placeholder API key used internally when no-auth mode is requested (empty apiKey /
* {@link org.springframework.ai.model.NoopApiKey}). The OpenAI Java SDK requires a
* non-empty string to pass its credential validation at both construction and
* per-request time --- an empty string causes an {@link IllegalStateException} in
* {@code securityHeaders()}. The OkHttp interceptor registered in
* {@link #buildNoAuthClientOptions} guarantees this placeholder value is stripped
* from the {@code Authorization} header before any request leaves the JVM.
*/
private static final String NO_AUTH_PLACEHOLDER_KEY = "no-auth-placeholder";
java
// OpenAiSetup.java:108-116
String calculatedApiKey = apiKey != null ? apiKey : detectApiKey(modelProvider);
if (calculatedApiKey != null && calculatedApiKey.isEmpty()) {
// No-auth mode: build the client directly via ClientOptions so we can inject
// a custom HttpClient that strips the Authorization header before sending requests.
return new OpenAIClientImpl(buildNoAuthClientOptions(baseUrl, modelProvider, modelName, azureDeploymentName,
azureOpenAiServiceVersion, organizationId, timeout, maxRetries, proxy, customHeaders,
observationRegistry, meterRegistry, httpClientCustomizers));
}
java
// OpenAiSetup.java:228-240(★ 拦截器在 customizer 之前装)
// No API Key defined, so remove the mandatory "Authorization" header.
httpBuilder
.interceptor(chain -> chain.proceed(chain.request().newBuilder().removeHeader("Authorization").build()));
for (OpenAiHttpClientBuilderCustomizer customizer : httpClientCustomizers) {
customizer.customize(httpBuilder);
}
...
.apiKey(NO_AUTH_PLACEHOLDER_KEY)
★ 为什么必须这么绕?Javadoc 说得很清楚:官方 SDK 在构造时和每次请求时都会校验 credential 非空 ,空串会在 securityHeaders() 抛 IllegalStateException。所以 Spring AI 只能"骗"它一个占位串,再用 OkHttp 拦截器在请求出门前把 Authorization 头摘掉。
★ 三个陷阱:
- 触发条件极窄 :必须
calculatedApiKey != null && isEmpty()。也就是说spring.ai.openai.api-key=(空值)能触发,但不配api-key且环境变量也没有 时calculatedApiKey == null,走的是credential/azureAuthentication()分支------不会进 no-auth 模式。 - 拦截器装在 customizer 之前 (
:229vs:232)。如果你自己注册了一个OpenAiHttpClientBuilderCustomizer去加Authorization头,它会在拦截器之后执行 → 你加的头会留下来,而 SDK 加的头会被摘掉。顺序是反直觉的。 - 这个模式只对"自建的 OpenAI 兼容服务"有意义(Javadoc 明确说 "custom OpenAI-compatible servers that use cookie-based or other non-bearer-token authentication")。
23.3.4 ★ detectModelProvider 四级判定与"你的 baseUrl 被丢掉"
java
// OpenAiSetup.java:278-302
public static ModelProvider detectModelProvider(boolean isMicrosoftFoundry, boolean isGitHubModels,
@Nullable String baseUrl, @Nullable String azureDeploymentName,
@Nullable AzureOpenAIServiceVersion azureOpenAIServiceVersion) {
if (isMicrosoftFoundry) {
return ModelProvider.MICROSOFT_FOUNDRY; // Forced by the user
}
if (isGitHubModels) {
return ModelProvider.GITHUB_MODELS; // Forced by the user
}
if (baseUrl != null) {
if (baseUrl.endsWith("openai.azure.com") || baseUrl.endsWith("openai.azure.com/")
|| baseUrl.endsWith("cognitiveservices.azure.com")
|| baseUrl.endsWith("cognitiveservices.azure.com/")) {
return ModelProvider.MICROSOFT_FOUNDRY;
}
else if (baseUrl.startsWith(GITHUB_MODELS_URL)) {
return ModelProvider.GITHUB_MODELS;
}
}
if (azureDeploymentName != null || azureOpenAIServiceVersion != null) {
return ModelProvider.MICROSOFT_FOUNDRY;
}
return ModelProvider.OPEN_AI;
}
四级:显式开关 → baseUrl 后缀嗅探 → Azure 参数存在性 → 兜底 OPEN_AI。
★ 注意第 22 章 22.4 的伏笔在这里收口:isMicrosoftFoundry / isGitHubModels 在 OpenAiAutoConfigurationUtil.resolveCommonProperties() 里是用 || 合并的(子级 false 关不掉父级 true),而这里的判定是第一级就是显式开关 ------所以父级一旦 true,这里就"被强制",配置层面完全无法撤销。
★ 更狠的是 calculateBaseUrl:
java
// OpenAiSetup.java:304-336(节选)
static String calculateBaseUrl(@Nullable String baseUrl, ModelProvider modelProvider, @Nullable String modelName,
@Nullable String azureDeploymentName) {
if (modelProvider == ModelProvider.OPEN_AI) {
if (baseUrl == null || baseUrl.isBlank()) {
return OPENAI_URL;
}
return baseUrl; // 原样用
}
else if (modelProvider == ModelProvider.GITHUB_MODELS) {
if (baseUrl == null || baseUrl.isBlank()) {
return GITHUB_MODELS_URL;
}
if (baseUrl.startsWith(GITHUB_MODELS_URL)) {
return baseUrl; // 支持"特定 org"的 URL
}
return GITHUB_MODELS_URL; // ★ 你写的 baseUrl 被丢弃!
}
else if (modelProvider == ModelProvider.MICROSOFT_FOUNDRY) {
if (baseUrl == null || baseUrl.isBlank()) {
throw new IllegalArgumentException("Base URL must be provided for Microsoft Foundry.");
}
String tmpUrl = baseUrl;
if (baseUrl.endsWith("/") || baseUrl.endsWith("?")) {
tmpUrl = baseUrl.substring(0, baseUrl.length() - 1);
}
return tmpUrl;
}
else {
throw new IllegalArgumentException("Unknown model provider: " + modelProvider);
}
}
★ GITHUB_MODELS 分支里,只要你的 baseUrl 不以 https://models.github.ai/inference 开头,它就被无条件替换成那个常量------不报警、不报错。你以为你在调自己的 GitHub Models 网关,实际请求打到了官方地址。
另外注意 MICROSOFT_FOUNDRY 分支的尾巴清理只处理 / 和 ? 各一个 字符(substring(0, length-1)),"https://x/openai/v1//" 只会去掉一个。
23.3.5 四套环境变量与它们的优先级
java
// OpenAiSetup.java:262-276
static @Nullable String detectBaseUrlFromEnv(@Nullable String baseUrl) {
if (baseUrl == null) {
var openAiBaseUrl = System.getenv("OPENAI_BASE_URL");
if (openAiBaseUrl != null) {
baseUrl = openAiBaseUrl;
logger.debug("OpenAI Base URL detected from environment variable OPENAI_BASE_URL.");
}
var azureOpenAiBaseUrl = System.getenv("AZURE_OPENAI_BASE_URL");
if (azureOpenAiBaseUrl != null) {
baseUrl = azureOpenAiBaseUrl;
logger.debug("Microsoft Foundry Base URL detected from environment variable AZURE_OPENAI_BASE_URL.");
}
}
return baseUrl;
}
★ ★ 两个都设时 AZURE_OPENAI_BASE_URL 赢 ------第二个 if 直接覆盖第一个,没有 else。这不是 bug 就是刻意的"Azure 优先",但文档里没写。
java
// OpenAiSetup.java:348-362
static @Nullable String detectApiKey(ModelProvider modelProvider) {
if (modelProvider == ModelProvider.OPEN_AI && System.getenv(OPENAI_API_KEY) != null) {
return System.getenv(OPENAI_API_KEY);
}
else if (modelProvider == ModelProvider.MICROSOFT_FOUNDRY && System.getenv(MICROSOFT_FOUNDRY_API_KEY) != null) {
return System.getenv(MICROSOFT_FOUNDRY_API_KEY);
}
else if (modelProvider == ModelProvider.MICROSOFT_FOUNDRY && System.getenv(OPENAI_API_KEY) != null) {
return System.getenv(OPENAI_API_KEY); // ★ 同一 provider 两个分支
}
else if (modelProvider == ModelProvider.GITHUB_MODELS && System.getenv(GITHUB_TOKEN) != null) {
return System.getenv(GITHUB_TOKEN);
}
return null;
}
| 环境变量 | 作用 | 覆盖关系 |
|---|---|---|
OPENAI_BASE_URL |
base URL | 被 AZURE_OPENAI_BASE_URL 覆盖 |
AZURE_OPENAI_BASE_URL |
base URL | 最后写的赢 |
OPENAI_API_KEY |
api key(OpenAI / Foundry 兜底) | Foundry 下先试 MICROSOFT_FOUNDRY_API_KEY |
MICROSOFT_FOUNDRY_API_KEY |
api key(Foundry 优先) | --- |
GITHUB_TOKEN |
api key(GitHub Models) | --- |
其余两个细节:
java
// OpenAiSetup.java:338-346(★ 靠 catch NoClassDefFoundError 判断可选依赖)
static Credential azureAuthentication() {
try {
return AzureInternalOpenAiHelper.getAzureCredential();
}
catch (NoClassDefFoundError e) {
throw new IllegalArgumentException("Microsoft Foundry was detected, but no credential was provided. "
+ "If you want to use passwordless authentication, you need to add the Azure Identity library "
+ "(groupId=`com.azure`, artifactId=`azure-identity`) to your classpath.");
}
}
// OpenAiSetup.java:364-367
static AzureUrlPathMode resolveAzureUrlPathMode(@Nullable String baseUrl) {
return (baseUrl != null && baseUrl.trim().endsWith("/openai/v1")) ? AzureUrlPathMode.UNIFIED
: AzureUrlPathMode.LEGACY;
}
★ azureAuthentication() 用 catch (NoClassDefFoundError) 来检测 azure-identity 是否在 classpath------与第 22 章 22.6.4 里 ToolCallingAutoConfiguration.getClassOrNull 反射加载 ClientAuthorizationException 是同一类"可选依赖"手法,但这里是捕获 Error(不是 Exception),且只在真正需要时才触发。
User-Agent 硬编码为 "spring-ai-openai"(:176),customHeaders 用 Collectors.toMap 转 Map<String, List<String>>(:202-204)------★ 这个 Collectors.toMap 在 value 为 null 时会抛 NullPointerException,而 customHeaders 是 Map<String,String>,YAML 里写 spring.ai.openai.custom-headers.foo:(无值)就会产生 null value。
23.4 ★ OpenAI(二):OpenAiChatModel 主干
23.4.1 call:观测包裹 + 一个方法完成全部
java
// OpenAiChatModel.java:196-258(结构节选)
public ChatResponse call(Prompt prompt) {
Prompt requestPrompt = buildRequestPrompt(prompt);
verifyPromptChatOptions(requestPrompt);
return this.internalCall(requestPrompt, null);
}
private ChatResponse internalCall(Prompt prompt, @Nullable ChatResponse previousChatResponse) {
ChatCompletionCreateParams request = this.createRequest(prompt, false);
RequestOptions requestOptions = this.buildRequestOptions(prompt);
ChatModelObservationContext observationContext = ChatModelObservationContext.builder()
.prompt(prompt)
.provider(AiProvider.OPENAI.value())
.build();
ChatResponse response = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION
.observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext,
this.observationRegistry)
.observe(() -> {
ChatCompletion chatCompletion = this.openAiClient.chat().completions().create(request, requestOptions);
List<ChatCompletion.Choice> choices = chatCompletion.choices();
if (choices.isEmpty()) {
if (logger.isWarnEnabled()) {
logger.warn("No choices returned for prompt: " + prompt);
}
return new ChatResponse(List.of()); // ★ 空结果不抛异常
}
...
Usage accumulatedUsage = UsageCalculator.getCumulativeUsage(currentChatResponseUsage,
previousChatResponse);
ChatResponse chatResponse = new ChatResponse(generations, from(chatCompletion, accumulatedUsage));
observationContext.setResponse(chatResponse);
return chatResponse;
});
return response;
}
★ 三个细节:
choices.isEmpty()只 warn 并返回空ChatResponse------不抛异常。调用方拿到一个没有 generation 的响应,getResult().getOutput().getContent()才会 NPE/越界,错误定位点远离根因。provider(AiProvider.OPENAI.value())------ OpenAI 是直接用枚举的(见 23.9)。buildRequestOptions只从OpenAiChatOptions里取了一个东西:
java
// OpenAiChatModel.java:929-939
private RequestOptions buildRequestOptions(Prompt prompt) {
Assert.notNull(prompt, "Prompt cannot be null");
Assert.isInstanceOf(OpenAiChatOptions.class, prompt.getOptions(),
"Prompt options must be OpenAiChatOptions type");
OpenAiChatOptions chatOptions = (OpenAiChatOptions) prompt.getOptions();
RequestOptions.Builder requestOptionsBuilder = RequestOptions.builder();
if (chatOptions.getTimeout() != null) {
requestOptionsBuilder.timeout(chatOptions.getTimeout());
}
return requestOptionsBuilder.build();
}
★ 每次请求只覆盖 timeout 。没有 per-request 的 maxRetries、没有 header、没有 model------这些只在 OpenAiSetup 建客户端时定死。
23.4.2 ★ 流式:两个客户端 + ChunkMerger 三段管道
java
// OpenAiChatModel.java:273-328(结构节选)
private Flux<ChatResponse> internalStream(Prompt prompt) {
return Flux.deferContextual(contextView -> {
ChatCompletionCreateParams request = this.createRequest(prompt, true);
RequestOptions requestOptions = this.buildRequestOptions(prompt);
ConcurrentHashMap<String, String> roleMap = new ConcurrentHashMap<>();
ConcurrentHashMap<String, String> reasoningMap = new ConcurrentHashMap<>();
final ChatModelObservationContext observationContext = ChatModelObservationContext.builder()
.prompt(prompt).provider(AiProvider.OPENAI.value()).streaming(true).build();
Observation observation = ...;
Observation parentObservation = contextView.getOrDefault(ObservationThreadLocalAccessor.KEY, null);
observation.parentObservation(parentObservation);
// ★★ 显式 openScope 让 Micrometer 正确推导 span 父子
try (Observation.Scope ignored = parentObservation != null ? parentObservation.openScope()
: Observation.Scope.NOOP) {
observation.start();
}
// 1) SDK 的 AsyncStreamResponse → Flux
Flux<ChatCompletionChunk> chunks = Flux.<ChatCompletionChunk>create(sink -> {
AsyncStreamResponse<ChatCompletionChunk> response = this.openAiClientAsync.chat()
.completions().createStreaming(request, requestOptions);
sink.onDispose(response::close);
response.subscribe(sink::next).onCompleteFuture().whenComplete((unused, throwable) -> { ... });
});
// 2) 把 tool call 相关的 chunk 聚成一坨
AtomicBoolean isInsideTool = new AtomicBoolean(false);
Flux<ChatCompletion> aggregatedChatCompletions = chunks.doOnNext(chunk -> {
if (ChunkMerger.hasToolCall(chunk)) {
isInsideTool.set(true);
}
}).bufferUntil(chunk -> {
if (isInsideTool.get() && ChunkMerger.toolCallsDone(chunk)) {
isInsideTool.set(false);
return true;
}
return !isInsideTool.get();
}).map(ChunkMerger::mergeChunks).map(ChunkMerger::chunkToChatCompletion);
// 3) 转 ChatResponse
...
});
}
★ 那个 try (Observation.Scope ignored = parentObservation.openScope()) 是 OpenAI 特有的修复,注释写得很直白:
Briefly make the parent observation current while starting this one, so Micrometer tracing derives the span's parent from the parent observation rather than from whatever scope happens to be open on the current thread (e.g. the servlet HTTP span). This keeps span parenting correct without relying on automatic context propagation.
★ 也就是说:OpenAI 修了"流式 span 父子错乱",Anthropic 没修(见 23.5.2)。这是两家在观测层面最实质的一处差异。
再看 ChunkMerger(OpenAiChatModel.java:1179-1270):
java
static boolean hasToolCall(ChatCompletionChunk chunk) {
return !chunk.choices().isEmpty()
&& chunk.choices().get(0).delta().toolCalls().filter(toolCalls -> !toolCalls.isEmpty()).isPresent();
}
static boolean toolCallsDone(ChatCompletionChunk chunk) {
return !chunk.choices().isEmpty()
&& FinishReason.TOOL_CALLS == chunk.choices().get(0).finishReason().orElse(null);
}
static ChatCompletionChunk mergeChunks(List<ChatCompletionChunk> chunks) {
ChatCompletionChunk.Builder builder = chunks.get(0).toBuilder(); // ★ 以第一个 chunk 为骨架
Map<Long, Choice> choices = new LinkedHashMap<>();
chunks.get(0).choices().forEach(choice -> choices.put(choice.index(), choice));
for (int i = 1; i < chunks.size(); i++) {
ChatCompletionChunk chunk = chunks.get(i);
chunk.usage().ifPresent(builder::usage); // ★ usage 最后一个非空胜
chunk.serviceTier().ifPresent(builder::serviceTier);
chunk.choices().forEach(choice -> choices.compute(choice.index(),
(ix, c) -> c == null ? choice : mergeChoices(c, choice)));
}
return builder.choices(new ArrayList<>(choices.values())).build();
}
private static Choice mergeChoices(Choice c1, Choice c2) {
return Choice.builder()
.index(c1.index())
.finishReason(c1.finishReason().or(c2::finishReason)) // ★ 第一个非空胜
.logprobs(c1.logprobs().or(c2::logprobs))
.delta(mergeDeltas(c1.delta(), c2.delta()))
.build();
}
★ 四个必须记住的点:
hasToolCall/toolCallsDone只看choices.get(0)。 请求n>1时,第 2 个及以后的 choice 里的 tool call 不会被识别 ,isInsideTool永远不被置位 → 聚合逻辑退化。mergeChunks以chunks.get(0)为骨架 ,所以id/created/model都取第一个 chunk 的值;只有usage与serviceTier会被后续 chunk 覆盖。finishReason用.or(c2::finishReason)------第一个非空胜,不是"最后一个胜"。- tool call 合并按
index归并,Javadoc 里专门解释了为什么不能用id:
java
// OpenAiChatModel.java:1216-1220
private static Delta mergeDeltas(Delta left, Delta right) {
// Deltas of the same logical tool call share the required 'index' field.
// Some OpenAI-compatible providers (e.g. DeepSeek) send an empty-string id
// on continuation deltas instead of omitting it, so id presence cannot be
// used to detect the start of a new tool call.
★ 这是一处为 DeepSeek 兼容而写的注释------OpenAI 模块里写明了它在迁就另一家的实现。
mergeDeltas 还顺手把 reasoning 片段拼起来,但只认两个 key:
java
// OpenAiChatModel.java:1237-1243
for (String reasoningKey : List.of("reasoning_content", "reasoning")) {
stringProperty(right, reasoningKey).filter(StringUtils::hasLength)
.ifPresent(rightFragment -> deltaBuilder.putAdditionalProperty(reasoningKey,
JsonValue.from(stringProperty(left, reasoningKey).orElse("") + rightFragment)));
}
23.4.3 ★ strict 模式:Spring AI 帮你改写 JSON Schema
这是 OpenAiChatModel 里最"不像框架、像补丁"的一段。
java
// OpenAiChatModel.java:1001-1016(节选)
private List<ChatCompletionTool> getChatCompletionTools(List<ToolDefinition> toolDefinitions,
@Nullable OpenAiChatOptions requestOptions) {
return toolDefinitions.stream().map(toolDefinition -> {
FunctionParameters.Builder parametersBuilder = FunctionParameters.builder();
// Defaults to false: OpenAI's strict mode requires every schema property to
// appear in "required" (optionality is expressed via nullable types, not
// omission), which JsonSchemaGenerator does not produce by default. ...
Boolean strictMode = false;
if (requestOptions != null && requestOptions.getStrict() != null) {
strictMode = requestOptions.getStrict();
}
else if (this.options != null && this.options.getStrict() != null) {
strictMode = this.options.getStrict();
}
if (!toolDefinition.inputSchema().isEmpty()) {
try {
Map<String, Object> schemaMap = objectMapper.readValue(toolDefinition.inputSchema(), Map.class);
if (Boolean.TRUE.equals(strictMode)) {
applyStrictModeRequirements(schemaMap); // ★ 原地改写
}
schemaMap.forEach((key, value) -> parametersBuilder.putAdditionalProperty(key, JsonValue.from(value)));
}
catch (Exception e) {
logger.error("Failed to parse tool schema", e); // ★ 只 log,schema 变成空的
}
}
...
}).toList();
}
★ 注意 schemaMap.forEach(...) 是全量透传 ------$schema、$defs、type、additionalProperties 全部塞进 FunctionParameters。这与 Anthropic(只取 properties + required)形成鲜明对比(23.5.3)。
strict 模式的改写逻辑:
java
// OpenAiChatModel.java:1047-1099
private static final Map<String, Object> NULL_TYPE_SCHEMA = Map.of("type", "null");
@SuppressWarnings("unchecked")
private static void applyStrictModeRequirements(Map<String, Object> schema) {
// ① 先递归 $defs
if (schema.get("$defs") instanceof Map<?, ?> defs) {
for (Object definition : defs.values()) {
if (definition instanceof Map<?, ?> definitionSchema) {
applyStrictModeRequirements((Map<String, Object>) definitionSchema);
}
}
}
if (!(schema.get("properties") instanceof Map<?, ?> properties) || properties.isEmpty()) {
return;
}
List<?> alreadyRequired = schema.get("required") instanceof List<?> required ? required : List.of();
for (Object propertySchemaValue : properties.values()) {
if (!(propertySchemaValue instanceof Map<?, ?> propertySchema)) {
continue;
}
applyStrictModeRequirements((Map<String, Object>) propertySchema); // ② 递归嵌套对象
if (propertySchema.get("items") instanceof Map<?, ?> itemsSchema) {
applyStrictModeRequirements((Map<String, Object>) itemsSchema); // ③ 递归数组元素
}
}
for (Map.Entry<?, ?> property : properties.entrySet()) {
if (alreadyRequired.contains(property.getKey())
|| !(property.getValue() instanceof Map<?, ?> propertySchema)) {
continue;
}
widenToNullable((Map<String, Object>) propertySchema); // ④ 可选属性加 null
}
schema.put("required", new ArrayList<>(properties.keySet())); // ⑤ required 全量回填
}
java
// OpenAiChatModel.java:1107-1140
private static void widenToNullable(Map<String, Object> propertySchema) {
Object type = propertySchema.get("type");
if (type instanceof String typeName) {
if (!"null".equals(typeName)) {
propertySchema.put("type", new ArrayList<>(List.of(typeName, "null"))); // "string" → ["string","null"]
}
return;
}
if (type instanceof List<?> typeList) {
if (!typeList.contains("null")) {
List<Object> widened = new ArrayList<>(typeList);
widened.add("null");
propertySchema.put("type", widened);
}
return;
}
if (propertySchema.get("anyOf") instanceof List<?> anyOf) {
if (!anyOf.contains(NULL_TYPE_SCHEMA)) {
List<Object> widened = new ArrayList<>(anyOf);
widened.add(NULL_TYPE_SCHEMA);
propertySchema.put("anyOf", widened);
}
return;
}
if (propertySchema.containsKey("$ref")) {
Map<String, Object> refBranch = new LinkedHashMap<>(propertySchema);
Object description = refBranch.remove("description");
propertySchema.clear();
if (description != null) {
propertySchema.put("description", description);
}
propertySchema.put("anyOf", new ArrayList<>(List.of(refBranch, NULL_TYPE_SCHEMA))); // ★ 把整个 $ref 分支包进 anyOf
}
}
★ 五步改写总结:
| 步 | 动作 | 后果 |
|---|---|---|
| ① | 递归 $defs 里每个定义 |
★ OpenAI 是唯一会读 $defs 的一家 |
| ②③ | 递归 properties 与 items |
嵌套结构全覆盖 |
| ④ | 非 required 的属性 type 加 "null" |
"string" → ["string","null"] |
| ⑤ | required 回填所有属性 key |
原来 required:[a]、properties {a,b} → required:[a,b] |
★ 但注意 $ref 的处理(:1131-1139):它会把整个 property schema(含 $ref)复制成一个 anyOf 分支。这意味着 $ref: "#/$defs/Foo" 这个引用在 anyOf[0] 里依然指向 $defs ------ 而 $defs 因为步骤 ① 也保留着。所以 OpenAI strict 模式下 $ref 不会断链(对比 Anthropic 会断)。
★ 反过来说:strict 默认 false ,所以默认行为是"原样透传 JsonSchemaGenerator 产出的常规 JSON Schema",@ToolParam(required=false) 的属性不在 required 里 ------这正是 OpenAI strict 模式所不允许的。要开 strict 得显式设 spring.ai.openai.chat.options.strict=true。
23.4.4 reasoning 内容:两个 key、三个落点
java
// OpenAiChatModel.java:1142-1154
private String getReasoningContent(ChatCompletion.Choice choice) {
String reasoningContent = "";
Map<String, JsonValue> additionalProperties = choice.message()._additionalProperties();
if (additionalProperties.get("reasoning_content") != null) {
reasoningContent = (String) additionalProperties.get("reasoning_content").asString().orElse("");
}
else {
if (additionalProperties.get("reasoning") != null) {
reasoningContent = (String) additionalProperties.get("reasoning").asString().orElse("");
}
}
return reasoningContent;
}
★ 两个 key(reasoning_content 优先、reasoning 兜底),因为 OpenAI 官方用前者、DeepSeek 等兼容家用后者(与 23.4.2 里那段"为 DeepSeek 兼容"的注释同源)。
三个落点:
internalCall的 generation metadata(:239-240,key 是常量REASONING_CONTENT = "reasoningContent")internalStream里用ConcurrentHashMap跨 chunk 累加 (:339-340):
java
// OpenAiChatModel.java:339-340
String accumulatedReasoning = reasoningMap.merge(id + ":" + choice.index(),
getReasoningContent(choice), String::concat);
mergeDeltas里再拼一次(:1237-1243)
★ 注释解释了为什么非要累加:
Accumulate reasoning fragments across the streamed chunks so the final response of this stream carries the full reasoning content, surviving last-wins metadata aggregation (e.g.
MessageAggregator).
即第 12 章讲过的 MessageAggregator 是"后到的覆盖先到的",不累加就只剩最后一个 chunk 的推理片段。
23.4.5 其它值得记住的 OpenAI 细节
java
// OpenAiChatModel.java:130-134
public final class OpenAiChatModel implements ChatModel {
private static final ChatModelObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultChatModelObservationConvention();
private static final String REASONING_CONTENT = "reasoningContent";
- ★
OpenAiChatModel是public final class------不能继承 (AnthropicChatModel也是final,但BedrockProxyChatModel/DeepSeekChatModel/OllamaChatModel/MistralAiChatModel都不是)。想包装只能组合。 parseToolChoice(:955-976)是public static,把JsonNode转成 SDK 的ChatCompletionToolChoiceOption;"auto"/"required"/"none"三个分支都走ofAuto(...),只是枚举不同------注释自嘲 "There is a built-in 'auto' option --- but how to get it depends on SDK version"。ResponseFormat(:1371-1495)是public static class,Type枚举里有TEXT/JSON_OBJECT/JSON_SCHEMA。
23.5 ★ Anthropic:1961 行的最大实现
23.5.1 AnthropicSetup:四个 setupSyncClient 重载与连接池标签
java
// AnthropicSetup.java:84-108
public final class AnthropicSetup {
static final String ANTHROPIC_URL = "https://api.anthropic.com";
static final String ANTHROPIC_API_KEY = "ANTHROPIC_API_KEY";
static final String ANTHROPIC_AUTH_TOKEN = "ANTHROPIC_AUTH_TOKEN";
static final String ANTHROPIC_BASE_URL = "ANTHROPIC_BASE_URL";
static final String DEFAULT_USER_AGENT = "spring-ai-anthropic-sdk";
private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(60);
private static final int DEFAULT_MAX_RETRIES = 2;
// Disambiguates sync/async client connection-pool gauges sharing one MeterRegistry.
private static final List<Tag> SYNC_CLIENT_TAGS = List.of(Tag.of("client.kind", "sync"));
private static final List<Tag> ASYNC_CLIENT_TAGS = List.of(Tag.of("client.kind", "async"));
private AnthropicSetup() {
}
}
★ 与 OpenAiSetup 的三处差异:
| 维度 | OpenAiSetup |
AnthropicSetup |
|---|---|---|
| User-Agent | "spring-ai-openai" |
"spring-ai-anthropic-sdk"(★ 带 -sdk 后缀) |
| 默认 timeout | 由外部传入(spring.ai.openai.timeout = 60s) |
类内常量 DEFAULT_TIMEOUT = 60s |
| 默认 maxRetries | 由外部传入(spring.ai.openai.max-retries = 3) |
类内常量 DEFAULT_MAX_RETRIES = 2 |
| 连接池指标 | 无标签 | ★ client.kind=sync/async 两个 Tag |
★ DEFAULT_MAX_RETRIES = 2 是写死的类常量 ,不像 OpenAI 那样从 Properties 走。也就是说 Anthropic 的重试次数没有 Spring 属性可以配 (spring.ai.retry.* 无效、spring.ai.anthropic.* 里也没有 max-retries 走这条路)。
四个 setupSyncClient 重载构成一条"逐步加参数"的链(:125 / :151 / :171 / :190),最终都汇到 buildClientOptions:
java
// AnthropicSetup.java:293-344(节选)
private static ClientOptions buildClientOptions(@Nullable String baseUrl, @Nullable String apiKey,
@Nullable Duration timeout, @Nullable Integer maxRetries, @Nullable Proxy proxy,
@Nullable Map<String, String> customHeaders, ObservationRegistry observationRegistry,
@Nullable MeterRegistry meterRegistry, Iterable<Tag> connectionPoolTags,
@Nullable ExecutorService dispatcherExecutor,
List<AnthropicHttpClientBuilderCustomizer> httpClientCustomizers) {
String resolvedBaseUrl = detectBaseUrlFromEnv(baseUrl);
String resolvedApiKey = apiKey != null ? apiKey : detectApiKey();
Duration resolvedTimeout = timeout != null ? timeout : DEFAULT_TIMEOUT;
int resolvedMaxRetries = maxRetries != null ? maxRetries : DEFAULT_MAX_RETRIES;
AnthropicBackend backend = buildBackend(resolvedBaseUrl, resolvedApiKey);
ClientOptions.Builder optsBuilder = ClientOptions.builder()
.timeout(resolvedTimeout)
.maxRetries(resolvedMaxRetries)
.putHeader("User-Agent", DEFAULT_USER_AGENT);
if (customHeaders != null) {
customHeaders.forEach(optsBuilder::putHeader); // ★ 逐个 putHeader,不是 Collectors.toMap
}
SpringAiAnthropicHttpClient.Builder rawHttpBuilder = SpringAiAnthropicHttpClient.builder()
.backend(backend).timeout(resolvedTimeout).proxy(proxy)
.observationRegistry(observationRegistry)
.meterRegistry(meterRegistry)
.dispatcherExecutorService(dispatcherExecutor);
for (AnthropicHttpClientBuilderCustomizer customizer : httpClientCustomizers) {
customizer.customize(rawHttpBuilder);
}
// ★★ customizer 跑完后,把 backend 与 meterTags 再强制覆盖回去
rawHttpBuilder.backend(backend);
rawHttpBuilder.meterTags(connectionPoolTags);
SpringAiAnthropicHttpClient rawHttp = rawHttpBuilder.build();
// No-op when a static apiKey/authToken is set; otherwise fetches WIF credentials.
backend.applyCredentials(rawHttp, optsBuilder);
return optsBuilder.httpClient(rawHttp).build();
}
★ 那段"跑完 customizer 再强制覆盖"的注释是本章最有价值的一段工程说明:
Re-apply Spring AI's resolved backend and meterTags after the customizer loop so that neither can be inadvertently overridden:
backend:applyCredentials()below relies on the same backend instance; a replacement would misalign WIF credential injection.meterTags:SYNC_CLIENT_TAGS/ASYNC_CLIENT_TAGSdiscriminate the two OkHttp connection-pool metric bindings; identical tags would cause duplicate gauge registration in theMeterRegistryat startup.
翻译成人话:你自己写的 AnthropicHttpClientBuilderCustomizer 改 backend 或 meterTags 是无效的 ,会被 Spring AI 覆盖回去;而且这不是"以防万一"------如果 sync 与 async 用了相同 tag,启动时 MeterRegistry 会因为重复 gauge 注册而报错。
★ 另外注意 customHeaders.forEach(optsBuilder::putHeader) 是逐个 put,与 OpenAI 的 Collectors.toMap 不同------Anthropic 这条路径不会因为 null value 抛 NPE (putHeader 接受 null)。同一件事,两家实现风格不同、健壮性也不同。
23.5.2 ★★ 流式:Javadoc 自己写明的两个缺陷
AnthropicChatModel.stream() 上方的 Javadoc 是全 Spring AI 里最坦诚的一段文档------它直接承认了两个问题:
java
// AnthropicChatModel.java:266-285
/**
* Streams the chat completion as a {@link Flux} of {@link ChatResponse} events.
*
* <p>
* <b>Observability note.</b> The outbound HTTP attempt is observed as
* {@code okhttp.requests} with timer + {@code traceparent}, but for streaming calls
* the HTTP span is not parented under the chat-model's
* {@code gen_ai.client.operation} span. The SDK's async path internally schedules the
* HTTP call on {@code ForkJoinPool.commonPool()} before Spring AI's HTTP client runs,
* which drops the calling thread's observation context. Filter by
* {@code okhttp.requests} + host {@code api.anthropic.com} and correlate by trace ID
* or timestamp if you need to join the spans in your tracing UI.
...
★ 缺陷一:流式 HTTP span 不挂在 gen_ai.client.operation 下 。原因是 Anthropic SDK 的 async 路径在 Spring AI 的 HTTP client 之前就把调用调度到了 ForkJoinPool.commonPool(),线程本地的 observation context 丢了。
★ 这正是 23.4.2 里 OpenAI 用 try (Observation.Scope ignored = parentObservation.openScope()) 修掉的那个问题。Anthropic 没修,而是在文档里让用户"用 trace ID 或时间戳自己关联"。
缺陷二在 internalStream 的 Javadoc 里:
java
// AnthropicChatModel.java:287-307
/**
* <p>
* Rate-limit headers are read from the streaming response via
* {@code withRawResponse().createStreaming(...)} and attached to the aggregated
* {@link ChatResponse}. Because that SDK call exposes the stream as a blocking
* {@link StreamResponse}, the events are pulled on
* {@link Schedulers#boundedElastic()}; a streaming call therefore holds a worker
* thread for the duration of the stream.
java
// AnthropicChatModel.java:336-346
Flux<ChatResponse> chatResponseFlux = Mono
.fromFuture(() -> this.anthropicClientAsync.messages()
.withRawResponse()
.createStreaming(request, requestOptionsFor(prompt)))
.flatMapMany(rawResponse -> {
streamingState.setRateLimit(AnthropicRateLimit.from(rawResponse.headers()));
StreamResponse<RawMessageStreamEvent> streamResponse = rawResponse.parse();
return Flux.fromStream(streamResponse.stream())
.doFinally(signal -> streamResponse.close())
.subscribeOn(Schedulers.boundedElastic()); // ★ 占住一个 worker
})
★ 为了读 rate-limit 头,Anthropic 用了 withRawResponse(),而 SDK 把它暴露成阻塞的 StreamResponse ,于是只能放到 Schedulers.boundedElastic() 上拉------每个流式调用在整个流期间占住一个 boundedElastic 工作线程。并发一高就会打满这个池(默认 10×CPU)。
这是一个明确的架构权衡:Spring AI 选择了"能拿到 rate-limit 头"而放弃了"线程不占用"。你在做容量规划时必须知道这一点。
23.5.3 ★ $defs 的两处剥离:结构化输出与工具 schema
第 20 章讲过"Anthropic 剥掉 $schema 与 $defs 使重复嵌套类型的 $ref 断链"。这里给出完整的两处落点。
落点一:结构化输出(output schema)
java
// AnthropicChatOptions.java:614-640
@Override
public B outputSchema(@Nullable String outputSchema) {
if (outputSchema != null) {
Map<String, Object> schemaMap = JSON_MAPPER.readValue(outputSchema,
new TypeReference<Map<String, Object>>() {
});
JsonOutputFormat.Schema.Builder schemaBuilder = JsonOutputFormat.Schema.builder();
for (Map.Entry<String, Object> entry : schemaMap.entrySet()) {
// Strip JSON Schema meta-fields not supported by the Anthropic API
if ("$schema".equals(entry.getKey()) || "$defs".equals(entry.getKey())) {
continue; // ★ 直接跳过
}
schemaBuilder.putAdditionalProperty(entry.getKey(), JsonValue.from(entry.getValue()));
}
JsonOutputFormat jsonOutputFormat = JsonOutputFormat.builder().schema(schemaBuilder.build()).build();
OutputConfig.Builder configBuilder = OutputConfig.builder().format(jsonOutputFormat);
if (this.outputConfig != null) {
this.outputConfig.effort().ifPresent(configBuilder::effort);
}
this.outputConfig = configBuilder.build();
}
else {
this.outputConfig = null;
}
return self();
}
落点二:工具 schema(更狠)
java
// AnthropicChatModel.java:1270-1310(★ 节选自 toAnthropicTool)
@SuppressWarnings("unchecked")
private Tool toAnthropicTool(ToolDefinition toolDefinition) {
try {
var jsonMapper = tools.jackson.databind.json.JsonMapper.builder().build();
java.util.Map<String, Object> schemaMap = jsonMapper.readValue(toolDefinition.inputSchema(),
new tools.jackson.core.type.TypeReference<java.util.Map<String, Object>>() {
});
Tool.InputSchema.Properties.Builder propertiesBuilder = Tool.InputSchema.Properties.builder();
Object propertiesObj = schemaMap.get("properties");
if (propertiesObj instanceof java.util.Map) {
java.util.Map<String, Object> properties = (java.util.Map<String, Object>) propertiesObj;
for (java.util.Map.Entry<String, Object> entry : properties.entrySet()) {
propertiesBuilder.putAdditionalProperty(entry.getKey(), JsonValue.from(entry.getValue()));
}
}
Tool.InputSchema.Builder inputSchemaBuilder = Tool.InputSchema.builder()
.properties(propertiesBuilder.build());
Object requiredObj = schemaMap.get("required");
if (requiredObj instanceof java.util.List) {
java.util.List<String> required = (java.util.List<String>) requiredObj;
for (String req : required) {
inputSchemaBuilder.addRequired(req);
}
}
return Tool.builder()
.name(toolDefinition.name())
.description(toolDefinition.description())
.inputSchema(inputSchemaBuilder.build())
.build();
}
catch (Exception e) {
throw new RuntimeException("Failed to parse tool input schema: " + toolDefinition.inputSchema(), e);
}
}
★ ★ 关键对比(这是本章最重要的一张表之一):
| 供应商 | 工具 schema 怎么处理 | $defs 命运 |
|---|---|---|
| OpenAI | schemaMap.forEach(... putAdditionalProperty) 全量透传 |
保留 ;strict 模式下还会递归修正 $defs 里的每个定义 |
| Anthropic | 只取 properties 和 required 两个键,其余全丢 |
丢失 → 嵌套 $ref 断链,且不报错 |
| Google GenAI | JsonSchemaConverter.convertToOpenApiSchema() |
有 $defs 就抛 IllegalArgumentException |
★ 所以同一个 @Tool 方法(返回类型里含重复嵌套类型,会触发 JsonSchemaGenerator 生成 $defs):
- 配 OpenAI:正常工作;
- 配 Anthropic:schema 里少掉
$defs,属性里的$ref: "#/$defs/Foo"指向不存在的路径 → 模型看到的是一个断链 schema,行为不可预期(可能忽略该参数、可能报错); - 配 Google:启动/首次调用就抛异常,明确告诉你不支持。
★ 注意 Anthropic 的这两个落点行为也不一致 :outputSchema() 是"跳过 $schema / $defs 两个键、其余保留",toAnthropicTool() 是"只保留 properties / required、其余全丢"(连 type: object 都丢了)。
23.5.4 缓存:CacheEligibilityResolver + 最多 4 个断点
Anthropic 的 prompt caching 是全仓库只有它和 Bedrock 有的能力。
java
// CacheBreakpointTracker.java:22-56
/**
* Tracks cache breakpoints used (max 4 allowed by Anthropic). Non-static to ensure each
* request has its own instance.
*/
class CacheBreakpointTracker {
private int count = 0;
private boolean hasWarned = false;
public boolean canUse() {
return this.count < 4;
}
public boolean allBreakpointsAreUsed() {
return !this.canUse();
}
public void use() {
if (this.count < 4) {
this.count++;
}
else if (!this.hasWarned) {
logger.warn(
"Anthropic cache breakpoint limit (4) reached. Additional cache_control directives will be ignored. "
+ "Consider using fewer cache strategies or simpler content structure.");
this.hasWarned = true;
}
}
}
★ 三个细节:
count是实例字段且非 static(类注释明确说 "Non-static to ensure each request has its own instance")------每个请求独立计数。- 硬编码
4(写了 3 次:注释、canUse()、use())。 hasWarned保证每个实例只 warn 一次 ------因为是每请求一个实例,所以实际上是每个请求 warn 一次(不是全局一次)。
策略与消息类型的映射:
java
// CacheEligibilityResolver.java:75-82
private static Set<MessageType> extractEligibleMessageTypes(AnthropicCacheStrategy strategy) {
return switch (strategy) {
case NONE -> Set.of();
case SYSTEM_ONLY, SYSTEM_AND_TOOLS -> Set.of(MessageType.SYSTEM);
case TOOLS_ONLY -> Set.of(); // ★ 消息全不缓存
case CONVERSATION_HISTORY -> Set.of(MessageType.values());
};
}
java
// CacheEligibilityResolver.java:84-106
public @Nullable CacheControlEphemeral resolve(MessageType messageType, @Nullable String content) {
Integer length = this.contentLengthFunction.apply(content);
Integer minLength = this.messageTypeMinContentLengths.get(messageType);
Assert.state(minLength != null, "The minimum content length of the message type must be defined");
if (this.cacheStrategy == AnthropicCacheStrategy.NONE || !this.cacheEligibleMessageTypes.contains(messageType)
|| length < minLength || this.cacheBreakpointTracker.allBreakpointsAreUsed()) {
...
return null;
}
AnthropicCacheTtl cacheTtl = this.messageTypeTtl.get(messageType);
Assert.state(cacheTtl != null, "The message type ttl of the message type must be defined");
...
return CacheControlEphemeral.builder().ttl(cacheTtl.getSdkTtl()).build();
}
★ TOOLS_ONLY 这个策略名很有迷惑性:它让消息全部不缓存 (Set.of()),只有工具定义带 cache_control------靠 resolveToolCacheControl()(:108-134)单独处理。
★ 两个 Assert.state 会在 messageTypeMinContentLengths / messageTypeTtl 缺某个 MessageType 时抛 IllegalStateException。CONVERSATION_HISTORY 策略下 eligibleTypes = Set.of(MessageType.values()),所以必须为每个 MessageType 都配好 minLength 与 ttl,否则运行时炸。
23.5.5 三个 Beta 头常量与其它
java
// AnthropicChatModel.java:161-165
private static final String BETA_SKILLS = "skills-2025-10-02";
private static final String BETA_CODE_EXECUTION = "code-execution-2025-08-25";
private static final String BETA_FILES_API = "files-api-2025-04-14";
★ 三个 beta 特性头(Skills / Code Execution / Files API)是 private static final 字符串------没有开关、没有配置,用不用由代码路径决定。
其它:
ANTHROPIC_THINKING_CONTENTS_PROPERTY = "anthropicThinkingContents"(:167,包级可见,测试用)DEFAULT_TOOL_CALLING_MANAGER = ToolCallingManager.builder().build()(:169)------★ 与其他家一样,默认 manager 在静态字段里,全局共享- 内部调用签名不同:Anthropic 的
internalCall/internalStream是public(:545/:308),而 OpenAI 的是private。注释解释是因为"recursively to support multi-turn tool calling"需要被外部(测试/代理)调用。
23.6 ★ 自研 Api 派:DeepSeek / Mistral / Ollama
23.6.1 三家共性
| 共性 | DeepSeek | Mistral | Ollama |
|---|---|---|---|
自研 *Api.java |
DeepSeekApi(1251 行) |
MistralAiApi(1775 行) |
OllamaApi(836 行) |
| 双客户端 | RestClient + WebClient |
同 | 同 |
| 流式 chunk 合并 helper | DeepSeekStreamFunctionCallingHelper |
MistralAiStreamFunctionCallingHelper |
--- |
| 重试 | RetryUtils.execute(retryTemplate, ...) |
同 | 同 |
ChatModel 是否 final |
否(public class) |
否 | 否 |
| 默认 base URL | https://api.deepseek.com |
https://api.mistral.ai |
http://localhost:11434 |
★ 三家的 ChatModel 都不是 final(OpenAI / Anthropic 是),这是阵营差异的一个侧面。
MistralAiApi 的 SSE 结束判定值得一提:
java
// MistralAiApi.java:97
private static final Predicate<String> SSE_DONE_PREDICATE = "[DONE]"::equals;
23.6.2 DeepSeek:betaPrefixPath 与默认模型
java
// DeepSeekApi.java:57-108(结构节选)
public class DeepSeekApi {
public static final DeepSeekApi.ChatModel DEFAULT_CHAT_MODEL = ChatModel.DEEPSEEK_V4_FLASH;
private final String completionsPath;
private final String betaPrefixPath;
private final RestClient restClient;
private final WebClient webClient;
private final DeepSeekStreamFunctionCallingHelper chunkMerger = new DeepSeekStreamFunctionCallingHelper();
java
// DeepSeekConstants.java
public final class DeepSeekConstants {
public static final String DEFAULT_BASE_URL = "https://api.deepseek.com";
public static final String DEFAULT_COMPLETIONS_PATH = "/chat/completions";
public static final String DEFAULT_BETA_PATH = "/beta";
public static final String PROVIDER_NAME = AiProvider.DEEPSEEK.value();
private DeepSeekConstants() {
}
}
★ 三个点:
DEFAULT_CHAT_MODEL = ChatModel.DEEPSEEK_V4_FLASH------ 默认模型硬编码在Api类里(不是 Properties)。想在 2.x 换默认模型只能改配置或改代码。betaPrefixPath(默认/beta)------DeepSeek 的 beta 能力走不同的路径前缀,而不是不同的参数。ChatModel是个implements ChatModelDescription的枚举(:233),带value/getName()。
java
// DeepSeekChatModel.java:88-98
private final DeepSeekChatOptions options;
public final RetryTemplate retryTemplate; // ★ public final 字段!
private final DeepSeekApi deepSeekApi;
★ public final RetryTemplate retryTemplate ------ 公开可变字段 。这是全仓库 ChatModel 里唯一把 RetryTemplate 暴露成 public 字段的(Ollama / Mistral 都是 private final)。虽然是 final(引用不可改),但 RetryTemplate 本身的状态可以被外部读走。
23.6.3 Mistral:三套 Api 与 OCR
bash
find models/spring-ai-mistral-ai/src/main -name '*Api.java'
# api/MistralAiApi.java 1775 行 chat + embedding
# api/MistralAiModerationApi.java moderation
# ocr/MistralOcrApi.java OCR
★ Mistral 是全仓库唯一一家把 OCR 做成一等公民的供应商(ocr/MistralOcrApi.java + MistralAiOcrOptions.java)------这也解释第 22 章 22.2.4 那个硬编码的 spring.ai.model.ocr 为什么只服务于它。
MistralAiChatModel 的流式也包了 RetryUtils.execute:
java
// MistralAiChatModel.java:252
Flux<ChatCompletionChunk> completionChunks = RetryUtils.execute(this.retryTemplate, ...);
★ 如 23.2.3 所说,RetryTemplate 在这里保护的是"建立 Flux"而非"重放流"。
23.6.4 Ollama:构造器里的模型初始化与五个 Duration 元数据
第 22 章 22.16 讲过自动配置侧(spring.ai.ollama.init.*),这里补模型侧。
java
// OllamaChatModel.java:124-149
public OllamaChatModel(OllamaApi ollamaApi, OllamaChatOptions options, ToolCallingManager toolCallingManager,
ObservationRegistry observationRegistry, ModelManagementOptions modelManagementOptions) {
this(ollamaApi, options, toolCallingManager, observationRegistry, modelManagementOptions,
RetryUtils.DEFAULT_RETRY_TEMPLATE);
}
public OllamaChatModel(OllamaApi ollamaApi, OllamaChatOptions options, ToolCallingManager toolCallingManager,
ObservationRegistry observationRegistry, ModelManagementOptions modelManagementOptions,
RetryTemplate retryTemplate) {
Assert.notNull(ollamaApi, "ollamaApi must not be null");
...
this.modelManager = new OllamaModelManager(this.chatApi, modelManagementOptions); // ① 拉 additionalModels
this.retryTemplate = retryTemplate;
String model = options.getModel();
Assert.state(model != null, "model must not be null");
initializeModel(model, modelManagementOptions.pullModelStrategy()); // ② 拉主模型
}
java
// OllamaChatModel.java:474-478
private void initializeModel(String model, @Nullable PullModelStrategy pullModelStrategy) {
if (pullModelStrategy != null && !PullModelStrategy.NEVER.equals(pullModelStrategy)) {
this.modelManager.pullModel(model, pullModelStrategy);
}
}
★ 一次 bean 创建里两轮 拉取:① OllamaModelManager 构造器拉 additionalModels(第 22 章 22.16.3),② OllamaChatModel 构造器拉主模型 。两者都在同一个 @Bean 方法里同步阻塞执行。
★ OllamaChatModel 的元数据是各家中最丰富的------五个 Duration:
java
// OllamaChatModel.java:155-206(from(...) 方法)
Duration evalDuration = response.getEvalDuration();
Duration promptEvalDuration = response.getPromptEvalDuration();
Duration loadDuration = response.getLoadDuration();
Duration totalDuration = response.getTotalDuration();
...
return ChatResponseMetadata.builder()
.usage(aggregatedUsage)
.model(response.model())
.keyValue(METADATA_CREATED_AT, response.createdAt())
.keyValue(METADATA_EVAL_DURATION, evalDuration)
.keyValue(METADATA_EVAL_COUNT, aggregatedUsage.getCompletionTokens())
.keyValue(METADATA_LOAD_DURATION, loadDuration)
.keyValue(METADATA_PROMPT_EVAL_DURATION, promptEvalDuration)
.keyValue(METADATA_PROMPT_EVAL_COUNT, aggregatedUsage.getPromptTokens())
.keyValue(METADATA_TOTAL_DURATION, totalDuration)
.keyValue(DONE, response.done())
.build();
★ 而且这些 Duration 会跨轮累加 (:168-184):previousChatResponse 非空时把上一轮的四个 duration 加进来。这是唯一一家做时长累加的(其它家只累加 token)。
★ 另外 Ollama 是唯一把 thinking 放进 AssistantMessage.properties 的(:247-249),并且要求 promptEvalCount != null && evalCount != null 才生成 ChatGenerationMetadata(:257)------★ 否则 generationMetadata = ChatGenerationMetadata.NULL,finishReason 直接丢失。
23.7 ★ Bedrock:唯一需要两个 AWS bean 的供应商
23.7.1 两个 bedrock 模块,只有一个有 ChatModel
bash
find models/spring-ai-bedrock/src/main -name '*.java' | grep -i chat # 无输出
find models/spring-ai-bedrock-converse/src/main -name '*ChatModel.java'
# BedrockProxyChatModel.java
| 模块 | 内容 | 有没有 ChatModel |
|---|---|---|
spring-ai-bedrock |
MessageToPromptConverter、cohere/、titan/、api/AbstractBedrockApi |
✘(只有 embedding) |
spring-ai-bedrock-converse |
BedrockProxyChatModel + api/ 下 8 个辅助类 |
✔ |
★ 老模块里那个 MessageToPromptConverter 是 1.x "invoke API"(把消息拼成纯文本 prompt)的遗留;2.x 主推的是 Converse API。
23.7.2 ★ @ConditionalOnBean 双条件:缺一个就静默不注册
java
// BedrockConverseProxyChatAutoConfiguration.java:52-69
@AutoConfiguration
@EnableConfigurationProperties({ BedrockConverseProxyChatProperties.class, BedrockAwsConnectionConfiguration.class })
@ConditionalOnClass({ BedrockProxyChatModel.class, BedrockRuntimeClient.class, BedrockRuntimeAsyncClient.class })
@ConditionalOnProperty(name = SpringAIModelProperties.CHAT_MODEL, havingValue = SpringAIModels.BEDROCK_CONVERSE,
matchIfMissing = true)
@Import(BedrockAwsConnectionConfiguration.class)
public class BedrockConverseProxyChatAutoConfiguration {
@Bean
@ConditionalOnMissingBean
@ConditionalOnBean({ AwsCredentialsProvider.class, AwsRegionProvider.class }) // ★★ 两个
public BedrockProxyChatModel bedrockProxyChatModel(AwsCredentialsProvider credentialsProvider,
AwsRegionProvider regionProvider, BedrockAwsConnectionProperties connectionProperties,
...) {
★ 这是第 22 章 22.10.1 里"11 个 @ConditionalOnBean"中最关键的一处。它的特点:
- 类级条件全部通过 (
@ConditionalOnClass满足、@ConditionalOnProperty默认matchIfMissing = true),但 bean 级还有一个@ConditionalOnBean双条件。 AwsCredentialsProvider与AwsRegionProvider都来自 Spring Cloud AWS (类注释写 "Leverages the Spring Cloud AWS to resolve the AwsCredentialsProvider")。没引 Spring Cloud AWS starter → 这两个 bean 不存在 →BedrockProxyChatModel静默不注册。- ★ 后果:你引了
spring-ai-starter-model-bedrock-ai,启动成功 ,但注入ChatModel时NoSuchBeanDefinitionException。没有任何 warn------因为@ConditionalOnBean不匹配是"正常"的条件回退。
★ 对比:Amazon Bedrock 也是唯一一家 ChatModel 名字带 Proxy 的(BedrockProxyChatModel),以及唯一一家 @ConditionalOnProperty 的 havingValue 是 SpringAIModels.BEDROCK_CONVERSE 而不是简写的。
23.7.3 五个超时 + 一个自带 SSRF 防护的 MediaFetcher
java
// BedrockProxyChatModel.java:859-885(Builder 默认值)
public static final class Builder {
private @Nullable AwsCredentialsProvider credentialsProvider;
private @Nullable Region region;
private Duration timeout = Duration.ofMinutes(5L); // 整体
private Duration connectionTimeout = Duration.ofSeconds(5L);
private Duration asyncReadTimeout = Duration.ofSeconds(30L);
private Duration connectionAcquisitionTimeout = Duration.ofSeconds(30L);
private Duration socketTimeout = Duration.ofSeconds(30L);
...
}
★ 五个超时,且默认值分了三个量级 (5 分钟 / 5 秒 / 30 秒)。这是所有供应商里超时细分最细的一家(因为 AWS SDK 的 ApacheHttpClient 本身就分这些)。
MediaFetcher 是 Bedrock 独有的多模态 URL 抓取器,自带 SSRF 防护:
java
// MediaFetcher.java:76-82
public final class MediaFetcher {
public static final int DEFAULT_MAX_FETCH_SIZE_BYTES = 40 * 1024 * 1024; // 40 MB
java
// URLValidator.java:147-156
/**
* Returns {@code true} if the given address is a loopback, link-local, site-local, or
* wildcard address. Covers both IPv4 and IPv6 private/internal ranges.
*/
public static boolean isBlockedAddress(InetAddress address) {
return address.isLoopbackAddress() || address.isLinkLocalAddress() || address.isSiteLocalAddress()
|| address.isAnyLocalAddress();
}
★ 防护是三层的:
- DNS 解析层 :自定义
DnsResolver(:304-309)解析出地址后立刻isBlockedAddress校验; - Socket 连接层 :自定义
SocketFactory(:250/:279)在真正 connect 前再校验一次(防 DNS rebinding); - allowlist(可选) :
MediaFetcher(Set<String> allowedHosts),支持*.example.com通配。
java
// MediaFetcher.java:100-119
/**
* Creates a {@code MediaFetcher} with no host allowlist (blocklist-only protection).
*/
public MediaFetcher() {
this(Set.of());
}
...
public MediaFetcher(Set<String> allowedHosts) {
this.allowedHosts = Set.copyOf(allowedHosts);
this.restClient = createSsrfSafeRestClient();
}
★ 注意:无参构造是 "blocklist-only" (只挡内网,不限制外网主机)。也就是说默认情况下任何公网 URL 都会被抓取 ------如果你把用户输入的 URL 传给 Bedrock 的多模态消息,等于开放了一个 40MB 的 SSRF 代理(只挡内网)。要限制就得显式传 allowedHosts。
★ 另外 URLValidator 里有个被自己标记为 @Deprecated 的方法:
java
// URLValidator.java:54-68
/**
* Check if the string looks like a URL using a simple regex pattern to disstinct it
* from base64 or other text. ...
* @deprecated This method is not sufficient for security-sensitive URL validation and
* should not be relied upon for security-critical checks. Use
* {@link #isValidURLStrict(String)} instead for robust validation.
*/
@Deprecated
public static boolean isValidURLBasic(String urlString) { ... }
★ 这个 @Deprecated 的 Javadoc 是全 Spring AI 里少见的"安全自省"------明确说 basic 版不能满足安全需求。但注意:isValidURLBasic 在全仓库是否还有调用点值得你自己 grep 确认(本章复核命令里给了)。
23.7.4 缓存策略与 CONVERSATION_HISTORY 的"最后一条 user"
java
// BedrockProxyChatModel.java:247-270(createRequest 节选)
BedrockCacheOptions cacheOptions = options.getCacheOptions();
boolean shouldCacheConversationHistory = cacheOptions != null
&& cacheOptions.getStrategy() == BedrockCacheStrategy.CONVERSATION_HISTORY;
// Get all non-system messages
List<org.springframework.ai.chat.messages.Message> allNonSystemMessages = prompt.getInstructions()
.stream()
.filter(message -> message.getMessageType() != MessageType.SYSTEM)
.toList();
// Find the last user message index for CONVERSATION_HISTORY caching
int lastUserMessageIndex = -1;
if (shouldCacheConversationHistory) {
for (int i = allNonSystemMessages.size() - 1; i >= 0; i--) {
if (allNonSystemMessages.get(i).getMessageType() == MessageType.USER) {
lastUserMessageIndex = i;
break;
}
...
★ Bedrock 的 CONVERSATION_HISTORY 缓存策略与 Anthropic 的实现不同:它是从后往前找最后一条 USER 消息 、在那儿打 cache point,而不是给每条消息都打(CacheBreakpointTracker 最多 4 个的限制在 Bedrock 侧由 buildCachePoint 单独处理)。
23.8 ★ Google GenAI:SDK 客户端 + Spring RetryTemplate 的"混合派"
Google GenAI 是本章最后一个还没展开的 ChatModel(1230 行),也是唯一打破 23.2.3 那条判据 的一家:它用官方 SDK 客户端,却用 Spring 的 RetryTemplate 做重试。这一节把它单独拆开,顺带把 23.0 总纲第 5 条($defs 三家三种处理)补齐------Google 正是三家之中唯一"快速失败"的一家。
23.8.1 模块边界:chat 类在根包,ChatModel 已经继承 StreamingChatModel
java
// spring-ai-model/src/main/java/org/springframework/ai/chat/model/ChatModel.java:30
public interface ChatModel extends Model<Prompt, ChatResponse>, StreamingChatModel {
// GoogleGenAiChatModel.java:153
public class GoogleGenAiChatModel implements ChatModel, DisposableBean {
★ ChatModel 本身 extends StreamingChatModel,所以 Google 只声明 implements ChatModel, DisposableBean 就够了------AnthropicChatModel 那个 implements ChatModel, StreamingChatModel 里的 StreamingChatModel 是冗余声明 (写成两个只是更醒目)。二者都能流式,判定能不能流式要看有没有 stream(Prompt) 实现,而不是看有没有显式 implements。
包结构与 openai/anthropic 一致(chat 类直接放 org.springframework.ai.google.genai 根包),与 deepseek/mistral 的 api/ 子包不同。整个模块 4404 行,分布如下:
| 文件 | 行数 |
|---|---|
GoogleGenAiChatModel |
1230 |
GoogleGenAiChatOptions |
796 |
cache/ 四个类(Service / Content / Request / UpdateRequest) |
481 / 266 / 179 / 89 |
schema/JsonSchemaConverter + GoogleGenAiToolCallingManager |
219 / 99 |
metadata/ 三个类(Usage / ModalityTokenCount / TrafficType) |
202 / 107 / 98 |
common/ 四个类(SafetySetting / ThinkingLevel / ServiceTier / Constants) |
190 / 76 / 52 / 34 |
MimeTypeDetector + aot/GoogleGenAiRuntimeHints |
121 / 45 |
★ 注意 GoogleGenAiConstants 只有 34 行、只装一个常量(见 23.9),是全仓库最"空"的常量类;而 GoogleGenAiChatOptions 有 796 行------Google 的复杂度全在 Options 和 ChatModel 里,不在常量层。
★ DisposableBean 是空实现:
java
// GoogleGenAiChatModel.java:990-992
@Override
public void destroy() throws Exception {
// GenAI Client doesn't need explicit closing
}
也就是说 Google 是唯一实现 DisposableBean 却什么都不关的 ChatModel。com.google.genai.Client 的生命周期交给 SDK 自己;别指望 Spring 容器销毁时帮你释放连接池。
23.8.2 ★ 混合派:SDK 客户端 + RetryUtils.execute(修正 23.2.3)
Google 用官方 SDK 的 com.google.genai.Client(private final Client genAiClient,:159),属于"SDK 派"------模块里没有 GoogleGenAiApi.java。但它偏偏用 Spring 的重试:
java
// GoogleGenAiChatModel.java:166 + :212 + :420 + :1030 + :1052
private final RetryTemplate retryTemplate;
Assert.notNull(retryTemplate, "RetryTemplate must not be null"); // :212 构造器
return RetryUtils.execute(this.retryTemplate, () -> { ... }); // :420 internalCall
private RetryTemplate retryTemplate = RetryUtils.DEFAULT_RETRY_TEMPLATE; // :1030 Builder
public Builder retryTemplate(RetryTemplate retryTemplate) { ... } // :1052
所以 23.2.3 的"SDK 派用 SDK 的 maxRetries、自研派用 RetryTemplate"这条二分判据必须改成三分:
| 供应商 | HTTP 客户端 | 重试落点 | 默认值 |
|---|---|---|---|
| OpenAI | 官方 SDK OpenAIClient |
SDK ClientOptions.maxRetries |
3(AbstractOpenAiOptions:40 DEFAULT_MAX_RETRIES,chat/embedding/image/audio 共用) |
| Anthropic | 官方 SDK AnthropicClient |
SDK maxRetries |
2(AnthropicSetup.DEFAULT_MAX_RETRIES;由 AnthropicChatAutoConfiguration:74 注入) |
| Google GenAI | 官方 SDK Client |
Spring RetryTemplate (仅 call) |
RetryUtils.DEFAULT_RETRY_TEMPLATE(10 次) |
| Bedrock | AWS SDK | AWS SDK 重试 | 见 23.7.3 五个超时 |
| DeepSeek / Mistral / Ollama | 自研 *Api(RestClient+WebClient) |
Spring RetryTemplate |
10 次 |
★ 两个容易踩的点:
- 重试只包
call:RetryUtils.execute在internalCall:420,internalStream直接generateContentStream(...)(:481 之后)------流式完全没有重试 ,一次网络抖动就是Flux直接 error。 - 构造器
Assert.notNull:自己new GoogleGenAiChatModel.Builder(...)时若传 nullretryTemplate,Assert.notNull(retryTemplate, "RetryTemplate must not be null")(:212)会在启动期炸掉,不是运行期。
这也是"混合派"名字的来源:HTTP 层是 SDK 的,重试层是 Spring 的------观测、重试、HTTP 三者不统一。
23.8.3 ★ $defs 有就抛异常:JsonSchemaConverter 全表
java
// schema/JsonSchemaConverter.java:64-83
public static ObjectNode convertToOpenApiSchema(ObjectNode jsonSchemaNode) {
Assert.notNull(jsonSchemaNode, "JSON Schema node must not be null");
Assert.isTrue(!jsonSchemaNode.has("$defs"), "Google's Structured Output schema doesn't support $defs property");
try {
ObjectNode openApiSchema = convertSchema(jsonSchemaNode, JacksonUtils.getDefaultJsonMapper().getNodeFactory());
if (!openApiSchema.has("openapi")) { openApiSchema.put("openapi", "3.0.0"); }
return openApiSchema;
}
catch (Exception e) {
throw new IllegalStateException("Failed to convert JSON Schema to OpenAPI format: " + e.getMessage(), e);
}
}
★ Assert.isTrue 在 try 之前 (:66 vs :68),所以 $defs 抛的是 IllegalArgumentException,不是 IllegalStateException。这是三家之中唯一"快速失败"的(OpenAI 全量透传、Anthropic 静默剥离)。
转换由两个方法完成,convertSchema(:209-217)只是 copyCommonProperties + handleJsonSchemaSpecifics 的组合:
① copyCommonProperties(:90-105)------白名单 17 个键
arduino
format, description, default, maximum, minimum, maxLength, minLength, pattern, enum,
multipleOf, uniqueItems, // 核心 schema 属性
example, deprecated, readOnly, writeOnly, discriminator, xml, externalDocs // OpenAPI 特有
★ 白名单意味着:不在名单里的键被静默丢弃 。你 schema 里写的 title、$comment、x-* 扩展、const、examples(数组形式)全部消失,且不报错。对比 OpenAI 的 getChatCompletionTools(23.4.3)是透传整个 schemaMap ,Google 是重建一个只含白名单键的新对象------这是"转换"与"透传"的根本差别。
② handleJsonSchemaSpecifics(:113-201)
| 处理的键 | 行为 | 行号 |
|---|---|---|
type |
数组时挑出 "null" → nullable=true;剩下 1 个收敛成标量,>1 保留数组;标量直接拷 |
:119-145 |
nullable |
有则直拷;上面判出 nullable 再补 put("nullable", true) |
:146-151 |
properties |
递归 convertSchema |
:154-163 |
required |
直拷 | :166-168 |
additionalProperties |
布尔直拷;对象是递归 | :171-180 |
items |
对象是递归 (数组形式的 items 不处理) |
:183-189 |
allOf / anyOf / oneOf |
target.putArray(combiner).addAll((ArrayNode) combinerNode)------原样搬,不递归 |
:192-200 |
★ 最后一行是隐藏陷阱:allOf/anyOf/oneOf 三支是整体搬运不做转换 ,所以这三支内部的 type: "string" 不会被转成大写、内部的 $defs 也不会被 Assert 发现------和 23.8.4 的 convertTypeValuesToUpperCase 只在顶层跑一次是同一个问题的两面。
最后注入 openapi: "3.0.0"(:74-76):只有不存在 时才 put(if (!openApiSchema.has("openapi"))),所以你自己写了 openapi 键会被保留------但它不在 copyCommonProperties 白名单里,本来也不会被拷进来,实际等价于"永远是 3.0.0"。
三家 $defs 处理对照(23.0 总纲第 5 条落地)
| 供应商 | 落点 | 对 $defs |
对 $ref |
失败方式 |
|---|---|---|---|---|
| OpenAI | getChatCompletionTools:1001-1045(全量透传 schemaMap)+ applyStrictModeRequirements:1065-1099 |
保留,strict 模式还会递归修正 | strict 模式下被包进 anyOf,不断链 |
strict 校验失败只 logger.error |
| Anthropic | toAnthropicTool:1270-1310(只取 properties+required)+ AnthropicChatOptions:614-640(跳过 $schema/$defs) |
丢失 | 断链 ($ref 指向的 $defs 没了) |
静默 |
| Google GenAI | JsonSchemaConverter:66 |
抛异常 | 结构化输出同理($defs 直接用不了) |
IllegalArgumentException |
23.8.4 工具 schema 的两段转换链与 convertTypeValuesToUpperCase
java
// schema/GoogleGenAiToolCallingManager.java:66-79(节选)
public List<ToolDefinition> resolveToolDefinitions(ToolCallingChatOptions chatOptions) {
List<ToolDefinition> toolDefinitions = this.delegateToolCallingManager.resolveToolDefinitions(chatOptions);
return toolDefinitions.stream().map(td -> {
ObjectNode jsonSchema = JsonSchemaConverter.fromJson(td.inputSchema());
ObjectNode openApiSchema = JsonSchemaConverter.convertToOpenApiSchema(jsonSchema);
JsonSchemaGenerator.convertTypeValuesToUpperCase(openApiSchema);
return DefaultToolDefinition.builder()
.name(td.name()).description(td.description())
.inputSchema(openApiSchema.toPrettyString())
.build();
}).toList();
}
★ 三步:解析 → 转 OpenAPI → 类型值转大写 ("string" → "STRING",OpenAPI/Gemini 的要求)。第三步是 Google 独有的一步,也是最容易出错的一步------它只处理它能认出的 type 节点(allOf/anyOf/oneOf 内部的不转,见 23.8.3)。
executeToolCalls(:88-90)纯委托 给 delegateToolCallingManager------这个 manager 只改 schema,不改执行语义。构造器里的包装逻辑:
java
// GoogleGenAiChatModel.java:222-226
if (toolCallingManager instanceof GoogleGenAiToolCallingManager) { this.toolCallingManager = toolCallingManager; }
else { /* 包一层 GoogleGenAiToolCallingManager,你传的变成 delegate */ }
★ 所以你在 Builder 里传自定义 ToolCallingManager 时,它会被降级成 delegate :schema 转换仍然强制走 Google 那套(包括 $defs 抛异常),你自己的 resolveToolDefinitions 结果会被再加工一遍。想彻底接管必须自己传一个 GoogleGenAiToolCallingManager 子类(或者接受它被包一层)。
其余相关:
jsonToSchema(:389-391)用带SchemaMixin的JsonMapper把 JSON 反序列化成 SDK 的Schema;SchemaMixin+@JsonDeserialize(builder = Schema.Builder.class)(:1225-1226)是为了让 Jackson 能构造 SDK 的 builder 类型------这是 AOT/反射之外的一条隐藏反射点。GeminiRequest是 record(:1221):record GeminiRequest(List<Content> contents, String modelName, GenerateContentConfig config)。
23.8.5 ★ thinking:thinkingBudget vs thinkingLevel 的硬编码矩阵
thinkingConfig 只在三个选项任一非空时才构建(:705-725):
java
if (requestOptions.getThinkingBudget() != null || requestOptions.getIncludeThoughts() != null
|| requestOptions.getThinkingLevel() != null) {
if (requestOptions.getThinkingLevel() != null) {
validateThinkingLevelForModel(requestOptions.getThinkingLevel(), modelName);
}
ThinkingConfig.Builder thinkingBuilder = ThinkingConfig.builder();
// thinkingBudget / includeThoughts / thinkingLevel 逐个 set
configBuilder.thinkingConfig(thinkingBuilder.build());
}
★ 全仓库唯一一处"Spring AI 硬编码了供应商模型能力矩阵":
java
// GoogleGenAiChatModel.java:879-902 THINKING_LEVEL_SUPPORT_BY_MODEL(9 个模型 id 写死)
Map.entry(ChatModel.GEMINI_2_5_FLASH.getValue(), EnumSet.noneOf(GoogleGenAiThinkingLevel.class)),
Map.entry(ChatModel.GEMINI_2_5_FLASH_LIGHT.getValue(), EnumSet.noneOf(GoogleGenAiThinkingLevel.class)),
Map.entry(ChatModel.GEMINI_3_0_PRO_PREVIEW.getValue(), EnumSet.of(LOW, HIGH)),
Map.entry(ChatModel.GEMINI_3_1_PRO_PREVIEW.getValue(), EnumSet.of(LOW, MEDIUM, HIGH)),
Map.entry(ChatModel.GEMINI_3_FLASH_PREVIEW.getValue(), EnumSet.of(MINIMAL, LOW, MEDIUM, HIGH)),
Map.entry(ChatModel.GEMINI_3_5_FLASH.getValue(), EnumSet.of(MINIMAL, LOW, MEDIUM, HIGH)),
Map.entry(ChatModel.GEMINI_3_5_FLASH_LITE.getValue(), EnumSet.of(MINIMAL, LOW, MEDIUM, HIGH)),
Map.entry(ChatModel.GEMINI_3_6_FLASH.getValue(), EnumSet.of(MINIMAL, LOW, MEDIUM, HIGH)),
Map.entry(ChatModel.GEMINI_3_1_FLASH_LITE_IMAGE.getValue(), EnumSet.of(MINIMAL, HIGH)),
gemini-2.5-flash/gemini-2.5-flash-light→EnumSet.noneOf(...):完全不支持thinkingLevel,只能用thinkingBudget。validateThinkingLevelForModel(:913-932):先把 modelName 取最后一段(substring(lastIndexOf('/') + 1),兼容 Vertex 风格全资源名projects/.../models/{model})再小写;★ 不在 map 里就return不校验 ;在 map 里但集合为空 →IllegalArgumentException "... This model does not support thinkingLevel; use thinkingBudget instead.";非空但不包含 →... Supported levels: ...。- ★ Javadoc(:872-878)自己承认:
gemini-2.5-pro的thinkingLevel支持是按账号/API key 的,客户端验不了,所以故意不进矩阵。结论:新模型不在 map 里 = 不校验 = 不会报错(反面好处);而 Google 一旦给 2.5-flash 加上 thinkingLevel 支持,Spring AI 不改代码你就用不了。
配套枚举:GoogleGenAiThinkingLevel(76 行)、GoogleGenAiServiceTier(52 行,configBuilder.serviceTier(...) 用 .getValue(),:737-739)。
23.8.6 缓存内容、safety settings 与"按模态统计 token"
- ★
cachedContentService由四个条件决定(:219-221):
java
this.cachedContentService = (genAiClient != null && genAiClient.caches != null && genAiClient.async != null
&& genAiClient.async.caches != null) ? new GoogleGenAiCachedContentService(genAiClient) : null;
缺任何一个就是 null,getCachedContentService()(:985-986)返回 null。缓存内容 API 是 cache/ 子包 4 个类 1015 行的独立体系(GoogleGenAiCachedContentService 481 / GoogleGenAiCachedContent 266 / CachedContentRequest 179 / CachedContentUpdateRequest 89)。
- 真正用上缓存要两个开关同时成立(:790-795):
useCachedContent == TRUE && cachedContentName != null才configBuilder.cachedContent(...),且只打 debug 日志------配了名字但开关没开,请求照发不缓存,无任何日志。 safetySettings(:733-734)→toGeminiSafetySettings(:948-956)→GoogleGenAiSafetySetting(190 行,含HarmCategory/HarmBlockThreshold嵌套枚举与两个mapToGenAi*映射方法)。★ 与其它供应商最大的差别:Google 把"内容安全阈值"做成了一等公民选项(HarmBlockThreshold可逐类配置),OpenAI/Anthropic 侧没有对等物。googleSearchRetrieval(:753-759):只有prompt.getOptions() instanceof GoogleGenAiChatOptions且TRUE.equals(getGoogleSearchRetrieval())才追加Tool.builder().googleSearch(...)。★ 注意它读的是prompt.getOptions(),而不是前面合并出来的requestOptions------两处来源不同,走"运行时 Prompt 覆盖"路径时行为要留意。- 元数据三家最细:
GoogleGenAiUsage(202)+GoogleGenAiModalityTokenCount(107)+GoogleGenAiTrafficType(98)。★ Google 是唯一提供按模态(modality)拆分 token 与流量类型 的供应商;Ollama 是唯一提供五个Duration的(23.6.4),两边各有各的"元数据过剩"。 - AOT:
aot/GoogleGenAiRuntimeHints(45 行)。
23.8.7 流式:与 OpenAI 同款的 openScope 修复,但没有 ChunkMerger
java
// GoogleGenAiChatModel.java:461-474(与 OpenAiChatModel:295-298 完全同款)
return Flux.deferContextual(contextView -> {
ChatModelObservationContext observationContext = ChatModelObservationContext.builder()
.prompt(prompt).provider(GoogleGenAiConstants.PROVIDER_NAME).streaming(true).build();
Observation observation = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION.observation(...);
Observation parentObservation = contextView.getOrDefault(ObservationThreadLocalAccessor.KEY, null);
observation.parentObservation(parentObservation);
try (Observation.Scope ignored = parentObservation != null ? parentObservation.openScope()
: Observation.Scope.NOOP) {
observation.start();
}
...
★ Google 修了 Anthropic 没修的那个问题(23.5.2 缺陷一):HTTP span 能挂到 gen_ai.client.operation 下面。而且它也没有缺陷二------SDK 直接给 ResponseStream<GenerateContentResponse>,不需要 withRawResponse + boundedElastic 那套:
java
// :481-499(节选)
ResponseStream<GenerateContentResponse> responseStream = this.genAiClient.models
.generateContentStream(request.modelName, request.contents, request.config);
Flux<ChatResponse> chatResponseFlux = Flux.fromIterable(responseStream).concatMap(response -> {
... Usage cumulativeUsage = UsageCalculator.getCumulativeUsage(currentUsage, previousChatResponse);
return Flux.just(new ChatResponse(generations, toChatResponseMetadata(cumulativeUsage, response.modelVersion().get())));
});
★ 代价与收益:Google 不需要 ChunkMerger,所以也没有 OpenAI 那三个"只看 choices.get(0)"的陷阱(23.4.2);但反过来,delta 归并由 SDK 全权完成,你失去了对归并过程的任何控制 (想改 tool call 按 index 归并的逻辑,在 Google 侧做不到)。每个 chunk 都会 UsageCalculator.getCumulativeUsage(...) 累加一次(-:494)。
23.9 ★ provider 标识:同一件事的三套写法(23.0 总纲第 6 条落地)
AiProvider 枚举是唯一真源(spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java),10 个值:
scss
ANTHROPIC("anthropic") BEDROCK_CONVERSE("bedrock_converse") DEEPSEEK("deepseek")
GOOGLE_GENAI_AI("google_genai") MISTRAL_AI("mistral_ai") OLLAMA("ollama")
ONNX("onnx") OPENAI("openai") SPRING_AI("spring_ai")
VERTEX_AI("vertex_ai")
但取到这个值有三套写法 。models/ 下全仓库共 20 处 .provider((排除测试):
| 写法 | 代表落点 | 模块 |
|---|---|---|
① 直接用枚举 AiProvider.X.value() |
AnthropicChatModel:315/551、BedrockProxyChatModel:215/785、OpenAiChatModel:215/281、OpenAiEmbeddingModel:242、OpenAiImageModel:201、TransformersEmbeddingModel:320(ONNX)、VertexAiTextEmbeddingModel:127 |
7 处 |
② 模块常量类 XxxConstants.PROVIDER_NAME |
DeepSeekChatModel:142/217(DeepSeekConstants)、GoogleGenAiChatModel:412/463(GoogleGenAiConstants) |
4 处 |
③ Api 类常量 XxxApi.PROVIDER_NAME |
MistralAiChatModel:181/237、MistralAiEmbeddingModel:117(MistralAiApi)、OllamaChatModel:228/293、OllamaEmbeddingModel:117(OllamaApiConstants) |
6 处 |
★ 三套最终都指向同一个 AiProvider 值------它们只是转发:
java
// DeepSeekConstants / GoogleGenAiConstants / MistralAiApi / OllamaApiConstants
public static final String PROVIDER_NAME = AiProvider.DEEPSEEK.value(); // "deepseek"
public static final String PROVIDER_NAME = AiProvider.GOOGLE_GENAI_AI.value(); // "google_genai"
public static final String PROVIDER_NAME = AiProvider.MISTRAL_AI.value(); // "mistral_ai"
public static final String PROVIDER_NAME = AiProvider.OLLAMA.value(); // "ollama"
所以 metrics 里的 gen_ai.system 不会因为写法不同而错乱 ------三套是等价的,纯属历史与 import 便利性问题(OllamaApiConstants.PROVIDER_NAME 之所以放 Api 类,是因为 Ollama 没有独立的 Constants 类;DeepSeek 有 DeepSeekConstants 就放那儿)。
★ 但真正的坑是同一模块组内部不统一------Google:
| Google 模块 | 写法 |
|---|---|
GoogleGenAiChatModel:412/463 |
GoogleGenAiConstants.PROVIDER_NAME(② 常量类) |
GoogleGenAiTextEmbeddingModel:132 |
AiProvider.GOOGLE_GENAI_AI.value()(① 直接枚举) |
GoogleGenAiImageModel:120 |
AiProvider.GOOGLE_GENAI_AI.value()(① 直接枚举) |
同一供应商三个模块、两套写法。★ 这也是"为什么 34 行的 GoogleGenAiConstants 只被 chat 模块引用"的原因------另外两个模块嫌绕,直接写了枚举。
★ 两个空转的枚举值:SPRING_AI("spring_ai") 全仓库零引用 (没有任何 .provider(AiProvider.SPRING_AI.value()));ONNX 只被 TransformersEmbeddingModel 用、VERTEX_AI 只被 VertexAiTextEmbeddingModel 用------这两个有 provider 值但没有 ChatModel(呼应 23.1.3)。
给写自己的 Model 的人的结论 :三套都能用,但要整模块统一 ;最稳的是直接 AiProvider.X.value()(枚举是唯一真源,常量类只是省一次 import 的转发层)。如果你要新增供应商而 AiProvider 里没有对应值,只能先用字符串常量------这时务必确认它与 AiProvider 现有值不重名,否则 metrics 会混在一起。
23.10 陷阱总表
本章的坑按"你会在什么阶段撞上"分成六组。编号只在本章内有效。
23.10.1 选型期:你以为有的东西其实没有(1--6)
| # | 坑 | 证据 | 后果 / 规避 |
|---|---|---|---|
| 1 | OpenAiApi 在 2.0.1 不存在 |
find models -name "OpenAiApi.java" 无输出 |
1.x 里所有 new OpenAiApi(...) 的写法全部失效;改用 OpenAiSetup.setupSyncClient(...) 或 Spring Boot 自动配置 |
| 2 | 同理 AnthropicApi / GoogleApi 也不存在 |
自研 Api 派只有 DeepSeek/Mistral/Ollama 有 *Api.java |
"有 *Api 类"成了区分两大阵营的可靠判据,别指望 OpenAI 侧有 |
| 3 | 全仓库只有 7 个 ChatModel 实现 |
Anthropic/OpenAI/Google/Bedrock/Mistral/DeepSeek/Ollama | 15 个 models/* 模块里一半没有 chat(embedding/image/audio 专属) |
| 4 | AiProvider 有 10 个值,但 SPRING_AI 零引用 |
grep -rn "\.provider(" models/ 20 处无 SPRING_AI |
别把它当"自定义供应商"的预留位用,它就是个没接线的枚举值 |
| 5 | ONNX / VERTEX_AI 有 provider 值但没有 ChatModel |
TransformersEmbeddingModel:320、VertexAiTextEmbeddingModel:127 |
做"全供应商 chat 路由"时这两个要显式排除 |
| 6 | "实现了 StreamingChatModel" 不是流式的判据 |
ChatModel extends Model, StreamingChatModel(ChatModel.java:30) |
AnthropicChatModel 的 implements ChatModel, StreamingChatModel 里后者是冗余的;能不能流式要看有没有 stream() 实现 |
23.10.2 OpenAI:Setup 与 ChunkMerger(7--14)
| # | 坑 | 证据 | 后果 / 规避 |
|---|---|---|---|
| 7 | apiKey 为空不报错 ,走 no-auth 模式,请求头被摘掉 Authorization |
OpenAiSetup:64-73 + :228-240 |
指向 vLLM/Ollama/自建网关时这是特性;指向真正的 OpenAI 时会拿到 401,排查方向容易跑偏 |
| 8 | ★ GITHUB_MODELS 分支会丢弃你写的 baseUrl |
calculateBaseUrl:304-336 不以 GITHUB_MODELS_URL 开头就无条件返回常量 |
你以为在打自建网关,实际一直在打 models.github.ai |
| 9 | MICROSOFT_FOUNDRY 要求必须有 baseUrl,且只削一个尾 / 或 ? |
同上分支 | 结尾两个斜杠削不掉,拼出 ...// 的怪路径 |
| 10 | 环境变量里 AZURE_OPENAI_BASE_URL 覆盖 OPENAI_BASE_URL(第二个 if 无 else) |
detectBaseUrlFromEnv |
两个都设时你以为是后者生效 |
| 11 | ★ hasToolCall / toolCallsDone 只看 choices.get(0) |
ChunkMerger:1199-1204 |
供应商若返回多 choice(n>1),第 2 个及以后的 tool call 全程看不见 |
| 12 | strict 模式默认 false,schema 不会被改写 | getChatCompletionTools:1001-1045 |
想要 OpenAI 帮你补 required/additionalProperties=false 必须显式开 |
| 13 | strict 校验失败只 logger.error,不抛异常 |
同上 catch 块 | 静默降级成非 strict,你以为生效了其实没有 |
| 14 | reasoning 有两个 key(reasoningContent / reasoning_content) |
getReasoningContent:1142-1154 |
换供应商/换模型时 reasoning 突然"消失",多半是 key 对不上 |
23.10.3 Anthropic:Javadoc 自己写明的两缺陷与 schema 剥离(15--22)
| # | 坑 | 证据 | 后果 / 规避 |
|---|---|---|---|
| 15 | ★★ 流式 HTTP span 挂不到 gen_ai.client.operation 下 |
AnthropicChatModel:266-285 Javadoc |
SDK async 调度到 ForkJoinPool.commonPool() 丢了 context;OpenAI/Google 用 openScope() 修了,Anthropic 没有 |
| 16 | ★★ 流式会占住一个 boundedElastic worker 线程整个流期间 |
AnthropicChatModel:287-307 + :336-346 |
为读 rate-limit 头用了 withRawResponse();并发流式一多就吃满 boundedElastic |
| 17 | ★ 自定义 HttpClient customizer 改的 backend/meterTags 会被覆盖 | AnthropicSetup:327-336 |
注释解释了原因:sync/async 用相同 tag 会触发 MeterRegistry 重复 gauge 注册报错 |
| 18 | ★ 结构化输出侧 continue 掉 $schema 和 $defs |
AnthropicChatOptions:624 |
schema 里的 $ref 指向的 $defs 没了 → 断链,且不报错 |
| 19 | ★ toAnthropicTool 只取 properties + required |
AnthropicChatModel:1270-1310 |
工具 schema 的其它键(含 $defs)静默丢失 |
| 20 | 缓存断点最多 4 个,超出只 warn 一次 | CacheBreakpointTracker:22-56 |
非 static、每请求一实例、hasWarned 每请求重置 |
| 21 | TOOLS_ONLY → extractEligibleMessageTypes 返回 Set.of() |
CacheEligibilityResolver:75-82 |
消息类型全不缓存;配了 TOOLS_ONLY 却以为消息也被缓存了 |
| 22 | CacheEligibilityResolver 用 Assert.state(IllegalStateException)而非 Assert.isTrue |
:84-106 两处 |
异常类型与其它供应商不一致,全局异常处理器要分开处理 |
23.10.4 自研 Api 派:DeepSeek / Mistral / Ollama(23--29)
| # | 坑 | 证据 | 后果 / 规避 |
|---|---|---|---|
| 23 | ★ DeepSeekChatModel.retryTemplate 是 public final 字段 |
DeepSeekChatModel 字段声明 |
唯一一家把重试模板暴露成公开字段;其余是 private final |
| 24 | DeepSeekApi 默认模型硬编码 DEEPSEEK_V4_FLASH,betaPrefixPath 默认 /beta |
DeepSeekApi 常量 |
换 baseUrl 指向代理时,路径仍带 /beta 前缀 |
| 25 | MistralAiApi 的 defaultStatusHandler 只装在 RestClient 上 ,WebClient 没有 |
MistralAiApi:92-131 |
同步与流式的错误处理不一致 |
| 26 | ★ Ollama 元数据要求 promptEvalCount != null && evalCount != null |
OllamaChatModel 构造函数 |
否则返回 ChatGenerationMetadata.NULL,finishReason 丢失 |
| 27 | Ollama 五个 Duration 会跨轮累加 |
OllamaChatModel.from(...) |
多轮对话里这些数字持续增长,别拿它当单次耗时 |
| 28 | ★ RetryUtils.execute 重试耗尽后把 RetryException 拆包成原始 cause |
RetryUtils:170-178 |
上层 catch (RetryException) 永远抓不到;要 catch 原始 ResourceAccessException 等 |
| 29 | RetryUtils 默认 10 次、初始 2s、×5、上限 3 分钟 |
RetryUtils 常量 |
最坏累计约 19 分钟;自研派三家都吃这套默认 |
23.10.5 Bedrock:条件装配与 SSRF(30--35)
| # | 坑 | 证据 | 后果 / 规避 |
|---|---|---|---|
| 30 | ★★ 缺 AwsCredentialsProvider 或 AwsRegionProvider 任一 → 静默不注册 |
BedrockConverseProxyChatAutoConfiguration:62 |
启动成功、无 warn,注入时才 NoSuchBeanDefinitionException。全仓库唯一的 @ConditionalOnBean 双条件 |
| 31 | 两个 bedrock 模块,只有 spring-ai-bedrock-converse 有 ChatModel |
spring-ai-bedrock 只有 embedding + MessageToPromptConverter |
引错 starter 会"编译过、启动过、没有 chat bean" |
| 32 | ★ 五个超时各有默认值(5m/5s/30s/30s/30s) | BedrockProxyChatModel.Builder:865-873(setter :915-939) |
超时细分最细的一家,但也最容易只改了其中一个以为改了全部 |
| 33 | MediaFetcher 默认拉取上限 40MB |
MediaFetcher:82 |
大文件静默失败 |
| 34 | SSRF 三层防护 + isValidURLBasic 被自标 @Deprecated |
MediaFetcher + URLValidator:153-156 |
用了 deprecated 方法 = 只剩基础校验 |
| 35 | CONVERSATION_HISTORY 是从后往前找最后一条 USER 打点 |
BedrockProxyChatModel:247-270 |
与 Anthropic 的"每条消息打点(上限 4)"语义不同,别照搬 |
23.10.6 Google GenAI:schema 转换与"混合派"(36--42)
| # | 坑 | 证据 | 后果 / 规避 |
|---|---|---|---|
| 36 | ★★ schema 里有 $defs 直接抛 IllegalArgumentException |
JsonSchemaConverter:66 |
三家唯一快速失败的;Assert 在 try 之外,所以不是 IllegalStateException |
| 37 | ★ copyCommonProperties 是 17 键白名单,其它键静默丢弃 |
JsonSchemaConverter:90-105 |
title / const / x-* 全丢且不报错 |
| 38 | ★ allOf/anyOf/oneOf 原样搬运不递归 |
JsonSchemaConverter:192-200 |
三支内部的 type 不会被转大写、内部 $defs 也不会被 Assert 发现 |
| 39 | ★ thinkingLevel 有硬编码的 9 模型支持矩阵 |
GoogleGenAiChatModel:879-902 |
模型不在矩阵里 = 不校验(不报错);2.5-flash/flash-lite 完全不支持 thinkingLevel |
| 40 | cachedContentService 由四个条件决定,缺一为 null |
GoogleGenAiChatModel:219-221 |
getCachedContentService() 返回 null,后续 NPE |
| 41 | ★ RetryUtils.execute 只包 call,流式完全没有重试 |
:420 vs internalStream |
流式一次抖动直接 error;且构造器 Assert.notNull(retryTemplate) 会让 null 在启动期就炸 |
| 42 | DisposableBean.destroy() 是空实现 |
:990-992 |
别指望容器销毁释放 SDK 连接池 |
23.11 小结
- 只有 7 个
ChatModel:Anthropic(1961) > OpenAI(1637) > Google(1230) > Bedrock(1035) > Mistral(615) > DeepSeek(586) > Ollama(570)。行数差距不是"功能多少",而是"供应商 API 有多少独特语义要映射"。 - ★
OpenAiApi在 2.0.1 不存在 ------这是 1.x → 2.x 迁移里最大的单类断裂,取而代之的是final class OpenAiSetup(17 参数构造OpenAIClient)。 - ★ 两大阵营的判据是"有没有自研
*Api.java+ 用不用RestClient",而不是"是不是大厂":OpenAI/Anthropic/Google/Bedrock 用官方 SDK,DeepSeek/Mistral/Ollama 自研。 - ★ 重试落点必须记成三态 而非两态:OpenAI/Anthropic = SDK 的
maxRetries(3 / 2);DeepSeek/Mistral/Ollama = SpringRetryTemplate(10);Google = SDK 客户端 + SpringRetryTemplate的混合派,且只包call;Bedrock = AWS SDK。 - ★
$defs三家三种处理,是本章最能体现"抽象漏了"的一处:OpenAI 透传+递归修正、Anthropic 静默剥离($ref断链)、Google 直接抛异常。同一个ChatOptions换供应商,行为完全不同。 - ★ Anthropic 的流式把两个缺陷写进了 Javadoc :span 不挂父(SDK 丢 context)+ 占住
boundedElastic线程。OpenAI 与 Google 都用Flux.deferContextual+parentObservation.openScope()修了第一个。 - ★ Bedrock 是全仓库唯一
@ConditionalOnBean双条件 (AwsCredentialsProvider+AwsRegionProvider)的自动配置------缺一个就静默不注册,这是最难排查的一类"启动成功但缺 bean"。 - ★ Ollama 是元数据最丰富的一家(五个
Duration跨轮累加),但也最"不像供应商"(本地进程、init拉模型、ConnectionDetails三源收口)。 - ★ Google 是唯一把"供应商模型能力矩阵"硬编码进 Java 的(
THINKING_LEVEL_SUPPORT_BY_MODEL9 个模型 id),也是唯一提供"按模态统计 token"的。 - ★ provider 标识有三套写法(枚举 / 模块常量类 / Api 类常量),值都等价;但 Google 自己三个模块用了两套,
SPRING_AI零引用。 ChatModel extends StreamingChatModel,所以"显式 implementsStreamingChatModel"是冗余声明;判定流式能力要看有没有stream()实现。- ★
RetryUtils.execute重试耗尽会拆包RetryException抛原始 cause------上层catch (RetryException)永远抓不到(呼应第 22 章 22.18 的SpringAiRetryProperties)。 - OpenAI 的
ChunkMerger三个"只看choices.get(0)"的判定,是流式 tool call 在n>1时静默丢失的根因;Google 没有ChunkMerger(SDK 全权归并)也就没有这个坑,代价是不可控。 OpenAiSetup的 no-auth 模式(占位 key + 摘Authorization头)是指向自建网关的官方姿势,但它会让"真正的 401"变得难排查。calculateBaseUrl在GITHUB_MODELS分支会丢弃你写的 baseUrl------这是本章最反直觉的一处"你配的不算数"。- Anthropic 的
AnthropicSetup会在 customizer 之后强制覆盖 backend 与 meterTags,理由是避免 MeterRegistry 重复 gauge 注册报错。 - Google 的
JsonSchemaConverter是白名单重建 而非透传:title/const/x-*静默丢失,allOf/anyOf/oneOf不递归。 - 选型结论:要"最贴近 Spring Boot 原生"→ Ollama;要"结构化输出最稳"→ OpenAI(strict 会帮你补 schema);要"最长上下文+缓存"→ Anthropic(注意流式两缺陷);要"企业级安全与合规"→ Bedrock(先确认两个 AWS bean 都在);要"多模态与搜索 grounding"→ Google(先确认 schema 里没有
$defs)。
23.12 思考题
- 为什么
OpenAiApi被删掉是"必然"而不是"可选"?请从"官方 SDK 已经提供了连接池/重试/观测"与"Spring AI 自研 Api 的双客户端(RestClient+WebClient)"两个角度论证,并说明删掉之后谁 接管了原来OpenAiApi的职责。 OpenAiSetup.setupSyncClient有 17 个参数。请列举其中至少 6 个,并说明为什么 Spring AI 不把它们收进一个OpenAiConnectionDetails(对比 Ollama 的OllamaConnectionDetails三源收口)。calculateBaseUrl的GITHUB_MODELS分支会无条件丢弃你的 baseUrl。请说明这个行为的合理性 (为什么敢这么写)与风险(你怎么发现自己被丢了)。- 对比
OpenAiChatModel与AnthropicChatModel的流式实现:为什么前者能让 HTTP span 挂在gen_ai.client.operation下、后者不能?给出两者的具体行号与关键代码差异。 - Anthropic 流式为什么要
subscribeOn(Schedulers.boundedElastic())?如果不这么写会怎样?这个设计反映了 SDK 的什么取舍? - ★
$defs在三家有不同的命运。假设你有一个用$defs+$ref组织的复杂工具 schema,请分别写出它在 OpenAI(开/关 strict)、Anthropic、Google 下的最终结果,并说明哪一种"最危险"。 AnthropicSetup为什么要在 customizer 之后再覆盖一次 backend 与 meterTags?如果把它挪到 customizer 之前会出什么问题?(提示:MeterRegistry 的 gauge 注册语义)- ★ Bedrock 的
@ConditionalOnBean({AwsCredentialsProvider.class, AwsRegionProvider.class})是双条件。请说明:只引spring-ai-bedrock-conversestarter 而不配 Spring Cloud AWS 时,启动日志里有没有任何警告?你要怎么在 30 秒内定位? MediaFetcher的三层 SSRF 防护分别防住了什么?为什么isValidURLBasic还被标了@Deprecated?"三层"里哪一层是可选的(可关的)?- ★
RetryUtils.execute把RetryException拆包成原始 cause 再抛。请说明这个设计对"上层统一异常处理"的影响,并写出能真正抓到重试耗尽的代码片段。 - Google 的
THINKING_LEVEL_SUPPORT_BY_MODEL硬编码了 9 个模型。请评价这个设计:它解决了什么问题?引入了什么新问题?如果你来改,你会把它挪到哪里(提示:想想第 22 章的配置属性与外部化)? - 综合 23.2(两大阵营)与 23.9(三套 provider 写法):如果你要为公司内部的自研大模型写一个 Spring AI 模块,请给出你的技术选型清单(HTTP 层、重试层、观测层、provider 标识写法),并逐条说明你参考了本章哪一家的做法、为什么。
23.13 文件索引
路径均相对仓库根 D:/CODE/spring-ai-2.0.1(即 spring-ai-2.0.1/)。
23.13.1 七个 ChatModel 与所属模块
| 模块 | 主类 | 行数 | 关键行 |
|---|---|---|---|
models/spring-ai-anthropic |
AnthropicChatModel.java |
1961 | :266-285 流式缺陷一 Javadoc、:287-307 缺陷二、:336-346 boundedElastic、:545 internalCall、:1270-1310 toAnthropicTool |
models/spring-ai-openai |
OpenAiChatModel.java |
1637 | :130 类声明、:196-258 internalCall、:295-298 openScope、:1001-1045 工具 schema、:1065-1099 strict 改写、:1142-1154 reasoning、:1179-1270 ChunkMerger |
models/spring-ai-google-genai |
GoogleGenAiChatModel.java |
1230 | :153 类声明、:219-221 缓存服务四条件、:420 RetryUtils.execute、:461-474 openScope、:879-902 thinking 矩阵、:990-992 空 destroy() |
models/spring-ai-bedrock-converse |
BedrockProxyChatModel.java |
1035 | :143 类声明、:247-270 CONVERSATION_HISTORY、:859-873 Builder 五超时(setter :915-939) |
models/spring-ai-mistral-ai |
MistralAiChatModel.java |
615 | :181/:237 provider、:252 流式 RetryUtils.execute |
models/spring-ai-deepseek |
DeepSeekChatModel.java |
586 | :142/:217 provider、retryTemplate 为 public final 字段 |
models/spring-ai-ollama |
OllamaChatModel.java |
570 | :124-149 构造器、:146-148+:474-478 主模型初始化、:155-206 五 Duration 元数据 |
(配套:spring-ai-model/src/main/java/org/springframework/ai/chat/model/ChatModel.java:30 ------ ChatModel extends Model<Prompt, ChatResponse>, StreamingChatModel。)
23.13.2 OpenAI 落点
| 文件 | 行 | 内容 |
|---|---|---|
models/spring-ai-openai/.../openai/setup/OpenAiSetup.java |
64-73 | NO_AUTH_PLACEHOLDER_KEY 与 no-auth 判定 |
| 同上 | 108-116 | 空 apiKey → OpenAIClientImpl(buildNoAuthClientOptions(...)) |
| 同上 | 228-240 | 摘 Authorization 头的拦截器(在 customizer 之前) |
| 同上 | 278-302 | detectModelProvider 四级判定 |
| 同上 | 304-336 | calculateBaseUrl(GITHUB_MODELS 丢 baseUrl / MICROSOFT_FOUNDRY 削尾) |
models/spring-ai-openai/.../openai/OpenAiChatOptions.java |
1224(全文) | 结构化输出与工具选项 |
models/spring-ai-openai/.../openai/metadata/ |
--- | OpenAI 侧 usage / rate limit 元数据 |
★ 不存在:models/spring-ai-openai/**/OpenAiApi.java(find 无输出)。
23.13.3 Anthropic 落点
| 文件 | 行 | 内容 |
|---|---|---|
models/spring-ai-anthropic/.../anthropic/setup/AnthropicSetup.java |
84-108 | SYNC/ASYNC_CLIENT_TAGS(client.kind) |
| 同上 | 293-344 | buildClientOptions |
| 同上 | 327-336 | ★ customizer 之后强制覆盖 backend + meterTags |
.../anthropic/AnthropicChatModel.java |
155-165 | Beta 头常量(skills-2025-10-02 等) |
.../anthropic/AnthropicChatOptions.java |
614-640(624) | ★ 跳过 $schema / $defs |
.../anthropic/cache/CacheBreakpointTracker.java |
22-56 | 最多 4 断点、非 static、hasWarned |
.../anthropic/cache/CacheEligibilityResolver.java |
75-82 / 84-106 | TOOLS_ONLY -> Set.of()、两处 Assert.state |
23.13.4 自研 Api 派 + Google 落点
| 文件 | 行 | 内容 |
|---|---|---|
models/spring-ai-deepseek/.../api/DeepSeekApi.java |
常量区 | DEFAULT_CHAT_MODEL = DEEPSEEK_V4_FLASH、betaPrefixPath 默认 /beta |
models/spring-ai-deepseek/.../api/DeepSeekConstants.java |
--- | DEFAULT_BASE_URL、PROVIDER_NAME = AiProvider.DEEPSEEK.value() |
models/spring-ai-mistral-ai/.../api/MistralAiApi.java |
92-131 | RestClient+WebClient;defaultStatusHandler 只在 RestClient |
models/spring-ai-ollama/.../api/OllamaApiConstants.java |
--- | DEFAULT_BASE_URL=http://localhost:11434、PROVIDER_NAME |
models/spring-ai-google-genai/.../schema/JsonSchemaConverter.java |
64-83(66) | ★ $defs → IllegalArgumentException |
| 同上 | 90-105 / 113-201 / 192-200 | 17 键白名单 / 递归规则 / allOf 不递归 |
.../google/genai/schema/GoogleGenAiToolCallingManager.java |
66-79 / 88-90 | 解析→转 OpenAPI→转大写;executeToolCalls 纯委托 |
.../google/genai/common/GoogleGenAiConstants.java |
全文 34 行 | 只有一个 PROVIDER_NAME |
.../google/genai/GoogleGenAiChatModel.java |
879-902 / 913-932 | ★ thinking 支持矩阵与校验 |
.../google/genai/GoogleGenAiChatModel.java |
1221 / 1225-1226 | GeminiRequest record、SchemaMixin |
23.13.5 Bedrock 与公共落点
| 文件 | 行 | 内容 |
|---|---|---|
models/spring-ai-bedrock-converse/.../autoconfigure/BedrockConverseProxyChatAutoConfiguration.java |
52-69(62) | ★ @ConditionalOnBean({AwsCredentialsProvider, AwsRegionProvider}) |
models/spring-ai-bedrock/.../bedrock/ |
--- | 只有 embedding + 遗留 MessageToPromptConverter,无 ChatModel |
models/spring-ai-bedrock-converse/.../MediaFetcher.java |
82 | DEFAULT_MAX_FETCH_SIZE_BYTES = 40MB |
.../bedrock/converse/URLValidator.java |
153-156 | isBlockedAddress(loopback/link-local/site-local/any-local) |
spring-ai-commons/.../retry/RetryUtils.java |
170-178 | ★ 拆包 RetryException |
spring-ai-commons/.../observation/conventions/AiProvider.java |
35-80 | 10 个枚举值 |
spring-ai-model/.../chat/model/ChatModel.java |
30 | extends Model, StreamingChatModel |
23.14 复核命令(可直接复制执行)
全部在仓库根执行:cd /d/CODE/spring-ai-2.0.1
bash
# 1) 七个 ChatModel:确认数量与主类行数
find models -name "*ChatModel.java" -not -path "*/test/*" | sort
wc -l models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java \
models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java \
models/spring-ai-google-genai/src/main/java/org/springframework/ai/google/genai/GoogleGenAiChatModel.java
# 2) ★ OpenAiApi 不存在(应无输出);自研 Api 派三个 Api 类存在
find models -name "OpenAiApi.java" -o -name "AnthropicApi.java" -o -name "GoogleApi.java"
find models -name "*Api.java" -not -path "*/test/*" | sort
# 3) 两大阵营判据:谁用了 RestClient/WebClient(自研派),谁没有(SDK 派)
grep -rln "RestClient.Builder" --include=*.java models/ | sort
grep -rln "WebClient.Builder" --include=*.java models/ | sort
# 4) ★ provider 三套写法(20 处)
grep -rn "\.provider(" --include=*.java models/ | grep -v "/test/"
# 6) OpenAiSetup:no-auth 占位 key 与拦截器
grep -n "NO_AUTH_PLACEHOLDER_KEY\|removeHeader(\"Authorization\")\|GITHUB_MODELS_URL\|MICROSOFT_FOUNDRY" \
models/spring-ai-openai/src/main/java/org/springframework/ai/openai/setup/OpenAiSetup.java
# 7) ★ calculateBaseUrl:GITHUB_MODELS 丢 baseUrl 那一段
sed -n '278,340p' models/spring-ai-openai/src/main/java/org/springframework/ai/openai/setup/OpenAiSetup.java
# 8) OpenAI 流式 openScope 修复(与 Google 同款)
sed -n '290,300p' models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java
sed -n '461,475p' models/spring-ai-google-genai/src/main/java/org/springframework/ai/google/genai/GoogleGenAiChatModel.java
# 9) ★ ChunkMerger 只看 choices.get(0)
sed -n '1195,1225p' models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java
# 10) OpenAI strict 模式改写(默认 false + $ref 包 anyOf)
sed -n '1060,1140p' models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java
# 11) ★ Anthropic 流式两缺陷的 Javadoc(自己写明的)
sed -n '266,310p' models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java
# 12) Anthropic boundedElastic
grep -n "Schedulers.boundedElastic()" -r models/spring-ai-anthropic/src/main/java/
# 13) ★ AnthropicSetup customizer 之后覆盖 backend/meterTags
sed -n '320,340p' models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/setup/AnthropicSetup.java
# 14) ★ Anthropic $defs 剥离(两处)
sed -n '610,645p' models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatOptions.java
sed -n '1270,1312p' models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java
# 15) 缓存:4 断点 + TOOLS_ONLY
sed -n '20,60p' models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/cache/CacheBreakpointTracker.java
sed -n '70,110p' models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/cache/CacheEligibilityResolver.java
# 16) DeepSeek 公开字段 retryTemplate + 默认模型
grep -n "public final RetryTemplate\|DEFAULT_CHAT_MODEL\|DEFAULT_BETA_PATH\|DEFAULT_COMPLETIONS_PATH" -r models/spring-ai-deepseek/src/main/java/
# 17) Mistral 三套 Api 与 SSE_DONE
grep -rn "SSE_DONE_PREDICATE\|defaultStatusHandler\|PROVIDER_NAME" models/spring-ai-mistral-ai/src/main/java/ | head
# 18) Ollama 主模型初始化(两轮拉取)+ 五 Duration
sed -n '124,150p' models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/OllamaChatModel.java
sed -n '470,480p' models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/OllamaChatModel.java
grep -n "Duration" models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/OllamaChatModel.java | head
# 19) ★ Bedrock 双 @ConditionalOnBean(唯一)
grep -rn "@ConditionalOnBean" --include=*.java models/ | grep -v "/test/"
sed -n '50,70p' models/spring-ai-bedrock-converse/src/main/java/org/springframework/ai/bedrock/converse/autoconfigure/BedrockConverseProxyChatAutoConfiguration.java
# 20) Bedrock 五超时 + SSRF
grep -n "timeout\s*=\|Duration.of" models/spring-ai-bedrock-converse/src/main/java/org/springframework/ai/bedrock/converse/BedrockProxyChatModel.java | head
grep -n "DEFAULT_MAX_FETCH_SIZE_BYTES\|isBlockedAddress\|Deprecated" models/spring-ai-bedrock-converse/src/main/java/org/springframework/ai/bedrock/converse/MediaFetcher.java
# 21) ★ Google $defs 抛异常 + 17 键白名单 + allOf 不递归
sed -n '60,110p' models/spring-ai-google-genai/src/main/java/org/springframework/ai/google/genai/schema/JsonSchemaConverter.java
sed -n '190,205p' models/spring-ai-google-genai/src/main/java/org/springframework/ai/google/genai/schema/JsonSchemaConverter.java
# 22) Google 混合派:SDK 客户端 + RetryUtils.execute(只在 call)
grep -n "RetryTemplate\|RetryUtils.execute\|generateContentStream" \
models/spring-ai-google-genai/src/main/java/org/springframework/ai/google/genai/GoogleGenAiChatModel.java
# 23) ★ Google thinking 硬编码矩阵
sed -n '870,935p' models/spring-ai-google-genai/src/main/java/org/springframework/ai/google/genai/GoogleGenAiChatModel.java
# 24) RetryUtils 拆包与默认值
grep -n "RetryException\|DEFAULT_MAX_ATTEMPTS\|INITIAL_INTERVAL\|MAX_INTERVAL" \
spring-ai-commons/src/main/java/org/springframework/ai/retry/RetryUtils.java
23.15 下一章预告(第 24 章)
第 22、23 章把"框架怎么把供应商接进来 "和"每个供应商各自长什么样 "讲完了。还差最后一块:把这些知识落到生产环境里。
第 24 章:生产实践与 2.x 迁移------陷阱清单、AOT 与调优,计划展开:
- 观测与可观测性闭环 :把第 22 章的
ChatClient观测、第 23 章各家 provider 标识与流式 span 父子关系串起来,给出一套能真正用的 Micrometer + Tracing 配置(以及为什么 Anthropic 流式在链路图上是"断的")。 - 结构化输出与工具调用的跨供应商兼容 :以 23.5.3 / 23.8.3 的三家差异为基础,给出"一份 schema 跑七家"的可行做法与必须做的降级(
allOf改写、$defs内联、strict 开关)。 - 重试与超时的统一治理 :
SpringAiRetryProperties(22.18)+RetryUtils(23.10.4 #28)+ 各家 SDK 自带重试(23.8.2 三态表)三层叠加时的实际行为,以及怎么只留一层。 - AOT / GraalVM 与 RuntimeHints :第 22 章的 19 处
RuntimeHints+ 本章各模块的aot/子包,哪些反射点在原生镜像里会炸,怎么补。 - 1.x → 2.0.1 迁移清单 :以
OpenAiApi消失(23.3.1)、ChatOptions两级合并(22.4)、265 个废弃属性(22.12)为主线,给出按模块分组的迁移步骤与回归验证点。 - 性能与容量 :流式线程模型(Anthropic
boundedElastic占线程 vs Google/OpenAI SDK 归并)、连接池(Anthropicclient.kindtag 与重复 gauge)、Ollama 本地进程的资源占用。