Spring AI 2.0 源码解析(一):一次 ChatClient 调用到底经历了什么?

15 阅读8分钟

ChatGPT Image 2026年8月18日 11_23_10.png

先跑个示例

仍然从预热文章里的这几行代码开始:

String content = chatClient.prompt()
	.user("你好")
	.call()
	.content();

4 行代码,就能完成一次和大模型的对话。

仔细一看,问题就出来了:

  • ChatClient 是谁创建的?
  • prompt() 返回了什么?
  • user("你好") 什么时候变成 UserMessage
  • call() 到底有没有调用模型?
  • Advisor 在哪个位置介入?
  • 最后的字符串又是怎样从 ChatResponse 里取出来的?

实则,这段代码并非从 ChatClient 直接跳到 OpenAiChatModel。中间还有一段逻辑,比如创建请求规格、组装 Prompt、构建 Advisor Chain,再由链尾的 ChatModelCallAdvisor 调用 ChatModel

还有一个很容易混淆的地方:

call() 并不会直接发起模型请求,真正触发请求调用的是 content()

这也是本章要重点讲解的主要链路。

版本基线

项目版本
Spring AI2.0.0
Commitef502da
Spring Boot4.1.0
Java17
模型实现OpenAiChatModel

后续文章如果没有特别说明,都是沿用这套基线,确保不混入不同版本的源码。

最小示例

先引入 OpenAI Starter。

Spring AI 的版本我们交给 BOM 管理:

<properties>
	<java.version>17</java.version>
	<spring-ai.version>2.0.0</spring-ai.version>
</properties>
<dependencyManagement>
	<dependencies>
		<dependency>
			<groupId>org.springframework.ai</groupId>
			<artifactId>spring-ai-bom</artifactId>
			<version>${spring-ai.version}</version>
			<type>pom</type>
			<scope>import</scope>
		</dependency>
	</dependencies>
</dependencyManagement>
<dependencies>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-web</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework.ai</groupId>
		<artifactId>spring-ai-starter-model-openai</artifactId>
	</dependency>
</dependencies>

2.0.0 的 OpenAI Chat 配置中,模型属性直接放在 spring.ai.openai.chat.model,中间没有 options

spring:
	ai:
		openai:
			api-key: ${OPENAI_API_KEY}
			chat:
			  # 业务上一般采用便宜且更具备性价比的模型
				model: gpt-4.1-mini

然后写一个最小 Controller:

@RestController
@RequestMapping("/ai")
public class ChatController {
	private final ChatClient chatClient;
	public ChatController(ChatClient.Builder builder) {
		this.chatClient = builder.build();
	}
	@GetMapping
	public String chat(
		@RequestParam(defaultValue = "你好") String message) {
		return this.chatClient.prompt()
			.user(message)
			.call()
			.content();
	}
}

这里注入的是 ChatClient.Builder,不是 ChatClient

这个区别很重要。

ChatClient 到底是谁创建的

引入 spring-ai-starter-model-openai 后,Starter 会把 OpenAI 模型实现、ChatClient 和对应的自动配置一起带进来。

启动阶段主要发生两件事:

  1. OpenAiChatAutoConfiguration 根据配置创建 OpenAiChatModel
  2. ChatClientAutoConfiguration 使用这个 ChatModel 创建 ChatClient.Builder

大致的链路关系可以先看下面的流程图:

flowchart TD
	A[spring-ai-starter-model-openai] --> B[OpenAiChatAutoConfiguration]
	B --> C[OpenAiChatModel 作为 ChatModel Bean]
	C --> D[ChatClientAutoConfiguration]
	D --> E[rototype ChatClient.Builder]
	E --> F[业务代码调用 build]
	F --> G[DefaultChatClient]

继续来看这两个配置类,OpenAiChatAutoConfiguration 的核心代码并不复杂:

@Bean
@ConditionalOnMissingBean
public OpenAiChatModel openAiChatModel(
	OpenAiCommonProperties commonProperties,
	OpenAiChatProperties chatProperties,
	ToolCallingManager toolCallingManager,
	ObjectProvider<ObservationRegistry> observationRegistry,
	...) {
	var chatModel = OpenAiChatModel.builder()
		.openAiClient(openAIClient)
		.openAiClientAsync(openAIClientAsync)
		.options(chatProperties.toOptions())
		.toolCallingManager(toolCallingManager)
		.observationRegistry(
			observationRegistry.getIfUnique(
				() -> ObservationRegistry.NOOP))
		.build();
	return chatModel;
}

再看 ChatClientAutoConfiguration

