跳到主要内容

数据截至 (上游 commit c988e72ab728)

可移植模型层:一套抽象罩住十几家供应商

30 秒导读: Spring AI 最常被引用的一句宣传语是"换个模型只改配置"。这句话在代码里靠三层东西撑着:一组泛型接口Model / ModelRequest / ModelResponse / ModelResult)定义"调模型"这个动作的形状;一套 provider 无关的数据模型Message 家族、PromptChatOptions)当中间语;每家一个适配器OpenAiChatModelAnthropicChatModel…)负责把中间语翻成该家 API 的方言、再翻回来。本章还讲结构化输出——它是"可移植"的一个特例:同一个 entity(Invoice.class) 调用,在支持原生 JSON Schema 的模型上走 API 参数,在不支持的模型上退化成往提示词里塞格式说明,业务代码一个字不改。

本章讲什么:"换模型只改配置"这句话在源码里由谁兑现。从最抽象的泛型骨架,一路走到某家适配器里的一行 FunctionDefinition.builder(),再走到结构化输出的完整往返链路。

本章不讲:ChatClient 流式 API 怎么攒出一个请求(见 01-chatclient-and-request-assembly.md);Advisor 责任链本身的机制(见 02-advisor-chain.md);工具调用的多轮循环怎么转(见 03-tool-calling.md);embedding 与向量存储(见 05-memory-rag-and-vector-store.md);starter 与可观测性的装配(见 06-boot-integration-observability-and-mcp.md)。


1. 这是什么(零基础也能懂)

1.1 一句话定义

可移植模型层 = 一层"通用插座"。 你的业务代码只认这个插座;OpenAI、Anthropic、Ollama、DeepSeek 各自做一个"转换头"插上去。换供应商 = 换转换头,插座和插头(你的代码)都不动。

1.2 "换个模型只改配置"到底难在哪

裸调各家 SDK 时,同一件事在不同家长得完全不一样。这不是换个 URL 的事:

差异面OpenAIAnthropic后果
系统提示词消息列表里一条 role=system顶层独立的 system 参数消息结构要重排
工具定义FunctionDefinition{name, description, parameters}Tool{name, description, input_schema}schema 要拆开重装
工具结果回传role=tool 的消息role=user 里塞一个 tool_result会话历史要重写
输出结构一个 content 字符串 + tool_calls 数组一个 content 块数组(text / tool_use / thinking…)解析逻辑完全不同
token 计数字段prompt_tokens / completion_tokensinput_tokens / output_tokens计费统计要改

所以"可移植"的工程量,几乎全在双向翻译上,而不是在"发一个 HTTP 请求"上。

1.3 用起来什么样

下面这段是使用者视角,注意:没有一个标识符提到某家供应商

// 示意,非源码
ChatClient client = ChatClient.builder(chatModel).build(); // chatModel 由 Boot 注入

// 纯文本
String answer = client.prompt("用一句话解释 CAP 定理").call().content();

// 结构化:直接要一个 Java 对象
record Invoice(String vendor, double amount, String currency) {}
Invoice inv = client.prompt("从这段文本里抽出发票信息:...").call().entity(Invoice.class);

换供应商时,改的是 classpath 上的 starter 和 application.yml 里的 key/model,上面这段代码不动

1.4 一句话直觉

把它想成 JDBCConnection / Statement / ResultSet 是通用接口,MySQL 和 PostgreSQL 各写一个 Driver。Spring AI 的 ChatModel / Prompt / ChatResponse 就是那三个接口,OpenAiChatModel 就是那个 Driver。区别在于:SQL 方言的差异比 LLM API 的差异小得多,所以 Spring AI 的"Driver"要干的翻译活重得多。


2. 顶层全景(它大概怎么转)

2.1 分层图

怎么读这张图:从上往下是"越来越具体"。虚线上方的东西完全不知道供应商存在;虚线下方每一格对应一家。

你的业务代码
│ 只认 Prompt / ChatResponse / ChatOptions

┌──────────────────────────────────────────────┐
│ ① 泛型骨架 Model / StreamingModel │ 最抽象:只说"有 call 和 stream"
│ ModelRequest/Response/Result │
├──────────────────────────────────────────────┤
│ ② chat 域落地 ChatModel / StreamingChatModel│ 把泛型槽位填成 Prompt→ChatResponse
│ Prompt · Message 家族 │ provider 无关的"中间语"
│ ChatOptions(8 个可移植字段) │
└──────────────────────────────────────────────┘
- - - - - - - - 供应商边界 - - - - - - - - - - -
┌───────────────┬───────────────┬──────────────┐
│ OpenAiChatModel│AnthropicChat- │ Ollama / … │ ③ 适配器:双向翻译
│ ↕ 官方 SDK │Model ↕官方SDK │ ↕ 自建 Api │
│ com.openai.* │ com.anthropic.*│ │
└───────────────┴───────────────┴──────────────┘

2.2 部件一句话职责

部件干什么在哪个文件
Model<TReq, TRes>泛型的"调一次模型"契约,只有一个 callspring-ai-model/src/main/java/org/springframework/ai/model/Model.java:31
StreamingModel泛型的流式契约,返回 Flux<TResChunk>spring-ai-model/src/main/java/org/springframework/ai/model/StreamingModel.java:34
ModelRequest<T>请求 = 输入(getInstructions)+ 选项(getOptionsspring-ai-model/src/main/java/org/springframework/ai/model/ModelRequest.java:32
ModelResponse<T>响应 = 一组 ModelResult + ResponseMetadataspring-ai-model/src/main/java/org/springframework/ai/model/ModelResponse.java:34
ChatModelchat 域的落地接口,附带一堆 default 便利方法spring-ai-model/src/main/java/org/springframework/ai/chat/model/ChatModel.java:30
Promptchat 域的 ModelRequest:消息列表 + ChatOptionsspring-ai-model/src/main/java/org/springframework/ai/chat/prompt/Prompt.java:47
Message 家族provider 无关的消息中间表示spring-ai-model/src/main/java/org/springframework/ai/chat/messages/AbstractMessage.java:34
ChatOptions8 个跨厂商都认的调参字段 + 自递归 Builderspring-ai-model/src/main/java/org/springframework/ai/chat/prompt/ChatOptions.java:29
OpenAiChatModelOpenAI 适配器,内部包 com.openai 官方 SDKmodels/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java:130
AnthropicChatModelAnthropic 适配器,内部包 com.anthropic 官方 SDKmodels/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java:155
BeanOutputConverter结构化输出主力:生成 schema + 清洗 + 反序列化spring-ai-model/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java

2.3 主线走一遍(不进代码)

一次 client.prompt("…").call().entity(Invoice.class) 的旅程:

  1. ChatClient 把用户输入、默认选项、advisor 攒成一个 Prompt(第 1 章)。
  2. entity() 决定要 Invoice,于是造一个 BeanOutputConverter<Invoice>,从 Invoice 反射出 JSON Schema,把"格式要求"塞进请求上下文。
  3. Advisor 链走到最后一环 ChatModelCallAdvisor,它在这里分叉:走原生 schema(写进 ChatOptions)还是走提示词注入(追加到最后一条 user message)。
  4. ChatModel.call(prompt) 进适配器:Prompt → 该家 SDK 的请求对象;发出去;SDK 响应 → ChatResponse
  5. 回到 entity():拿到文本,先过一条清洗管线(去 <think> 标签、去 markdown 围栏、trim),再 Jackson 反序列化成 Invoice

3. 泛型骨架:五个接口撑起所有模态

这节讲什么: Spring AI 最抽象的那一层长什么样,以及它为什么要泛型。

3.1 为什么要有这一层

Spring AI 不只做 chat,还做 embedding、image、audio、moderation。这些模态的共同点只有一句话:送一个请求进去,拿一组结果回来,附带元数据。 泛型骨架就是把这句话写成 Java。

3.2 两个动作接口

Model 只有一个方法,泛型参数被约束成必须是 ModelRequest / ModelResponse

public interface Model<TReq extends ModelRequest<?>, TRes extends ModelResponse<?>> {
TRes call(TReq request);
}

spring-ai-model/src/main/java/org/springframework/ai/model/Model.java:31-38,符号 Model。)

