Spring AI 2.0 源码解析(四):Prompt、Message、Options 的对象模型

前言1

前三章发布后,我收到了一些反馈:内容偏晦涩,读完之后,虽然能知道代码怎么流转,但对于框架为什么这样设计、解决了什么问题,以及这些设计对实际开发有什么启发,理解还不够深入。

所以从第四章开始,这个系列会调整写法,尽量按照 5W1H 来展开。

除了讲清楚 What 和 How,更会把重点放在 Why 上,并增加对象关系图、流程图、时序图和实际案例,帮助理解不同设计之间的关系。

源码仍然会保留,但它更多作为设计结论的证据,而不是整篇文章唯一的主线。

希望后面的内容能做到:知其然也知其所以然

前言2

system、历史消息、当前用户输入和调用参数组装成一次模型请求,本身并不复杂。

真正值得关注的是,Spring AI 为什么没有直接把这些内容塞进一个请求对象,而是在模型调用之前,专门抽象出 PromptMessageChatOptions 三层结构。

这三层边界解决了什么问题,又分别承担什么职责,才是理解 Spring AI 调用模型机制的关键。

Prompt 更像是模型无关层与供应商适配层之间的一层协议边界。

MessageChatOptions 等对象拆分得越清楚,后续接入 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:三个对象分别守住什么边界

flowchart TD A[&#34;DefaultChatClientRequestSpec<br>可变的请求状态&#34;] --> B[&#34;DefaultChatClientUtils<br>统一转换&#34;] B --> C[&#34;ChatClientRequest&#34;] C --> D[&#34;Prompt<br>发给模型的输入&#34;] C --> E[&#34;Context<br>Advisor 链路状态&#34;] D --> F[&#34;Message List<br>角色与内容&#34;] D --> G[&#34;ChatOptions<br>本次调用参数&#34;]

这几个对象各自解决不同的问题:

  • 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();
}

不同消息再携带自己的扩展信息:

classDiagram Message <|-- AbstractMessage AbstractMessage <|-- SystemMessage AbstractMessage <|-- UserMessage AbstractMessage <|-- AssistantMessage AbstractMessage <|-- ToolResponseMessage UserMessage --> Media AssistantMessage --> ToolCall ToolResponseMessage --> ToolResponse

如果提前把所有内容拼成一段文本:

makefile 复制代码
System: 你是 Java 助手
User: 解释 Prompt

那么原本明确的角色、多模态内容、工具调用结果等结构化信息,都会退化成普通字符串。

这样一来,底层模型适配器无法再直接识别消息语义,只能重新从文本中判断哪些是 system、哪些是 user、哪些是工具结果,这种方式既不稳定,也很难覆盖不同模型协议。

Spring AI 因此选择把消息一直保留为对象结构。到了 OpenAiChatModel、Anthropic 或其他模型适配器这一层,再分别转换成各自供应商要求的请求格式。

metadata 和 context 不是一回事

虽然两者都是 Map,看起来很接近,但它们承载的信息和生命周期并不相同:

flowchart TD A[&#34;ChatClientRequest&#34;] --> B[&#34;Prompt&#34;] A --> C[&#34;Advisor Context&#34;] B --> D[&#34;Message&#34;] D --> E[&#34;Message Metadata&#34;] C --> F[&#34;Memory / Observation / 业务参数&#34;]
位置 作用范围 典型用途
Message.metadata 单条消息 消息来源、附件信息、供应商响应元数据
ChatClientRequest.context 整次 ChatClient 请求 conversationId、Advisor 参数、链路状态

context 主要用于 Advisor Chain 内部流转,并不属于 Prompt 本身。

如果把它和 Prompt 混在一起理解,后面分析 Memory、RAG 和 Tool Calling 时,很容易把调用上下文和模型输入混为一谈。

How:源码如何把零散状态变成 Prompt

上一章已经看到,call()stream() 都会进入同一个转换方法:

java 复制代码
DefaultChatClientUtils.toChatClientRequest(this)

主链如下:

sequenceDiagram participant App as &#34;业务代码&#34; participant Spec as &#34;RequestSpec&#34; participant Utils as &#34;DefaultChatClientUtils&#34; participant Request as &#34;ChatClientRequest&#34; participant Prompt as &#34;Prompt&#34; App->>Spec: &#34;system / messages / user / options&#34; Note over Spec: &#34;只保存中间状态&#34; App->>Spec: &#34;call 或 stream&#34; Spec->>Utils: &#34;toChatClientRequest&#34; Utils->>Utils: &#34;渲染模板并创建 Message&#34; Utils->>Prompt: &#34;new Prompt with messages and options&#34; Utils->>Request: &#34;Prompt + Advisor context&#34;

同步调用和流式调用在这里还没有产生差异。

两者使用的是同一套请求组装逻辑,真正的分叉发生在后续的 Advisor ChainChatModel 调用阶段。

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);

这里的核心在于对象的组装顺序,以及每一步转换发生的位置。