@Bean
@Scope("prototype")
@ConditionalOnMissingBean
ChatClient.Builder chatClientBuilder(
	ChatClientBuilderProperties properties,
	ChatClientBuilderConfigurer configurer,
	ChatModel chatModel,
	ObjectProvider<ObservationRegistry> observationRegistry,
	...) {
	ChatClient.Builder builder = ChatClient.builder(
		chatModel,
		observationRegistry.getIfUnique(
			() -> ObservationRegistry.NOOP),
		...);
	return configurer.configure(builder);
}

这里有两个细节需要关注。

第一,Spring AI 自动配置的是 ChatClient.Builder,最终的 ChatClient 由业务代码调用 build() 创建。

第二,这个 Builder 是 prototype。每次从 Spring 容器获取它,都会得到一个新的 Builder,业务代码可以设置不同的 default system、default advisor 和 default options,互不影响。

继续进入 DefaultChatClientBuilder#build()

@Override
public ChatClient build() {
	return new DefaultChatClient(this.defaultRequest);
}

到这里,真正工作的实现类 DefaultChatClient 才出现。

Spring 在这里没有直接提供一个全局 ChatClient,而是提供可定制的 Builder。这样做确实方便了不同业务场景去设置各自的默认配置,但同样带来新的问题:排查 Bean 创建问题时,又多了一层跳转。这自然是有利有弊,也是 Spring 家族产品的一贯特点。

prompt() 只是复制一份请求规格

现在回到业务代码:

chatClient.prompt()

DefaultChatClient#prompt() 的实现只有一行:

@Override
public ChatClientRequestSpec prompt() {
	return new DefaultChatClientRequestSpec(
		this.defaultChatClientRequest);
}

它既没有创建网络请求,也没有调用模型。

只是根据 ChatClient 保存的默认配置,复制出一份新的 DefaultChatClientRequestSpec。Builder 中设置的默认 system text、messages、options、advisors、tools 和 advisor params,都会成为这次请求的起点。

所以同一个 ChatClient 就能实现重复使用,而每次 prompt() 都有独立的请求状态。

user() 还没有创建 UserMessage

下一步:

.user("你好")

这一步也比想象中简单。源码只是把文本暂存在 RequestSpec 中:

@Override
public ChatClientRequestSpec user(String text) {
	Assert.hasText(text, "text cannot be null or empty");
	this.userText = text;
	return this;
}

此时仍然没有 UserMessage

真正的消息组装发生在 DefaultChatClientUtils#toChatClientRequest()。它会按顺序处理 system text、已有 messages 和 user text,再创建 Prompt

Builder promptBuilder = Prompt.builder()
	.messages(processedMessages)
	.chatOptions(processedChatOptions);
return ChatClientRequest.builder()
	.prompt(promptBuilder.build())
	.context(new ConcurrentHashMap<>(
		inputRequest.getAdvisorParams()))
	.build();

最终进入 Advisor Chain 的对象不是零散的字符串,而是:

public record ChatClientRequest(
	Prompt prompt,
	Map<String, Object> context) {
}

Prompt 保存发给模型的 messages 和 options;context 保存 Advisor 在调用链中共享的数据。

两者不可混为一谈。

模型供应商最终关心的是 Prompt,Advisor 还需要旁边这份 context 来传递 Memory、Structured Output、Tool Calling 等控制信息。

call() 为什么没有调用模型

接着看最容易误判的一步:

.call()

DefaultChatClientRequestSpec#call() 的源码如下:

@Override
public CallResponseSpec call() {
	BaseAdvisorChain advisorChain = buildAdvisorChain();
	return new DefaultCallResponseSpec(
		DefaultChatClientUtils.toChatClientRequest(this),
		advisorChain,
		this.observationRegistry,
		this.chatClientObservationConvention);
}

它只做了两件事:

  • 构建 Advisor Chain;
  • 把当前 RequestSpec 转成 ChatClientRequest

然后返回 DefaultCallResponseSpec

没有 chatModel.call(...),也没有网络请求。

所以这段代码执行完,模型还没有收到任何内容:

CallResponseSpec responseSpec = chatClient.prompt()
	.user("你好")
	.call();

只有继续调用 content()chatResponse()chatClientResponse()entity(),同步请求才会真正开始。

命名上确实容易让人产生误解。

从实际行为来看,call() 更像是“切换到同步调用模式并构造 ResponseSpec”,真正触发模型调用的是后续的终止方法。

Advisor Chain 的组装

继续往下看,call() 先进入 buildAdvisorChain()