流式是独立的接口 StreamingModel,返回 Flux<TResChunk>spring-ai-model/src/main/java/org/springframework/ai/model/StreamingModel.java:34-41,符号 StreamingModel#stream)。拆开的好处是:不支持流式的模态(比如 moderation)根本不用实现它。

3.3 三个数据接口 + 元数据

接口契约位置
ModelRequest<T>T getInstructions() 拿输入、ModelOptions getOptions() 拿调参spring-ai-model/src/main/java/org/springframework/ai/model/ModelRequest.java:38,44
ModelResponse<T>getResult() 取第一个、getResults() 取全部、getMetadata() 取响应级元数据spring-ai-model/src/main/java/org/springframework/ai/model/ModelResponse.java:40,46,52
ModelResult<T>getOutput() 拿这一条的产物、getMetadata() 拿这一条的元数据spring-ai-model/src/main/java/org/springframework/ai/model/ModelResult.java:35,41
ResponseMetadata一个只读的 key-value 袋子(get/getRequired/getOrDefault/entrySetspring-ai-model/src/main/java/org/springframework/ai/model/ResponseMetadata.java:31
ModelOptions空标记接口,只为类型约束存在spring-ai-model/src/main/java/org/springframework/ai/model/ModelOptions.java:29

ResponseMetadata 的具体实现是 AbstractResponseMetadataspring-ai-model/src/main/java/org/springframework/ai/model/AbstractResponseMetadata.java:26)和可写的 MutableResponseMetadataspring-ai-model/src/main/java/org/springframework/ai/model/MutableResponseMetadata.java:27)。

3.4 chat 域怎么把槽位填满

泛型槽位chat 域的实体文件
TReqPromptimplements ModelRequest<List<Message>>spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/Prompt.java:47
TResChatResponseimplements ModelResponse<Generation>spring-ai-model/src/main/java/org/springframework/ai/chat/model/ChatResponse.java:41
ModelResult<T>Generationimplements ModelResult<AssistantMessage>spring-ai-model/src/main/java/org/springframework/ai/chat/model/Generation.java:30
ResponseMetadataChatResponseMetadata(带 id/model/usage/rateLimitspring-ai-model/src/main/java/org/springframework/ai/chat/metadata/ChatResponseMetadata.java:39
ResultMetadataChatGenerationMetadata(带 finishReason,有个 NULL 空对象)spring-ai-model/src/main/java/org/springframework/ai/chat/metadata/ChatGenerationMetadata.java:35,37
ModelOptionsChatOptionsspring-ai-model/src/main/java/org/springframework/ai/chat/prompt/ChatOptions.java:29

3.5 ChatModel 接口本身:一条主方法 + 一堆糖

ChatModel 同时继承了两个东西(spring-ai-model/src/main/java/org/springframework/ai/chat/model/ChatModel.java:30):

public interface ChatModel extends Model<Prompt, ChatResponse>, StreamingChatModel {

它只强制适配器实现 ChatResponse call(Prompt)(第 45 行)。其余全是 default

  • call(String message) / call(Message... messages):包一层 new Prompt(...),取第一个 Generation 的文本(第 32-42 行)。
  • getOptions():返回本模型的默认选项,基类默认给个空的 ChatOptions.builder().build()(第 52-54 行)。这是 2.0 新增的方法,老的 getDefaultOptions() 已标 @Deprecated(forRemoval = true) 并直接委托给它(第 59-62 行)。
  • stream(Prompt):默认 UnsupportedOperationException("streaming is not supported")(第 64-66 行)——所以"不支持流式"是一个合法状态,不是编译错误。

StreamingChatModel@FunctionalInterfacespring-ai-model/src/main/java/org/springframework/ai/chat/model/StreamingChatModel.java:29-30),同样给了 stream(String) / stream(Message...) 两个把 Flux<ChatResponse> 拍成 Flux<String> 的便利方法(第 32-46 行)。

要点: getOptions() 不只是个 getter。它是**"模型自带的默认参数"的唯一入口**,第 5 节的选项合并、第 6 节的 buildRequestPrompt 都从它取值。


4. 消息体系:provider 无关的中间语

这节讲什么: 上层和适配器之间传的到底是什么数据结构。

4.1 一个抽象基类,三个字段

AbstractMessagespring-ai-model/src/main/java/org/springframework/ai/chat/messages/AbstractMessage.java:34)只有三个 protected final 字段:

字段类型干什么行号
messageTypeMessageType这条消息是谁说的:44
textContent@Nullable String文本内容:49
metadataMap<String, Object>附加信息袋(存 messageType 等):54

三个字段就是全部的"共性"。 别的东西(媒体、工具调用、工具结果)都由子类各自加。

4.2 四种角色

MessageType 是个只有四个常量的枚举(spring-ai-model/src/main/java/org/springframework/ai/chat/messages/MessageType.java:23):

常量线上值对应类承担什么
USERuserUserMessage:39,实现 MediaContent 能带图/音频)用户输入
ASSISTANTassistantAssistantMessage:41模型回复,可能带工具调用
SYSTEMsystemSystemMessage:35系统指令
TOOLtoolToolResponseMessage:33工具执行结果回传

注意 TOOL 的枚举值字符串仍是 "tool",但它的 Javadoc 里还留着历史包袱式的 "function" 措辞(MessageType.java:47-52)——这是从 function calling 时代改名留下的痕迹。

4.3 工具调用的通用协议:两个 record

这是整个消息体系里最关键的可移植设计——两个极简 record 定义了跨厂商的工具协议:

