跪拜 Guibai
← Back to the summary

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:

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:

  1. OpenAiChatAutoConfiguration creates OpenAiChatModel based on the configuration;
  2. ChatClientAutoConfiguration uses this ChatModel to create ChatClient.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:

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:

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:

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 &#34;Business Code&#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: 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:

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

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

Comments

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