Spring AI 2.0 源码解析(三):ChatClient 的 Fluent API 如何构建请求?

0 阅读9分钟

ChatGPT Image 2026年8月21日 12_30_27.png

还是从这几行代码开始

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[&#34;DefaultChatClientBuilder<br>持有 defaultRequest&#34;] --> B[&#34;defaultSystem / defaultUser / defaultOptions&#34;]
    B --> C[&#34;build()&#34;]
    C --> D[&#34;DefaultChatClient<br>持有同一个 defaultRequest 引用&#34;]
    D --> E[&#34;prompt()&#34;]
    E --> F[&#34;复制 DefaultChatClientRequestSpec&#34;]
    F --> G[&#34;system / user / messages / advisors / tools&#34;]
    G --> H[&#34;call() 或 stream()&#34;]
    H --> I[&#34;DefaultChatClientUtils.toChatClientRequest()&#34;]
    I --> J[&#34;ChatClientRequest<br>Prompt + Advisor context&#34;]

这里面有个细节要注意:build() 没有复制 defaultRequest

默认配置如何进入单次请求

Builder 保存的内容

DefaultChatClientBuilder 自己只有一个真正关键的成员:

protected final DefaultChatClientRequestSpec defaultRequest;

构造方法会初始化一份空的 RequestSpec,并将 ChatModel、Observation、模板渲染器、Tool Calling Advisor Builder 等请求执行所需的基础组件注入进去。

虽然构造参数数量较多,但从职责上看,可以归纳为几类:

分组RequestSpec 中的字段用途
模型与观测chatModelobservationRegistry、两个 convention后续执行和观测
SystemsystemTextsystemParamssystemMetadata默认或本次 system message
UseruserTextuserParamsuserMetadatamedia默认或本次 user message
显式消息messages已经构造好的 Message 列表
OptionsoptionsCustomizer在模型默认 Options 上继续合并
AdvisoradvisorsadvisorParamsAdvisor 列表和链路 context
TooltoolCallbackstoolCallbackProviderstoolContext工具定义和执行上下文
模板templateRenderersystem/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 并不是一个简单的配置容器。

它从初始化阶段开始,就持有并维护一份默认 RequestSpecbuild() 做的只是基于这份默认请求状态创建 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() 复制自身状态。

因此,本次请求中的 usermediaadvisor paramstools 修改都会作用于请求副本,不会反向影响 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[&#34;Builder.defaultRequest&#34;]
    ClientDefault[&#34;Client.defaultChatClientRequest&#34;]
    A[&#34;RequestSpec A&#34;]
    B[&#34;RequestSpec B&#34;]
    RA[&#34;ChatClientRequest A&#34;]
    RB[&#34;ChatClientRequest B&#34;]

    Default -->|&#34;build 传递同一引用&#34;| ClientDefault
    ClientDefault -->|&#34;第一次 prompt 复制&#34;| A
    ClientDefault -->|&#34;第二次 prompt 复制&#34;| B
    A -->|&#34;call 组装&#34;| RA
    B -->|&#34;call 组装&#34;| RB

Fluent API 把数据保存到哪里

默认 system、user 和本次值的关系

RequestSpec 内部只维护一份 systemText 和一份 userText

Builder 创建 ChatClient 时,会先将默认的 system、user 内容写入 RequestSpec。之后每次调用 prompt(),都会基于默认 RequestSpec 创建新的副本。

本次请求通过 system(String)user(String) 设置的内容,会直接替换副本中的对应字段,不会自动生成两条消息。

Consumer 写法的处理逻辑略有不同:如果本次设置了 text,则覆盖原有内容;如果没有设置,则继续沿用默认值。而 paramsmetadatamedia 则会在原有基础上继续合并。

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(),因此不会覆盖已有的 systemTextuserText。最终构造 Prompt 时,消息顺序固定为:

  1. 默认或本次请求生成的 SystemMessage
  2. 显式传入的 messages
  3. 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...) 支持多种输入形式,包括 ToolCallbackToolCallbackProvider、数组、集合以及普通 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() 怎样组装最终请求

这一步可以分成三个阶段:

  1. 渲染 system 参数,并生成对应的 SystemMessage
  2. 将显式传入的 messages 追加到消息列表;
  3. 渲染 user 参数,生成包含 mediametadataUserMessage

消息列表构建完成后,开始处理本次请求的 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 的模型输入,包含 messagesChatOptions

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() 阶段。
  • toolstoolContext 在收集阶段保持独立,只有请求组装时才会合并到 ToolCallingChatOptions
  • build() 并不会让 Builder 与 Client 完全隔离。Builder 仍然持有默认状态,因此不应该作为运行期共享配置对象使用。

回到开头

重新梳理整个流程,可以看到 Fluent API 的核心并不是立即执行请求,而是在不同阶段逐步构建请求状态:

Builder 维护默认 RequestSpecChatClient 在每次 prompt() 时基于它创建请求副本,本次 Fluent 调用继续修改该副本,最终由 call()stream() 将副本转换为 Prompt + Advisor context

这套设计让 ChatClient 可以被长期复用,同时允许默认配置与本次请求参数通过同一套 API 组合。

当然也会有代价,就是请求构造过程中存在多层中间状态,并且 build() 并不会像传统 Builder 模式那样生成一个完全独立的不可变快照。

因此,在实际项目中,Builder 更适合作为初始化阶段的配置对象,而不是运行期反复修改的共享状态,否则多个请求之间可能产生难以排查的状态问题。

下一篇继续分析 PromptMessageChatOptions,看看 Fluent API 收口后,最终交给模型的请求对象具体由哪些部分组成。