跳到主要内容

数据截至 (上游 commit c988e72ab728)

Advisor 责任链:Spring AI 的拦截器模型

30 秒导读: 你在 ChatClient 上写的那句 .advisors(...),最终变成一条洋葱式责任链:请求从外往里被一层层改写,走到最里层才真正打给模型,响应再从里往外被一层层加工。这一章讲清楚谁能改什么、按什么顺序改、以及链本身是怎么被消费掉的

本章源码路径约定: 下文写成 advisor/Xxx.java:NN 的引用,完整前缀是 spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/; 写成 DefaultChatClient.java:NN 的同理。文末「代码地图」给全路径。

上一章 ChatClient:从流式 API 到一个 ChatClientRequest 讲的是请求怎么攒出来;这一章接着讲攒好的请求在飞向模型的路上被谁动了手脚


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

  • 一句话定义: Advisor 就是 Spring AI 版的拦截器 / 中间件——每个 advisor 都能在「请求发给模型之前」和「响应回来之后」插一脚。

  • 它解决什么问题: 给模型发请求这件事,从来不只是"把用户那句话发出去"。真实业务里你还要:把历史对话拼进去、把检索到的文档塞进去、打日志、拦敏感词、校验返回的 JSON、跑工具调用循环。这些关切互相正交,塞进一个方法里会变成一坨。

  • 思路: 每个关切写成一个独立的 advisor,串成一条链;顺序用一个整数 order 决定。

  • 一句话直觉: 把它当成 Servlet Filter / Express middleware / Python 装饰器——洋葱模型:外层先看到请求,最后才看到响应。

用起来什么样(示意,非源码;真实 API 见第 1 章):

ChatResponse response = chatClient.prompt()
.advisors(
new SimpleLoggerAdvisor(), // 最外层:打日志
MessageChatMemoryAdvisor.builder(memory).build() // 里一层:拼历史对话
)
.user("上一个问题我问的是什么?")
.call()
.chatResponse();

你没写的那一层是框架自己加的:链的最里层永远是真正调模型的那颗 advisor


2. 顶层全景:一次调用是怎么走的

三个角色,记住它们就够读懂全章:

角色干什么关键类型
Advisor洋葱的一层皮,能改请求、改响应、也能直接短路CallAdvisor / StreamAdvisor
AdvisorChain拿着"还没跑的 advisor 队列",提供 nextCall 让当前层往里走DefaultAroundAdvisorChain
终结者 Advisor链尾那颗,不再调 nextCall,而是真的打模型ChatModelCallAdvisor

怎么读这张图: 从上往下是请求方向(before),从下往上是响应方向(after);缩进越深越靠近模型。

ChatClient.call()

▼ advisorChain.nextCall(request)
┌─────────────────────────────────────────────┐
│ ① SimpleLoggerAdvisor order 越小越外 │
│ ┌───────────────────────────────────────┐ │
│ │ ② MessageChatMemoryAdvisor 拼历史 │ │
│ │ ┌─────────────────────────────────┐ │ │
│ │ │ ③ ToolCallingAdvisor 工具循环 │ │ │
│ │ │ ┌───────────────────────────┐ │ │ │
│ │ │ │ ④ ChatModelCallAdvisor │ │ │ │
│ │ │ │ ← 链尾,真的打模型 │ │ │ │
│ │ │ └───────────────────────────┘ │ │ │
│ │ └─────────────────────────────────┘ │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────┘

▼ ChatClientResponse(从里往外逐层被 after 加工)

一句话主线: 每层拿到 request → 想改就改 → 调 chain.nextCall(改过的 request) → 拿到 response → 想改就改 → 返回给外层。哪层不调 nextCall,请求就在那一层被短路,永远到不了模型。


3. 接口分层:五个接口 + 两个标记

advisor/api/ 目录里一共七个对外类型,先看全貌再逐个看。

怎么读这张图: 箭头是"继承 / 扩展",左边是被继承的一方。

Ordered (Spring 核心,只有 getOrder())

Advisor ── getName() + order 常量

