Spring AI 2.0.1 深度解析-第 02 章 核心抽象层:`Model` / `StreamingModel` 与 ChatModel 体系

3 阅读1小时+

Spring AI 2.0.1 深度解析-第 02 章 核心抽象层:Model / StreamingModel 与 ChatModel 体系

本章把 spring-ai-model 的抽象层拆到每一行。 所有代码均原样摘自 spring-ai-2.0.1,标注 文件:行号。 凡 1.x 时代存在但 2.0.1 中已不存在的类/方法,本章会明确标注「不存在」——这些往往是搜索源码时最容易白费时间的地方。


2.1 开场:一个只有 41 行的接口,撑起了 168 个 artifact

整个 Spring AI 的门面,是这样一个文件:

// spring-ai-model/src/main/java/org/springframework/ai/model/Model.java
17→package org.springframework.ai.model;
18→
19→/**
20→ * The Model interface provides a generic API for invoking AI models. It is designed to
21→ * handle the interaction with various types of AI models by abstracting the process of
22→ * sending requests and receiving responses. The interface uses Java generics to
23→ * accommodate different types of requests and responses, enhancing flexibility and
24→ * adaptability across different AI model implementations.
25→ *
26→ * @param <TReq> the generic type of the request to the AI model
27→ * @param <TRes> the generic type of the response from the AI model
28→ * @author Mark Pollack
29→ * @since 0.8.0
30→ */
31→public interface Model<TReq extends ModelRequest<?>, TRes extends ModelResponse<?>> {
32→
33→	/**
34→	 * Executes a method call to the AI model.
35→	 * @param request the request object to be sent to the AI model
36→	 * @return the response from the AI model
37→	 */
38→	TRes call(TReq request);
39→
40→}

41 行(含 license 与 package),一个方法。

这个数字值得停下来想一想。Spring AI 2.0.1 有 168 个受管 artifact、15 个模型供应商模块、23 个向量库模块、数十个 advisor,而它们的共同祖先只有 TRes call(TReq request)。

这不是"设计得简单",而是设计得克制。要理解这种克制,需要看清楚它放弃了什么:

  • 没有 getDefaultOptions() —— options 管理不在这一层(2.0.0 才由 ChatModel.getOptions() 引入,见 §2.5)。
  • 没有 getObservationConvention() —— 观察约定不在这一层(§2.5 会证明 2.0.1 里它根本不存在于 ChatModel)。
  • 没有 callAsync() / callStreaming() —— 流式由平行的 StreamingModel 承担,而不是塞进同一个接口。
  • 没有 任何与"工具调用"相关的方法 —— 2.x 把工具执行循环从模型层彻底移除了(第 08 章详述)。
  • 没有 任何与具体模态相关的方法 —— 文本/图像/音频/向量各自用不同的 TReq/TRes。

一个接口能有多小,取决于它把多少职责推给了泛型参数。Model<TReq, TRes> 把"模态"编码进了类型系统,而不是编码进方法名对比一下 1.x 之前的世界,能立刻看出这次演进的意义。upgrade-notes.adoc 里记录了一次早期更名:

// spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc:3080
3080→We renamed the 'old' `ModelClient` to `Model` and renamed implementing classes, for
     example `ImageClient` was renamed to `ImageModel`.  The `Model` implementation
     represents the portability layer that converts between the Spring AI API and the
     underlying AI Model API.

ModelClient → Model。去掉 "Client" 这个后缀,是一次语义上的重新定位:它不再被理解为"某个远程服务的客户端",而是"一个可被调用的模型能力"。这个定位差异直接决定了后面所有的设计——客户端关心连接、超时、协议;模型只关心"输入 → 输出"。

2.1.1 泛型边界为什么是 ? 而不是具体类型

Model<TReq extends ModelRequest<?>, TRes extends ModelResponse<?>> ——两个边界都用了通配符 <?>。

这个细节容易被一眼扫过。它意味着 Model 不关心请求里承载的 instructions 是什么类型。

对比两种没被采用的写法:若把 instructions 的类型 T 也提到签名里(<T, TReq extends ModelRequest<T>, ...>),ChatModel extends Model<..., Prompt, ChatResponse> 就得多声明一个类型变量,纯属噪音;若用裸类型(ModelRequest 不带泛型),会触发编译器警告,在 -Xep:NullAway:ERROR 严格模式下还可能被拦。

实际写法是"我只约束它是个 ModelRequest,其余一概不管"的最小承诺。这正是 ImageModel extends Model<ImagePrompt, ImageResponse>、ModerationModel extends Model<ModerationPrompt, ModerationResponse>、EmbeddingModel extends Model<EmbeddingRequest, EmbeddingResponse> 能用同一个 Model 表达的原因——模态差异被编码进了类型参数,而不是方法名。

2.1.2 @since 0.8.0 的分量

Model.java 的 Javadoc 第 29 行写着 @since 0.8.0。在 2.0.1 这个大版本号下,这个标注说明:这个接口从 0.8.0 起,签名从未变过。

把它和本章后面会看到的其他类放在一起:

类@since说明
Model0.8.02.0.1 零变更
StreamingModel0.8.02.0.1 零变更
ModelRequest0.8.02.0.1 零变更
ModelResponse0.8.02.0.1 零变更
ModelResult0.8.02.0.1 零变更
ModelOptions0.8.02.0.1 零变更
ChatResponseMetadata1.0.02.0.1 仍是具体类
ChatModel.getOptions()2.0.02.x 新增
Usage.getCacheReadInputTokens()2.0.02.x 新增
StreamingTranscriptionModel2.0.12.0.1 新增

越靠近根部,变动越少。 这不是巧合,而是任何成熟框架都必然呈现的形态:向上的 20 个模块可以随意重构,向下的这 7 个接口一旦动了,168 个 artifact 全部陪葬。

本章会反复回到这条规律。当你在 2.x 里看到某个抽象层的类被改名、被删除、被替换,第一反应应该是:它一定不在最底下那 7 个里。


2.2 四层契约:Model → ModelResponse → ModelResult

在深入 ChatModel 之前,必须先把这条三级泛型链刻清楚。很多人读 Spring AI 源码读得似是而非,根源就在这里没建立起来。

Model<TReq, TRes>
   │
   ├── TReq extends ModelRequest<?>      ── 请求:instructions + options
   │        └── ModelRequest<T>  { T getInstructions();  @Nullable ModelOptions getOptions(); }
   │
   └── TRes extends ModelResponse<?>     ── 响应:多个结果 + 响应级元数据
            └── ModelResponse<T extends ModelResult<?>>
                     ├── @Nullable T getResult()          ── 第一个结果
                     ├── List<T> getResults()             ── 全部结果
                     └── ResponseMetadata getMetadata()   ── 响应级元数据(模型名、usage、rateLimit)
                              │
                              └── T extends ModelResult<?>
                                       ├── T getOutput()               ── 真正的产出
                                       └── ResultMetadata getMetadata() ── 结果级元数据(finishReason 等)

注意这里有两套 metadata,名字不同、包相同:

  • ResponseMetadata(响应级):模型名、id、usage、rateLimit、promptMetadata
  • ResultMetadata(结果级):finishReason、contentFilters

它们都在 org.springframework.ai.model 包下,但互不相干:

// spring-ai-model/src/main/java/org/springframework/ai/model/ResultMetadata.java:29
29→public interface ResultMetadata {
30→
31→}
// spring-ai-model/src/main/java/org/springframework/ai/model/ModelOptions.java:29
29→public interface ModelOptions {
30→
31→}

两个都是空标记接口(ModelOptions 的 Javadoc 里甚至直接自称 "This marker interface")。它们的存在价值是给泛型提供边界,以及给"所有 options 的公共祖先"一个统一类型。

2.2.1 为什么要分两级?

一次 LLM 调用返回 n 个候选结果(n 即 ChatOptions.getN(),OpenAI 里通常叫 n)。

  • 响应级元数据描述"这次 API 调用":花了多少 token、用的是哪个模型、限流还剩多少、请求 id 是什么。这些对 n 个候选是共享的。
  • 结果级元数据描述"这个候选":为什么停下来了(finishReason)、触发了哪些内容过滤器(contentFilters)。这些对每个候选可能不同。

如果合并成一级,要么把 usage 复制到每个候选里(浪费且不一致),要么把 finishReason 提到响应级(语义错误——不同候选的停止原因确实可能不同)。

这是一个教科书式的"共享 vs 独立"分离,值得在自己的 API 设计里复刻。

2.2.2 ModelResponse.getResult() 的 @Nullable 契约

// spring-ai-model/src/main/java/org/springframework/ai/model/ModelResponse.java:34
34→public interface ModelResponse<T extends ModelResult<?>> {
35→
36→	/**
37→	 * Retrieves the (first) result of the AI model.
38→	 * @return the result generated by the AI model
39→	 */
40→	@Nullable T getResult();
41→
42→	/**
43→	 * Retrieves the list of generated outputs by the AI model.
44→	 * @return the list of generated outputs
45→	 */
46→	List<T> getResults();
47→
48→	/**
49→	 * Retrieves the response metadata associated with the AI model's response.
50→	 * @return the response metadata
51→	 */
52→	ResponseMetadata getMetadata();
53→
54→}

第 40 行的 @Nullable T getResult() 是一个契约声明:结果列表可能为空,此时返回 null。

但契约只是契约。2.0.1 里至少有三种互相冲突的实现(§2.24 会给出完整清单):

实现getResult() 空列表行为是否符合契约
ChatResponse返回 null✅
ImageResponse返回 null✅
EmbeddingResponseAssert.notEmpty(...) 抛异常❌
AudioTranscriptionResponse永不 null(单结果模型)⚠️ 不适用
TextToSpeechResponseIndexOutOfBoundsException❌

这种"同一个契约、多种实现"的局面,是框架演进到一定规模后的必然产物。本章不打算粉饰它,而是把它当成实证材料:@Nullable 是给调用方看的承诺,但 2.0.1 里并不是所有实现类都兑现了它。你在写容错代码时,不能只依赖注解判断,必须看具体实现。


2.3 StreamingModel:68 个字符里藏着的架构决策

// spring-ai-model/src/main/java/org/springframework/ai/model/StreamingModel.java:34
34→public interface StreamingModel<TReq extends ModelRequest<?>, TResChunk extends ModelResponse<?>> {
35→
36→	/**
37→	 * Executes a method call to the AI model.
38→	 * @param request the request object to be sent to the AI model
39→	 * @return the streaming response from the AI model
40→	 */
41→	Flux<TResChunk> stream(TReq request);
42→
43→}

2.3.1 TResChunk 的命名与它的真实类型

变量名叫 TResChunk("Chunk",分片)。任何读过 LLM 流式文档的人都会立刻把"chunk"理解成"一小段增量文本"——OpenAI 的 SSE 里每个 data: 事件确实只携带几个 token 的 delta。

但类型边界写的是 TResChunk extends ModelResponse<?>。

也就是说:流里的每一个元素,都是一个语法上完整的响应对象,而不是一个"半截 Generation"。

具体到 Chat:StreamingChatModel extends StreamingModel<Prompt, ChatResponse>,所以:

Flux<ChatResponse> stream(Prompt prompt);

每个 chunk 都是一个完整的 ChatResponse,里面包含一个 Generation,而这个 Generation 里装的是"到目前为止累积/或本次增量"的 AssistantMessage。

2.3.2 为什么这很重要?三个直接后果

后果一:每个 chunk 都携带一份独立的 metadata。

ChatResponse 有 ChatResponseMetadata,所以每个流元素都有自己的 usage、id、model。但真实的 LLM 流式协议里,usage 通常只在最后一个 chunk 出现(OpenAI 需要 stream_options: {include_usage: true} 才会在末尾多给一个 usage-only chunk)。

这个错配直接导致了两个东西的存在:

  1. 大多数 chunk 的 usage 是 EmptyUsage(返回 0)—— 由 ChatResponseMetadata 的默认字段值提供(§2.10);
  2. 需要一个聚合器把流式的多个 ChatResponse 合并成一个完整响应——这正是 MessageAggregator 存在的理由(第 04 章详述)。

后果二:ChatResponse 必须是不可变的,或者至少是"可安全丢弃的"。

流里每秒可能产生 20-50 个 ChatResponse 对象。如果它们共享同一个可变 metadata 实例,消费端就会看到"过去的值被后来的改掉了"这种幽灵 bug。

有意思的是,2.0.1 并没有完全做到这点:Generation 的 chatGenerationMetadata 字段不是 final(§2.8.2)。这是一个真实的设计缝隙。

后果三:Flux<String> 是"有损转换"。

ChatClient.stream() 可以返回 Flux<String>(纯文本流),也可以返回 Flux<ChatResponse>(完整响应流)。前者丢失了 metadata、finishReason、tool calls——第 04 章会详细论证为什么在工具调用场景下必须用后者。

2.3.3 一个反事实推演:如果 chunk 是 ModelResult 会怎样?

假设 Spring AI 把边界写成 TResChunk extends ModelResult<?>,那么流元素就是 Generation:

Flux<Generation> stream(Prompt prompt);   // 假想

好处是"语义更准确"——确实每次只来一小段。坏处是:

  1. 丢失响应级 metadata:usage、rateLimit、id 只能挂在 Generation 上,但它们是响应级的,语义错位(§2.2.1 讨论过)。
  2. 无法表达"流结束"信号:很多协议需要一个"最后一个事件"来携带 usage 或 finishReason,而它不是内容的一部分。ChatResponse 可以带一个内容为空但 metadata 完整的实例,Generation 很难优雅表达。
  3. 破坏与 call() 的类型统一:call() 返回 ChatResponse,stream() 返回 Generation,两条链路的类型不同,advisor 链(第 05 章)就必须写两套。

第 3 点是决定性的。 Advisor 链需要对"流式"和"非流式"做统一抽象(CallAroundAdvisor / StreamAroundAdvisor),如果两条链路的元素类型不同,抽象会立刻崩塌。

所以 TResChunk extends ModelResponse<?> 不是疏忽,而是为了让流与非流共用同一套类型而付出的代价:语义上略有冗余(每个 chunk 都声称自己是"完整响应"),架构上获得巨大收益。


2.4 ModelRequest:一处只有 8 个单词的注释,说明了整个 nullness 策略

// spring-ai-model/src/main/java/org/springframework/ai/model/ModelRequest.java:32
32→public interface ModelRequest<T> {
33→
34→	/**
35→	 * Retrieves the instructions or input required by the AI model.
36→	 * @return the instructions or input required by the AI model
37→	 */
38→	T getInstructions(); // required input
39→
40→	/**
41→	 * Retrieves the customizable options for AI model interactions.
42→	 * @return the customizable options for AI model interactions
43→	 */
44→	@Nullable ModelOptions getOptions();
45→
46→}

2.4.1 两个方法的不对称

方法返回nullness含义
getInstructions()T非 null(隐式)必需输入
getOptions()@Nullable ModelOptions可为 null(显式)可选配置

第 38 行尾部那条 // required input 是这个文件里唯一的源码内联注释(Javadoc 不算)。作者觉得有必要手写这 8 个单词,说明"instructions 非 null"这件事不能靠类型系统自证——因为 Java 的引用类型默认可空。

那它是怎么保证的?靠包级 @NullMarked:

// spring-ai-model/src/main/java/org/springframework/ai/model/package-info.java:28
28→@NullMarked
29→package org.springframework.ai.model;
30→
31→import org.jspecify.annotations.NullMarked;

@NullMarked 的语义是:这个包内所有未显式标注 @Nullable 的引用类型,默认非空。所以:

  • T getInstructions() 没有 @Nullable ⇒ 非空;
  • @Nullable ModelOptions getOptions() 有显式 @Nullable ⇒ 可空。

这是 JSpecify 规范(不是 Spring 的 org.springframework.lang.Nullable)。ModelRequest.java 第 19 行的 import 是 org.jspecify.annotations.Nullable——整个 2.x 都在往 JSpecify 迁移,这是可观测性之外另一个贯穿全书的主题。

