还是从这几行代码开始
String content = chatClient.prompt()
.system(s -> s.text("你是 {role}")
.param("role", "Java 助手"))
.user(u -> u.text("用 {language} 解释 {topic}")
.param("language", "中文")
.param("topic", "Builder"))
.options(OpenAiChatOptions.builder().temperature(0.2))
.call()
.content();
前两章已经把这条调用链路从头到尾理了一遍,中间留了几个问题,现在我们回头来看,只看 prompt() 到 call() 之间发生了什么。
这段代码中出现的 system()、user()、messages()、advisors()、tools() 等 Fluent API,大多数并不会立即创建新的请求对象,而是在持续修改同一个 DefaultChatClientRequestSpec。
模板参数也不会在调用 param() 时马上完成替换。
整个 Fluent 阶段只是收集请求状态,直到 call() 或 stream() 执行时,这些分散保存的信息才会被统一组装为最终的 Prompt + context。
所以这一章要解决几个具体问题:
- Builder 的默认配置保存在哪里;
build()到底复制了什么;- 为什么每次
prompt()都要复制 RequestSpec; - 默认 system、user 和本次 system、user 是追加还是覆盖;
- messages、media、options、advisors、tools、toolContext 分别放在哪里;
param()什么时候真正渲染;- 哪些对象可以复用,哪些对象只能属于一次请求。
最小可运行示例
本章不需要真实调用 OpenAI的模型进行对话,具体可以看下面的代码实例。
下面的 ChatModel 只是把最终 Prompt 打印出来,然后返回一个固定结果。这样可以单独观察 Fluent API 的组装行为,不涉及任何供应商的 SDK 。
import java.util.List;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
public class FluentApiDemo {
public static void main(String[] args) {
ChatModel chatModel = new InspectingChatModel();
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(s -> s.text("你是 {role}").param("role", "Java 助手"))
.build();
String content = chatClient.prompt()
.user(u -> u.text("用 {language} 解释 {topic}")
.param("language", "中文")
.param("topic", "Builder"))
.call()
.content();
System.out.println(content);
}
static final class InspectingChatModel implements ChatModel {
private final ChatOptions options = ChatOptions.builder().build();
@Override
public ChatResponse call(Prompt prompt) {
prompt.getInstructions().forEach(message ->
System.out.println(message.getMessageType() + ": " + message.getText()));
return new ChatResponse(List.of(new Generation(new AssistantMessage("已收到"))));
}
@Override
public ChatOptions getOptions() {
return this.options;
}
}
}
输出的核心内容是:
SYSTEM: 你是 Java 助手
USER: 用中文解释 Builder
已收到
通过这条调用链可以确认两个关键点:默认 system 会进入实际请求,而模板变量替换发生在模型调用之前,模型接收到的已经是渲染后的 Prompt。
先看整体结构
flowchart LR
A["DefaultChatClientBuilder<br>持有 defaultRequest"] --> B["defaultSystem / defaultUser / defaultOptions"]
B --> C["build()"]
C --> D["DefaultChatClient<br>持有同一个 defaultRequest 引用"]
D --> E["prompt()"]
E --> F["复制 DefaultChatClientRequestSpec"]
F --> G["system / user / messages / advisors / tools"]
G --> H["call() 或 stream()"]
H --> I["DefaultChatClientUtils.toChatClientRequest()"]
I --> J["ChatClientRequest<br>Prompt + Advisor context"]
这里面有个细节要注意:build() 没有复制 defaultRequest。
默认配置如何进入单次请求
Builder 保存的内容
DefaultChatClientBuilder 自己只有一个真正关键的成员:
protected final DefaultChatClientRequestSpec defaultRequest;
构造方法会初始化一份空的 RequestSpec,并将 ChatModel、Observation、模板渲染器、Tool Calling Advisor Builder 等请求执行所需的基础组件注入进去。
虽然构造参数数量较多,但从职责上看,可以归纳为几类:
| 分组 | RequestSpec 中的字段 | 用途 |
|---|---|---|
| 模型与观测 | chatModel、observationRegistry、两个 convention | 后续执行和观测 |
| System | systemText、systemParams、systemMetadata | 默认或本次 system message |
| User | userText、userParams、userMetadata、media | 默认或本次 user message |
| 显式消息 | messages | 已经构造好的 Message 列表 |
| Options | optionsCustomizer | 在模型默认 Options 上继续合并 |
| Advisor | advisors、advisorParams | Advisor 列表和链路 context |
| Tool | toolCallbacks、toolCallbackProviders、toolContext | 工具定义和执行上下文 |
| 模板 | templateRenderer | system/user 文本的延迟渲染 |
Builder 上所有 defaultXxx 方法,几乎都是把参数转交给这份 defaultRequest:
public Builder defaultSystem(String text) {
this.defaultRequest.system(text);
return this;
}
public Builder defaultAdvisors(Advisor... advisors) {
this.defaultRequest.advisors(advisors);
return this;
}
public Builder defaultToolContext(Map<String, Object> toolContext) {
this.defaultRequest.toolContext(toolContext);
return this;
}
这里有一个容易误解的地方:Builder 并不是一个简单的配置容器。
它从初始化阶段开始,就持有并维护一份默认 RequestSpec,build() 做的只是基于这份默认请求状态创建 ChatClient。
build() 没有冻结配置
DefaultChatClientBuilder#build() 只有一行:
public ChatClient build() {
return new DefaultChatClient(this.defaultRequest);
}
DefaultChatClient 构造方法同样没有复制。Builder 和它创建出来的 DefaultChatClient 持有同一份默认 RequestSpec 引用。
ChatClient.Builder builder = ChatClient.builder(chatModel)
.defaultSystem("第一版");
ChatClient client = builder.build();
builder.defaultSystem("第二版");
后续再次调用 client.prompt() 时,复制的默认 RequestSpec 可能已经包含 Builder 修改后的“第二版”配置。
因此,Builder 更适合只在初始化阶段使用,build() 完成后不要再继续修改同一个 Builder。若需要基于已有配置派生新的客户端,应使用 builder.clone() 或 chatClient.mutate() 创建新的配置副本。
自动配置将 Builder 声明为 prototype,解决的是不同 Bean 获取 Builder 时的隔离问题,并不意味着同一个 Builder 可以在运行期间被多个线程同时修改和复用。
prompt() 为什么必须复制
DefaultChatClient#prompt() 才是单次请求隔离真正发生的位置:
@Override
public ChatClientRequestSpec prompt() {
return new DefaultChatClientRequestSpec(this.defaultChatClientRequest);
}
复制构造器会通过 putAll() 和 addAll() 创建 Map、List 的副本,而 Options Builder 则通过 clone() 复制自身状态。
因此,本次请求中的 user、media、advisor params 和 tools 修改都会作用于请求副本,不会反向影响 Builder 保存的默认配置。
this.optionsCustomizer = customizer != null ? customizer.clone() : null;
this.userParams.putAll(userParams);
this.systemParams.putAll(systemParams);
this.toolCallbacks.addAll(toolCallbacks);
this.messages.addAll(messages);
this.media.addAll(media);
this.advisors.addAll(advisors);
this.advisorParams.putAll(advisorParams);
this.toolContext.putAll(toolContext);
把复杂的调用链抽掉,只看对象状态变化,可以简化为:
flowchart TD
Default["Builder.defaultRequest"]
ClientDefault["Client.defaultChatClientRequest"]
A["RequestSpec A"]
B["RequestSpec B"]
RA["ChatClientRequest A"]
RB["ChatClientRequest B"]
Default -->|"build 传递同一引用"| ClientDefault
ClientDefault -->|"第一次 prompt 复制"| A
ClientDefault -->|"第二次 prompt 复制"| B
A -->|"call 组装"| RA
B -->|"call 组装"| RB
Fluent API 把数据保存到哪里
默认 system、user 和本次值的关系
RequestSpec 内部只维护一份 systemText 和一份 userText。
Builder 创建 ChatClient 时,会先将默认的 system、user 内容写入 RequestSpec。之后每次调用 prompt(),都会基于默认 RequestSpec 创建新的副本。
本次请求通过 system(String) 或 user(String) 设置的内容,会直接替换副本中的对应字段,不会自动生成两条消息。
Consumer 写法的处理逻辑略有不同:如果本次设置了 text,则覆盖原有内容;如果没有设置,则继续沿用默认值。而 params、metadata 和 media 则会在原有基础上继续合并。
this.systemText = StringUtils.hasText(systemSpec.text())
? systemSpec.text()
: this.systemText;
this.systemParams.putAll(systemSpec.params());
this.systemMetadata.putAll(systemSpec.metadata());
defaultUser 的处理规则完全一致。
这里有一个容易忽略的细节:text 的覆盖和 params 的合并是两个独立行为。
当新的 text 替换旧内容时,原有的 params 并不会被同步清空。即使这些参数最终没有参与文本渲染,它们仍然可能保留在当前 RequestSpec 中。
messages、media、options 保存在哪里
messages 保存在 RequestSpec 的消息列表中。写入时使用 addAll(),因此不会覆盖已有的 systemText 或 userText。最终构造 Prompt 时,消息顺序固定为:
- 默认或本次请求生成的
SystemMessage - 显式传入的
messages userText生成的UserMessage
media 不会单独生成 Message,而是暂存在 PromptUserSpec 中,最终与 userText 一起组装成一个 UserMessage。
options(...) 保存的是 ChatOptions.Builder,此时还没有生成最终的 ChatOptions:
ChatOptions.Builder<?> builder = inputRequest.getChatModel()
.getOptions()
.mutate();
if (inputRequest.getOptionsCustomizer() != null) {
builder = builder.combineWith(inputRequest.getOptionsCustomizer());
}
ChatOptions processedOptions = builder.build();
最终 options 的来源有两层:底层 ChatModel 提供默认值,本次请求的 options 用于覆盖对应字段。具体字段合并规则会在下一章展开。
advisors、tools 和 toolContext 又放在哪里
Advisor 相关状态分为两部分:
advisors保存实际的Advisor实例,用于后续构建 Advisor Chain;advisorParams保存 Advisor 使用的参数,并在最终转换时写入ChatClientRequest.context。
tools(Object...) 支持多种输入形式,包括 ToolCallback、ToolCallbackProvider、数组、集合以及普通 POJO。对于普通 POJO,会通过 ToolCallbacks.from(...) 转换为统一的 ToolCallback。因此,RequestSpec 最终保存的是归一化后的 callback/provider 信息。
toolContext 则先独立保存在一个 Map 中。直到 DefaultChatClientUtils 组装最终请求时,如果当前 ChatOptions 类型是 ToolCallingChatOptions.Builder,才会将其写入工具调用相关配置。
需要区分两个容易混淆的概念:
toolContext:传递给工具执行过程使用;Advisor context:存放在ChatClientRequest.context中,供 Advisor Chain 内部共享。
两者名称相似,但作用范围完全不同。
param() 什么时候替换
param() 只是在 Map 里存值:
public PromptUserSpec param(String key, Object value) {
this.params.put(key, value);
return this;
}
真正渲染发生在 DefaultChatClientUtils#toChatClientRequest():
String processedUserText = inputRequest.getUserText();
if (StringUtils.hasText(processedUserText)
&& !CollectionUtils.isEmpty(inputRequest.getUserParams())) {
processedUserText = PromptTemplate.builder()
.template(processedUserText)
.variables(inputRequest.getUserParams())
.renderer(inputRequest.getTemplateRenderer())
.build()
.render();
}
System text 和走同一套逻辑。默认渲染器是 StTemplateRenderer,本次也可以用 templateRenderer(...) 替换。模板文本、变量和 renderer 在 Fluent API 阶段分开保存,到组装 ChatClientRequest 时才碰到一起。
call() 如何收口
toChatClientRequest() 怎样组装最终请求
这一步可以分成三个阶段:
- 渲染 system 参数,并生成对应的
SystemMessage; - 将显式传入的
messages追加到消息列表; - 渲染 user 参数,生成包含
media和metadata的UserMessage。
消息列表构建完成后,开始处理本次请求的 Options:
- 从
ChatModel.getOptions().mutate()获取默认 Options; - 合并本次请求的 Options;
- 展开 provider 提供的 callbacks;
- 写入
toolContext。
最终创建两个核心对象:
Prompt prompt = Prompt.builder()
.messages(processedMessages)
.chatOptions(processedChatOptions)
.build();
return ChatClientRequest.builder()
.prompt(prompt)
.context(new ConcurrentHashMap<>(inputRequest.getAdvisorParams()))
.build();
到这里,Fluent API 阶段只负责收集和组装请求,尚未触发真正的模型调用。
Prompt 和 Advisor context 为什么分开
Prompt 表示最终发送给 ChatModel 的模型输入,包含 messages 和 ChatOptions。
Advisor context 则属于框架调用链的上下文状态,用于在 Advisor 之间传递信息,例如 conversation id、检索参数、structured output 控制信息等。
两者职责不同,因此不能混合:
- 如果 context 直接进入 Prompt,框架内部参数可能会意外暴露给模型;
- 如果 Advisor 修改上下文需要同步修改 Prompt,调用链状态和模型输入会产生强耦合。
ChatClientRequest(Prompt, context) 将模型看到的内容和框架内部流转状态分离,使请求模型和 Advisor Chain 各自维护自己的状态边界。
容易误解的地方
defaultSystem不会和本次system叠加生成两条消息。本次system会直接覆盖RequestSpec中的systemText。- Fluent API 链式调用过程中不会不断创建新的
RequestSpec,真正创建请求副本的时机是prompt()。 param()只是保存模板参数,不会立即执行变量替换。真正的渲染发生在toChatClientRequest()阶段。tools和toolContext在收集阶段保持独立,只有请求组装时才会合并到ToolCallingChatOptions。build()并不会让 Builder 与 Client 完全隔离。Builder 仍然持有默认状态,因此不应该作为运行期共享配置对象使用。
回到开头
重新梳理整个流程,可以看到 Fluent API 的核心并不是立即执行请求,而是在不同阶段逐步构建请求状态:
Builder 维护默认
RequestSpec,ChatClient在每次prompt()时基于它创建请求副本,本次 Fluent 调用继续修改该副本,最终由call()或stream()将副本转换为Prompt + Advisor context。
这套设计让 ChatClient 可以被长期复用,同时允许默认配置与本次请求参数通过同一套 API 组合。
当然也会有代价,就是请求构造过程中存在多层中间状态,并且 build() 并不会像传统 Builder 模式那样生成一个完全独立的不可变快照。
因此,在实际项目中,Builder 更适合作为初始化阶段的配置对象,而不是运行期反复修改的共享状态,否则多个请求之间可能产生难以排查的状态问题。
下一篇继续分析 Prompt、Message 和 ChatOptions,看看 Fluent API 收口后,最终交给模型的请求对象具体由哪些部分组成。