flowchart LR A[&#34;system&#34;] --> D[&#34;① SystemMessage&#34;] B[&#34;messages&#34;] --> E[&#34;② 显式 Message 列表&#34;] C[&#34;user&#34;] --> F[&#34;③ UserMessage&#34;] D --> G[&#34;Prompt.instructions&#34;] E --> G F --> G

最终的消息顺序由转换逻辑统一确定,与 Fluent API 的书写顺序无关。

即使代码写成:

java 复制代码
chatClient.prompt()
        .user("最后解释")
        .messages(history)
        .system("你是 Java 助手");

最终组装出来的顺序仍然是:

bash 复制代码
SystemMessage
→ history 中的 Message
→ UserMessage

常见误区 :链式 API 看起来像按顺序追加消息,实际上它只是在修改不同字段。真正的消息排序发发生在 toChatClientRequest()

这个边界会直接影响消息位置的控制方式。需要精确指定顺序时,应显式构造 Message 列表,而不是依赖 Fluent API 的调用先后。

system 和 user 在这里才完成模板渲染

调用 system()user() 时,保存的是原始文本、模板变量、metadatamedia 等信息,实际的模板渲染发生在请求组装阶段。

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。

flowchart TD A[&#34;模板文本<br>解释 {topic}&#34;] --> D[&#34;TemplateRenderer&#34;] B[&#34;变量<br>topic = Prompt&#34;] --> D C[&#34;metadata / media&#34;] --> E[&#34;UserMessage Builder&#34;] D --> E E --> F[&#34;UserMessage<br>解释 Prompt&#34;]

因此,在 user()param() 阶段打断点时,看到的仍然可能是未渲染的模板内容。

模板变量缺失、渲染失败等问题,也通常会在请求组装阶段才暴露出来。

为什么 Options 要与消息分离

Message 描述发送给模型的内容,ChatOptions 描述这次调用应如何执行。

ChatOptions 定义了一组供应商无关的公共参数,例如 modeltemperaturemaxTokens;具体模型实现再通过 OpenAiChatOptions 等扩展类型,补充各自协议特有的参数。

这种拆分让消息内容保持稳定,同时把模型调用策略独立出来,便于不同供应商之间复用和适配。

classDiagram ChatOptions <|-- OpenAiChatOptions ChatOptions <|-- AnthropicChatOptions ChatOptions : model ChatOptions : temperature ChatOptions : maxTokens

这里需要区分两个不同的 Options 合并阶段。

toChatClientRequest() 负责把 RequestSpec 中已经收集到的 Options 构建出来,并写入 Prompt

模型默认参数与本次请求参数的最终合并,则发生在后续的 ChatModel 调用阶段。

flowchart LR A[&#34;ChatClient 默认 Options&#34;] --> B[&#34;prompt 复制 RequestSpec&#34;] C[&#34;本次 options&#34;] --> D[&#34;Options Customizer&#34;] B --> D D --> E[&#34;Prompt.options&#34;] E --> F[&#34;具体 ChatModel&#34;] G[&#34;ChatModel / Provider 默认配置&#34;] --> F F --> H[&#34;供应商请求参数&#34;]

这条 Options 处理链可以分成两个阶段:

  1. ChatClient 阶段 :默认 RequestSpec 与本次 options() 按字段组合,生成 Prompt.options
  2. ChatModel 阶段 :具体模型实现继续处理模型默认配置、运行时 Options 和供应商专属参数。

第二个阶段放到第六章再展开。

这里先明确边界:Prompt.options 表示传入 ChatModel 的请求级参数,并不是供应商最终收到的完整参数对象。

三个关键设计结论

flowchart TD A[&#34;业务输入变化&#34;] --> B[&#34;Message&#34;] C[&#34;模型参数变化&#34;] --> D[&#34;ChatOptions&#34;] B --> E[&#34;Prompt 快照&#34;] D --> E E --> F[&#34;模型适配层&#34;]
  • Prompt 可以理解为一次模型调用的请求快照。进入执行链后,本次调用使用的消息和参数已经确定,后续对 Builder 或业务上下文的修改不会同步到当前 Prompt
  • Message 的类型和顺序本身就是协议的一部分。保留对象结构,才能稳定承载多模态、Tool 消息以及后续的供应商协议转换。
  • ChatOptions 用于描述模型调用参数。Memory、权限、租户等业务上下文更适合放在 Advisor Context 或业务层,避免模型参数与业务状态混在一起。

这套设计更值得借鉴的地方,是按照变化原因划分边界,再通过类型系统限制错误组合。

下一章我们继续看另一个容易产生疑问的地方:Prompt 已经准备完成,为什么调用 call() 后还不会直接进入模型?

相关推荐
名字还没想好☜1 小时前
Spring @EventListener 事件驱动解耦实战:同步转异步、事务绑定与顺序控制
java·数据库·后端·python·spring
IT大白鼠1 小时前
MySQL 分布式集群系列 · 第八篇(收官)——NDB 集群面试高频题 +架构总结与未来演进
分布式·mysql·面试
小海豚儿1 小时前
没有反馈的 Loop,只是更贵的重试
人工智能·ai编程
ShineWinsu2 小时前
对于MySQL:内置函数的解析
linux·数据库·c++·mysql·面试·函数·查询
vibecoding772 小时前
AI 大模型广场选型完整指南:七大平台模型矩阵、接口兼容与定价横向对比(2026 年)
人工智能·大模型·ai编程
掘金挖土2 小时前
前端手摸手跑路之 AI 应用开发(三)
前端·后端
letisgo52 小时前
JAVA 高级进阶02篇《并发编程三板斧:JMM、CAS与AQS的源码级拆解》
java·面试·并发编程·aqs·jmm
胡写代码2 小时前
若依 + MyBatis-Plus,分页为什么悄悄失效了?
java·后端
掘金者阿豪2 小时前
OceanBase 和金仓怎么选?别只看分布式,复杂查询更考验架构取舍
前端·后端·架构