
前言1
前三章发布后,我收到了一些反馈:内容偏晦涩,读完之后,虽然能知道代码怎么流转,但对于框架为什么这样设计、解决了什么问题,以及这些设计对实际开发有什么启发,理解还不够深入。
所以从第四章开始,这个系列会调整写法,尽量按照 5W1H 来展开。
除了讲清楚 What 和 How,更会把重点放在 Why 上,并增加对象关系图、流程图、时序图和实际案例,帮助理解不同设计之间的关系。
源码仍然会保留,但它更多作为设计结论的证据,而不是整篇文章唯一的主线。
希望后面的内容能做到:知其然也知其所以然
前言2
把 system、历史消息、当前用户输入和调用参数组装成一次模型请求,本身并不复杂。
真正值得关注的是,Spring AI 为什么没有直接把这些内容塞进一个请求对象,而是在模型调用之前,专门抽象出 Prompt、Message 和 ChatOptions 三层结构。
这三层边界解决了什么问题,又分别承担什么职责,才是理解 Spring AI 调用模型机制的关键。
Prompt更像是模型无关层与供应商适配层之间的一层协议边界。
Message、ChatOptions等对象拆分得越清楚,后续接入 Advisor、Memory、Tool Calling 和多模型适配时,各自的职责就越不容易混在一起。
本章先建立 Spring AI 的对象模型,再结合 DefaultChatClientUtils#toChatClientRequest 的源码,看这些抽象最终如何被组装成一次模型请求。
调用链只展开到能够说明设计边界的位置,模型执行部分留到后续章节。
先跑一个最小例子
java
var response = chatClient.prompt()
.system(s -> s.text("你是{role}")
.param("role", "Java 助手"))
.messages(new AssistantMessage("上一轮回答"))
.user(u -> u.text("解释 {topic}")
.param("topic", "Prompt"))
.options(OpenAiChatOptions.builder()
.temperature(0.2)
.build())
.call()
.chatClientResponse();
在 DefaultChatClientUtils.toChatClientRequest() 处打断点,最终可以看到:
| 对象 | 结果 |
|---|---|
Prompt.instructions |
SystemMessage → AssistantMessage → UserMessage |
Prompt.options |
本次请求设置的 OpenAiChatOptions |
ChatClientRequest.context |
Advisor 使用的请求级参数,不属于 Prompt |
先记住这个结论,下面回到源码,看它是如何一步步落地的。
What:三个对象分别守住什么边界
这几个对象各自解决不同的问题:
RequestSpec:服务于 Fluent API,负责分步收集调用状态;ChatClientRequest:作为 ChatClient 内部流转的请求对象;Prompt:作为ChatModel能够识别的标准输入;Message:保留角色、文本、多模态内容和工具调用结果;ChatOptions:描述本次模型调用参数;context:在 Advisor Chain 中传递请求级状态,不会直接作为模型消息发送。
因此,Prompt 既不是 Prompt Template,也不是一段普通字符串。
可以把它理解成一次模型调用的完整请求载体:Message 是内容,ChatOptions 是调用参数,而 Advisor context 则属于框架内部的执行上下文。
如果直接传 Map,会简单很多吗
短期看会更简单,长期看反而更容易失控。
直接使用 Map<String, Object>,确实可以减少类型定义和对象转换,但代价是把消息语义、模型参数、框架上下文和业务扩展字段混在同一个容器里。
| 方案 | 直接收益 | 长期代价 |
|---|---|---|
统一 Map |
接入快、字段灵活 | 边界依赖约定,重构、校验和多模型适配成本更高 |
Prompt + Message + Options |
语义清晰、类型可检查 | 对象更多,组装和转换链更长 |
Spring AI 选择后者,并不是为了增加抽象,而是希望通过类型系统把不同维度的变化隔离开。
代价也很明显:调用链中会多出若干组装和转换步骤,阅读源码时容易产生"为什么要多绕几层"的感觉。
为什么 Message 不能只是 role 加 content
Message 的核心信息可以概括为三部分:
java
public interface Message {
MessageType getMessageType();
String getText();
Map<String, Object> getMetadata();
}
不同消息再携带自己的扩展信息:
如果提前把所有内容拼成一段文本:
makefile
System: 你是 Java 助手
User: 解释 Prompt
那么原本明确的角色、多模态内容、工具调用结果等结构化信息,都会退化成普通字符串。
这样一来,底层模型适配器无法再直接识别消息语义,只能重新从文本中判断哪些是 system、哪些是 user、哪些是工具结果,这种方式既不稳定,也很难覆盖不同模型协议。
Spring AI 因此选择把消息一直保留为对象结构。到了 OpenAiChatModel、Anthropic 或其他模型适配器这一层,再分别转换成各自供应商要求的请求格式。
metadata 和 context 不是一回事
虽然两者都是 Map,看起来很接近,但它们承载的信息和生命周期并不相同:
| 位置 | 作用范围 | 典型用途 |
|---|---|---|
Message.metadata |
单条消息 | 消息来源、附件信息、供应商响应元数据 |
ChatClientRequest.context |
整次 ChatClient 请求 | conversationId、Advisor 参数、链路状态 |
context 主要用于 Advisor Chain 内部流转,并不属于 Prompt 本身。
如果把它和 Prompt 混在一起理解,后面分析 Memory、RAG 和 Tool Calling 时,很容易把调用上下文和模型输入混为一谈。
How:源码如何把零散状态变成 Prompt
上一章已经看到,call() 和 stream() 都会进入同一个转换方法:
java
DefaultChatClientUtils.toChatClientRequest(this)
主链如下:
同步调用和流式调用在这里还没有产生差异。
两者使用的是同一套请求组装逻辑,真正的分叉发生在后续的 Advisor Chain 和 ChatModel 调用阶段。
Message 顺序为什么是语义的一部分
去掉 Tool Calling 等与本章无关的分支,toChatClientRequest() 的主干可以压缩成下面这样:
java
List<Message> processedMessages = new ArrayList<>();
// 1. 渲染 system,并创建 SystemMessage
processedMessages.add(systemMessage);
// 2. 追加显式传入的 messages
processedMessages.addAll(inputRequest.getMessages());
// 3. 渲染 user,并创建 UserMessage
processedMessages.add(userMessage);
// 4. 创建 Prompt
Prompt prompt = new Prompt(processedMessages, requestOptions);
这里的核心在于对象的组装顺序,以及每一步转换发生的位置。
最终的消息顺序由转换逻辑统一确定,与 Fluent API 的书写顺序无关。
即使代码写成:
java
chatClient.prompt()
.user("最后解释")
.messages(history)
.system("你是 Java 助手");
最终组装出来的顺序仍然是:
bash
SystemMessage
→ history 中的 Message
→ UserMessage
常见误区 :链式 API 看起来像按顺序追加消息,实际上它只是在修改不同字段。真正的消息排序发发生在
toChatClientRequest()。
这个边界会直接影响消息位置的控制方式。需要精确指定顺序时,应显式构造 Message 列表,而不是依赖 Fluent API 的调用先后。
system 和 user 在这里才完成模板渲染
调用 system() 和 user() 时,保存的是原始文本、模板变量、metadata 和 media 等信息,实际的模板渲染发生在请求组装阶段。
以 system 为例,核心逻辑可以简化为:
java
String text = inputRequest.getSystemText();
if (StringUtils.hasText(text)
&& !CollectionUtils.isEmpty(inputRequest.getSystemParams())) {
text = PromptTemplate.builder()
.template(text)
.variables(inputRequest.getSystemParams())
.renderer(inputRequest.getTemplateRenderer())
.build()
.render();
}
SystemMessage message = SystemMessage.builder()
.text(text)
.metadata(inputRequest.getSystemMetadata())
.build();
user 的处理方式相同,但创建的是 UserMessage,还会带上 media。
因此,在 user() 或 param() 阶段打断点时,看到的仍然可能是未渲染的模板内容。
模板变量缺失、渲染失败等问题,也通常会在请求组装阶段才暴露出来。
为什么 Options 要与消息分离
Message 描述发送给模型的内容,ChatOptions 描述这次调用应如何执行。
ChatOptions 定义了一组供应商无关的公共参数,例如 model、temperature、maxTokens;具体模型实现再通过 OpenAiChatOptions 等扩展类型,补充各自协议特有的参数。
这种拆分让消息内容保持稳定,同时把模型调用策略独立出来,便于不同供应商之间复用和适配。
这里需要区分两个不同的 Options 合并阶段。
toChatClientRequest() 负责把 RequestSpec 中已经收集到的 Options 构建出来,并写入 Prompt。
模型默认参数与本次请求参数的最终合并,则发生在后续的 ChatModel 调用阶段。
这条 Options 处理链可以分成两个阶段:
- ChatClient 阶段 :默认
RequestSpec与本次options()按字段组合,生成Prompt.options; - ChatModel 阶段 :具体模型实现继续处理模型默认配置、运行时
Options和供应商专属参数。
第二个阶段放到第六章再展开。
这里先明确边界:Prompt.options 表示传入 ChatModel 的请求级参数,并不是供应商最终收到的完整参数对象。
三个关键设计结论
Prompt可以理解为一次模型调用的请求快照。进入执行链后,本次调用使用的消息和参数已经确定,后续对 Builder 或业务上下文的修改不会同步到当前Prompt。Message的类型和顺序本身就是协议的一部分。保留对象结构,才能稳定承载多模态、Tool 消息以及后续的供应商协议转换。ChatOptions用于描述模型调用参数。Memory、权限、租户等业务上下文更适合放在 Advisor Context 或业务层,避免模型参数与业务状态混在一起。
这套设计更值得借鉴的地方,是按照变化原因划分边界,再通过类型系统限制错误组合。
下一章我们继续看另一个容易产生疑问的地方:Prompt 已经准备完成,为什么调用 call() 后还不会直接进入模型?