Spring AI 2.0.1 深度解析-第 05 章 Advisor 架构:接口体系、栈式链与 context 传播
本章定位
前四章我们已经把
ChatModel(模型调用)、Prompt/Message(输入)、ChatClient(门面)讲清楚了。但真正让 Spring AI 从"一个 HTTP 客户端"变成"一个可编排框架"的,是 Advisor。在 1.x 时代,Advisor 是
ChatClient的一个可选增强——你可以不用它,直接chatModel.call(prompt)也能跑。但在 2.0.x,这个判断不再成立:工具调用被搬出了模型层,改由ToolCallingAdvisor承担(upgrade-notes.adoc与ToolAdvisor.java:17-27的 javadoc 都明确写了 "replacing the model-internal tool execution path")。这意味着哪怕你一行 advisor 都不写,ChatClient也会自动在你的链里塞进一个ToolCallingAdvisor。换句话说:2.x 里不存在"没有 advisor 的调用"。Advisor 从"特性"变成了"地基"。
本章要做的是把这块地基彻底挖开:接口怎么分层、链为什么是栈而不是队列、order 怎么决定"谁包着谁"、context 如何在 advisor 之间流动、以及内置的 12 个 advisor 各自到底干了什么。
5.0 开场:一个反直觉的事实
先看一段 2.0.1 里真实存在的行为。假设你写:
ChatClient client = ChatClient.builder(chatModel).build();
String answer = client.prompt("今天北京天气怎么样").call().content();
你没有配置任何 advisor,没有配置任何工具。但这一次调用实际经过的链是:
ToolCallingAdvisor (order = Integer.MIN_VALUE + 300)
└── ChatModelCallAdvisor (order = Integer.MAX_VALUE)
两个 advisor。其中 ToolCallingAdvisor 是 DefaultChatClient.buildAdvisorChain()(第 1189-1202 行)无条件加进去的。
证据在 DefaultChatClient.java:1190:
private BaseAdvisorChain buildAdvisorChain() {
autoRegisterToolCallingAdvisor();
validateSingleToolAdvisor();
// At the stack bottom add the model call advisors.
// They play the role of the last advisors in the advisor chain.
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();
}
以及 autoRegisterToolCallingAdvisor() 的 javadoc(DefaultChatClient.java:1204-1215):
Auto-registers a
ToolCallingAdvisorunless auto-registration is disabled or aToolAdvisoris already present in the chain. The advisor is always registered so that tools injected dynamically at runtime (e.g. by another advisor) are handled correctly even when no static tools are configured on the call.
"always registered" —— 注意这个措辞。它注册的目的不是"你配了工具所以要执行",而是"万一运行期有别的 advisor 动态注入了工具呢"。
这解释了一类常见的困惑:为什么我明明没用工具,/actuator/metrics 里还是能看到 spring.ai.advisor 的 Tool_Calling span?因为它就是被注册了,只是它的 adviseCall 在第一行就判断"没有 ToolCallingChatOptions,放行",走的是空转路径(ToolCallingAdvisor.java:126-131,§第 06 章 §6.7 会展开)。
这是理解 2.x Advisor 的第一个关键点:链的构成不完全由你控制。 框架会在两个位置插入 advisor:
- 栈顶(order 最小,最先执行):
ToolCallingAdvisor(自动注册,可禁用) - 栈底(order 最大,最后执行):
ChatModelCallAdvisor+ChatModelStreamAdvisor(永远存在,不可禁用)
你的 advisor 只能插在这两者之间。
这条约束会贯穿全章。记住它。
5.1 Advisor:一个只有 47 行的根接口
5.1.1 完整源码
路径:spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/Advisor.java
17 package org.springframework.ai.chat.client.advisor.api;
18
19 import org.springframework.core.Ordered;
20
21 /**
22 * Parent advisor interface for all advisors.
23 *
24 * @author Christian Tzolov
25 * @author Dariusz Jedrzejczyk
26 * @since 1.0.0
27 * @see CallAdvisor
28 * @see StreamAdvisor
29 * @see BaseAdvisor
30 */
31 public interface Advisor extends Ordered {
32
33 /**
34 * Default order for chat-memory advisors. Placed before (outside)
35 * {@link org.springframework.ai.chat.client.advisor.ToolCallingAdvisor#DEFAULT_ORDER}
36 * so the memory advisor wraps the tool-call loop, and the {@code ToolCallingAdvisor}
37 * manages its own intermediate conversation history.
38 */
39 int DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER = Ordered.HIGHEST_PRECEDENCE + 200;
40
41 /**
42 * Return the name of the advisor.
43 * @return the advisor name.
44 */
45 String getName();
46
47 }
47 行。去掉 license 和 package,实体内容只有 17 行。
它做了三件事:
- 继承
org.springframework.core.Ordered—— 这是 advisor 排序的唯一依据 - 定义一个常量
DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER - 声明一个方法
getName()
5.1.2 为什么是 Ordered 而不是自己搞一套
继承 Spring Framework 的 Ordered 而不是自定义 getOrder(),是一个刻意的设计选择,至少有四个好处:
(1)复用 OrderComparator。 排序直接用 org.springframework.core.OrderComparator.sort(),不需要自己写比较器。这一点在 DefaultAroundAdvisorChain.Builder.reOrder() 里直接体现(第 279 行、第 284 行)。
(2)复用 @Order 注解与 Ordered 的语义约定。 Spring 生态的开发者对"值越小优先级越高"已经是肌肉记忆,没有学习成本。
(3)advisor 可以放进 Spring 的 List<Advisor> 注入。 当你在 @Configuration 里声明多个 Advisor bean,然后 @Autowired List<Advisor>,Spring 会自动按 Ordered 排序。这意味着"配置文件里声明的顺序"与"运行时的执行顺序"可以解耦。
(4)与 AnnotationAwareOrderComparator 兼容。 它同时支持 Ordered 接口和 @Order 注解。如果你的 advisor 是一个 @Component 且标了 @Order(100),即使用它没实现 getOrder(),也能被正确排序。
反过来说,代价是:你必须为 getOrder() 提供一个实现,否则会继承 Ordered 的 default int getOrder() { return LOWEST_PRECEDENCE; }——即 Integer.MAX_VALUE。这意味着你的 advisor 会被排到最内层,比 ChatModelCallAdvisor 还内(后者也是 LOWEST_PRECEDENCE)。
这时候会发生什么?看 reOrder() 的实现就知道了:OrderComparator.sort() 是稳定排序(Collections.sort 的 TimSort 保证稳定),所以 order 相同时保持插入顺序。而 buildAdvisorChain() 是先加用户 advisor、再加两个 model advisor,所以你的 advisor 会排在 ChatModelCallAdvisor 前面——先执行。
表面上"看起来能工作",但这是意外正确,不是设计意图。给它一个明确的 order 值永远是对的。
5.1.3 DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER 的数值玄机
int DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER = Ordered.HIGHEST_PRECEDENCE + 200;
Ordered.HIGHEST_PRECEDENCE 是 Integer.MIN_VALUE。所以这个值是 Integer.MIN_VALUE + 200。
要注意的是:Integer.MIN_VALUE + 200 仍然是负数(-2147483448)。它绝不是"200"。
这个命名方式在整个 Spring 生态里很常见(比如 Ordered.HIGHEST_PRECEDENCE + 1000),目的是在最高优先级附近划出一个"档位",让不同的框架组件可以插入而不互相冲突。
Spring AI 2.0.1 里已知的"档位":
| 常量 | 值 | 定义位置 | 使用者 |
|---|---|---|---|
Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER | MIN_VALUE + 200 | Advisor.java:39 | MessageChatMemoryAdvisor、VectorStoreChatMemoryAdvisor、SemanticCacheAdvisor |
ToolCallingAdvisor.DEFAULT_ORDER | MIN_VALUE + 300 | ToolCallingAdvisor.java:75 | ToolCallingAdvisor、ToolSearchToolCallingAdvisor |
ChatModelCallAdvisor.getOrder() | Integer.MAX_VALUE | ChatModelCallAdvisor.java:113 | 栈底 |
ChatModelStreamAdvisor.getOrder() | Integer.MAX_VALUE | ChatModelStreamAdvisor.java:88 | 栈底 |
SafeGuardAdvisor.DEFAULT_ORDER | 0 | SafeGuardAdvisor.java:60 | 默认 0 |
SimpleLoggerAdvisor 默认 order | 0 | SimpleLoggerAdvisor.java:56 | 默认 0 |
QuestionAnswerAdvisor.DEFAULT_ORDER | 0 | QuestionAnswerAdvisor.java:74 | 默认 0 |
这张表本身就是一份"默认执行顺序图":
order = MIN_VALUE + 200 → 记忆类 advisor(最外层)
order = MIN_VALUE + 300 → 工具调用 advisor
order = 0 → 日志 / 安全 / 问答 / 校验(中间层)
order = MAX_VALUE → 模型调用 advisor(最内层)
注意 MIN_VALUE + 200 < MIN_VALUE + 300 < 0 < MAX_VALUE。所以记忆在工具之外。
为什么?javadoc 第 34-37 行给了官方答案:
Placed before (outside)
ToolCallingAdvisor#DEFAULT_ORDERso the memory advisor wraps the tool-call loop, and theToolCallingAdvisormanages its own intermediate conversation history.
这句话是全章最重要的一句注释,值得逐字拆:
- "Placed before (outside)" —— 记忆 advisor 在外层
- "so the memory advisor wraps the tool-call loop" —— 它包裹整个工具调用循环。也就是说,工具循环内部发生的所有中间轮次(LLM 请求工具 → 执行工具 → LLM 再生成),对记忆 advisor 而言都是一次调用
- "and the
ToolCallingAdvisormanages its own intermediate conversation history" —— 正因为记忆 advisor 看不到中间轮次,所以工具循环自己必须记住中间历史
这不是两个独立的陈述,而是因果关系:因为记忆在外层看不见中间过程,所以工具循环必须自己管历史。
这个因果关系在 ToolCallingAdvisor 里对应一个布尔字段 conversationHistoryEnabled(第 93 行),以及在 DefaultChatClient.autoRegisterToolCallingAdvisor() 里的动态判定(第 1228-1231 行):
boolean hasDownstreamMemoryAdvisor = this.advisors.stream()
.anyMatch(a -> a instanceof MemoryAdvisor && a.getOrder() > configuredOrder);
this.advisors.add(this.toolCallingAdvisorBuilder.copy()
.conversationHistoryEnabled(!hasDownstreamMemoryAdvisor)
.build());
判定条件是 a.getOrder() > configuredOrder —— 即"存在一个比我更靠内(order 更大)的 MemoryAdvisor"。
默认配置下:记忆是 MIN+200,工具是 MIN+300,200 > 300 为 false,所以 conversationHistoryEnabled = true(工具循环自己管历史)。
如果你把记忆 advisor 的 order 调到 MIN+400(比工具更内),那么 400 > 300 为 true,conversationHistoryEnabled 就变成 false —— 工具循环不再管历史,因为内层那个记忆 advisor 会为每个中间轮次都保存一遍。
这是一个很隐蔽但很优雅的协作协议:两个 advisor 通过 order 值互相感知对方的存在,并据此调整自己的行为,全程不需要任何显式配置。
代价是:如果你不知道这个协议,随手改了 order,就会得到"历史被保存两遍"或"历史丢失中间轮次"的诡异行为,而且没有任何报错。
5.1.4 getName() 的三种实现风格
Advisor.getName() 是接口唯一的方法。在 2.0.1 的内置实现里,它有三种写法:
写法 A:返回类名(BaseAdvisor 的 default)
// BaseAdvisor.java:83-85
@Override
default String getName() {
return this.getClass().getSimpleName();
}
写法 B:返回固定字符串(终结 advisor)
// ChatModelCallAdvisor.java:108-111
@Override
public String getName() {
return "call";
}
// ChatModelStreamAdvisor.java:83-86
@Override
public String getName() {
return "stream";
}
写法 C:子类手工覆盖
// SimpleLoggerAdvisor.java:100-103
@Override
public String getName() {
return this.getClass().getSimpleName();
}
// ToolSearchToolCallingAdvisor.java:138
public String getName() { ... }
这个方法的用途有两个,都跟可观测性有关:
- 作为 Micrometer Observation 的
contextualName(§5.8 会详述) - 作为错误信息里的标识(
validateSingleToolAdvisor()抛异常时会打印advisor.getName() + " (order=" + a.getOrder() + ")")
注意写法 B:ChatModelCallAdvisor.getName() 返回 "call" 而不是 "ChatModelCallAdvisor"。这会让 span 名字变成 spring.ai.advisor + contextualName call,而不是一长串类名。这是刻意的,因为终结 advisor 是最常见的 span,名字短一点对时序数据库的基数更友好。
5.2 两条链路:CallAdvisor 与 StreamAdvisor
5.2.1 CallAdvisor
路径:spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/CallAdvisor.java(全文 33 行,实体 15 行)
17 package org.springframework.ai.chat.client.advisor.api;
18
19 import org.springframework.ai.chat.client.ChatClientRequest;
20 import org.springframework.ai.chat.client.ChatClientResponse;
21
22 /**
23 * Advisor for execution flows ultimately resulting in a call to an AI model
24 *
25 * @author Christian Tzolov
26 * @author Dariusz Jedrzejczyk
27 * @author Thomas Vitale
28 * @since 1.0.0
29 */
30 public interface CallAdvisor extends Advisor {
31
32 ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain);
33
34 }
一个抽象方法,没有 default。
这是本章第一个值得停下来看的细节:CallAdvisor 没有 @FunctionalInterface 注解。
对比第 02 章讲过的 StreamingChatModel(spring-ai-model/.../StreamingChatModel.java:32)就标了 @FunctionalInterface。为什么这里不标?
因为 @FunctionalInterface 的语义是"这个接口应当被用作 lambda 目标"。而 advisor 的实现几乎必然需要:
- 实现
getName() - 实现
getOrder() - 通常还要实现
adviseStream
一个只有 adviseCall 一个抽象方法的接口,加上 @FunctionalInterface 会误导使用者以为可以用 lambda 写。而一旦用了 lambda,getName() 就会回退到某个 default,getOrder() 会拿到 LOWEST_PRECEDENCE——恰好是最容易出错的组合。
所以不标是保护性设计。
5.2.2 StreamAdvisor
路径:.../advisor/api/StreamAdvisor.java(全文 35 行)
30 public interface StreamAdvisor extends Advisor {
31
32 Flux<ChatClientResponse> adviseStream(ChatClientRequest chatClientRequest,
33 StreamAdvisorChain streamAdvisorChain);
34
35 }
方法名差异之外,返回类型是 Flux<ChatClientResponse>。
为什么不是 Flux<String>? 因为 advisor 需要看到完整的 ChatResponse(含 metadata、toolCalls、finishReason),而不只是文本片段。如果返回 Flux<String>,那么:
SimpleLoggerAdvisor就无法打印 usageToolCallingAdvisor就无法检测 toolCallsStructuredOutputValidationAdvisor就无法判断 finishReason
Flux<String> 只存在于 ChatClient 的最外层(StreamResponseSpec.content()),那是给应用开发者看的,不是给 advisor 用的。advisor 层永远是 Flux<ChatClientResponse>。
5.2.3 一个必须澄清的改名:CallAroundAdvisor → CallAdvisor
如果你看过 Spring AI 1.x 的资料、博客,或者早期官方文档,会看到这些名字:
CallAroundAdvisorStreamAroundAdvisorCallAroundAdvisorChainStreamAroundAdvisorChainAroundAdvisorChainBaseAroundAdvisor(部分资料里)
在 2.0.1 里,这些名字一个都不存在。 真实的名字是:
| 1.x 名字 | 2.0.1 真实名字 | 位置 |
|---|---|---|
CallAroundAdvisor | CallAdvisor | advisor/api/CallAdvisor.java |
StreamAroundAdvisor | StreamAdvisor | advisor/api/StreamAdvisor.java |
CallAroundAdvisorChain | CallAdvisorChain | advisor/api/CallAdvisorChain.java |
StreamAroundAdvisorChain | StreamAdvisorChain | advisor/api/StreamAdvisorChain.java |
AroundAdvisorChain | BaseAdvisorChain | advisor/api/BaseAdvisorChain.java |
nextAroundCall(...) | nextCall(...) | CallAdvisorChain.java:41 |
nextAroundStream(...) | nextStream(...) | StreamAdvisorChain.java:46 |
验证命令(在第 05 章末尾 §5.14 会给完整版):
find . -name "CallAroundAdvisor.java" # 无输出
find . -name "AroundAdvisorChain.java" # 无输出
有意思的是,"Around" 这个概念的痕迹还留在代码里。看 DefaultAroundAdvisorChain.Builder.pushAll() 的局部变量命名(第 251-253 行):
List<CallAdvisor> callAroundAdvisorList = advisors.stream()
.filter(a -> a instanceof CallAdvisor)
.map(a -> (CallAdvisor) a)
.toList();
变量名还叫 callAroundAdvisorList。以及 DefaultAdvisorObservationConvention.getContextualName()(第 63-66 行):
return ParsingUtils.reConcatenateCamelCase(context.getAdvisorName(), "_")
.replace("_around_advisor", "")
.replace("_advisor", "");
它还在替换 "_around_advisor" 后缀——说明曾经确实有过 XxxAroundAdvisor 这种命名。
这是第 4 处"改名化石"(前 3 处在第 02、03、04 章)。这类残留不会造成功能问题,但会让你在 grep 时被误导:搜 CallAroundAdvisor 会命中局部变量,让你以为这个类还在。
5.2.4 为什么去掉 "Around"
不只是 shortening。"Around" 这个词来自 AOP 的 around advice(环绕通知),暗示"我在目标方法前后都能插手"。
2.x 去掉它,是因为 advisor 的语义其实比 around advice 更宽:SafeGuardAdvisor 可以完全短路不调用模型(相当于 @Around 但不 proceed),ToolCallingAdvisor 会多次调用下游(相当于 around 里循环 proceed),StructuredOutputValidationAdvisor 会在失败时改请求重试。
这些都不是标准 AOP around 能表达的。去掉 "Around" 反而更准确——它只是一个"处理请求并产生响应的环节"。
5.3 Chain 体系:四层接口
5.3.1 AdvisorChain:一个只有 default 方法的空壳
路径:.../advisor/api/AdvisorChain.java(全文 33 行,实体 12 行)
17 package org.springframework.ai.chat.client.advisor.api;
18
19 import io.micrometer.observation.ObservationRegistry;
20
25 public interface AdvisorChain {
26
27 default ObservationRegistry getObservationRegistry() {
28 return ObservationRegistry.NOOP;
29 }
30
31 }
只有一个方法,且是 default。所以这是一个"空接口"。
它存在的唯一目的是给两条链一个共同父类型,让 BaseAdvisor.before(...) / after(...) 可以声明参数类型为 AdvisorChain,从而同时接受 call 链和 stream 链:
// BaseAdvisor.java
ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain);
ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain);
注意 getObservationRegistry() 的默认值是 ObservationRegistry.NOOP。
NOOP 是 Micrometer 的"空实现"——所有记录操作都是无副作用的。这意味着:如果你自己实现一个 AdvisorChain 而不覆盖这个方法,所有观测数据都会被静默丢弃。这在写测试的 fake chain 时很方便(不会有 NPE),但在生产代码里是隐患。
5.3.2 CallAdvisorChain / StreamAdvisorChain
两个接口结构完全对称(CallAdvisorChain.java 51 行 / StreamAdvisorChain.java 57 行):
// CallAdvisorChain.java
public interface CallAdvisorChain extends AdvisorChain {
ChatClientResponse nextCall(ChatClientRequest chatClientRequest);
List<CallAdvisor> getCallAdvisors();
CallAdvisorChain copy(CallAdvisor after);
}
// StreamAdvisorChain.java
public interface StreamAdvisorChain extends AdvisorChain {
Flux<ChatClientResponse> nextStream(ChatClientRequest chatClientRequest);
List<StreamAdvisor> getStreamAdvisors();
StreamAdvisorChain copy(StreamAdvisor after);
}
三个方法,各有讲究:
(1)nextCall / nextStream —— 推进链。
这是 advisor 调用下游的唯一入口。注意它的参数只有一个 ChatClientRequest,没有 index。链的位置状态藏在 chain 对象内部(§5.4)。
(2)getCallAdvisors() —— 拿到"链创建时"的完整列表。
javadoc 写得很明确(CallAdvisorChain.java:46-49):
Returns the list of all the
CallAdvisorinstances included in this chain at the time of its creation.
"at the time of its creation" —— 这是一个快照,不是当前状态。这一点在 §5.4.1 会看到它为什么重要。
(3)copy(after) —— 从某个 advisor 之后截断,生成新链。
javadoc(CallAdvisorChain.java:51-57):
Creates a new CallAdvisorChain copy that contains all advisors after the specified advisor. @throws IllegalArgumentException if the specified advisor is not part of the chain
这个方法是为 ToolCallingAdvisor 准备的。它需要在循环里反复调用"我后面的所有 advisor",而不是"包含我在内的所有 advisor"(否则会无限递归)。
copy(this) 是 ToolCallingAdvisor 能工作的基础(§第 06 章 §6.7.3)。
5.3.3 BaseAdvisorChain:两条链合一 + Builder
路径:.../advisor/api/BaseAdvisorChain.java(95 行)
35 public interface BaseAdvisorChain extends CallAdvisorChain, StreamAdvisorChain {
36
44 default Builder<?> mutate() {
45 throw new UnsupportedOperationException("mutate() must be overridden to return the most concrete Builder");
46 }
47
55 static Builder<?> builder(ObservationRegistry observationRegistry) {
56 return new DefaultAroundAdvisorChain.Builder(observationRegistry);
57 }
58
66 interface Builder<B extends Builder<B>> {
67
73 B push(Advisor advisor);
74
80 B pushAll(List<? extends Advisor> advisors);
81
86 BaseAdvisorChain build();
87
88 }
89
90 }
几个要点:
(1)mutate() 是 default 且抛异常,上面还有个 TODO:
// TODO: change from default to abstract once all implementations override mutate()
这是一个过渡期妥协。作者想把它变成 abstract(强制所有实现覆盖),但为了二进制兼容暂时留成 default。
类似的注释在整个 2.x 里不罕见。看到 // TODO: change from default to abstract once ... 就要警觉:下一次主版本升级时,这个 default 可能变成 abstract,你的实现类如果没覆盖就会编译失败。
(2)Builder 的方法叫 push 而不是 add。
这个命名直接暴露了链的栈本质。push 是栈的术语(对应 pop)。而 DefaultAroundAdvisorChain.nextCall() 里确实是 this.callAdvisors.pop()(第 105 行)。
命名与实现一致,这是好代码的标志。
(3)Builder<B extends Builder<B>> 的自限泛型。
这是 Spring 里常见的"fluent subtype chaining"模式(javadoc 第 64 行用了这个词)。它让 DefaultAroundAdvisorChain.Builder 的方法返回具体类型,从而在链式调用时不会丢失子类方法:
DefaultAroundAdvisorChain.builder(registry)
.observationConvention(conv) // 返回 Builder(具体类型)
.pushAll(advisors)
.build(); // 返回 DefaultAroundAdvisorChain
对比 BaseAdvisorChain.builder(registry) 返回的是 Builder<?>,调用 .observationConvention() 会编译失败——因为 ? 通配符的具体方法集未知。
这是一个实际会踩的坑:用 BaseAdvisorChain.builder(...) 拿不到 observationConvention(),必须用 DefaultAroundAdvisorChain.builder(...)。
5.4 DefaultAroundAdvisorChain:栈式执行的完整剖析
路径:spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java(296 行)
这是全章最核心的类。我们逐段读。
5.4.1 字段:为什么要存两份 advisor 列表
59 public class DefaultAroundAdvisorChain implements BaseAdvisorChain {
60
61 public static final AdvisorObservationConvention DEFAULT_OBSERVATION_CONVENTION
62 = new DefaultAdvisorObservationConvention();
63
64 private static final ChatClientMessageAggregator CHAT_CLIENT_MESSAGE_AGGREGATOR
65 = new ChatClientMessageAggregator();
66
67 private final List<CallAdvisor> originalCallAdvisors;
68
69 private final List<StreamAdvisor> originalStreamAdvisors;
70
71 private final Deque<CallAdvisor> callAdvisors;
72
73 private final Deque<StreamAdvisor> streamAdvisors;
74
75 private final ObservationRegistry observationRegistry;
76
77 private final AdvisorObservationConvention observationConvention;
四个 advisor 字段,两两成对:
| 字段 | 类型 | 用途 |
|---|---|---|
originalCallAdvisors | List<CallAdvisor> | 不可变快照,供 getCallAdvisors() 返回 |
callAdvisors | Deque<CallAdvisor> | 可变工作队列,pop() 消耗它 |
originalStreamAdvisors | List<StreamAdvisor> | 同上 |
streamAdvisors | Deque<StreamAdvisor> | 同上 |
为什么需要两份?因为链是一次性的。
看构造器(第 77-91 行):
DefaultAroundAdvisorChain(ObservationRegistry observationRegistry, Deque<CallAdvisor> callAdvisors,
Deque<StreamAdvisor> streamAdvisors, @Nullable AdvisorObservationConvention observationConvention) {
Assert.notNull(observationRegistry, "the observationRegistry must be non-null");
Assert.notNull(callAdvisors, "the callAdvisors must be non-null");
Assert.notNull(streamAdvisors, "the streamAdvisors must be non-null");
this.observationRegistry = observationRegistry;
this.callAdvisors = callAdvisors;
this.streamAdvisors = streamAdvisors;
this.originalCallAdvisors = List.copyOf(callAdvisors);
this.originalStreamAdvisors = List.copyOf(streamAdvisors);
this.observationConvention = observationConvention != null ? observationConvention
: DEFAULT_OBSERVATION_CONVENTION;
}
第 87-88 行:List.copyOf(callAdvisors) —— 在构造时把 deque 快照成不可变 List。
这是一个非常重要的设计决定。 因为 nextCall() 会 pop() 掉 deque 里的元素,一旦 pop 完,deque 就空了。如果 getCallAdvisors() 直接返回 deque,那么:
- 第一次调用前:返回全部
- 调用过程中:返回"还没执行的"
- 调用完成后:返回空
这样的 API 毫无用处。所以必须有 original* 快照。
推论:DefaultAroundAdvisorChain 实例不能被复用。 每次 call() / stream() 都要重建一条链。
这解释了 DefaultChatClient 为什么在 DefaultCallResponseSpec 的构造里才调 buildAdvisorChain()(第 1177-1179 行):
BaseAdvisorChain advisorChain = buildAdvisorChain();
return new DefaultCallResponseSpec(DefaultChatClientUtils.toChatClientRequest(this), advisorChain, ...);
每次 .call() 都是一条新链。如果你要并发调用同一个 ChatClient,每次都会新建链,不会有线程安全问题。
但反过来:如果你把一条 chain 存起来复用,第二次调用会抛 IllegalStateException("No CallAdvisors available to execute")。 这是很实际的一个坑(§5.12 思考题 3)。
5.4.2 nextCall:11 行的核心
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 }
122
逐行分析:
第 101-103 行:空链检查。抛的是 IllegalStateException,消息 "No CallAdvisors available to execute"。
这个异常在两种情况下会出现:
- 复用了一条已耗尽的链
- 手工构造了一条空链然后调用
第 105 行:pop()。这是全章最关键的三个字符。
Deque.pop() 等价于 removeFirst() —— 从头部移除并返回。
所以链的执行顺序是:先 pop 出来的先执行。而先 pop 的是 order 最小的(见 §5.4.4)。所以 order 最小 = 最先执行 = 最外层。
第 107-111 行:为每个 advisor 构造一个 AdvisorObservationContext,含 advisorName / request / order。
第 113-120 行:用 Micrometer 的 observation(...).observe(...) 包住实际调用。
注意第 118 行:在 observe() 的 lambda 内部才 setChatClientResponse()。这是为了让观测处理器在 observation 结束时能拿到响应(比如计算 token、记录耗时)。
这个 lambda 的副作用顺序很讲究:advisor.adviseCall() 返回之后立刻 set,然后 return。如果在 return 之前不 set,observation 记录到的 response 就是 null。
5.4.3 nextCall 的"栈"到底怎么形成
假设链里有三个 advisor,order 分别是:
A(order = 100)B(order = 200)C=ChatModelCallAdvisor(order = MAX_VALUE)
排序后 deque 是 [A, B, C](A 在头)。
执行过程:
1. ChatClient.call() → chain.nextCall(req)
2. pop() → A
3. A.adviseCall(req, chain)
4. A 做前置处理 → req'
5. A 调 chain.nextCall(req') ← 注意:还是同一个 chain 对象
6. pop() → B
7. B.adviseCall(req', chain)
8. B 做前置处理 → req''
9. B 调 chain.nextCall(req'')
10. pop() → C
11. C.adviseCall(req'', chain)
12. C = ChatModelCallAdvisor,不调 nextCall,直接 chatModel.call()
13. 返回 resp''
14. C 返回 resp'' ← 第 11 行返回
15. B 拿到 resp'',做后置处理 → resp'
16. B 返回 resp' ← 第 7 行返回
17. A 拿到 resp',做后置处理 → resp
18. A 返回 resp ← 第 3 行返回
19. ChatClient 拿到 resp
这是一个标准的递归下降/回溯过程。 调用栈(JVM 方法栈)的形状是:
nextCall
└─ A.adviseCall
└─ nextCall
└─ B.adviseCall
└─ nextCall
└─ C.adviseCall
└─ chatModel.call()
"栈"这个名字有两个含义,容易混淆:
- 调用栈(JVM stack)—— 上面这个形状,任何递归调用都是这样
- Deque 这个数据结构 ——
pop()从头部取
DefaultAroundAdvisorChain 同时用了两者:用 Deque 的 pop 来决定"下一个是谁",用 JVM 调用栈来实现"后置处理"。
为什么不用一个 index 计数器?
// 假想的 index 实现
private int index = 0;
public ChatClientResponse nextCall(ChatClientRequest req) {
CallAdvisor advisor = this.advisors.get(this.index++);
return advisor.adviseCall(req, this);
}
这个实现是错的,原因有两个:
(1)ToolCallingAdvisor 需要 copy(this)。 它要拿到"我之后的 advisor 组成的新链"。用 index 也能做(新链的 index 从 this.index 开始),但那个新链会和原链共享 index 状态吗?如果不共享,嵌套调用的推进就乱了;如果共享,两条链的推进会互相干扰。
用 Deque 就很干净:copy(this) 生成一条全新的、独立的 Deque,两者互不影响。
(2)并发。 如果同一个链被两个线程调用(虽然 DefaultChatClient 不会这么做,但 API 上没禁止),index 的 ++ 是非原子的。而 ConcurrentLinkedDeque.pop() 是原子的。
注意第 233-234 行:
this.callAdvisors = new ConcurrentLinkedDeque<>();
this.streamAdvisors = new ConcurrentLinkedDeque<>();
用的是 ConcurrentLinkedDeque 而不是 ArrayDeque 或 LinkedList。 这就是为了 pop() 的原子性。
代价是:ConcurrentLinkedDeque 的每次 pop() 是一次 CAS 操作,比 ArrayDeque 的数组索引慢。但 advisor 链通常只有个位数元素,且每元素只 pop 一次,性能完全不是瓶颈。用并发安全换正确性,这个取舍是对的。
5.4.4 reOrder():order 如何变成执行顺序
274 /**
275 * (Re)orders the advisors in priority order based on their Ordered attribute.
276 */
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 }
这是全章最重要的 14 行代码。 它决定了 order 的语义。
第 278 行:把 deque 转成 ArrayList(因为 OrderComparator.sort() 需要 List)。
第 279 行:OrderComparator.sort(callAdvisors)。
OrderComparator 的默认排序规则是升序:o1.getOrder() - o2.getOrder()。所以 order 小的排在 List 前面。
第 281 行:callAdvisors.forEach(this.callAdvisors::addLast) —— 按排序后的顺序,从尾部依次加入 deque。
addLast 是往尾部加。所以加入顺序 A, B, C 后,deque 是 [A, B, C](A 在头,C 在尾)。
第 105 行的 pop() 从头部取。所以 A 最先被取到。
结论(源码级证据):
OrderComparator.sort()升序 +addLast保序 +pop()取头 = order 最小的 advisor 最先执行,即最外层。
这个链条上三个环节都是必需的,缺一不可:
- 如果 sort 是降序 → order 最大的最外层
- 如果用
addFirst→ 顺序反转 - 如果用
pollLast()→ 顺序反转
为什么叫 reOrder? 因为它在每次 pushAll() 之后都调用(第 269 行):
public Builder pushAll(List<? extends Advisor> advisors) {
...
if (!CollectionUtils.isEmpty(advisors)) {
// 分类 push
...
this.reOrder();
}
return this;
}
这意味着每次 push 都会全量重排。如果你 push 100 次,就排序 100 次。对 advisor 数量来说无所谓,但这是一个"简单粗暴但正确"的实现:它保证了无论你以什么顺序 push,最终顺序都由 order 唯一决定。
推论:push 的顺序不影响执行顺序,只有 order 值影响。
// 这两段代码效果完全相同
builder.push(a(order=300)).push(b(order=100));
builder.push(b(order=100)).push(a(order=300));
这是一个强保证:你的 advisor 声明顺序与执行顺序解耦,只靠 order 决定。在多模块、多 starter 自动装配的场景下,这一点至关重要——否则执行顺序会依赖于 Spring bean 的加载顺序,那是不可控的。
5.4.5 pushAll:一次分流成两条链
247 public Builder pushAll(List<? extends Advisor> advisors) {
248 Assert.notNull(advisors, "the advisors must be non-null");
249 Assert.noNullElements(advisors, "the advisors must not contain null elements");
250 if (!CollectionUtils.isEmpty(advisors)) {
251 List<CallAdvisor> callAroundAdvisorList = advisors.stream()
252 .filter(a -> a instanceof CallAdvisor)
253 .map(a -> (CallAdvisor) a)
254 .toList();
255
256 if (!CollectionUtils.isEmpty(callAroundAdvisorList)) {
257 callAroundAdvisorList.forEach(this.callAdvisors::push);
258 }
259
260 List<StreamAdvisor> streamAroundAdvisorList = advisors.stream()
261 .filter(a -> a instanceof StreamAdvisor)
262 .map(a -> (StreamAdvisor) a)
263 .toList();
264
265 if (!CollectionUtils.isEmpty(streamAroundAdvisorList)) {
266 streamAroundAdvisorList.forEach(this.streamAdvisors::push);
267 }
268
269 this.reOrder();
270 }
271 return this;
272 }
一个 advisor 列表被分流成两条独立的链。
判定依据是 instanceof:
- 实现
CallAdvisor→ 进入 call 链 - 实现
StreamAdvisor→ 进入 stream 链 - 两个都实现(如
BaseAdvisor的实现类)→ 两条链都进 - 两个都不实现(只实现
Advisor)→ 两条链都不进,被静默丢弃
最后一条是一个静默的陷阱:如果你写了一个类只 implements Advisor(比如只实现了某个标记接口),它会被 pushAll 接受(因为参数类型是 List<? extends Advisor>),然后被两个 filter 都过滤掉,最终不出现在任何链里。没有警告,没有异常。
验证:
class MyAdvisor implements Advisor {
public String getName() { return "my"; }
// 注意:没有 getOrder(),继承 Ordered 的 default = LOWEST_PRECEDENCE
// 注意:没有实现 CallAdvisor 或 StreamAdvisor
}
var chain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
.push(new MyAdvisor())
.pushAll(List.of(ChatModelCallAdvisor.builder().chatModel(m).build()))
.build();
chain.getCallAdvisors(); // 只有 ChatModelCallAdvisor,MyAdvisor 不见了
这就是为什么 BaseAdvisor 存在 —— 它同时 extends CallAdvisor, StreamAdvisor,保证你的 advisor 一定会被两条链都收录。
5.4.6 copy(after) 与 mutate()
copy(第 168-195 行):
168 @Override
169 public CallAdvisorChain copy(CallAdvisor after) {
170 return this.copyAdvisorsAfter(this.getCallAdvisors(), after);
171 }
178 private DefaultAroundAdvisorChain copyAdvisorsAfter(List<? extends Advisor> advisors, Advisor after) {
179
180 Assert.notNull(after, "The after advisor must not be null");
181 Assert.notNull(advisors, "The advisors must not be null");
182
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();
195 }
关键点:
(1)第 170 行用的是 getCallAdvisors(),即原始快照,不是当前 deque。
这很重要。因为 ToolCallingAdvisor 在循环里反复调 copy(this),如果每次都基于"当前 deque"(已经被 pop 消耗过),那么第二次循环时 indexOf(this) 会返回 -1(因为 this 已经被 pop 掉了),直接抛 IllegalArgumentException。
用快照保证了 copy(this) 可以被无限次调用,每次都返回相同的"我之后的所有 advisor"。
这是 §5.4.1 那个"为什么要存两份"设计的直接受益者。
(2)第 183 行 indexOf(after) 用的是对象的 equals()。
advisor 没有覆写 equals(),所以是引用比较。这意味着:
var a = new MyAdvisor();
var chain = builder.push(a).build();
chain.copy(a); // OK
chain.copy(new MyAdvisor()); // IllegalArgumentException,即使"看起来一样"
(3)第 189-194 行变量名是 remainingStreamAdvisors,但它是泛型方法,也服务于 call 链。
这是一个复制粘贴导致的命名错误——方法同时被 copy(CallAdvisor) 和 copy(StreamAdvisor) 调用,但变量名只写了 "Stream"。属于无害的命名瑕疵,但会误导阅读者。
mutate(第 212-219 行):
212 @Override
213 public Builder mutate() {
214 LinkedHashSet<Advisor> all = new LinkedHashSet<>(this.originalCallAdvisors);
215 all.addAll(this.originalStreamAdvisors);
216 return DefaultAroundAdvisorChain.builder(this.observationRegistry)
217 .observationConvention(this.observationConvention)
218 .pushAll(new ArrayList<>(all));
219 }
(1)用 LinkedHashSet 去重。
如果一个 advisor 同时实现了 CallAdvisor 和 StreamAdvisor(比如所有 BaseAdvisor 的实现),它会出现在两个原始列表里。用 LinkedHashSet 合并后只保留一个。
为什么必须去重? 因为 pushAll 会把同一个对象 push 进两个 deque。对于 call 链,如果 push 了两次,就会 pop() 两次,这个 advisor 会执行两次。
mutate() 的使用场景是 DefaultChatClient.resolveAdvisorChain()(第 572-585 行):
private BaseAdvisorChain resolveAdvisorChain(Consumer<EntityParamSpec> consumer,
StructuredOutputConverter<?> converter) {
var spec = new DefaultEntityParamSpec();
consumer.accept(spec);
if (spec.isEnableNative()) {
this.request.context().put(ChatClientAttributes.STRUCTURED_OUTPUT_NATIVE.getKey(), true);
}
if (spec.isValidated()) {
var validationAdvisor = StructuredOutputValidationAdvisor.builder()
.outputJsonSchema(converter.getJsonSchema())
.build();
return this.advisorChain.mutate().push(validationAdvisor).build();
}
return this.advisorChain;
}
当你调用 .entity(...) 且开启了 .validated(true),会 mutate() 当前链再 push 一个校验 advisor。如果不去重,所有 BaseAdvisor 都会执行两次 —— 记忆会被读两遍、日志会打两遍。
这是一个真实的 bug 防御点。
(2)mutate() 返回的是 Builder,不是 chain。
所以调用方要 .mutate().push(x).build()。这也解释了为什么 BaseAdvisorChain.mutate() 的 default 实现要抛异常——它必须返回"最具体的 Builder 类型"才能继续链式调用(javadoc 第 42-44 行明确说了)。
5.4.7 流式:nextStream 的复杂度来源
123 @Override
124 public Flux<ChatClientResponse> nextStream(ChatClientRequest chatClientRequest) {
125 Assert.notNull(chatClientRequest, "the chatClientRequest cannot be null");
126
127 return Flux.deferContextual(contextView -> {
128 if (this.streamAdvisors.isEmpty()) {
129 return Flux.error(new IllegalStateException("No StreamAdvisors available to execute"));
130 }
131
132 var advisor = this.streamAdvisors.pop();
133
134 AdvisorObservationContext observationContext = AdvisorObservationContext.builder()
135 .advisorName(advisor.getName())
136 .chatClientRequest(chatClientRequest)
137 .order(advisor.getOrder())
138 .build();
139
140 var observation = AdvisorObservationDocumentation.AI_ADVISOR.observation(this.observationConvention,
141 DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, this.observationRegistry);
142
143 Observation parentObservation = contextView.getOrDefault(ObservationThreadLocalAccessor.KEY, null);
144 observation.parentObservation(parentObservation);
145 // Briefly make the parent observation current while starting this one, so
146 // Micrometer tracing derives the span's parent from the parent observation
147 // rather than from whatever scope happens to be open on the current thread
148 // (e.g.
149 // the servlet HTTP span). This keeps span parenting correct without relying
150 // on
151 // automatic context propagation.
152 try (Observation.Scope ignored = parentObservation != null ? parentObservation.openScope()
153 : Observation.Scope.NOOP) {
154 observation.start();
155 }
156
157 // @formatter:off
158 Flux<ChatClientResponse> chatClientResponse = Flux.defer(() -> advisor.adviseStream(chatClientRequest, this)
159 .doOnError(observation::error)
160 .doFinally(s -> observation.stop())
161 .contextWrite(ctx -> ctx.put(ObservationThreadLocalAccessor.KEY, observation)));
162 // @formatter:on
163 return CHAT_CLIENT_MESSAGE_AGGREGATOR.aggregateChatClientResponse(chatClientResponse,
164 observationContext::setChatClientResponse);
165 });
166 }
167
对比 nextCall,这里多了四样东西:
(1)Flux.deferContextual(第 127 行)。
defer 系列操作符的核心是"延迟到订阅时才执行 lambda"。这对流式至关重要:如果不用 defer,pop() 会在方法调用的那一刻就执行,而不是订阅时。
在 Reactor 里,Flux 可以被多次订阅。用 deferContextual 能保证每次订阅都有自己的执行上下文(虽然这里 pop() 只发生一次——第二次订阅会因为 deque 为空而报错)。
(2)错误处理用 Flux.error 而不是 throw(第 128-130 行)。
对比 nextCall 第 101-103 行的 throw new IllegalStateException(...)。
这是响应式编程的惯例:在返回 Flux 的方法里,不要 throw,而要返回 Flux.error(...)。因为 throw 会在组装期抛出,调用方如果没用 try-catch 就会直接崩溃;而 Flux.error 只在订阅时才发出 error 信号,可以被 .onErrorResume() 处理。
这个差异在第 02 章讲 ChatModel.stream() 时已经出现过(ChatModel 用 throw,StreamingTranscriptionModel 用 Flux.error)。这里再次出现,说明项目内部对这两种风格并没有完全统一。
(3)parentObservation 的处理(第 143-155 行)—— 带一段 7 行注释。
原样摘录注释:
// 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.
这段话在说一个非常具体的分布式追踪问题:
- 在流式场景,
observation.start()可能发生在任意线程(Reactor 的线程池) - 那个线程上"当前打开的作用域"可能是任何东西(比如一个 servlet HTTP span)
- 如果让 Micrometer 自己推断父 span,它会用"当前线程的当前作用域"——这是错的
- 正确做法是:从 Reactor Context 里取出我们保存的 parent observation,显式设为父
第 152-155 行的写法很讲究:
try (Observation.Scope ignored = parentObservation != null ? parentObservation.openScope()
: Observation.Scope.NOOP) {
observation.start();
}
在 start() 的瞬间临时打开父作用域,start 完立刻关闭。这样 Micrometer 在 start 时看到的"当前 observation"就是正确的父。
而 parent 是从哪来的?第 161 行:
.contextWrite(ctx -> ctx.put(ObservationThreadLocalAccessor.KEY, observation))
每个 advisor 在处理完后,把自己写入 Reactor Context。 下一个 advisor 在第 143 行从 Context 里读出来当作父。
这就形成了一条"通过 Reactor Context 传递的 observation 父子链",完全不依赖 ThreadLocal(因为在响应式场景下 ThreadLocal 会随线程切换而丢失)。
这是全库最精致的一段工程代码之一。 注释里的 "rather than from whatever scope happens to be open on the current thread" 带着一点无奈——作者显然踩过这个坑。
(4)CHAT_CLIENT_MESSAGE_AGGREGATOR.aggregateChatClientResponse(...)(第 163-164 行)。
每一个 advisor 的流式输出都会被聚合一次,聚合结果通过 observationContext::setChatClientResponse 回填到观测上下文。
注意第 63-65 行:
private static final ChatClientMessageAggregator CHAT_CLIENT_MESSAGE_AGGREGATOR
= new ChatClientMessageAggregator();
是 static final 共享实例。 这是安全的吗?
看 ChatClientMessageAggregator.aggregateChatClientResponse() 的实现(§第 06 章 §6.13.2):它内部的状态(AtomicReference<Map> context)是每次调用新建的,不存储在实例字段里。所以共享实例是线程安全的。
但 SimpleLoggerAdvisor(第 88 行)和 MessageChatMemoryAdvisor(第 147 行)里用的是 new ChatClientMessageAggregator() —— 每次新建实例。
两种用法并存,说明作者也没有统一约定。 因为无状态,两者都对;但读代码时会疑惑。
5.5 BaseAdvisor:把两条链路压缩成 before/after
路径:.../advisor/api/BaseAdvisor.java(107 行)
5.5.1 完整源码
40 public interface BaseAdvisor extends CallAdvisor, StreamAdvisor {
41
42 Scheduler DEFAULT_SCHEDULER = Schedulers.boundedElastic();
43
44 @Override
45 default ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) {
46 Assert.notNull(chatClientRequest, "chatClientRequest cannot be null");
47 Assert.notNull(callAdvisorChain, "callAdvisorChain cannot be null");
48
49 ChatClientRequest processedChatClientRequest = before(chatClientRequest, callAdvisorChain);
50 ChatClientResponse chatClientResponse = callAdvisorChain.nextCall(processedChatClientRequest);
51 return after(chatClientResponse, callAdvisorChain);
52 }
53
54 @Override
55 default Flux<ChatClientResponse> adviseStream(ChatClientRequest chatClientRequest,
56 StreamAdvisorChain streamAdvisorChain) {
57 Assert.notNull(chatClientRequest, "chatClientRequest cannot be null");
58 Assert.notNull(streamAdvisorChain, "streamAdvisorChain cannot be null");
59 Assert.notNull(getScheduler(), "scheduler cannot be null");
60
61 Flux<ChatClientResponse> chatClientResponseFlux = Mono.just(chatClientRequest)
62 .publishOn(getScheduler())
63 .map(request -> this.before(request, streamAdvisorChain))
64 .flatMapMany(streamAdvisorChain::nextStream);
65
66 return chatClientResponseFlux.map(response -> {
67 if (AdvisorUtils.onFinishReason().test(response)) {
68 response = after(response, streamAdvisorChain);
69 }
70 return response;
71 }).onErrorResume(error -> Flux.error(new IllegalStateException("Stream processing failed", error)));
72 }
73
74 @Override
75 default String getName() {
76 return this.getClass().getSimpleName();
77 }
78
/**
* Logic to be executed before the rest of the advisor chain is called.
*/
ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain);
/**
* Logic to be executed after the rest of the advisor chain is called.
*/
ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain);
/**
* Scheduler used for processing the advisor logic when streaming.
*/
default Scheduler getScheduler() {
return DEFAULT_SCHEDULER;
}
}
这是整个 Advisor 体系里最重要的"糖"。 它把"实现两个响应式接口"简化成"实现两个同步方法"。
5.5.2 非流式:三行搞定
ChatClientRequest processed = before(request, chain);
ChatClientResponse response = chain.nextCall(processed);
return after(response, chain);
这是最直观的环绕语义:前处理 → 下游 → 后处理。
注意 before 的返回值被用在了 nextCall 上。 如果你的 before 忘了 return 修改后的 request(比如你直接改了 context 然后 return chatClientRequest),那修改仍然生效(因为 context 是共享的可变 Map,见 §5.6.3),但如果你改的是 prompt 而忘了 return,修改就丢了。
5.5.3 流式:四个要点
(1)Mono.just(request).publishOn(scheduler)(第 61-62 行)。
为什么要把一个已知的单值包成 Mono 再 publishOn?
因为 before() 是同步阻塞的(比如 MessageChatMemoryAdvisor.before() 会查数据库、QuestionAnswerAdvisor.before() 会查向量库)。如果在订阅线程上直接跑,就阻塞了 Netty event loop 或调用方线程。
publishOn(scheduler) 把后续操作切换到 boundedElastic 线程池。
Schedulers.boundedElastic() 的特点:
- 线程池大小有上限(默认
10 × CPU 核数) - 线程可复用,空闲 60 秒后销毁
- 专为阻塞 I/O 设计
这是一个正确的选择。但要注意:如果你的 advisor 在 before() 里做 CPU 密集计算,boundedElastic 就不合适了(应该用 Schedulers.parallel())。可以通过覆盖 getScheduler() 改。
(2)after() 的触发条件是 AdvisorUtils.onFinishReason()(第 67 行)。
这是流式与非流式的最大行为差异:
- 非流式:
after()一定被调用一次 - 流式:
after()只在"包含 finishReason 的那个 chunk"上被调用
看 AdvisorUtils.onFinishReason()(AdvisorUtils.java:33-44):
public static Predicate<ChatClientResponse> onFinishReason() {
return chatClientResponse -> {
ChatResponse chatResponse = chatClientResponse.chatResponse();
return chatResponse != null && chatResponse.getResults() != null
&& chatResponse.getResults()
.stream()
.anyMatch(result -> result != null && result.getMetadata() != null
&& StringUtils.hasText(result.getMetadata().getFinishReason()));
};
}
判定:存在一个 result,其 metadata 的 finishReason 非空。
通常只有最后一个 chunk 带 finishReason。 所以 after() 在流结束时被调用一次。
但如果供应商不发 finishReason 呢? 那 after() 就永远不会被调用。
这对 MessageChatMemoryAdvisor 是灾难性的——它的 after() 负责把 assistant 消息写回记忆库。如果 after() 不执行,助手的回复就永远不会进入记忆。
这就是为什么 MessageChatMemoryAdvisor 自己 override 了 adviseStream(第 140-152 行),改用 ChatClientMessageAggregator 在流完成时触发:
@Override
public Flux<ChatClientResponse> adviseStream(ChatClientRequest chatClientRequest,
StreamAdvisorChain streamAdvisorChain) {
// Get the scheduler from BaseAdvisor
Scheduler scheduler = this.getScheduler();
// Process the request with the before method
return Mono.just(chatClientRequest)
.publishOn(scheduler)
.map(request -> this.before(request, streamAdvisorChain))
.flatMapMany(streamAdvisorChain::nextStream)
.transform(flux -> new ChatClientMessageAggregator().aggregateChatClientResponse(flux,
response -> this.after(response, streamAdvisorChain)));
}
注意 transform(...) + aggregateChatClientResponse(flux, this::after) —— 聚合器在流完成时调用 after,不依赖 finishReason。
VectorStoreChatMemoryAdvisor(第 190-201 行)也是同样的写法。
这是一个"框架提供便利,但关键场景必须自己接管"的典型案例。 写自定义 advisor 时,如果 after() 是必须的(而不是可选的),就应该 override adviseStream 用聚合器。
(3)onErrorResume 包装(第 71 行)。
.onErrorResume(error -> Flux.error(new IllegalStateException("Stream processing failed", error)));
这一行的行为值得商榷。
它把所有下游异常包装成 IllegalStateException。后果:
- 原始的
OpenAiApiException(含 HTTP 状态码、响应体)变成了 cause - 你在
.onErrorResume(e -> ...)里拿到的永远是IllegalStateException - 要拿原始错误必须
e.getCause()
这破坏了异常类型的信息传递。 对比非流式的 adviseCall(第 44-52 行)——它没有任何 try-catch,异常原样向上传播。
两条链路的错误处理语义不一致。 非流式你能 catch 到具体异常,流式只能 catch 到 IllegalStateException。
实践建议:如果你的流式调用需要根据异常类型做不同处理(比如 429 重试、401 换 token),不要依赖 BaseAdvisor 的默认 adviseStream,自己 override 并去掉这层包装。
(4)getScheduler() 是可覆盖的(第 96-98 行)。
default Scheduler getScheduler() {
return DEFAULT_SCHEDULER;
}
MessageChatMemoryAdvisor 和 VectorStoreChatMemoryAdvisor 都把它暴露成了 builder 可配(MessageChatMemoryAdvisor.Builder.scheduler(...))。
但 QuestionAnswerAdvisor 和 SemanticCacheAdvisor 是硬编码的:
// QuestionAnswerAdvisor.java:92
this.scheduler = scheduler != null ? scheduler : BaseAdvisor.DEFAULT_SCHEDULER;
它有构造器参数,但外部不能直接覆盖 getScheduler()。
5.5.4 getName() 的默认实现
@Override
default String getName() {
return this.getClass().getSimpleName();
}
getSimpleName() 对 MessageChatMemoryAdvisor 返回 "MessageChatMemoryAdvisor"。
而 DefaultAdvisorObservationConvention.getContextualName() 会做:
ParsingUtils.reConcatenateCamelCase(name, "_")
.replace("_around_advisor", "")
.replace("_advisor", "");
reConcatenateCamelCase("MessageChatMemoryAdvisor", "_") → Message_Chat_Memory_Advisor
.replace("_advisor", "") → Message_Chat_Memory
所以 span 的 contextualName 是 Message_Chat_Memory。
注意 .replace() 是替换所有出现,且区分大小写。 如果类名里有 "advisor" 但前面没有下划线(比如 MyadvisorFoo),不会被替换。
5.6 ChatClientRequest / ChatClientResponse:context 传播模型
5.6.1 两个 record
// ChatClientRequest.java:36
public record ChatClientRequest(Prompt prompt, Map<String, @Nullable Object> context) {
public ChatClientRequest {
Assert.notNull(prompt, "prompt cannot be null");
Assert.notNull(context, "context cannot be null");
Assert.noNullElements(context.keySet(), "context keys cannot be null");
}
...
}
// ChatClientResponse.java:35
public record ChatClientResponse(@Nullable ChatResponse chatResponse, Map<String, @Nullable Object> context) {
public ChatClientResponse {
Assert.notNull(context, "context cannot be null");
Assert.noNullElements(context.keySet(), "context keys cannot be null");
}
...
}
三个关键观察:
(1)chatResponse 是 @Nullable,prompt 不是。
ChatClientResponse 允许没有 ChatResponse。什么时候会发生?
看 ChatClientResponse.Builder.build()(第 80-82 行):
public ChatClientResponse build() {
return new ChatClientResponse(this.chatResponse, this.context);
}
没有 Assert.state(this.chatResponse != null)。所以 ChatClientResponse.builder().context(...).build() 是合法的,会得到一个 chatResponse == null 的响应。
这就是 §第 06 章 §6.2 里 StructuredOutputValidationAdvisor.validateOutputSchema() 要判 null 的原因(第 179-186 行)。
(2)context 的校验只检查 key 不为 null,不检查 value。
Assert.noNullElements(context.keySet(), ...) —— 只校验 key。
value 可以是 null。 类型签名也写明了:Map<String, @Nullable Object>。
这意味着 context.get(k) 可能返回 null,且这个 null 无法区分"没设置"与"设置为 null"。要区分必须用 containsKey(k)。
ChatModelCallAdvisor.augmentWithFormatInstructions() 就用了 containsKey(第 82-83 行):
boolean usesNativeStructuredOutput = chatClientRequest.context()
.containsKey(ChatClientAttributes.STRUCTURED_OUTPUT_NATIVE.getKey());
因为它要区分"设为 false"与"没设置"——虽然实际只 put 了 true(DefaultChatClient.java:577)。
(3)构造器没有做 Map.copyOf()。
对比第 03 章的 Prompt、第 02 章的 ChatResponse,它们都在构造器里做不可变拷贝。而这里没有。
5.6.2 copy() 与 mutate():浅拷贝
// ChatClientRequest.java:44-50
public ChatClientRequest copy() {
return new ChatClientRequest(this.prompt.copy(), new HashMap<>(this.context));
}
public Builder mutate() {
return new Builder().prompt(this.prompt.copy()).context(new HashMap<>(this.context));
}
注意区别:
| 方法 | 返回 | prompt | context |
|---|---|---|---|
copy() | ChatClientRequest | prompt.copy() 深拷贝 | new HashMap<>(...) 浅拷贝 |
mutate() | Builder | prompt.copy() 深拷贝 | new HashMap<>(...) 浅拷贝 |
prompt 是真拷贝,context 是浅拷贝。
浅拷贝意味着:新 request 的 context 是一个新的 HashMap,但 value 如果是可变对象(比如 List<Document>),新旧 request 共享同一个 List 实例。
这在实践中有影响。QuestionAnswerAdvisor.before()(第 122 行):
Map<String, Object> context = new HashMap<>(chatClientRequest.context());
context.put(RETRIEVED_DOCUMENTS, documents);
它手工 shallow copy 后 put。如果它改成 chatClientRequest.context().put(...),就会污染上游 advisor 的 context——因为那是同一个 Map。
而 DefaultChatClient.resolveAdvisorChain() 第 577 行直接:
this.request.context().put(ChatClientAttributes.STRUCTURED_OUTPUT_NATIVE.getKey(), true);
直接改原 Map。 因为此时 request 已经构造完,就是想让所有下游看到。
结论:context 是一个"共享可变状态",不是"不可变传递"。
这与 Spring AI 2.x 整体的"不可变"取向(第 02、03 章反复强调)恰好相反。这是 Advisor 体系里少数的可变设计。
为什么?因为 advisor 之间确实需要双向通信:
- 内层 advisor 要把结果告诉外层(
QuestionAnswerAdvisor把检索到的文档放进 context,供外层的日志 advisor 打印) - 但 record 是不可变的,改了就得创建新对象
- 创建新对象意味着外层 advisor 拿不到(因为外层已经 pop 出去了,它只能看到
after()的返回值)
所以 context 必须可变。这是一个务实的选择,代价是引入了共享可变状态的所有经典问题。
5.6.3 context 的类型陷阱
签名是 Map<String, @Nullable Object>。
这意味着所有 value 都是 Object,取出时要强转。 比如 ToolSearchToolCallingAdvisor 第 251-252 行:
var cachedToolCallbacks = (Map<String, ToolCallback>) chatClientRequest.context()
.get(CACHED_TOOL_CALLBACKS_KEY);
带 @SuppressWarnings("unchecked") 的强转。写错类型会在使用时才 ClassCastException。
这是一个"约定优于配置"的设计:框架不提供类型安全的 context,key 的语义和 value 的类型都由使用者自己保证。
对比 ChatClientAttributes 枚举(§5.6.4)—— 框架只提供了它自己用的 key 常量,第三方 advisor 的 key 都是字符串常量(如 QuestionAnswerAdvisor.RETRIEVED_DOCUMENTS = "qa_retrieved_documents")。
5.6.4 ChatClientAttributes:一个枚举,两个同名 key
路径:spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientAttributes.java(55 行,实体 30 行)
25 public enum ChatClientAttributes {
26
27 //@formatter:off
28
29 OUTPUT_FORMAT("spring.ai.chat.client.output.format"),
30
31 STRUCTURED_OUTPUT_SCHEMA("spring.ai.chat.client.structured.output.schema"),
32
33 STRUCTURED_OUTPUT_NATIVE("spring.ai.chat.client.structured.output.native"),
34
35 /**
36 * Deprecated in favor of {@link #TOOL_CALLING_ADVISOR_AUTO_REGISTER}.
37 */
38 @Deprecated(since = "2.0.0", forRemoval = true)
39 TOOL_CALL_ADVISOR_AUTO_REGISTER("spring.ai.chat.client.tool.calling.advisor.auto-register"),
40
41 TOOL_CALLING_ADVISOR_AUTO_REGISTER("spring.ai.chat.client.tool.calling.advisor.auto-register");
42
43 //@formatter:on
注意第 39 行和第 41 行的字符串字面量完全相同:"spring.ai.chat.client.tool.calling.advisor.auto-register"。
一个废弃,一个新增,key 值一模一样。
这是一个刻意的兼容处理:ToolCallAdvisor 改名成 ToolCallingAdvisor 时,枚举常量也改名了,但 context key 保持原样。这样:
- 老代码写
context.put(ChatClientAttributes.TOOL_CALL_ADVISOR_AUTO_REGISTER.getKey(), false)—— 编译有 deprecation 警告,但运行正常 - 新代码写
TOOL_CALLING_ADVISOR_AUTO_REGISTER—— 运行正常 - 两者混用 —— 也正常,因为 key 相同
代价:如果你在配置文件里写 spring.ai.chat.client.tool.calling.advisor.auto-register=false,无法从 key 判断它对应哪个常量(反正效果一样)。
另一个细节:这是一个 enum 而不是 final class + public static final String。用 enum 的好处是可以在 javadoc 的 @link 里引用(第 36 行 {@link #TOOL_CALLING_ADVISOR_AUTO_REGISTER}),且类型安全。
5.7 终结 advisor:ChatModelCallAdvisor 与 ChatModelStreamAdvisor
5.7.1 ChatModelCallAdvisor
路径:spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelCallAdvisor.java(126 行)
46 public final class ChatModelCallAdvisor implements CallAdvisor {
47
48 private final ChatModel chatModel;
49
50 private ChatModelCallAdvisor(ChatModel chatModel) {
51 Assert.notNull(chatModel, "chatModel cannot be null");
52 this.chatModel = chatModel;
53 }
54
55 @Override
56 public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) {
57 Assert.notNull(chatClientRequest, "the chatClientRequest cannot be null");
58
59 ChatClientRequest formattedChatClientRequest = augmentWithFormatInstructions(chatClientRequest);
60
61 ChatResponse chatResponse = this.chatModel.call(formattedChatClientRequest.prompt());
62
63 return ChatClientResponse.builder()
64 .chatResponse(chatResponse)
65 .context(Map.copyOf(formattedChatClientRequest.context()))
66 .build();
67 }
要点:
(1)final class,构造器 private。 只能通过 builder 创建。
(2)不调用 callAdvisorChain.nextCall()。 它是终点。
如果它调用了,且 deque 已空,就会抛 IllegalStateException。它是唯一不推进链的 advisor(另一个是 ChatModelStreamAdvisor)。
(3)getName() 返回 "call"(第 108-111 行),getOrder() 返回 Ordered.LOWEST_PRECEDENCE(第 113-116 行)。
(4)第 65 行 Map.copyOf(context)。
这是全库少见的"把 context 变不可变"的地方。因为要传给下游客体的 response 了,做一次防御性拷贝。
但注意:Map.copyOf() 是浅拷贝,value 仍是原对象。而且 Map.copyOf() 会拒绝 null value——如果 context 里有 null value,这里会抛 NPE。
这是一个潜在问题:ChatClientRequest 明确允许 null value(Map<String, @Nullable Object>),但 Map.copyOf() 不允许。两者直接冲突。
什么时候会触发? 如果你 context.put("key", null),然后走到 ChatModelCallAdvisor,就 NPE。
5.7.2 augmentWithFormatInstructions:结构化输出的最后一步
69 private static ChatClientRequest augmentWithFormatInstructions(ChatClientRequest chatClientRequest) {
70
71 String outputFormat = (String) chatClientRequest.context().get(ChatClientAttributes.OUTPUT_FORMAT.getKey());
72
73 String outputSchema = (String) chatClientRequest.context()
74 .get(ChatClientAttributes.STRUCTURED_OUTPUT_SCHEMA.getKey());
75
76 if (!StringUtils.hasText(outputFormat) && !StringUtils.hasText(outputSchema)) {
77 return chatClientRequest;
78 }
79
80 boolean usesNativeStructuredOutput = chatClientRequest.context()
81 .containsKey(ChatClientAttributes.STRUCTURED_OUTPUT_NATIVE.getKey());
82
83 if (usesNativeStructuredOutput && StringUtils.hasText(outputSchema) && chatClientRequest.prompt()
84 .getOptions() instanceof StructuredOutputChatOptions structuredOutputChatOptions) {
85
86 var augmentedOptions = structuredOutputChatOptions.mutate().outputSchema(outputSchema).build();
87 Prompt augmentedPrompt = chatClientRequest.prompt().mutate().chatOptions(augmentedOptions).build();
88
89 return ChatClientRequest.builder()
90 .prompt(augmentedPrompt)
91 .context(Map.copyOf(chatClientRequest.context()))
92 .build();
93 }
94
95 Prompt augmentedPrompt = chatClientRequest.prompt()
96 .augmentUserMessage(userMessage -> userMessage.mutate()
97 .text(userMessage.getText() + System.lineSeparator() + outputFormat)
98 .build());
99
100 return ChatClientRequest.builder()
101 .prompt(augmentedPrompt)
102 .context(Map.copyOf(chatClientRequest.context()))
103 .build();
104 }
这是"软结构化输出"与"原生结构化输出"的分水岭。
分支 1(第 76-78 行):没有 format 也没有 schema → 原样返回。绝大多数普通调用走这里。
分支 2(第 80-93 行):使用了原生结构化输出(useProviderStructuredOutput())且 options 是 StructuredOutputChatOptions 类型 → 把 schema 写进 options。
这是"硬约束"路径:schema 会通过供应商的 JSON Schema 能力(OpenAI 的 response_format: json_schema)强制约束模型输出。
分支 3(第 95-103 行):把 outputFormat 字符串拼到用户消息末尾。
这是"软约束"路径。格式说明只是 prompt 的一部分,模型可能不遵守。
注意第 97 行用的是 System.lineSeparator()。 在 Windows 上是 \r\n,Linux 上是 \n。这意味着同样的 prompt 在不同操作系统上产生的 token 数可能不同。对需要精确复现的场景(比如测试基线)是个隐患。
一个关键的不对称(第 04 章已经提过,这里给出源码证据):
ChatModelStreamAdvisor.adviseStream() 的全文(第 62-71 行):
62 @Override
63 public Flux<ChatClientResponse> adviseStream(ChatClientRequest chatClientRequest,
64 StreamAdvisorChain streamAdvisorChain) {
65 Assert.notNull(chatClientRequest, "the chatClientRequest cannot be null");
66
67 return this.chatModel.stream(chatClientRequest.prompt())
68 .map(chatResponse -> ChatClientResponse.builder()
69 .chatResponse(chatResponse)
70 .context(Map.copyOf(chatClientRequest.context()))
71 .build())
72 .publishOn(Schedulers.boundedElastic()); // TODO add option to disable
73 }
没有调用 augmentWithFormatInstructions。
流式路径完全忽略 OUTPUT_FORMAT 和 STRUCTURED_OUTPUT_SCHEMA。
所以:
// 非流式:格式说明被拼进 prompt
client.prompt("列出三个城市").call().entity(new ParameterizedTypeReference<List<String>>() {});
// 流式:格式说明被丢弃,模型自由输出
client.prompt("列出三个城市").stream().content(); // 没有 entity()
第 04 章说过 StreamResponseSpec 没有 entity() 方法。这里是第二重原因:即使有,格式指令也不会生效。
第 72 行的 // TODO add option to disable 又一处 TODO。publishOn(Schedulers.boundedElastic()) 是硬编码的,作者希望未来能关掉。
为什么要 publishOn?因为 chatModel.stream() 返回的 Flux 可能在 I/O 线程(如 Netty event loop)上发射。切到 boundedElastic 可以避免下游 advisor 的同步处理阻塞 I/O 线程。
代价是一次额外的线程切换(每个 chunk 一次)。对高频流式有可测量的开销。
5.8 观测:advisor 层的 Micrometer 集成
5.8.1 AdvisorObservationDocumentation
路径:.../advisor/observation/AdvisorObservationDocumentation.java(约 115 行)
31 public enum AdvisorObservationDocumentation implements ObservationDocumentation {
32
36 AI_ADVISOR {
37 @Override
38 public Class<? extends ObservationConvention<? extends Observation.Context>> getDefaultConvention() {
39 return DefaultAdvisorObservationConvention.class;
40 }
...
只有一个枚举值 AI_ADVISOR。
低基数 key(4 个):
| 枚举 | 实际字符串 |
|---|---|
AI_OPERATION_TYPE | AiObservationAttributes.AI_OPERATION_TYPE.value() |
AI_PROVIDER | AiObservationAttributes.AI_PROVIDER.value() |
SPRING_AI_KIND | "spring.ai.kind" |
ADVISOR_NAME | "spring.ai.advisor.name" |
高基数 key(1 个):
| 枚举 | 实际字符串 |
|---|---|
ADVISOR_ORDER | "spring.ai.advisor.order" |
为什么 order 是高基数? 因为 order 的取值理论上无限(任意 int),虽然实践中有限。但把它列为高基数意味着 Micrometer 默认不会把它作为 metric tag 导出(高基数 key 只用于 trace,不用于 metric)。
这是一个正确的判断:如果 order 成了 metric tag,每个不同 order 会创建一个时间序列,可能导致基数爆炸。
5.8.2 DefaultAdvisorObservationConvention
39 public class DefaultAdvisorObservationConvention implements AdvisorObservationConvention {
40
41 public static final String DEFAULT_NAME = "spring.ai.advisor";
观测名:spring.ai.advisor。
getContextualName:三段变形
61 @Override
62 public String getContextualName(AdvisorObservationContext context) {
63 Assert.notNull(context, "context cannot be null");
64 return ParsingUtils.reConcatenateCamelCase(context.getAdvisorName(), "_")
65 .replace("_around_advisor", "")
66 .replace("_advisor", "");
67 }
第一步:reConcatenateCamelCase(name, "_") —— 驼峰转下划线。"MessageChatMemoryAdvisor" → "Message_Chat_Memory_Advisor"
第二步:.replace("_around_advisor", "") —— 去掉遗留的 around advisor 后缀(§5.2.3 的化石)
第三步:.replace("_advisor", "") —— 去掉 advisor 后缀
结果:"Message_Chat_Memory"
为什么要这么麻烦? 因为 span 名字应该短且稳定。spring.ai.advisor + contextualName Message_Chat_Memory 比 MessageChatMemoryAdvisor 更适合做指标聚合。
注意第一步的首字母保留了大写。所以是 Message_Chat_Memory 而不是 message_chat_memory。这与很多 span 命名规范(全小写)不一致。
KeyValues
74 @Override
75 public KeyValues getLowCardinalityKeyValues(AdvisorObservationContext context) {
76 Assert.notNull(context, "context cannot be null");
77 return KeyValues.of(aiOperationType(context), aiProvider(context), springAiKind(), advisorName(context));
78 }
79
80 protected KeyValue aiOperationType(AdvisorObservationContext context) {
81 return KeyValue.of(LowCardinalityKeyNames.AI_OPERATION_TYPE, AiOperationType.FRAMEWORK.value());
82 }
83
84 protected KeyValue aiProvider(AdvisorObservationContext context) {
85 return KeyValue.of(LowCardinalityKeyNames.AI_PROVIDER, AiProvider.SPRING_AI.value());
86 }
87
88 protected KeyValue springAiKind() {
89 return KeyValue.of(LowCardinalityKeyNames.SPRING_AI_KIND, SpringAiKind.ADVISOR.value());
90 }
91
92 protected KeyValue advisorName(AdvisorObservationContext context) {
93 return KeyValue.of(LowCardinalityKeyNames.ADVISOR_NAME, context.getAdvisorName());
94 }
注意第 80-82 行:aiOperationType() 接收了 context 参数但没用它。
protected KeyValue aiOperationType(AdvisorObservationContext context) {
return KeyValue.of(..., AiOperationType.FRAMEWORK.value());
}
advisor 观测的 operation type 永远是 FRAMEWORK(不是 CHAT、不是 EMBEDDING)。
这是因为 advisor 是框架层的编排动作,不是模型层的推理动作。
springAiKind()(第 88 行)同理,永远返回 SpringAiKind.ADVISOR,且不接收 context——不一致的签名,但结果都对。
高基数只有一个:ADVISOR_ORDER(第 106-110 行)。
5.9 自动注册:buildAdvisorChain() 的三步
完整源码(已见 §5.0,这里补充分析)。
5.9.1 第一步:autoRegisterToolCallingAdvisor()
private void autoRegisterToolCallingAdvisor() {
boolean autoRegisterDisabled = Boolean.FALSE
.equals(this.advisorParams.get(ChatClientAttributes.TOOL_CALLING_ADVISOR_AUTO_REGISTER.getKey()));
if (autoRegisterDisabled) {
return;
}
boolean hasToolCallingAdvisor = this.advisors.stream().anyMatch(a -> a instanceof ToolAdvisor);
if (hasToolCallingAdvisor) {
return;
}
int configuredOrder = this.toolCallingAdvisorBuilder.getAdvisorOrder();
boolean hasDownstreamMemoryAdvisor = this.advisors.stream()
.anyMatch(a -> a instanceof MemoryAdvisor && a.getOrder() > configuredOrder);
this.advisors.add(this.toolCallingAdvisorBuilder.copy()
.conversationHistoryEnabled(!hasDownstreamMemoryAdvisor)
.build());
}
三个 return 条件,一个副作用:
(1)禁用判定用的是 Boolean.FALSE.equals(...)。
注意这个写法:Boolean.FALSE.equals(x) 而不是 !((Boolean) x) 或 (Boolean) x == false。
为什么?因为 x 是 Object(从 Map<String, Object> 取的)。Boolean.FALSE.equals(x):
x是Boolean.FALSE→ truex是Boolean.TRUE→ falsex是null→ falsex是字符串"false"→ false(类型不同)
这是 null-safe 的惯用法。 语义是"显式设为 false 才禁用",未设置(null)不禁用。
(2)this.advisors.add(...) 修改了 builder 的字段。
注意:buildAdvisorChain() 是在 DefaultChatClientRequestSpec 里调用的(第 1177 行),而 this.advisors 是 spec 的字段(第 787 行)。
所以每次 .call() / .stream() 都会 add 一次。
如果一个 spec 被复用多次(比如你在循环里用同一个 prompt() 对象),this.advisors 会不断增长,每次多一个 ToolCallingAdvisor。
然后 validateSingleToolAdvisor() 会抛异常:
At most one ToolAdvisor is allowed in the advisor chain, but found 2: [...]
这是一个实际的坑(§5.12 思考题 4)。
(3)this.toolCallingAdvisorBuilder.copy()
注意是 copy() 而不是直接用 builder。因为 builder 是有状态的,直接 build() 可能返回同一个实例或共享状态。
ToolCallingAdvisor.Builder.copy()(第 603 行)的 javadoc:
Factory method called by {@link #copy()} to create the blank builder instance ... instance of their own builder type so that {@link #copy()} preserves the full ...
这是一个自限泛型的复制方法,保证 ToolSearchToolCallingAdvisor.Builder.copy() 返回自己类型的 builder。
5.9.2 第二步:validateSingleToolAdvisor()
private void validateSingleToolAdvisor() {
List<Advisor> toolAdvisors = this.advisors.stream().filter(a -> a instanceof ToolAdvisor).toList();
if (toolAdvisors.size() > 1) {
String names = String.join(", ",
toolAdvisors.stream().map(a -> a.getName() + " (order=" + a.getOrder() + ")").toList());
throw new IllegalStateException("At most one ToolAdvisor is allowed in the advisor chain, but found "
+ toolAdvisors.size() + ": [" + names + "]");
}
}
异常消息包含 advisor 名和 order —— 这是很好的诊断信息。你一眼就能看出是哪两个冲突了。
为什么只允许一个? 因为两个工具 advisor 会各自驱动一个循环,互相嵌套,行为不可预测(可能死循环,可能工具被执行两次)。
5.9.3 第三步:追加两个终结 advisor
List<Advisor> chain = new ArrayList<>(this.advisors);
chain.add(ChatModelCallAdvisor.builder().chatModel(this.chatModel).build());
chain.add(ChatModelStreamAdvisor.builder().chatModel(this.chatModel).build());
注意是两个都加,不是二选一。
然后 pushAll 会分流:ChatModelCallAdvisor 只进 call 链,ChatModelStreamAdvisor 只进 stream 链。
所以每条链最终都恰好有一个终结 advisor。
配合 §5.4.4 的排序:它们的 order 是 LOWEST_PRECEDENCE = MAX_VALUE,所以一定排最后。
除非你也给了某个 advisor MAX_VALUE 的 order —— 那它会因为稳定排序排在终结 advisor 前面(因为先 add)。你的 advisor 会执行,但终结 advisor 仍在最后。不会出错,但语义上你的 advisor "不是最内层"了。
5.10 全库 advisor 清单与实践排序建议
5.10.1 完整清单(生产代码,排除测试)
经 grep implements BaseAdvisor|implements CallAdvisor|implements StreamAdvisor|extends BaseAdvisor|implements BaseChatMemoryAdvisor|implements ToolAdvisor|implements MemoryAdvisor 全库扫描,2.0.1 的 advisor 实现类只有 12 个(不含接口与 Builder):
| # | 类名 | 所在模块 | 接口 | 默认 order | 一句话作用 |
|---|---|---|---|---|---|
| 1 | ChatModelCallAdvisor | spring-ai-client-chat | CallAdvisor | MAX_VALUE | 终结:调 chatModel.call() |
| 2 | ChatModelStreamAdvisor | spring-ai-client-chat | StreamAdvisor | MAX_VALUE | 终结:调 chatModel.stream() |
| 3 | MessageChatMemoryAdvisor | spring-ai-client-chat | BaseChatMemoryAdvisor | MIN+200 | 短记忆:消息列表前置 |
| 4 | SafeGuardAdvisor | spring-ai-client-chat | CallAdvisor, StreamAdvisor | 0 | 敏感词短路 |
| 5 | SimpleLoggerAdvisor | spring-ai-client-chat | CallAdvisor, StreamAdvisor | 0 | 请求/响应日志 |
| 6 | StructuredOutputValidationAdvisor | spring-ai-client-chat | CallAdvisor, StreamAdvisor | 内部默认 | JSON Schema 校验 + 重试 |
| 7 | ToolCallingAdvisor | spring-ai-client-chat | CallAdvisor, StreamAdvisor, ToolAdvisor | MIN+300 | 工具调用循环 |
| 8 | ToolCallAdvisor | spring-ai-client-chat | extends ToolCallingAdvisor | MIN+300 | 已废弃的兼容壳 |
| 9 | QuestionAnswerAdvisor | spring-ai-vector-store-advisor | BaseAdvisor | 0 | 向量检索 + 上下文注入 |
| 10 | VectorStoreChatMemoryAdvisor | spring-ai-vector-store-advisor | BaseChatMemoryAdvisor | MIN+200 | 长期记忆:system 文本注入 |
| 11 | RetrievalAugmentationAdvisor | spring-ai-rag | (模块化 RAG) | 自定义 | 完整 RAG 流水线 |
| 12 | SemanticCacheAdvisor | spring-ai-redis-semantic-cache | BaseChatMemoryAdvisor | MIN+200 | 语义缓存 |
| 13 | ToolSearchToolCallingAdvisor | spring-ai-tool-search-advisor | extends ToolCallingAdvisor | MIN+300 | 工具检索式选择 |
13 个(含废弃的 ToolCallAdvisor)。
这个数字本身就很说明问题:Spring AI 并没有追求"advisor 生态繁荣",而是把能力集中在了少数几个核心 advisor 上。RAG 的完整能力(query 改写、扩展、重排、后处理)不在 advisor 层,而在 spring-ai-rag 的组件层(第 06、07 章)。
5.10.2 推荐的 order 布局
基于 §5.1.3 的档位表,一个生产级链应该是:
MIN_VALUE
│
├─ MIN+100 自定义审计/埋点 advisor(看最原始的输入输出)
│
├─ MIN+200 MessageChatMemoryAdvisor(默认档位)
│ VectorStoreChatMemoryAdvisor
│ SemanticCacheAdvisor
│
├─ MIN+300 ToolCallingAdvisor(自动注册,默认档位)
│
├─ 0 SafeGuardAdvisor(默认)
│ SimpleLoggerAdvisor(默认)
│ QuestionAnswerAdvisor(默认)
│ StructuredOutputValidationAdvisor
│
├─ 自定义 你的业务 advisor(建议 100 ~ 1000)
│
MAX_VALUE
ChatModelCallAdvisor / ChatModelStreamAdvisor
几条实践规则:
规则一:不要占用 MIN_VALUE + 0 ~ MIN_VALUE + 500 区间。 这是框架保留区(记忆 200、工具 300)。
规则二:审计类 advisor 用 MIN_VALUE + 100(比记忆更外)。这样能看到未经任何加工的用户输入——这对合规审计是必需的。
规则三:日志 advisor 的 order 取决于你想看什么。
MIN+100:看原始输入(不含历史、不含检索上下文)0:看加工后的输入(含历史,取决于记忆 advisor 的位置)MAX_VALUE - 100:看最终发给模型的 prompt(含所有加工)
生产环境建议 MAX_VALUE - 100——你需要知道模型实际收到了什么。
规则四:SafeGuardAdvisor 应该尽量靠外(推荐 MIN+150)。因为如果它太靠内,前面的 advisor 已经做了一堆无用功(向量检索、数据库查询),被拦截时这些成本就白花了。
但注意:不能比审计 advisor 更外,否则审计看不到被拦截的请求。
规则五:业务 advisor 用 100 的倍数(100、200、300...)。留出空隙方便将来插入。
5.10.3 一个完整的生产配置示例
ChatMemory chatMemory = MessageWindowChatMemory.builder()
.chatMemoryRepository(repo)
.maxMessages(20)
.build();
ChatClient client = ChatClient.builder(chatModel)
.defaultAdvisors(
// 1. 审计:最外层,看原始输入
auditAdvisor(), // order = MIN+100
// 2. 安全:拦截优先,避免下游无用功
SafeGuardAdvisor.builder()
.sensitiveWords(List.of("..."))
.order(Ordered.HIGHEST_PRECEDENCE + 150)
.build(),
// 3. 记忆:框架保留档位
MessageChatMemoryAdvisor.builder(chatMemory)
.order(Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER)
.build(),
// 4. 检索:默认档位
QuestionAnswerAdvisor.builder(vectorStore)
.order(0)
.build(),
// 5. 业务:自定义区间
businessRuleAdvisor(), // order = 500
// 6. 日志:最内层,看最终请求
SimpleLoggerAdvisor.builder()
.order(Ordered.LOWEST_PRECEDENCE - 100)
.build()
)
.build();
注意 #4 QuestionAnswerAdvisor 在 #3 记忆 之内。
这意味着:检索用的 query 是用户原始输入(不含历史),因为记忆 advisor 的 before() 还没执行(它在外层,先执行……等等)。
等一下,这里需要仔细想。 外层先执行 before()。所以:
记忆.before() → prompt 变成 [历史..., 用户消息]
↓
检索.before() → getUserMessage() 拿到的是用户消息(不含历史)
QuestionAnswerAdvisor 用的是 prompt.getUserMessage().getText(),它拿到的是用户消息本身,不是整个 prompt。所以历史不影响检索 query。
这正是 §第 06 章 §6.5.2 说的"检索失败"问题的根源——无论记忆 advisor 在哪个位置,检索 query 都只有用户当前这一句话。
要解决这个问题,需要用 RetrievalAugmentationAdvisor + CompressionQueryTransformer(第 07 章),它会把历史压缩进查询。
5.11 本章小结
如果本章只记五条:
第一条:advisor 链是栈,不是队列。 DefaultAroundAdvisorChain.nextCall() 用 Deque.pop()(第 105 行)取下一个 advisor,配合 reOrder() 的 OrderComparator.sort() 升序 + addLast(第 277-287 行),得出order 最小者最先执行、位于最外层。这是源码级结论,不是推测。
第二条:链是一次性的。 pop() 会消耗 deque,所以同一个 chain 实例不能复用。这也是为什么必须保留 originalCallAdvisors 快照(第 87 行)——getCallAdvisors() 和 copy(after) 都依赖它。ToolCallingAdvisor 每轮循环调 copy(this)(第 166 行)拿到一条全新链,这是它能循环的前提。
第三条:2.x 里不存在"没有 advisor 的调用"。 buildAdvisorChain() 无条件加 ChatModelCallAdvisor + ChatModelStreamAdvisor(第 1196-1197 行),并自动注册 ToolCallingAdvisor(第 1190 行)——除非你显式禁用或已有 ToolAdvisor。工具调用已经从模型层搬到了 advisor 层。
第四条:context 是共享可变状态,是整个 2.x "不可变"取向里的例外。 ChatClientRequest/ChatClientResponse 的构造器不做 Map.copyOf(),mutate() 只做浅拷贝(第 45-50 行)。这让 advisor 之间能双向通信,代价是引入了共享可变状态的所有问题。ChatClientMessageAggregator 甚至让所有流式 chunk 共享同一个 context Map(第 43、54 行)。
第五条:BaseAdvisor 的流式默认实现不可靠。 它的 after() 只在 AdvisorUtils.onFinishReason() 为真时触发(第 67 行),而 finishReason 是供应商可选字段。凡是有副作用(写库、发消息)的 after(),都必须像 MessageChatMemoryAdvisor 那样 override adviseStream 并用 ChatClientMessageAggregator。
5.11.1 本章揪出的问题清单
按严重程度排:
| # | 问题 | 位置 | 影响 |
|---|---|---|---|
| 1 | 流式 after() 依赖可选字段 finishReason | BaseAdvisor.java:67 | 记忆丢失、日志缺失 |
| 2 | 流式 context 在所有 chunk 间共享同一 Map | ChatClientMessageAggregator.java:43,54 | 状态在时间轴上泄漏 |
| 3 | augmentWithFormatInstructions 流式不执行 | ChatModelStreamAdvisor.java:62-73 | 流式结构化输出完全失效 |
| 4 | 只 implements Advisor 会被 pushAll 静默丢弃 | DefaultAroundAdvisorChain.java:251,260 | advisor 不生效且无提示 |
| 5 | 复用同一 spec 多次调用导致 ToolAdvisor 重复 | DefaultChatClient.java:1231 | IllegalStateException |
| 6 | BaseAdvisor.adviseStream 把异常包成 IllegalStateException | BaseAdvisor.java:71 | 丢失原始异常类型 |
| 7 | Map.copyOf() 拒绝 null value,但 context 允许 null | ChatModelCallAdvisor.java:65 | 潜在 NPE |
| 8 | SimpleLoggerAdvisor 默认 order 0,无法事后修改 | SimpleLoggerAdvisor.java:56 | 位置不可控 |
| 9 | MessageChatMemoryAdvisor.after() 写入所有 Generation | 第 137-145 行 | n>1 时记忆污染 |
| 10 | MessageChatMemoryAdvisor.before() 在调用前写记忆 | 第 105-107 行 | 失败时记忆不一致 |
| 11 | purgeExcess 全裁完返回空列表 | LastMaxTokenSizeContentPurger.java:59-61 | 静默发空 prompt |
| 12 | 复制粘贴导致的变量名 remainingStreamAdvisors | DefaultAroundAdvisorChain.java:189 | 误导阅读 |
5.11.2 本章发现的"改名化石"
延续前几章的统计:
| # | 位置 | 化石 | 现实 |
|---|---|---|---|
| 1 | DefaultAroundAdvisorChain.java:251,260 | 变量名 callAroundAdvisorList / streamAroundAdvisorList | 接口已叫 CallAdvisor |
| 2 | DefaultAdvisorObservationConvention.java:65 | .replace("_around_advisor", "") | 已无 XxxAroundAdvisor |
| 3 | ToolCallAdvisor.java(整个类) | 类存在但全废弃 | 实际是 ToolCallingAdvisor |
| 4 | ChatClientAttributes.java:38-41 | 两个枚举常量、同一个 key 字符串 | 改名遗留的兼容 |
5.12 本章的思考题
第 1 题:DefaultAroundAdvisorChain 用 Deque.pop() 推进链。假设某人把它改成用 int index 计数器(每次 nextCall 时 this.advisors.get(this.index++))。请分析:这个改动会破坏哪两个具体能力?给出源码位置作为依据。
第 2 题:BaseAdvisor.adviseStream 的 after() 只在 onFinishReason() 为真时触发。MessageChatMemoryAdvisor 通过 override 解决了这个问题。请说出:还有哪些内置 advisor 有同样风险?其中哪些确实有问题、哪些无所谓?依据是什么?
第 3 题:ChatClientMessageAggregator 让所有 chunk 共享同一个 context Map。假设你在流式链里有一个 advisor,它在 before() 里 context.put("roundMarker", System.nanoTime())。请描述:聚合完成时,你从 aggregatedResponse.context().get("roundMarker") 拿到的是什么?为什么?如果要拿到"最后一个 chunk 的值",该怎么改?
第 4 题:DefaultChatClient.buildAdvisorChain() 会向 this.advisors 里 add 一个 ToolCallingAdvisor。而 this.advisors 是 DefaultChatClientRequestSpec 的字段。请分析:在什么操作序列下会触发 validateSingleToolAdvisor() 抛异常?给出最小的复现代码。
第 5 题:autoRegisterToolCallingAdvisor() 里判定 hasDownstreamMemoryAdvisor 用的是 a.getOrder() > configuredOrder。请分析:如果你把 MessageChatMemoryAdvisor 的 order 从默认的 MIN+200 改成 MIN+400,会发生什么?conversationHistoryEnabled 变成什么?doGetNextInstructionsForToolCall 的行为如何变化?最终用户能观察到什么现象?
第 6 题:SafeGuardAdvisor 短路时返回的伪造响应没有 finishReason(§第 06 章 §6.2.3)。请追踪这个响应在链上的完整传播路径:如果外层有一个 MessageChatMemoryAdvisor,它的 after() 会被调用吗?为什么?这个行为在什么场景下是好的、什么场景下是坏的?
第 7 题:MessageAggregator 对 usage 采取"取最后一个非 0 值"策略(第 126-132 行)。假设某个供应商在每个 chunk 都返回增量 usage(第 1 个 chunk completionTokens=1,第 2 个 =1,……)。请计算:聚合后的 usage 是多少?真实值是多少?如果要同时支持"全量型"和"增量型"两种供应商,你会怎么改(给出算法思路即可)?
第 8 题:ChatModelCallAdvisor 的 getName() 返回 "call",而 MessageChatMemoryAdvisor 的返回 "MessageChatMemoryAdvisor"。经过 DefaultAdvisorObservationConvention.getContextualName() 处理后,两者的 span contextualName 分别是什么?为什么后者要保留 "call" 这种短名?如果让你统一,你会怎么设计?
5.13 关键文件索引
spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/
├── ChatClientRequest.java (90 行,record,context 可变)
├── ChatClientResponse.java (86 行,record,chatResponse @Nullable)
├── ChatClientAttributes.java (55 行,枚举,两常量同 key)
├── ChatClientMessageAggregator.java (57 行,context 共享陷阱 ★)
├── DefaultChatClient.java (~1250 行,buildAdvisorChain 在 1189)
├── DefaultChatClientUtils.java (options 合并)
└── advisor/
├── api/
│ ├── Advisor.java (47 行,Ordered + getName)
│ ├── CallAdvisor.java (33 行)
│ ├── StreamAdvisor.java (35 行)
│ ├── AdvisorChain.java (33 行,仅 getObservationRegistry)
│ ├── CallAdvisorChain.java (51 行,nextCall/getCallAdvisors/copy)
│ ├── StreamAdvisorChain.java (57 行)
│ ├── BaseAdvisorChain.java (95 行,Builder + mutate)
│ ├── BaseAdvisor.java (107 行,before/after ★)
│ ├── BaseChatMemoryAdvisor.java (~50 行,getConversationId)
│ ├── MemoryAdvisor.java (标记接口,@since 2.0.0)
│ └── ToolAdvisor.java (标记接口,@since 2.0.0)
├── DefaultAroundAdvisorChain.java (296 行 ★★ 栈式核心)
├── AdvisorUtils.java (46 行,onFinishReason 谓词)
├── ChatModelCallAdvisor.java (126 行,终结 + format 注入)
├── ChatModelStreamAdvisor.java (~100 行,终结,无 format 注入)
├── SimpleLoggerAdvisor.java (154 行)
├── SafeGuardAdvisor.java (149 行)
├── MessageChatMemoryAdvisor.java (~200 行 ★)
├── StructuredOutputValidationAdvisor.java(~330 行)
├── ToolCallingAdvisor.java (~640 行 ★★ 递归循环)
├── ToolCallAdvisor.java (61 行,全废弃)
├── UsageAccumulator.java (~200 行)
└── observation/
├── AdvisorObservationContext.java
├── AdvisorObservationConvention.java
├── AdvisorObservationDocumentation.java
└── DefaultAdvisorObservationConvention.java
advisors/spring-ai-vector-store-advisor/.../vectorstore/
├── QuestionAnswerAdvisor.java (BaseAdvisor,默认模板)
└── VectorStoreChatMemoryAdvisor.java (BaseChatMemoryAdvisor,XML 转义)
advisors/spring-ai-tool-search-advisor/.../toolsearch/
└── ToolSearchToolCallingAdvisor.java (extends ToolCallingAdvisor)
vector-stores/spring-ai-redis-semantic-cache/.../semantic/
└── SemanticCacheAdvisor.java (implements BaseChatMemoryAdvisor)
spring-ai-model/src/main/java/org/springframework/ai/chat/model/
└── MessageAggregator.java (~210 行 ★ 13 个 AtomicReference)
5.14 可执行的复核命令
在本仓库根目录执行,可逐条验证本章的所有结论。
# 1. 验证 1.x 的 Around 系列类在 2.0.1 已不存在(应全部无输出)
find . -name "CallAroundAdvisor.java"
find . -name "StreamAroundAdvisor.java"
find . -name "AroundAdvisorChain.java"
find . -name "CallAroundAdvisorChain.java"
# 2. 验证真实的接口名
ls spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/
# 应看到:Advisor CallAdvisor StreamAdvisor AdvisorChain CallAdvisorChain
# StreamAdvisorChain BaseAdvisorChain BaseAdvisor BaseChatMemoryAdvisor
# MemoryAdvisor ToolAdvisor
# 3. 验证栈式执行:OrderComparator.sort 升序 + addLast + pop
sed -n '277,287p' spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java
sed -n '105p' spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java
# 4. 验证链的一次性:original 快照
sed -n '87,88p' spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java
sed -n '197,205p' spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java
# 5. 验证 order 档位
grep -n "HIGHEST_PRECEDENCE + 200" spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/Advisor.java
grep -n "DEFAULT_ORDER = Ordered.HIGHEST_PRECEDENCE" spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ToolCallingAdvisor.java
grep -n "Ordered.LOWEST_PRECEDENCE" spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelCallAdvisor.java
# 6. 验证自动注册与唯一性校验
sed -n '1189,1240p' spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java
# 7. 验证 context 是可变共享(构造器无 Map.copyOf)
sed -n '36,50p' spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientRequest.java
# 8. 验证流式 context 共享同一个 Map
sed -n '43,54p' spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientMessageAggregator.java
# 9. 验证 BaseAdvisor 的 after 依赖 finishReason
sed -n '61,72p' spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/BaseAdvisor.java
# 10. 验证流式不做 format 注入(对比非流式)
grep -n "augmentWithFormatInstructions" \
spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelCallAdvisor.java \
spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelStreamAdvisor.java
# 只应在 ChatModelCallAdvisor 中命中
# 11. 验证两个枚举常量共享同一个 key 字符串
sed -n '29,41p' spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientAttributes.java
# 12. 验证全库 advisor 实现类只有 13 个
grep -rln "implements BaseAdvisor\|implements CallAdvisor\|implements StreamAdvisor\|extends BaseAdvisor\|implements BaseChatMemoryAdvisor\|implements ToolAdvisor\|implements MemoryAdvisor\|extends ToolCallingAdvisor" \
--include=*.java . | grep -v "/test/" | grep -v "/target/" | wc -l
# 13. 验证 usage 采用"最后非 0 值"而非累加
sed -n '124,132p' spring-ai-model/src/main/java/org/springframework/ai/chat/model/MessageAggregator.java
# 14. 验证 @since 2.0.0 的两个标记接口
grep -n "@since 2.0.0" spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/MemoryAdvisor.java
grep -n "@since 2.0.0" spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/ToolAdvisor.java
# 15. 验证改名化石
grep -rn "callAroundAdvisorList\|streamAroundAdvisorList" --include=*.java .
grep -rn "_around_advisor" --include=*.java .
本章讲的是架构:接口怎么分层、链为什么是栈、order 怎么决定嵌套、context 怎么流动。这些都是"元规则",无论你用什么 advisor 都逃不开。
但架构只有落到具体实现上才有意义。第 06 章会把 2.0.1 里全部 13 个内置 advisor 逐个剖开——从 154 行的 SimpleLoggerAdvisor 到 640 行的 ToolCallingAdvisor,看它们各自怎么用(或绕过)本章讲的这些规则,以及各自埋了什么坑。
其中 RetrievalAugmentationAdvisor(spring-ai-rag)因为内部还有一条完整的 RAG 流水线,复杂度超过其余所有 advisor 之和,会单独放到第 07 章。