┌─────┴───────┬──────────────┬──────────────┐
▼ ▼ ▼ ▼
CallAdvisor StreamAdvisor MemoryAdvisor ToolAdvisor
adviseCall adviseStream (空·标记) (空·标记)
└──────┬──────┘

BaseAdvisor ── 给你写好 adviseCall/adviseStream,
你只填 before() / after()

3.1 Advisor:只有名字和顺序

它继承 Spring 的 Ordered,所以每个 advisor 必须有一个 int getOrder();再加一个 getName() 用于日志和观测(advisor/api/Advisor.java:31-45,符号 Advisor)。

它还挂了一个共享常量:

int DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER = Ordered.HIGHEST_PRECEDENCE + 200;

出处 advisor/api/Advisor.java:39,符号 DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER。注释写得很直白:memory advisor 要排在 ToolCallingAdvisor 外面,这样它能包住整个工具循环。

3.2 CallAdvisor / StreamAdvisor:同步与流式两套签名

两个接口各只有一个方法,签名对称:

接口方法返回文件
CallAdvisoradviseCall(ChatClientRequest, CallAdvisorChain)ChatClientResponseadvisor/api/CallAdvisor.java:30
StreamAdvisoradviseStream(ChatClientRequest, StreamAdvisorChain)Flux<ChatClientResponse>advisor/api/StreamAdvisor.java:32

为什么拆成两个而不是一个泛型接口: 因为流式的返回是 Flux,"after 阶段"这个概念在流上根本不成立(响应是一串 chunk,不是一个值)。拆开后,只支持同步的 advisor 可以只实现 CallAdvisor——StructuredOutputValidationAdvisor 就是这么干的(见 §7)。

3.3 AdvisorChain 三兄弟:往里走的把手

链接口也是对称的三层:

接口提供什么文件
AdvisorChain只有一个默认 getObservationRegistry(),默认 NOOPadvisor/api/AdvisorChain.java:28-32
CallAdvisorChainnextCall()getCallAdvisors()copy(CallAdvisor after)advisor/api/CallAdvisorChain.java:33-55
StreamAdvisorChainnextStream()getStreamAdvisors()copy(StreamAdvisor after)advisor/api/StreamAdvisorChain.java:35-58
BaseAdvisorChain同时继承上面两个,提供 builder()mutate()advisor/api/BaseAdvisorChain.java:33-57

copy(after) 是整套设计里最关键、也最不显然的一个方法——它单独放在 §5 讲。

3.4 BaseAdvisor:把 before/after 模板写死

绝大多数 advisor 的形状是一样的:"改请求 → 往里走 → 改响应"。BaseAdvisor 直接把这个模板实现掉,子类只需要填两个方法:

ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain);
ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain);

出处 advisor/api/BaseAdvisor.java:84-89。同步分支的默认实现只有三行,一眼能看懂三明治结构:

ChatClientRequest processedChatClientRequest = before(chatClientRequest, callAdvisorChain);
ChatClientResponse chatClientResponse = callAdvisorChain.nextCall(processedChatClientRequest);
return after(chatClientResponse, callAdvisorChain);

出处 advisor/api/BaseAdvisor.java:51-53,符号 BaseAdvisor#adviseCall

流式分支要复杂一些,而且藏着一个坑——留到 §8 单独说。

3.5 两个标记接口:MemoryAdvisorToolAdvisor

这两个接口方法体是空的advisor/api/MemoryAdvisor.java:33advisor/api/ToolAdvisor.java:33),它们不提供能力,只提供身份声明

标记接口声明"我负责……"谁在读这个标记
MemoryAdvisor对话记忆的存取生命周期DefaultChatClient 判断要不要自动注册、以及工具循环要不要自己管历史
ToolAdvisor工具调用循环的生命周期DefaultChatClient 判断要不要自动注册、以及是否出现重复

它们存在的唯一理由是让组装阶段能做决策,具体两条决策见 §6。


4. DefaultAroundAdvisorChain:链是怎么被消费的

这是唯一的链实现(advisor/DefaultAroundAdvisorChain.java:59,符号 DefaultAroundAdvisorChain)。理解它只要抓住一件事:

链是一个被 pop() 掉的队列,不是一个索引游标。

