Spring AI 2.0.1 深度解析-第 04 章 `ChatClient` 全流程:Builder、两条链路与结构化输出

Spring AI 2.0.1 深度解析-第 04 章 ChatClient 全流程:Builder、两条链路与结构化输出

ChatClient 是 Spring AI 的门面,也是最容易"用错而不自知"的一层。 本章从源码拆解:call() 与 stream() 两条链路的完整执行路径、advisor 链为什么是栈 、order 值怎么决定"谁包着谁"、以及 entity() 是怎么把一段文本变成 POJO 的。

代码均原样摘自 spring-ai-2.0.1\spring-ai-client-chat,标注 文件:行号。


4.1 开场:先纠正五个"广为流传但 2.0.1 里不存在"的类名

在 1.x 的材料和大量博客里,你会看到这些名字。它们在 2.0.1 中都不存在------如果你按它们去搜源码,只会浪费时间。

你以为的名字 2.0.1 的真实名字 出处
ChatClientResponseSpec ChatClient.CallResponseSpec ChatClient.java:236
CallAroundAdvisor / StreamAroundAdvisor CallAdvisor / StreamAdvisor advisor/api/CallAdvisor.java、StreamAdvisor.java
CallAroundAdvisorChain / StreamAroundAdvisorChain CallAdvisorChain / StreamAdvisorChain advisor/api/
nextAroundCall / nextAroundStream nextCall / nextStream DefaultAroundAdvisorChain.java:98、123
AroundAdvisorChain(顶层接口) 只有具体类 DefaultAroundAdvisorChain advisor/DefaultAroundAdvisorChain.java

另外还有三个:

  • MutateSpec 不存在 ------ mutate() 返回的就是 Builder(ChatClient.java:392)
  • ObservationSpec 不存在
  • EntitySpec 不存在 ------ 真实名 ChatClient.EntityParamSpec(ChatClient.java:202)

4.1.1 CallAroundAdvisor → CallAdvisor 是什么时候改的?

注意:不是 2.0 改的,是 1.0.0 改的。

官方记录在 api/advisors.adoc:438-439,标注为 1.0.0 的变更。upgrade-notes.adoc 里没有为它单列条目------所以很多人(包括从 1.x 早期版本迁移的人)还带着旧名字的记忆。

这一章反复会强调一个方法论:当你从任何二手材料里看到一个类/方法名,先在源码里 grep 它。2.x 经过多轮改名,凭记忆写代码的失败率极高。

4.1.2 顺带:第 02 章埋的伏笔在这里收口

第 02 章 §2.6.1 讲过 StreamingChatModel 是 @FunctionalInterface。本章会看到 @FunctionalInterface 的另一批用户:CallAdvisor(34 行)、StreamAdvisor(36 行)、AdvisorChain(34 行)------advisor 体系的接口都极其精简。


4.2 ChatClient 接口全景

ChatClient.java 共 595 行,主体是五个嵌套接口:

scss 复制代码
ChatClient                        (61 行起)
├── PromptUserSpec      (141)     ------ .user(u -> ...) 的 DSL
├── PromptSystemSpec    (168)     ------ .system(s -> ...) 的 DSL
├── AdvisorSpec         (186)     ------ .advisors(a -> ...) 的 DSL
├── EntityParamSpec     (202)     ------ .entity(Type.class, spec -> ...) 的 DSL
├── CallResponseSpec    (236)     ------ .call() 的返回类型
├── StreamResponseSpec  (376)     ------ .stream() 的返回类型
├── ChatClientRequestSpec (386)   ------ .prompt() 的返回类型
└── Builder             (499)     ------ ChatClient.builder() 的返回类型

4.2.1 ChatClientRequestSpec:装配阶段

java 复制代码
386→	interface ChatClientRequestSpec {
392→		Builder mutate();
394→		ChatClientRequestSpec advisors(Consumer<AdvisorSpec> consumer);
396→		ChatClientRequestSpec advisors(Advisor... advisors);
398→		ChatClientRequestSpec advisors(List<Advisor> advisors);
400→		ChatClientRequestSpec messages(Message... messages);
402→		ChatClientRequestSpec messages(List<Message> messages);
447→		ChatClientRequestSpec tools(Object... tools);
454→		ChatClientRequestSpec toolCallbacks(ToolCallback... toolCallbacks);
468→		ChatClientRequestSpec toolCallbacks(ToolCallbackProvider... toolCallbackProviders);
470→		ChatClientRequestSpec toolContext(Map<String, Object> toolContext);
472→		ChatClientRequestSpec system(String text);
480→		ChatClientRequestSpec user(String text);
488→		ChatClientRequestSpec templateRenderer(TemplateRenderer templateRenderer);
490→		CallResponseSpec call();
492→		StreamResponseSpec stream();

关键在第 490-492 行 :.call() 和 .stream() 是"分岔点"------走到这里,请求装配完成,接下来选择执行模式。

注意方法的返回类型差异 :system(...) 返回 ChatClientRequestSpec(可继续装配),而 call() 返回 CallResponseSpec(进入响应处理阶段)。这是典型的**流式 DSL(fluent interface)**设计。

4.2.2 CallResponseSpec:非流式响应

java 复制代码
236→	interface CallResponseSpec {
295→		ChatClientResponse chatClientResponse();

以及多个 entity() 重载(第 529、538、546、552、561、567 行):

java 复制代码
529→		public <T> @Nullable T entity(ParameterizedTypeReference<T> type, Consumer<EntityParamSpec> entitySpecConsumer)
538→		public <T> @Nullable T entity(Class<T> type, Consumer<EntityParamSpec> entitySpecConsumer)
546→		public <T> @Nullable T entity(ParameterizedTypeReference<T> type)
552→		public <T> @Nullable T entity(StructuredOutputConverter<T> structuredOutputConverter, Consumer<...> ...)
561→		public <T> @Nullable T entity(StructuredOutputConverter<T> structuredOutputConverter)
567→		public <T> @Nullable T entity(Class<T> type)

六个 entity() 重载,可以归为三组:

组 重载 用途
按类型 entity(Class<T>)、entity(ParameterizedTypeReference<T>) 直接给目标类型
带配置 + Consumer<EntityParamSpec> 可开原生 schema / schema 校验
自定义转换器 entity(StructuredOutputConverter<T>) 用 MapOutputConverter / ListOutputConverter 等

ParameterizedTypeReference 的存在很重要 ------它让 entity(new ParameterizedTypeReference<List<MyPojo>>() {}) 这种泛型类型成为可能(Jackson 需要完整的泛型信息才能正确反序列化 List<T>)。

4.2.3 StreamResponseSpec:只有三个方法

java 复制代码
376→	interface StreamResponseSpec {
378→		Flux<ChatClientResponse> chatClientResponse();
380→		Flux<ChatResponse> chatResponse();
382→		Flux<String> content();

注意:没有 entity()。

这是本章最重要的发现之一------流式链路不支持结构化输出的便捷方法。§4.14 会详细论证为什么这是"双重不可用"。


4.3 三种返回类型:你丢掉了什么?

这是本章最实用的部分。stream() 之后你可以选三种类型,它们的"信息量"差别巨大。

4.3.1 Flux<String>(.content())

只有文本。 每个元素是一小段增量文本。

丢失的信息:

  • ❌ ChatResponseMetadata(model、id、usage、rateLimit)
  • ❌ finishReason(不知道为什么停)
  • ❌ toolCalls(工具调用完全不可见)
  • ❌ 多候选(n > 1 时只给你第一个的文本)
  • ❌ 每个 chunk 的边界(你拿到的是被拆过的片段)

4.3.2 Flux<ChatResponse>(.chatResponse())

每个元素是完整的 ChatResponse (第 02 章 §2.3 讲过:流元素类型是 ChatResponse,不是增量片段)。

保留:

  • ✅ ChatResponseMetadata(但多数 chunk 的 usage 是空的)
  • ✅ finishReason
  • ✅ toolCalls(可见)
  • ✅ 多候选

丢失:

  • ❌ ChatClientResponse.context() ------ advisor 链传递的那些状态

4.3.3 Flux<ChatClientResponse>(.chatClientResponse())

信息最全。 每个元素是 ChatClientResponse(ChatClientResponse.java:35):

java 复制代码
35→public record ChatClientResponse(@Nullable ChatResponse chatResponse, Map<String, @Nullable Object> context) {

保留全部:ChatResponse 加 advisor context。

4.3.4 对照表

content() chatResponse() chatClientResponse()
类型 Flux<String> Flux<ChatResponse> Flux<ChatClientResponse>
文本 ✅ ✅ ✅
metadata / usage ❌ ✅ ✅
finishReason ❌ ✅ ✅
toolCalls ❌ ✅ ✅
advisor context ❌ ❌ ✅
典型用途 聊天界面打字机 需要 token 统计 调试、自定义 advisor 后处理

4.3.5 实践结论

如果你在做工具调用(第 08 章),绝不能用 .content()。

因为 ToolCallingAdvisor 需要看到 toolCalls 才能驱动工具执行循环------而 Flux<String> 里根本没这个字段。用 .content() 时模型返回的 tool call 会被静默丢弃,你会得到一个空字符串或者模型的"我要调用工具"的自然语言描述,而不是工具执行结果。

推荐默认用 .chatResponse() ,需要调试或自定义 advisor 时才用 .chatClientResponse()。


4.4 DefaultChatClientBuilder 与 prototype 语义

4.4.1 自动配置里的硬证据

java 复制代码
// auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/
//   .../ChatClientAutoConfiguration.java:112
112→	@Bean
113→	@Scope("prototype")
114→	@ConditionalOnMissingBean

Javadoc(第 55-57 行)说得很清楚:

This will produce a ChatClient.Builder bean with the prototype scope , meaning each injection point will receive a newly cloned instance of the builder.

4.4.2 为什么必须 prototype?

看 DefaultChatClientBuilder 的实现------它只持有一个可变字段:

java 复制代码
// DefaultChatClientBuilder.java:61
61→	private final DefaultChatClientBuilder.DefaultChatClientRequestSpec defaultRequest;

而所有 defaultXxx(...) 方法都是原地修改:

java 复制代码
// 典型形态(第 122-135 行等)
public Builder defaultSystem(String text) {
    this.defaultRequest.system(text);     // 改的是同一个 defaultRequest
    return this;
}

如果做成 singleton :第一个注入点调了 .defaultSystem("你是客服"),所有后续注入点拿到的 builder 都带着这个 system prompt------配置互相污染,而且是那种极难排查的 bug(表现为"A 服务的提示词出现在 B 服务的调用里")。

prototype + clone() 就是隔离手段:

java 复制代码
// DefaultChatClientBuilder.java:118
118→	public Builder clone() {
119→		return this.defaultRequest.mutate();
120→	}

每次注入点拿到的是 clone() 出来的新 Builder,各自改各自的。

4.4.3 build() 与 clone() 的关系

java 复制代码
114→	public ChatClient build() {
115→		return new DefaultChatClient(this.defaultRequest);
116→	}
117→
118→	public Builder clone() {
119→		return this.defaultRequest.mutate();
120→	}

build() 把当前的 defaultRequest 固化成一个不可变的 ChatClient;clone() 复制出一个新的可变 Builder。

这里有个微妙点:build() 之后如果再改 builder,ChatClient 不受影响吗?

答案是取决于 DefaultChatClient 构造时是否拷贝 。第 02 章的教训(§2.8.1)告诉我们 ChatResponse 会 List.copyOf,但 DefaultChatClient 持有的是 defaultChatClientRequest 引用------如果 builder 之后被修改,理论上会影响已 build 的 client。

实践建议 :build() 之后不要再用那个 builder。要改配置就 clone() 一个新的。


4.5 非流式链路:call() 的完整执行路径

从 .call() 到拿到 ChatResponse,中间经过六步。

步骤 1:.prompt() 装配

DefaultChatClientBuilder / DefaultChatClientRequestSpec 收集:messages、system/user 文本与参数、advisors、advisorParams、tools、options、templateRenderer 等。

步骤 2:DefaultChatClientUtils 把 spec 变成 ChatClientRequest

第 03 章 §3.13.3 已经看过 options 合并(第 103-106 行)。完整方法还做三件事:

java 复制代码
// DefaultChatClientUtils.java(示意,行号见素材)
- 渲染 system / user 模板(templateRenderer)
- 构造 UserMessage(含 media、metadata)
- 合并 options(chatModel.getOptions().mutate().combineWith(customizer))
- 组装 ChatClientRequest,context = new ConcurrentHashMap<>(advisorParams)

第 133-136 行:

java 复制代码
133→		return ChatClientRequest.builder()
134→			.prompt(promptBuilder.build())
135→			.context(new ConcurrentHashMap<>(inputRequest.getAdvisorParams()))
136→			.build();

步骤 3:DefaultCallResponseSpec 的方法分派

call() 返回 DefaultCallResponseSpec(DefaultChatClient.java:431),它持有:

java 复制代码
435→		private final BaseAdvisorChain advisorChain;

调用 .chatResponse() 时走到第 626 行:

java 复制代码
626→			return doGetObservableChatClientResponse(this.request).chatResponse();

步骤 4:观测包装(doGetObservableChatClientResponse)

java 复制代码
639→		private ChatClientResponse doGetObservableChatClientResponse(ChatClientRequest chatClientRequest,
640→				BaseAdvisorChain advisorChain) {
641→
642→			String outputFormat = (String) chatClientRequest.context()
643→				.getOrDefault(ChatClientAttributes.OUTPUT_FORMAT.getKey(), null);
644→
645→			ChatClientObservationContext observationContext = ChatClientObservationContext.builder()
646→				.request(chatClientRequest)
647→				.advisors(advisorChain.getCallAdvisors())
648→				.stream(false)
649→				.format(outputFormat)
650→				.build();
651→
652→			var observation = ChatClientObservationDocumentation.AI_CHAT_CLIENT.observation(this.observationConvention,
653→					DEFAULT_CHAT_CLIENT_OBSERVATION_CONVENTION, () -> observationContext, this.observationRegistry);
654→
655→			// CHECKSTYLE:OFF
656→			var chatClientResponse = observation.observe(() -> {
657→				// Apply the advisor chain that terminates with the ChatModelCallAdvisor.
658→				var response = advisorChain.nextCall(chatClientRequest);
659→				observationContext.setResponse(response);
660→				return response;
661→			});
662→			// CHECKSTYLE:ON
663→			return chatClientResponse != null ? chatClientResponse : ChatClientResponse.builder().build();
664→		}

五个要点:

  1. 第 642-643 行 :从 context 里取 OUTPUT_FORMAT(spring.ai.chat.client.output.format),放进观测上下文。这是 entity() 留下的痕迹。
  2. 第 648 行 .stream(false):观测上下文里标记"非流式"。第 17 章会看到这个字段如何变成 metric tag。
  3. 第 658 行 :真正驱动 advisor 链的就是这一行 advisorChain.nextCall(...)。
  4. 第 663 行 :null 兜底------返回空 ChatClientResponse 而不是 null。
  5. 第 655/662 行的 // CHECKSTYLE:OFF / ON:作者为了保持 lambda 的紧凑排版,临时关掉了 checkstyle。这类标记在 Spring AI 源码里零星可见。

步骤 5:advisor 链执行(nextCall)

见 §4.7。

步骤 6:取内容

java 复制代码
666→		private static @Nullable String getContentFromChatResponse(@Nullable ChatResponse chatResponse) {
667→			return Optional.ofNullable(chatResponse)
668→				.map(ChatResponse::getResult)
669→				.map(Generation::getOutput)
670→				.map(AbstractMessage::getText)
671→				.orElse(null);
672→		}

注意这里是 Optional 链 (第 02 章 §2.6.2 讨论过这种风格),且最终可能是 null(不是 "")。

对比 ChatModel.call(String)(ChatModel.java:35)在空时返回 ""------两条路径的空值行为不一致。所以:

java 复制代码
chatModel.call("hi")                          // 可能返回 ""(非 null)
chatClient.prompt("hi").call().content()      // 可能返回 null!

写判空代码时必须区分 。Objects.requireNonNullElse(..., "") 是安全的做法。


4.6 流式链路:stream() 的完整执行路径

java 复制代码
699→		private Flux<ChatClientResponse> doGetObservableFluxChatResponse(ChatClientRequest chatClientRequest) {
700→			return Flux.deferContextual(contextView -> {
701→
702→				ChatClientObservationContext observationContext = ChatClientObservationContext.builder()
703→					.request(chatClientRequest)
704→					.advisors(this.advisorChain.getStreamAdvisors())
705→					.stream(true)
706→					.build();
...
718→				try (Observation.Scope ignored = parentObservation != null ? parentObservation.openScope()
719→						: Observation.Scope.NOOP) {
720→					observation.start();
721→				}
722→
723→				// @formatter:off
724→				// Apply the advisor chain that terminates with the ChatModelStreamAdvisor.
725→				Flux<ChatClientResponse> chatClientResponse = this.advisorChain.nextStream(chatClientRequest)
726→						.doOnError(observation::error)
727→						.doFinally(s -> observation.stop())
728→						.contextWrite(ctx -> ctx.put(ObservationThreadLocalAccessor.KEY, observation));
729→				// @formatter:on
730→				return CHAT_CLIENT_MESSAGE_AGGREGATOR.aggregateChatClientResponse(chatClientResponse,
731→						observationContext::setResponse);
732→			});
733→		}

4.6.1 与非流式的四处关键差异

差异一:Flux.deferContextual(第 700 行)。

非流式是"立即执行",流式是"每次订阅时 才执行"(defer 的语义)。这让每个订阅者都能拿到独立的观测上下文。

差异二:显式处理 parent observation(第 718-721 行)。

java 复制代码
try (Observation.Scope ignored = parentObservation != null ? parentObservation.openScope()
        : Observation.Scope.NOOP) {
    observation.start();
}

注释(第 711-717 行)解释了为什么:

Briefly make the parent observation current while starting this one, so Micrometer tracing derives the span's parent from the parent observation rather than from whatever scope happens to be open on the current thread (e.g. the servlet HTTP span). This keeps span parenting correct without relying on automatic context propagation.

这是在修一个真实的分布式追踪 bug:在 WebFlux 环境下,当前线程的 scope 可能是 servlet 的 HTTP span,而不是你的业务 span。如果不显式 openScope,span 的父子关系会错乱------调用链会断。

差异三:doOnError / doFinally 结束观测(第 726-727 行)。

非流式用 observation.observe(() -> {...})(自动结束),流式必须手工 doFinally(s -> observation.stop())------因为流什么时候结束只有运行时知道。

差异四:CHAT_CLIENT_MESSAGE_AGGREGATOR(第 730 行)。

非流式不需要聚合(一次就一个响应),流式需要把多个 chunk 的 context 合并(§4.10.4)。

4.6.2 一个隐蔽的坑:重复订阅会抛异常

素材里的"偏差二"提到:nextStream 的 pop() 在 deferContextual 内(第 132 行)而不在 Flux.defer 内(第 158 行)。

更准确地说------streamAdvisors 是一个会被 pop() 消耗的 Deque。第一次订阅把 advisor 弹出来,第二次订阅时 Deque 可能已经空了:

java 复制代码
// DefaultAroundAdvisorChain.java:127-129
if (this.streamAdvisors.isEmpty()) {
    return Flux.error(new IllegalStateException("No StreamAdvisors available to execute"));
}

所以:同一个 Flux 不要订阅两次。 如果你需要多次消费,用 .cache() 或重新调用 .stream()。

(严格说,deferContextual 在每次订阅时都会重新执行 lambda,所以理论上每次订阅都会重新 pop()------但 Deque 是共享的,消耗掉了就没了。这就是"链是一次性消耗品"的本质。)



4.7 advisor 链为什么是栈 :Deque.pop() 而不是 index++

这是本章技术含量最高的一节。

4.7.1 五条源码证据

证据 1:字段类型是 Deque

java 复制代码
// DefaultAroundAdvisorChain.java:69
69→	private final Deque<CallAdvisor> callAdvisors;
70→
71→	private final Deque<StreamAdvisor> streamAdvisors;

证据 2:推进用 pop()(= removeFirst())

java 复制代码
// DefaultAroundAdvisorChain.java:105
105→		var advisor = this.callAdvisors.pop();
...
// DefaultAroundAdvisorChain.java:132
132→			var advisor = this.streamAdvisors.pop();

如果是队列,应该用 poll() / removeFirst() 的语义配合"全部遍历完";但这里是 pop() ------ 每次调用破坏性地弹出一个。

证据 3:建造方法叫 push / pushAll(栈术语)

java 复制代码
// BaseAdvisorChain.java:71,78
71→		B push(Advisor advisor);
78→		B pushAll(List<? extends Advisor> advisors);

证据 4:官方注释用词 "stack bottom"

java 复制代码
// DefaultChatClient.java:1193-1194
1193→		// At the stack bottom add the model call advisors.
1194→		// They play the role of the last advisors in the advisor chain.

证据 5:after 阶段天然逆序

因为 advisor.adviseCall(chatClientRequest, this) 把链自己 传下去(第 117 行),内层返回后外层才执行 after------这是递归回弹,不是循环。

4.7.2 完整源码:nextCall

java 复制代码
 97→	@Override
 98→	public ChatClientResponse nextCall(ChatClientRequest chatClientRequest) {
 99→		Assert.notNull(chatClientRequest, "the chatClientRequest cannot be null");
100→
101→		if (this.callAdvisors.isEmpty()) {
102→			throw new IllegalStateException("No CallAdvisors available to execute");
103→		}
104→
105→		var advisor = this.callAdvisors.pop();
106→
107→		var observationContext = AdvisorObservationContext.builder()
108→			.advisorName(advisor.getName())
109→			.chatClientRequest(chatClientRequest)
110→			.order(advisor.getOrder())
111→			.build();
112→
113→		return AdvisorObservationDocumentation.AI_ADVISOR
114→			.observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext,
115→					this.observationRegistry)
116→			.observe(() -> {
117→				var chatClientResponse = advisor.adviseCall(chatClientRequest, this);
118→				observationContext.setChatClientResponse(chatClientResponse);
119→				return chatClientResponse;
120→			});
121→	}

七行核心逻辑,每一行都有信息量:

  1. 第 101-103 行 :空链抛 IllegalStateException。注意消息是 "No CallAdvisors available to execute"------"CallAdvisors" 是 2.x 的新术语(1.x 会说 CallAroundAdvisors)。
  2. 第 105 行 :pop() 弹出当前 advisor。注意是"弹出",所以链是一次性的。
  3. 第 107-111 行:为这个 advisor 建独立的观测上下文,记录 advisorName 和 order。
  4. 第 117 行 :advisor.adviseCall(chatClientRequest, this) ------ 把 this(链自己)传进去 。这是递归的关键:advisor 内部再调 chain.nextCall(...),就推进到下一个 advisor。

4.7.3 执行模型:递归而非循环

把三个 advisor(A、B、C,order 递增)的执行画出来:

erlang 复制代码
nextCall(req)                       ← 第 1 次调用
  advisor = A (pop)                 ← A 被弹出
  A.adviseCall(req, chain):
      A.before(req)                 ← ① A 的前置
      chain.nextCall(req):          ← 递归
          advisor = B (pop)
          B.adviseCall(req, chain):
              B.before(req)         ← ② B 的前置
              chain.nextCall(req):
                  advisor = C (pop)
                  C.adviseCall(req, chain):
                      C.before(req) ← ③ C 的前置
                      result = 真正调模型(或 C 不再递归)
                      C.after(res)  ← ④ C 的后置
                      return res
              B.after(res)          ← ⑤ B 的后置
              return res
      A.after(res)                  ← ⑥ A 的后置
      return res

执行顺序:A.before → B.before → C.before → 模型 → C.after → B.after → A.after

before 正序,after 逆序。 这正是"洋葱模型"(onion model)------与 Servlet Filter、Spring AOP 的 @Around、OkHttp Interceptor 完全一致。

4.7.4 为什么用栈而不是"index++ 的数组"?

假设用数组 + index:

java 复制代码
// 假想实现
private int index = 0;
public ChatClientResponse nextCall(ChatClientRequest req) {
    Advisor advisor = advisors.get(this.index++);
    return advisor.adviseCall(req, this);
}

这也能工作,但链变成有状态的、不可重入的 ------index 是链的一部分,多线程/重复订阅会互相干扰。

用 Deque.pop() 的好处是:"执行到哪了"这个状态被显式地编码进了数据结构本身 。链对象被消耗,语义清晰;要重来就 copy() 一条新的(§4.9)。

代价是链只能跑一次------这也是 §4.6.2 那个坑的根源。


4.8 order 语义:谁包着谁(三条交叉印证)

这是最容易搞反的地方,所以给出三条独立证据。

4.8.1 证据一:排序是升序

java 复制代码
// DefaultAroundAdvisorChain.java:277
277→		private void reOrder() {
278→			ArrayList<CallAdvisor> callAdvisors = new ArrayList<>(this.callAdvisors);
279→			OrderComparator.sort(callAdvisors);
280→			this.callAdvisors.clear();
281→			callAdvisors.forEach(this.callAdvisors::addLast);
282→
283→			ArrayList<StreamAdvisor> streamAdvisors = new ArrayList<>(this.streamAdvisors);
284→			OrderComparator.sort(streamAdvisors);
285→			this.streamAdvisors.clear();
286→			streamAdvisors.forEach(this.streamAdvisors::addLast);
287→		}

OrderComparator.sort 是 Spring 的标准排序器,升序 (值小的在前)。结果 addLast 回填到 Deque,所以 head = 最小 order。

pop() 从 head 取 → 执行顺序 = order 升序。

4.8.2 证据二:单元测试钉死

java 复制代码
// DefaultAroundAdvisorChainTests.java:216-230
216→		CallAdvisor advisor1 = createMockAdvisor("advisor1", 1);
217→		CallAdvisor advisor2 = createMockAdvisor("advisor2", 2);
218→		CallAdvisor advisor3 = createMockAdvisor("advisor3", 3);
...
227→		assertThat(chain.getCallAdvisors()).hasSize(3);
228→		assertThat(chain.getCallAdvisors().get(0).getName()).isEqualTo("advisor1");
229→		assertThat(chain.getCallAdvisors().get(1).getName()).isEqualTo("advisor2");
230→		assertThat(chain.getCallAdvisors().get(2).getName()).isEqualTo("advisor3");

输入 order = 1/2/3,输出 index = 0/1/2,同序。

4.8.3 证据三:真实 advisor 的 order 值

java 复制代码
// Advisor.java:39
39→	int DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER = Ordered.HIGHEST_PRECEDENCE + 200;

// ToolCallingAdvisor.java:75
75→	public static final int DEFAULT_ORDER = Ordered.HIGHEST_PRECEDENCE + 300;

Ordered.HIGHEST_PRECEDENCE = Integer.MIN_VALUE。所以:

  • 记忆 advisor:MIN + 200
  • 工具调用 advisor:MIN + 300

记忆 advisor 的 order 更小 → 更早执行 → 在外层。

这是有语义依据的:记忆要在工具调用之前加载(先把历史消息取出来拼进 prompt,再让模型决定要不要调工具)。所以记忆 advisor 必须"包着"工具 advisor。

再看 DefaultChatClient.java:1232-1233 的一段判断:

java 复制代码
1232→		boolean hasDownstreamMemoryAdvisor = this.advisors.stream()
1233→			.anyMatch(a -> a instanceof MemoryAdvisor && a.getOrder() > configuredOrder);

"order 更大" == "downstream(更靠内/下游)" ------ 官方注释直接确认了这个方向。

而链路最内层必然是模型调用 advisor:

java 复制代码
// ChatModelCallAdvisor.java:109-111
109→	@Override
110→	public int getOrder() {
111→		return Ordered.LOWEST_PRECEDENCE;    // Integer.MAX_VALUE
112→	}

LOWEST_PRECEDENCE = Integer.MAX_VALUE → 最后 pop → 最内层 → 真正调模型。

4.8.4 规则总结

sql 复制代码
order 值小 → 更早 pop → before 先跑、after 后跑 → 外层(outer / 包着别人)
order 值大 → 更晚 pop → before 后跑、after 先跑 → 内层(inner / 被包)

一句话记法:order 越小越在外层,就像"越早拦到请求,越晚放走响应"。

4.8.5 内置 advisor 的 order 全景(部分)

advisor order 层位 作用
(自定义,最小) 自定义 最外 ---
MessageChatMemoryAdvisor HIGHEST_PRECEDENCE + 200 外 加载/保存对话历史
ToolCallingAdvisor HIGHEST_PRECEDENCE + 300 中 驱动工具调用循环
QuestionAnswerAdvisor 默认(未设则为 LOWEST_PRECEDENCE?) --- RAG 检索增强
ChatModelCallAdvisor LOWEST_PRECEDENCE 最内 真正调用模型

实际项目中,QuestionAnswerAdvisor 等若未显式设 order,getOrder() 通常返回 Ordered.LOWEST_PRECEDENCE(即 Integer.MAX_VALUE)------这会让它与 ChatModelCallAdvisor 同 order,此时顺序由 OrderComparator 的稳定排序决定(保留原插入顺序)。如果顺序敏感,务必显式设 order。


4.9 copy(after):递归 advisor 的命门

java 复制代码
// DefaultAroundAdvisorChain.java:183
183→		int afterAdvisorIndex = advisors.indexOf(after);
184→
185→		if (afterAdvisorIndex < 0) {
186→			throw new IllegalArgumentException("The specified advisor is not part of the chain: " + after.getName());
187→		}
188→
189→		var remainingStreamAdvisors = advisors.subList(afterAdvisorIndex + 1, advisors.size());
190→
191→		return DefaultAroundAdvisorChain.builder(this.getObservationRegistry())
192→			.observationConvention(this.observationConvention)
193→			.pushAll(remainingStreamAdvisors)
194→			.build();

4.9.1 它解决什么问题?

考虑 ToolCallingAdvisor:它需要重新跑一遍链路中它之后的部分 (因为工具执行完要再次问模型)。但 Deque 已经被 pop() 消耗了------直接再调 nextCall() 会拿到错的 advisor 或者抛"链已空"。

copy(after) 就是解法:给我一条"从 after 之后开始"的新链。

java 复制代码
// ToolCallingAdvisor 里的典型用法(概念)
var newChain = chain.copy(this);   // 复制"我之后"的部分
var response = newChain.nextCall(updatedRequest);   // 重新跑下游

4.9.2 三个坑

坑一:indexOf(after) 用的是对象相等性。

第 183 行 advisors.indexOf(after) 依赖 equals。而 Advisor 接口的实现大多没有重写 equals(第 05 章详述)------所以是引用比较。

如果你传入一个"内容相同但不是同一个对象"的 advisor,indexOf 返回 -1,抛 IllegalArgumentException。

坑二:getCallAdvisors() 返回的是快照,不是活的 Deque。

java 复制代码
// DefaultAroundAdvisorChain.java:197-200
197→	@Override
198→	public List<CallAdvisor> getCallAdvisors() {
199→		return this.originalCallAdvisors;
200→	}

第 87 行 originalCallAdvisors = List.copyOf(...) 是快照 ,不受 pop() 影响。

这是好的设计 :链可以被消耗,但 introspection(查询链里有哪些 advisor)仍然正确。代价是你不能 通过 getCallAdvisors() 判断"还剩多少没跑"。

坑三:copy() 后原来的链仍被消耗。

copy 只是复制了"剩余的 advisor",不影响原 Deque 的状态。所以第 4.7.4 说的"链是一次性的"依然成立。


4.10 ChatClientRequest / ChatClientResponse 与 context 传播

4.10.1 数据结构

java 复制代码
// ChatClientRequest.java:36
36→public record ChatClientRequest(Prompt prompt, Map<String, @Nullable Object> context) {
java 复制代码
// ChatClientResponse.java:35
35→public record ChatClientResponse(@Nullable ChatResponse chatResponse, Map<String, @Nullable Object> context) {

都是 record,且 context 是普通可变 Map。

实际注入的是 ConcurrentHashMap(DefaultChatClientUtils.java:135)------因为流式场景下可能被多线程访问。

4.10.2 四种传播机制

机制一:共享可变引用(直接 put,全链可见)

java 复制代码
// DefaultChatClient.java:595-599
595→		if (StringUtils.hasText(outputConverter.getFormat())) {
596→			// Used for default structured output format support, based on prompt
597→			// instructions.
598→			this.request.context().put(ChatClientAttributes.OUTPUT_FORMAT.getKey(), outputConverter.getFormat());
599→		}

写入方是 entity(),读取方是最内层的 ChatModelCallAdvisor:

java 复制代码
// ChatModelCallAdvisor.java:68-71
68→		String outputFormat = (String) chatClientRequest.context().get(ChatClientAttributes.OUTPUT_FORMAT.getKey());
69→
70→		String outputSchema = (String) chatClientRequest.context()
71→			.get(ChatClientAttributes.STRUCTURED_OUTPUT_SCHEMA.getKey());

这是"跨 advisor 传递状态"的主力机制:context 就是一个共享的 Map,谁都能读写。

机制二:advisorParams 作为 context 初始值(用户配置入口)

java 复制代码
// ChatClient.java:188-190
188→		AdvisorSpec param(String k, Object v);
190→		AdvisorSpec params(Map<String, Object> p);

用户侧:

java 复制代码
chatClient.prompt("...")
    .advisors(a -> a.param("tenantId", "acme"))
    .call()
    .content();

流入:.advisors(a -> a.param(...)) → DefaultChatClientRequestSpec.advisorParams → new ConcurrentHashMap<>(advisorParams) → context。

advisor 侧:request.context().get("tenantId")。

机制三:mutate() 做防御性拷贝(隔离,不回传)

java 复制代码
// ChatClientRequest.java:44-50
44→	public ChatClientRequest copy() {
45→		return new ChatClientRequest(this.prompt.copy(), new HashMap<>(this.context));
46→	}
47→
48→	public Builder mutate() {
49→		return new Builder().prompt(this.prompt.copy()).context(new HashMap<>(this.context));
50→	}

⚠️ 第 45/49 行的 new HashMap<>(this.context) 是浅拷贝出新 Map ------ 一旦某 advisor 走 mutate() 再 build(),它下游对 context 的改动不会回传给上游。

这也是为什么 StructuredOutputValidationAdvisor.java:168 必须显式 chatClientRequest.mutate().prompt(augmentedPrompt).build() 才能改写 prompt------不改回原对象,改的是副本。

机制四:response 侧的 context(反向传递 + 聚合)

java 复制代码
// ChatClientMessageAggregator.java:43-51
43→		AtomicReference<Map<String, Object>> context = new AtomicReference<>(new HashMap<>());
...
46→			context.get().putAll(chatClientResponse.context());
...
49→			ChatClientResponse aggregatedChatClientResponse = ChatClientResponse.builder()
50→				.chatResponse(aggregatedChatResponse)
51→				.context(context.get())

流式场景下,每个 chunk 的 context 会被 putAll 累积合并 ,最后统一回填。这意味着:流式过程中 advisor 往 context 里写的东西,最终都能在聚合后的 response 里看到。

4.10.3 四个官方 context key

java 复制代码
// ChatClientAttributes.java:29-41
spring.ai.chat.client.output.format           → OUTPUT_FORMAT
spring.ai.chat.client.structured.output.schema → STRUCTURED_OUTPUT_SCHEMA
spring.ai.chat.client.structured.output.native → STRUCTURED_OUTPUT_NATIVE
spring.ai.chat.client.tool.calling.advisor.auto-register → TOOL_CALLING_ADVISOR_AUTO_REGISTER

另有一个非官方但广泛使用的:ChatMemory.CONVERSATION_ID(BaseChatMemoryAdvisor.java:41)------记忆 advisor 用它定位对话。

4.10.4 实践建议:context key 的命名

用你自己的 key 时,加前缀避免冲突:

java 复制代码
// ❌ 危险:可能与未来版本的内置 key 冲突
request.context().put("userId", id);

// ✅ 安全
request.context().put("com.acme.myapp.userId", id);

4.11 mutate() 的语义:为什么叫 mutate 而不是 copy

ChatClient.Builder.mutate()(ChatClient.java:139 / ChatClientRequestSpec.mutate() 第 392 行)返回的是 Builder,不是成品。

4.11.1 三条源码理由

理由一:返回的是可继续变异的 Builder,不是成品。

对比 ChatClientRequest.copy()(第 44 行)返回成品。命名区分的是"返回物是否还能继续变"。

理由二:实现是"重放配置"而非"复制状态"。

java 复制代码
// DefaultChatClient.java:934-963
934→			DefaultChatClientBuilder builder = (DefaultChatClientBuilder) ChatClient
935→				.builder(this.chatModel, this.observationRegistry, this.chatClientObservationConvention,
936→						this.advisorObservationConvention, this.toolCallingAdvisorBuilder)
937→				.defaultTemplateRenderer(this.templateRenderer)
938→				.defaultTools(this.toolCallbacks.toArray(new ToolCallback[0]))
939→				.defaultTools((Object[]) this.toolCallbackProviders.toArray(new ToolCallbackProvider[0]))
940→				.defaultToolContext(this.toolContext);
...
943→				builder.defaultAdvisors(a -> a.advisors(this.advisors).params(this.advisorParams));
...
948→				builder.defaultUser(u -> u.text(text)
949→					.params(this.userParams)
950→					.media(this.media.toArray(new Media[0]))
951→					.metadata(this.userMetadata));
...
956→				builder.defaultSystem(s -> s.text(text).params(this.systemParams).metadata(this.systemMetadata));
...
960→				builder.defaultOptions(this.optionsCustomizer);
963→			builder.addMessages(this.messages);

整个方法是逐个调用 defaultXxx(...) 重新描述配置,是"重放"而不是"拷贝字段"。

理由三:能迁移接口外暴露不了的内部状态。

java 复制代码
// DefaultChatClientBuilder.java:244-246
244→	void addMessages(List<Message> messages) {
245→		this.defaultRequest.messages(messages);
246→	}

addMessages 是包私有 方法,不在 ChatClient.Builder 接口里。第 963 行调用了它------纯 "copy" 语义下这个能力不在公开契约内。

4.11.2 clone() 与 mutate() 的关系

Javadoc(ChatClient.java:497-498)明写 "A mutable builder",接口单独提供 clone()(第 589 行):

  • clone() = 纯复制,返回可变物但不加任何东西
  • mutate() = 复制 并 交还一个已按当前 spec 装配好的可变构造物

而 clone() 的实现就是 mutate() (DefaultChatClientBuilder.java:118-120):

java 复制代码
118→	public Builder clone() {
119→		return this.defaultRequest.mutate();
120→	}

所以两者在 Builder 层面是一回事 ;区别在 ChatClientRequestSpec.mutate()(第 392 行)------那是"把这次请求的配置固化成 Builder 默认值"。



4.12 entity() 的完整路径:五步把文本变成 POJO

entity(Class<T>) 是 Spring AI 最"魔法"的 API------一行代码把 LLM 输出变成 Java 对象。本节拆开它。

步骤 1:入口,new 一个 BeanOutputConverter

java 复制代码
// DefaultChatClient.java:566-570
566→	@Override
567→	public <T> @Nullable T entity(Class<T> type) {
568→		Assert.notNull(type, "type cannot be null");
569→		return doSingleWithBeanOutputConverter(new BeanOutputConverter<>(type));
570→	}

注意第 569 行 :entity(Class<T>) 的实现就是"new 一个 BeanOutputConverter 然后走通用流程"。

如果你传了自己的 converter(entity(StructuredOutputConverter<T>),第 561-564 行),就跳过这一步。

步骤 2:BeanOutputConverter 构造时即生成 JSON Schema

java 复制代码
// BeanOutputConverter.java:138-144
138→	private BeanOutputConverter(Type type, @Nullable JsonMapper jsonMapper, @Nullable ResponseTextCleaner textCleaner) {
139→		Objects.requireNonNull(type, "Type cannot be null;");
140→		this.type = type;
141→		this.jsonMapper = jsonMapper != null ? jsonMapper : getJsonMapper();
142→		this.textCleaner = textCleaner != null ? textCleaner : createDefaultTextCleaner();
143→		this.jsonSchema = Objects.requireNonNull(generateSchema(), "JSON schema cannot be null");
144→	}
java 复制代码
// BeanOutputConverter.java:179-181
179→	protected String generateSchema() {
180→		return JsonSchemaGenerator.generateForType(this.type);
181→	}

构造时就生成 schema 并缓存 (第 143 行)。这意味着:同一个 BeanOutputConverter 实例可以复用 ,但每次 entity(Class) 都会 new 一个新的(第 569 行),所以 schema 会被重复生成。

性能提示 :如果在循环里调 entity(),schema 生成开销会累积。可以预先创建 converter 并复用:

java 复制代码
BeanOutputConverter<MyPojo> converter = new BeanOutputConverter<>(MyPojo.class);
for (...) {
    MyPojo p = chatClient.prompt(...).call().entity(converter);   // 复用 schema
}

步骤 3:把 format 指令塞进 context

java 复制代码
// DefaultChatClient.java:592-608
592→	private <T> @Nullable T doSingleWithBeanOutputConverter(StructuredOutputConverter<T> outputConverter,
593→			BaseAdvisorChain advisorChain) {
...
595→		if (StringUtils.hasText(outputConverter.getFormat())) {
596→			// Used for default structured output format support, based on prompt
597→			// instructions.
598→			this.request.context().put(ChatClientAttributes.OUTPUT_FORMAT.getKey(), outputConverter.getFormat());
599→		}
600→
601→		if (Boolean.TRUE
602→			.equals(this.request.context().get(ChatClientAttributes.STRUCTURED_OUTPUT_NATIVE.getKey()))) {
...
605→			this.request.context()
606→				.put(ChatClientAttributes.STRUCTURED_OUTPUT_SCHEMA.getKey(), outputConverter.getJsonSchema());
607→		}
608→
610→		var chatResponse = doGetObservableChatClientResponse(this.request, advisorChain).chatResponse();

两条分支:

  • 默认(第 595-599 行) :把 getFormat()(一段自然语言说明,如 "Your response should be in JSON format / The JSON Schema is: ...")放进 context 的 OUTPUT_FORMAT 键。
  • 原生(第 601-607 行) :如果 context 里 STRUCTURED_OUTPUT_NATIVE 为 true,额外把 JSON Schema 放进 STRUCTURED_OUTPUT_SCHEMA 键。

STRUCTURED_OUTPUT_NATIVE 由用户通过 EntityParamSpec.useProviderStructuredOutput()(第 225 行)开启:

java 复制代码
chatClient.prompt("...")
    .call()
    .entity(MyPojo.class, spec -> spec.useProviderStructuredOutput());

步骤 4:ChatModelCallAdvisor 把指令拼进 prompt

软约束路径(默认):

java 复制代码
// ChatModelCallAdvisor.java:92-95
92→		Prompt augmentedPrompt = chatClientRequest.prompt()
93→			.augmentUserMessage(userMessage -> userMessage.mutate()
94→				.text(userMessage.getText() + System.lineSeparator() + outputFormat)
95→				.build());

第 94 行是关键 :把格式说明追加到用户消息末尾 (用 System.lineSeparator() 分隔)。

这就是为什么 entity() 能工作 ------没有任何 API 层面的约束,只是"在提示词里求模型按格式输出"。模型可能不遵守。

原生路径:

java 复制代码
// ChatModelCallAdvisor.java:80-90(概念)
structuredOutputChatOptions.mutate().outputSchema(outputSchema).build()

走供应商的 JSON Schema 能力(如 OpenAI 的 response_format: json_schema)。这是硬约束------供应商保证输出符合 schema。

两条路径的对比:

软约束(默认) 原生(useProviderStructuredOutput)
机制 把说明拼进 prompt 供应商 API 的 schema 参数
保证 无,模型可能不遵守 有,API 层面强制
兼容性 所有模型 仅支持的供应商/模型
提示词开销 增加 token 不增加(schema 走独立字段)
失败模式 输出非法 JSON API 报错或保证合法

实践建议 :如果目标模型支持原生 schema,永远开启它。软约束在长对话、复杂 schema 下失败率显著更高。

步骤 5:取文本 → 清洗 → 反序列化

java 复制代码
// DefaultChatClient.java:612-616
612→		var stringResponse = getContentFromChatResponse(chatResponse);
613→		if (stringResponse == null) {
614→			return null;
615→		}
616→		return outputConverter.convert(stringResponse);
java 复制代码
// BeanOutputConverter.java:188-195
188→	@SuppressWarnings("unchecked")
189→	@Override
190→	public T convert(String text) {
191→		// Clean the text using the configured text cleaner
192→		text = this.textCleaner.clean(text);
193→
194→		return (T) this.jsonMapper.readValue(text, this.jsonMapper.constructType(this.type));
195→	}

第 192 行的 textCleaner.clean(text) 是容易被忽略但极其重要的一步。

清洗链(BeanOutputConverter.java:163-170):

复制代码
WhitespaceCleaner → ThinkingTagCleaner → MarkdownCodeBlockCleaner → WhitespaceCleaner

四个清洗器各自解决一个真实问题:

清洗器 解决的问题 例子
WhitespaceCleaner 前后空白 "\n\n{...}\n"
ThinkingTagCleaner 推理模型的思考过程 <think>让我想想...</think>{...}
MarkdownCodeBlockCleaner 模型习惯性包代码块 json\n{...}\n
WhitespaceCleaner 清洗后再去一次空白 ---

ThinkingTagCleaner 的存在说明 2.x 跟上了推理模型(reasoning model)的浪潮 ------DeepSeek-R1、o1 这类模型会输出 <think> 标签包裹的思考过程,如果不剥离,JSON 解析必然失败。

MarkdownCodeBlockCleaner 则是对付模型的"markdown 强迫症"------LLM 训练语料里代码都包在 ``` 里,所以让它输出 JSON 时也常常包一层。

这两个清洗器是"实践出真知"的典范:没有它们,entity() 在生产环境的失败率会高得多。


4.13 MessageAggregator:last-non-zero-wins 而不是累加

流式链路需要一个组件把 Flux<ChatResponse> 聚合成单个 ChatResponse。它在**spring-ai-model**(不是 client-chat):

css 复制代码
spring-ai-model/src/main/java/org/springframework/ai/chat/model/MessageAggregator.java  (237 行)

4.13.1 usage 累计算法(原样代码)

java 复制代码
124→				if (chatResponse.getMetadata().getUsage() != null) {
125→					Usage usage = chatResponse.getMetadata().getUsage();
126→					metadataUsagePromptTokensRef.set(
127→							usage.getPromptTokens() > 0 ? usage.getPromptTokens() : metadataUsagePromptTokensRef.get());
128→					metadataUsageGenerationTokensRef.set(usage.getCompletionTokens() > 0 ? usage.getCompletionTokens()
129→							: metadataUsageGenerationTokensRef.get());
130→					metadataUsageTotalTokensRef
131→						.set(usage.getTotalTokens() > 0 ? usage.getTotalTokens() : metadataUsageTotalTokensRef.get());
132→				}

这不是加法,是 "last-non-zero-wins"(最后一个非零值胜出):

ini 复制代码
new = (incoming > 0) ? incoming : current

三个字段(promptTokens / completionTokens / totalTokens)同构处理。

4.13.2 为什么不是加法?

设计前提是"provider 上报累计值"。

多数流式 API(包括 OpenAI 开 include_usage 时)在最后一个 chunk 上报的是整次调用的累计 usage,而不是"本 chunk 的增量"。如果把每个 chunk 的 usage 相加,会重复计算。

> 0 守卫的作用 :跳过流式中间 chunk 上报的 0。因为大多数 chunk 的 usage 是 EmptyUsage(返回 0,ChatResponseMetadata.java:51),只有最后一个 chunk 有真实值。

4.13.3 累加器初值与重置

java 复制代码
70→		AtomicReference<Integer> metadataUsagePromptTokensRef = new AtomicReference<>(0);
71→		AtomicReference<Integer> metadataUsageGenerationTokensRef = new AtomicReference<>(0);
72→		AtomicReference<Integer> metadataUsageTotalTokensRef = new AtomicReference<>(0);

初值 0,在三个地方(第 70-72、88-90、201-203 行)完全一致地初始化------说明这个方法被复制了三次(或有三条代码路径)。

4.13.4 最终组装

java 复制代码
157→			var usage = new DefaultUsage(metadataUsagePromptTokensRef.get(), metadataUsageGenerationTokensRef.get(),
158→					metadataUsageTotalTokensRef.get());
159→
160→			var chatResponseMetadata = ChatResponseMetadata.builder()
161→				.id(metadataIdRef.get())
162→				.model(metadataModelRef.get())
163→				.rateLimit(metadataRateLimitRef.get())
164→				.usage(usage)
165→				.promptMetadata(metadataPromptMetadataRef.get())
166→				.build();

组装出一个完整的 ChatResponseMetadata。

4.13.5 真正的加法在另一层

跨 advisor 多轮(如工具调用循环)的 usage 累加由 UsageAccumulator 负责:

java 复制代码
// UsageAccumulator.java:83-86
83→	public @Nullable ChatResponse addRoundResponse(@Nullable ChatResponse roundChatResponse) {
84→		this.accumulated = UsageCalculator.accumulateResponseUsage(roundChatResponse, this.accumulated);
85→		return this.accumulated;
86→	}

这里才是真正的加法 (accumulateResponseUsage)。

两层区分:

层 类 算法 场景
chunk → response MessageAggregator last-non-zero-wins 流式单个响应
response → response UsageAccumulator 真加法 工具调用多轮

实践影响 :如果你的供应商上报的是增量 usage 而不是累计 usage(少数情况),MessageAggregator 会低估 token 消耗。这时需要自己写聚合逻辑。


4.14 流式不支持 entity():一个"双重不可用"的真相

这是本章最需要提醒使用者的发现。

4.14.1 第一重:StreamResponseSpec 根本没有 entity()

java 复制代码
// ChatClient.java:376-384
376→	interface StreamResponseSpec {
378→		Flux<ChatClientResponse> chatClientResponse();
380→		Flux<ChatResponse> chatResponse();
382→		Flux<String> content();
383→	}

三个方法,没有 entity()。 编译期就挡住了。

4.14.2 第二重:即使手动传 context,流式也不会追加格式指令

假设你想绕过第一重,手动把 format 放进 context 然后读流:

java 复制代码
// 用户侧的尝试
chatClient.prompt("...")
    .advisors(a -> a.param(ChatClientAttributes.OUTPUT_FORMAT.getKey(), myFormat))
    .stream()
    .content();

不会生效。 因为:

java 复制代码
// ChatModelStreamAdvisor.adviseStream(第 48-59 行)
// ------ 没有 augmentWithFormatInstructions

ChatModelStreamAdvisor 没有实现"把 OUTPUT_FORMAT 追加到 prompt"的逻辑 (对比 ChatModelCallAdvisor 第 92-95 行有)。

所以即使 context 里有 OUTPUT_FORMAT,流式链路不会把它拼进 prompt。模型不知道你要 JSON,输出自然不是 JSON。

4.14.3 结论与替代方案

结论:流式 + 结构化输出,在 2.0.1 里是不可用的。

如果你必须"流式打字机效果 + 结构化结果",两条可行路径:

方案一:流式拿文本,结束后自己解析。

java 复制代码
Flux<String> chunks = chatClient.prompt("...").stream().content();
String full = chunks.reduce("", String::concat).block();   // 拼成完整文本
MyPojo pojo = new BeanOutputConverter<>(MyPojo.class).convert(full);   // 自己解析

注意:这条路仍然需要你自己 把格式指令写进 prompt(因为框架不会加),并且要自己处理 ``` 代码块 / <think> 标签(可以复用 BeanOutputConverter 的清洗链)。

方案二:非流式调用拿结构,流式只用于展示。

先 .call().entity(...) 拿到结构化结果,再用另一条链路做流式展示。代价是两次调用。

方案三(推荐用于生产):用非流式 + 原生 schema。

java 复制代码
MyPojo p = chatClient.prompt("...")
    .call()
    .entity(MyPojo.class, spec -> spec.useProviderStructuredOutput());

放弃打字机效果,换取可靠性。

4.14.4 为什么框架不做?

推测两条:

  1. 流式的每个 chunk 是完整 ChatResponse,但没有"聚合后的完整响应"可用于解析 ------ 解析必须在流结束之后,而 Flux<String> 的语义是"边来边给"。要 entity() 就得 block(),与流式背道而驰。
  2. 结构化输出的流式场景(如逐步填充的 JSON)需要专门的增量解析,成本高。

这是框架的一个明确的能力边界,值得记住。


4.15 StructuredOutputValidationAdvisor:自纠错

EntityParamSpec.validateSchema()(第 232 行)会挂上这个 advisor。

java 复制代码
// DefaultChatClient.java:583-585
583→				return this.advisorChain.mutate().push(validationAdvisor).build();
585→			return this.advisorChain;

第 583 行:把校验 advisor 推到链上。

StructuredOutputValidationAdvisor.java 共 370 行,是内置 advisor 里最复杂的一个。它的核心机制:

  1. 拿到模型输出后,用 JSON Schema 校验
  2. 不合法 → 把错误信息 + 原输出塞进 prompt,让模型重试
  3. 重试 N 次仍失败 → 抛异常

第 168 行的关键写法:

java 复制代码
chatClientRequest.mutate().prompt(augmentedPrompt).build()

如 §4.10.2 机制三所述,mutate() 会拷贝 context。所以这个 advisor 修改后的 prompt 只影响它下游,不回传上游。

这个 advisor 的 order 会在第 06 章详述(内置 advisor 逐个拆解)。


4.16 观测集成:两条链路各有一个 span

第 17 章会系统展开,这里只标出本章相关的两个点。

4.16.1 ChatClient 层有一个 observation

java 复制代码
// DefaultChatClient.java:645-650
645→			ChatClientObservationContext observationContext = ChatClientObservationContext.builder()
646→				.request(chatClientRequest)
647→				.advisors(advisorChain.getCallAdvisors())
648→				.stream(false)          // ← 流式是 true
649→				.format(outputFormat)
650→				.build();

4.16.2 每个 advisor 也有一个 observation

java 复制代码
// DefaultAroundAdvisorChain.java:107-111
107→		var observationContext = AdvisorObservationContext.builder()
108→			.advisorName(advisor.getName())
109→			.chatClientRequest(chatClientRequest)
110→			.order(advisor.getOrder())
111→			.build();

所以一次 call() 会产生 1 + N 个 span(N = advisor 数量)。

实践影响:如果你的 advisor 链很长(比如 8 个),一次调用会产生 9 个 span。在高频场景下这会显著增加追踪系统的负担------考虑用采样率控制。

4.16.3 流式链路额外做了 parent observation 处理

§4.6.1 差异二讲过(第 718-721 行)。非流式没有这段------因为非流式是同步调用,线程上下文天然正确。



4.17 Kotlin 扩展:两行代码省掉所有样板

kotlin 复制代码
// spring-ai-client-chat/src/main/kotlin/org/springframework/ai/chat/client/ChatClientExtensions.kt:28
28→inline fun <reified T : Any> ChatClient.CallResponseSpec.entity(): T =
29→	entity(object : ParameterizedTypeReference<T>() {}) as T
30→
31→inline fun <reified T : Any> ChatClient.CallResponseSpec.responseEntity(): ResponseEntity<ChatResponse, T> =
32→	responseEntity(object : ParameterizedTypeReference<T>() {})

作者是 Josh Long(Spring 的 Kotlin 布道者)。

4.17.1 它解决什么痛点

Java 里拿泛型类型要写匿名内部类:

java 复制代码
List<MyPojo> list = chatClient.prompt("...")
    .call()
    .entity(new ParameterizedTypeReference<List<MyPojo>>() {});

那对 {} 是 Java 类型擦除留下的疤 ------ParameterizedTypeReference 靠子类化才能保留泛型信息(getGenericSuperclass() 反射)。

Kotlin 有 reified 类型参数,可以在编译期拿到真实类型。于是扩展函数把它适配过去:

kotlin 复制代码
val list: List<MyPojo> = chatClient.prompt("...").call().entity()

零样板。 类型由返回值声明推断。

4.17.2 为什么 responseEntity() 也值得注意

ResponseEntity<ChatResponse, T> 是一个双载 结构:既给你原始 ChatResponse(含 metadata、usage),又给你转换后的 POJO。

kotlin 复制代码
val (chatResponse, pojo) = chatClient.prompt("...").call().responseEntity<MyPojo>()
//  ↑ 拿 token 统计       ↑ 拿业务对象

这在需要同时要"结构化结果"和"token 消耗"的场景下是唯一解 ------因为 entity() 只给你 POJO,把 ChatResponse 丢了。

Java 里对应:

java 复制代码
ResponseEntity<ChatResponse, MyPojo> re = chatClient.prompt("...")
    .call()
    .responseEntity(new ParameterizedTypeReference<MyPojo>() {});
ChatResponse resp = re.response();
MyPojo pojo = re.entity();

4.18 创建 ChatClient 的五种方式

ChatClient 提供了 create() 与 builder() 两组静态工厂。

4.18.1 create():两个重载

java 复制代码
// ChatClient.java:63
63→	static ChatClient create(ChatModel chatModel) {
64→		return create(chatModel, ObservationRegistry.NOOP);
65→	}
66→
67→	static ChatClient create(ChatModel chatModel, ObservationRegistry observationRegistry) {
68→		return create(chatModel, observationRegistry, null, null);
69→	}

注意第 64 行:ObservationRegistry.NOOP。

create(chatModel) 是无观测 的。生产环境必须用 create(chatModel, observationRegistry) 或 builder() 传入真实 registry。

4.18.2 builder():三个重载,五参数版最完整

java 复制代码
// ChatClient.java:112(五参数版,最完整)
112→	static Builder builder(ChatModel chatModel, ObservationRegistry observationRegistry,
113→			@Nullable ChatClientObservationConvention chatClientObservationConvention,
114→			@Nullable AdvisorObservationConvention advisorObservationConvention,
115→			ToolCallingAdvisor.@Nullable Builder<?> toolCallingAdvisorBuilder) {
116→		Assert.notNull(chatModel, "chatModel cannot be null");
117→		Assert.notNull(observationRegistry, "observationRegistry cannot be null");
118→
119→		return new DefaultChatClientBuilder(chatModel, observationRegistry, chatClientObservationConvention,
120→				advisorObservationConvention, toolCallingAdvisorBuilder);
121→	}

4.18.3 一个容易被忽略的 Javadoc 段落(第 92-104 行)

java 复制代码
/**
 * ...
 * When {@code toolCallingAdvisorBuilder} is {@code null}, a default
 * {@link ToolCallingAdvisor} is created with a {@link ToolCallingManager} backed by the
 * supplied {@code observationRegistry}.
 * <p>
 * When {@code toolCallingAdvisorBuilder} is non-null it is used as-is. The caller is
 * then responsible for configuring the builder's {@link ToolCallingManager}, including
 * any {@link ObservationRegistry}, since the supplied {@code observationRegistry} will
 * not be automatically applied to it.
 */

这段话解释了一个重要事实:ChatClient 默认就挂了 ToolCallingAdvisor。

这回答了"为什么 ChatClient 能自动执行工具,而 ChatModel.call() 不能"(第 02 章 §2.5.6 的问题)------因为 ChatClient 默认在 advisor 链里放了一个 ToolCallingAdvisor。

但有个陷阱 :如果你自己 传了 toolCallingAdvisorBuilder,那么:

你必须自己配置它的 ToolCallingManager,包括 ObservationRegistry------因为传入的 observationRegistry 不会自动应用。

实践后果 :自定义 ToolCallingAdvisor 时容易丢掉观测。要么不传(用默认的),要么自己把 registry 设进去。

4.18.4 五种创建方式对照

方式 观测 工具调用 适用场景
create(chatModel) ❌ NOOP ✅ 默认 advisor 快速原型、测试
create(chatModel, registry) ✅ ✅ 简单生产场景
builder(chatModel) ❌ NOOP ✅ 需要配置但不需要观测
builder(chatModel, registry) ✅ ✅ 推荐
builder(chatModel, registry, ccConv, aConv, tcBuilder) ✅ 可自定义 可自定义 深度定制

Spring Boot 项目里不需要手工创建 ------ChatClientAutoConfiguration 提供 prototype 的 Builder bean(§4.4.1),直接注入即可。


4.19 一个端到端调试案例:用本章知识定位问题

假设你遇到这个现象:

用 chatClient.prompt(...).stream().content() 做打字机效果,模型明明应该调用工具(你在 .tools() 里注册了),但界面上只看到一段文字"我需要查询天气,请稍候",工具从未执行。

4.19.1 定位过程

第一步:确认工具注册了。

检查 .tools() / .toolCallbacks() 是否被调用(第 447/454 行)。假定已调用。

第二步:确认 advisor 链里有 ToolCallingAdvisor。

§4.18.3 说过,ChatClient 默认会挂 ToolCallingAdvisor(order = HIGHEST_PRECEDENCE + 300)。所以链里应该有。

第三步:看返回类型。

.content() → Flux<String>(§4.3.1)。

第四步:关键洞察。

ToolCallingAdvisor 需要看到 ChatResponse.hasToolCalls() 才能驱动循环。而 Flux<String> 是 Flux<ChatResponse> 经过 map(...) 转成纯文本的结果------toolCalls 在这一步被丢掉了。

但等等:advisor 链是在 stream() 内部跑的,ToolCallingAdvisor 看到的应该是 Flux<ChatClientResponse>(完整信息),它应该能工作......

第五步:查 ToolCallingAdvisor 是否实现了流式。

ToolCallingAdvisor 同时实现 CallAdvisor 和 StreamAdvisor。但在流式路径上,工具执行循环需要"等工具调用 chunk 全部到达后才能执行"------这是一个聚合 + 重新发起的过程,实现复杂。

第 08 章会详述:ToolCallingAdvisor 的流式实现有明确的限制条件。

4.19.2 真正的结论

不要在需要工具调用的场景用 .stream().content()。

改用:

java 复制代码
// 方案 A:非流式(工具调用最可靠)
var result = chatClient.prompt("...").tools(myTools).call().content();

// 方案 B:流式但保留完整响应,自己处理 toolCalls
Flux<ChatResponse> flux = chatClient.prompt("...").tools(myTools).stream().chatResponse();

4.19.3 这个案例暴露的通用排查方法

  1. 确认返回类型丢掉了什么(§4.3)
  2. 确认 advisor 链的构成与顺序(§4.8)
  3. 确认 advisor 是否支持该链路(call / stream 双实现)
  4. 确认 context 是否被正确传递(§4.10)

这四步能解决 80% 的 ChatClient 行为异常。


4.20 advisor 链的一次性消耗与并发安全

4.20.1 链是一次性的

§4.7.2 讲过:pop() 破坏性地弹出 advisor。所以:

java 复制代码
BaseAdvisorChain chain = ...;
chain.nextCall(req);     // 跑完,Deque 空了
chain.nextCall(req);     // ❌ IllegalStateException: No CallAdvisors available to execute

在 DefaultChatClient 里这不是问题 ------因为每次 call() / stream() 都会重新 build() 一条链(通过 advisorChain.mutate() 或类似机制)。

但如果你自己在 advisor 内部操作链,就必须注意:

java 复制代码
// ✗ 错误:复用一条已消耗的链
var response1 = chain.nextCall(req1);
var response2 = chain.nextCall(req2);   // 崩

// ✓ 正确:copy 一条新的
var response2 = chain.copy(someAdvisor).nextCall(req2);

4.20.2 getCallAdvisors() 返回的是快照

java 复制代码
// DefaultAroundAdvisorChain.java:87
87→	// originalCallAdvisors = List.copyOf(...)   (快照)
java 复制代码
197→	@Override
198→	public List<CallAdvisor> getCallAdvisors() {
199→		return this.originalCallAdvisors;
200→	}

这解决了"消耗与 introspection 的矛盾":链可以被消耗,但查询"链里有哪些 advisor"永远正确。

代价:你无法通过 getCallAdvisors().size() 判断还剩几个没跑。

4.20.3 流式链路的并发隐患

§4.6.2 提到的"重复订阅"问题,根源就在这里。Deque 是共享的可变状态:

scss 复制代码
订阅 1 → deferContextual → pop() → advisor A
订阅 2 → deferContextual → pop() → advisor B   ← 拿到的不是 A!

如果你的 Flux 被订阅两次(比如 WebFlux 里 Flux 被两个 handler 消费),第二次会拿到错乱的 advisor 链,或者直接报错。

修法:

java 复制代码
// 用 cache() 让结果可重复消费
Flux<String> cached = chatClient.prompt("...").stream().content().cache();

或者每次都重新调用 .stream()。

4.20.4 一个设计层面的反思

"用 Deque.pop() 表达进度"是个优雅但不安全的选择:

  • 优雅:状态显式、语义清晰、copy(after) 实现简单
  • 不安全:共享可变状态 + 破坏性操作 = 天然的并发隐患

框架通过"每次调用都新建链"规避了大部分风险。但在自定义 advisor 里操作链时,这个风险会重新浮现。

给自定义 advisor 作者的建议 :永远用 chain.copy(...) 而不是直接 chain.nextXxx() 重复调用。



4.21 决策树:ChatClient 还是 ChatModel?

第 02 章讲过 ChatModel 是抽象层,本章讲了 ChatClient 是门面层。什么时候用哪个?

4.21.1 能力对照

能力 ChatModel.call(prompt) ChatClient
options 合并 ❌ 不合并(§3.13) ✅ 以模型默认值为基线合并
advisor 链 ❌ 没有 ✅ 完整支持
工具自动执行 ❌ 返回原始响应(§2.5.6) ✅ 默认挂 ToolCallingAdvisor
对话记忆 ❌ 要自己拼消息 ✅ MessageChatMemoryAdvisor
RAG ❌ 要自己检索 ✅ RetrievalAugmentationAdvisor
结构化输出 ❌ 要自己做 ✅ entity()
观测 由实现提供 ✅ 内置 client + advisor 双层
流式 ✅ Flux<ChatResponse> ✅ 三种粒度
依赖 spring-ai-model spring-ai-client-chat(+ 更多)

4.21.2 决策树

scss 复制代码
你需要以下任何一项吗?
  ├─ advisor(记忆 / RAG / 工具 / 日志 / 校验)
  ├─ 自动工具执行
  ├─ entity() 结构化输出
  └─ 只设置部分 options 而保留模型默认值
        │
        ├─ 是 → 用 ChatClient
        │
        └─ 否 → 你在写一个底层组件吗?(比如自定义 ChatModel、写单元测试)
                    │
                    ├─ 是 → 用 ChatModel(依赖更少,行为更可预测)
                    └─ 否 → 仍然建议用 ChatClient(未来可扩展)

4.21.3 三条实践建议

建议一:应用代码一律用 ChatClient。

除了"我在实现一个新的 ChatModel"这种框架级场景,业务代码都应该用 ChatClient。理由不是"它功能多",而是 options 合并语义 (§3.13)------用 ChatModel 时你必须自己保证 options 完整,这是持续性的负担。

建议二:写 ChatModel 的单元测试时用 ChatModel。

因为 ChatClient 会引入 advisor 链和观测,让测试变复杂。直接构造 Prompt 调 chatModel.call() 最干净。

建议三:如果必须用 ChatModel,记住 options 规则。

java 复制代码
// 模板
ChatOptions opts = chatModel.getOptions().mutate()   // 基线
    .temperature(0.2)                                 // 你的覆盖
    .build();
chatModel.call(new Prompt(messages, opts));

4.21.4 一个反例:什么时候 ChatClient 反而不好

场景:你需要精确控制发给模型的每一个字段。

ChatClient 的 advisor 链可能修改 prompt(比如 ChatModelCallAdvisor 追加格式指令,MessageChatMemoryAdvisor 拼接历史)。如果你在做协议级调试 (比如验证某个供应商的某个参数是否被正确传递),ChatClient 的"帮你做事"会变成干扰。

这时用 ChatModel 更透明:

java 复制代码
// 你知道请求里每一个字节
ChatResponse r = chatModel.call(new Prompt(List.of(new UserMessage("hi")), exactOptions));

4.22 本章小结:六条结论

结论一:五个"经典"类名在 2.0.1 不存在

ChatClientResponseSpec → CallResponseSpec;CallAroundAdvisor → CallAdvisor;nextAroundCall → nextCall。搜源码前先 grep(§4.1)。

结论二:advisor 链是栈 ,用 Deque.pop() 推进

不是数组 + index。五条证据:字段是 Deque、推进是 pop()、建造是 push、注释说 "stack bottom"、after 天然逆序(§4.7)。

链是一次性消耗品 ------重复调用会抛 IllegalStateException,重复订阅会拿到错乱的 advisor(§4.20)。

结论三:order 越小越在外层

OrderComparator.sort 升序 → head 是最小 order → pop() 先取 → before 先跑、after 后跑 = 外层。

三条交叉印证:reOrder() 源码、单测(1/2/3 保持同序)、真实 advisor 的 order 值(记忆 +200 在外,工具 +300 在内,模型调用 LOWEST_PRECEDENCE 最内)(§4.8)。

结论四:.content() 会丢掉 toolCalls

三种返回类型的信息量:Flux<ChatClientResponse> > Flux<ChatResponse> > Flux<String>。工具调用场景绝不能用 .content()(§4.3)。

结论五:entity() 默认是软约束

默认路径把格式说明拼进 prompt (ChatModelCallAdvisor.java:92-95),模型可能不遵守。要硬约束必须 useProviderStructuredOutput() 走供应商的 JSON Schema 能力(§4.12)。

而 BeanOutputConverter 的清洗链(Whitespace → ThinkingTag → MarkdownCodeBlock → Whitespace)是对付模型"不听话"的关键------尤其是 ThinkingTagCleaner(推理模型)和 MarkdownCodeBlockCleaner(markdown 强迫症)。

结论六:流式不支持 entity(),而且是双重不可用

第一重:StreamResponseSpec 没有 entity() 方法。第二重:即使手动塞 OUTPUT_FORMAT 到 context,ChatModelStreamAdvisor 也不会把它拼进 prompt(§4.14)。

要"流式 + 结构化",只能自己拼完整文本再解析,或者分两次调用。


4.23 本章的思考题

第 1 题 :advisor 链用 Deque.pop() 推进。假设你有三个 advisor,order 分别是 MIN+200(记忆)、MIN+300(工具)、MAX(模型调用)。请写出完整的 before / after 执行顺序,并说明:如果记忆 advisor 的 order 被误设为 MIN+400(比工具大),会发生什么?业务表现是什么?

第 2 题 :MessageAggregator 用 "last-non-zero-wins" 而不是累加(第 124-132 行)。请回答: (a)这个设计依赖供应商的什么行为? (b)如果某供应商每个 chunk 上报的是增量 usage,聚合结果会怎样?偏差方向是高估还是低估? (c)多轮工具调用的 usage 累加在哪一层做?为什么不能放在 MessageAggregator 里?

第 3 题 :你的自定义 advisor 需要在工具调用后重新执行下游链路。请写出正确代码,并说明为什么不能直接 chain.nextCall(...)。进一步:copy(after) 用 indexOf(after) 定位,如果你的 advisor 没重写 equals,会有什么后果?

第 4 题 :entity(MyPojo.class) 默认走软约束。请列出三种会导致转换失败的模型输出形态,并说明 BeanOutputConverter 的清洗链分别能处理哪些、不能处理哪些。对于不能处理的,你有什么额外手段?

第 5 题 :ChatClient.create(chatModel) 用的是 ObservationRegistry.NOOP。如果你的项目用了这个 API 但又接入了 Micrometer,会发现什么现象?如何在代码评审中把这类问题挡在门外(提示:考虑 ArchUnit 或自定义 lint 规则)?

第 6 题 :builder(chatModel, registry, ccConv, aConv, tcBuilder) 的五参数版本,当你传了非 null 的 toolCallingAdvisorBuilder 时,Javadoc 明确警告"传入的 observationRegistry 不会自动应用"。请说明:这个警告背后的技术原因是什么(提示:想想 ToolCallingManager 是怎么构造的),以及如果你确实要自定义,该怎么补救。

第 7 题 :Flux<String> cached = stream().content().cache() 与"每次重新调 .stream()"两种方案,在以下维度上有什么差异:内存占用、重复执行的 API 调用次数、观测 span 的数量?哪种更适合"一个界面多处展示同一响应"的场景?



4.24 附录:常用 DSL 速查表

把本章涉及的 API 按"你想干什么"组织。

创建

java 复制代码
// 注入(Boot 项目,推荐)
@Autowired ChatClient.Builder builder;
ChatClient client = builder.build();

// 手工(无 Boot)
ChatClient client = ChatClient.builder(chatModel, observationRegistry).build();

装配一次请求

java 复制代码
chatClient.prompt()                              // 或 prompt("文本") / prompt(prompt)
    .system("你是客服")                          // system 文本
    .system(s -> s.text(...).param("k", v))      // system DSL(支持模板变量)
    .user("问题:{q}")                           // user 文本
    .user(u -> u.text("看图")
                .media(MimeType.valueOf("image/png"), resource)
                .param("q", question))           // user DSL(支持附件、变量、metadata)
    .messages(extraMessages)                     // 追加历史消息
    .advisors(myAdvisor)                         // 单个
    .advisors(a -> a.param("tenantId", "acme"))  // DSL(传 context 参数)
    .tools(myToolObject)                         // 注册工具(@Tool 注解的对象)
    .toolCallbacks(callbacks)                    // 注册 ToolCallback
    .toolContext(Map.of("k", "v"))               // 工具执行的额外上下文
    .options(OpenAiChatOptions.builder()
        .temperature(0.2))                       // ← 注意:不 .build()
    .templateRenderer(customRenderer)            // 自定义模板渲染器

执行与取结果

java 复制代码
// 非流式
.call().content()                    // String
.call().chatResponse()               // ChatResponse
.call().chatClientResponse()         // ChatClientResponse(含 context)
.call().entity(MyPojo.class)         // POJO
.call().entity(MyPojo.class, s -> s.useProviderStructuredOutput())   // 原生 schema
.call().entity(MyPojo.class, s -> s.validateSchema())                // 开校验自纠错
.call().entity(new ParameterizedTypeReference<List<MyPojo>>() {})    // 泛型
.call().responseEntity(new ParameterizedTypeReference<MyPojo>() {})  // 双载

// 流式
.stream().content()                  // Flux<String>       ← 丢 toolCalls
.stream().chatResponse()             // Flux<ChatResponse> ← 推荐
.stream().chatClientResponse()       // Flux<ChatClientResponse>
// 注意:.stream() 后没有 entity()

构造 Builder(复用配置)

java 复制代码
ChatClient.Builder base = ChatClient.builder(chatModel, registry)
    .defaultSystem("你是客服")
    .defaultAdvisors(memoryAdvisor, ragAdvisor)
    .defaultOptions(OpenAiChatOptions.builder().temperature(0.3));

ChatClient c1 = base.build();
ChatClient c2 = base.clone().defaultSystem("你是翻译").build();   // 基于副本改

从 spec 派生 Builder

java 复制代码
ChatClientRequestSpec spec = chatClient.prompt("...").system("...").user("...");
ChatClient.Builder derived = spec.mutate();      // 把这次请求的配置变成默认值
ChatClient client2 = derived.build();

自定义 advisor 骨架

java 复制代码
public class MyAdvisor implements CallAdvisor, StreamAdvisor {

    @Override
    public String getName() { return "MyAdvisor"; }

    @Override
    public int getOrder() { return Ordered.HIGHEST_PRECEDENCE + 100; }  // 越小越外层

    @Override
    public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
        // before
        ChatClientResponse resp = chain.nextCall(req);   // 推进(会 pop)
        // after
        return resp;
    }

    @Override
    public Flux<ChatClientResponse> adviseStream(ChatClientRequest req, StreamAdvisorChain chain) {
        return chain.nextStream(req).map(r -> { /* after */ return r; });
    }
}

注意 :adviseCall 里只调一次 chain.nextCall(req)。要重跑下游用 chain.copy(this)。


4.25 关键文件索引

css 复制代码
spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/
├── ChatClient.java                        (595 行 ★ 接口全景 + 5 个嵌套 spec)
├── DefaultChatClient.java                 (1252 行 ★ 两条链路实现、entity 五步)
├── DefaultChatClientBuilder.java          (248 行,prototype + clone + addMessages)
├── ChatClientRequest.java                 (90 行,record,context)
├── ChatClientResponse.java                (86 行,record,context)
├── ResponseEntity.java                    (43 行,双载结构)
├── ChatClientAttributes.java              (55 行,4 个官方 context key)
├── AdvisorParams.java                     (88 行)
├── DefaultChatClientUtils.java            (139 行 ★ spec → request,options 合并)
├── ChatClientMessageAggregator.java       (57 行,chunk context 聚合)
└── advisor/
    ├── DefaultAroundAdvisorChain.java     (296 行 ★★★ 栈式执行核心)
    ├── ChatModelCallAdvisor.java          (136 行 ★ 追加 format 指令、调模型)
    ├── ChatModelStreamAdvisor.java        (94 行 ★ 流式版,**不追加 format**)
    ├── StructuredOutputValidationAdvisor.java(370 行,自纠错)
    ├── UsageAccumulator.java              (177 行,跨轮真加法)
    ├── AdvisorUtils.java                  (51 行)
    └── api/
        ├── Advisor.java                   (47 行,DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER)
        ├── AdvisorChain.java              (34 行)
        ├── CallAdvisor.java               (34 行)
        ├── CallAdvisorChain.java          (56 行)
        ├── StreamAdvisor.java             (36 行)
        ├── StreamAdvisorChain.java        (59 行)
        ├── BaseAdvisor.java               (98 行)
        ├── BaseAdvisorChain.java          (88 行,push/pushAll)
        ├── MemoryAdvisor.java             (35 行)
        ├── ToolAdvisor.java               (35 行)
        └── BaseChatMemoryAdvisor.java     (45 行,CONVERSATION_ID)

spring-ai-client-chat/src/main/kotlin/org/springframework/ai/chat/client/
└── ChatClientExtensions.kt                (32 行,reified entity / responseEntity)

spring-ai-model/src/main/java/org/springframework/ai/chat/model/
└── MessageAggregator.java                 (237 行 ★ last-non-zero-wins)

spring-ai-model/src/main/java/org/springframework/ai/converter/
├── StructuredOutputConverter.java         (48 行)
├── BeanOutputConverter.java               (245 行 ★ schema 生成 + 清洗链)
├── MapOutputConverter.java                (67 行)
├── ListOutputConverter.java               (56 行)
├── FormatProvider.java                    (34 行)
└── AbstractMessageOutputConverter.java    (49 行)

auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/
└── .../ChatClientAutoConfiguration.java   (184 行 ★ @Scope("prototype"))

4.26 复核命令

shell 复制代码
cd D:/CODE/spring-ai-2.0.1

# 1. 验证 5 个"经典"类名确实不存在(应无输出)
grep -rn "interface CallAroundAdvisor\|interface StreamAroundAdvisor" --include=*.java .
grep -rn "nextAroundCall\|nextAroundStream" --include=*.java spring-ai-client-chat/
grep -rn "interface ChatClientResponseSpec" --include=*.java .

# 2. 验证真实名字
grep -n "interface CallResponseSpec" spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClient.java
grep -n "nextCall\|nextStream" spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java

# 3. 验证链是栈(Deque + pop)
grep -n "Deque<\|.pop()\|pushAll" spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java

# 4. 验证排序升序
grep -n "OrderComparator.sort" spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java

# 5. 验证 order 值
grep -rn "DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER\|DEFAULT_ORDER" \
     spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/
grep -n "getOrder" spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelCallAdvisor.java

# 6. 验证 StreamResponseSpec 没有 entity()
sed -n '376,385p' spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClient.java

# 7. 验证 ChatClient 默认挂 ToolCallingAdvisor
sed -n '92,104p' spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClient.java

# 8. 验证 entity() 五步的关键行
sed -n '567,570p;592,608p;612,616p' spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java
sed -n '92,95p' spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelCallAdvisor.java

# 9. 验证 MessageAggregator 的 last-non-zero-wins
sed -n '124,132p' spring-ai-model/src/main/java/org/springframework/ai/chat/model/MessageAggregator.java

# 10. 验证 prototype
grep -n "prototype" auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientAutoConfiguration.java

# 11. 验证 BeanOutputConverter 的清洗链
grep -n "WhitespaceCleaner\|ThinkingTagCleaner\|MarkdownCodeBlockCleaner" \
     spring-ai-model/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java

# 12. 验证 Kotlin 扩展
cat spring-ai-client-chat/src/main/kotlin/org/springframework/ai/chat/client/ChatClientExtensions.kt

4.27 附录:性能考量(四条可量化的建议)

4.27.1 ChatClient 实例可以复用,Builder 不要复用

java 复制代码
// ✓ 正确:client 是无状态的门面,可长期持有
private final ChatClient chatClient;   // 注入或构造一次

// ✗ 错误:每次请求都 build 一个 client
public String ask(String q) {
    return ChatClient.builder(chatModel, registry).build()   // ← 每次重建
        .prompt(q).call().content();
}

每次 builder(...) 都会 new 一个 DefaultChatClientBuilder + 内部的 DefaultChatClientRequestSpec + 默认的 ToolCallingAdvisor(§4.18.3)。在高频路径上这是纯粹的浪费。

4.27.2 复用 BeanOutputConverter 避免重复生成 schema

§4.12 步骤 2 说过,entity(Class) 每次都会 new converter,而 converter 构造时即生成 JSON Schema (BeanOutputConverter.java:143)。

Schema 生成用反射遍历类型(JsonSchemaGenerator.generateForType),对复杂 POJO 有可观的开销。

java 复制代码
// ✗ 循环里每次都生成 schema
for (Item item : items) {                       // 1000 条
    Result r = chatClient.prompt(...).call().entity(Result.class);
}

// ✓ 复用 converter
BeanOutputConverter<Result> converter = new BeanOutputConverter<>(Result.class);
for (Item item : items) {
    Result r = chatClient.prompt(...).call().entity(converter);
}

注意 :entity(StructuredOutputConverter<T>)(第 561 行)接受 converter 实例,所以这个优化是可行的。

4.27.3 advisor 链长度直接决定 span 数量

§4.16.2 讲过:一次 call() 产生 1 + N 个 span(N = advisor 数)。

如果 QPS 是 100、advisor 数是 8,那就是每秒 900 个 span。对追踪系统(Jaeger / Zipkin / 商业 APM)是显著压力。

缓解:

  • 合并功能相近的 advisor(比如把多个"日志"advisor 合成一个)
  • 配置采样率(management.tracing.sampling.probability)
  • 生产环境关闭 SimpleLoggerAdvisor(第 06 章)

4.27.4 流式链路的 CHAT_CLIENT_MESSAGE_AGGREGATOR 开销

§4.6.1 差异四:CHAT_CLIENT_MESSAGE_AGGREGATOR.aggregateChatClientResponse(...) 会把每个 chunk 的 context putAll 累积 (ChatClientMessageAggregator.java:46)。

如果 advisor 往 context 里放了大对象(比如完整的检索文档列表),那么每个 chunk 都会触发一次复制------O(chunk 数 × context 大小)。

实践建议 :context 里只放轻量的标量或引用,不要放大型集合。大对象应该放在 advisor 的字段里,或者用 ToolContext 单独传递。


4.28 附录:1.x → 2.x ChatClient 迁移检查表

A 类:编译期失败

# 1.x 写法 2.x 写法 出处
1 implements CallAroundAdvisor implements CallAdvisor 1.0.0 改的名
2 implements StreamAroundAdvisor implements StreamAdvisor 同上
3 CallAroundAdvisorChain CallAdvisorChain 同上
4 chain.nextAroundCall(...) chain.nextCall(...) DefaultAroundAdvisorChain.java:98
5 chain.nextAroundStream(...) chain.nextStream(...) 同上 :123
6 .options(builtOptions) .options(optionsBuilder) upgrade-notes.adoc:1441
7 .defaultOptions(builtOptions) .defaultOptions(optionsBuilder) ChatClient.java:507
8 ChatClientResponseSpec 类型声明 ChatClient.CallResponseSpec ChatClient.java:236

B 类:静默行为变化

# 变化 影响 出处
1 内部工具循环移除 ChatModel.call() 不再自动执行工具;ChatClient 仍会(默认 advisor) upgrade-notes.adoc:537
2 options 合并上移到 ChatClient 直接调 ChatModel 丢失默认值 upgrade-notes.adoc:1463
3 internalToolExecutionEnabled 移除 相关代码编译失败且行为不存在 upgrade-notes.adoc:398
4 ToolExecutionEligibilityPredicate 移除 改用 ToolExecutionEligibilityChecker upgrade-notes.adoc:423
5 buildRequestPrompt 从公开 API 移除 自定义扩展失效 upgrade-notes.adoc:1466 附近

C 类:需要主动适配

  • 检查所有自定义 advisor 的接口实现(CallAroundAdvisor → CallAdvisor)
  • 检查所有 chain.nextAroundXxx() 调用
  • 检查所有 .options() / .defaultOptions() 是否传了成品
  • 检查是否依赖了"ChatModel 自动执行工具"的行为
  • 检查是否用了 internalToolExecutionEnabled
  • 检查是否用了 buildRequestPrompt
  • 确认 advisor 的 getOrder() 是否符合"越小越外层"的语义

4.29 附录:ChatClient 常见误用 TOP 8

按"踩坑频率 × 排查难度"排序。

误用 1:在工具调用场景用 .stream().content()

症状:工具从不执行,只看到模型的"我要调工具"文字。

原因 :Flux<String> 丢掉了 toolCalls(§4.3.1)。

修法 :改用 .call() 或 .stream().chatResponse()。

误用 2:每次请求重建 ChatClient

症状:QPS 上不去,GC 频繁。

原因 :每次 builder() 都构造完整对象图(§4.27.1)。

修法:持有实例复用。

误用 3:.options(...) 传了 .build() 后的成品

症状:编译错误(泛型约束)。

原因:2.x 强制要求 Builder(§3.13.4)。

修法 :去掉 .build()。

误用 4:直接调 ChatModel,只设了部分 options

症状:模型名、maxTokens 等"莫名其妙"失效。

原因 :ChatModel 不合并(§3.13.1)。

修法 :以 chatModel.getOptions().mutate() 为基线,或改用 ChatClient。

误用 5:以为 entity() 保证输出合法

症状 :偶发 JsonParseException。

原因:默认是软约束(§4.12 步骤 4)。

修法 :useProviderStructuredOutput() + validateSchema()。

误用 6:自定义 advisor 里重复 chain.nextCall()

症状 :IllegalStateException: No CallAdvisors available to execute。

原因 :链被 pop() 消耗(§4.20.1)。

修法 :用 chain.copy(this) 拿新链。

误用 7:把大对象放进 advisor context

症状:流式场景内存暴涨。

原因 :每个 chunk 都会 putAll 累积 context(§4.27.4)。

修法:context 只放轻量值。

误用 8:以为 create(chatModel) 有观测

症状:Micrometer / tracing 里看不到 AI 调用的 span。

原因 :create(chatModel) 用 ObservationRegistry.NOOP(ChatClient.java:64)。

修法 :用 builder(chatModel, observationRegistry),或让 Boot 注入。


4.30 附录:一次 call() 的十二个步骤(完整时序)

把本章所有内容串成一条完整的执行链。以下以 chatClient.prompt("hi").options(builder).call().content() 为例。

scss 复制代码
①  chatClient.prompt("hi")
    └─> DefaultChatClient.prompt(String) → 创建 DefaultChatClientRequestSpec
        收集:content = "hi"

②  .options(OpenAiChatOptions.builder().temperature(0.2))
    └─> spec.optionsCustomizer = builder(注意:存的是 Builder,不是成品)

③  .call()
    └─> new DefaultCallResponseSpec(request, advisorChain)
        此时 advisorChain 已构建好(含默认 ToolCallingAdvisor、ChatModelCallAdvisor 等)

④  .content()
    └─> doGetObservableChatClientResponse(this.request).chatResponse()
        → getContentFromChatResponse(...)

⑤  在此之前:DefaultChatClientUtils 把 spec 转成 ChatClientRequest
    ├─ 渲染 system / user 模板(StTemplateRenderer,{ } 分隔,THROW 校验)
    ├─ 构造 UserMessage(text + media + metadata + params)
    ├─ 【options 合并】chatModel.getOptions().mutate()
    │                    .combineWith(用户 customizer)
    │                    .build()          ← 第 03 章 §3.13
    ├─ 收集 toolCallbacks / toolContext(若 Builder 是 ToolCallingChatOptions.Builder)
    │   └─ validateToolCallbacks() 检查重复工具名
    └─ context = new ConcurrentHashMap<>(advisorParams)

⑥  doGetObservableChatClientResponse
    ├─ context.get(OUTPUT_FORMAT) → 放进观测上下文
    ├─ ChatClientObservationContext.builder()
    │     .request(req).advisors(...).stream(false).format(...).build()
    └─ observation.observe(() -> advisorChain.nextCall(req))   ← 起 span

⑦  advisorChain.nextCall(req)
    ├─ Assert.notNull(req)
    ├─ if (callAdvisors.isEmpty()) throw IllegalStateException
    ├─ advisor = callAdvisors.pop()          ← 栈!order 最小的最先弹出
    ├─ AdvisorObservationContext(advisorName + order)  ← 每个 advisor 一个 span
    └─ advisor.adviseCall(req, this)          ← 把链自己传下去

⑧  advisor A.before(req)
    (可能是记忆加载 / RAG 检索 / 日志 / 参数注入)

⑨  递归:A 调用 chain.nextCall(req)
    ├─ advisor = callAdvisors.pop()          ← 下一个
    └─ advisor B.adviseCall(req, chain)
        ├─ B.before(req)
        ├─ chain.nextCall(req) → ... → 最内层

⑩  最内层:ChatModelCallAdvisor(order = LOWEST_PRECEDENCE)
    ├─ context.get(OUTPUT_FORMAT) → 若非空:
    │     prompt.augmentUserMessage(u -> u.mutate()
    │         .text(u.getText() + lineSeparator + outputFormat).build())
    ├─ context.get(STRUCTURED_OUTPUT_SCHEMA) → 若开启原生:
    │     structuredOutputChatOptions.mutate().outputSchema(...).build()
    └─ chatModel.call(prompt)                ← 真正发 HTTP 请求

⑪  回弹(逆序)
    B.after(response) → A.after(response)
    (可能是保存记忆 / 写日志 / 校验重试)

⑫  observation.stop()
    └─ 返回 ChatClientResponse → 取 .chatResponse() → getContentFromChatResponse()
        → Optional 链:getResult → getOutput → getText → orElse(null)

注意第 ⑫ 步的最后 :返回 String 可能是 null(不是 "",§4.5 步骤 6)。

4.30.1 流式版本的五处差异

对照 §4.6.1:

步骤 非流式 流式
③ DefaultCallResponseSpec DefaultStreamResponseSpec
⑥ observation.observe(同步) Flux.deferContextual + 手工 start/stop
⑥ --- 显式 parentObservation.openScope()
⑦ nextCall nextStream(+ CHAT_CLIENT_MESSAGE_AGGREGATOR)
⑫ 一次返回 每个 chunk 触发 map;最后聚合 context

4.31 附录:为什么 2.x 把编排放在 advisor 层?

这是本章最后一个、也是最有架构价值的问题。

4.31.1 1.x 的做法:能力内置在 ChatModel 里

1.x 的 ChatModel 实现里塞了三样东西:

  1. 内部工具执行循环 ------ 模型返回 tool call 就自动执行、自动再问,循环到没有 tool call 为止
  2. options 深度合并 ------ 用 ModelOptionsUtils.merge() + BeanWrapper 反射
  3. 观测逻辑(部分实现里)

三样都由每个供应商实现各自写一遍。

后果是什么?看 upgrade-notes.adoc:537 列出的名单:

adoc 复制代码
539→In 2.0, this internal loop has been removed from every `ChatModel` implementation
     (OpenAI, Anthropic, Ollama, DeepSeek, Bedrock, MiniMax, Mistral, Google GenAI).

八个实现,八份工具循环代码。 每份的行为细节都有差异:循环上限不同、错误处理不同、流式支持不同、观测埋点不同。

这意味着:同一个应用换供应商,工具调用的行为会变。 而 Spring AI 的核心卖点恰恰是"可移植性"(第 01 章 §1.12 信条一)。

4.31.2 2.x 的做法:能力外置到 advisor

2.x 把这三样分别搬走:

能力 1.x 位置 2.x 位置
工具执行循环 每个 ChatModel 里 ToolCallingAdvisor(一份)
options 合并 ModelOptionsUtils.merge() ChatClient 层 + Builder.combineWith()
观测 散落 ChatModelObservationConvention + advisor 观测

收益是三重的:

  1. 一份实现,所有供应商共用 ------ 换模型,工具调用行为完全一致。
  2. 可组合 ------ advisor 可以叠加、排序、替换。想要"先记忆再 RAG 再工具"?排个序就行。1.x 时代这是不可能的。
  3. 可观测 ------ 每个 advisor 一个 span,链路一目了然(§4.16.2)。

4.31.3 代价:用户必须理解 advisor

这是真实成本。1.x 时代你 chatModel.call(prompt) 就能拿到"工具已执行完"的结果;2.x 你必须知道:

  • 工具循环在 advisor 层
  • 直接调 ChatModel 不会执行工具
  • 流式 + .content() 看不到 toolCalls

本章 §4.19 的调试案例、§4.29 的误用 TOP 8,本质上都是这个架构转向带来的认知成本。

4.31.4 这个转向的普适启示

Spring AI 2.x 的这次重构,是一个可以迁移到自己项目里的模式:

当"能力"被复制 N 份到 N 个实现里时,把它抽出来变成"可组合的中间件"。

判据:

  • 同样的逻辑写了 ≥ 3 遍 → 该抽了
  • 不同实现的行为有细微差异,且用户会因此困惑 → 必须抽
  • 用户需要"改变这些能力的组合方式" → 抽成中间件(而不是抽成基类)

Spring AI 选择了 advisor(中间件/拦截链)而不是抽象基类,正是因为第三点:组合优于继承(第 01 章 §1.12 信条二)。

这也是为什么第 05、06 章要花整整两章讲 advisor ------ 它不是"一个可选的扩展点",而是 2.x 架构的核心承载层。


4.32 附录:写第一个自定义 advisor(可运行的完整示例)

本节用真实 API 写一个生产可用的 advisor。所有签名均已从源码确认。

4.32.1 四个接口的真实形状

java 复制代码
// advisor/api/Advisor.java:15
15→public interface Advisor extends Ordered {
23→	int DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER = Ordered.HIGHEST_PRECEDENCE + 200;
29→	String getName();
java 复制代码
// advisor/api/CallAdvisor.java:14
14→public interface CallAdvisor extends Advisor {
16→	ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain);
java 复制代码
// advisor/api/StreamAdvisor.java:16
16→public interface StreamAdvisor extends Advisor {
18→	Flux<ChatClientResponse> adviseStream(ChatClientRequest chatClientRequest, StreamAdvisorChain streamAdvisorChain);
java 复制代码
// advisor/api/BaseAdvisor.java:26
26→public interface BaseAdvisor extends CallAdvisor, StreamAdvisor {
31→	default ChatClientResponse adviseCall(...) { ... }     // 已实现
41→	default Flux<ChatClientResponse> adviseStream(...) { ... }  // 已实现
61→	default String getName() { ... }                       // 默认返回类名
73→	ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain);
78→	default Scheduler getScheduler() { ... }

关键观察:

  1. Advisor extends Ordered ------ 所以每个 advisor 都有 getOrder(),Ordered 接口有默认实现返回 LOWEST_PRECEDENCE。
  2. CallAdvisor 只有一个方法 (getName() 从父接口继承)------ 所以它也应该是 @FunctionalInterface(虽然源码里没标)。
  3. BaseAdvisor 提供了模板方法模式 :你只需实现 before() 和 after(),adviseCall / adviseStream 的样板由接口默认方法提供。
  4. getScheduler() (第 78 行)是流式 after 的执行调度器 ------ 用来避免阻塞 Reactor 的事件循环线程。

4.32.2 示例:一个"记录耗时 + 注入租户 ID"的 advisor

java 复制代码
public class TenantTimingAdvisor implements BaseAdvisor {

    private static final String TENANT_KEY = "com.acme.tenantId";
    private static final String START_KEY = "com.acme.startNanos";

    private final int order;

    public TenantTimingAdvisor(int order) {
        this.order = order;
    }

    @Override
    public String getName() {
        return "TenantTimingAdvisor";   // 不写也行,BaseAdvisor 默认返回类名
    }

    @Override
    public int getOrder() {
        return this.order;              // 越小越外层(§4.8)
    }

    @Override
    public ChatClientRequest before(ChatClientRequest request, AdvisorChain chain) {
        // before:能改请求、能写 context
        String tenantId = TenantContext.currentId();       // 从 ThreadLocal 取
        request.context().put(TENANT_KEY, tenantId);
        request.context().put(START_KEY, System.nanoTime());
        return request;                                     // 返回原对象或 mutate 后的副本
    }

    @Override
    public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) {
        // after:能读响应、能读 context
        Long start = (Long) response.context().get(START_KEY);
        String tenant = (String) response.context().get(TENANT_KEY);
        if (start != null) {
            long ms = (System.nanoTime() - start) / 1_000_000;
            log.info("tenant={} cost={}ms", tenant, ms);
        }
        return response;
    }
}

4.32.3 五个必须注意的点

(1)before 返回的 ChatClientRequest 可以是原对象。

第 03 章 §4.10.2 讲过 mutate() 会拷贝 context。如果你只是往 context 里 put,直接返回原对象即可(共享引用,下游可见)。

如果你要改 prompt,必须 mutate():

java 复制代码
return request.mutate()
    .prompt(request.prompt().augmentSystemMessage("额外指令"))
    .build();

(2)getName() 默认返回类名。

BaseAdvisor.java:61 有默认实现。但显式写更好------因为这个名字会出现在观测的 span 里 (AdvisorObservationContext.advisorName,DefaultAroundAdvisorChain.java:108)。

(3)getOrder() 默认是 LOWEST_PRECEDENCE。

Ordered 接口的默认实现是 LOWEST_PRECEDENCE(Integer.MAX_VALUE)------ 即最内层。

如果你不设 order,你的 advisor 会紧贴着 ChatModelCallAdvisor (也是 LOWEST_PRECEDENCE)。对于"记录耗时"这种场景,这意味着只记录了模型调用本身的耗时,不包住记忆加载 / RAG 检索。

要包住整条链,必须显式设一个很小的 order。

(4)流式的 after 在哪个线程执行?

BaseAdvisor.getScheduler()(第 78 行)决定。默认实现通常返回 Schedulers.boundedElastic() ------ 即不在 Netty 事件循环线程上执行。

如果你的 after 里有阻塞 IO(写数据库、发 HTTP),默认调度器是安全的。 但如果你改成了 Schedulers.immediate(),就必须自己保证不阻塞。

(5)after 里的异常会怎样?

看 BaseAdvisor.adviseStream 的第 57 行:

java 复制代码
}).onErrorResume(error -> Flux.error(new IllegalStateException("Stream processing failed", error)));

流式链路里 after 抛异常会被包装成 IllegalStateException("Stream processing failed", cause)。

所以排查时要看 cause,不要只看最外层消息。

4.32.4 单元测试怎么写

java 复制代码
@Test
void shouldInjectTenantAndLogTiming() {
    var advisor = new TenantTimingAdvisor(Ordered.HIGHEST_PRECEDENCE + 100);

    var chain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
        .push(advisor)
        .push(new ChatModelCallAdvisor(stubChatModel, ObservationRegistry.NOOP))
        .build();

    var request = ChatClientRequest.builder()
        .prompt(new Prompt("hi"))
        .context(new ConcurrentHashMap<>())
        .build();

    var response = chain.nextCall(request);

    assertThat(response.context()).containsKey("com.acme.tenantId");
}

注意 ObservationRegistry.NOOP ------ 测试里不需要真实 registry。

注意 push 的顺序 :先 push 的 order 小的(如果不显式设 order,排序会按 Ordered 默认值,全是 LOWEST_PRECEDENCE,此时稳定排序保持 push 顺序)。更稳妥的做法是显式给每个 advisor 设 order。

4.32.5 一个真实陷阱:advisor 的 equals

§4.9.2 讲过 copy(after) 用 indexOf(after) 定位,而 indexOf 依赖 equals。

上面的 TenantTimingAdvisor 没有重写 equals ------ 所以是引用比较。如果你的 advisor 需要 copy(this),没问题(同一个对象)。但如果你构造了一个"内容相同的新实例"传进去,会抛 IllegalArgumentException。

建议 :需要 copy 的 advisor 务必让 getName() 稳定,并考虑重写 equals。或者干脆不要依赖 copy,改用其他方式实现重入。


4.33 附录:观测上下文字段速查(本章涉及的部分)

第 17 章会系统展开,这里只列本章源码里出现过的字段。

ChatClientObservationContext(DefaultChatClient.java:645-650)

java 复制代码
ChatClientObservationContext.builder()
    .request(chatClientRequest)              // 请求(含 prompt)
    .advisors(advisorChain.getCallAdvisors()) // advisor 清单
    .stream(false)                            // 是否流式 ← 非流式 false / 流式 true
    .format(outputFormat)                     // 从 context 的 OUTPUT_FORMAT 取
    .build();

stream 字段是区分两条链路的关键 tag ------ 在指标里可以按它分组统计"流式调用 vs 非流式调用"的耗时分布。

format 字段 只在 entity() 场景下非空。所以你可以用"format 是否为空"来区分结构化输出调用与普通对话调用。

AdvisorObservationContext(DefaultAroundAdvisorChain.java:107-111)

java 复制代码
AdvisorObservationContext.builder()
    .advisorName(advisor.getName())          // ← getName() 会出现在 span 名/tag 里
    .chatClientRequest(chatClientRequest)
    .order(advisor.getOrder())               // ← order 也是 tag
    .build();

order 作为 tag 很有用:在追踪系统里按 order 排序,就能看到完整的 advisor 执行层次。

实践提示

如果你的 advisor 链很长且观测开销敏感(§4.27.3),可以:

  1. 减少 advisor 数量
  2. 配置采样率
  3. 自定义 AdvisorObservationConvention(通过 builder(chatModel, registry, ccConv, aConv, tcBuilder) 的第四个参数传入)只保留关键 KeyValue

4.34 本章速查:四条最该记住的规则

本章覆盖了 ChatClient 的整个表面。信息量偏大是正常的------ChatClient 是 Spring AI 里最复杂的一层,它把抽象层(ChatModel)、编排层(advisor 链)、观测层(Micrometer)缝合在了一起。前面三章讲的所有东西,最终都会在这一层汇合;而其中最能体现 2.x 架构转向的 advisor 部分,会在第 05、06 章单独展开。

如果本章你只记五条,记这五条:

规则一:.content() 丢 toolCalls。 工具调用场景用 .call() 或 .stream().chatResponse(),绝不用 Flux<String>。(§4.3、误用 1)

规则二:order 越小越外层。 OrderComparator 升序 → 小 order 先 pop() → before 先跑、after 后跑。记忆(+200)包着工具(+300),模型调用(LOWEST_PRECEDENCE)在最内。(§4.8)

规则三:advisor 链是一次性的。 pop() 破坏性弹出。要重跑下游用 chain.copy(this),不要直接再调 nextCall()。(§4.9、§4.20)

规则四:entity() 默认是软约束。 格式说明被拼进 prompt(ChatModelCallAdvisor:92-95),模型可能不遵守。生产环境用 useProviderStructuredOutput()。流式则完全不支持 entity()。(§4.12、§4.14)

规则五:ChatClient 默认就挂了 ToolCallingAdvisor。 这是"为什么 ChatClient 能自动执行工具而 ChatModel.call() 不能"的答案(ChatClient.java:92-104 的 Javadoc)。但如果你自己传了 toolCallingAdvisorBuilder,就必须自己配置它的 ToolCallingManager 与 ObservationRegistry------框架不会帮你注入。(§4.18.3)

第四条和第五条合起来看,能解释 Spring AI 里最多 的一类"为什么我的工具不执行"问题:要么是返回类型丢了 toolCalls,要么是自定义 advisor 时丢掉了观测或循环配置。

这五条覆盖了绝大多数 ChatClient 使用问题。剩下的边界情况请回到 §4.29 的误用 TOP 8 逐条排查------那张表是按"踩坑频率 × 排查难度"排的序。


下一章 :第 05 章进入 Advisor 架构的深水区。我们会拆解 Advisor 接口体系(Advisor / CallAdvisor / StreamAdvisor / BaseAdvisor / BaseAdvisorChain)、copy(after) 为什么是递归 advisor 的命门、Ordered 排序与 BaseAdvisor 的 getOrder() 默认值陷阱,以及为什么 2.x 把工具循环搬到了 advisor 层 ------这是理解 Spring AI 2.x 架构转向的关键。我们会拆解 Advisor 接口体系(Advisor / CallAdvisor / StreamAdvisor / BaseAdvisor / BaseAdvisorChain)、copy(after) 为什么是递归 advisor 的命门、Ordered 排序与 BaseAdvisor 的 getOrder() 默认值陷阱,以及为什么 2.x 把工具循环搬到了 advisor 层------这是理解 Spring AI 2.x 架构转向的关键。

相关推荐
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 08 章 RAG 组件逐个拆解:实现细节与八个可投产组件
java·人工智能·开源
Ward的AI产品实战1 小时前
多个订阅接多个 Agent 怎么管?我从 CC-Switch 迁移到了开源 AI 网关 Magpie(上篇)
开源·ai编程
匠测AI说2 小时前
AI for Testing 提效实战 · 执行自动化(五):这次该跑哪些用例,让 AI 圈回归范围
人工智能·测试
SL_staff2 小时前
告别报表重复开发:JVS-BI 模板市场的参数化继承实践
java·数据分析·数据可视化
PingCAP3 小时前
从一句需求到一套可追溯机制:我们这样推进电子病历改造
人工智能·agent·工作流引擎
PingCAP3 小时前
平凯星辰刘松:稳定交付严肃任务正成为企业级智能体的重要考验
人工智能·agent·工作流引擎
SL_staff3 小时前
从邮箱传文档到协同流重构:企业文档协作的系统性破局实践
java·开源·github
米小虾3 小时前
模型即 Agent?那 Harness 还有用吗
人工智能·agent