跳到主要内容

数据截至 (上游 commit c988e72ab728)

ChatClient:从流式 API 到一个 ChatClientRequest

30 秒导读: Spring AI 让你写 chatClient.prompt().user("讲个笑话").call().content()。这一串点号背后没有魔法:每个方法只是往一个可变的请求草稿DefaultChatClientRequestSpec)里塞状态;直到你调 call()stream(),一个叫 toChatClientRequest 的函数才把草稿一次性冷冻成不可变的 ChatClientRequest。本章只讲这段"从草稿到冷冻"的路,不碰后面的顾问链和工具执行。


1. 这是什么(零基础也能懂)

一句话定义: ChatClient 是 Spring AI 给应用开发者的门面——一套链式(fluent)API,把"我要问模型什么"翻译成模型层能吃的请求对象。

解决什么问题: 直接用底层 ChatModel 调模型,你得自己拼 List<Message>、自己决定 system 放第几条、自己合并"应用默认参数"和"这次调用的参数"、自己把工具注册到 options 里。ChatClient 把这些琐事收进一个链式调用。

它管什么、不管什么:

职责归谁本章讲吗
收集 system / user / messages / 变量 / 工具 / 参数ChatClient 的各个 Spec 类
把收集到的东西装配成一个不可变请求DefaultChatClientUtils.toChatClientRequest讲(重点)
请求发出前后的拦截、记忆、RAG、日志Advisor 责任链不讲(第 2 章
模型说"我要调工具"之后的多轮循环ToolCallingAdvisor / ToolCallingManager不讲(第 3 章
真正发 HTTP 给 OpenAI / AnthropicChatModel 各家实现不讲(第 4 章

用起来什么样:

// 示意,非源码
ChatClient client = ChatClient.builder(chatModel) // 起点:一个 ChatModel
.defaultSystem("你是 {role},只用中文回答") // 应用级默认值
.build();

String answer = client.prompt() // 开一次请求
.system(s -> s.param("role", "资深 Java 工程师")) // 填模板变量
.user("解释一下 volatile") // 本次问题
.call() // 阻塞式执行
.content(); // 取纯文本

一句话直觉:ChatClient 当成 Spring 的 RestClient——builder 阶段配"这个客户端的默认值",prompt() 阶段配"这一次请求的东西",call() 是扳机。区别只在于:这里的"请求体"不是 JSON,是一串带角色的消息。


2. 顶层全景(它大概怎么转)

这节给一张图 + 一张部件表,先看懂大盘。

怎么读这张图: 从上往下是时间顺序。上半段(builder)在应用启动时跑一次;下半段(prompt → call)每次请求跑一次。

ChatClient.builder(chatModel) ← 应用启动时,跑一次
│ defaultSystem / defaultTools / defaultOptions ...

DefaultChatClientBuilder
└─ defaultRequest :一个"默认值容器" RequestSpec
│ .build()

DefaultChatClient ────────────────────────────────┐

───────────────────────────────────────────────────── 每次请求

.prompt() → 新的 RequestSpec(拷贝默认值) ◄┘

│ .system(...) .user(...) .tools(...) .options(...)
│ (只是往草稿上堆状态,没有任何计算)

.call() / .stream() ← 扳机

┌───────────┴───────────────┐
▼ ▼
buildAdvisorChain() toChatClientRequest()
(顾问链,第 2 章) (装配不可变请求,本章重点)
└───────────┬───────────────┘

DefaultCallResponseSpec / DefaultStreamResponseSpec

部件一句话职责:

部件干什么文件
ChatClient接口门面:静态工厂 create / builder,实例方法 prompt / mutatespring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClient.java
DefaultChatClientBuilder默认值容器;所有 defaultXxx 都写进同一个 defaultRequestspring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java
DefaultChatClient唯一实现;prompt() 就是拷一份默认 specspring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java
六个 DefaultXxxSpec分工收集状态:user / system / advisor / 请求整体 / 响应(阻塞)/ 响应(流)同上(都是内部静态类)
DefaultChatClientUtils唯一的装配函数 toChatClientRequestspring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClientUtils.java
ChatClientRequest / ChatClientResponse两个 record:Prompt + 一张 context Mapspring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientRequest.javaChatClientResponse.java

主线走一遍(高层): 用户链式调用 → 状态堆在 RequestSpec 上 → call() 触发 → 消息按 system/中段/user 排序并渲染模板 → ChatOptions 三段合并 → 工具按条件注入 → 打包成 ChatClientRequest → 连同顾问链交给 DefaultCallResponseSpec


3. 门面与 Builder:默认值住在哪里

这节回答一个容易被忽略的问题:defaultSystem(...) 到底存哪儿了。

3.1 静态工厂只是 Builder 的糖

ChatClient 上四个 create / builder 重载层层委托,最终都落到同一个五参数版本:

// ChatClient.java:117 —— builder(ChatModel, ObservationRegistry, ...)
return new DefaultChatClientBuilder(chatModel, observationRegistry, chatClientObservationConvention,
advisorObservationConvention, toolCallingAdvisorBuilder);

create(chatModel) 只是 builder(chatModel).build()ChatClient.java:63-78)。所以 实现只有一条路DefaultChatClientBuilderDefaultChatClient

多出来的两个参数是留给 Spring Boot 自动配置的:ObservationRegistry 用于埋点,toolCallingAdvisorBuilder 用于替换默认的工具顾问(ChatClient.java:117-126,细节见第 6 章)。

3.2 Builder 没有自己的字段——它只有一个 RequestSpec

这是本章第一个反直觉点。DefaultChatClientBuilder 内部只有一个字段

// DefaultChatClientBuilder.java:61
protected final DefaultChatClientRequestSpec defaultRequest;

构造函数里用一堆空集合初始化它(DefaultChatClientBuilder.java:108-111),然后每个 defaultXxx 方法都是一行转发:

Builder 方法实际动作行号
defaultSystem(String)defaultRequest.system(text)DefaultChatClientBuilder.java:168-171
defaultUser(Resource, Charset)先读成字符串再 defaultRequest.user(...):147-157
defaultTools(Object...)defaultRequest.tools(toolObjects):194-198
defaultOptions(ChatOptions.Builder)defaultRequest.options(customizer):137-140
defaultTemplateRenderer(...)defaultRequest.templateRenderer(...):238-242

好处: 默认值和请求值用的是同一套数据结构和同一套校验,不需要写两遍合并逻辑。代价: 默认值是可变的共享状态——所以下一小节的拷贝语义才关键。

3.3 clone 与 mutate:靠"重放"而不是深拷贝

Builder.clone() 一行就完事:

// DefaultChatClientBuilder.java:118-120
public Builder clone() {
return this.defaultRequest.mutate();
}

真正干活的是 DefaultChatClientRequestSpec.mutate()DefaultChatClient.java:933-966):它新建一个 builder,然后把自己身上的状态一项项重新调用一遍——defaultTemplateRendererdefaultToolsdefaultToolContextdefaultAdvisorsdefaultUserdefaultSystemdefaultOptionsaddMessages

这叫重放式克隆:不是复制字段引用,而是走一遍公开的设置路径。结果是克隆体和原体互不影响——DefaultChatClientBuilderTests.whenCloneBuilderThenModifyingOriginalDoesNotAffectClone 就是钉这条的(测试文件 spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/DefaultChatClientBuilderTests.java:129-146)。

ChatClient.mutate()DefaultChatClient.java:146-148)转发到同一个方法,用途是"基于已有 client 派生一个改了默认值的新 client"。

