源码小说CodeStory
登录注册
上一章

第二章 · 国书与回函:Prompt/Message 体系与 ChatResponse

下一章
字体

主题

版式

16,955 字 · 约 42 分钟

平⼼⽽论,Spring AI 把丑话写在 Javadoc ⾥了——"by default, this method will call the remote endpoint""recommended to override this method"。各家驻馆翻译官(具体 EmbeddingModel 实现)确实⼤多覆盖了它、直接返回硬编码维度,避开这⼀⼑。但我还是要给这个默认设计记⼀笔批评:把⼀个有真实⽹络副作⽤的操作,藏在⼀个签名看起来零成本的 default int dimensions() 背后,本身就是⼀种契约上的"骗术"。 注释能提醒⼈类,却拦不住 IDE ⾃动补全时那只⼿。

更稳妥的做法,是让 dimensions() 成为⼀个⽆默认实现的抽象⽅法、逼每个实现显式给出维度;或者退⼀步,⾄少把⽅法名改成 probeDimensions() 之类带"探测"语义的名字,让副作⽤写在脸上。现在这样,是把"别踩"的责任全推给了调⽤⽅的阅读⾃觉——⽽我们都知道,没⼈读 Javadoc。

把这⼀颗暗礁记牢:在 Spring AI ⾥,"看起来像 getter 的 default ⽅法"未必真的便宜。 这是这套抽象为了"降⻔槛"付出的、不算⼩的隐性代价。

七、顺⼿再点两处粗糙夸了⼀路泛型之美,临收尾,再客观补两处不那么漂亮的地⽅,省得这章读着像软⽂。

其⼀,泛型噪⾳确实重。 Model<TReq extends ModelRequest<?>, TRes extends ModelResponse<?>> 这种"泛型套通配符再套上下界"的签名,初读⻔槛真不低。它换来的是⽆可挑剔的类型安全和可移植性,但也意味着——你想顺着继承链往上读懂⼀个模型实现,得先在脑⼦⾥把好⼏层尖括号拆开。这是 Spring AI为"⼀套抽象吃遍所有模态"交的学费,值,但不便宜。

其⼆,过渡期的痕迹散落在接⼝⾥。⽐如 ChatModel ⾥ getOptions() ( @since 2.0.0 )和被 @Deprecated(forRemoval = true) 标记的旧

getDefaultOptions() 并排站着( ChatModel.java:52-62 ),后者只是转调前者过个场。这是 2.0.0 演进留下的"新旧之变"的疤——可以理解,但⼀个号称"地基"的接⼝⾥带着待删⽅法,总归是块不够利落的补丁。

这两笔不影响⼤局。但记住它们,你对这套抽象的认识才是⽴体的:它优雅,可它的优雅是有重量、有历史包袱的优雅,不是凭空飘着的完美。

🎯 三句带⾛

1. 顶层只有两个根接⼝: Model<TReq,TRes>.call() 同步、 StreamingModel<TReq,TResChunk>.stream() 返 Flux ;流式块本身也是⼀个完整

ModelResponse ( Model.java:38 、 StreamingModel.java:41 )。

2. 三件套契约固定: ModelRequest (instructions + 可空 options)→ ModelResponse (⾸个/全部 result + 总账)→ ModelResult (output + ⼩账);

ModelOptions / ResultMetadata 是空标记接⼝,具体语义下放各模态( ModelRequest.java 、 ModelResponse.java 、 ModelOptions.java:29 )。

3. 各模态只是"填尖括号":ChatModel=同步+流式⼆合⼀、EmbeddingModel 只同步、ImageModel 是 @FunctionalInterface ;
call(String) / embed(String) 等 default ⽅法降⻔槛,但 EmbeddingModel.dimensions() 默认实现会真发⼀次远程 embed,慎⽤

( ChatModel.java:30 、 EmbeddingModel.java:39/137-139 、 ImageModel.java:22 )。

