ChatModel & ChatClient
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Written against Spring AI 2.0.1, Spring Boot 4.1.x and Java 21 (verified 2026-09-29). This page adds the first user-visible
capability of the running DocsAssistant service: a POST /api/chat endpoint that takes a question and returns an
answer. It builds on the project from Setup & Starters (Ollama
llama3.1:8b, no hosted account needed). Every Java class below was compiled against the 2.0.1 jars, and the behaviour
of the defaults and options was checked with a stub ChatModel that replays canned replies, because no model server
was running when the page was written; the printed prompts are therefore what Spring AI builds, not what a model
answers.
Two levels: ChatModel and ChatClient
Spring AI has two API levels for chat. ChatModel is the portable model interface, the counterpart of the JDBC
driver: you give it a Prompt (a list of messages plus optional options) and get a ChatResponse back. It also
extends StreamingChatModel, so the same object streams. ChatClient is the fluent layer built on top of it,
comparable to JdbcClient: it assembles the prompt from parts, applies advisors, maps the reply to a Java type and
integrates with Spring Boot’s observability. Most application code uses ChatClient; ChatModel is the right tool when
you already have a fully built Prompt, want no advisors, or are writing a library on top of Spring AI.
In the 0.8.x releases that Mastering Spring AI targets, the name ChatClient belonged to what is now ChatModel, and
the fluent ChatClient did not exist. Code copied from those sources that calls chatClient.generate(…) or reads
getContent() needs to be rewritten: the text of an assistant message is getText() in 2.0.
ChatModel
The interface has a convenience call(String) returning text, the full call(Prompt) returning a ChatResponse, and a
stream(Prompt) returning a Flux<ChatResponse>. A ChatResponse holds one or more Generation objects, each with an
AssistantMessage output, plus response metadata such as token usage.
package com.example.docsassistant;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
@Service
class ChatModelDirect {
private final ChatModel chatModel;
ChatModelDirect(ChatModel chatModel) {
this.chatModel = chatModel;
}
String oneShot(String question) {
return this.chatModel.call(question);
}
String withPrompt(String question) {
ChatResponse response = this.chatModel.call(new Prompt(question));
return response.getResult().getOutput().getText();
}
Flux<String> streamed(String question) {
return this.chatModel.stream(new Prompt(question))
.mapNotNull(response -> response.getResult().getOutput().getText());
}
}
A model is created with start-up options taken from the spring.ai.<provider>.chat.* properties. When you call
ChatModel.call(Prompt) directly, options carried by the Prompt completely take precedence over those start-up
options, and a Prompt without options uses the model’s defaults. ChatClient, by contrast, lets you give only the
values you want to change; see Chat Options. Source:
Spring AI: Chat Model API.
ChatClient
Spring Boot auto-configuration registers a prototype-scoped ChatClient.Builder for the active ChatModel. Inject the
builder, set defaults once, and build a ChatClient bean. The builder is prototype-scoped on purpose: each injection point
gets its own copy, so configuring one client never changes another.
package com.example.docsassistant;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
class ChatClientConfig {
@Bean
ChatClient docsChatClient(ChatClient.Builder builder) {
return builder
.defaultSystem("""
You are DocsAssistant, a helper for the Irurueta documentation site.
Answer in at most three short paragraphs and say so when you do not know.
""")
.defaultAdvisors(new SimpleLoggerAdvisor())
.build();
}
}
SimpleLoggerAdvisor logs each request and response at DEBUG on the
org.springframework.ai.chat.client.advisor logger. It is useful on a laptop and a privacy risk anywhere else, because
it writes the whole prompt and reply, which may contain users' data; see
Advisors and Security & Guardrails.
Source: Spring AI: Chat Client API.
package com.example.docsassistant;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
class DocsAssistantController {
record ChatRequest(String question) { }
record ChatAnswer(String answer) { }
private final ChatClient chatClient;
DocsAssistantController(ChatClient docsChatClient) {
this.chatClient = docsChatClient;
}
@PostMapping("/api/chat")
ChatAnswer chat(@RequestBody ChatRequest request) {
String answer = this.chatClient.prompt()
.user(request.question())
.call()
.content();
return new ChatAnswer(answer);
}
}
Calling it is an ordinary JSON request: curl -H 'Content-Type: application/json' -d '{"question":"What is an advisor?"}'
localhost:8080/api/chat returns {"answer":"…"}. The endpoint has no authentication or memory yet. The question text
is untrusted input that goes straight into the prompt, so treat the endpoint like any other input surface; per-user
identity arrives with Chat Memory, and prompt-injection defences with
Security & Guardrails. Source:
Spring AI: Chat Client API.
The fluent prompt
chatClient.prompt() opens a request. The variants are prompt() (build it piece by piece), prompt(String) (a user
message) and prompt(Prompt) (a Prompt you built yourself, which is how a hand-written list of messages or an options
object enters the fluent API). The request spec then offers system(…), user(…), messages(…), options(…),
tools(…), toolContext(…), advisors(…) and templateRenderer(…), and finally call() or stream().
Nothing is sent to the model until you call a terminal method such as content(), chatResponse() or entity(…).
call() and stream() only choose the synchronous or the reactive path.
Builder defaults and per-request overrides
Settings that every request shares belong on the builder; the same setting without the default prefix overrides it for
one request. The builder methods below were listed from the compiled 2.0.1 interface.
| Builder default | Request override | What it sets |
|---|---|---|
|
|
The system prompt, optionally a template with |
|
|
A default user message template |
|
|
Model, temperature and the other options; both take a builder, not a built object |
|
|
The advisor chain, including memory and RAG |
|
|
Objects with |
|
|
Ready-made |
|
|
Data passed to tools that is never sent to the model |
|
|
How |
package com.example.docsassistant;
import java.util.Map;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
class ClientDefaults {
@Bean
ChatClient defaultsDemo(ChatClient.Builder builder, DocsTools docsTools) {
return builder
.defaultSystem("You are DocsAssistant. Answer as {tone} as you can.")
.defaultUser(u -> u.text("{question}"))
.defaultOptions(ChatOptions.builder().temperature(0.2).maxTokens(600))
.defaultAdvisors(new SimpleLoggerAdvisor())
.defaultTools(docsTools)
.defaultToolContext(Map.of("tenant", "public"))
.build();
}
String ask(ChatClient client, String question) {
return client.prompt()
.system(s -> s.param("tone", "concisely"))
.user(u -> u.param("question", question))
.call()
.content();
}
ChatClient variant(ChatClient base) {
return base.mutate().defaultSystem("You are DocsAssistant. Answer in Spanish.").build();
}
}
Running ask against a stub model produced a system message "You are DocsAssistant. Answer as concisely as you can." and
a user message "why?" for the question "why?", so the template parameters were substituted as expected.
mutate() returns a new builder initialised with the client’s defaults (on a ChatClient) or with the request’s current
settings (on a request spec), which is how you derive a variant without repeating the configuration. DocsTools is the
searchDocs tool from the vector-rag tools
section; how tools are described and executed is the subject of Tool Calling.
The system text is a template rendered with the default StringTemplate renderer, whose delimiters are curly braces. A literal brace in a default prompt, for example a JSON example, must be avoided or the delimiters changed; that is the topic of Prompts & Templates. Source: Spring AI: Chat Client API.
call() and stream()
The terminal methods differ between the two paths. On call() the result is the whole answer; on stream() it is a
reactive Flux that emits pieces as the model produces them.
| Terminal | After call() |
After stream() |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
A Java type parsed from the reply; see Structured Output |
Not available: typed parsing needs the complete reply |
|
A |
Not available |
package com.example.docsassistant;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
@Service
class StreamPreview {
private final ChatClient chatClient;
StreamPreview(ChatClient docsChatClient) {
this.chatClient = docsChatClient;
}
Flux<String> tokens(String question) {
return this.chatClient.prompt().user(question).stream().content();
}
Flux<ChatResponse> chunks(String question) {
return this.chatClient.prompt().user(question).stream().chatResponse();
}
}
Returning that Flux from a controller as text/event-stream gives a server-sent-events endpoint; that is built in
Streaming & Reactive. Reactor comes with the Spring AI modules, so no
extra dependency is needed for the type itself. Source:
Spring AI: Chat Client API.
Usage metadata
Hosted models bill by tokens, and ChatResponse carries the counts. Ask for chatResponse() instead of content() and
read the Usage from the metadata. Individual values can be null when a provider does not report them, so convert
with care.
package com.example.docsassistant;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.stereotype.Service;
@Service
class UsageReporter {
record Answer(String text, int promptTokens, int completionTokens, int totalTokens) { }
private final ChatClient chatClient;
UsageReporter(ChatClient docsChatClient) {
this.chatClient = docsChatClient;
}
Answer askWithUsage(String question) {
ChatResponse response = this.chatClient.prompt()
.user(question)
.call()
.chatResponse();
String text = response.getResult().getOutput().getText();
Usage usage = response.getMetadata().getUsage();
return new Answer(text,
orZero(usage.getPromptTokens()),
orZero(usage.getCompletionTokens()),
orZero(usage.getTotalTokens()));
}
/** Cost in the provider's currency, given the price per million tokens for input and output. */
static double cost(Answer answer, double inputPerMillion, double outputPerMillion) {
return (answer.promptTokens() * inputPerMillion + answer.completionTokens() * outputPerMillion) / 1_000_000.0;
}
private static int orZero(Integer value) {
return value == null ? 0 : value;
}
}
Source: Spring AI: Chat Client API. The estimate in cost is
where and are the prompt and completion token counts and , the prices per million tokens. With the illustrative prices 0.50 and 1.50 per million tokens, a call with 1200 prompt and 300 completion tokens costs
which the method returned in a test. The prices are made up for the arithmetic; use your provider’s price list. The usage of a single call is also
exported as a metric, which
Observability turns into dashboards, and the token vocabulary is explained in
Tokens & Tokenization. In 2.0 the ToolCallingAdvisor also
accumulates the usage of every model call in a tool loop into the final response, so the numbers are cumulative rather
than those of the last call only. Source: Spring AI: Upgrade Notes
and Spring AI: Chat Client API.
More than one ChatClient
Applications often want two clients: a cheap fast one and a strong one, a local one and a hosted one, or two personalities of the same model.
For the same provider, inject the auto-configured builder twice. Because it is prototype-scoped, each bean is independent.
For different providers, the reference warns that building a client with ChatClient.create(chatModel) or
ChatClient.builder(chatModel) bypasses the auto-configured builder, so observability and any
ChatClientBuilderCustomizer beans are ignored: the one-argument ChatClient.builder(chatModel) uses a no-op
ObservationRegistry. To keep observations, build with the five-argument ChatClient.builder(…), passing the
ObservationRegistry, the two observation conventions and the auto-configured ToolCallingAdvisor.Builder, as the
auto-configuration itself does. Then pass that builder through ChatClientBuilderConfigurer, which applies only the
customizer beans. The auto-configured ChatClient.Builder itself needs exactly one ChatModel bean: with two model beans and
no @Primary one, creating it fails with a "expected single matching bean but found 2" error (checked with an
ApplicationContextRunner on 2.0.1). Mark one ChatModel @Primary, as the example does, if any code injects the
builder.
package com.example.docsassistant;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.ToolCallingAdvisor;
import org.springframework.ai.chat.client.advisor.observation.AdvisorObservationConvention;
import org.springframework.ai.chat.client.observation.ChatClientObservationConvention;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.model.chat.client.autoconfigure.ChatClientBuilderConfigurer;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.ai.ollama.api.OllamaChatOptions;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
@Configuration
class MultiChatClientConfig {
private final ChatClientBuilderConfigurer configurer;
private final ObjectProvider<ObservationRegistry> observationRegistry;
private final ObjectProvider<ChatClientObservationConvention> chatClientConvention;
private final ObjectProvider<AdvisorObservationConvention> advisorConvention;
private final ObjectProvider<ToolCallingAdvisor.Builder<?>> toolCallingAdvisorBuilder;
MultiChatClientConfig(ChatClientBuilderConfigurer configurer,
ObjectProvider<ObservationRegistry> observationRegistry,
ObjectProvider<ChatClientObservationConvention> chatClientConvention,
ObjectProvider<AdvisorObservationConvention> advisorConvention,
ObjectProvider<ToolCallingAdvisor.Builder<?>> toolCallingAdvisorBuilder) {
this.configurer = configurer;
this.observationRegistry = observationRegistry;
this.chatClientConvention = chatClientConvention;
this.advisorConvention = advisorConvention;
this.toolCallingAdvisorBuilder = toolCallingAdvisorBuilder;
}
// Exactly one ChatModel must be primary for the auto-configured ChatClient.Builder to resolve.
@Bean
@Primary
ChatModel primaryChatModel(OllamaChatModel model) {
return model;
}
@Bean
@Primary
ChatClient localChatClient(OllamaChatModel model) {
return build(model);
}
@Bean
ChatClient hostedChatClient(OpenAiChatModel model) {
return build(model);
}
// Same arguments the auto-configured builder uses, so observations and the tool-calling advisor are kept.
private ChatClient build(ChatModel model) {
ChatClient.Builder builder = ChatClient.builder(model,
this.observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP),
this.chatClientConvention.getIfUnique(), this.advisorConvention.getIfUnique(),
this.toolCallingAdvisorBuilder.getIfAvailable());
return this.configurer.configure(builder).build();
}
// Same provider, another personality: the auto-configured builder is prototype-scoped.
@Bean
ChatClient terseChatClient(ChatClient.Builder builder) {
return builder
.defaultSystem("Answer in one sentence.")
.defaultOptions(OllamaChatOptions.builder().temperature(0.0))
.build();
}
}
For both OllamaChatModel and OpenAiChatModel to exist at once, both starters must be present and
spring.ai.model.chat must be left unset, because that property selects a single provider (see
Setup & Starters). Injecting a
specific client by bean name or @Qualifier then picks the model per use case. Another pattern is a single
OpenAI-compatible model class instantiated twice with different base-url and api-key values, which the reference
demonstrates with Groq and OpenAI; read keys from environment variables, as the reference example does, and never
from source code. Source: Spring AI: Chat Client API.
Why the books look different
Three renames account for most of the friction when reading older material with 2.0 open. ChatClient in 0.8.x is
ChatModel now. The assistant text accessor getContent() became getText(). And code that configured models through
spring.ai.<provider>.chat.options. uses the flattened spring.ai.<provider>.chat. paths in 2.0. The prompt itself is
covered by Prompts & Templates, the options by
Chat Options, and cross-cutting behaviour by
Advisors.