3.4 prompt() 的三个重载

// DefaultChatClient.java:106-108
public ChatClientRequestSpec prompt() {
return new DefaultChatClientRequestSpec(this.defaultChatClientRequest);
}

走的是拷贝构造函数DefaultChatClient.java:804-810),所以每次 prompt() 都拿到一份和默认值隔离的新草稿——并发请求不会互相踩踏。

prompt(Prompt):117-139)多做两件事:把 prompt.getInstructions() 塞进 messages;如果 Prompt 自带 options,就和 spec 已有的 optionsCustomizercombineWith。这里能安全 mutate 是因为拷贝构造函数已经把 optionsCustomizer clone 过一份(DefaultChatClient.java:840),动的是副本,Builder 的默认值不会被污染。


4. 六个 Spec 类:谁收集什么

链式 API 之所以"点得出来",是因为每一段都返回一个专门的 Spec 接口。实现全在 DefaultChatClient 的内部静态类里。

Spec 类定义位置收集什么出口
DefaultChatClientRequestSpecDefaultChatClient.java:761请求的全部状态(18 个字段)call() / stream()
DefaultPromptUserSpec:150user 的 text / params / media / metadatauser(Consumer) 吸收
DefaultPromptSystemSpec:268system 的 text / params / metadata(没有 media)system(Consumer) 吸收
DefaultAdvisorSpec:351advisors 列表 + params Mapadvisors(Consumer) 吸收
DefaultCallResponseSpec:431已装配好的 request + 顾问链content() / entity() / chatResponse()
DefaultStreamResponseSpec:676同上,但走 Fluxcontent() / chatResponse()