private BaseAdvisorChain buildAdvisorChain() {
	autoRegisterToolCallingAdvisor();
	validateSingleToolAdvisor();
	List<Advisor> chain = new ArrayList<>(this.advisors);
	chain.add(ChatModelCallAdvisor.builder()
		.chatModel(this.chatModel)
		.build());
	chain.add(ChatModelStreamAdvisor.builder()
		.chatModel(this.chatModel)
		.build());
	return DefaultAroundAdvisorChain
		.builder(this.observationRegistry)
		.observationConvention(
			this.advisorObservationConvention)
		.pushAll(chain)
		.build();
}

业务配置的 Advisor 会先进入列表,Spring AI 再把两个模型调用 Advisor 放到链尾:

  • ChatModelCallAdvisor 处理同步调用;
  • ChatModelStreamAdvisor 处理流式调用。

2.0.0 还会默认注册 ToolCallingAdvisor。即使当前请求没有静态 Tool,它也会保留在链中,以便其他 Advisor 在运行时动态加入工具。

我们先记住一点:

ChatModelCallAdvisor 是同步 Advisor Chain 通往 ChatModel 的最后一站。

content() 才真正触发调用

继续执行:

.content()

DefaultCallResponseSpec#content() 会调用 doGetObservableChatClientResponse()

@Override
public @Nullable String content() {
	ChatResponse chatResponse =
		doGetObservableChatClientResponse(this.request)
			.chatResponse();
	return getContentFromChatResponse(chatResponse);
}

在 Observation 包装内部,真正的入口是:

var response = advisorChain.nextCall(chatClientRequest);

DefaultAroundAdvisorChain#nextCall() 每次从队列中取出一个 Advisor,再调用它的 adviseCall()

var advisor = this.callAdvisors.pop();
return observation.observe(() -> {
	var response = advisor.adviseCall(
		chatClientRequest, this);
	observationContext.setChatClientResponse(response);
	return response;
});

普通 Advisor 在自己的 adviseCall() 中继续调用 chain.nextCall(request),请求就会逐层向后传递;当响应返回时,再沿原路向前回来。

能看出来,这就是个典型的 around chain。

链尾怎样调用 ChatModel

当请求走到 ChatModelCallAdvisor,才第一次看到真正的模型调用:

@Override
public ChatClientResponse adviseCall(
	ChatClientRequest chatClientRequest,
	CallAdvisorChain callAdvisorChain) {
	ChatClientRequest formattedRequest =
		augmentWithFormatInstructions(chatClientRequest);
	ChatResponse chatResponse =
		this.chatModel.call(formattedRequest.prompt());
	return ChatClientResponse.builder()
		.chatResponse(chatResponse)
		.context(Map.copyOf(formattedRequest.context()))
		.build();
}

这里完成了两个边界转换:

  • ChatClientRequest 中取出 Prompt,交给 ChatModel
  • ChatModel 返回的 ChatResponse 和 Advisor context 重新包装成 ChatClientResponse

ChatModel 是供应商无关的接口:

public interface ChatModel
	extends Model<Prompt, ChatResponse>,
		StreamingChatModel {
	@Override
	ChatResponse call(Prompt prompt);
}

上层只依赖 ChatModel。换成 Anthropic、Ollama 或其他模型实现时,ChatClient 和 Advisor Chain 不需要跟着供应商 SDK 一起改。

这层抽象就解决了隔离供应商差异的问题,非常巧妙。

同时也代价也很明显——排查困难。当排查一次对话请求时,要从 ChatClient 再穿过 Advisor Chain,才能看到真正的 Provider 实现。

OpenAiChatModel 怎样进入 SDK

当前示例注入的 ChatModel 实现是 OpenAiChatModel

它的同步入口是:

@Override
public ChatResponse call(Prompt prompt) {
	Prompt requestPrompt = buildRequestPrompt(prompt);
	verifyPromptChatOptions(requestPrompt);
	return this.internalCall(requestPrompt, null);
}

internalCall() 先把 Spring AI 的 Prompt 转成 OpenAI Java SDK 的 ChatCompletionCreateParams,然后发起请求:

ChatCompletionCreateParams request =
	createRequest(prompt, false);
ChatCompletion chatCompletion =
	this.openAiClient.chat()
		.completions()
		.create(request);

供应商返回结果后,OpenAiChatModel 会把 choices 转成 Spring AI 的 Generation,再组装成统一的 ChatResponse

到这里,一次完整的同步调用链就完成了。

一次调用的完整时序

让我们结合时序图,再来回顾这次的调用链路:

sequenceDiagram
	participant U as &#34;业务代码&#34;
	participant C as &#34;DefaultChatClient&#34;
	participant A as &#34;Advisor Chain&#34;
	participant M as &#34;ChatModelCallAdvisor&#34;
	participant P as &#34;OpenAiChatModel / SDK&#34;
	U->>C: prompt().user(&#34;你好&#34;).call()
	C-->>U: DefaultCallResponseSpec
	U->>C: content()
	C->>A: nextCall(ChatClientRequest)
	A->>A: 依次执行已注册 Advisor
	A->>M: adviseCall(request)
	M->>P: chatModel.call(prompt)
	P->>P: createRequest() 并调用 OpenAI SDK
	P-->>M: ChatResponse
	M-->>A: ChatClientResponse
	A-->>C: ChatClientResponse
	C-->>U: 提取 output.text

注意时序图的前两步:

prompt().user(...).call() 返回 DefaultCallResponseSpec,到 content() 才让请求进入 Advisor Chain。

后面分析重试、Tool Calling 和结构化输出时,很容易把执行位置判断错,因此这一块要做重点记忆。

最后的 String 从哪里来

Advisor Chain 返回 ChatClientResponse 后,content() 取出里面的 ChatResponse,再沿着下面这条路径拿到文本:

private static @Nullable String getContentFromChatResponse(
	@Nullable ChatResponse chatResponse) {
	return Optional.ofNullable(chatResponse)
		.map(ChatResponse::getResult)
		.map(Generation::getOutput)
		.map(AbstractMessage::getText)
		.orElse(null);
}

完整路径是:

ChatClientResponse
	-> ChatResponse
	-> Generation
	-> AssistantMessage
	-> text

所以 content() 只是一个方便调用者使用的文本提取方法。

如果业务需要 token usage、finish reason、response metadata 或 Advisor context,就不要过早把响应压成一个字符串,应该使用 chatResponse()chatClientResponse()

建议打这些断点

如果觉得光看文章有点模糊,可以直接把我提供的 github 仓库 clone 下来,打开项目后按下面的顺序依次打断点:

顺序断点位置看什么
1ChatClientAutoConfiguration#chatClientBuilderBuilder 怎样拿到 ChatModel
2DefaultChatClient#prompt默认请求怎样复制
3DefaultChatClientRequestSpec#useruser text 保存在哪里
4DefaultChatClientRequestSpec#callRequest 和 Advisor Chain 怎样准备
5DefaultCallResponseSpec#content真正执行从哪里开始
6DefaultAroundAdvisorChain#nextCall当前执行的是哪个 Advisor
7ChatModelCallAdvisor#adviseCallChatClientRequest 怎样进入 ChatModel
8OpenAiChatModel#callProvider 层怎样处理 Prompt
9OpenAiChatModel#internalCallOpenAI SDK 请求在哪里发出

调试时保持观察,重点看这三个对象:

  • DefaultChatClientRequestSpec:还没有最终组装的请求参数;
  • ChatClientRequest:已经包含 Prompt + context
  • ChatClientResponse:已经包含 ChatResponse + context

看似这几个都是请求和响应,实际上所在的层次完全不同。

回到开头

现在再回头来看这 4 行代码:

String content = chatClient.prompt()
	.user("你好")
	.call()
	.content();

可以把它展开成下面这条主链:

ChatClient.Builder
	-> DefaultChatClient
	-> DefaultChatClientRequestSpec
	-> ChatClientRequest(Prompt, context)
	-> DefaultAroundAdvisorChain
	-> ChatModelCallAdvisor
	-> ChatModel
	-> OpenAiChatModel
	-> OpenAI Java SDK
	-> ChatResponse
	-> content
  • ChatClient 负责提供流式 API 和保存默认配置;
  • RequestSpec 负责收集本次请求参数;
  • Advisor Chain 负责组织调用前后的增强逻辑;
  • ChatModel 隔离供应商差异;
  • OpenAiChatModel 最终完成 Spring AI 对象和 OpenAI SDK 对象之间的转换。

每一层都有自己的职责。

把这条主链理清后,后面的 Chat Memory、Tool Calling、Structured Output 和 RAG 才能更容易理解。

下一篇我们来拆解 Starter 的自动配置:一个 spring-ai-starter-model-openai 依赖,到底向 Spring 容器里放了哪些 Bean。

相关源码

  • Spring AI v2.0.0 源码
  • ChatClientAutoConfiguration.java
  • DefaultChatClient.java
  • DefaultAroundAdvisorChain.java
  • ChatModelCallAdvisor.java
  • OpenAiChatModel.java