4.1 核心数据结构:两个 Deque + 两份原始快照

private final List<CallAdvisor> originalCallAdvisors; // 只读快照,给 copy() 用
private final List<StreamAdvisor> originalStreamAdvisors;
private final Deque<CallAdvisor> callAdvisors; // 会被 pop 空的工作队列
private final Deque<StreamAdvisor> streamAdvisors;

出处 advisor/DefaultAroundAdvisorChain.java:65-71。构造函数里用 List.copyOf(...) 拍下不可变快照(:87-88)。

为什么要两份: 工作队列 callAdvisors 会被消费到空;而 copy(after) 需要知道"完整的原始顺序"才能截出子链。

4.2 nextCall:pop 一个,跑一个

原理演示(示意,非源码):

def next_call(request):
if not queue: # 队列空 = 没人能处理了
raise IllegalState("No CallAdvisors available to execute")
advisor = queue.popleft() # 单次消费:弹出后队列里就没它了
return advisor.advise_call(request, self) # 把 self 传下去,它再 pop 下一个

重点看两处:弹出即消费,以及self(同一个 chain 对象)传给 advisor——所以第 N 层调 nextCall 时,弹出的自然就是第 N+1 层。递归靠队列状态推进,不需要索引。

真实实现在 advisor/DefaultAroundAdvisorChain.java:98-121(符号 nextCall):

var advisor = this.callAdvisors.pop();

出处 advisor/DefaultAroundAdvisorChain.java:105

这带来一个硬约束:一个 chain 实例只能走一遍。 好在 DefaultChatClient 每次 call() / stream() 都新建一条链(DefaultChatClient.java:1176-1187),所以跨请求安全;同一次请求内想再走一遍,就必须用 copy()(§5)。

4.3 排序:pushAll 之后立刻 reOrder

Builder.pushAll 做三件事(advisor/DefaultAroundAdvisorChain.java:247-272,符号 Builder#pushAll):

  1. 按类型分流——实现了 CallAdvisor 的进 call 队列,实现了 StreamAdvisor 的进 stream 队列;同时实现两个的,两个队列各进一份
  2. 逐个 pushConcurrentLinkedDeque
  3. reOrder()

reOrder() 的写法很朴素——倒进 ArrayListOrderComparator.sort、清空、再 addLast 回去(advisor/DefaultAroundAdvisorChain.java:277-287,符号 reOrder):

ArrayList<CallAdvisor> callAdvisors = new ArrayList<>(this.callAdvisors);
OrderComparator.sort(callAdvisors);
this.callAdvisors.clear();
callAdvisors.forEach(this.callAdvisors::addLast);

推导出的顺序语义: OrderComparator 升序排 → 小 order 排在 ArrayList 前面 → addLast 后位于 Deque 队头 → pop() 先取到。

记住这一句:order 越小 = 越靠外 = 越早看到请求、越晚看到响应。

因为每次 pushAll 结束都会重排,push 的先后顺序不影响最终链序——只有 order 说了算。

4.4 每一层都被包进一个 observation

nextCall 弹出 advisor 后,不是直接调用,而是先建一个 AdvisorObservationContext(带 advisor 名字和 order),再用 AdvisorObservationDocumentation.AI_ADVISOR 把调用包起来(advisor/DefaultAroundAdvisorChain.java:107-120):

var observationContext = AdvisorObservationContext.builder()
.advisorName(advisor.getName())
.chatClientRequest(chatClientRequest)
.order(advisor.getOrder())
.build();

结果就是:链的嵌套结构天然变成 trace 里的嵌套 span,你在 APM 上能直接看到"哪一层花了多久"。可观测性的完整栈见 落到 Spring Boot:starter、可观测性、MCP 与工具检索

4.5 流式分支的额外功课:openScope() 修 span 父子关系

nextStreamadvisor/DefaultAroundAdvisorChain.java:124-166)不能照抄同步版本,因为响应式流的执行线程和发起线程不是同一个,ThreadLocal 里的"当前 observation"完全不可信。

它的解法分三步:

① 从 Reactor Context 里取父 observation(不是从 ThreadLocal 取)
contextView.getOrDefault(ObservationThreadLocalAccessor.KEY, null)