承上启下这⼀章,我们把那块只有⼀⾏字的⽯碑——"通⽤⽂牒"——彻底拆开了,看清了它如何⽤两个泛型参数收编所有番邦、⼜如何让聊天/嵌⼊/画图三种模态⻓成三胞胎。但你⼀定注意到了:聊天模态填进尖括号的那对类型, Prompt 和 ChatResponse ,我们只点了名,没开箱。

⽂牒的格式定下来了,可"⽂牒上到底该写什么字"还没说。你递给番邦的那份 Prompt ,是怎么把"系统训谕""客官问话""模型先前的答复""差役办差的回执"这四种身份的话,捆成⼀封能让模型读懂的国书的?它递回来的 ChatResponse ,⼜是怎么把"答了什么""耗了多少墨""为什么收笔"装进⼀封回函的?

下⼀章——「第⼆章 · 国书与回函:Prompt/Message 体系与 ChatResponse」——我们就⾛进誊⽂房,看春芽译馆怎么起草国书、怎么拆读回函。带上这⼀章的三件套,你会发现那⼀切,不过是 ModelRequest 和 ModelResponse 在聊天模态⾥穿上了戏服。

第⼆章 · 国书与回函:Prompt/Message 体系与 ChatResponse"我不是⼀句话。我是⼀摞封了印的信函,按先后排好,递到番邦案前。" —— Prompt 的⾃述上⼀章我们摸清了译馆的⻣架: Model<TReq,TRes> ⼀纸通⽤⽂牒⾛天下,请求进、响应出。可那条泛型链终究是抽象的"形"。这⼀章要落到⾎⾁——客官递进去的那封国书到底⻓什么样?番邦回过来的那纸回函⼜是怎么拆封的?

先抛个问题给你:在 Spring AI ⾥,⼀句"帮我写⾸诗",和⼀段三⽅对话(系统设定 + ⽤户问 + 助⼿答),⽤的是同⼀个类型吗?

答案是:是。⽽且这个"是",正是译馆全部对话能⼒的地基。

⼀、⼀切皆 Content:信纸的纤维在拆国书之前,得先认得"纸"。译馆⾥所有能装⽂字的东⻄——⽆论是你递进去的消息,还是藏经阁⾥的典籍( Document )——都⻓在同⼀种纤维上,叫 Content 。它在 commons 模块,简单到只有两个⽅法: getText() 取正⽂、 getMetadata() 取附笺。

往上⼀层叠出 MediaContent ,加了⼀个"夹带附件"的能⼒:

public interface MediaContent extends Content {

/**

* Get the media associated with the content.

*/

List<Media> getMedia();
}

spring-ai-commons/.../content/MediaContent.java:21为什么要分这两层?因为不是所有信都能夹图。系统训谕(SystemMessage)是⼀张纯⽩训诫,不该塞图⽚;⽽⽤户的问、助⼿的答,可能带着图、带着⾳频、带着 PDF。

于是译馆让能夹附件的消息去 implements MediaContent ,不能的就⽼实待在 Content 这⼀层。这是⼀种很克制的接⼝分层——能⼒按需上身,不搞"⼈⼈都有getMedia 然后多半返回空列表"的虚胖。

到了对话场景, Content 再往上⻓⼀节就成了 Message :在内容之上,多问⼀句"你是谁发的"—— getMessageType() 。这就引出了译馆国书的四种封印。

⼆、四种封印:USER / ASSISTANT / SYSTEM / TOOL⼀封国书,递信⼈有四种身份。 MessageType 这个枚举把它们钉死:

public enum MessageType {
USER("user"),
ASSISTANT("assistant"),
SYSTEM("system"),
TOOL("tool");

// ...

}

