What Actually Happens When Spring AI's ChatClient Calls a Model
Run an example first
Still starting from these few lines of code from the warm-up article:
String content = chatClient.prompt()
.user("你好")
.call()
.content();
4 lines of code to complete a conversation with a large model.
Looking closely, questions arise:
- Who created
ChatClient? - What does
prompt()return? - When does
user("你好")become aUserMessage? - Does
call()actually invoke the model? - Where does the Advisor intervene?
- How is the final string extracted from
ChatResponse?
In reality, this code does not jump directly from ChatClient to OpenAiChatModel. There is a layer of logic in between, such as creating the request specification, assembling the Prompt, building the Advisor Chain, and then having the ChatModelCallAdvisor at the end of the chain call ChatModel.
There is also a very confusing point:
call() does not directly initiate a model request; the actual trigger for the request call is content().
This is also the main chain that this chapter will focus on explaining.
Version Baseline
| Project | Version |
|---|---|
| Spring AI | 2.0.0 |
| Commit | ef502da |
| Spring Boot | 4.1.0 |
| Java | 17 |
| Model Impl | OpenAiChatModel |
Unless otherwise specified, subsequent articles will follow this baseline to ensure no mixing of source code from different versions.
Minimal Example
First, introduce the OpenAI Starter.
We hand Spring AI's version management to the 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>
In the 2.0.0 OpenAI Chat configuration, model properties are placed directly under spring.ai.openai.chat.model, without options in between:
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
# Businesses generally use cheaper and more cost-effective models
model: gpt-4.1-mini
Then write a minimal 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();
}
}
What is injected here is ChatClient.Builder, not ChatClient.
This distinction is important.
Who Actually Created ChatClient
After introducing spring-ai-starter-model-openai, the Starter brings in the OpenAI model implementation, ChatClient, and the corresponding auto-configuration together.
Two main things happen during the startup phase:
OpenAiChatAutoConfigurationcreatesOpenAiChatModelbased on the configuration;ChatClientAutoConfigurationuses thisChatModelto createChatClient.Builder.
The approximate chain relationship can be seen in the flowchart below:
flowchart TD
A[spring-ai-starter-model-openai] --> B[OpenAiChatAutoConfiguration]
B --> C[OpenAiChatModel as ChatModel Bean]
C --> D[ChatClientAutoConfiguration]
D --> E[prototype ChatClient.Builder]
E --> F[Business code calls build]
F --> G[DefaultChatClient]
Continuing to look at these two configuration classes, the core code of OpenAiChatAutoConfiguration is not complicated:
@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;
}
Now look at 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);
}
Two details need attention here.
First, Spring AI auto-configures ChatClient.Builder, and the final ChatClient is created by the business code calling build().
Second, this Builder is prototype. Every time it is fetched from the Spring container, a new Builder is obtained, allowing business code to set different default systems, default advisors, and default options without interfering with each other.
Continuing into DefaultChatClientBuilder#build():
@Override
public ChatClient build() {
return new DefaultChatClient(this.defaultRequest);
}
At this point, the actual working implementation class DefaultChatClient appears.
Spring does not directly provide a global ChatClient here but provides a customizable Builder. This indeed makes it convenient for different business scenarios to set their own default configurations, but it also brings a new problem: when troubleshooting Bean creation issues, there is an extra layer of indirection. This naturally has pros and cons, and is a consistent characteristic of Spring family products.
prompt() Just Copies a Request Specification
Now back to the business code:
chatClient.prompt()
The implementation of DefaultChatClient#prompt() has only one line:
@Override
public ChatClientRequestSpec prompt() {
return new DefaultChatClientRequestSpec(
this.defaultChatClientRequest);
}
It neither creates a network request nor calls the model.
It just copies a new DefaultChatClientRequestSpec based on the default configuration saved by ChatClient. The default system text, messages, options, advisors, tools, and advisor params set in the Builder will all become the starting point for this request.
So the same ChatClient can be reused, and each prompt() has an independent request state.
user() Has Not Yet Created a UserMessage
Next step:
.user("你好")
This step is also simpler than imagined. The source code just temporarily stores the text in the RequestSpec:
@Override
public ChatClientRequestSpec user(String text) {
Assert.hasText(text, "text cannot be null or empty");
this.userText = text;
return this;
}
There is still no UserMessage at this point.
The actual message assembly happens in DefaultChatClientUtils#toChatClientRequest(). It processes system text, existing messages, and user text in order, then creates a Prompt:
Builder promptBuilder = Prompt.builder()
.messages(processedMessages)
.chatOptions(processedChatOptions);
return ChatClientRequest.builder()
.prompt(promptBuilder.build())
.context(new ConcurrentHashMap<>(
inputRequest.getAdvisorParams()))
.build();
The object that ultimately enters the Advisor Chain is not a scattered string, but:
public record ChatClientRequest(
Prompt prompt,
Map<String, Object> context) {
}
Prompt holds the messages and options sent to the model; context holds the data shared by Advisors in the call chain.
The two cannot be conflated.
What the model provider ultimately cares about is Prompt; Advisors also need the accompanying context to pass control information like Memory, Structured Output, and Tool Calling.
Why call() Did Not Call the Model
Next, look at the most easily misjudged step:
.call()
The source code of DefaultChatClientRequestSpec#call() is as follows:
@Override
public CallResponseSpec call() {
BaseAdvisorChain advisorChain = buildAdvisorChain();
return new DefaultCallResponseSpec(
DefaultChatClientUtils.toChatClientRequest(this),
advisorChain,
this.observationRegistry,
this.chatClientObservationConvention);
}
It only does two things:
- Build the Advisor Chain;
- Convert the current RequestSpec into a
ChatClientRequest.
Then it returns DefaultCallResponseSpec.
There is no chatModel.call(...), and no network request.
So after this code executes, the model has not yet received anything:
CallResponseSpec responseSpec = chatClient.prompt()
.user("你好")
.call();
Only by continuing to call content(), chatResponse(), chatClientResponse(), or entity() will the synchronous request actually begin.
The naming is indeed misleading.
From the actual behavior, call() is more like "switch to synchronous call mode and construct ResponseSpec"; the real trigger for the model call is the subsequent terminal method.
Assembly of the Advisor Chain
Continuing down, call() first enters 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();
}
Business-configured Advisors enter the list first, then Spring AI places two model-calling Advisors at the end of the chain:
ChatModelCallAdvisorhandles synchronous calls;ChatModelStreamAdvisorhandles streaming calls.
2.0.0 also registers ToolCallingAdvisor by default. Even if the current request has no static Tools, it remains in the chain so that other Advisors can dynamically add tools at runtime.
Let's remember one point first:
ChatModelCallAdvisor is the last stop of the synchronous Advisor Chain leading to ChatModel.
content() Actually Triggers the Call
Continue execution:
.content()
DefaultCallResponseSpec#content() calls doGetObservableChatClientResponse():
@Override
public @Nullable String content() {
ChatResponse chatResponse =
doGetObservableChatClientResponse(this.request)
.chatResponse();
return getContentFromChatResponse(chatResponse);
}
Inside the Observation wrapper, the real entry point is:
var response = advisorChain.nextCall(chatClientRequest);
DefaultAroundAdvisorChain#nextCall() pops an Advisor from the queue each time and calls its adviseCall():
var advisor = this.callAdvisors.pop();
return observation.observe(() -> {
var response = advisor.adviseCall(
chatClientRequest, this);
observationContext.setChatClientResponse(response);
return response;
});
A regular Advisor continues to call chain.nextCall(request) within its own adviseCall(), and the request is passed backward layer by layer; when the response returns, it comes back forward along the original path.
It can be seen that this is a typical around chain.
How the End of the Chain Calls ChatModel
When the request reaches ChatModelCallAdvisor, the actual model call is seen for the first time:
@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();
}
Two boundary conversions are completed here:
- Extract
PromptfromChatClientRequestand hand it toChatModel; - Repackage the
ChatResponsereturned byChatModeland the Advisor context into aChatClientResponse.
ChatModel is a vendor-agnostic interface:
public interface ChatModel
extends Model<Prompt, ChatResponse>,
StreamingChatModel {
@Override
ChatResponse call(Prompt prompt);
}
The upper layer only depends on ChatModel. When switching to Anthropic, Ollama, or other model implementations, ChatClient and the Advisor Chain do not need to change along with the vendor SDK.
This layer of abstraction solves the problem of isolating vendor differences, which is very clever.
But the cost is also obvious—troubleshooting is difficult. When investigating a conversation request, one must go from ChatClient through the Advisor Chain to see the actual Provider implementation.
How OpenAiChatModel Enters the SDK
The ChatModel implementation injected in the current example is OpenAiChatModel.
Its synchronous entry point is:
@Override
public ChatResponse call(Prompt prompt) {
Prompt requestPrompt = buildRequestPrompt(prompt);
verifyPromptChatOptions(requestPrompt);
return this.internalCall(requestPrompt, null);
}
internalCall() first converts Spring AI's Prompt into the OpenAI Java SDK's ChatCompletionCreateParams, then initiates the request:
ChatCompletionCreateParams request =
createRequest(prompt, false);
ChatCompletion chatCompletion =
this.openAiClient.chat()
.completions()
.create(request);
After the vendor returns the result, OpenAiChatModel converts the choices into Spring AI's Generation, then assembles them into a unified ChatResponse.
At this point, a complete synchronous call chain is finished.
Complete Sequence of a Single Call
Let's review this call chain with a sequence diagram:
sequenceDiagram
participant U as "Business Code"
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: Execute registered Advisors in order
A->>M: adviseCall(request)
M->>P: chatModel.call(prompt)
P->>P: createRequest() and call OpenAI SDK
P-->>M: ChatResponse
M-->>A: ChatClientResponse
A-->>C: ChatClientResponse
C-->>U: Extract output.text
Note the first two steps of the sequence diagram:
prompt().user(...).call() returns DefaultCallResponseSpec; it is content() that lets the request enter the Advisor Chain.
When analyzing retries, Tool Calling, and structured output later, it is easy to misjudge the execution position, so this part needs to be memorized.
Where the Final String Comes From
After the Advisor Chain returns ChatClientResponse, content() extracts the ChatResponse inside it, then gets the text along the following path:
private static @Nullable String getContentFromChatResponse(
@Nullable ChatResponse chatResponse) {
return Optional.ofNullable(chatResponse)
.map(ChatResponse::getResult)
.map(Generation::getOutput)
.map(AbstractMessage::getText)
.orElse(null);
}
The complete path is:
ChatClientResponse
-> ChatResponse
-> Generation
-> AssistantMessage
-> text
So content() is just a text extraction method convenient for callers to use.
If the business needs token usage, finish reason, response metadata, or Advisor context, do not prematurely compress the response into a string; use chatResponse() or chatClientResponse() instead.
Suggested Breakpoints
If reading the article feels a bit vague, you can directly clone the GitHub repository I provided, open the project, and set breakpoints in the following order:
| Order | Breakpoint Location | What to Observe |
|---|---|---|
| 1 | ChatClientAutoConfiguration#chatClientBuilder |
How Builder gets ChatModel |
| 2 | DefaultChatClient#prompt |
How the default request is copied |
| 3 | DefaultChatClientRequestSpec#user |
Where user text is stored |
| 4 | DefaultChatClientRequestSpec#call |
How Request and Advisor Chain are prepared |
| 5 | DefaultCallResponseSpec#content |
Where actual execution starts |
| 6 | DefaultAroundAdvisorChain#nextCall |
Which Advisor is currently executing |
| 7 | ChatModelCallAdvisor#adviseCall |
How ChatClientRequest enters ChatModel |
| 8 | OpenAiChatModel#call |
How the Provider layer handles Prompt |
| 9 | OpenAiChatModel#internalCall |
Where the OpenAI SDK request is sent |
Keep observing during debugging, focusing on these three objects:
DefaultChatClientRequestSpec: Request parameters not yet finally assembled;ChatClientRequest: Already containsPrompt + context;ChatClientResponse: Already containsChatResponse + context.
Although these seem to be requests and responses, they are actually at completely different layers.
Back to the Beginning
Now let's look back at these 4 lines of code:
String content = chatClient.prompt()
.user("你好")
.call()
.content();
It can be expanded into the following main chain:
ChatClient.Builder
-> DefaultChatClient
-> DefaultChatClientRequestSpec
-> ChatClientRequest(Prompt, context)
-> DefaultAroundAdvisorChain
-> ChatModelCallAdvisor
-> ChatModel
-> OpenAiChatModel
-> OpenAI Java SDK
-> ChatResponse
-> content
ChatClientis responsible for providing the fluent API and saving default configurations;- RequestSpec is responsible for collecting request parameters for this call;
- Advisor Chain is responsible for organizing the enhancement logic before and after the call;
ChatModelisolates vendor differences;OpenAiChatModelfinally completes the conversion between Spring AI objects and OpenAI SDK objects.
Each layer has its own responsibility.
After clarifying this main chain, subsequent topics like Chat Memory, Tool Calling, Structured Output, and RAG will be easier to understand.
In the next article, we will dissect the Starter's auto-configuration: exactly which Beans a single spring-ai-starter-model-openai dependency places into the Spring container.
Related Source Code
- Spring AI v2.0.0 Source Code
- ChatClientAutoConfiguration.java
- DefaultChatClient.java
- DefaultAroundAdvisorChain.java
- ChatModelCallAdvisor.java
- OpenAiChatModel.java
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
Chapter 2 "How Does the Starter Auto-Configure ChatClient?": https://juejin.cn/post/7675302968470061083