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

还是从这几行代码开始

java 复制代码
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 。

java 复制代码
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;
        }
    }
}

输出的核心内容是:

makefile 复制代码
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 自己只有一个真正关键的成员:

java 复制代码
protected final DefaultChatClientRequestSpec defaultRequest;

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

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

分组 RequestSpec 中的字段 用途
模型与观测 chatModelobservationRegistry、两个 convention 后续执行和观测
System systemTextsystemParamssystemMetadata 默认或本次 system message
User userTextuserParamsuserMetadatamedia 默认或本次 user message
显式消息 messages 已经构造好的 Message 列表
Options optionsCustomizer 在模型默认 Options 上继续合并
Advisor advisorsadvisorParams Advisor 列表和链路 context
Tool toolCallbackstoolCallbackProviderstoolContext 工具定义和执行上下文
模板 templateRenderer system/user 文本的延迟渲染

Builder 上所有 defaultXxx 方法,几乎都是把参数转交给这份 defaultRequest

java 复制代码
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() 只有一行:

java 复制代码
public ChatClient build() {
    return new DefaultChatClient(this.defaultRequest);
}

DefaultChatClient 构造方法同样没有复制。Builder 和它创建出来的 DefaultChatClient 持有同一份默认 RequestSpec 引用。

java 复制代码
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() 才是单次请求隔离真正发生的位置:

java 复制代码
@Override
public ChatClientRequestSpec prompt() {
    return new DefaultChatClientRequestSpec(this.defaultChatClientRequest);
}

复制构造器会通过 putAll()addAll() 创建 Map、List 的副本,而 Options Builder 则通过 clone() 复制自身状态。

因此,本次请求中的 usermediaadvisor paramstools 修改都会作用于请求副本,不会反向影响 Builder 保存的默认配置。

java 复制代码
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 则会在原有基础上继续合并。

java 复制代码
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

java 复制代码
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 里存值:

java 复制代码
public PromptUserSpec param(String key, Object value) {
    this.params.put(key, value);
    return this;
}

真正渲染发生在 DefaultChatClientUtils#toChatClientRequest()

java 复制代码
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

最终创建两个核心对象:

java 复制代码
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 收口后,最终交给模型的请求对象具体由哪些部分组成。

相关推荐
软件开发JR1 小时前
基于Web的足球青训俱乐部管理后台系统的设计与开发
java·前端·spring boot·毕业设计
重生之我是Java开发战士2 小时前
【Java EE】Spring AOP :面向切面编程
java·spring·java-ee
凤山老林2 小时前
基于 Spring Batch 的海量数据迁移与批处理架构:分片、容错与断点续跑
java·spring boot·spring·架构·spring batch
Javatutouhouduan2 小时前
Java初学者如何高效学习JVM?
java·jvm·java虚拟机·java面试·后端开发·java程序员·java八股文
掘金者阿豪2 小时前
数据库迁移工具选了半年,最后发现决策的起点错了
后端
ZJU_统一阿萨姆2 小时前
【算子开发】全局内存访问与合并访存
java·服务器·网络·人工智能·语言模型
卷无止境2 小时前
FastAPI查询参数模型:把散落的参数收拢成一个整齐的盒子
后端·python
7177772 小时前
不止工具集成:基于 Gitee 软件工厂构建 DevSecOps 研发治理底座
java·服务器·gitee
禁止摆烂_才浅3 小时前
JavaScript 类型判断:instanceof 与 constructor 原理深度解析
前端·javascript·面试