public record ToolCall(String id, String type, String name, String arguments) { }

AssistantMessage.java:111,符号 AssistantMessage.ToolCallarguments 是 JSON 字符串。)

public record ToolResponse(String id, String name, String responseData) { }

ToolResponseMessage.java:75,符号 ToolResponseMessage.ToolResponse。)

AssistantMessagehasToolCalls():65-67)判断有没有工具调用;ToolResponseMessage 的构造函数硬编码 super(MessageType.TOOL, "", metadata):38)——它的 textContent 永远是空串,真正的内容全在 responses 列表里。

翻译责任由适配器承担。同一个 ToolCall

  • OpenAI 侧被拆成 id / function.name / function.argumentsOpenAiChatModel.java:378-391,符号 buildGeneration)。
  • Anthropic 侧被拆成 ToolUseBlock.id() / .name() / ._input()AnthropicChatModel.java:1016-1021,符号 buildGenerations)。而且 Anthropic 那边加了一句关键注释:JsonValue.toString() 产出的是 Java Map 格式 {key=value} 而不是合法 JSON,所以必须走 visitor 转成 JSON 字符串(:1017-1019)。这是个真实踩过的坑。

4.4 Prompt:消息 + 选项的不可变容器

Prompt implements ModelRequest<List<Message>>Prompt.java:47),只有两个字段:messages@Nullable chatOptions

它提供一组读取器(都对空安全,找不到就返回空对象而非 null):

方法行为行号
getSystemMessage()第一条 system,没有就返回 new SystemMessage(""):106-113
getUserMessage()最后一条 user:120-128
getLastUserOrToolResponseMessage()最后一条 user 或 tool 消息(多轮工具循环要用):134-142

以及一组改写器——全部返回新的 Prompt,原对象不动:

augmentUserMessage(fn) 从后往前找最后一条 UserMessage → 用 fn 改写它
├─ 找到 → 原位替换
└─ 没找到 → 在末尾追加一条由 fn(new UserMessage("")) 造出来的消息
augmentSystemMessage(fn) 从前往后找第一条 SystemMessage → 改写
└─ 没找到 → 插到列表最前面(index 0)

augmentUserMessagePrompt.java:261-274augmentSystemMessage:228-250。两者各有一个接 String 的重载,内部调用 mutate().text(...):252-254:282-284。)

augmentUserMessage 是本章后半段的主角。 结构化输出的提示词注入、schema 校验失败后的错误回喂,用的都是它。

mutate():286-292)返回一个已经填好深拷贝消息和选项的 Builder。深拷贝逻辑在 instructionsCopy():197-226)里,它对四种消息类型逐一处理,碰到不认识的类型直接抛 IllegalArgumentException:216)——这意味着自定义 Message 子类不能安全地走 mutate()/copy()


5. ChatOptions:可移植字段集、两个扩展面、合并规则

这节讲什么: "调参"这件事怎么做到既可移植又不阉割厂商特性。

5.1 八个可移植字段

ChatOptionsspring-ai-model/src/main/java/org/springframework/ai/chat/prompt/ChatOptions.java:29)就是一张"最大公约数"清单:

字段类型行号
modelString:35
frequencyPenaltyDouble:41
maxTokensInteger:47
presencePenaltyDouble:53
stopSequencesList<String>:59
temperatureDouble:65
topKInteger:71
topPDouble:77

全部 @Nullable——null 表示"没设置",而不是"设成 0"。这个约定是后面合并规则的基石。

5.2 自递归 Builder:为什么写成 Builder<B extends Builder<B>>

一个纯技术但很实用的设计(ChatOptions.java:99):

interface Builder<B extends Builder<B>> extends Cloneable {
B model(@Nullable String model);
// …
B combineWith(ChatOptions.Builder<?> other);
ChatOptions build();
}

自递归泛型(俗称 CRTP)让 OpenAiChatOptions.Builder.temperature(0.7) 返回 OpenAiChatOptions.Builder 而不是基类 Builder,于是可以继续链式调 OpenAI 独有的方法而不用强转。DefaultChatOptionsBuilder 用一个 protected B self()spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/DefaultChatOptionsBuilder.java:60-63)配合 @SuppressWarnings("unchecked") 实现。

mutate()ChatOptions.java:86)的契约里写得很直白:具体子类必须返回最具体的 Builder 实现StructuredOutputChatOptions 就是靠覆写 mutate() 收窄返回类型,才让通用代码能写出 options.mutate().outputSchema(s).build() 而不用转型(见 StructuredOutputChatOptions.java:37-42 的注释)。

5.3 combineWith:合并语义只有一条例外

DefaultChatOptionsBuilder#combineWithDefaultChatOptionsBuilder.java:119-154)的规则:

对每个字段:
other 的值非 null → 覆盖 this 的值
other 的值是 null → 保留 this 的值

唯一的例外是 stopSequences:它是"追加"而不是"覆盖"。

if (that.stopSequences != null) {
if (this.stopSequences == null) { this.stopSequences = new ArrayList<>(that.stopSequences); }
else { List<String> merged = new ArrayList<>(this.stopSequences);
merged.addAll(that.stopSequences);
this.stopSequences = merged; }
}

:133-142。)

这条不对称规则很容易在实际使用中咬人——你以为运行时 stopSequences 会替换掉默认值,实际是两份拼在一起。

各厂商的 Options 都覆写 combineWith先调 super.combineWith(other),再合并自己的独有字段,形成一条继承链上的合并瀑布(例:OpenAiChatOptions.java:1068-1069AnthropicChatOptions.java:896-897)。

5.4 合并到底发生在哪(两个点,别搞混)

【点 A】ChatClient 组装请求时 【点 B】ChatModel 收到 Prompt 时
────────────────────────────────── ──────────────────────────────────
chatModel.getOptions().mutate() if (prompt.getOptions() == null)
│ ← 模型默认选项作为基底 → 用 this.getOptions() 补上
▼ else
.combineWith(用户在 ChatClient 上设的选项) → 原样使用,不再合并

▼ 得到本次请求的 ChatOptions
  • 点 ADefaultChatClientUtils.toChatClientRequestspring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClientUtils.java:103(取模型默认并 mutate())和 :105combineWith 用户选项)。
  • 点 B 在适配器的 buildRequestPromptOpenAiChatModel.java:1170-1176——它只做兜底,不做合并。如果 Prompt 已经带了选项,模型的默认值一个都不会补进去。

还有一处:DefaultChatClient#prompt(Prompt)DefaultChatClient.java:128-136)在你直接传一个现成 Prompt 时,也会把它的选项 mutate() 出来再 combineWith

5.5 两个扩展面:mixin 式接口

厂商特性不可能全塞进 8 个可移植字段。Spring AI 用两个 mixin 接口把跨厂商的共性能力再抽一层:

接口加了什么位置
ToolCallingChatOptionsgetToolCallbacks()getToolContext(),以及三个静态工具方法spring-ai-model/src/main/java/org/springframework/ai/model/tool/ToolCallingChatOptions.java:39
StructuredOutputChatOptions只加一个 getOutputSchema() + Builder 的 outputSchema(String)spring-ai-model/src/main/java/org/springframework/ai/model/tool/StructuredOutputChatOptions.java:29

ToolCallingChatOptions 的三个静态方法定义了合并语义(注意和 combineWith 不同):

  • mergeToolCallbacks:69-75):运行时非空就整体取代默认,不做并集。
  • mergeToolContext:77-90):两个 Map 合并,运行时 key 覆盖默认 key。
  • validateToolCallbacks:92-101):重名工具直接 IllegalStateException

StructuredOutputChatOptions 极简到只有一个 getter,但它是第 7 节"原生 schema 路"的唯一开关——ChatModelCallAdvisorinstanceof StructuredOutputChatOptions 判断这家模型支不支持原生结构化输出。

目前同时实现两个 mixin 的有 OpenAiChatOptionsOpenAiChatOptions.java:57)和 AnthropicChatOptionsAnthropicChatOptions.java:71)。spring-ai-model 里还有两个通用实现 DefaultToolCallingChatOptions:41)和 DefaultStructuredOutputChatOptions:37),都继承 DefaultChatOptions

5.6 ModelOptionsUtils 现在只剩一个方法(诚实说明)

历史上 ModelOptionsUtils 是个大工具类。在本 commit(c988e72a)它整个文件只有 一个方法

@Contract("_, !null -> !null")
public static <T> @Nullable T mergeOption(@Nullable T runtimeValue, @Nullable T defaultValue) {
return runtimeValue == null ? defaultValue : runtimeValue;
}

spring-ai-model/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java:32-40,符号 ModelOptionsUtils#mergeOption。)

就是一个"运行时值优先、否则用默认值"的空值合并。它现在的用户主要是那些还在自建 API 客户端的适配器,比如 OllamaEmbeddingModel.java:167-173VertexAiTextEmbeddingModel.java:180-187(逐字段 merge 出请求参数)。DeepSeek、OpenAI、Anthropic 适配器已先后切到 Builder 体系(DeepSeek 逐字段 if-null 设值,见 DeepSeekChatModel.java:399-412;OpenAI/Anthropic 用 combineWith),完全不用它

老文档常说"选项合并靠 ModelOptionsUtils",在 2.0 已经不准确了。 主线合并路径是 ChatOptions.Builder#combineWithmergeOption 退化成了老适配器的局部辅助。

5.7 可移植字段 ≠ 人人支持

ChatOptions 里有 topK,但 OpenAI 的 chat completions 没这个参数。适配器的处理方式是警告并忽略,不是报错:

private void verifyPromptChatOptions(Prompt prompt) {
var chatOptions = prompt.getOptions();
if (chatOptions != null && chatOptions.getTopK() != null) {
logger.warn("The topK option is not supported by OpenAI chat models. Ignoring.");
}
}

OpenAiChatModel.java:499-505,符号 verifyPromptChatOptions,在 callstream 入口各调一次::198:263。)

这是"可移植"付出的代价之一:统一接口是最大公约数的并集,不是交集,落到具体家会有静默降级。


6. 供应商适配器:2.0 的转向

这节讲什么: 适配器内部长什么样,以及 2.0 做了一个重要的方向性改变。

6.1 转向:从"自建 HTTP 客户端"到"包官方 SDK"

1.x 时代,每家适配器下面有一个自己手写的 XxxApi 类(用 RestClient + 一堆 Java record 映射 JSON)。2.0 对两家最重要的供应商换了做法:

供应商底层依据
OpenAI官方 com.openai:openai-java-core;仓库内已无 OpenAiApi.javamodels/spring-ai-openai/pom.xml:28-29OpenAiChatModel.java:36-72 一整片 import com.openai.*
Anthropic官方 com.anthropic:anthropic-java-core(pom 注释说明特意选 core 版以避开 OkHttp 传递依赖)models/spring-ai-anthropic/pom.xml:26-31AnthropicChatModel.java:30-71
DeepSeek / Mistral / Ollama 等仍是自建 API 客户端models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/api/DeepSeekApi.java:57models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java:84

这是个权衡: 包官方 SDK 意味着新特性跟得快、不用自己追 API 变更,代价是 Spring AI 的 API 表面上多了一层第三方类型(比如 OpenAiChatOptions 里直接出现 ChatCompletionToolChoiceOption),而且适配器代码变长了(OpenAiChatModel 1637 行、AnthropicChatModel 1961 行)。

仓库内自带 7 个 ChatModel 实现,models/ 下共 15 个模块(含 embedding/image/audio 专用模块)。"十几家"的实际覆盖面还要靠 OpenAI 兼容端点(extraBody、自定义 baseUrl)向外扩。

6.2 一次 call 的五步骨架

OpenAiChatModel#internalCallOpenAiChatModel.java:208-256)是所有适配器的模板。五步:

① createRequest(prompt, false) → Prompt 翻译成 ChatCompletionCreateParams :210
② 建 ChatModelObservationContext + observe() → 把整段调用包进 Micrometer observation :213-221
③ openAiClient.chat().completions().create() → 真正发请求(官方 SDK) :223
④ choices.stream().map(buildGeneration) → SDK 响应翻回 List<Generation> :239-247
⑤ UsageCalculator.getCumulativeUsage(...) → token 用量跨轮累加,装进 ChatResponseMetadata :247-249

internalCall 的第二个参数 previousChatResponse 就是为第 ⑤ 步服务的:多轮工具调用时,每一轮的用量要累加到一起(工具循环本身见 03-tool-calling.md——注意 2.0 里循环已经不在 ChatModel 内部了,适配器只用 toolCallingManagerresolveToolDefinitions,见 :879)。

Anthropic 的对应方法结构一致,但多做一件事:用 withRawResponse() 拿到 HTTP 头,从中解析限流信息。

HttpResponseFor<Message> rawResponse = this.anthropicClient.messages().withRawResponse().create(request);
Message message = rawResponse.parse();
RateLimit rateLimit = AnthropicRateLimit.from(rawResponse.headers());

AnthropicChatModel.java:559-561,符号 internalCall。)

6.3 ToolDefinition → SDK 的函数定义

Spring AI 内部的 ToolDefinition 把入参 schema 存成一个 JSON 字符串。两家 SDK 都要结构化对象,所以适配器都得"解析再重装":

OpenAI 侧OpenAiChatModel.java:1001-1045,符号 getChatCompletionTools):