spring-ai-model/.../chat/messages/MessageType.java:30-52对应到译馆的隐喻: SYSTEM 是馆主⽴下的训谕(⾼层⼈设/格式约束), USER 是客官的客⾔(真正的提问), ASSISTANT 是番邦的馆答, TOOL 是差役办完事回来的回执。每个枚举常量都揣着⼀个⼩写字符串( "user" / "system" …),这串字符不是装饰——它就是⽇后翻译官递给番邦 API 时的 role 字段原值。译馆早早把"⻆⾊"这件跨⼚商通⽤的事,收敛成了⼀个枚举。

⚠ 暗礁:枚举叫 TOOL ,⽂档却还喊它 "FUNCTION"。 翻到这个⽂件最顶上的类注释,赫然写着:

/**

* Enumeration representing types of {@link Message Messages} in a chat application. It

* can be one of the following: USER, ASSISTANT, SYSTEM, FUNCTION.

*/

spring-ai-model/.../chat/messages/MessageType.java:19-21枚举⾥压根没有 FUNCTION 这个常量,它⽼早改名叫 TOOL 了——这是 OpenAI 当年 function calling 改叫 tool calling 的历史回声。常量改了,Javadoc没跟上。第 52 ⾏那个 TOOL 上⽅的注释还固执地说 "type {@literal function}"。这不是 bug,但它是⼀处化⽯:提醒你 Spring AI 这套抽象是从 function-calling 时代⼀路演化过来的,某些⻆落的漆没补全。读源码时遇到 "FUNCTION/function" 的字眼,⼼⾥要换算成今天的 "TOOL"。

三、AbstractMessage:递信前的安检⻔四种消息共⽤⼀个抽象基类 AbstractMessage ,它⼲两件事:存(类型/正⽂/元数据),以及——安检。

protected AbstractMessage(MessageType messageType, @Nullable String textContent, Map<String, Object> metadata) {
Assert.notNull(messageType, "Message type must not be null");
if (messageType == MessageType.SYSTEM || messageType == MessageType.USER) {
Assert.notNull(textContent, "Content must not be null for SYSTEM or USER messages");
}
Assert.notNull(metadata, "Metadata must not be null");
this.messageType = messageType;
this.textContent = textContent;
this.metadata = new HashMap<>(metadata);
this.metadata.put(MESSAGE_TYPE, messageType);
}

spring-ai-model/.../chat/messages/AbstractMessage.java:63-73这⾥有个⾮对称的校验规则值得停⼀秒:SYSTEM 和 USER 的正⽂不许为 null,但 ASSISTANT 和 TOOL 可以。为什么?因为⼈(系统/⽤户)发话,没理由发个空内容;⽽ASSISTANT 完全可能"只派差役、⼀个字不说"——它的回复正⽂是 null、 toolCalls 才是⼲货(这正是⼯具调⽤的典型形态)。TOOL 消息的正⽂则被实现类直接钉成空串(下⾯会看到)。译馆把"哪种⻆⾊允许没正⽂"这件事,编进了构造器的硬约束⾥。

🦴 ⻣灰级细节: messageType 被悄悄塞进了 metadata。 看最后⼀⾏ this.metadata.put(MESSAGE_TYPE, messageType) ——构造时把消息类型再抄⼀份进

metadata map,key 是常量 "messageType" 。明明已经有 getMessageType() 强类型访问器了,为什么还要往 map ⾥冗余⼀份?因为下游有些只认Map<String,Object> 的通⽤序列化/模板渲染路径(⽐如把消息摊平成 map 喂给 prompt 模板),它们没法调⽤强类型⽅法,只能从 map ⾥捞。这是"强类型 API +开放 map"双轨制在消息层的⼜⼀次现身——和上⼀章 metadata 那套开放槽是同⼀个设计哲学。代价是:同⼀份信息存了两处, equals / hashCode 也得把metadata 算进去( :110-116 ),改 type 必须两边同步,否则就埋雷。

还有⼀处藏在 :71 : this.metadata = new HashMap<>(metadata) ——传进来的 map 被拷⻉了⼀份,⽽不是直接引⽤。外⾯再怎么改原 map,也动不了消息内部状态。不可变性的⼩动作,做得很细。