② 临时把父 observation 打开成 scope,只为了在这一瞬间 start() 本层
try (Scope ignored = parentObservation.openScope()) { observation.start(); }


③ 把本层 observation 写回下游的 Reactor Context,给更里层当父亲
.contextWrite(ctx -> ctx.put(ObservationThreadLocalAccessor.KEY, observation))

对应源码 advisor/DefaultAroundAdvisorChain.java:143-161

第 ② 步为什么必须有: 源码注释交代了动机——Micrometer tracing 推导 span 的父亲时会看"当前线程上开着的 scope"。不加这一手,本层 span 可能被挂到当地线程上恰好开着的那个无关 span(注释举的例子是 servlet 的 HTTP span)下面,trace 树就长歪了。

流式还多一步收尾:用 ChatClientMessageAggregator 把整条 Flux 聚合成一个完整响应,回填到 observation context 里(advisor/DefaultAroundAdvisorChain.java:163-164),否则 observation 里只有碎片 chunk。


5. copy(after):截出"我之后的那段子链"

这是全章最值得学走的一招

5.1 它要解决的问题

ToolCallingAdvisor 要跑一个循环:调模型 → 模型说要调工具 → 执行工具 → 把结果拼回去 → 再调一次模型 → ……

问题来了:它想"再调一次模型",手上只有 callAdvisorChain。可是(a)队列已经被 pop 到它后面了,(b)就算能重置,从头再走一遍会再次经过它自己,变成无限自我递归。

5.2 解法:按原始快照,截出下半段

原始链(快照 originalCallAdvisors):
[ Logger ] [ Memory ] [ ToolCalling ] [ ChatModelCall ]
↑ this
copy(this) 返回:
[ ChatModelCall ] ← 全新的 chain 实例
(不含自己、不含外层)

ToolCallingAdvisor 于是可以在自己的 do-while 里,每一轮都 copy(this) 一条干净的子链,反复往模型打,而外层的 Logger / Memory 只会被穿过一次。

真实用法:

  • 同步:chatClientResponse = callAdvisorChain.copy(this).nextCall(processedChatClientRequest);advisor/ToolCallingAdvisor.java:167
  • 流式:StreamAdvisorChain chainCopy = streamAdvisorChain.copy(this);advisor/ToolCallingAdvisor.java:293,注释直接写着 "Get a copy of the chain excluding this advisor")

工具循环本身的细节见 工具调用:从 @Tool 注解到多轮循环

5.3 实现:一次 indexOf + 一次 subList

同步和流式共用一个私有方法 copyAdvisorsAfteradvisor/DefaultAroundAdvisorChain.java:178-195):

int afterAdvisorIndex = advisors.indexOf(after);
if (afterAdvisorIndex < 0) {
throw new IllegalArgumentException("The specified advisor is not part of the chain: " + after.getName());
}
var remainingStreamAdvisors = advisors.subList(afterAdvisorIndex + 1, advisors.size());

传进去的 advisorsgetCallAdvisors() / getStreamAdvisors(),也就是原始快照:198-205)——不是被 pop 过的工作队列。这正是 §4.1 要留两份的原因。

截出来的子链再走一遍 builder().pushAll(...).build(),所以它是一个全新的、队列满的 chain 实例,observation registry 和 convention 都继承自父链。

5.4 顺带一提:mutate()

DefaultAroundAdvisorChain.mutate():213-219)把 call 和 stream 两份快照并成一个 LinkedHashSet 去重,返回一个预填好的 Builder,方便在既有链上增删后重建。BaseAdvisorChain.mutate() 的默认实现直接抛 UnsupportedOperationExceptionadvisor/api/BaseAdvisorChain.java:45-47),源码里留着 TODO 说等所有实现都覆盖后改成抽象方法。


6. 链是怎么被组装出来的

组装发生在 DefaultChatClient.DefaultChatClientRequestSpec#buildAdvisorChainDefaultChatClient.java:1189-1203)。五行代码,做三件事:

buildAdvisorChain()