前四个是输入侧,后两个是输出侧——toChatClientRequest 正好是两者之间的那道门。

4.1 三个小 Spec 是"临时收纳盒"

DefaultPromptUserSpec / DefaultPromptSystemSpec / DefaultAdvisorSpec 都不长期存在。它们被 new 出来、交给用户的 lambda 填、然后立刻被抄进主 spec:

// DefaultChatClient.java:1156-1166 —— user(Consumer<PromptUserSpec>)
var us = new DefaultPromptUserSpec();
consumer.accept(us);
this.userText = StringUtils.hasText(us.text()) ? us.text() : this.userText;
this.userParams.putAll(us.params());
this.media.addAll(us.media());
this.userMetadata.putAll(us.metadata());

注意这里的非对称合并规则system(Consumer):1117-1126 同理):

  • text覆盖,且只在新值非空时覆盖——所以 .system(s -> s.param("role", "x")) 不会抹掉 builder 里的 defaultSystem
  • params / metadata / media追加putAll / addAll)。

这条规则是"默认 system 提示词 + 每次请求只填变量"这个常见用法能成立的原因。

4.2 tools() 的类型分发

tools(Object...) 接收异构参数,靠 instanceof 分拣(DefaultChatClient.java:1028-1072):

传进来的东西去向
ToolCallback / ToolCallback[]直接进 toolCallbacks 列表
ToolCallbackProvider / ToolCallbackProvider[]toolCallbackProviders延迟到装配时才展开
Collection<?>逐个元素再走一遍上面的规则
其它任意对象当成 @Tool 注解的 POJO,攒起来交给 ToolCallbacks.from(...) 生成回调(:1067-1069

ToolCallbackProvider 被单独留一个列表、而不是当场展开,是为 MCP 这类"工具集合运行时才知道"的场景准备的(第 6 章)。


5. 核心装配:toChatClientRequest 做的三件事

这是本章的心脏。整个函数只有 88 行(DefaultChatClientUtils.java:50-137),源码里用注释分成三段:MESSAGES / OPTIONS / REQUEST

【MESSAGES】 systemText ──(有 params 才渲染)──► SystemMessage ┐
messages ────────原样────────► ...中段... ├─► List<Message>
userText ──(有 params 才渲染)──► UserMessage(+media) ┘

【OPTIONS】 chatModel.getOptions().mutate() ← 起点:模型自己的默认值
└─ combineWith(用户 optionsCustomizer)
└─ 若 builder 是 ToolCallingChatOptions.Builder:
validateToolCallbacks → toolCallbacks / toolContext

【REQUEST】 Prompt(messages, options) + context = ConcurrentHashMap(advisorParams)
└────────────────► ChatClientRequest(不可变 record)

5.1 消息顺序:system 在前、messages 居中、user 在后

代码写得几乎像散文,三段各自带注释(DefaultChatClientUtils.java:59 / :76 / :81):

// DefaultChatClientUtils.java:57-97(结构,已省略渲染细节)
List<Message> processedMessages = new ArrayList<>();
// System Text => First in the list
if (StringUtils.hasText(processedSystemText)) { processedMessages.add(SystemMessage...); }
// Messages => In the middle of the list
if (!CollectionUtils.isEmpty(inputRequest.getMessages())) { processedMessages.addAll(...); }
// User Text => Last in the list
if (StringUtils.hasText(processedUserText)) { processedMessages.add(UserMessage...); }

这条顺序是硬编码的,和你链式调用的先后无关。 你写 .user("A").system("B") 还是 .system("B").user("A"),出来的都是 [system B, user A]

这带来一个实用后果:.messages(历史消息) 塞进去的对话历史永远夹在 system 和本轮 user 之间——正好是聊天模型期望的排布。测试 whenSystemTextAndSystemMessageAreProvidedThenSystemTextIsFirstwhenUserTextAndUserMessageAreProvidedThenUserTextIsLast 分别锁住两端(spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/DefaultChatClientUtilsTests.java:173:193)。

另一个细节:systemText / userText 各自只有一个(是 String 字段不是列表),后设置的覆盖先设置的。要多条 system 消息,只能走 .messages(new SystemMessage(...)) 从中段进——但那样它就排在第一条 system 之后了。

5.2 模板渲染:只有传了变量才渲染

先看直觉。Spring AI 的 prompt 模板长这样:

// 示意,非源码
client.prompt()
.user(u -> u.text("把 {text} 翻译成 {lang}") // 模板串
.param("text", "hello")
.param("lang", "中文")) // 变量
.call().content();

真实实现是临时建一个 PromptTemplate 渲染一次:

// DefaultChatClientUtils.java:84-90
if (!CollectionUtils.isEmpty(inputRequest.getUserParams())) {
processedUserText = PromptTemplate.builder()
.template(processedUserText)
.variables(inputRequest.getUserParams())
.renderer(inputRequest.getTemplateRenderer())
.build()
.render();
}

这里藏着最容易踩的坑:那个 if 判的是"有没有传变量",不是"文本里有没有占位符"。

你的写法发生什么
文本有 {name},且 param("name", ...)正常渲染
文本有 {name},但一个 param 都没传不渲染{name} 原样发给模型
文本有 {name}{age},只传了 name渲染器抛异常(见下)
文本里有 JSON 大括号 {"k":1},且传了任意 param被当成模板变量,报错

渲染器由 TemplateRenderer 抽象(spring-ai-commons/src/main/java/org/springframework/ai/template/TemplateRenderer.java,本质是 BiFunction<String, Map, String>)。默认实现是 StringTemplate 包装:

// DefaultChatClient.java:94
private static final TemplateRenderer DEFAULT_TEMPLATE_RENDERER = StTemplateRenderer.builder().build();

它的默认分隔符是 { / },默认校验模式是 ValidationMode.THROWspring-ai-template-st/src/main/java/org/springframework/ai/template/st/StTemplateRenderer.java:63-67)——缺变量直接抛异常,不是静默留空。要换分隔符(比如为了避开 JSON 的花括号)或改成宽松模式,就用 .templateRenderer(...)(请求级,DefaultChatClient.java:1169-1173)或 .defaultTemplateRenderer(...)(客户端级)。