2.4.2 这个不对称是后面所有 options 语义的源头

getOptions() 可空,意味着"请求可以不带 options"。那么模型实现就必须回答:请求没带 options 时我用什么?

1.x 的答案是:把请求带的 options 和模型自带的 default options 做深度合并(逐字段,ModelOptionsUtils.merge() + BeanWrapper 反射)。

2.x 的答案完全不同:不合并,二选一(upgrade-notes.adoc:1463)。

这两种答案的差异,是 1.x → 2.x 最重要的架构结论之一。它会直接改变你写"给某次调用单独调 temperature"这类代码的方式——第 03 章会用完整源码和官方原文展开,这里只先埋一个种子:2.x 里 ChatModel.call() 收到带 options 的 Prompt 时,会原样使用这个 options,不会用模型自身的默认 options 去补齐其中为 null 的字段。


2.5 ChatModel:69 行,每一段都有话要说

// spring-ai-model/src/main/java/org/springframework/ai/chat/model/ChatModel.java
17→package org.springframework.ai.chat.model;
18→
19→import java.util.Arrays;
20→
21→import org.jspecify.annotations.Nullable;
22→import reactor.core.publisher.Flux;
23→
24→import org.springframework.ai.chat.messages.Message;
25→import org.springframework.ai.chat.messages.UserMessage;
26→import org.springframework.ai.chat.prompt.ChatOptions;
27→import org.springframework.ai.chat.prompt.Prompt;
28→import org.springframework.ai.model.Model;
29→
30→public interface ChatModel extends Model<Prompt, ChatResponse>, StreamingChatModel {
31→
32→	default @Nullable String call(String message) {
33→		Prompt prompt = new Prompt(new UserMessage(message));
34→		Generation generation = call(prompt).getResult();
35→		return (generation != null) ? generation.getOutput().getText() : "";
36→	}
37→
38→	default @Nullable String call(Message... messages) {
39→		Prompt prompt = new Prompt(Arrays.asList(messages));
40→		Generation generation = call(prompt).getResult();
41→		return (generation != null) ? generation.getOutput().getText() : "";
42→	}
43→
44→	@Override
45→	ChatResponse call(Prompt prompt);
46→
47→	/**
48→	 * Gets the chat options for this model.
49→	 * @return the chat options
50→	 * @since 2.0.0
51→	 */
52→	default ChatOptions getOptions() {
53→		return ChatOptions.builder().build();
54→	}
55→
56→	/**
57→	 * @deprecated use {@link #getOptions()} instead.
58→	 */
59→	@Deprecated(forRemoval = true)
60→	default ChatOptions getDefaultOptions() {
61→		return getOptions();
62→	}
63→
64→	default Flux<ChatResponse> stream(Prompt prompt) {
65→		throw new UnsupportedOperationException("streaming is not supported");
66→	}
67→
68→}

2.5.1 第 30 行:双继承模式

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

注意 StreamingChatModel 后面没有泛型参数。因为 StreamingChatModel 自己已经把 StreamingModel<Prompt, ChatResponse> 参数化完毕了(§2.6 第 30 行)。

这个"XxxModel extends Model<Req,Res>, StreamingXxxModel"的模式,在 2.0.1 里被复用了至少三次:

模型声明文件
ChatModelextends Model<Prompt, ChatResponse>, StreamingChatModelchat/model/ChatModel.java:30
TranscriptionModelextends Model<AudioTranscriptionPrompt, AudioTranscriptionResponse>, StreamingTranscriptionModelaudio/transcription/TranscriptionModel.java:32-33
TextToSpeechModelextends Model<TextToSpeechPrompt, TextToSpeechResponse>, StreamingTextToSpeechModelaudio/tts/TextToSpeechModel.java:28

而不支持流式的模型就不继承 Streaming 接口:

// spring-ai-model/src/main/java/org/springframework/ai/image/ImageModel.java:21
21→@FunctionalInterface
22→public interface ImageModel extends Model<ImagePrompt, ImageResponse> {
23→
24→	ImageResponse call(ImagePrompt request);
25→
26→}
// spring-ai-model/src/main/java/org/springframework/ai/moderation/ModerationModel.java:30
30→@FunctionalInterface
31→public interface ModerationModel extends Model<ModerationPrompt, ModerationResponse> {
32→
33→	ModerationResponse call(ModerationPrompt request);
34→
35→}

"能不能流式"被编码进了类型继承关系,而不是运行时标志位。 这比"给所有模型加一个 isStreamingSupported()"要强得多——编译期就能约束,且不需要调用方做防御性判断。

2.5.2 第 44-45 行:@Override 一个没有 default 的抽象方法

@Override
ChatResponse call(Prompt prompt);

这一行没有 default,所以它是纯抽象的。那为什么要写?

因为 Model<TReq, TRes>.call(TReq) 的返回类型是 TRes(泛型变量),而 ChatModel 把它具化为 ChatResponse。这是 Java 的协变返回类型技巧:子类重新声明父类方法,把返回类型收窄到更具体的类型。

好处是:拿到 ChatModel 引用的人,model.call(prompt) 直接得到 ChatResponse,不需要强转。

ChatModel model = ...;
ChatResponse resp = model.call(prompt);   // 无需 (ChatResponse) 强转
Model<Prompt, ChatResponse> m = model;
ChatResponse resp2 = m.call(prompt);     // 其实也行,因为 TRes = ChatResponse

严格说这里协变带来的收益有限(因为 TRes 已经被具化为 ChatResponse),但重声明本身有文档价值:它明明白白地告诉读者"这个模型接受 Prompt、返回 ChatResponse",不需要去数泛型参数。

同样的 @Override 重声明出现在 EmbeddingModel(EmbeddingModel.java:41-42)、TranscriptionModel(:40)、TextToSpeechModel(:40-41)、DocumentEmbeddingModel(:29-30)。是统一的写法习惯。

2.5.3 第 32-42 行:两个便捷方法,与一个"保守标注"

default @Nullable String call(String message) {
    Prompt prompt = new Prompt(new UserMessage(message));
    Generation generation = call(prompt).getResult();
    return (generation != null) ? generation.getOutput().getText() : "";
}

三点值得注意:

(1)返回类型标了 @Nullable String,但实现永远不会返回 null。 空时返回 ""。这是保守标注:作者在类型上留了余地(万一未来子类改变行为),但当前实现保证非空。这类标注在读源码时要小心——不能因为看到 @Nullable 就写 if (s == null),也不能因为看到实现返回 "" 就假设所有实现都这样。

(2)new Prompt(new UserMessage(message)) 说明单字符串会被包装成 UserMessage。 角色是 USER 而非 SYSTEM,意味着 chatModel.call("你好") 等价于"只有一条用户消息、没有 system prompt"的调用。想给 system prompt 必须自己构造 Prompt。

(3)call(Message...) 用 new Prompt(Arrays.asList(messages)),注意没有做 List.of() 不可变拷贝(Prompt 内部是否拷贝见第 03 章)。

两个方法逻辑完全同构,没有互相委托——是复制粘贴式重复。这在本项目不是孤例,StreamingChatModel 的两个便捷方法同样如此(§2.6)。

2.5.4 第 47-62 行:getDefaultOptions() 之死

这是 2.0.0 最重要的一次 API 重命名之一:

/**
 * Gets the chat options for this model.
 * @return the chat options
 * @since 2.0.0
 */
default ChatOptions getOptions() {
    return ChatOptions.builder().build();
}

/**
 * @deprecated use {@link #getOptions()} instead.
 */
@Deprecated(forRemoval = true)
default ChatOptions getDefaultOptions() {
    return getOptions();
}

@Deprecated(forRemoval = true),注意没有 since 属性。 @Deprecated 的完整签名是 @Deprecated(since = "...", forRemoval = boolean)。这里只写了 forRemoval = true,说明作者连"从哪个版本开始废弃"都懒得标——是"尽快移除,别再用了"级别的信号。

官方迁移说明在 upgrade-notes.adoc:768-789:

768→==== Default Values Moved to Options Constructors
769→
770→Default values for options (like default model names, temperatures, etc.) have been moved
     from the `Model` implementations and `*Properties` configuration classes to the options
     constructors themselves.
771→Default configuration properties in `*Properties` classes have been removed as they are no
     longer needed and should be consistent with the options-level defaults.
772→
773→===== Impact
774→
775→- `ChatModel#getDefaultOptions()` is deprecated in favor of `ChatModel#getOptions()`.
776→- Default values are no longer duplicated in `*Properties` classes.
777→
778→===== Migration
779→
780→Use `getOptions()` instead of `getDefaultOptions()` when retrieving the default options for a model:
781→
782→[source,java]
783→----
784→// Before
785→ChatOptions options = chatModel.getDefaultOptions();
786→
787→// After
788→ChatOptions options = chatModel.getOptions();
789→----

第 770-771 行才是重点,比改名本身更重要:

默认值(默认模型名、temperature 等)已经从 Model 实现类和 *Properties 配置类,移动到了 options 的构造器里。*Properties 里的默认配置属性已被删除。

这意味着什么?在 1.x,默认值散落在两处:

  • OpenAiChatModel 的构造器里可能写 this.defaultOptions = OpenAiChatOptions.builder().model("gpt-4o").temperature(0.7).build();
  • OpenAiChatProperties 里可能写 private String model = "gpt-4o"; private Double temperature = 0.7;

两处必须保持同步,否则通过 starter 配置和通过代码构造会得到不同的默认值。这是典型的配置漂移(configuration drift)。

2.x 的解法:默认值只写在 OpenAiChatOptions 的无参构造器里,*Properties 直接引用 options 的默认值。单一真相源(single source of truth)。

这解释了为什么 ChatModel.getOptions() 的默认实现是:

return ChatOptions.builder().build();

——空 options。因为"默认值"现在由具体 options 类的构造器提供(OpenAiChatOptions.builder().build() 会填 gpt-4o、0.7 等),而不是由 ChatModel 接口提供。

2.5.5 第 64-66 行:那个著名的 UnsupportedOperationException

default Flux<ChatResponse> stream(Prompt prompt) {
    throw new UnsupportedOperationException("streaming is not supported");
}

这是本章最容易被写错的一个点,务必记清:

stream(Prompt) 抛异常的默认实现,在 ChatModel 里,不在 StreamingChatModel 里。

StreamingChatModel.stream(Prompt) 是纯抽象方法(§2.6 第 48-49 行)。是 ChatModel 继承它之后,重新提供了一个 default 实现来屏蔽掉这个抽象方法。

这条继承链非常精巧,也很容易误读:

StreamingModel<TReq,TResChunk>
    │  abstract: Flux<TResChunk> stream(TReq)
    ▼
StreamingChatModel extends StreamingModel<Prompt, ChatResponse>
    │  abstract: Flux<ChatResponse> stream(Prompt)     ← 强制实现
    ▼
ChatModel extends Model<Prompt,ChatResponse>, StreamingChatModel
    │  default: throw new UnsupportedOperationException("streaming is not supported")
    │           ← 用一个 default 把父接口的抽象方法"兜住"了

语义翻译成人话:

  • StreamingChatModel 说:"我要求你实现流式。"
  • ChatModel 说:"我允许你不实现流式——不实现的话就抛异常。"

所以:一个 ChatModel 的实现类可以不写 stream() 方法,编译能过,运行时抛 UnsupportedOperationException。

这是一个非常有争议的设计。批评意见是:它把一个"能力"降级成了"运行时才发现的错误"。辩护意见是:如果 ChatModel 不提供 default,那么所有只支持非流式的模型(比如某些 embedding-only 或老式模型)都必须写一个抛异常的 stream() 实现,代码里会充斥样板。

2.x 选择了后者。而更有意思的是:同一个版本里, Transcription 侧选择了不同的写法(§2.7)。

2.5.6 getObservationConvention() 不在 ChatModel 上

这是一个必须点明的"它不存在"。

全库 grep getObservationConvention,只有 1 处命中,而且是在测试类里:

spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChainTests.java:85

方法名是 getObservationConventionIsNullThenUseDefault——是一个测试方法名,不是 ChatModel 的 API。

所以:ChatModel 接口里没有任何与观测相关的方法。

观测体系在 2.x 是完全独立的一套:

  • org.springframework.ai.chat.observation.ChatModelObservationConvention(extends ObservationConvention<ChatModelObservationContext>,@since 1.0.0)
  • org.springframework.ai.model.observation.ModelObservationContext
  • org.springframework.ai.model.observation.ModelUsageMetricsGenerator
  • org.springframework.ai.model.observation.ErrorLoggingObservationHandler

约定(Convention)不再由模型接口暴露,而是在调用侧注入(第 17 章详述)。这个变化的方向是对的:观测是横切关注点,不该污染领域接口。


2.6 StreamingChatModel:@FunctionalInterface 与被覆盖的抽象方法

// spring-ai-model/src/main/java/org/springframework/ai/chat/model/StreamingChatModel.java
17→package org.springframework.ai.chat.model;
18→
19→import java.util.Arrays;
20→import java.util.Optional;
21→
22→import reactor.core.publisher.Flux;
23→
24→import org.springframework.ai.chat.messages.AssistantMessage;
25→import org.springframework.ai.chat.messages.Message;
26→import org.springframework.ai.chat.prompt.Prompt;
27→import org.springframework.ai.model.StreamingModel;
28→
29→@FunctionalInterface
30→public interface StreamingChatModel extends StreamingModel<Prompt, ChatResponse> {
31→
32→	default Flux<String> stream(String message) {
33→		Prompt prompt = new Prompt(message);
34→		return stream(prompt).map(response -> Optional.ofNullable(response.getResult())
35→			.map(Generation::getOutput)
36→			.map(AssistantMessage::getText)
37→			.orElse(""));
38→	}
39→
40→	default Flux<String> stream(Message... messages) {
41→		Prompt prompt = new Prompt(Arrays.asList(messages));
42→		return stream(prompt).map(response -> Optional.ofNullable(response.getResult())
43→			.map(Generation::getOutput)
44→			.map(AssistantMessage::getText)
45→			.orElse(""));
46→	}
47→
48→	@Override
49→	Flux<ChatResponse> stream(Prompt prompt);
50→
51→}

2.6.1 一个含 3 个方法的接口,凭什么是 @FunctionalInterface?

@FunctionalInterface 的编译期约束是:有且仅有一个抽象方法。default 方法不算。

这里 3 个方法里,2 个是 default(32、40 行),只有第 49 行的 stream(Prompt) 是抽象的。所以标注成立。

这意味着 StreamingChatModel 可以用 lambda 实现:

StreamingChatModel m = prompt -> Flux.just(new ChatResponse(List.of(new Generation(new AssistantMessage("hi")))));

这个特性在实际项目里很有用——写单元测试的 mock 时不需要 Mockito:

@Test
void advisorShouldSeeEmptyResponse() {
    StreamingChatModel stub = prompt -> Flux.empty();
    // ...
}

同样的 @FunctionalInterface 也出现在 ImageModel(:21)、ModerationModel(:30)、StreamingTextToSpeechModel(:28)。凡是"只有一个核心抽象方法"的接口,Spring AI 都会标上它。 这是一个明确的倾向:鼓励函数式、轻量实现。

2.6.2 第 34-37 行:Optional 链 vs 三元运算符的风格分裂

return stream(prompt).map(response -> Optional.ofNullable(response.getResult())
    .map(Generation::getOutput)
    .map(AssistantMessage::getText)
    .orElse(""));

对比 ChatModel.call(String)(:32-36)用的是:

Generation generation = call(prompt).getResult();
return (generation != null) ? generation.getOutput().getText() : "";

同一件事,两种写法——这是一个考古信号:两段代码大概出自不同作者、不同时期,且没被统一。

技术上 Optional 链更安全一点:AssistantMessage.getText() 在某些实现里可能返回 null,Optional.map 会自动短路,而三元运算符只判了 generation != null 这一层。但流式场景下 Optional 有性能隐患:每个 chunk 创建 3 个 Optional 对象,每秒几十个 chunk 就是上百次分配。JIT 的逃逸分析通常能优化掉,但高频流式路径上手写 null 检查仍是更保守的选择。