Map<String, Object> schemaMap = objectMapper.readValue(toolDefinition.inputSchema(), Map.class);
if (Boolean.TRUE.equals(strictMode)) {
applyStrictModeRequirements(schemaMap); // strict 模式要重写 schema(所有属性进 required)
}
schemaMap.forEach((key, value) -> parametersBuilder.putAdditionalProperty(key, JsonValue.from(value)));
FunctionDefinition functionDefinition = FunctionDefinition.builder()
.name(toolDefinition.name()).description(toolDefinition.description())
.parameters(parametersBuilder.build()).strict(strictMode).build();

注意三个细节:

  1. schema 的顶层键被平铺FunctionParameters 的 additionalProperties,而不是整块塞进去。
  2. strict 模式默认关闭、显式开启:1005-1016):strictMode 默认 false,只有调用方通过 OpenAiChatOptions#strict(请求级或模型级)打开时才置 true,且打开后会先用 applyStrictModeRequirements 重写 schema,把所有属性补进 required 以满足 OpenAI strict 契约。
  3. 解析失败只 logger.error 不抛(:1031-1032),于是会静默发出一个没有参数定义的工具。

Anthropic 侧AnthropicChatModel.java:1271-1310,符号 toAnthropicTool)走的是同一套"解析 → 重装",但目标形状不同:只取 propertiesrequired 两个键装进 Tool.InputSchema,其余键(type$schemadescription 等)被丢弃:

Object propertiesObj = schemaMap.get("properties"); // → putAdditionalProperty 逐个塞
Object requiredObj = schemaMap.get("required"); // → addRequired 逐个加
return Tool.builder().name(...).description(...).inputSchema(inputSchemaBuilder.build()).build();

而且失败时抛 RuntimeException:1308)——和 OpenAI 侧的静默吞掉不一致。

6.4 buildGeneration:把 SDK 响应填回中间语

OpenAiChatModel#buildGeneration:370-421)干四件事:

  1. 把 SDK 的 toolCalls 转成 List<AssistantMessage.ToolCall>:378-391),并过滤掉没有 function 的项
  2. finishReason 写进 ChatGenerationMetadata:393-396)。
  3. 处理音频输出:base64 解码成 Media,文本为空时用 transcript 顶上(:402-415)。
  4. 组装 AssistantMessage 并包成 Generation:417-421)。

调用方传进来的 metadata map(:237-247)承载了那些没有可移植字段可放的东西:idroleindexfinishReasonrefusalannotations,以及 reasoningContent

reasoningContent 的取法值得一看——它从 SDK 的 _additionalProperties() 里试两个键名:

additionalProperties.get("reasoning_content") // DeepSeek 风格
additionalProperties.get("reasoning") // 另一批 OpenAI 兼容端点

:1142-1154,符号 getReasoningContent。)这是"兼容 OpenAI 协议但字段各异"的现实写照。

响应级元数据由 from(ChatCompletion, Usage):453-476)组装:idmodelusagecreated,然后把 SDK 未识别的所有 _additionalProperties 逐个塞进 metadata:463-471)。getCreated:484)还专门兜底了"某些 OpenAI 兼容端点(如 GitHub Copilot)不返回 created 字段"的情况。

Anthropic 的 buildGenerations:988-1070)形状不同:因为 Anthropic 的响应是内容块数组,它要遍历 message.content(),按块类型分派:

块类型处理方式
text追加进 textContent,顺便抽 citations
toolUse转成一个 ToolCall
thinking单独产出一个 Generation,signature 放 metadata
redactedThinking单独 Generation,只带 data 标记
webSearchToolResult抽成 AnthropicWebSearchResult
其余(容器上传、代码执行等)logger.warn 明确标为不支持(:1054

这就是"一个 ChatResponse 可能有多个 Generation"的真实来源之一。

6.5 Usage:三个类 + 一个累加器

类型角色位置
Usage(接口)getPromptTokens / getCompletionTokensgetTotalTokens 是 default(两者相加);getNativeUsage() 保留厂商原始对象;getCacheReadInputTokens / getCacheWriteInputTokens 是 default 返回 nullspring-ai-model/src/main/java/org/springframework/ai/chat/metadata/Usage.java:29,56,68,78,90
DefaultUsage通用实现,多组构造函数spring-ai-model/src/main/java/org/springframework/ai/chat/metadata/DefaultUsage.java:36
EmptyUsage全 0 的空对象,用于"这次响应没带用量"spring-ai-model/src/main/java/org/springframework/ai/chat/metadata/EmptyUsage.java:28
UsageCalculator跨轮累加spring-ai-model/src/main/java/org/springframework/ai/support/UsageCalculator.java:32

UsageCalculator.getCumulativeUsage(current, previousChatResponse):52)的关键性质写在它自己的 Javadoc 里(:41-47):一旦真的发生累加,结果退化成一个普通 DefaultUsage,厂商特有的 nativeUsage 对象会丢失(因为跨响应无法合并)。只有"没东西可累加"时才原样返回 currentUsage。缓存指标(cacheRead/cacheWrite)单独累加,且两边都为 null 时保持 null:73-89),以区分"没有缓存"和"缓存了 0 个 token"。

6.6 流式:三层聚合

流式比同步多了一个问题:chunk 是碎的,但工具调用必须是完整的。 OpenAI 适配器用三层解决(internalStream:273-368):

SDK AsyncStreamResponse
│ Flux.create + subscribe(sink::next) :301-312

Flux<ChatCompletionChunk> 碎片
│ ① ChunkMerger 缓冲:检测到 toolCall 就开始攒,
│ 攒到 toolCallsDone 才放行 :318-328

Flux<ChatCompletion> 每个都是"完整可解析"的
│ ② buildGeneration 逐个翻译成 ChatResponse :331-352

Flux<ChatResponse> 增量响应(给用户实时看)
│ ③ MessageAggregator 旁路汇总成一个完整 ChatResponse :366
▼ (只喂给 observation,不改变主流)
observationContext.setResponse(完整响应)

第 ① 层 ChunkMerger:1179)是个私有静态类,bufferUntil 的判定逻辑:不在工具调用中就每个 chunk 立即放行;一旦 hasToolCall 就置位 isInsideTool,直到 toolCallsDone 才合并放行。

第 ③ 层 MessageAggregator#aggregatespring-ai-model/src/main/java/org/springframework/ai/chat/model/MessageAggregator.java:55)是通用的(不属于任何厂商),签名是 Flux<ChatResponse> aggregate(Flux<ChatResponse>, Consumer<ChatResponse>)。它用一堆 AtomicReference 边流边攒(:59-154),流结束时(doOnComplete:155)拼出一个完整的 AssistantMessage 交给回调。几条聚合规则值得记:

  • 文本:StringBuilder 追加;带 isThought 元数据的片段额外单独攒一份,最后写进 thoughts / outputWithoutThoughts:104-112:170-173)。
  • 元数据 map:putAll后来的覆盖先前的:114)。
  • usage:三个计数各自取"最后一个大于 0 的值"(:126-131),不是求和。
  • 工具调用:addAll 累积(:118-120),另外还会从响应级 metadata 的 "toolCalls" 键里再捞一次(:147-152)。