├─ ① autoRegisterToolCallingAdvisor() ← 需要时补一颗工具 advisor
├─ ② validateSingleToolAdvisor() ← 校验:最多一颗
└─ ③ 用户 advisors + ChatModelCallAdvisor + ChatModelStreamAdvisor
→ pushAll → build(内部按 order 重排)

第 ③ 步的源码注释说得很清楚——"At the stack bottom add the model call advisors":

List<Advisor> chain = new ArrayList<>(this.advisors);
chain.add(ChatModelCallAdvisor.builder().chatModel(this.chatModel).build());
chain.add(ChatModelStreamAdvisor.builder().chatModel(this.chatModel).build());

出处 DefaultChatClient.java:1195-1197。两颗终结者总是一起加,因为建链时还不知道用户接下来会调 call() 还是 stream()

6.1 校验一:autoRegisterToolCallingAdvisor

四步短路逻辑(DefaultChatClient.java:1217-1238):

步骤判断结果
1advisorParams 里显式关掉了自动注册直接 return,不注册
2链里已经有 ToolAdvisor 标记的 advisor直接 return,不重复注册
3链里有 MemoryAdvisor 且它的 order 大于 工具 advisor 的 order记为"下游有记忆管家"
4——注册一颗 ToolCallingAdvisor,并按第 3 步结果设置 conversationHistoryEnabled

第 3 步的判断是这一段的精华:

boolean hasDownstreamMemoryAdvisor = this.advisors.stream()
.anyMatch(a -> a instanceof MemoryAdvisor && a.getOrder() > configuredOrder);

出处 DefaultChatClient.java:1232-1233。回忆 §4.3 的顺序语义:order 更大 = 更靠里 = 在工具循环内部。既然每一轮工具调用都会穿过那个 memory advisor,它自然会把中间轮次的消息记下来,那么 ToolCallingAdvisor 就没必要再自己维护一份中间历史——于是 conversationHistoryEnabled(false):1236)。

方法上的 Javadoc 还交代了一个容易忽略的设计取舍:即使这次调用一个静态工具都没配,也照样注册,因为工具可能被别的 advisor 在运行时动态注入(DefaultChatClient.java:1205-1216)。

6.2 校验二:validateSingleToolAdvisor

一条硬规则:链里最多一颗 ToolAdvisor,超过就 fail-fast(DefaultChatClient.java:1240-1248):

throw new IllegalStateException("At most one ToolAdvisor is allowed in the advisor chain, but found "
+ toolAdvisors.size() + ": [" + names + "]");

出处 DefaultChatClient.java:1245-1246。错误信息里会把每颗的 nameorder 都列出来,方便定位。为什么必须唯一: 两颗工具 advisor 会各自跑一个循环、各自执行同一批 tool call,产生重复副作用。


7. 链尾的终结者与内置 advisor

7.1 ChatModelCallAdvisor / ChatModelStreamAdvisor

两颗都是 final class,都只有 Ordered.LOWEST_PRECEDENCEInteger.MAX_VALUE)这一个 order 值(advisor/ChatModelCallAdvisor.java:109-111advisor/ChatModelStreamAdvisor.java:67-69),因此永远排在最里层。它们的共同特征是:方法体里没有 nextCall / nextStream——链在这里终止,改为真的调 chatModel.call(...) / chatModel.stream(...)

它们的 getName() 分别返回 "call""stream"advisor/ChatModelCallAdvisor.java:104-106advisor/ChatModelStreamAdvisor.java:62-64),这就是你在 trace 里看到的那个 span 名。

7.2 augmentWithFormatInstructions:结构化输出的最后一脚

同步终结者在打模型之前多干一件事(advisor/ChatModelCallAdvisor.java:56 调用,实现在 :66-101,符号 augmentWithFormatInstructions)。它从请求 context 里读两个键,然后三选一:

context 状态动作
OUTPUT_FORMATSTRUCTURED_OUTPUT_SCHEMA 都为空原样返回,什么都不做
打了 STRUCTURED_OUTPUT_NATIVE 标记,且有 schema,且 options 是 StructuredOutputChatOptions原生路径:把 schema 塞进 outputSchema 选项
其余情况兜底路径:把格式说明文本换行追加到 user message 末尾

