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

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 的门面,是这样一个文件:

java 复制代码
// 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 里记录了一次早期更名:

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 源码读得似是而非,根源就在这里没建立起来。

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

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

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

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

java 复制代码
// spring-ai-model/src/main/java/org/springframework/ai/model/ResultMetadata.java:29
29→public interface ResultMetadata {
30→
31→}
java 复制代码
// 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 契约

java 复制代码
// 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 个字符里藏着的架构决策

java 复制代码
// 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>,所以:

java 复制代码
Flux<ChatResponse> stream(Prompt prompt);

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

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

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

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

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

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

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

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

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

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

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

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

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

java 复制代码
Flux<Generation> stream(Prompt prompt);   // 假想

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

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

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

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


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

java 复制代码
// 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:

java 复制代码
// 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 行,每一段都有话要说

java 复制代码
// 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 行:双继承模式

java 复制代码
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 接口:

java 复制代码
// 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→}
java 复制代码
// 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 的抽象方法

java 复制代码
@Override
ChatResponse call(Prompt prompt);

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

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

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

java 复制代码
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 行:两个便捷方法,与一个"保守标注"

java 复制代码
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 重命名之一:

java 复制代码
/**
 * 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:

adoc 复制代码
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() 的默认实现是:

java 复制代码
return ChatOptions.builder().build();

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

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

java 复制代码
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 实现来屏蔽掉这个抽象方法。

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

java 复制代码
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 处命中,而且是在测试类里:

bash 复制代码
spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChainTests.java:85

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

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

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

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

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


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

java 复制代码
// 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 实现:

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

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

java 复制代码
@Test
void advisorShouldSeeEmptyResponse() {
    StreamingChatModel stub = prompt -> Flux.empty();
    // ...
}

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

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

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

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

java 复制代码
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 "兜住"的命运

java 复制代码
@Override
Flux<ChatResponse> stream(Prompt prompt);

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

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

但要注意一个陷阱:

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

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


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

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

方式一:ChatModel ------ 同步 throw

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

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

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

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

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

正确写法只能是:

java 复制代码
try {
    return model.stream(prompt);
} catch (UnsupportedOperationException e) {
    return Flux.just(fallback);
}

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

java 复制代码
// 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 操作符有效:

java 复制代码
model.stream(prompt)
    .onErrorResume(e -> Flux.just(fallback));   // ✅ 能抓到

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

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

java 复制代码
// 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:

java 复制代码
// audio/transcription/StreamingTranscriptionModel.java:33
33→	 * @since 2.0.1

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

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

adoc 复制代码
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 给使用者的实践建议

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

java 复制代码
// 对 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 完整结构

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

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

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

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

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

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

java 复制代码
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,它是独立的顶层类:

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

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

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

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

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

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

看第 32 行和第 34 行:

java 复制代码
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 的四个查询方法

java 复制代码
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:

adoc 复制代码
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 给出了答案:

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

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

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

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

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

java 复制代码
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 行):

java 复制代码
return "Generation[" + "assistantMessage=" + this.assistantMessage + ", chatGenerationMetadata="
        + this.chatGenerationMetadata + ']';

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

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

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

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

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

java 复制代码
// 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 输出,就是从这里来的。建议:

java 复制代码
// 不要这样
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 的一处"越界"

java 复制代码
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:手工合并的痕迹

java 复制代码
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

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

三点:

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

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

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

java 复制代码
// 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 存在的意义是惰性求值:

java 复制代码
// 这样即使 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 完整源码

java 复制代码
// 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 表示"没有",而是用一个行为合理的空对象。

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

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

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

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

java 复制代码
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() 的静默忽略

java 复制代码
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 日志)。这是一个容易被忽略的行为:

java 复制代码
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() 与一个日志可读性缺陷

java 复制代码
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 并打印它,得到的是:

yaml 复制代码
{ 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 接口定义

java 复制代码
// 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:

adoc 复制代码
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:一个真不可变类

java 复制代码
// 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 基本类型,其余五个是包装类型。为什么?看构造器:

java 复制代码
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 行):

java 复制代码
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 行):

java 复制代码
* 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 个方法

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

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


2.12 RateLimit:从 SimpleDateFormat 到 Duration 的现代化

2.12.1 六方法两组结构

java 复制代码
// 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:全部返回零

java 复制代码
// 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:

java 复制代码
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 的优雅设计

java 复制代码
// 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 就直接实现了这个接口,零额外类、零匿名内部类。

java 复制代码
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:一处类型不安全

java 复制代码
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:

java 复制代码
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:结果级元数据的形态

java 复制代码
// 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() 是本章唯一"信息收敛"的范例:

java 复制代码
// 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 不存在。

正确路径是:

java 复制代码
// 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 个方法

java 复制代码
// 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() 默认实现会真的发起远程调用

java 复制代码
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 的一个实用新增

java 复制代码
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 的顺序保证

java 复制代码
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 里也写明了:

java 复制代码
// 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() 抛异常:一处契约违背

java 复制代码
// 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。

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

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

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

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

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

java 复制代码
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()

java 复制代码
// 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(一处瑕疵)

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

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

java 复制代码
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 章)的基础。

java 复制代码
// 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 修复的注释

java 复制代码
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 数(避免重复计算),结果:

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

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

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

算法要点:

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

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


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

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

这是一个三层优化:

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

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

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

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

java 复制代码
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 实现

java 复制代码
// 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 化石

java 复制代码
// 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

java 复制代码
// 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

java 复制代码
// 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 不存在。 正确名字是:

java 复制代码
// 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 完全同构的双继承

java 复制代码
extends Model<AudioTranscriptionPrompt, AudioTranscriptionResponse>, StreamingTranscriptionModel

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

java 复制代码
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:一个单结果模型

java 复制代码
// 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 的接口

java 复制代码
// 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 包也不存在。

正确的是:

java 复制代码
// 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 记录了完整清单:

adoc 复制代码
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 改造

java 复制代码
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):

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

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

2.19.3 call(String) 的双层 null 防护

java 复制代码
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 的两处问题

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

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

java 复制代码
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

java 复制代码
// 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 与空标记接口

java 复制代码
// 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→}
java 复制代码
// 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 复核命令

shell 复制代码
# 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() 当立)。

相关推荐
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 07 章 RAG 模块化架构:RetrievalAugmentationAdvisor流水线与 Query体系
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 09 章 工具调用(上):`@Tool` 注解、ToolDefinition 与 JSON Schema 生成
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 01 章 全景:模块拓扑、依赖层次与设计哲学
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 05 章 Advisor 架构:接口体系、栈式链与 context 传播
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 04 章 `ChatClient` 全流程:Builder、两条链路与结构化输出
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 08 章 RAG 组件逐个拆解:实现细节与八个可投产组件
java·人工智能·开源
Ward的AI产品实战2 小时前
多个订阅接多个 Agent 怎么管?我从 CC-Switch 迁移到了开源 AI 网关 Magpie(上篇)
开源·ai编程
匠测AI说2 小时前
AI for Testing 提效实战 · 执行自动化(五):这次该跑哪些用例,让 AI 圈回归范围
人工智能·测试