ChatClientMessageAggregatorspring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientMessageAggregator.java:37-55)是它在 ChatClient 层的薄包装:把 Flux<ChatClientResponse> 拆出 chatResponse 喂给 MessageAggregator,同时用一个 AtomicReference<Map> 把 advisor 上下文一路 putAll 累积回去。


7. 结构化输出:从 entity(Invoice.class) 到一个 Java 对象

这节讲什么: 让模型吐出能直接反序列化的 JSON,这件事在两条完全不同的路上怎么走通。

7.1 问题与两条路

模型天生吐自由文本。要拿到 Invoice 对象,必须解决两件事:(a)怎么让它按 schema 输出;(b)它没完全按,怎么办。

对 (a),业界有两条路,Spring AI 两条都走:

要一个 Invoice

┌──────────────┴──────────────┐
▼ ▼
【路 1】提示词注入(默认) 【路 2】原生 schema(需显式开启)
把 schema + 格式说明追加到 把 schema 写进 ChatOptions,
最后一条 user message 由 provider API 层强制
│ │
✔ 所有模型都能用 ✔ 命中率高、不浪费 token
✘ 模型可能不听话 ✘ 只有部分模型支持,且各有限制
└──────────────┬──────────────┘

回来的文本 → 清洗 → Jackson → Invoice

(可选)schema 校验失败 → 把错误追加进 prompt 重试

7.2 转换器的契约

public interface StructuredOutputConverter<T> extends Converter<String, T>, FormatProvider {
String NO_JSON_SCHEMA = "";
default String getJsonSchema() { return NO_JSON_SCHEMA; }
}

spring-ai-model/src/main/java/org/springframework/ai/converter/StructuredOutputConverter.java:31-46。)

它把两个职责焊在一起:

来自方法服务于
Converter<String, T>(Spring core)T convert(String)回程:文本 → 对象
FormatProviderString getFormat()去程(路 1):给模型的自然语言格式说明
自身(2.0 新增)String getJsonSchema()去程(路 2):给 API 的机器可读 schema

getJsonSchema() 默认返回空串,意味着不是所有转换器都能走原生路——ListOutputConverter / MapOutputConverter 都没覆写它。

7.3 BeanOutputConverter:主力

BeanOutputConverter<T>spring-ai-model/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java)构造时就做完两件重活(:138-144):确定 JsonMapper、确定清洗器、立刻生成 schema 并缓存

  • schema 生成generateSchema():179-181)委托给 JsonSchemaGenerator.generateForType(this.type)spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java:196)。方法是 protected,留了子类覆写的口子。
  • 提示词模板getFormat():214-224)。这段模板本身很有意思,它对着模型连说三遍"别加 markdown 围栏"
Your response should be in JSON format.
Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
Do not include markdown code blocks in your response.
Remove the ```json markdown from the output.
Here is the JSON Schema instance your output must adhere to:
```<这里插入生成的 schema>```

(对应 BeanOutputConverter.java:215-222。求了三遍还是不放心,所以回程还有一整条清洗管线——见下一节。)

  • 反序列化convert(String):190-195)只有两行:先 textCleaner.clean(text),再 jsonMapper.readValue
  • 容错配置getJsonMapper():201-206)显式 disable(FAIL_ON_UNKNOWN_PROPERTIES)——模型多吐几个字段不会炸

7.4 清洗管线:不信任模型的输出

ResponseTextCleaner 是个 @FunctionalInterfacespring-ai-model/src/main/java/org/springframework/ai/converter/ResponseTextCleaner.java:30),只有 String clean(String)CompositeResponseTextCleaner:34)按顺序串起来(clean:63-69)。

默认管线在 createDefaultTextCleaner()BeanOutputConverter.java:163-170),四步,注意 WhitespaceCleaner 出现两次

顺序清洗器干什么位置
1WhitespaceCleanertrimspring-ai-model/src/main/java/org/springframework/ai/converter/WhitespaceCleaner.java:27
2ThinkingTagCleaner正则删掉思考块spring-ai-model/src/main/java/org/springframework/ai/converter/ThinkingTagCleaner.java:45
3MarkdownCodeBlockCleaner剥掉 ```json … ``` 围栏spring-ai-model/src/main/java/org/springframework/ai/converter/MarkdownCodeBlockCleaner.java:32
4WhitespaceCleaner再 trim 一次(源码注释:final trim after all cleanups)同 1

ThinkingTagCleaner 的默认模式表(:50-60)覆盖五种写法:<thinking>(Amazon Nova)、<think>(Qwen)、<reasoning>```thinking 围栏、<!-- thinking: --> 注释。类的 Javadoc 强调它有 fast-path 优化,所以对不产思考标签的模型开销可忽略(:38-41)。

MarkdownCodeBlockCleaner:35-68)除了标准的三反引号块,还专门处理了"单行围栏"这种不合规但现实中会出现的形态:```{"key": "value"}```:53-58)。

7.5 两个轻量转换器

转换器目标类型手法位置
ListOutputConverterList<String>提示词要求"逗号分隔",用 Spring 的 DefaultConversionServicespring-ai-model/src/main/java/org/springframework/ai/converter/ListOutputConverter.java:32,43,51
MapOutputConverterMap<String, Object>提示词要求 JSON,用 JacksonJsonMessageConverterspring-ai-model/src/main/java/org/springframework/ai/converter/MapOutputConverter.java:38,46,57

MapOutputConverter#convert:46-54)自己手写了一个"如果以 ```json 开头就切掉"的剥壳(:47-49),没有复用 ResponseTextCleaner 体系——这两个老转换器还没跟上清洗管线的重构。

7.6 entity() 的完整调用链

entity(Invoice.class, spec -> spec.useProviderStructuredOutput().validateSchema()) 为例(DefaultChatClient.java):

① entity(type, consumer) :538-543
└─ new BeanOutputConverter<>(type) 构造时生成 schema
└─ resolveAdvisorChain(consumer, converter) :572-586
├─ spec.isEnableNative() → context 打上 STRUCTURED_OUTPUT_NATIVE=true :576-578
└─ spec.isValidated() → advisorChain.mutate().push(校验 advisor) :579-583
② doSingleWithBeanOutputConverter(converter, chain) :592-617
├─ context[OUTPUT_FORMAT] = converter.getFormat() :595-599
├─ context[STRUCTURED_OUTPUT_SCHEMA] = converter.getJsonSchema() :601-608(仅 native 开启时)
├─ 走 advisor 链 → ChatModel
└─ converter.convert(文本) :615