原生路径的关键两行(advisor/ChatModelCallAdvisor.java:83-84):

var augmentedOptions = structuredOutputChatOptions.mutate().outputSchema(outputSchema).build();
Prompt augmentedPrompt = chatClientRequest.prompt().mutate().chatOptions(augmentedOptions).build();

兜底路径则是拼字符串(advisor/ChatModelCallAdvisor.java:92-95):

Prompt augmentedPrompt = chatClientRequest.prompt()
.augmentUserMessage(userMessage -> userMessage.mutate()
.text(userMessage.getText() + System.lineSeparator() + outputFormat)
.build());

为什么这一步非要放在链的最里层: 因为格式说明必须贴在最终那条 user message 上。如果放在外层,中间任何一个 advisor(比如 RAG 往 user message 里插文档)都可能把它冲掉或挤到中间。放在链尾就没人能再动它了。

一个可核实的不对称: ChatModelStreamAdvisor.adviseStreamadvisor/ChatModelStreamAdvisor.java:49-59没有这段逻辑——它直接 chatModel.stream(chatClientRequest.prompt())。结构化输出在两条路径上的完整差异,见 可移植模型层:一套抽象罩住十几家供应商

7.3 内置 advisor 一览与 order 取值

Ordered.HIGHEST_PRECEDENCE = Integer.MIN_VALUELOWEST_PRECEDENCE = Integer.MAX_VALUE。下表按"从外到内"排:

Advisor默认 order实现的接口一句话职责定义处
MessageChatMemoryAdvisorHIGHEST_PRECEDENCE + 200BaseChatMemoryAdvisor(= BaseAdvisor + MemoryAdvisorbefore 里把历史消息拼进 prompt、把新 user message 写回记忆advisor/MessageChatMemoryAdvisor.java:171
ToolCallingAdvisorHIGHEST_PRECEDENCE + 300CallAdvisor, StreamAdvisor, ToolAdvisor驱动多轮工具调用循环advisor/ToolCallingAdvisor.java:75
SimpleLoggerAdvisor0CallAdvisor, StreamAdvisorDEBUG 级别打请求和响应advisor/SimpleLoggerAdvisor.java:130
SafeGuardAdvisor0CallAdvisor, StreamAdvisor命中敏感词就短路,直接返回固定文案advisor/SafeGuardAdvisor.java:52
StructuredOutputValidationAdvisorLOWEST_PRECEDENCE - 2000CallAdvisor, StreamAdvisor用 JSON Schema 校验输出,失败就把错误追加进 user message 重试advisor/StructuredOutputValidationAdvisor.java:251
ChatModelCallAdvisor / ChatModelStreamAdvisorLOWEST_PRECEDENCECallAdvisor / StreamAdvisor链尾终结者,真正打模型advisor/ChatModelCallAdvisor.java:110

四点值得单独拎出来:

  • MessageChatMemoryAdvisorToolCallingAdvisor 小 100,这不是巧合——就是为了让记忆包住工具循环(advisor/api/Advisor.java:34-39 的注释明说了)。它同时覆写了 getScheduler()advisor/MessageChatMemoryAdvisor.java:68-71),因为记忆读写通常是阻塞 IO。

  • SimpleLoggerAdvisorSafeGuardAdvisor 都默认 0,也就是排在两颗 HIGHEST_PRECEDENCE + N 的内置 advisor 里面、结构化校验 外面。两者的 order 都能通过 Builder 改。

  • SafeGuardAdvisor 是"短路"的教科书例子:命中敏感词就不调 nextCall,直接造一个 ChatClientResponse 返回(advisor/SafeGuardAdvisor.java:83-90),一个 token 都不会花。

  • StructuredOutputValidationAdvisor 只支持同步:它实现了 StreamAdvisor,但 adviseStream 直接返回 Flux.error(new UnsupportedOperationException(...))advisor/StructuredOutputValidationAdvisor.java:218-223)。它的构造函数还会断言 order 严格落在最高与最低优先级之间(:80-81)。

