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

先跑个示例

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

scss 复制代码
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 AI 2.0.0
Commit ef502da
Spring Boot 4.1.0
Java 17
模型实现 OpenAiChatModel

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

最小示例

先引入 OpenAI Starter。

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

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

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

然后写一个最小 Controller:

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

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

css 复制代码
flowchart TD
	A["spring-ai-starter-model-openai"] --> B["OpenAiChatAutoConfiguration"]
	B --> C["OpenAiChatModel<br>作为 ChatModel Bean"]
	C --> D["ChatClientAutoConfiguration"]
	D --> E["prototype ChatClient.Builder"]
	E --> F["业务代码调用 build()"]
	F --> G["DefaultChatClient"]

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

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

less 复制代码
@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()

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

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

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

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

现在回到业务代码:

scss 复制代码
chatClient.prompt()

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

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

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

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

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

user() 还没有创建 UserMessage

下一步:

sql 复制代码
.user("你好")

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

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

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

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

typescript 复制代码
public record ChatClientRequest(
	Prompt prompt,
	Map<String, Object> context) {
}

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

两者不可混为一谈。

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

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

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

scss 复制代码
.call()

DefaultChatClientRequestSpec#call() 的源码如下:

kotlin 复制代码
@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(...),也没有网络请求。

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

ini 复制代码
CallResponseSpec responseSpec = chatClient.prompt()
	.user("你好")
	.call();

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

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

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

Advisor Chain 的组装

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

scss 复制代码
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() 才真正触发调用

继续执行:

scss 复制代码
.content()

DefaultCallResponseSpec#content() 会调用 doGetObservableChatClientResponse()

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

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

ini 复制代码
var response = advisorChain.nextCall(chatClientRequest);

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

kotlin 复制代码
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,才第一次看到真正的模型调用:

scss 复制代码
@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 是供应商无关的接口:

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

它的同步入口是:

java 复制代码
@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,然后发起请求:

ini 复制代码
ChatCompletionCreateParams request =
	createRequest(prompt, false);
ChatCompletion chatCompletion =
	this.openAiClient.chat()
		.completions()
		.create(request);

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

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

一次调用的完整时序

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

rust 复制代码
sequenceDiagram
	participant U as "业务代码"
	participant C as "DefaultChatClient"
	participant A as "Advisor Chain"
	participant M as "ChatModelCallAdvisor"
	participant P as "OpenAiChatModel / SDK"
	U->>C: prompt().user("你好").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,再沿着下面这条路径拿到文本:

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

完整路径是:

rust 复制代码
ChatClientResponse
	-> ChatResponse
	-> Generation
	-> AssistantMessage
	-> text

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

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

建议打这些断点

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

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

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

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

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

回到开头

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

scss 复制代码
String content = chatClient.prompt()
	.user("你好")
	.call()
	.content();

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

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

相关源码

相关推荐
andongni2031 小时前
后端参数校验
spring boot·后端·mybatis
子林super1 小时前
使用dockerfile打包ng镜像演示
面试
幻灵尔依1 小时前
LLM 推理核心链路&缓存命中讲解
llm·agent·ai编程
全栈弄潮儿1 小时前
让 AI 解释一段看不懂的代码:学习和接手项目都适用
chatgpt·openai·ai编程
颜进强1 小时前
06 - OpenSpec change 从模糊想法到完整契约:new change / explore / propose 三连
前端·后端·ai编程
Cache技术分享1 小时前
499. Java 反射 - 获取类型上的注解
前端·后端
用户69371750013841 小时前
9531 款 AI 工具流量真相:当 90% 的访问涌向 100 个平台,普通创业者还有机会吗?
前端·后端
王中阳Go1 小时前
苏神百万年薪简历翻车:一个包装争议,给后端同学的 5 个工程视角警示
面试
颜进强1 小时前
07 - OpenSpec change 修正带与照单施工:update 修订 + apply 实现
前端·后端·ai编程