EntityParamSpec 的两个开关定义在 ChatClient.java:202-233useProviderStructuredOutput():225)和 validateSchema():232)。它们的实现类 DefaultEntityParamSpec 就是两个 boolean(DefaultChatClient.java:403-429)。三个 context key 定义在 ChatClientAttributes.java:29,31,33

7.7 分叉点:一个 if

两条路在 ChatModelCallAdvisor#augmentWithFormatInstructionsspring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelCallAdvisor.java:66-101)分开。这是整个结构化输出链路的枢纽

if (usesNativeStructuredOutput && StringUtils.hasText(outputSchema)
&& chatClientRequest.prompt().getOptions() instanceof StructuredOutputChatOptions structuredOutputChatOptions) {
var augmentedOptions = structuredOutputChatOptions.mutate().outputSchema(outputSchema).build();
// → 写进 ChatOptions,不碰消息
}
// 否则:
Prompt augmentedPrompt = chatClientRequest.prompt()
.augmentUserMessage(userMessage -> userMessage.mutate()
.text(userMessage.getText() + System.lineSeparator() + outputFormat).build());

:81-91 是原生路,:93-96 是提示词路。)

三个条件同时满足才走原生路,任何一个不满足就静默回落到提示词注入。第三个条件 instanceof StructuredOutputChatOptions 就是"这家模型支不支持"的判定——EntityParamSpec#useProviderStructuredOutput 的 Javadoc 明确写着 "Has no effect if the underlying ChatModel does not support StructuredOutputChatOptions"(ChatClient.java:206-208)。

落到具体家,outputSchema 最后变成什么:

供应商Builder#outputSchema(s) 的动作位置
OpenAI造一个 ResponseFormat{type=JSON_SCHEMA, jsonSchema=s}createRequest 再把它转成 SDK 的 ResponseFormatJsonSchemaOpenAiChatOptions.java:1054-1060OpenAiChatModel.java:764-780
Anthropic解析成 Map 装进 outputConfig;反向 getOutputSchema() 再从 SDK 的 _additionalProperties 还原成 JSON 字符串AnthropicChatOptions.java:615-617:411-417

7.8 校验 + 重试:最后一道补救

StructuredOutputValidationAdvisorspring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/StructuredOutputValidationAdvisor.java:64)用 JSON Schema Draft 2020-12 校验模型输出,失败就把错误信息追加到 user message 重试。

核心是一个 for 重试循环(:131-178):

for (currentAttemptNumber = 1 + maxRepeatAttempts; :131-132
currentAttemptNumber > 0 && !isValidationSuccess; currentAttemptNumber--) {
response = chain.copy(this).nextCall(request) :134
usageAccumulator.addRoundResponse(response) :138
if (response 没有 toolCalls) { :140-141 ← 工具调用轮不校验
校验 JSON :142-143
if (失败) {
errMsg = "Output JSON validation failed because of: " + 错误
request = 原始 request.prompt().augmentUserMessage(原文 + \n + errMsg) :162-168
}
}
}
return usageAccumulator.applyAccumulatedUsage(response) :176

四个设计细节:

  1. 不回喂上一次的错误输出,只回喂校验错误。源码注释解释了原因:怕把模型带偏、也怕 prompt 变复杂(:151-155)。
  2. 每次重试都从 chatClientRequest(原始请求)派生,不是在上一次的增强版上继续叠加——所以错误信息不会累积成一坨。
  3. 默认重试 3 次(:251private int maxRepeatAttempts = 3)。
  4. UsageAccumulator 把每一轮的 token 累加后统一贴回最终响应(:129:138:176)——重试的开销不会被漏计。

它同时实现 CallAdvisorStreamAdvisor,但类 Javadoc 直说 "Streaming responses are not supported"(:59)。


8. 巧妙之处(可借鉴的技术)

① 用"空对象 + 非空 getter"消灭 null 检查。 Prompt.getSystemMessage() 找不到就返回 new SystemMessage("")Prompt.java:113),ChatGenerationMetadata.NULL:37)、EmptyUsageEmptyRateLimit 同理。调用方不用写 if (x != null)

@Nullable 承担"未设置"语义,让合并规则只有一行。 因为所有 ChatOptions 字段都是包装类型且可空,combineWith 才能写成"非空即覆盖"(DefaultChatOptionsBuilder.java:119-154)。如果用了 double temperature = 0.0 这种基本类型,就永远分不清"用户设成 0"和"用户没设"。

③ 自递归 Builder 让扩展不牺牲链式调用。 Builder<B extends Builder<B>> + self()DefaultChatOptionsBuilder.java:60-63)让子类 Builder 的方法返回自己的类型,OpenAiChatOptions.builder().temperature(0.7).responseFormat(...) 才能一路点下去。

④ 用 mixin 接口做"能力探测",而不是布尔标志位。 instanceof StructuredOutputChatOptionsChatModelCallAdvisor.java:81)就是"这家支不支持原生结构化输出"的判定——不需要维护一张能力表,编译期就保证实现了接口就一定有 getOutputSchema()

⑤ 流式聚合走"旁路"不改主流。 MessageAggregator 挂在 doOnComplete 上,把完整响应交给回调(MessageAggregator.java:190-192),主 Flux 里流出去的仍是增量片段。用户实时看到打字机效果,observation 拿到的是完整响应,互不干扰。

⑥ 清洗管线承认"提示词不够用"。 getFormat() 里连喊三遍不要 markdown 围栏,回程仍然接一条 MarkdownCodeBlockCleanerBeanOutputConverter.java:163-170)。这种"要求 + 兜底"的双保险,比任何一边单独做都可靠。

⑦ 累加时诚实丢弃不可合并的数据。 UsageCalculator 的 Javadoc 明说:一旦真的相加,nativeUsage 就丢(:41-47)。在注释里写清楚有损,比悄悄留一个错的对象好。


9. 边界与局限(诚实)