四、四种消息类:同⼀副⻣架,四副表情UserMessage / SystemMessage —— 最朴素的两封UserMessage 是客官的问, implements MediaContent (可夹图); SystemMessage 是纯⽂本训谕,不实现 MediaContent(系统设定夹图没意义)。两者都给了copy() / mutate() 和 Builder,⾛的是上⼀章⻅过的"不可变 + 派⽣新对象"套路。 UserMessage 的 Builder 还做了⼀道互斥校验:⽂本和 Resource (从⽂件读内容)不能同时设,否则报错——免得你既给字符串⼜给⽂件、谁也说不清以谁为准。这块不展开,记住⼀句:⼈发的话,正⽂不可空,且可带媒体(系统消息除外)。

AssistantMessage —— 番邦的回话,可能"⾔不及义只派差"

这是四封信⾥信息量最⼤的⼀封。它 implements MediaContent ,但真正的看点是 toolCalls :

public class AssistantMessage extends AbstractMessage implements MediaContent {
private final List<ToolCall> toolCalls;
protected final List<Media> media;
public AssistantMessage(@Nullable String content) {
this(content, Map.of(), List.of(), List.of());
}

// ...

public boolean hasToolCalls() {
return !CollectionUtils.isEmpty(this.toolCalls);
}

// ...

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

spring-ai-model/.../chat/messages/AssistantMessage.java:41-104注意 content 上的 @Nullable ——呼应上⼀节的校验:助⼿可以⼀字不答。⽽ ToolCall 是个 record:番邦不会真的去执⾏函数,它只是回⼀张"申请单"——id (这次调⽤的编号)、 type 、 name (要调哪个差役)、 arguments (⼀段 JSON 字符串形式的参数)。

这⾥必须点破隐喻边界:我们把⼯具叫"差役",听上去像番邦能直接使唤⼈。但 ToolCall 是 record、是纯数据, arguments 也只是没解析的字符串。番邦从头到尾没有执⾏任何东⻄,它只是"申请派⼀个叫 X 的差役、带这些参数"。真正派差、办差、把回执塞回去再请番邦续写的整套调度,是译馆这边(2.0 ⾥已上移到 ChatClient的 ToolCallingAdvisor )⼲的活——后⾯讲⼯具回路的章节会专⻔拆。这⼀章你只需记住: AssistantMessage ⾥的 toolCalls 是意图,不是执⾏。

hasToolCalls() ( :64 )就是问⼀句"这封馆答⾥有没有夹申请单",这是后⾯判断"要不要进⼯具回路"的总开关。

它的 Builder ⽤了⾃递归泛型 Builder<B extends Builder<B>> ( :106 ), self() 强转返回 B ( :119-122 )。这副"绕"的写法是为了让⼦类(⽐如某个 provider

扩展的 AssistantMessage)继承 Builder 后,链式调⽤仍能返回⼦类型⽽不丢类型信息——上⼀章在 ChatOptions.Builder ⻅过同⼀招(CRTP),全书反复出现。

ToolResponseMessage —— 差役回执,正⽂恒空差役办完事,结果得回填给番邦。这就是 ToolResponseMessage :

public class ToolResponseMessage extends AbstractMessage {
protected final List<ToolResponse> responses;
protected ToolResponseMessage(List<ToolResponse> responses, Map<String, Object> metadata) {
super(MessageType.TOOL, "", metadata);
this.responses = responses;
}

// ...

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

spring-ai-model/.../chat/messages/ToolResponseMessage.java:33-77

🦴 ⻣灰级细节:它把正⽂硬编码成 "" ,不是 null。 第 38 ⾏ super(MessageType.TOOL, "", metadata) ——TOOL 消息的 textContent 永远是空串。回想第

三节的校验:TOOL 不在"正⽂不可为 null"的名单⾥,本可以传 null,这⾥却主动传了空串。为什么不偷懒传 null?因为差役的真正产出全在 responses 这个List<ToolResponse> ⾥,正⽂字段对 TOOL 来说没有语义;给它⼀个稳定的空串⽽⾮ null,能让下游所有" getText() 然后 append"的拼接逻辑(⽐如把整段对话摊成纯⽂本)免去⼀次空判、不会炸 NPE。⼀个空串换来全链路省⼼,这是有意为之的"防御性默认值"。

ToolResponse 同样是 record: id (对上前⾯那张申请单的编号)、 name (差役名)、 responseData (办差结果,字符串)。 id 是缝合点——番邦靠它把"我申请的第⼏号差"和"这是第⼏号差的结果"对上号。注意它和 AssistantMessage.ToolCall 是两个不同的 record(⼀个是申请、⼀个是回执),字段相似但别混。

五、Prompt:不是⼀句话,是⼀摞排好序的信四种信集⻬,该装信封了。 Prompt 就是那个信封——⽽它的真身,⼀⾏类声明说尽:

public class Prompt implements ModelRequest<List<Message>> {
private final List<Message> messages;
private final @Nullable ChatOptions chatOptions;

spring-ai-model/.../chat/prompt/Prompt.java:46-50回扣上⼀章: ModelRequest<T> 的 getInstructions() 返回"指令"。在 Chat 场景,这个 T 被特化成 List<Message> ——译馆眼⾥,"指令"天然就是⼀摞有序的消息,⽽不是⼀根光秃秃的字符串。再加⼀个可选的 chatOptions (本次请求的通⽤条款,下⼀章主⻆)。整个 Prompt 就两个字段,⼲净得过分。

它给了⼀⼤把便捷构造器,体贴到极点:

public Prompt(String contents) {
this(new UserMessage(contents));
}

spring-ai-model/.../chat/prompt/Prompt.java:52-54回答开篇的问题——"帮我写⾸诗"为什么和三⽅对话是同⼀个类型?喏, Prompt(String) 在内部把你那句话裹成⼀个 UserMessage ,再塞进单元素列表。简单场景⼀⾏字符串、复杂场景⼀摞消息,共⽤同⼀个 Prompt ,这就是译馆"⼀种类型吃透对话"的底⽓。 List<Message> / Message... /带 ChatOptions 的各种重载( :56-81 )铺满了从最懒到最讲究的所有⽤法。

Prompt 还⾃带⼀组导航助⼿,像信封上的索引签: getSystemMessage() 找第⼀封训谕(找不到返⼀个空 SystemMessage ,空对象兜底、绝不返 null, :105-113 )、getUserMessage() 取最后⼀条客⾔( :119 )、 getLastUserOrToolResponseMessage() 取最后⼀条"⽤户或差役回执"( :133 )。这个"最后⼀条"的⽅向性很要紧:多轮对话⾥,真正要番邦回应的总是最新那⼀句。

不可变的 augment:动⼀封信,换⼀整封信封Prompt 最讲究的地⽅,是它的"增强"全是不可变的。⽐如 RAG ⾥要往系统消息塞检索到的上下⽂:

public Prompt augmentSystemMessage(Function<SystemMessage, SystemMessage> systemMessageAugmenter) {
var messagesCopy = new ArrayList<>(this.messages);
boolean found = false;
for (int i = 0; i < messagesCopy.size(); i++) {
Message message = messagesCopy.get(i);
if (message instanceof SystemMessage systemMessage) {
messagesCopy.set(i, systemMessageAugmenter.apply(systemMessage));
found = true;
break;
}
}
if (!found) {
messagesCopy.add(0, systemMessageAugmenter.apply(new SystemMessage("")));
}
return new Prompt(messagesCopy, this.chatOptions);
}

spring-ai-model/.../chat/prompt/Prompt.java:231-248它绝不原地改:先 new ArrayList<>(this.messages) 拷⼀份消息列表,替换其中那封系统消息(找不到就在最前⾯新插⼀封),最后 new Prompt(...) 还你⼀个全新 Prompt。原 Prompt 纹丝不动。这意味着同⼀个 Prompt 能被多个 Advisor、多个线程安全地传来传去,谁要改谁就拿⾛⼀个副本——这正是后⾯驿丞责任链(Advisor)能层层增强、互不踩踏的前提。 augmentUserMessage ( :264 )对最后⼀条客⾔做同样的事。

⚠ ⼀处要点破的不对称:augmentSystemMessage 改的是"第⼀封",augmentUserMessage 改的是"最后⼀封"。 系统消息从头找、命中即停(改第⼀条);⽤户消息

从尾往前找(改最后⼀条)。语义上说得通——⼈设在最前、最新问题在最后——但这套"⽅向不⼀致"是隐含约定,⽅法名augmentSystemMessage / augmentUserMessage 本身没写明"第⼀/最后",得读循环⽅向才知道。顺带⼀提, :251-257 那个接受字符串的augmentSystemMessage 注释⾥还写着 "the last system message",⽽代码实质改的是第⼀条——注释⼜⼀次没跟上代码(本章第⼆处"⽂档与实现对不⻬"),读时以代码为准。

还有个细节: copy() 背后的 instructionsCopy() ( :196-224 )对四种消息类型逐⼀深拷⻉,遇到不认识的消息类型直接 throwIllegalArgumentException ( :219 )。它不肯对未知类型"尽⼒⽽为",宁愿炸出来——快速失败,免得拷出个半残的 Prompt 在系统⾥飘。

六、回函:ChatResponse → Generation → Usage国书递出去,番邦回来的是 ChatResponse 。它的形状,是上⼀章那条三件套链的精确特化:

ChatResponse → List<Generation> → AssistantMessage(每条 Generation 的 output)

(回函) (⼀封封回话) (馆答正⽂ + 可能的差役申请)

public class ChatResponse implements ModelResponse<Generation> {
private final ChatResponseMetadata chatResponseMetadata;
private final List<Generation> generations;

// ...

public ChatResponse(List<Generation> generations, ChatResponseMetadata chatResponseMetadata) {
Assert.notNull(generations, "'generations' must not be null");
this.chatResponseMetadata = Objects.requireNonNullElse(chatResponseMetadata, new ChatResponseMetadata());
this.generations = List.copyOf(generations);
}

spring-ai-model/.../chat/model/ChatResponse.java:41-70List.copyOf ⼀上来就把 generations 冻成不可变( :69 ),metadata 为 null 时兜⼀个空对象( :68 )。为什么是 List ?因为⼀次请求可以让番邦给多个候选回答

(n>1),每个候选就是⼀条 Generation 。 getResult() 返第⼀条、空列表返 null( :92-97 ); getResults() 返全部。

每条 Generation 是 ModelResult<AssistantMessage> ——它的"产出"就是上⾯那封 AssistantMessage 。它对元数据做了空对象兜底:

public Generation(AssistantMessage assistantMessage) {
this(assistantMessage, ChatGenerationMetadata.NULL);
}

spring-ai-model/.../chat/model/Generation.java:36-38

ChatGenerationMetadata ⾥藏着结果级的关键信息,最要紧的是 getFinishReason() (收笔缘由: stop / length / tool_calls …)。

回函上还有两个语义助⼿,直接⻓在 ChatResponse 上:

public boolean hasToolCalls() {
if (CollectionUtils.isEmpty(this.generations)) {
return false;
}
return this.generations.stream().anyMatch(generation -> generation.getOutput().hasToolCalls());
}

spring-ai-model/.../chat/model/ChatResponse.java:111-116hasToolCalls() 在回函层⾯问"任何⼀条回话⾥有没有差役申请"——这是⼯具回路的总闸。 hasFinishReasons(Set) ( :121-131 )则⼤⼩写不敏感地⽐对收笔缘由,⽅便跨⼚商(有的回 STOP 、有的回 stop )统⼀判断。

Usage:耗墨账,2.0 多记了"缓存"两笔回函⾥还夹着⼀张"耗墨账" Usage ——这次烧了多少 token:

default Integer getTotalTokens() {
Integer promptTokens = getPromptTokens();
promptTokens = promptTokens != null ? promptTokens : 0;
Integer completionTokens = getCompletionTokens();
completionTokens = completionTokens != null ? completionTokens : 0;
return promptTokens + completionTokens;
}

// ...

default @Nullable Long getCacheReadInputTokens() {
return null;
}
default @Nullable Long getCacheWriteInputTokens() {
return null;
}

spring-ai-model/.../chat/metadata/Usage.java:56-92getTotalTokens() 是 default ⽅法,缺省时由 prompt + completion 现算,且两边都做了 null→0 兜底——provider 不给总数也不会炸。

✨ 爽点: getCacheReadInputTokens() / getCacheWriteInputTokens() 是 2.0.0 新增的。 注意它俩返回 Long (不是别处的 Integer ), @since 2.0.0 ,默

认返 null。这两笔账记的是"prompt 缓存"的命中读取量和写⼊量。如今 Anthropic、OpenAI 都上了 prompt caching——重复的⻓前缀可以缓存、第⼆次便宜⼤半。译馆没把这事甩给各家的 getNativeUsage() ⾃⼰消化,⽽是把缓存读写提升成了可移植的通⽤账⽬:不管你后⾯换哪家番邦,只要它⽀持缓存,你都能⽤同⼀个⽅法名读到这两笔。这就是上⼀章说的"可移植抽象主动追新能⼒"——抽象层不是定好就躺平,它在跟着 provider 的新本事⻓。

七、MessageAggregator:把碎成渣的流,缝回⼀封整信流式( stream )场景⾥,番邦不是⼀⼝⽓把整封回函甩给你,⽽是⼀个字⼀个字、⼀个 chunk ⼀个 chunk 地吐。每个 chunk 都是⼀个残缺的 ChatResponse 。可观测、记忆这些旁路系统想要的却是"⼀封完整的馆答"。谁来缝? MessageAggregator 。

它的缝法很"Reactor":挂在 Flux 的⽣命周期钩⼦上,⽤⼀堆 AtomicReference 当累积缸。

return fluxChatResponse.doOnSubscribe(subscription -> {
messageTextContentRef.set(new StringBuilder());

// ...各 ref清零

}).doOnNext(chatResponse -> {
if (chatResponse.getResult() != null) {
if (chatResponse.getResult().getOutput().getText() != null) {
messageTextContentRef.get().append(chatResponse.getResult().getOutput().getText());

// ...spring-ai-model/.../chat/model/MessageAggregator.java:80-102三步⾛: doOnSubscribe 开缸清零( :80 )、 doOnNext 把每个 chunk 的⽂本/思维链/⼯具申请/各项 usage 逐⽚追加进对应的 AtomicReference ( :94 )、doOnComplete 把所有缸⾥的料拼成⼀封终极 AssistantMessage 、包成⼀个完整 ChatResponse 交出去( :155-192 )。

🦴 ⻣灰级细节:usage 累加⽤的是"⼤于 0 才更新",防被 0 冲掉。

metadataUsagePromptTokensRef.set(
usage.getPromptTokens() > 0 ? usage.getPromptTokens() : metadataUsagePromptTokensRef.get());

spring-ai-model/.../chat/model/MessageAggregator.java:126-127流式⾥,绝⼤多数中间 chunk 的 usage 是 0(token 账往往只在最后⼀个 chunk 才结算)。如果⽆脑⽤"最新值覆盖",那最后⼀刻的真实账⽬会被前⾯成⽚的 0 反复抹掉——或者结算到了、⼜被⼀个尾随的 0 chunk 清零。这⾥⽤ > 0 ? 新值 : 旧值 的三元判断,等于说"只认有意义的⾮零值",把账牢牢攥住。⼀个不起眼的三元表达式,挡住了流式聚合最容易出的⼀类错。 id / model 同理⽤ StringUtils.hasText 判空才更新( :141-145 ), rateLimit 则跳过 EmptyRateLimit 占位符( :137-138 )——空对象模式在这⾥发挥了"可识别的占位、不会污染累积"的作⽤。

⚠ 暗礁:此处有第⼆个 DefaultUsage ,和 chat.metadata.DefaultUsage 同名不同类。 聚合完毕要造⼀个 usage 对象时:

var usage = new DefaultUsage(metadataUsagePromptTokensRef.get(), metadataUsageGenerationTokensRef.get(),
metadataUsageTotalTokensRef.get());

spring-ai-model/.../chat/model/MessageAggregator.java:157-158这个 DefaultUsage 是定义在 MessageAggregator 内部的私有 record( :210 ),三字段、实现 Usage 。⽽译馆⾥还有⼀个公开的org.springframework.ai.chat.metadata.DefaultUsage (六字段、带 Jackson 注解、能序列化)。两个类同名、同实现 Usage 、字段还不⼀样。维护时若靠IDE ⾃动 import,极易引错——你以为在⽤那个能 JSON 序列化的公开版,实际拿的是聚合器⾃带的三字段精简版(它的 getNativeUsage() 返的是⼀个⼿搓的Map ,⽽⾮原⽣对象)。这是上⼀章笔记⾥点过的粗糙处,在这⾥得到实证。批评归批评:为内部聚合搞⼀个轻量 record ⽆可厚⾮,但取名时与公开类撞⻋,是给后来的维护者埋的⼀个不必要的认知陷阱——叫 AggregatedUsage 之类本可⼀字之差避开。

缝合的最后,它按"有没有⼯具申请"分两路 build AssistantMessage ( :176-189 ):有差役申请就带上 toolCalls ,没有就只拼正⽂。思维链( thoughts ,据metadata ⾥的 isThought 标记区分)被单独抽出来塞进 messageMetadata ( :170-173 )。⼀封流式吐出来的散信,就这样被⼀字不差地缝回了整封。

🎯 三句带⾛

1. Prompt implements ModelRequest<List<Message>> ——指令本质是有序消息列表 + 可选 ChatOptions; Prompt(String) 只是把单句裹成

UserMessage ,简单与复杂场景共⽤⼀个类型。

2. 四种消息(USER/ASSISTANT/SYSTEM/TOOL)共⽤ AbstractMessage :SYSTEM/USER 正⽂不可为 null、ASSISTANT 可空(只派⼯具时)、TOOL 正⽂恒
为 "" ; AssistantMessage.ToolCall 是申请、 ToolResponseMessage.ToolResponse 是回执,靠 id 缝合。
3. 回函链 ChatResponse → List<Generation> → AssistantMessage , Usage 在 2.0 新增
getCacheReadInputTokens()/getCacheWriteInputTokens() (返 Long );流式靠 MessageAggregator ⽤ AtomicReference + Reactor 钩⼦
( doOnSubscribe/Next/Complete )聚成完整消息,usage 累加"⼤于 0 才更新"。

承上启下我们拆透了信封⾥装什么(Message)、回函怎么读(ChatResponse/Usage)。但你也许注意到, Prompt 那个字段⾥还躺着⼀位没正式登场的⻆⾊—— @NullableChatOptions chatOptions 。它就是写在国书末尾的那⼀栏条款:温度、最⼤ token、模型名……哪些是天下番邦都认的"通⽤条款"?哪些⼜是某家番邦私设的"专属条款"?构造期定好的默认参数和运⾏时随 Prompt 临时携带的参数,撞⻋了⼜听谁的?下⼀章,我们就掀开这张⽂牒的最后⼀栏——「第三章 · 通⽤条款与番邦私货:ChatOptions 与流式契约」,看译馆如何⽤⼀套⾃递归泛型 Builder,既守住"⼀纸契约⾛天下"的可移植,⼜给各路番邦留⾜夹带私货的余地。