关于 LastMaxTokenSizeContentPurger不是 advisor——public class LastMaxTokenSizeContentPurger 既不实现 Advisor,也没有 getOrder()advisor/LastMaxTokenSizeContentPurger.java:34)。它是一个纯工具类:拿 TokenCountEstimator 从列表头部丢弃内容,直到总 token 数落到 maxTokenSize 以下(符号 purgeExcess:46-64)。 它虽然放在 advisor 包下,但在本 commit 的整个仓库里没有任何调用方(全仓 grep LastMaxTokenSizeContentPurger 只命中它自己的定义文件)——是留给用户自己组装截断型 advisor 的零件。

RAG 与向量存储相关的 advisor(spring-ai-ragadvisors/spring-ai-vector-store-advisor)不在本章范围,见 记忆、RAG 与向量存储:把企业数据接进模型


8. 坑点:流式的 afteronFinishReason 决定

这是 BaseAdvisor 里最容易写出 bug 的地方。

8.1 流式版本长这样

Flux<ChatClientResponse> chatClientResponseFlux = Mono.just(chatClientRequest)
.publishOn(getScheduler())
.map(request -> this.before(request, streamAdvisorChain))
.flatMapMany(streamAdvisorChain::nextStream);

return chatClientResponseFlux.map(response -> {
if (AdvisorUtils.onFinishReason().test(response)) {
response = after(response, streamAdvisorChain);
}
return response;
}).onErrorResume(error -> Flux.error(new IllegalStateException("Stream processing failed", error)));

出处 advisor/api/BaseAdvisor.java:63-73,符号 BaseAdvisor#adviseStream

8.2 判定条件到底判了什么

AdvisorUtils.onFinishReason() 返回的 Predicate 只做一件事:响应里任意一个 result 的 metadata 带非空 finishReason,就返回 trueadvisor/AdvisorUtils.java:40-49,符号 onFinishReason):

return chatResponse != null && chatResponse.getResults() != null
&& chatResponse.getResults().stream()
.anyMatch(result -> result != null && result.getMetadata() != null
&& StringUtils.hasText(result.getMetadata().getFinishReason()));

8.3 由此推出的三条坑

触发条件后果
after 可能一次都不跑供应商的流式响应从不填 finishReason你的收尾逻辑(写记忆、落库)静默丢失,没有任何报错
after 可能跑多次一次请求返回多个候选(n>1),或多个 chunk 都带 finishReason记忆被写重、副作用被执行多次
after 拿到的不是完整响应它拿到的是那个 chunk,不是聚合后的全文想看完整回答得自己聚合(参考 ChatClientMessageAggregatorChatClientMessageAggregator.java:40

对照同步版本adviseCallafter 一定且只跑一次,拿到的一定是完整响应。同一个 advisor 的 after 在两条路径上的语义并不等价——写 advisor 时必须为流式单独想一遍。

8.4 顺带两个流式细节

  • before 跑在 boundedElasticpublishOn(getScheduler()),默认 Schedulers.boundedElastic()advisor/api/BaseAdvisor.java:44:64)。所以在 before 里写阻塞 IO(查数据库、查向量库)是被允许的,不会掐死事件循环线程。

  • 异常会被换类型onErrorResume 把任何下游异常统一包成 IllegalStateException("Stream processing failed", error)advisor/api/BaseAdvisor.java:73)。想按异常类型做重试的调用方,得记得拆 getCause()


9. 巧妙之处(可以借鉴的三招)

  1. 用一个 int 而不是"注册顺序"来定链序。 pushAll 每次都重排(advisor/DefaultAroundAdvisorChain.java:269),于是"自动注册的 advisor 插在哪"这个难题被消掉了——框架只管往里丢,order 自己会把它落到正确的位置。

  2. "工作队列 + 原始快照"双结构。 工作队列让递归写起来只有一行 pop();原始快照让 copy(after) 能在任意时刻精确截断(advisor/DefaultAroundAdvisorChain.java:65-71:178-195)。少了任何一半,工具循环都实现不了。

  3. 空标记接口做组装期决策。 MemoryAdvisor / ToolAdvisor 一个方法都没有,却让 DefaultChatClient 能在建链之前判断"要不要补一颗""是不是重复了""下游有没有人管历史"(DefaultChatClient.java:1217-1248)。比反射扫类名或者靠配置声明都干净。


