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 | 说明 |
|---|---|---|
Model | 0.8.0 | 2.0.1 零变更 |
StreamingModel | 0.8.0 | 2.0.1 零变更 |
ModelRequest | 0.8.0 | 2.0.1 零变更 |
ModelResponse | 0.8.0 | 2.0.1 零变更 |
ModelResult | 0.8.0 | 2.0.1 零变更 |
ModelOptions | 0.8.0 | 2.0.1 零变更 |
ChatResponseMetadata | 1.0.0 | 2.0.1 仍是具体类 |
ChatModel.getOptions() | 2.0.0 | 2.x 新增 |
Usage.getCacheReadInputTokens() | 2.0.0 | 2.x 新增 |
StreamingTranscriptionModel | 2.0.1 | 2.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、promptMetadataResultMetadata(结果级):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 | ✅ |
EmbeddingResponse | Assert.notEmpty(...) 抛异常 | ❌ |
AudioTranscriptionResponse | 永不 null(单结果模型) | ⚠️ 不适用 |
TextToSpeechResponse | IndexOutOfBoundsException | ❌ |
这种"同一个契约、多种实现"的局面,是框架演进到一定规模后的必然产物。本章不打算粉饰它,而是把它当成实证材料:@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)。
这个错配直接导致了两个东西的存在:
- 大多数 chunk 的
usage是EmptyUsage(返回 0)—— 由ChatResponseMetadata的默认字段值提供(§2.10); - 需要一个聚合器把流式的多个
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); // 假想
好处是"语义更准确"——确实每次只来一小段。坏处是:
- 丢失响应级 metadata:
usage、rateLimit、id只能挂在 Generation 上,但它们是响应级的,语义错位(§2.2.1 讨论过)。 - 无法表达"流结束"信号:很多协议需要一个"最后一个事件"来携带 usage 或 finishReason,而它不是内容的一部分。
ChatResponse可以带一个内容为空但 metadata 完整的实例,Generation很难优雅表达。 - 破坏与
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 里被复用了至少三次:
| 模型 | 声明 | 文件 |
|---|---|---|
ChatModel | extends Model<Prompt, ChatResponse>, StreamingChatModel | chat/model/ChatModel.java:30 |
TranscriptionModel | extends Model<AudioTranscriptionPrompt, AudioTranscriptionResponse>, StreamingTranscriptionModel | audio/transcription/TranscriptionModel.java:32-33 |
TextToSpeechModel | extends Model<TextToSpeechPrompt, TextToSpeechResponse>, StreamingTextToSpeechModel | audio/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.ModelObservationContextorg.springframework.ai.model.observation.ModelUsageMetricsGeneratororg.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 能否捕获 | 编译期约束 |
|---|---|---|---|---|
ChatModel | throw new UnsupportedOperationException | 调用时(同步) | ❌ | 可不实现 |
StreamingTranscriptionModel | Flux.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 行是三段防御,值得逐行看:
Assert.notNull(generations, "'generations' must not be null")—— generations 必填,null 直接抛IllegalArgumentException。Objects.requireNonNullElse(chatResponseMetadata, new ChatResponseMetadata())—— metadata 可空,null 时兜底成空实例。不抛异常。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。
为什么会做这个改动?三点推测,都有源码依据:
Generation被流式路径大量使用(§2.3 讨论过Flux<ChatResponse>里每个 chunk 含一个 Generation)。让一个高频使用的类型嵌套在另一个类里,写起来啰嗦。- 其他
ModelResult实现都是顶层类:Embedding(embedding/Embedding.java:29)、ImageGeneration(image/ImageGeneration.java:21)、moderation.Generation。统一形态。 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)有三处细节:
Assert.notNull(finishReasons, "finishReasons cannot be null")—— 参数必填(对比getResult()的宽松)。- 第 127-128 行:finishReason 为 null 时视为
""。 - 第 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→
三点:
map是ConcurrentHashMap(第 36 行),不是HashMap。理由:流式响应可能在多线程环境下被填充/消费。- 第 26 行是裸 class,没有
implements ResponseMetadata——该接口由子类实现(ChatResponseMetadata extends AbstractResponseMetadata implements ResponseMetadata)。 - 第 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 的区别:
AbstractResponseMetadata | MutableResponseMetadata | |
|---|---|---|
implements ResponseMetadata | ❌(子类实现) | ✅ |
| 写入方法 | ❌(只有继承的 map 可被子类写) | ✅ put() / clear() / computeIfAbsent() |
| 暴露原始 map | ❌ | ✅ getRawMap() |
| 使用者 | ChatResponseMetadata、EmbeddingResponseMetadata | ImageResponseMetadata |
为什么 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→ }
五个字段,全部有非空默认值:
| 字段 | 类型 | 默认值 |
|---|---|---|
id | String | "" |
model | String | "" |
rateLimit | RateLimit | new EmptyRateLimit() |
usage | Usage | new EmptyUsage() |
promptMetadata | PromptMetadata | PromptMetadata.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 = 0getCacheReadInputTokens()/getCacheWriteInputTokens()不覆盖 ⇒ 走 default ⇒nullgetNativeUsage()返回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(重置) |
|---|---|---|---|
| requests | getRequestsLimit() | getRequestsRemaining() | getRequestsReset() |
| tokens | getTokensLimit() | 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);
| # | 方法 | 行号 | 抽象? |
|---|---|---|---|
| 1 | EmbeddingResponse call(EmbeddingRequest) | 41-42 | ✅ 抽象 |
| 2 | default float[] embed(String text) | 49-53 | ❌ |
| 3 | float[] embed(Document document) | 60 | ✅ 抽象 |
| 4 | default @Nullable String getEmbeddingContent(Document) | 73-76 | ❌ |
| 5 | default List<float[]> embed(List<String> texts) | 83-90 | ❌ |
| 6 | default List<float[]> embed(List<Document>, EmbeddingOptions, BatchingStrategy) | 102-118 | ❌ |
| 7 | default EmbeddingResponse embedForResponse(List<String>) | 125-128 | ❌ |
| 8 | default 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,别再改回去"。
算法要点:
- 逐个累加
tokenCount,超过阈值就开新批次(152-156 行)。 - 单个文档超限直接抛异常(147-150 行),不做切分——切分是
TokenTextSplitter的职责,不是批处理策略的职责。职责分离得很清楚。 - 第 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→ }
这是一个三层优化:
- 查表优先:classpath 下
/embedding/embedding-model-dimensions.properties记录了已知模型的维度(比如text-embedding-3-small=1536)。命中就直接返回,零网络调用。 - 实测兜底:未命中才
embed(dummyContent)。 - 缓存结果:
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 的真实新增点,值得记住):
| 类/方法 | 位置 |
|---|---|
StreamingTranscriptionModel | audio/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 |
DefaultToolCallingManager | model/tool/DefaultToolCallingManager.java:150 |
ToolCallLimitBehavior | model/tool/ToolCallLimitBehavior.java:24 |
ToolCallLimitExceededException | model/tool/ToolCallLimitExceededException.java:38 |
ToolCallLimits | model/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 |
|---|---|---|---|
ChatModel | throw(历史包袱) | 调用时同步 | ❌ 抓不到 |
StreamingTranscriptionModel | Flux.error(新写法) | 订阅时异步 | ✅ 能抓到 |
StreamingTextToSpeechModel | 无 default | 编译期 | — 必须实现 |
写容错代码时必须分情况,不要假设行为一致。
结论七:契约与实现存在系统性裂缝
这是本章最"不客气"的一条。2.0.1 里 ModelResponse.getResult() 的 @Nullable 契约并没有被所有实现兑现:
| 实现 | getResult() 空列表行为 | 符合 @Nullable? |
|---|---|---|
ChatResponse | return null | ✅ |
ImageResponse | return null | ✅ |
EmbeddingResponse | Assert.notEmpty → 抛 IllegalArgumentException | ❌ |
TextToSpeechResponse | get(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() 当立)。