renderer 字段在 spec 构造时兜底:没传就用上面那个常量(DefaultChatClient.java:860)。

5.3 ChatOptions:三段合并,起点是模型自己

很多人以为 .options(...) 传什么就发什么。实际是三段叠加

① chatModel.getOptions().mutate() ← 底:模型实例配置的默认值(temperature、model 名…)

▼ combineWith(other):other 里非 null 的字段覆盖当前值
② 用户的 optionsCustomizer ← 中:.options(...) / .defaultOptions(...)

▼ 直接 setter
③ toolCallbacks / toolContext ← 顶:只在类型匹配时注入(见 5.4)

对应源码:

// DefaultChatClientUtils.java:103-106
ChatOptions.Builder<?> builder = inputRequest.getChatModel().getOptions().mutate();
if (inputRequest.getOptionsCustomizer() != null) {
builder = builder.combineWith(inputRequest.getOptionsCustomizer());
}

三个要点:

  1. 起点是 chatModel.getOptions()(接口默认实现返回空 options,spring-ai-model/src/main/java/org/springframework/ai/chat/model/ChatModel.java:52-54;各家 ChatModel 会覆盖成自己的配置)。所以 ChatClient 从不"清空"模型配置,只在上面叠加。
  2. .mutate() 保证不改原对象——它按约定返回一个用当前值初始化的新 Builder(spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/ChatOptions.java:86)。模型的默认 options 是共享的,不 mutate 就会被并发请求污染。
  3. combineWith 的语义是"非 null 才覆盖"spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/DefaultChatOptionsBuilder.java:119-150,接口声明在 ChatOptions.java:169)。所以你只设 temperaturemodel 名仍然沿用模型的默认值。唯一的例外是 stopSequences——它是追加合并,不是覆盖(DefaultChatOptionsBuilder.java:133-141)。