10. 边界与局限(诚实清单)

  • chain 实例一次性。 pop() 消费掉就没了;跨请求复用同一个 chain 会直接撞上 IllegalStateException("No CallAdvisors available to execute")advisor/DefaultAroundAdvisorChain.java:101-103)。

  • call 与 stream 是两条独立的队列。 一个只实现 CallAdvisor 的 advisor 在流式调用里完全不存在——不会报错,只是静默不生效。

  • copy(after) 要求 after 在原始链里。 不在就抛 IllegalArgumentExceptionadvisor/DefaultAroundAdvisorChain.java:185-187)。子链被截掉的外层 advisor,在后续轮次里不会再被穿过——这是设计意图,但也意味着"每轮都要重新记日志"这类需求得把 advisor 放到 ToolCallingAdvisor 里面。

  • order 相同的 advisor 之间顺序不保证。 OrderComparator.sort 是稳定排序,实际相对次序取决于它们进 Deque 时的位置,代码里没有对此做任何承诺(inferred)。SimpleLoggerAdvisorSafeGuardAdvisor 默认都是 0,同时用要显式设 order。

  • BaseAdvisorChain.mutate() 默认抛异常。 只有 DefaultAroundAdvisorChain 覆写了(advisor/api/BaseAdvisorChain.java:44-47 留着 TODO)。


11. 代码地图(导航索引)

路径相对克隆根;CC/ = spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/

主题文件路径符号名
根接口 + 记忆默认 orderCC/advisor/api/Advisor.javaAdvisorDEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER
同步 / 流式 advisor 契约CC/advisor/api/CallAdvisor.javaCC/advisor/api/StreamAdvisor.javaadviseCalladviseStream
before/after 模板CC/advisor/api/BaseAdvisor.javaBaseAdvisor#adviseCallBaseAdvisor#adviseStreambeforeaftergetScheduler
链契约 + 子链截取CC/advisor/api/CallAdvisorChain.javaCC/advisor/api/StreamAdvisorChain.javanextCallnextStreamcopy
链的 Builder 入口CC/advisor/api/BaseAdvisorChain.javaBaseAdvisorChainBuilder#pushAllmutate
标记接口CC/advisor/api/MemoryAdvisor.javaCC/advisor/api/ToolAdvisor.javaMemoryAdvisorToolAdvisor
链的唯一实现CC/advisor/DefaultAroundAdvisorChain.javanextCallnextStreamcopyAdvisorsAfterBuilder#pushAllreOrdermutate
流式 after 触发条件CC/advisor/AdvisorUtils.javaonFinishReason
链尾终结者(同步)CC/advisor/ChatModelCallAdvisor.javaChatModelCallAdvisoraugmentWithFormatInstructions
链尾终结者(流式)CC/advisor/ChatModelStreamAdvisor.javaChatModelStreamAdvisor#adviseStream
链的组装与两条校验CC/DefaultChatClient.javabuildAdvisorChainautoRegisterToolCallingAdvisorvalidateSingleToolAdvisor
短路型 advisor 范例CC/advisor/SafeGuardAdvisor.javaSafeGuardAdvisor#adviseCallcreateFailureResponse
日志 advisorCC/advisor/SimpleLoggerAdvisor.javalogRequestlogResponse
记忆 advisor(详见第 5 章)CC/advisor/MessageChatMemoryAdvisor.javaMessageChatMemoryAdvisor#beforeisMemoryAlreadyInPrompt
结构化输出校验重试CC/advisor/StructuredOutputValidationAdvisor.javaStructuredOutputValidationAdvisor#adviseCall
工具循环(详见第 3 章)CC/advisor/ToolCallingAdvisor.javaToolCallingAdvisor#adviseCallDEFAULT_ORDER
非 advisor 的截断零件CC/advisor/LastMaxTokenSizeContentPurger.javapurgeExcess
观测埋点CC/advisor/observation/AdvisorObservationDocumentation.javaCC/advisor/observation/AdvisorObservationContext.javaAI_ADVISORAdvisorObservationContext

下一步该读哪章: