
先跑个示例
仍然从预热文章里的这几行代码开始:
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 和对应的自动配置一起带进来。
启动阶段主要发生两件事:
OpenAiChatAutoConfiguration根据配置创建OpenAiChatModel;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。