5.4 工具注入:一道 instanceof 门 + 一次去重校验

工具不是无条件注入的,得先过类型检查:

// DefaultChatClientUtils.java:108-124(结构)
if (builder instanceof ToolCallingChatOptions.Builder<?> tbuilder) {
List<ToolCallback> toolCallbacks = new ArrayList<>(inputRequest.getToolCallbacks());
for (var provider : inputRequest.getToolCallbackProviders()) {
toolCallbacks.addAll(java.util.List.of(provider.getToolCallbacks())); // provider 在此展开
}
if (!toolCallbacks.isEmpty()) {
ToolCallingChatOptions.validateToolCallbacks(toolCallbacks);
tbuilder.toolCallbacks(toolCallbacks);
}
if (!inputRequest.getToolContext().isEmpty()) {
tbuilder.toolContext(inputRequest.getToolContext());
}
}

四个可以直接带走的结论:

  • 不支持工具的模型会静默丢弃工具。 如果 chatModel.getOptions() 返回的不是 ToolCallingChatOptions(比如某些 embedding 风格或简化实现),你 .tools(...) 注册的东西不报错、也不生效。代码里看不出有任何告警日志。
  • ToolCallbackProvider 在这一刻才展开:111-113)。这是"延迟解析"的兑现点——MCP 服务器那边工具列表变了,下一次请求就能拿到新的。
  • 重名工具在这里被拦下。 validateToolCallbacks 找出重复的工具名就抛 IllegalStateException: Multiple tools with the same name (...)spring-ai-model/src/main/java/org/springframework/ai/model/tool/ToolCallingChatOptions.java:92-101)。注意它只在 toolCallbacks 非空时被调(:115),且校验的是 ChatClient 收集到的那批,不含 options 里原有的。
  • 工具列表是覆盖,工具上下文是合并。 tbuilder.toolCallbacks(list) 是替换语义(spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingChatOptions.java:125-131),而 tbuilder.toolContext(map)putAll 语义(:146-152)。所以:
你同时做了结果
.options(opts 里带 toolA) + .tools(toolB)只剩 toolB(测试 whenToolCallbacksAndChatOptionsAreProvidedThenTheToolCallbacksOverrideDefaultChatClientUtilsTests.java:253-273
.options(opts 里带 ctx1) + .toolContext(ctx2)ctx1ctx2 都在(测试 whenToolContextAndChatOptionsAreProvidedThenTheValuesAreMerged:275-295

这个非对称不是 bug:工具集合"整体替换"才有确定语义,而上下文是键值对,天然可叠加。

5.5 收尾:打包成 record

// DefaultChatClientUtils.java:132-136
Builder promptBuilder = Prompt.builder().messages(processedMessages).chatOptions(processedChatOptions);
return ChatClientRequest.builder()
.prompt(promptBuilder.build())
.context(new ConcurrentHashMap<>(inputRequest.getAdvisorParams()))
.build();

两个设计点:

  • advisorParams(用户通过 .advisors(a -> a.param(k, v)) 塞的)被拷贝成 context 的初始内容,不是引用共享。所以并发请求各有各的 context。
  • 用的是 ConcurrentHashMap 而不是 HashMap——因为 context 会被顾问链上多个组件读写,流式场景下还跨线程(下一节详述)。副作用:context 不能存 null 值,放 null 会直接 NPE。

6. ChatClientRequest / ChatClientResponse:两个 record 和一张贯穿全链的 Map

6.1 结构

两个 record 都极简:

// ChatClientRequest.java:36
public record ChatClientRequest(Prompt prompt, Map<String, @Nullable Object> context) { ... }

// ChatClientResponse.java:35
public record ChatClientResponse(@Nullable ChatResponse chatResponse, Map<String, @Nullable Object> context) { ... }

对称设计:请求和响应各带一半"正事"(Prompt / ChatResponse)和同一张 context。顾问链上的组件收到 request、返回 response,context 就这样从头传到尾——RAG 检索到的文档、记忆里的会话 ID、结构化输出的 schema,都靠它捎带。

两者都提供 copy()mutate()ChatClientRequest.java:44-50)——record 本身不可变,要改就派生新的。注意 copy() 里 context 变成了普通 HashMap,不再是装配时的 ConcurrentHashMap

6.2 context 里已知的键

键名集中在 ChatClientAttributes 枚举(ChatClientAttributes.java:25-54):

枚举常量实际键谁写进去干什么用
OUTPUT_FORMATspring.ai.chat.client.output.formatDefaultCallResponseSpec.doSingleWithBeanOutputConverterDefaultChatClient.java:595-599把"请按这个格式输出"的提示词交给下游顾问附加到 prompt
STRUCTURED_OUTPUT_SCHEMA...structured.output.schema同上(:601-608原始 JSON Schema,交给支持原生结构化输出的模型
STRUCTURED_OUTPUT_NATIVE...structured.output.nativeAdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUTAdvisorParams.java:56-57)或 .entity(T.class, e -> e.useProviderStructuredOutput())DefaultChatClient.java:576-578开关:schema 走 API 参数还是走 prompt 文本
TOOL_CALLING_ADVISOR_AUTO_REGISTER...tool.calling.advisor.auto-registerAdvisorParams.toolCallingAdvisorAutoRegister(false)AdvisorParams.java:75-77关掉工具顾问的自动注册
TOOL_CALL_ADVISOR_AUTO_REGISTER同上(键值相同)2.0.0 起废弃旧名,键字符串与新常量完全一致

两个方向的时序差别值得注意:

  • STRUCTURED_OUTPUT_NATIVETOOL_CALLING_ADVISOR_AUTO_REGISTER用户在 .advisors(...) 阶段写的,随 advisorParams 一起被拷进 context(DefaultChatClientUtils.java:135)。
  • OUTPUT_FORMATSTRUCTURED_OUTPUT_SCHEMA装配之后才补写的——toChatClientRequest 跑完了,用户调 .entity(Foo.class) 时才知道要什么格式,于是直接 this.request.context().put(...)DefaultChatClient.java:598)。这就是 context 必须可变、必须线程安全的直接原因:一个已经"冻结"的 record,它的 Map 还在被写。
  • 唯一的例外是 TOOL_CALLING_ADVISOR_AUTO_REGISTERbuildAdvisorChain 是从 this.advisorParams 原地读的,压根没走 context(DefaultChatClient.java:1219-1220)。所以它虽然长在 ChatClientAttributes 里,实际是个"装配期开关"而非"运行期上下文"。

7. call()stream():扳机做了什么

两个方法几乎一模一样,只在最后 new 出的 Spec 类不同:

// DefaultChatClient.java:1176-1187
public CallResponseSpec call() {
BaseAdvisorChain advisorChain = buildAdvisorChain();
return new DefaultCallResponseSpec(DefaultChatClientUtils.toChatClientRequest(this), advisorChain,
this.observationRegistry, this.chatClientObservationConvention);
}