2.6.3 第 48-49 行:抽象方法,与它被 ChatModel "兜住"的命运

@Override
Flux<ChatResponse> stream(Prompt prompt);

如 §2.5.5 所述,这个抽象方法在 ChatModel 里被一个 default 覆盖掉了。

这在 Java 里是合法的:子接口可以用 default 方法覆盖父接口的抽象方法。效果是"实现 ChatModel 时不必实现 stream()"。

但要注意一个陷阱:

StreamingChatModel scm = (prompt) -> Flux.just(...);   // 必须实现,因为是抽象方法
ChatModel cm = new MyChatModel();                      // 可以不实现,编译能过

如果你拿到的是 StreamingChatModel 类型的引用,它保证有 stream();如果拿到的是 ChatModel 类型的引用,它不保证。这是类型系统里一个不容易察觉的"承诺降级"。


2.7 三种"不支持流式"的表达:2.0.1 里的一个一致性缺口

这是本章最有价值的发现之一。Spring AI 2.0.1 里,"这个模型不支持流式"这件事,用了三种不同的表达方式,且它们的行为差异很大。

方式一:ChatModel —— 同步 throw

// chat/model/ChatModel.java:64-66
default Flux<ChatResponse> stream(Prompt prompt) {
    throw new UnsupportedOperationException("streaming is not supported");
}

行为:调用 stream(prompt) 的那一刻就抛出,Flux 根本没被创建。

后果:Reactor 的错误处理操作符完全无效:

model.stream(prompt)
    .onErrorResume(e -> Flux.just(fallback));   // ❌ 抓不到!

因为异常发生在 stream(prompt) 这个方法调用的求值过程中,还没进入 Flux 的组装阶段。onErrorResume 只能捕获流内的错误信号。

正确写法只能是:

try {
    return model.stream(prompt);
} catch (UnsupportedOperationException e) {
    return Flux.just(fallback);
}

方式二:StreamingTranscriptionModel —— 异步 Flux.error

// spring-ai-model/src/main/java/org/springframework/ai/audio/transcription/StreamingTranscriptionModel.java:46
46→	default Flux<AudioTranscriptionResponse> stream(AudioTranscriptionPrompt transcriptionPrompt) {
47→		return Flux.error(new UnsupportedOperationException("Streaming transcription is not supported"));
48→	}

行为:stream(prompt) 正常返回一个 Flux,订阅时才发出 error 信号。

后果:Reactor 操作符有效:

model.stream(prompt)
    .onErrorResume(e -> Flux.just(fallback));   // ✅ 能抓到

这更符合 Reactive Streams 的契约:错误应该作为信号在流内传播,而不是作为异常从方法里抛出。

方式三:StreamingTextToSpeechModel —— 干脆不提供 default

// spring-ai-model/src/main/java/org/springframework/ai/audio/tts/StreamingTextToSpeechModel.java:28
28→@FunctionalInterface
29→public interface StreamingTextToSpeechModel extends StreamingModel<TextToSpeechPrompt, TextToSpeechResponse> {
30→
31→	default Flux<byte[]> stream(String text) { ... }
32→
37→	default Flux<byte[]> stream(String text, TextToSpeechOptions options) { ... }
38→
43→	@Override
44→	Flux<TextToSpeechResponse> stream(TextToSpeechPrompt prompt);
45→
46→}

而 TextToSpeechModel extends Model<...>, StreamingTextToSpeechModel(:28)也没有提供 default。

行为:实现 TextToSpeechModel 必须实现 stream(TextToSpeechPrompt),否则编译失败。

2.7.1 横向对比

接口"不支持"的表达何时报错onErrorResume 能否捕获编译期约束
ChatModelthrow new UnsupportedOperationException调用时(同步)❌可不实现
StreamingTranscriptionModelFlux.error(new UnsupportedOperationException(...))订阅时(异步)✅可不实现
StreamingTextToSpeechModel无 default编译期—必须实现
StreamingChatModel无 default编译期—必须实现

2.7.2 为什么会这样?一个合理的推测

StreamingTranscriptionModel 带 @since 2.0.1:

// audio/transcription/StreamingTranscriptionModel.java:33
33→	 * @since 2.0.1

也就是说,它是本次版本才新增的接口。新增接口时,作者必须考虑向后兼容:已经存在的 TranscriptionModel 实现类(包括用户自己写的)不能因为这次升级就编译失败。

官方在 upgrade-notes.adoc:243-255 里明确说明了这一点:

243→=== TranscriptionModel Now Extends StreamingTranscriptionModel
244→
245→`TranscriptionModel` now extends `StreamingTranscriptionModel`, making streaming
     transcription a first-class capability of every transcription model.
246→
247→`StreamingTranscriptionModel` provides a default `stream(AudioTranscriptionPrompt)`
     implementation that returns `Flux.error(new UnsupportedOperationException(...))`, so
     existing custom implementations of `TranscriptionModel` continue to compile without
     changes.
248→
249→==== Impact
250→
251→Calling `stream()` on a custom implementation that does not override it will return a
     `Flux` that emits an `UnsupportedOperationException`.
252→
253→===== Migration
254→
255→If your custom `TranscriptionModel` needs to support streaming transcription, override
     the `stream(AudioTranscriptionPrompt)` method.

第 247 行:"so existing custom implementations of TranscriptionModel continue to compile without changes"——明确是为了不破坏二进制兼容。

而它选择了 Flux.error 而不是 throw,说明作者知道 throw 的问题。那为什么 ChatModel 不改?

因为 ChatModel.stream() 的 throw 是 0.8.0 时代就有的写法,改掉它会改变所有现有 ChatModel 实现的运行时行为——那些原本抛异常的代码,会变成"返回一个 error 流"。这比编译期破坏更危险(静默的行为变化)。

所以现状是:历史包袱留在了 ChatModel 上,新接口采用了更正确的写法。

2.7.3 给使用者的实践建议

基于上表,写容错代码时必须分情况:

// 对 ChatModel:必须用 try-catch,onErrorResume 无效
Flux<ChatResponse> flux;
try {
    flux = chatModel.stream(prompt);
}
catch (UnsupportedOperationException e) {
    flux = Flux.just(fallbackResponse);
}

// 对 TranscriptionModel:可以用 onErrorResume
transcriptionModel.stream(audioPrompt)
    .onErrorResume(UnsupportedOperationException.class, e -> Flux.empty());

不要假设所有模型的"不支持流式"行为一致。 这是 2.0.1 的一个真实一致性缺口,也是面试/代码评审里能体现深度的好素材。


2.8 ChatResponse 与 Generation:一次二进制不兼容的"提升"

2.8.1 ChatResponse 完整结构

// spring-ai-model/src/main/java/org/springframework/ai/chat/model/ChatResponse.java:41
41→public class ChatResponse implements ModelResponse<Generation> {
42→
43→	private final ChatResponseMetadata chatResponseMetadata;
44→
45→	/**
46→	 * List of generated messages returned by the AI provider.
47→	 */
48→	private final List<Generation> generations;
49→
50→	/**
51→	 * Construct a new {@link ChatResponse} instance without metadata.
52→	 * @param generations the {@link List} of {@link Generation} returned by the AI
53→	 * provider.
54→	 */
55→	public ChatResponse(List<Generation> generations) {
56→		this(generations, new ChatResponseMetadata());
57→	}
58→
59→	/**
60→	 * Construct a new {@link ChatResponse} instance.
61→	 * @param generations the {@link List} of {@link Generation} returned by the AI
62→	 * provider.
63→	 * @param chatResponseMetadata {@link ChatResponseMetadata} containing information
64→	 * about the use of the AI provider's API.
65→	 */
66→	public ChatResponse(List<Generation> generations, ChatResponseMetadata chatResponseMetadata) {
67→		Assert.notNull(generations, "'generations' must not be null");
68→		this.chatResponseMetadata = Objects.requireNonNullElse(chatResponseMetadata, new ChatResponseMetadata());
69→		this.generations = List.copyOf(generations);
70→	}

只有两个字段,都是 private final。

第 67-69 行是三段防御,值得逐行看:

  1. Assert.notNull(generations, "'generations' must not be null") —— generations 必填,null 直接抛 IllegalArgumentException。
  2. Objects.requireNonNullElse(chatResponseMetadata, new ChatResponseMetadata()) —— metadata 可空,null 时兜底成空实例。不抛异常。
  3. List.copyOf(generations) —— 防御性不可变拷贝。调用方之后修改原 list 不会影响 ChatResponse。

注意第 2 点与第 1 点的不对称:generations 必填、metadata 可空兜底。理由很实际——generations 是业务核心(没有它这个响应毫无意义),metadata 是附加信息(很多模型/场景下确实没有)。

List.copyOf 还有一个副作用值得知道:它不允许 null 元素。

List<Generation> withNull = Arrays.asList(gen, null);
new ChatResponse(withNull);   // 抛 NullPointerException

这在某些边界场景下会咬人。

2.8.2 Generation 是顶层类,不是内部类(重要变更)

在 Spring AI 1.x,Generation 是 ChatResponse 的静态内部类,全限定名 org.springframework.ai.chat.model.ChatResponse.Generation。

在 2.0.1,它是独立的顶层类:

// spring-ai-model/src/main/java/org/springframework/ai/chat/model/Generation.java:30
30→public class Generation implements ModelResult<AssistantMessage> {
31→
32→	private final AssistantMessage assistantMessage;
33→
34→	private ChatGenerationMetadata chatGenerationMetadata;
35→
36→	public Generation(AssistantMessage assistantMessage) {
37→		this(assistantMessage, ChatGenerationMetadata.NULL);
38→	}
39→
40→	public Generation(AssistantMessage assistantMessage, ChatGenerationMetadata chatGenerationMetadata) {
41→		this.assistantMessage = assistantMessage;
42→		this.chatGenerationMetadata = chatGenerationMetadata != null ? chatGenerationMetadata
43→				: ChatGenerationMetadata.NULL;
44→	}
45→
46→	@Override
47→	public AssistantMessage getOutput() {
48→		return this.assistantMessage;
49→	}
50→
51→	@Override
52→	public ChatGenerationMetadata getMetadata() {
53→		return this.chatGenerationMetadata;
54→	}

ChatResponse.java 里没有任何 Generation 类定义,它引用的是同包的顶层类(所以 import 区里也没有 Generation 的 import——同包无需导入)。

这是一个二进制不兼容变更。 任何 1.x 代码里写 ChatResponse.Generation 的地方,在 2.x 都编译不过,必须改成 Generation。

为什么会做这个改动?三点推测,都有源码依据:

  1. Generation 被流式路径大量使用(§2.3 讨论过 Flux<ChatResponse> 里每个 chunk 含一个 Generation)。让一个高频使用的类型嵌套在另一个类里,写起来啰嗦。
  2. 其他 ModelResult 实现都是顶层类:Embedding(embedding/Embedding.java:29)、ImageGeneration(image/ImageGeneration.java:21)、moderation.Generation。统一形态。
  3. ChatResponse 的 Javadoc 里已经暴露了历史:第 76-82 行的注释写着 "It is a {@link List} of {@link List lists}",与实际返回类型 List<Generation> 不符——这是 1.x 早期 AiResponse 时代的遗留。

2.8.3 Generation 不是不可变类(一个真实的设计缝隙)

看第 32 行和第 34 行:

private final AssistantMessage assistantMessage;              // ← final
private ChatGenerationMetadata chatGenerationMetadata;        // ← 不是 final!

Generation 不是不可变的。chatGenerationMetadata 可以在构造后被修改(虽然没有任何 setter 方法——但同包的类可以直接访问 private 字段?不对,private 只有本类可访问)。

严格说,没有 setter,外部改不了。所以"不是 final"在实践中影响有限。但它破坏了"2.x 全面不可变"的叙事,也意味着:

  • 流式场景下,如果某个实现复用了 Generation 实例并改 metadata,消费端会看到"过去的值被改写";
  • 未来如果加了 setter(比如为了延迟填充 usage),风险立刻显现。

对比 DefaultUsage(§2.11.2)全部字段 final,ChatResponse 全部字段 final——Generation 是这一族里的例外。

2.8.4 ChatResponse 的四个查询方法

84→	@Override
85→	public List<Generation> getResults() {
86→		return this.generations;
87→	}
88→
89→	/**
90→	 * @return Returns the first {@link Generation} in the generations list.
91→	 */
92→	public @Nullable Generation getResult() {
93→		if (CollectionUtils.isEmpty(this.generations)) {
94→			return null;
95→		}
96→		return this.generations.get(0);
97→	}
98→
99→	/**
100→	 * @return Returns {@link ChatResponseMetadata} containing information about the use
101→	 * of the AI provider's API.
102→	 */
103→	@Override
104→	public ChatResponseMetadata getMetadata() {
105→		return this.chatResponseMetadata;
106→	}
107→
108→	/**
109→	 * Whether the model has requested the execution of a tool.
110→	 */
111→	public boolean hasToolCalls() {
112→		if (CollectionUtils.isEmpty(this.generations)) {
113→			return false;
114→		}
115→		return this.generations.stream().anyMatch(generation -> generation.getOutput().hasToolCalls());
116→	}
117→
118→	/**
119→	 * Whether the model has finished with any of the given finish reasons.
120→	 */
121→	public boolean hasFinishReasons(Set<String> finishReasons) {
122→		Assert.notNull(finishReasons, "finishReasons cannot be null");
123→		if (CollectionUtils.isEmpty(this.generations)) {
124→			return false;
125→		}
126→		return this.generations.stream().anyMatch(generation -> {
127→			var finishReason = (generation.getMetadata().getFinishReason() != null)
128→					? generation.getMetadata().getFinishReason() : "";
129→			return finishReasons.stream().anyMatch(fr -> fr.equalsIgnoreCase(finishReason));
130→		});
131→	}

hasToolCalls()(111-116)在 2.x 里地位陡增。 原因在 upgrade-notes.adoc:535-537:

535→==== Removed: Internal Tool Execution Loop in `ChatModel`
536→
537→In Spring AI 1.x, every `ChatModel` implementation contained its own internal
     tool-execution loop: calling `chatModel.call(prompt)` with tools would execute any tool
     calls in the response and re-call the model automatically, looping until the model
     produced a non-tool-call response.
538→
539→In 2.0, this internal loop has been removed from every `ChatModel` implementation (OpenAI,
     Anthropic, Ollama, DeepSeek, Bedrock, MiniMax, Mistral, Google GenAI). `ChatModel.call(prompt)`
     and `ChatModel.stream(prompt)` now return the model's raw response — tool calls are not
     executed automatically.

即:2.x 里 ChatModel.call() 返回的是原始响应,工具不会自动执行。工具循环被搬到了 advisor 层(ToolCallingAdvisor,第 08 章)。

那么谁来驱动这个循环?官方在 upgrade-notes.adoc:431 给出了答案:

431→`ToolExecutionEligibilityChecker` (`Function<ChatResponse, Boolean>`) can now be set on
     `ToolCallingAdvisor.Builder` to customize when the tool-call loop iterates. The default
     is `chatResponse -> chatResponse != null && chatResponse.hasToolCalls()`.

默认判定器就是 hasToolCalls()。 一个原本"内部使用"的方法,因为架构调整变成了"公开 API 的判定核心"。

hasFinishReasons(Set<String>)(121-131)有三处细节:

  1. Assert.notNull(finishReasons, "finishReasons cannot be null") —— 参数必填(对比 getResult() 的宽松)。
  2. 第 127-128 行:finishReason 为 null 时视为 ""。
  3. 第 129 行:equalsIgnoreCase —— 大小写不敏感。这很重要,因为各家供应商返回的 finish reason 大小写不一(OpenAI 的 "stop"、某些厂商的 "STOP"、"Stop")。

2.8.5 toString():一个必须给出安全警示的实现

133→	@Override
134→	public String toString() {
135→		return "ChatResponse [metadata=" + this.chatResponseMetadata + ", generations=" + this.generations + "]";
136→	}

全量打印,零脱敏。

this.generations 会调用每个 Generation.toString(),而后者(§2.8.2 第 74-77 行):

return "Generation[" + "assistantMessage=" + this.assistantMessage + ", chatGenerationMetadata="
        + this.chatGenerationMetadata + ']';

会打印完整的 AssistantMessage,也就是 LLM 生成的全部文本。

这意味着:log.info("resp: {}", chatResponse) 会把模型输出原样写进日志。

在以下场景这是真实风险:

  • 响应里包含用户 PII(比如"根据您提供的身份证号 1101...");
  • 响应里包含业务敏感数据(订单金额、内部代号);
  • 日志系统被非授权人员访问。

更讽刺的是,同一个代码库里 Embedding 做了脱敏(§2.16.5):

// embedding/Embedding.java:97-101
@Override
public String toString() {
    String message = this.embedding.length == 0 ? "<empty>" : "<has data>";
    return "Embedding{" + "embedding=" + message + ", index=" + this.index + '}';
}

向量打印成 <has data>(因为 1536 维浮点数会淹没日志),而聊天文本——同样可能很长、同样可能敏感——却全量打印。

这不是理论担忧。 如果你的生产日志里出现过大段 LLM 输出,就是从这里来的。建议:

// 不要这样
log.info("response: {}", chatResponse);

// 这样
log.info("response id={} model={} usage={} finishReason={}",
    resp.getMetadata().getId(),
    resp.getMetadata().getModel(),
    resp.getMetadata().getUsage(),
    resp.getResult() != null ? resp.getResult().getMetadata().getFinishReason() : null);

2.8.6 equals / hashCode 与 JSpecify 的一处"越界"

138→	@Override
139→	public boolean equals(@Nullable Object o) {
140→		if (this == o) {
141→			return true;
142→		}
143→		if (!(o instanceof ChatResponse that)) {
144→			return false;
145→		}
146→		return Objects.equals(this.chatResponseMetadata, that.chatResponseMetadata)
147→				&& Objects.equals(this.generations, that.generations);
148→	}
149→
150→	@Override
151→	public int hashCode() {
152→		return Objects.hash(this.chatResponseMetadata, this.generations);
153→	}

用的是 Java 16+ 的模式匹配 instanceof:o instanceof ChatResponse that —— 一行完成"判断 + 转型 + 绑定变量"。之后第 147 行直接访问 that.chatResponseMetadata(private 字段,但同 class 内可见,合法)。

第 139 行 equals(@Nullable Object o) 严格说是"越界标注"。 Object.equals(Object) 的语义本来就允许 null 参数(规定返回 false),标 @Nullable 在这里不改变行为,只是告诉静态分析工具"我接受 null"。Spring AI 全库都这么写(Generation.java:57、ChatResponseMetadata.java:102、EmbeddingResponse.java:82…)。

一致性很好:equals 覆盖两个字段,hashCode 也用这两个,toString 也打印这两个。没有遗漏。

2.8.7 ChatResponse.Builder:手工合并的痕迹

155→	public static final class Builder {
156→
157→		private @Nullable List<Generation> generations;
158→
159→		private ChatResponseMetadata.Builder chatResponseMetadataBuilder;
160→
161→		private Builder() {
162→			this.chatResponseMetadataBuilder = ChatResponseMetadata.builder();
163→		}
164→
165→		public Builder from(ChatResponse other) {
166→			this.generations = other.generations;
167→			return this.metadata(other.chatResponseMetadata);
168→		}
169→
170→		public Builder metadata(String key, Object value) {
171→			this.chatResponseMetadataBuilder.keyValue(key, value);
172→			return this;
173→		}
174→
175→		public Builder metadata(ChatResponseMetadata other) {
176→			this.chatResponseMetadataBuilder.model(other.getModel());
177→			this.chatResponseMetadataBuilder.id(other.getId());
178→			this.chatResponseMetadataBuilder.rateLimit(other.getRateLimit());
179→			this.chatResponseMetadataBuilder.usage(other.getUsage());
180→			this.chatResponseMetadataBuilder.promptMetadata(other.getPromptMetadata());
181→			Set<Map.Entry<String, Object>> entries = other.entrySet();
182→			for (Map.Entry<String, Object> entry : entries) {
183→				this.chatResponseMetadataBuilder.keyValue(entry.getKey(), entry.getValue());
184→			}
185→			return this;
186→		}
187→
188→		public Builder generations(List<Generation> generations) {
189→			this.generations = generations;
190→			return this;
191→
192→		}
193→
194→		public ChatResponse build() {
195→			Assert.notNull(this.generations, "'generations' must not be null");
196→			return new ChatResponse(this.generations, this.chatResponseMetadataBuilder.build());
197→		}
198→
199→	}

第 175-186 行的 metadata(ChatResponseMetadata other) 最值得看:逐个字段手工复制 5 个属性,再遍历 entrySet() 复制自定义 kv。

为什么要手工?因为不能用反射合并了——ModelOptionsUtils.merge() / copyToTarget() / BeanWrapper 反射在 2.x 已被彻底删除(第 03 章会给完整删除清单与官方说明)。这是**从"魔法"退回"显式"**的取舍:反射合并写起来省事但行为不可预测(哪些字段会被覆盖?null 会不会覆盖非 null?);手工复制啰嗦但每一行明明白白。这个取向在 options 体系(第 03 章)、advisor 体系(第 05 章)、工具调用体系(第 08 章)都能看到——2.x 的整体风格就是"把魔法换成显式"。

第 191 行那个多余的空行(return this; 之后、} 之前)是源码小瑕疵,原样保留以证明这是逐字摘录。


2.9 元数据基类:AbstractResponseMetadata / ResponseMetadata / MutableResponseMetadata

三个类,两种设计取向。

2.9.1 AbstractResponseMetadata:受保护的 map

// spring-ai-model/src/main/java/org/springframework/ai/model/AbstractResponseMetadata.java:26
26→public class AbstractResponseMetadata {
27→
28→	/**
29→	 * AI metadata string format.
30→	 */
31→	protected static final String AI_METADATA_STRING = "{ id: %1$s, usage: %2$s, rateLimit: %3$s }";
32→
33→	/**
34→	 * Metadata map.
35→	 */
36→	protected final Map<String, Object> map = new ConcurrentHashMap<>();
37→
38→	/**
39→	 * Create a new {@link AbstractResponseMetadata} instance.
40→	 */
41→	public AbstractResponseMetadata() {
42→	}
...
50→	public <T> @Nullable T get(String key) {
51→		return (T) this.map.get(key);
52→	}
...
61→	public <T> T getRequired(Object key) {
62→		T object = (T) this.map.get(key);
63→		if (object == null) {
64→			throw new IllegalArgumentException("Context does not have an entry for key [" + key + "]");
65→		}
66→		return object;
67→	}
...
89→	public Set<Map.Entry<String, Object>> entrySet() {
90→		return Collections.unmodifiableMap(this.map).entrySet();
91→	}
92→

三点:

  1. map 是 ConcurrentHashMap(第 36 行),不是 HashMap。理由:流式响应可能在多线程环境下被填充/消费。
  2. 第 26 行是裸 class,没有 implements ResponseMetadata——该接口由子类实现(ChatResponseMetadata extends AbstractResponseMetadata implements ResponseMetadata)。
  3. 第 61-67 行的 getRequired 抛 IllegalArgumentException,消息是 "Context does not have an entry for key [...]"——用了 "Context" 一词,暗示这段代码最初是从"上下文"类里复制过来的。

AI_METADATA_STRING(第 31 行)用位置索引格式串 %1$s / %2$s / %3$s。Java 的 String.formatted()(Java 15+)同时支持 %s(按序)与 %1$s(显式指定序号),这里用后者的好处是子类可只传部分参数或调整顺序——虽然实际三者都传了。

2.9.2 ResponseMetadata 接口:一个 1.11.0 还在加方法的接口

// spring-ai-model/src/main/java/org/springframework/ai/model/ResponseMetadata.java:31
31→public interface ResponseMetadata {
32→
39→	@Nullable <T> T get(String key);
...
48→	<T> T getRequired(Object key);
...
55→	boolean containsKey(Object key);
...
64→	<T> T getOrDefault(Object key, T defaultObject);
65→
66→	/**
67→	 * Returns an element or default if not present.
68→	 * @param key key
69→	 * @param defaultObjectSupplier supplier for default object to return
70→	 * @param <T> value type
71→	 * @return object or default if not present
72→	 * @since 1.11.0
73→	 */
74→	default <T> T getOrDefault(String key, Supplier<T> defaultObjectSupplier) {
75→		T value = get(key);
76→		return value != null ? value : defaultObjectSupplier.get();
77→	}
78→
79→	Set<Map.Entry<String, Object>> entrySet();
80→
81→	Set<String> keySet();
82→
87→	boolean isEmpty();
88→
89→}

第 72 行的 @since 1.11.0 是个有意思的信号:这个接口在 1.11 小版本里还在加方法。加的是 default 方法,所以不破坏兼容。

Supplier 版本的 getOrDefault 存在的意义是惰性求值:

// 这样即使 key 存在,默认值也会被计算
metadata.getOrDefault("foo", expensiveComputation());

// 这样只有 key 不存在时才计算
metadata.getOrDefault("foo", () -> expensiveComputation());

AbstractResponseMetadata 继承了这个 default 实现,而它的 get(key) 是 map.get(key)——所以惰性生效。

2.9.3 MutableResponseMetadata:可变的那个

MutableResponseMetadata(spring-ai-model/.../model/MutableResponseMetadata.java:27,140 行)在同一个 ConcurrentHashMap 之上增加了 put()(38 行)、clear()(131 行)、computeIfAbsent()(124 行)与 getRawMap()(135 行)。

与 AbstractResponseMetadata 的区别:

AbstractResponseMetadataMutableResponseMetadata
implements ResponseMetadata❌(子类实现)✅
写入方法❌(只有继承的 map 可被子类写)✅ put() / clear() / computeIfAbsent()
暴露原始 map❌✅ getRawMap()
使用者ChatResponseMetadata、EmbeddingResponseMetadataImageResponseMetadata

为什么 ImageResponseMetadata 需要可变? 因为它有 setUsage(Usage)(image/ImageResponseMetadata.java:73)——Image 响应通常是"先返回图片,之后回填 usage"。

另有一处风格瑕疵:第 50 行写作 @Nullable public <T> T get(String key),@Nullable 放在 public 之前;而本项目惯例是放在返回类型前:public <T> @Nullable T get(...)(见 AbstractResponseMetadata.java:50、ModelResponse.java:40)。编译等价,但不一致。


2.10 ChatResponseMetadata:Null Object 模式的教科书落地

2.10.1 先看"它不存在"的两个类

DefaultChatResponseMetadata 不存在。 EmptyChatResponseMetadata 不存在。

全库 find -name 均为 0 命中。如果你在 1.x 时代用过它们,或者在某篇博客上看到过,请在 2.0.1 里停止搜索——ChatResponseMetadata 自己就是具体类。

2.10.2 完整源码

// spring-ai-model/src/main/java/org/springframework/ai/chat/metadata/ChatResponseMetadata.java:39
39→public class ChatResponseMetadata extends AbstractResponseMetadata implements ResponseMetadata {
40→
41→	private static final Log logger = LogFactory.getLog(ChatResponseMetadata.class);
42→
43→	private String id = ""; // Set to blank to preserve backward compat with previous
44→
45→	// interface default methods
46→
47→	private String model = "";
48→
49→	private RateLimit rateLimit = new EmptyRateLimit();
50→
51→	private Usage usage = new EmptyUsage();
52→
53→	private PromptMetadata promptMetadata = PromptMetadata.empty();
54→
55→	public static Builder builder() {
56→		return new Builder();
57→	}

五个字段,全部有非空默认值:

字段类型默认值
idString""
modelString""
rateLimitRateLimitnew EmptyRateLimit()
usageUsagenew EmptyUsage()
promptMetadataPromptMetadataPromptMetadata.empty()

这就是 Null Object 模式:不用 null 表示"没有",而是用一个行为合理的空对象。

收益是调用方永远不需要判空:

// 不需要这样
if (resp.getMetadata().getUsage() != null) {
    total += resp.getMetadata().getUsage().getTotalTokens();
}

// 直接这样
total += resp.getMetadata().getUsage().getTotalTokens();   // 空 usage 返回 0

第 43-45 行有一条被意外换行的注释,原样是:

private String id = ""; // Set to blank to preserve backward compat with previous

// interface default methods

这本来应该是一行:// Set to blank to preserve backward compat with previous interface default methods。被格式化工具(或手工编辑)切断成两部分,中间还夹了个空行。含义是清楚的:id 默认为空字符串而不是 null,是为了兼容以前接口 default 方法的行为。

第 41 行用的是 org.apache.commons.logging(Apache Commons Logging),不是 SLF4J。 这是 Spring Framework 内核的传统(Spring 自己的代码也用 commons-logging 作为日志门面)。在这个项目里,凡是 spring-ai-* 主源码基本都用 commons-logging。

2.10.3 Builder.keyValue() 的静默忽略

137→		public Builder keyValue(String key, @Nullable Object value) {
138→			Assert.notNull(key, "Key must not be null"); // Defensive check
139→			if (value != null) {
140→				this.chatResponseMetadata.map.put(key, value);
141→			}
142→			else {
143→				if (logger.isDebugEnabled()) {
144→					logger.debug("Ignore null value for key [" + key + "]");
145→				}
146→			}
147→			return this;
148→		}

value 为 null 时静默忽略(只打 debug 日志)。这是一个容易被忽略的行为:

ChatResponseMetadata.builder()
    .keyValue("traceId", maybeNull)   // 如果 maybeNull 是 null,这个 key 根本不存在
    .build();
// 后面 get("traceId") 返回 null,而不是"存在但值为 null"

如果你依赖"key 存在但值为 null"来做某种标记,这里会失败。

第 138 行的 // Defensive check 注释说明作者认为这个 Assert 是"多余的保险"(因为按理 key 不该为 null),但保留了。

2.10.4 toString() 与一个日志可读性缺陷

119→	@Override
120→	public String toString() {
121→		return AI_METADATA_STRING.formatted(getId(), getUsage(), getRateLimit());
122→	}

AI_METADATA_STRING = "{ id: %1$s, usage: %2$s, rateLimit: %3$s }"(§2.9.1)。

问题在这里:EmptyRateLimit 和 EmptyUsage 都没有 toString() 方法(§2.12.2、§2.11.3)。

所以当你拿到一个默认构造的 ChatResponseMetadata 并打印它,得到的是:

{ id: , usage: org.springframework.ai.chat.metadata.EmptyUsage@6d06d69c, rateLimit: org.springframework.ai.chat.metadata.EmptyRateLimit@7852e922 }

DefaultUsage 有 toString()(打印可读的 token 数),EmptyUsage 没有。 这是一个明确的日志可读性缺陷——最常见的"空 metadata"情形,恰恰是打印出来最难看的那个。

如果你在日志里见过这种 @hash 串,就是这里来的。


2.11 Usage:2.0.0 为 prompt caching 新增的两个方法

2.11.1 接口定义

// spring-ai-model/src/main/java/org/springframework/ai/chat/metadata/Usage.java
29→public interface Usage {
30→
37→	Integer getPromptTokens();
...
46→	Integer getCompletionTokens();
...
56→	default Integer getTotalTokens() {
57→		Integer promptTokens = getPromptTokens();
58→		promptTokens = promptTokens != null ? promptTokens : 0;
59→		Integer completionTokens = getCompletionTokens();
60→		completionTokens = completionTokens != null ? completionTokens : 0;
61→		return promptTokens + completionTokens;
62→	}
63→
68→	@Nullable Object getNativeUsage();
69→
70→	/**
71→	 * Returns the number of input tokens read from the prompt cache, if the provider
72→	 * supports prompt caching. Cached tokens are tokens that were previously processed
73→	 * and stored by the provider, reducing cost and latency for repeated prompt prefixes.
74→	 * @return the number of cached input tokens read, or {@code null} if the provider
75→	 * does not support prompt caching or no cache hit occurred.
76→	 * @since 2.0.0
77→	 */
78→	default @Nullable Long getCacheReadInputTokens() {
79→		return null;
80→	}
81→
82→	/**
83→	 * Returns the number of input tokens written to the prompt cache, if the provider
84→	 * supports prompt caching. Cache writes occur when new prompt content is cached for
85→	 * the first time.
86→	 * @return the number of input tokens written to cache, or {@code null} if the
87→	 * provider does not support prompt caching or no cache write occurred.
88→	 * @since 2.0.0
89→	 */
90→	default @Nullable Long getCacheWriteInputTokens() {
91→		return null;
92→	}
93→
94→}

2.11.2 三个必须注意的技术点

(1)token 计数用 Integer,cache 计数用 Long。

这个不一致很扎眼。getPromptTokens() 返回 Integer,getCacheReadInputTokens() 返回 Long。

历史原因在 upgrade-notes.adoc:2935-2946:

2935→=== Changes to Usage Interface and DefaultUsage Implementation
2936→
2937→The `Usage` interface and its default implementation `DefaultUsage` have undergone the following changes:
2938→
2939→1. Method Rename:
2940→* `getGenerationTokens()` is now `getCompletionTokens()`
2941→
2942→2. Type Changes:
2943→* All token count fields in `DefaultUsage` changed from `Long` to `Integer`:
2944→** `promptTokens`
2945→** `completionTokens` (formerly `generationTokens`)
2946→** `totalTokens`

即:1.x 里 token 数是 Long,某次改成了 Integer。而 2.0.0 新增的 cache 字段用了 Long——新代码没有遵循旧代码的收敛结果。

实践影响:DefaultUsage.getTotalTokens() 返回 int(§2.11.3),如果你要累加跨多次调用的 token,用 int 可能溢出吗?单次调用 token 数不会超过 Integer.MAX_VALUE(21 亿),累加的话要看你的总量。

(2)getTotalTokens() 的 null 兜底是"各自兜底再相加"。

第 56-62 行:promptTokens 为 null 时算 0,completionTokens 为 null 时也算 0,然后相加。

注意:nativeUsage 不参与计算。 有些供应商只在 nativeUsage 里给完整的 usage 对象(比如 Anthropic 的 cache_creation_input_tokens),标准字段是 0——这种情况下 getTotalTokens() 会返回错误的值。要拿准确数据得自己解析 nativeUsage。

(3)2.0.0 新增的两个 cache 方法,是 prompt caching 的标准化入口。

getCacheReadInputTokens() / getCacheWriteInputTokens() 对应 Anthropic 的 cache_read_input_tokens / cache_creation_input_tokens 与 OpenAI 的 prompt_tokens_details.cached_tokens。返回 @Nullable Long 且 default 返回 null,说明大多数供应商不支持,返回 null 是正常情况。

这对成本计算很关键:cache 写入通常比普通 input 贵(Anthropic 为 1.25×),cache 读取则便宜得多(0.1×)。要算准成本必须拿到这两个数——2.0.0 把它们提到接口层,各家实现只需 override。

2.11.3 DefaultUsage:一个真不可变类

// spring-ai-model/src/main/java/org/springframework/ai/chat/metadata/DefaultUsage.java:34
34→@JsonPropertyOrder({ "promptTokens", "completionTokens", "totalTokens", "cacheReadInputTokens", "cacheWriteInputTokens",
35→		"nativeUsage" })
36→public class DefaultUsage implements Usage {
37→
38→	private final Integer promptTokens;
39→
40→	private final Integer completionTokens;
41→
42→	private final int totalTokens;
43→
44→	private final @Nullable Object nativeUsage;
45→
46→	private final @Nullable Long cacheReadInputTokens;
47→
48→	private final @Nullable Long cacheWriteInputTokens;

六个字段全部 final —— 对比 Generation(§2.8.3)的非 final metadata,DefaultUsage 是真不可变。

第 42 行 totalTokens 是 int 基本类型,其余五个是包装类型。为什么?看构造器:

83→	public DefaultUsage(@Nullable Integer promptTokens, @Nullable Integer completionTokens,
84→			@Nullable Integer totalTokens, @Nullable Object nativeUsage, @Nullable Long cacheReadInputTokens,
85→			@Nullable Long cacheWriteInputTokens) {
86→		this.promptTokens = promptTokens != null ? promptTokens : 0;
87→		this.completionTokens = completionTokens != null ? completionTokens : 0;
88→		this.totalTokens = totalTokens != null ? totalTokens
89→				: calculateTotalTokens(this.promptTokens, this.completionTokens);
90→		this.nativeUsage = nativeUsage;
91→		this.cacheReadInputTokens = cacheReadInputTokens;
92→		this.cacheWriteInputTokens = cacheWriteInputTokens;
93→	}

第 88-89 行保证 totalTokens 在构造期就被归一化成非 null(要么用传入值,要么用 promptTokens + completionTokens 算出来)。既然构造后必然非 null,字段就可以安全地用基本类型 int。

这是一个值得复刻的范式:构造期归一化 → 字段可用 primitive → 消除后续所有 null 检查。

注意第 89 行调用 calculateTotalTokens(this.promptTokens, this.completionTokens) 时,传的是已经归一化过的 this.promptTokens(第 86 行已赋值)。顺序很重要:如果先算 total 再归一化 prompt,就可能 NPE。

JSON 序列化(第 34-35 行的 @JsonPropertyOrder):顺序是 promptTokens, completionTokens, totalTokens, cacheReadInputTokens, cacheWriteInputTokens, nativeUsage。三个可空字段都标了 @JsonInclude(NON_NULL)(159、166、173 行),null 时不出现在 JSON 里。

toString() 有条件的字段(210-224 行):

210→	@Override
211→	public String toString() {
212→		StringBuilder sb = new StringBuilder("DefaultUsage{");
213→		sb.append("promptTokens=").append(this.promptTokens);
214→		sb.append(", completionTokens=").append(this.completionTokens);
215→		sb.append(", totalTokens=").append(this.totalTokens);
216→		if (this.cacheReadInputTokens != null) {
217→			sb.append(", cacheReadInputTokens=").append(this.cacheReadInputTokens);
218→		}
219→		if (this.cacheWriteInputTokens != null) {
220→			sb.append(", cacheWriteInputTokens=").append(this.cacheWriteInputTokens);
221→		}
222→		sb.append('}');
223→		return sb.toString();
224→	}

cache 字段为 null 时不打印。这是本章唯一一处有条件的 toString(虽然目的是可读性而非安全)。

一处 Javadoc 与实现脱节(第 121-122 行):

* Create a new DefaultUsage with promptTokens, completionTokens, and totalTokens.
* This constructor is used for JSON deserialization and handles both the new format
* with completionTokens and the legacy format with generationTokens.

但 @JsonCreator fromJson(129-137 行)根本没有 generationTokens 参数。注释描述的是 1.x 的行为(那时叫 generationTokens),代码已经是 2.x 的形态。

这与 §2.16.3 里 EmbeddingResponse.toString() 打印 "EmbeddingResult{" 是同一类现象:改名了,字符串没跟着改。

2.11.4 EmptyUsage:只覆盖 3 个方法

// spring-ai-model/src/main/java/org/springframework/ai/chat/metadata/EmptyUsage.java:28
28→public class EmptyUsage implements Usage {
29→
30→	@Override
31→	public Integer getPromptTokens() {
32→		return 0;
33→	}
34→
35→	@Override
36→	public Integer getCompletionTokens() {
37→		return 0;
38→	}
39→
40→	@Override
41→	public Object getNativeUsage() {
42→		return Map.of();
43→	}
44→
45→}
  • getTotalTokens() 不覆盖 ⇒ 走接口 default ⇒ 0 + 0 = 0
  • getCacheReadInputTokens() / getCacheWriteInputTokens() 不覆盖 ⇒ 走 default ⇒ null
  • getNativeUsage() 返回 Map.of()(空 map,不是 null)—— 但注意方法签名没有标 @Nullable,而接口里标了(§2.11.1 第 68 行)。不影响运行,但静态分析可能抱怨。

Javadoc 第 22 行有个语法错误:"A EmptyUsage"(应为 "An")。


2.12 RateLimit:从 SimpleDateFormat 到 Duration 的现代化

2.12.1 六方法两组结构

// spring-ai-model/src/main/java/org/springframework/ai/chat/metadata/RateLimit.java:28
28→public interface RateLimit {
29→
37→	Long getRequestsLimit();
...
46→	Long getRequestsRemaining();
...
55→	Duration getRequestsReset();
...
64→	Long getTokensLimit();
...
73→	Long getTokensRemaining();
...
82→	Duration getTokensReset();
83→
84→}
维度limit(上限)remaining(剩余)reset(重置)
requestsgetRequestsLimit()getRequestsRemaining()getRequestsReset()
tokensgetTokensLimit()getTokensRemaining()getTokensReset()

2.12.2 Duration 而不是 Instant:一个语义选择

第 19 行只 import 了 java.time.Duration。

getRequestsReset() 返回 Duration,语义是"距重置还有多久"(相对时长),而不是"重置发生在什么时刻"(绝对时刻)。

语义是"距重置还有多久"(相对时长),而非"重置发生在什么时刻"(绝对时刻)。好处是不需要处理时区——HTTP 响应头 x-ratelimit-reset-requests 通常就是 "30s" / "6m0s" 这类相对值(OpenAI 即此格式),直接解析成 Duration 最自然。

坏处是这个值是"调用时的快照",过一会儿就过期。缓存起来再读就是错的。

2.12.3 RateLimit 没有 toString() —— 一个已修复的历史问题

RateLimit.java 全文 85 行,最后的方法是 getTokensReset()(第 82 行),没有 toString()。

这不是"忘了写",而是删掉了:1.x 的实现里有基于 SimpleDateFormat 的 toString() 用于格式化 reset 时间。证据是全库 grep SimpleDateFormat 只剩 2 处命中,且都不在 chat/metadata 或 model 包——分别是 vector-stores/spring-ai-s3-vector-store/.../S3VectorFilterSearchExpressionConverter.java:20,39,42 与 vector-stores/spring-ai-gemfire-store/.../GemFireAiSearchFilterExpressionConverter.java:19,39,42,都是 new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"),用于向量库过滤表达式的日期转换(第 12 章会看到)。

SimpleDateFormat 是线程不安全的——作为静态字段在多线程下格式化日期会产生错误输出甚至抛异常。2.x 移除它,既是 API 现代化,也是修掉一个潜在的并发 bug。

2.12.4 EmptyRateLimit:全部返回零

// spring-ai-model/src/main/java/org/springframework/ai/chat/metadata/EmptyRateLimit.java:27
27→public class EmptyRateLimit implements RateLimit {
28→
29→	@Override
30→	public Long getRequestsLimit() {
31→		return 0L;
32→	}
33→
34→	@Override
35→	public Long getRequestsRemaining() {
36→		return 0L;
37→	}
38→
39→	@Override
40→	public Duration getRequestsReset() {
41→		return Duration.ZERO;
42→	}
43→
44→	@Override
45→	public Long getTokensLimit() {
46→		return 0L;
47→	}
48→
49→	@Override
50→	public Long getTokensRemaining() {
51→		return 0L;
52→	}
53→
54→	@Override
55→	public Duration getTokensReset() {
56→		return Duration.ZERO;
57→	}
58→
59→}

它是 ChatResponseMetadata 第 49 行的默认值。

注意一个语义陷阱:getRequestsRemaining() 返回 0。这既可能表示"配额用完了",也可能表示"供应商根本没返回限流信息"。无法区分。

所以基于 RateLimit 做限流保护是危险的:if (rl.getRequestsRemaining() <= 10) { throw ... } 会因空 rateLimit 而永远触发。

正确做法是先排除 Empty:

RateLimit rl = resp.getMetadata().getRateLimit();
if (rl.getRequestsLimit() > 0 && rl.getRequestsRemaining() <= rl.getRequestsLimit() * 0.1) { ... }
//  ↑ EmptyRateLimit 的 limit 也是 0,而真实供应商的 limit 不会是 0,以此区分

2.12.5 PromptMetadata:一个 @FunctionalInterface 的优雅设计

// spring-ai-model/src/main/java/org/springframework/ai/chat/metadata/PromptMetadata.java:32
32→@FunctionalInterface
33→public interface PromptMetadata extends Iterable<PromptMetadata.PromptFilterMetadata> {
34→
35→	/**
36→	 * Factory method used to create empty {@link PromptMetadata} when the information is
37→	 * not supplied by the AI provider.
38→	 * @return empty {@link PromptMetadata}.
39→	 */
40→	static PromptMetadata empty() {
41→		return of();
42→	}
43→
52→	static PromptMetadata of(PromptFilterMetadata... array) {
53→		return of(Arrays.asList(array));
54→	}
...
64→	static PromptMetadata of(Iterable<PromptFilterMetadata> iterable) {
65→		Assert.notNull(iterable, "An Iterable of PromptFilterMetadata must not be null");
66→		return iterable::iterator;
67→	}
68→
69→	/**
70→	 * Returns an {@link Optional} {@link PromptFilterMetadata} at the given index.
71→	 * @param promptIndex index of the {@link PromptFilterMetadata} contained in this
72→	 * {@link PromptMetadata}.
73→	 * @return {@link Optional} {@link PromptFilterMetadata} at the given index.
74→	 * @throws IllegalArgumentException if the prompt index is less than 0.
75→	 */
76→	default Optional<PromptFilterMetadata> findByPromptIndex(int promptIndex) {
77→
78→		Assert.isTrue(promptIndex > -1, "Prompt index [%d] must be greater than equal to 0".formatted(promptIndex));
79→
80→		return StreamSupport.stream(this.spliterator(), false)
81→			.filter(promptFilterMetadata -> promptFilterMetadata.getPromptIndex() == promptIndex)
82→			.findFirst();
83→	}

第 66 行 return iterable::iterator; 是全章最漂亮的一行代码。

PromptMetadata extends Iterable<PromptFilterMetadata>,所以它的唯一抽象方法是从 Iterable 继承来的 iterator()。而一个 Iterable<X> 天然就有 iterator() 方法——所以用方法引用 iterable::iterator 就直接实现了这个接口,零额外类、零匿名内部类。

PromptMetadata pm = someList::iterator;   // 一行搞定

工厂方法链:empty() → of(varargs) → of(Iterable),最终都汇聚到第 64 行。

findByPromptIndex(76-83)用 StreamSupport.stream(this.spliterator(), false) —— 注意第二个参数 false 表示非并行。这是刻意的:metadata 通常只有少量元素,并行化开销大于收益。

第 78 行的错误消息 "Prompt index [%d] must be greater than equal to 0" 语法不地道(应为 "greater than or equal to")。

2.12.6 嵌套的 PromptFilterMetadata:一处类型不安全

89→	interface PromptFilterMetadata {
...
101→		static PromptFilterMetadata from(int promptIndex, Object contentFilterMetadata) {
102→
103→			return new PromptFilterMetadata() {
104→
105→				@Override
106→				public int getPromptIndex() {
107→					return promptIndex;
108→				}
109→
110→				@Override
111→				@SuppressWarnings("unchecked")
112→				public <T> T getContentFilterMetadata() {
113→					return (T) contentFilterMetadata;
114→				}
115→			};
116→		}
117→
123→		int getPromptIndex();
...
133→		<T> T getContentFilterMetadata();
134→
135→	}

第 111-114 行:<T> T getContentFilterMetadata() 做 (T) contentFilterMetadata 强转,带 @SuppressWarnings("unchecked")。这是彻底的调用方约定——传入什么类型就得按什么类型取出,写错了在使用时才抛 ClassCastException:

PromptFilterMetadata m = PromptFilterMetadata.from(0, "some string");
Integer wrong = m.getContentFilterMetadata();   // 编译过,运行时炸

这类 API 在多供应商场景下有理由:各家内容过滤元数据结构完全不同(Azure 有自己的 ContentFilterResults,OpenAI 有自己的结构),无法用统一类型表达。使用者必须自己保证类型正确。

Javadoc 第 120 行有拼写错误:"fo the prompt filter metadata"(应为 "of")。


2.13 ChatGenerationMetadata:结果级元数据的形态

// spring-ai-model/src/main/java/org/springframework/ai/chat/metadata/ChatGenerationMetadata.java:35
35→public interface ChatGenerationMetadata extends ResultMetadata {
36→
37→	ChatGenerationMetadata NULL = builder().build();
38→
43→	@Nullable String getFinishReason();
44→
45→	Set<String> getContentFilters();
46→
47→	<T> @Nullable T get(String key);
48→
49→	boolean containsKey(String key);
50→
51→	<T> T getOrDefault(String key, T defaultObject);
52→
53→	Set<Entry<String, Object>> entrySet();
54→
55→	Set<String> keySet();
56→
57→	boolean isEmpty();
58→
59→	static Builder builder() {
60→		return new DefaultChatGenerationMetadataBuilder();
61→	}

第 37 行 ChatGenerationMetadata NULL = builder().build(); —— 这是 Generation 构造器(§2.8.2 第 42-43 行)兜底用的常量。

注意这里的命名:metadata 用 NULL(ChatGenerationMetadata.NULL),而 usage 用 EmptyUsage、rateLimit 用 EmptyRateLimit。命名不统一(NULL vs Empty 前缀),但语义相同:Null Object。

DefaultChatGenerationMetadata 的构造器是 package-private(无访问修饰符),只能通过 Builder 创建。这是一个刻意的封装:强制走 Builder。

它的 toString() 是本章唯一"信息收敛"的范例:

// DefaultChatGenerationMetadata.java:120-123
return "DefaultChatGenerationMetadata[finishReason='%s', filters=%d, metadata=%d]"
        .formatted(...);

只打印 finishReason 的值,filters 和 metadata 只打印数量(%d)。

对比一下本章见过的三种 toString() 策略:

类toString() 策略评价
ChatResponse全量打印(含 LLM 全部文本)⚠️ 有泄露风险
Embedding用 <has data> 替代向量✅ 合理
DefaultChatGenerationMetadata只打印数量✅ 合理
EmptyUsage / EmptyRateLimit继承 Object(打印 @hash)❌ 缺陷
DefaultUsage全量打印(但都是数字,无害)✅ 合理

同一个代码库里五种策略并存。 这不是"设计",而是"演进"——不同作者、不同时期、不同敏感度下的产物。读源码时要意识到这一点,不要假定有统一约定。


2.14 Embedding 体系:包路径纠正与两个"平行接口"

2.14.1 先纠正一个常见错误

org.springframework.ai.model.embedding.EmbeddingModel 不存在。

正确路径是:

// spring-ai-model/src/main/java/org/springframework/ai/embedding/EmbeddingModel.java:17
17→package org.springframework.ai.embedding;

没有 model 这一层。 spring-ai-model 模块(注意:模块名叫 spring-ai-model,但包根是 org.springframework.ai)的顶层包是 aot / audio / chat / converter / embedding / image / model / moderation / support / tool / util——embedding 与 model 是平级的。

这个误记很常见,因为 Model 接口本身在 org.springframework.ai.model,人们会自然地以为 EmbeddingModel 也在其下。实际不是。 写 import org.springframework.ai.model.embedding.EmbeddingModel; 会编译失败。

2.14.2 EmbeddingModel 的 8 个方法

// spring-ai-model/src/main/java/org/springframework/ai/embedding/EmbeddingModel.java:39
39→public interface EmbeddingModel extends Model<EmbeddingRequest, EmbeddingResponse> {
40→
41→	@Override
42→	EmbeddingResponse call(EmbeddingRequest request);
43→
44→	/**
45→	 * Embeds the given text into a vector.
46→	 * @param text the text to embed.
47→	 * @return the embedded vector.
48→	 */
49→	default float[] embed(String text) {
50→		Assert.notNull(text, "Text must not be null");
51→		List<float[]> response = this.embed(List.of(text));
52→		return response.iterator().next();
53→	}
54→
55→	/**
56→	 * Embeds the given document's content into a vector.
57→	 * @param document the document to embed.
58→	 * @return the embedded vector.
59→	 */
60→	float[] embed(Document document);
#方法行号抽象?
1EmbeddingResponse call(EmbeddingRequest)41-42✅ 抽象
2default float[] embed(String text)49-53❌
3float[] embed(Document document)60✅ 抽象
4default @Nullable String getEmbeddingContent(Document)73-76❌
5default List<float[]> embed(List<String> texts)83-90❌
6default List<float[]> embed(List<Document>, EmbeddingOptions, BatchingStrategy)102-118❌
7default EmbeddingResponse embedForResponse(List<String>)125-128❌
8default int dimensions()137-139❌

两个抽象方法:call(EmbeddingRequest) 和 embed(Document)。

注意一个容易踩的坑:没有 embed(List<Document>) 的单参重载。只有三参版本(102 行)。想批量 embed 文档,必须显式传 options(可为 null)和 batchingStrategy。

2.14.3 dimensions() 默认实现会真的发起远程调用

130→	/**
131→	 * Get the number of dimensions of the embedded vectors. Note that by default, this
132→	 * method will call the remote Embedding endpoint to get the dimensions of the
133→	 * embedded vectors. If the dimensions are known ahead of time, it is recommended to
134→	 * override this method.
135→	 * @return the number of dimensions of the embedded vectors.
136→	 */
137→	default int dimensions() {
138→		return embed("Test String").length;
139→	}

dimensions() 的默认实现是 embed("Test String").length —— 一次真实的 API 调用,产生真实费用。

Javadoc 第 132-134 行明确警告了这一点,并建议"如果维度已知,应该 override 这个方法"。

这个默认实现的存在理由很实际:很多 embedding 模型(尤其是自部署的)没有暴露"维度"的元数据接口,只能靠"embed 一个字符串,看返回的向量有多长"来推断。

生产建议:如果你的 EmbeddingModel 实现没有 override dimensions(),而在启动流程里被调用(比如向量库初始化时要校验维度),那么每次启动都会多一次 API 调用。量大时会累积成可观的成本和延迟。

AbstractEmbeddingModel 对此做了优化(§2.17)。

2.14.4 getEmbeddingContent(Document):2.x 的一个实用新增

62→	/**
63→	 * Extracts the text content from a {@link Document} to be used for embedding. By
64→	 * default, returns {@link Document#getText()}. Implementations that support
65→	 * {@link org.springframework.ai.document.MetadataMode} should override this method to
66→	 * return
67→	 * {@link Document#getFormattedContent(org.springframework.ai.document.MetadataMode)}
68→	 * with the appropriate metadata mode, so that metadata is included in the text sent
69→	 * to the embedding API.
70→	 * @param document the document to extract embedding content from.
71→	 * @return the text content to embed.
72→	 */
73→	default @Nullable String getEmbeddingContent(Document document) {
74→		Assert.notNull(document, "Document must not be null");
75→		return document.getText();
76→	}

默认只取 document.getText(),丢弃 metadata。但 Javadoc 建议支持 MetadataMode 的实现 override 它,把 metadata 也纳入 embedding 文本。

为什么这很重要? 考虑一个场景:你的文档有 {"source": "hr-policy.pdf", "year": 2025} 这样的 metadata。

  • 默认行为:只 embed 正文。检索时"2025 年的 HR 政策"这个查询可能匹配不到这篇文档(因为正文里没写 2025)。
  • override 后:embed "source: hr-policy.pdf\nyear: 2025\n正文...",查询就能匹配上。

这是 metadata-aware embedding,在 RAG 里是个实用技巧(详细讨论见第 13 章)。

2.14.5 批量 embed 的顺序保证

102→	default List<float[]> embed(List<Document> documents, @Nullable EmbeddingOptions options,
103→			BatchingStrategy batchingStrategy) {
104→		Assert.notNull(documents, "Documents must not be null");
105→		List<float[]> embeddings = new ArrayList<>(documents.size());
106→		List<List<Document>> batch = batchingStrategy.batch(documents);
107→		for (List<Document> subBatch : batch) {
108→			List<String> texts = subBatch.stream().map(this::getEmbeddingContent).toList();
109→			EmbeddingRequest request = new EmbeddingRequest(texts, options);
110→			EmbeddingResponse response = this.call(request);
111→			for (int i = 0; i < subBatch.size(); i++) {
112→				embeddings.add(response.getResults().get(i).getOutput());
113→			}
114→		}
115→		Assert.isTrue(embeddings.size() == documents.size(),
116→				"Embeddings must have the same number as that of the documents");
117→		return embeddings;
118→	}

第 115-116 行的一致性断言是关键:embeddings.size() == documents.size()。

为什么需要?因为分批之后,如果某个 call() 返回的 embedding 数量少于输入文本数量(某些供应商会静默丢弃超长输入),顺序和对应关系就会错位。这个断言把"静默错位"变成"显式失败"。

顺序保证的契约在 BatchingStrategy 的 Javadoc 里也写明了:

// embedding/BatchingStrategy.java:30
30→public interface BatchingStrategy {
31→
32→	/**
33→	 * EmbeddingModel implementations can call this method to optimize embedding tokens.
34→	 * The incoming collection of {@link Document}s are split into sub-batches. It is
35→	 * important to preserve the order of the list of {@link Document}s when batching as
36→	 * they are mapped to their corresponding embeddings by their order.
37→	 * @param documents to batch
38→	 * @return a list of sub-batches that contain {@link Document}s.
39→	 */
40→	List<List<Document>> batch(List<Document> documents);
41→
42→}

"preserve the order ... as they are mapped to their corresponding embeddings by their order" —— 顺序即对应关系,没有 id 映射。这是 embedding pipeline 里最常见的 bug 来源(第 13 章详述)。

2.14.6 EmbeddingResponse.getResult() 抛异常:一处契约违背

// spring-ai-model/src/main/java/org/springframework/ai/embedding/EmbeddingResponse.java:67
67→	@Override
68→	public Embedding getResult() {
69→		Assert.notEmpty(this.embeddings, "No embedding data available.");
70→		return this.embeddings.get(0);
71→	}

Assert.notEmpty 在空列表时抛 IllegalArgumentException。

但 ModelResponse.getResult() 的契约是 @Nullable T getResult()(§2.2.2 第 40 行)—— 应该返回 null。

这是明确的契约违背。 后果:

ChatResponse cr = ...;
Generation g = cr.getResult();      // 可能返回 null,需要判空

EmbeddingResponse er = ...;
Embedding e = er.getResult();       // 空列表时抛异常,判空没用

写通用代码时要格外小心。

另一个有趣的细节 —— EmbeddingResponse.toString()(第 99-101 行):

return "EmbeddingResult{" + "data=" + this.embeddings + ", metadata=" + this.metadata + '}';

类名已经从 EmbeddingResult 改成了 EmbeddingResponse,但 toString() 里仍是 "EmbeddingResult{",字段名也仍是 data=(现在是 embeddings)。

这是 1.x → 2.x 改名的字符串化石。 同类证据还有 ImagePrompt.toString() 打印 "NewImagePrompt{"(§2.18.2)。

如果你在日志里看到 EmbeddingResult{...} 而代码里明明是 EmbeddingResponse,不要以为看错了。

2.14.7 Embedding 的脱敏 toString()

// spring-ai-model/src/main/java/org/springframework/ai/embedding/Embedding.java:97
97→	@Override
98→	public String toString() {
99→		String message = this.embedding.length == 0 ? "<empty>" : "<has data>";
100→		return "Embedding{" + "embedding=" + message + ", index=" + this.index + '}';
101→	}

理由显而易见:向量通常 1536 维(OpenAI text-embedding-3-small)甚至 3072 维,全打印会产生几万字符的日志。

但这也意味着:调试时你无法从日志里看到 embedding 内容。 想看就得自己 Arrays.toString(embedding.getOutput())。

equals / hashCode 用了数组语义(Arrays.equals / Arrays.hashCode,第 89、94 行),这是对的——float[] 的默认 equals 是比较引用,不是内容。

2.14.8 EmbeddingResultMetadata.EMPTY 不是 final(一处瑕疵)

// spring-ai-model/src/main/java/org/springframework/ai/embedding/EmbeddingResultMetadata.java:34
34→	public static EmbeddingResultMetadata EMPTY = new EmbeddingResultMetadata();

public static 但没有 final。 任何代码都可以:

EmbeddingResultMetadata.EMPTY = someMaliciousInstance;   // 编译通过!

这会让所有依赖 EMPTY 的地方行为异常,且极难排查。

对比 ChatGenerationMetadata.NULL(接口里声明,接口字段隐式 public static final)和 ChatResponseMetadata 的 private 字段——这里是一处明显的疏漏。

ModalityType 枚举(第 78-82 行):TEXT, IMAGE, AUDIO, VIDEO。而 ModalityUtils.getModalityType(MimeType)(101-121 行)在 mimeType == null 时返回 TEXT(第 103-105 行),无法识别时抛 IllegalArgumentException。


2.15 TokenCountBatchingStrategy:8191 × 0.9 = 7371 的来历

这是本章最"工程"的一节,也是 ETL 流水线(第 13 章)的基础。

// spring-ai-model/src/main/java/org/springframework/ai/embedding/TokenCountBatchingStrategy.java:54
54→public class TokenCountBatchingStrategy implements BatchingStrategy {
55→
59→	private static final int MAX_INPUT_TOKEN_COUNT = 8191;
...
65→	private static final double DEFAULT_TOKEN_COUNT_RESERVE_PERCENTAGE = 0.1;
...
75→	public TokenCountBatchingStrategy() {
76→		this(EncodingType.CL100K_BASE, MAX_INPUT_TOKEN_COUNT, DEFAULT_TOKEN_COUNT_RESERVE_PERCENTAGE);
77→	}
...
108→		this.maxInputTokenCount = (int) Math.round(maxInputTokenCount * (1 - reservePercentage));

关键数字:

  • MAX_INPUT_TOKEN_COUNT = 8191 —— OpenAI embedding 模型的输入上限是 8192 token,取 8191 留一位余量。
  • DEFAULT_TOKEN_COUNT_RESERVE_PERCENTAGE = 0.1 —— 预留 10%。
  • 第 108 行:Math.round(8191 * 0.9) = Math.round(7371.9) = 7372。

但 Javadoc(第 41-42 行)写的是 "resulting in a max input token count of 7371"。

7371 vs 7372,差 1。来源是 Math.round(四舍五入得 7372)与 (int) 截断(得 7371)的差别:Javadoc 是按截断算的,代码用的是四舍五入。这是本章第 5 处 Javadoc 与实现脱节。差 1 个 token 在实践中无害,但它证明即便是有明确数字计算的 Javadoc 也可能不准——确认行为要看代码。

为什么预留 10%? 因为 jtokkit 的 CL100K 估算与供应商实际分词结果不完全一致。供应商可能用不同分词器,导致实际 token 数超过估算值。留 10% 缓冲,降低"超长被拒"的概率。

2.15.1 批处理算法与一处真实 bug 修复的注释

137→	public List<List<Document>> batch(List<Document> documents) {
...
142→		// Do not collect the documents into a Map keyed by Document: equal documents
143→		// would collapse to a single entry and be silently dropped from the batches.
144→		for (Document document : documents) {
145→			int tokenCount = this.tokenCountEstimator
146→				.estimate(document.getFormattedContent(this.contentFormatter, this.metadataMode));
147→			if (tokenCount > this.maxInputTokenCount) {
148→				throw new IllegalArgumentException(
149→						"Tokens in a single document exceeds the maximum number of allowed input tokens");
150→			}
151→			currentSize += tokenCount;
152→			if (currentSize > this.maxInputTokenCount) {
153→				batches.add(currentBatch);
154→				currentBatch = new ArrayList<>();
155→				currentSize = tokenCount;
156→			}
157→			currentBatch.add(document);
158→		}

第 142-143 行的注释记录了一个真实的 bug 修复:

不要把 documents 收集成以 Document 为 key 的 Map:相等的 document 会被合并成一条,然后从批次里被静默丢弃。

这是典型的"去重优化引入的 bug"。早期实现大概用了 Map<Document, Integer> 来缓存 token 数(避免重复计算),结果:

List<Document> docs = List.of(
    new Document("相同内容"),
    new Document("相同内容")    // 与上一条 equals
);
// 早期实现:Map 去重后只剩 1 条,第二条被静默丢弃
// 结果:embeddings.size() == 1,但 documents.size() == 2
// 于是 §2.14.5 第 115 行的断言炸了 —— 或者更糟,顺序错位

现在的实现用顺序 for 循环,不做去重。代价是重复文档会重复计算 token(性能损失),换来正确性。

这个注释本身就是一篇好教材:它告诉你"这里曾经出过 bug,别再改回去"。

算法要点:

  1. 逐个累加 tokenCount,超过阈值就开新批次(152-156 行)。
  2. 单个文档超限直接抛异常(147-150 行),不做切分——切分是 TokenTextSplitter 的职责,不是批处理策略的职责。职责分离得很清楚。
  3. 第 155 行 currentSize = tokenCount(不是 0),因为当前文档要放进新批次。

默认编码 EncodingType.CL100K_BASE(第 76 行),默认 MetadataMode.NONE(第 87 行)—— 即默认不把 metadata 计入 token,与 §2.14.4 的 getEmbeddingContent() 默认行为一致。


2.16 AbstractEmbeddingModel:用 properties 文件避免远程探测

// spring-ai-model/src/main/java/org/springframework/ai/embedding/AbstractEmbeddingModel.java:41
41→@ImportRuntimeHints(AbstractEmbeddingModel.Hints.class)
42→public abstract class AbstractEmbeddingModel implements EmbeddingModel {
43→
44→	private static final Resource EMBEDDING_MODEL_DIMENSIONS_PROPERTIES = new ClassPathResource(
45→			"/embedding/embedding-model-dimensions.properties");
46→
47→	private static final Map<String, Integer> KNOWN_EMBEDDING_DIMENSIONS = loadKnownModelDimensions();
48→
49→	/**
50→	 * Cached embedding dimensions.
51→	 */
52→	protected final AtomicInteger embeddingDimensions = new AtomicInteger(-1);
53→
63→	public static int dimensions(EmbeddingModel embeddingModel, String modelName, String dummyContent) {
64→
65→		if (KNOWN_EMBEDDING_DIMENSIONS.containsKey(modelName)) {
66→			// Retrieve the dimension from a pre-configured file.
67→			return KNOWN_EMBEDDING_DIMENSIONS.get(modelName);
68→		}
69→		else {
70→			// Determine the dimensions empirically.
71→			// Generate an embedding and count the dimension size;
72→			return embeddingModel.embed(dummyContent).length;
73→		}
74→	}
...
94→	@Override
95→	public int dimensions() {
96→		if (this.embeddingDimensions.get() < 0) {
97→			this.embeddingDimensions.set(dimensions(this, "Test", "Hello World"));
98→		}
99→		return this.embeddingDimensions.get();
100→	}

这是一个三层优化:

  1. 查表优先:classpath 下 /embedding/embedding-model-dimensions.properties 记录了已知模型的维度(比如 text-embedding-3-small=1536)。命中就直接返回,零网络调用。
  2. 实测兜底:未命中才 embed(dummyContent)。
  3. 缓存结果:AtomicInteger(-1) 作为"未初始化"哨兵值,一旦算出就缓存。

第 41 行 @ImportRuntimeHints(AbstractEmbeddingModel.Hints.class) 是 AOT / GraalVM Native Image 支持:注册那个 .properties 资源,否则打进 native image 后读不到(第 20 章详述)。

一个并发细节:第 96-98 行是 check-then-act,非原子,多线程首次调用可能重复探测多次。后果只是"多花几次 API 调用"(结果幂等),作者显然认为可接受——严格化应该用 compareAndSet 或双检锁。

一处值得注意的实现问题:第 97 行传的 modelName 是硬编码的 "Test":

dimensions(this, "Test", "Hello World")
//         ↑ 模型名写死成 "Test"

而 properties 文件里的 key 是真实模型名(如 text-embedding-3-small)。这意味着除非子类 override dimensions() 并传入真实模型名,否则查表永远不命中,每次都走实测。供应商实现(如 OpenAiEmbeddingModel)通常会 override 修正;你在写自定义 EmbeddingModel 时也要记得这么做。


2.17 Image 体系:三处 @since 2.0.1

2.17.1 ImageModel:最简的 Model 实现

// spring-ai-model/src/main/java/org/springframework/ai/image/ImageModel.java:21
21→@FunctionalInterface
22→public interface ImageModel extends Model<ImagePrompt, ImageResponse> {
23→
24→	ImageResponse call(ImagePrompt request);
25→
26→}

27 行,无 Javadoc、无 @author、无 @since。

对比 ChatModel(69 行、完整 Javadoc、含 @since 2.0.0 方法),Image 侧明显"照顾不足":没有 getOptions() / getDefaultOptions()、没有 call(String) 便捷方法、没有流式。这印证 §2.1.2 的观察——抽象层越核心、用得越多,就越完善;Image 在 Spring AI 里优先级低于 Chat,接口也就更简陋。

2.17.2 ImagePrompt.toString():NewImagePrompt 化石

// spring-ai-model/src/main/java/org/springframework/ai/image/ImagePrompt.java:65
65→	@Override
66→	public String toString() {
67→		return "NewImagePrompt{" + "messages=" + this.messages + ", imageModelOptions=" + this.imageModelOptions + '}';
68→	}

"NewImagePrompt{" —— 说明这个类在某个时期叫 NewImagePrompt(大概是为了和旧的 ImagePrompt 区分,后来旧的被删了,新的去掉了 New 前缀,但字符串没改)。

这与 §2.14.6 的 EmbeddingResult{ 是同一类证据。合起来可以确认:Spring AI 经历过一轮"去 New/去 Result 后缀"的改名。

2.17.3 ImageResponseMetadata:2.0.1 给 Image 加了 Usage

// spring-ai-model/src/main/java/org/springframework/ai/image/ImageResponseMetadata.java:32
32→public class ImageResponseMetadata extends MutableResponseMetadata {
33→
34→	private final Long created;
35→
36→	private Usage usage;
37→
38→	public ImageResponseMetadata() {
39→		this(System.currentTimeMillis(), new EmptyUsage());
40→	}
...
46→	/**
47→	 * Create a new {@code ImageResponseMetadata}.
48→	 * @param created the creation timestamp
49→	 * @param usage the token usage associated with the image response
50→	 * @since 2.0.1
51→	 */
52→	public ImageResponseMetadata(Long created, Usage usage) {
53→		this.created = created;
54→		this.usage = usage;
55→	}
...
61→	/**
62→	 * Return the token usage associated with the image response.
63→	 * @since 2.0.1
64→	 */
65→	public Usage getUsage() {
66→		return this.usage;
67→	}
68→
69→	/**
70→	 * Set the token usage associated with the image response.
71→	 * @since 2.0.1
72→	 */
73→	public void setUsage(Usage usage) {
74→		this.usage = usage;
75→	}

三处 @since 2.0.1(第 50、63、71 行) —— 这是 2.0.1 这个补丁版本里的真实新增:Image 响应现在携带 token usage。

这解释了为什么它要 extends MutableResponseMetadata(§2.9.3)而不是 AbstractResponseMetadata:需要 setUsage()。Image API 的典型流程是"先返回图片,再回填 usage"。

created 字段用 Long(epoch millis,第 39 行 System.currentTimeMillis())—— 对比 RateLimit 用 Duration(§2.12.2)。同一个代码库里两种时间建模:一个是绝对时刻(epoch millis),一个是相对时长。

2.17.4 ImageGeneration:匿名内部类的空 metadata

// spring-ai-model/src/main/java/org/springframework/ai/image/ImageGeneration.java:21
21→public class ImageGeneration implements ModelResult<Image> {
22→
23→	private static final ImageGenerationMetadata NONE = new ImageGenerationMetadata() {
24→
25→	};
26→
27→	private final ImageGenerationMetadata imageGenerationMetadata;
28→
29→	private final Image image;

第 23-25 行用匿名内部类(new ImageGenerationMetadata() {})实现空 metadata。对比 ChatGenerationMetadata.NULL = builder().build()(Builder 构造)与 EmbeddingResultMetadata.EMPTY = new EmbeddingResultMetadata()(真实实例)——三种不同的"空对象"实现方式,一致性为零。

第 35-38 行的构造器不判 null:imageGenerationMetadata 传 null 就存 null,与 Generation 的兜底(§2.8.2 第 42-43 行)不同。


2.18 Audio:TranscriptionModel 与那次关键的继承变更

2.18.1 类名纠正

AudioTranscriptionModel 不存在。 正确名字是:

// spring-ai-model/src/main/java/org/springframework/ai/audio/transcription/TranscriptionModel.java:30
30→	 * @since 1.0.0
32→public interface TranscriptionModel
33→		extends Model<AudioTranscriptionPrompt, AudioTranscriptionResponse>, StreamingTranscriptionModel {

@since 1.0.0 —— 从 1.0.0 起就叫 TranscriptionModel。如果你在搜 AudioTranscriptionModel,会一无所获。

注意 prompt 和 response 类保留了 Audio 前缀(AudioTranscriptionPrompt、AudioTranscriptionResponse),只有 model 类去掉了。这种"一半改一半不改"的现象在改名中很常见。

2.18.2 与 ChatModel 完全同构的双继承

extends Model<AudioTranscriptionPrompt, AudioTranscriptionResponse>, StreamingTranscriptionModel

结构上是 ChatModel 的翻版(§2.5.1)。三个方法:

40→	AudioTranscriptionResponse call(AudioTranscriptionPrompt transcriptionPrompt);
...
47→	default String transcribe(Resource resource) {
48→		return this.transcribe(resource, null);
49→	}
...
57→	default String transcribe(Resource resource, @Nullable AudioTranscriptionOptions options) {
58→		AudioTranscriptionPrompt prompt = new AudioTranscriptionPrompt(resource, options);
59→		AudioTranscription result = this.call(prompt).getResult();
60→		return result != null ? result.getOutput() : "";
61→	}

第 59-60 行与 ChatModel.call(String)(§2.5.3)完全同构:getResult() → 判空 → ""。

Resource 参数(而不是 byte[] 或 File)是 Spring 的惯用法:可以是 classpath 资源、文件系统、URL、甚至 InputStreamResource。Javadoc(第 39-43 行)列出支持的音频格式:mp3, mp4, mpeg, mpga, m4a, wav, webm。

2.18.3 AudioTranscriptionResponse:一个单结果模型

// spring-ai-model/src/main/java/org/springframework/ai/audio/transcription/AudioTranscriptionResponse.java:31
31→public class AudioTranscriptionResponse implements ModelResponse<AudioTranscription> {
32→
33→	private final AudioTranscription transcript;
34→
35→	private final AudioTranscriptionResponseMetadata transcriptionResponseMetadata;
36→
37→	public AudioTranscriptionResponse(AudioTranscription transcript) {
38→		this(transcript, new AudioTranscriptionResponseMetadata());
39→	}
...
41→	public AudioTranscriptionResponse(AudioTranscription transcript,
42→			AudioTranscriptionResponseMetadata transcriptionResponseMetadata) {
43→		Assert.notNull(transcript, "AudioTranscription must not be null");
44→		Assert.notNull(transcriptionResponseMetadata, "AudioTranscriptionResponseMetadata must not be null");
45→		this.transcript = transcript;
46→		this.transcriptionResponseMetadata = transcriptionResponseMetadata;
47→	}
48→
49→	@Override
50→	public AudioTranscription getResult() {
51→		return this.transcript;
52→	}
53→
54→	@Override
55→	public List<AudioTranscription> getResults() {
56→		return List.of(this.transcript);
57→	}

语音转文字只有一个结果(不像 chat 有 n 个候选),所以:

  • 构造器双 Assert.notNull(43-44 行)—— 两个都必填,不做兜底。
  • getResult() 永不返回 null(第 51 行直接返回 final 字段)。
  • getResults() 返回 List.of(transcript)(每次调用都新建一个单元素 list)。

没有 equals / hashCode / toString() —— 与 ChatResponse、EmbeddingResponse 都不同。这意味着 AudioTranscriptionResponse 不能正确用于 Set 或作为 Map key,且日志里打印出来是 @hash。

这是一个明显的"照顾不足",也再次印证 §2.17.1 的观察。

2.18.4 StreamingTranscriptionModel:本章唯一标注 @since 2.0.1 的接口

// spring-ai-model/src/main/java/org/springframework/ai/audio/transcription/StreamingTranscriptionModel.java
27→/**
28→ * Interface for the streaming transcription model. This extends {@link StreamingModel} to
29→ * provide a streaming API for speech-to-text transcription, where audio input is
30→ * converted to text output incrementally.
31→ *
32→ * @author guan xu
33→ * @since 2.0.1
34→ */
35→public interface StreamingTranscriptionModel
36→		extends StreamingModel<AudioTranscriptionPrompt, AudioTranscriptionResponse> {
37→
46→	default Flux<AudioTranscriptionResponse> stream(AudioTranscriptionPrompt transcriptionPrompt) {
47→		return Flux.error(new UnsupportedOperationException("Streaming transcription is not supported"));
48→	}
49→
55→	default Flux<String> streamTranscribe(Resource resource) {
56→		return this.streamTranscribe(resource, null);
57→	}
58→
66→	default Flux<String> streamTranscribe(Resource resource, @Nullable AudioTranscriptionOptions options) {
67→		AudioTranscriptionPrompt prompt = new AudioTranscriptionPrompt(resource, options);
68→		return this.stream(prompt)
69→			.map(response -> Optional.ofNullable(response.getResult()).map(AudioTranscription::getOutput).orElse(""));
70→	}
71→
72→}

@since 2.0.1(第 33 行) —— 在 2.0.1 这个补丁版本里新增的能力接口。这是本章能给出的最精确的"2.0.1 新增点"。

stream() 的 default 用 Flux.error(§2.7.2 讨论过)。

streamTranscribe(66-70)用了 Optional 链——与 StreamingChatModel.stream(String)(§2.6.2)同样的风格。

整个 spring-ai-model 主源码里标注 @since 2.0.1 的清单(这些是 2.0.1 的真实新增点,值得记住):

类/方法位置
StreamingTranscriptionModelaudio/transcription/StreamingTranscriptionModel.java:33
ImageResponseMetadata(Long, Usage)image/ImageResponseMetadata.java:50
ImageResponseMetadata.getUsage()image/ImageResponseMetadata.java:63
ImageResponseMetadata.setUsage(Usage)image/ImageResponseMetadata.java:71
ImageMessage 4 参构造器image/ImageMessage.java:55
DefaultToolCallingManagermodel/tool/DefaultToolCallingManager.java:150
ToolCallLimitBehaviormodel/tool/ToolCallLimitBehavior.java:24
ToolCallLimitExceededExceptionmodel/tool/ToolCallLimitExceededException.java:38
ToolCallLimitsmodel/tool/ToolCallLimits.java:35

注意后四个都跟工具调用限流有关——2.0.1 给工具调用加了次数上限(第 08 章详述)。这说明 2.0.1 不是一个纯粹的 bugfix 版本,它带了新特性。


2.19 TTS:TextToSpeechModel 与 OpenAI 侧的大规模改名

2.19.1 类名纠正

SpeechSynthesisModel 不存在,audio/synthesis 包也不存在。

正确的是:

// spring-ai-model/src/main/java/org/springframework/ai/audio/tts/TextToSpeechModel.java:28
28→public interface TextToSpeechModel extends Model<TextToSpeechPrompt, TextToSpeechResponse>, StreamingTextToSpeechModel {

包是 org.springframework.ai.audio.tts。

这是 2.x 的一次"把供应商特有的包名收编到通用包"的改动。upgrade-notes.adoc:2232-2243 记录了完整清单:

2232→===== Removed Classes
2233→
2234→The following deprecated classes have been removed from the
     `org.springframework.ai.openai.audio.speech` package:
2235→
2236→* `SpeechModel` → Use `TextToSpeechModel` (from `org.springframework.ai.audio.tts`)
2237→* `StreamingSpeechModel` → Use `StreamingTextToSpeechModel` (from `org.springframework.ai.audio.tts`)
2238→* `SpeechPrompt` → Use `TextToSpeechPrompt` (from `org.springframework.ai.audio.tts`)
2239→* `SpeechResponse` → Use `TextToSpeechResponse` (from `org.springframework.ai.audio.tts`)
2240→* `SpeechMessage` → Use `TextToSpeechMessage` (from `org.springframework.ai.audio.tts`)
2241→* `Speech` (in `org.springframework.ai.openai.audio.speech`) → Use `Speech` (from `org.springframework.ai.audio.tts`)
2242→
2243→Additionally, the `speed` parameter type changed from `Float` to `Double` across all
     OpenAI TTS components for consistency with other TTS providers.

六个类从 org.springframework.ai.openai.audio.speech 搬到 org.springframework.ai.audio.tts。

这次搬迁的意义:TTS 不再被视为 OpenAI 专有的能力。之前它在 openai 包下,意味着"这是 OpenAI 的功能";现在提到通用的 audio.tts,意味着"这是所有供应商都可能提供的能力"。

这正是 §2.1 讨论的"可移植性优先"信条的又一次体现。

顺带:speed 从 Float 改成 Double(第 2243 行)。源码印证在 TextToSpeechOptions.java:62(getSpeed() 返回 @Nullable Double)。

这类"Float → Double"的改动值得注意:它是源码不兼容的(装箱类型不同,方法签名变了)。如果你有代码写 .speed(1.0f),升级后要改成 .speed(1.0)。

2.19.2 与 ChatModel 一字不差的 options 改造

43→	/**
44→	 * Gets the options for this model.
45→	 * @return the options
46→	 * @since 2.0.0
47→	 */
48→	default TextToSpeechOptions getOptions() {
49→		return TextToSpeechOptions.builder().build();
50→	}
51→
52→	/**
53→	 * @deprecated use {@link #getOptions()} instead.
54→	 */
55→	@Deprecated(forRemoval = true)
56→	default TextToSpeechOptions getDefaultOptions() {
57→		return getOptions();
58→	}

Javadoc 文案与 ChatModel 完全相同(§2.5.4):

/**
 * Gets the options for this model.
 * @return the options
 * @since 2.0.0
 */
/**
 * @deprecated use {@link #getOptions()} instead.
 */
@Deprecated(forRemoval = true)

这是 2.0.0 统一重命名 getDefaultOptions() → getOptions() 的第二条独立证据。两条证据来自不同作者、不同模块,说明这是一次有组织的全局重构,而不是某个人顺手改的。

2.19.3 call(String) 的双层 null 防护

30→	default byte[] call(String text) {
31→		TextToSpeechPrompt prompt = new TextToSpeechPrompt(text);
32→		ModelResult<byte[]> result = call(prompt).getResult();
33→		if (result == null) {
34→			return new byte[0];
35→		}
36→		byte[] output = result.getOutput();
37→		return (output != null) ? output : new byte[0];
38→	}

两层判空:result == null(响应无结果)、output == null(结果无输出)。两层都返回 new byte[0]。

对比 ChatModel.call(String)(§2.5.3)只判了一层(generation != null),没判 getText() 是否为 null。所以 TTS 侧其实更严谨。

第 32 行用裸泛型 ModelResult<byte[]> 而不是 Speech(TextToSpeechResponse implements ModelResponse<Speech>)—— 略不一致,但功能等价(Speech.getOutput() 返回 byte[])。

2.19.4 TextToSpeechResponse 的两处问题

// audio/tts/TextToSpeechResponse.java
51→	public Speech getResult() {
52→		return this.results.get(0);
53→	}

空列表时抛 IndexOutOfBoundsException —— 与 EmbeddingResponse 一样违背 @Nullable 契约(§2.14.6),而且比它更糟(IndexOutOfBoundsException 的语义比 IllegalArgumentException 更模糊)。

68→	public boolean equals(@Nullable Object o) {
...
73→	public int hashCode() {

equals / hashCode 只比较 results,忽略 metadata。 这违反了 equals/hashCode 的最佳实践(应该覆盖所有业务字段),也不满足"equals 相等则 hashCode 相等"之外的一致性要求。后果:两个 metadata 不同但 results 相同的响应会被判为相等。

在实际使用中影响有限(很少有人比较 TTS 响应),但这是"复制粘贴式实现"的典型痕迹。


2.20 ModerationModel 与 ModelDescription

2.20.1 ModerationModel

// spring-ai-model/src/main/java/org/springframework/ai/moderation/ModerationModel.java:30
30→@FunctionalInterface
31→public interface ModerationModel extends Model<ModerationPrompt, ModerationResponse> {
32→
33→	ModerationResponse call(ModerationPrompt request);
34→
35→}

@since 1.0.0,@FunctionalInterface,不继承任何 Streaming 接口(内容审核没有流式需求),也没有 getOptions() —— 2.x 的 options 改造未覆盖 Moderation(也未覆盖 Image,§2.17.1)。

一个 2.0.1 细节:Categories.java 与 CategoryScores.java 有大量 @Deprecated(since = "2.0.1", forRemoval = true)(Categories.java:59,147,253,326;CategoryScores.java:60,148,261,334)并配套新的 @since 2.0.1 方法——这证明 2.0.1 这个补丁版本也做了废弃,不是纯粹 bugfix。

2.20.2 ModelDescription 与空标记接口

// spring-ai-model/src/main/java/org/springframework/ai/model/ModelDescription.java:26
26→public interface ModelDescription {
27→
28→	/**
29→	 * Returns the name of the model.
30→	 * @return the name of the model
31→	 */
32→	String getName();
33→
34→	/**
35→	 * Returns the description of the model.
36→	 * @return the description of the model
37→	 */
38→	default String getDescription() {
39→		return "";
40→	}
41→
42→	/**
43→	 * Returns the version of the model.
44→	 * @return the version of the model
45→	 */
46→	default String getVersion() {
47→		return "";
48→	}
49→
50→}
// spring-ai-model/src/main/java/org/springframework/ai/model/ChatModelDescription.java:26
26→public interface ChatModelDescription extends ModelDescription {
27→
28→}

ChatModelDescription 是完全空的标记接口。 它的 Javadoc(第 20-21 行)说"用来存模型信息,比如当前上下文长度"——但接口里什么都没有。

这说明该抽象设计过但没完成:预期用途大概是让每个 ChatModel 暴露"我的上下文窗口多大"供 token 预算计算,但最终只有继承来的 getName() 可用,上下文长度得靠实现类自行扩展。同包还有 EmbeddingModelDescription.java,同样结构。

这类"半成品抽象"在真实框架里很常见——识别它们很重要:不要对着空接口臆想它的能力,要看有没有实现类与调用点。



2.21 本章小结:抽象层的七条结论

结论一:越靠近根部,变动越少

Model、StreamingModel、ModelRequest、ModelResponse、ModelResult、ModelOptions、ModelOptionsUtils 七个类全部 @since 0.8.0,到 2.0.1 签名零变更;ChatModel.getOptions() 是 @since 2.0.0,StreamingTranscriptionModel 是 @since 2.0.1。重构永远发生在叶子——要依赖它,依赖最内层最安全。

结论二:三级泛型链是理解全库的钥匙

Model<TReq extends ModelRequest<?>, TRes extends ModelResponse<?>>,而 ModelResponse<T extends ModelResult<?>>。Response 级是"这次调用",Result 级是"这个候选"。 metadata 因此分成 ResponseMetadata(模型名/usage/rateLimit)与 ResultMetadata(finishReason/contentFilters)两套,互不干扰。

结论三:流元素是完整响应,不是增量片段

Flux<ChatResponse> 里每个元素都是语法完整的 ChatResponse。这是为了让流式与非流式共用同一套类型,从而让 advisor 链(第 05 章)能统一抽象两条链路。代价是每个 chunk 都要声明一份 metadata(多数是空的)。

结论四:getDefaultOptions() 已死,getOptions() 当立

ChatModel 与 TextToSpeechModel 两处独立印证(均 @since 2.0.0),upgrade-notes.adoc:775 官方背书。真正的变化不是改名,而是默认值从 Model 实现类和 *Properties 双份,收敛到 options 构造器单份(upgrade-notes.adoc:770-771)。

结论五:内部工具循环已从模型层移除

ChatModel.call() 现在返回原始响应,工具不自动执行。hasToolCalls() 从内部方法变成了公开 API 的判定核心。详见第 08 章。

结论六:三种"不支持流式"的表达并存

接口表达何时报错onErrorResume
ChatModelthrow(历史包袱)调用时同步❌ 抓不到
StreamingTranscriptionModelFlux.error(新写法)订阅时异步✅ 能抓到
StreamingTextToSpeechModel无 default编译期— 必须实现

写容错代码时必须分情况,不要假设行为一致。

结论七:契约与实现存在系统性裂缝

这是本章最"不客气"的一条。2.0.1 里 ModelResponse.getResult() 的 @Nullable 契约并没有被所有实现兑现:

实现getResult() 空列表行为符合 @Nullable?
ChatResponsereturn null✅
ImageResponsereturn null✅
EmbeddingResponseAssert.notEmpty → 抛 IllegalArgumentException❌
TextToSpeechResponseget(0) → IndexOutOfBoundsException❌
AudioTranscriptionResponse永不 null(单结果)⚠️ 不适用

其他裂缝:

  • Generation.chatGenerationMetadata 非 final(不是不可变类)
  • EmbeddingResultMetadata.EMPTY 非 final(可被外部篡改)
  • EmptyUsage / EmptyRateLimit 没有 toString()(日志里打印成 @hash)
  • ChatResponse.toString() 全量打印 LLM 输出(有 PII 泄露风险),而 Embedding.toString() 做了脱敏

识别这些裂缝的价值在于:你不会对着 @Nullable 注解写出错误的容错代码,也不会因为"Spring AI 是 Spring 官方项目"就假定它内部一致。真实的大型框架都是演进出来的,不是设计出来的。


2.22 本章的思考题

第 1 题:ChatModel 用 default(抛异常)覆盖了 StreamingChatModel 的抽象方法 stream(Prompt)。一个只实现 call(Prompt) 的实现类,在 StreamingChatModel asStream = impl; 与 ChatModel asChat = impl; 两种引用下分别是什么行为?提示:关键不是方法分派,而是编译期——"StreamingChatModel 类型的变量上,stream(Prompt) 是抽象方法还是 default"。

第 2 题:为 ChatModel(同步 throw)和 TranscriptionModel(异步 Flux.error)各写一段"流式不可用时降级到非流式"的代码,说明为什么不能复用同一套。

第 3 题:dimensions() 默认实现会真实发起远程调用。生产启动流程里调用 100 次会怎样?给出两种缓解方案,并指出 AbstractEmbeddingModel 用了哪一种、以及它实现里的漏洞(提示:看第 97 行传的 modelName)。

第 4 题:reservePercentage 改成 0 时 Math.round(8191 * 1.0) 是多少?此时超长风险多大?为什么默认是 10% 而不是 5% 或 20%?

第 5 题:本章列出 5 处"Javadoc 与实现脱节"。为什么字符串常量(toString() 里的类名、错误消息)在改名时最容易被遗漏?你的项目可以用什么构建期手段消灭这类化石?

第 6 题:ChatResponse.toString() 全量打印 LLM 输出,Embedding.toString() 只打印 <has data>。把 Spring AI 接入有合规要求的金融系统时,你会在哪几处加脱敏?为什么不能只靠调低日志级别解决?


2.23 关键文件索引(便于复核)

以下路径均相对于 spring-ai-model/src/main/java/org/springframework/ai/,★ 标记本章重点。

包文件(行数)关键结论
model/Model(41) StreamingModel(44) ModelRequest(47) ModelResponse(55) ModelResult(44) ModelOptions(32) ModelOptionsUtils(43)★ ResultMetadata(32) ResponseMetadata(90) AbstractResponseMetadata(102) MutableResponseMetadata(140) ModelDescription(51) ChatModelDescription(29) package-info(3)七基类全 @since 0.8.0;ModelOptionsUtils 只剩 mergeOption
chat/model/ChatModel(69)★ StreamingChatModel(52) ChatResponse(202)★ Generation(80)Generation 已是顶层类
chat/metadata/ChatResponseMetadata(182)★ ChatGenerationMetadata(102) Usage(95)★ DefaultUsage(227) EmptyUsage(46) RateLimit(85) EmptyRateLimit(60) PromptMetadata(138)Null Object 模式;2.0.0 新增 cache 方法;Duration 无 toString
embedding/EmbeddingModel(142)★ EmbeddingRequest(52) EmbeddingResponse(104) Embedding(104) EmbeddingResultMetadata(126) BatchingStrategy(43) TokenCountBatchingStrategy(166)★ AbstractEmbeddingModel(112) DocumentEmbeddingModel(35)包内无 model 层;getResult() 抛异常
image/ImageModel(27) ImagePrompt(87) ImageResponse(120) ImageGeneration(57) ImageResponseMetadata(78)★三处 @since 2.0.1;NewImagePrompt 化石
audio/transcription/TranscriptionModel(64) StreamingTranscriptionModel(73)★ AudioTranscriptionResponse(65)非 AudioTranscriptionModel
audio/tts/TextToSpeechModel(61) StreamingTextToSpeechModel(47)非 SpeechSynthesisModel
moderation/ModerationModel(36)无 options 改造

2.24 复核命令

# 1. 验证这 6 个类确实不存在(应全部无输出)
find . -name "DefaultChatResponseMetadata.java" -o -name "EmptyChatResponseMetadata.java" \
     -o -name "AudioTranscriptionModel.java" -o -name "SpeechSynthesisModel.java" \
     -o -name "EmbeddingBatchingStrategy.java"
ls spring-ai-model/src/main/java/org/springframework/ai/model/embedding   # 应报错:无此目录
# 2. ModelOptionsUtils 只剩一个方法
grep -c "public static" spring-ai-model/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java
# 3. 验证流式"不支持"的两种写法
grep -n "streaming is not supported" spring-ai-model/src/main/java/org/springframework/ai/chat/model/ChatModel.java
grep -n "Streaming transcription is not supported" \
     spring-ai-model/src/main/java/org/springframework/ai/audio/transcription/StreamingTranscriptionModel.java
# 4. 验证 NullAway + JSpecify
grep -n "NullAway" pom.xml
grep -n "@NullMarked" spring-ai-model/src/main/java/org/springframework/ai/model/package-info.java
# 5. 2.0.1 新增点、toString 化石、SimpleDateFormat 残留
grep -rn "@since 2.0.1" --include=*.java spring-ai-model/src/main/java/ | head -20
grep -rn "EmbeddingResult{\|NewImagePrompt{" --include=*.java spring-ai-model/src/main/java/
grep -rln "SimpleDateFormat" --include=*.java .   # 应只有 2 处,都在 vector-stores

下一章:第 03 章进入消息体系与 Prompt。我们会拆解 Message 接口的五个实现、MessageType 里那个"消失的 FUNCTION",以及本章埋下的最大伏笔——ChatOptions 为什么所有 getter 都是 @Nullable,以及 2.x 里 options 到底是怎么合并的(ModelOptionsUtils.merge() 已死,Builder.combineWith() 当立)。