局限具体表现依据
流式没有结构化输出StreamResponseSpec 只有 content() / chatResponse() / chatClientResponse()没有 entity()ChatModelStreamAdvisor 也不做任何格式注入(对比 call 版的 augmentWithFormatInstructionsChatClient.java:376-384ChatModelStreamAdvisor.java:49-59
校验重试不支持流式类 Javadoc 直接声明StructuredOutputValidationAdvisor.java:59
原生路对 List 会翻车OpenAI Structured Outputs 不接受顶层数组 schema,entity(List<T>) + useProviderStructuredOutput() 会失败,官方建议包一层 record 或退回提示词路ChatClient.java:219-222
原生路对 reasoning 模型会翻车Ollama 上 qwen3:8b 这类带思考模式的模型可能返回纯文本,反序列化失败;文档建议配 validateSchema() 或换非 reasoning 模型ChatClient.java:214-218
OpenAI 工具 strict 模式默认关闭、开启有额外代价getChatCompletionTools 默认 strict=false,只有 OpenAiChatOptions#strict 显式打开才置 true,且开启时会用 applyStrictModeRequirements 把 schema 重写成"所有属性必填"以满足 OpenAI 契约OpenAiChatModel.java:1005-1016
两家适配器的 schema 解析失败行为不一致OpenAI 只 log error 后继续(发出一个没参数的工具);Anthropic 抛 RuntimeExceptionOpenAiChatModel.java:1031-1032 vs AnthropicChatModel.java:1308
可移植字段会被静默忽略topK 给 OpenAI 只 warn 不报错OpenAiChatModel.java:499-505
stopSequences 的合并语义反直觉是追加不是覆盖DefaultChatOptionsBuilder.java:133-142
自定义 Message 子类不能 mutate()Prompt#instructionsCopy 只认四种内建类型,其余抛 IllegalArgumentExceptionPrompt.java:216
Map/List 转换器没跟上重构它们各自手写剥壳逻辑,不走 ResponseTextCleaner 管线,也不提供 getJsonSchema()MapOutputConverter.java:47-49
抽象层泄漏了第三方类型包官方 SDK 之后,OpenAiChatOptions.getToolChoice() 可能是 com.openaiChatCompletionToolChoiceOptionOpenAiChatModel.java:884-899

10. 横向对比

同 shelf 的兄弟项目在"provider 中立"上有各自的取舍:

项目中间语的形态结构化输出明显差异
Spring AI(本章)Prompt + Message 家族 + ChatOptions(强类型 Java 接口 + Builder)转换器双职责(getFormat / getJsonSchema),原生 vs 提示词两路,advisor 里一个 if 分叉类型系统吃得最重:自递归 Builder、mixin 接口、编译期能力探测
GriptapePromptStack + Message + MessageContent(Python,弱类型)structured_output_strategy 一个字符串开关三选一开关是配置字段而非类型判定,更灵活也更容易配错

Griptape 侧的实现细节见 griptape/03-drivers-and-provider-neutrality.md

一个共同的模式值得单独点出:两个项目都把"流式碎片 → 完整消息"的聚合抽成了独立部件(Spring AI 的 MessageAggregator、Griptape 的 BaseMessageContent.from_deltas)。因为无论中间语怎么设计,"chunk 是碎的但工具调用必须完整"这个约束是所有框架共同面对的。


11. 代码地图(导航索引)

路径相对克隆根 spring-ai/M/ = spring-ai-model/src/main/java/org/springframework/ai/C/ = spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/

主题文件路径符号名
泛型调用契约M/model/Model.javaModel / call
泛型流式契约M/model/StreamingModel.javaStreamingModel / stream
请求形状M/model/ModelRequest.javagetInstructions / getOptions
响应形状M/model/ModelResponse.javagetResult / getResults / getMetadata
单条结果形状M/model/ModelResult.javagetOutput
选项标记接口M/model/ModelOptions.javaModelOptions
运行时/默认值合并(老适配器用)M/model/ModelOptionsUtils.javamergeOption
chat 主接口 + default 糖M/chat/model/ChatModel.javaChatModel / getOptions / call(String)
chat 流式接口M/chat/model/StreamingChatModel.javaStreamingChatModel
流式碎片聚合(通用)M/chat/model/MessageAggregator.javaMessageAggregator#aggregate
消息基类M/chat/messages/AbstractMessage.javaAbstractMessage
四种角色M/chat/messages/MessageType.javaMessageType
工具调用 recordM/chat/messages/AssistantMessage.javaAssistantMessage.ToolCall / hasToolCalls
工具结果 recordM/chat/messages/ToolResponseMessage.javaToolResponseMessage.ToolResponse
请求容器 + 改写器M/chat/prompt/Prompt.javaaugmentUserMessage / augmentSystemMessage / mutate / instructionsCopy
可移植字段集M/chat/prompt/ChatOptions.javaChatOptions / Builder#combineWith
合并规则实现M/chat/prompt/DefaultChatOptionsBuilder.javacombineWith / self
工具能力扩展面M/model/tool/ToolCallingChatOptions.javamergeToolCallbacks / mergeToolContext / validateToolCallbacks
结构化输出扩展面M/model/tool/StructuredOutputChatOptions.javagetOutputSchema / Builder#outputSchema
token 用量累加M/support/UsageCalculator.javagetCumulativeUsage
用量接口与实现M/chat/metadata/Usage.java · DefaultUsage.java · EmptyUsage.javaUsage#getNativeUsage / DefaultUsage / EmptyUsage
转换器契约M/converter/StructuredOutputConverter.javaStructuredOutputConverter / getJsonSchema
格式说明契约M/converter/FormatProvider.javagetFormat
Bean 转换主力M/converter/BeanOutputConverter.javagenerateSchema / getFormat / convert / createDefaultTextCleaner
清洗器接口M/converter/ResponseTextCleaner.javaResponseTextCleaner#clean
清洗管线串联M/converter/CompositeResponseTextCleaner.javaCompositeResponseTextCleaner
思考标签清洗M/converter/ThinkingTagCleaner.javaDEFAULT_PATTERNS
markdown 围栏清洗M/converter/MarkdownCodeBlockCleaner.javaMarkdownCodeBlockCleaner#clean
List / Map 转换器M/converter/ListOutputConverter.java · MapOutputConverter.javaListOutputConverter / MapOutputConverter
schema 生成器M/util/json/schema/JsonSchemaGenerator.javagenerateForType
entity() 入口与开关C/DefaultChatClient.javadoSingleWithBeanOutputConverter / resolveAdvisorChain / DefaultEntityParamSpec
两个开关的声明C/ChatClient.javaEntityParamSpec#useProviderStructuredOutput / #validateSchema
context key 常量C/ChatClientAttributes.javaOUTPUT_FORMAT / STRUCTURED_OUTPUT_SCHEMA / STRUCTURED_OUTPUT_NATIVE
两条路的分叉点C/advisor/ChatModelCallAdvisor.javaaugmentWithFormatInstructions
默认选项合并点C/DefaultChatClientUtils.javatoChatClientRequestgetOptions().mutate() + combineWith
schema 校验重试C/advisor/StructuredOutputValidationAdvisor.javaadviseCall / validateOutputSchema / maxRepeatAttempts
ChatClient 层流式聚合C/ChatClientMessageAggregator.javaaggregateChatClientResponse
OpenAI 适配器主干models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.javainternalCall / internalStream / buildGeneration / createRequest / buildRequestPrompt
OpenAI 工具翻译同上getChatCompletionTools
OpenAI 流式分块合并同上ChunkMerger
OpenAI 不支持字段告警同上verifyPromptChatOptions
OpenAI 选项(含 native schema)models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.javagetOutputSchema / Builder#outputSchema
Anthropic 适配器主干models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.javainternalCall / buildGenerations / createRequest
Anthropic 工具翻译同上toAnthropicTool
Anthropic 选项(含 native schema)models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatOptions.javagetOutputSchema