public StreamResponseSpec stream() {
BaseAdvisorChain advisorChain = buildAdvisorChain();
return new DefaultStreamResponseSpec(DefaultChatClientUtils.toChatClientRequest(this), advisorChain, ...);
}

注意执行顺序:先建顾问链,后装配请求。 这不是随意的——buildAdvisorChain() 会往 this.advisors 里追加东西(下面 7.1),而 toChatClientRequest 读的是 advisorParams,两者不冲突,但顺序固定了"链的构成在请求冷冻前就定了"。

还要注意:call() 本身不发请求。 它只是把 request + chain 装进 DefaultCallResponseSpec。真正触发是后面的 .content() / .entity() / .chatResponse()——它们各自调 doGetObservableChatClientResponse,在 Micrometer observation 里执行 advisorChain.nextCall(...)DefaultChatClient.java:656-661)。流式那边则是 Flux.deferContextual 包着 advisorChain.nextStream(...):699-733)。链内部怎么调度,见第 2 章

7.1 buildAdvisorChain 的三步

// DefaultChatClient.java:1189-1203
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();
}

三步,本章只点到为止:

  1. 自动注册工具顾问:1217-1238):除非 advisorParams 里显式关掉、或链里已有 ToolAdvisor,否则总会补一个 ToolCallingAdvisor。"总是注册"是刻意的——即使这次没静态工具,别的顾问也可能在运行时动态塞工具进来。同时它会检查有没有 order 更大的 MemoryAdvisor,有就关掉自己内部的历史管理(:1232-1237),避免会话历史被记两遍。
  2. 单例校验:1240-1248):链里超过一个 ToolAdvisor 直接抛 IllegalStateException
  3. 压两个模型顾问收底ChatModelCallAdvisorChatModelStreamAdvisor 永远在链末——它们不再往下传,而是真正调 ChatModel

细节全在第 2 章第 3 章


8. 巧妙之处(可以借鉴的)

① 装配点单一。 从链式 API 到请求对象只有 toChatClientRequest 一个 88 行的函数(DefaultChatClientUtils.java:50-137),没有分散在十几个 setter 里的隐式转换。要搞清"我写的东西最后长什么样",读一个函数就够。

② "默认值容器"复用请求容器。 Builder 不定义自己的字段,直接持有一个 DefaultChatClientRequestSpecDefaultChatClientBuilder.java:61)。默认值和请求值走同一套校验、同一套合并规则,省掉一整套镜像代码。

③ 重放式克隆代替深拷贝。 mutate() 通过"把自己的状态重新调一遍公开 setter"来复制(DefaultChatClient.java:933-966)。新增一个字段时,只要顺手加一行 builder.defaultXxx(...),不用维护单独的拷贝逻辑。

④ 拷贝构造 + options clone 双保险。 prompt() 拷 spec(:107),拷贝构造里再 clone optionsCustomizer:840)。前者隔离请求之间,后者隔离请求和客户端默认值——所以 prompt(Prompt) 里那句 combineWith 能放心地原地改。

⑤ 用类型系统当特性开关。 工具要不要注入,不靠布尔标志,靠 builder instanceof ToolCallingChatOptions.BuilderDefaultChatClientUtils.java:108)。模型层是否支持工具,由它自己的 options 类型说了算,ChatClient 不用维护"哪家支持工具"的名单。这是第 4 章可移植性设计的一角。

⑥ context 是"逃生舱"。 两个 record 各带同一张 Map(ChatClientRequest.java:36ChatClientResponse.java:35),让 RAG、记忆、结构化输出这些横切关注点不必往 Prompt 里加字段就能传数据。代价是弱类型——所以键名被收进 ChatClientAttributes 枚举统一管理。


9. 边界与坑

诚实清单:

表现依据
模板不渲染文本里写了 {name} 但一个 param 没传 → 原样发给模型DefaultChatClientUtils.java:84(判的是 params 非空)
JSON 花括号被当变量prompt 里含 {"k": 1} 且传了任意 param → ST 渲染报错StTemplateRenderer.java:63-65 默认分隔符是 { }
缺变量直接抛异常不是静默留空StTemplateRenderer.java:67,默认 ValidationMode.THROW
工具静默失效模型 options 不是 ToolCallingChatOptions.tools(...) 无声无息DefaultChatClientUtils.java:108,无 else 分支、无日志
只能有一条 systemText多次 .system(...) 后者覆盖前者DefaultChatClient.java:1090-1094,字段是单个 String
消息顺序不可调想让 user 在 system 前面?做不到DefaultChatClientUtils.java:57-97 硬编码
context 不能放 null装配时用 ConcurrentHashMapDefaultChatClientUtils.java:135
Builder 默认值是共享可变状态拿到同一个 Builder 的两处代码会互相看见对方的 defaultXxxDefaultChatClientBuilder.java:61;要隔离得先 clone()
默认工具是全局的defaultTools(...) 注册的工具对所有请求可见ChatClient.java:554-556 的 Javadoc 明确警告

刻意不做的事: ChatClient 是无状态的(ChatClient.java:50 的 Javadoc: "stateless requests")。多轮会话不归它管——那是 MemoryAdvisor 的活(第 5 章)。


10. 代码地图

主题文件路径(相对克隆根)符号名
门面接口、静态工厂spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClient.javaChatClient.createChatClient.builder
六个 Spec 接口定义同上ChatClientRequestSpecPromptUserSpecPromptSystemSpecAdvisorSpecCallResponseSpecStreamResponseSpec
默认值容器 / clonespring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.javaDefaultChatClientBuilder.defaultRequestcloneaddMessages
prompt() 三个重载spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.javaDefaultChatClient.promptmutate
请求状态容器同上DefaultChatClientRequestSpec、其拷贝构造函数、mutate
临时收纳盒同上DefaultPromptUserSpecDefaultPromptSystemSpecDefaultAdvisorSpec
工具类型分发同上DefaultChatClientRequestSpec.tools
扳机与顾问链构建同上callstreambuildAdvisorChainautoRegisterToolCallingAdvisorvalidateSingleToolAdvisor
响应侧 Spec同上DefaultCallResponseSpecDefaultStreamResponseSpecDefaultEntityParamSpec
核心装配函数spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClientUtils.javaDefaultChatClientUtils.toChatClientRequest
请求 / 响应 recordspring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientRequest.javaChatClientResponse.javaChatClientRequestChatClientResponsecopymutate
context 键名spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientAttributes.javaChatClientAttributes.OUTPUT_FORMAT
预置顾问参数spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/AdvisorParams.javaENABLE_NATIVE_STRUCTURED_OUTPUTtoolCallingAdvisorAutoRegister
options 合并语义spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/ChatOptions.javaDefaultChatOptionsBuilder.javaChatOptions.mutateBuilder.combineWithBuilder.clone
工具选项与去重校验spring-ai-model/src/main/java/org/springframework/ai/model/tool/ToolCallingChatOptions.javaDefaultToolCallingChatOptions.javavalidateToolCallbacksBuilder.toolCallbacksBuilder.toolContext
模板渲染spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/PromptTemplate.javaspring-ai-commons/src/main/java/org/springframework/ai/template/TemplateRenderer.javaspring-ai-template-st/src/main/java/org/springframework/ai/template/st/StTemplateRenderer.javaPromptTemplate.renderTemplateRenderer.applyStTemplateRenderer
行为锁定测试spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/DefaultChatClientUtilsTests.javaDefaultChatClientBuilderTests.javawhenSystemTextAndSystemMessageAreProvidedThenSystemTextIsFirstwhenToolCallbacksAndChatOptionsAreProvidedThenTheToolCallbacksOverridewhenCloneBuilderThenModifyingOriginalDoesNotAffectClone

下一章: 请求冷冻之后交给谁 → Advisor 责任链:Spring AI 的拦截器模型