源码小说CodeStory
登录注册
上一章

第五章 · 一排驿丞:Advisor 责任链

下一章
字体

主题

版式

12,866 字 · 约 32 分钟

第五章 · ⼀排驿丞:Advisor 责任链

"我不是终点,我只是把客官往⾥再让⼀步——然后等他回来,我再补⼀句话。" ——某个 BaseAdvisor 的⾃⽩上⼀章我们看着前台掌柜(ChatClient)把客官的国书收⻬、铺好,最后喊了⼀声 call() 。可你有没有想过:从掌柜放下笔,到那封国书真正递到番邦翻译官⼿⾥,中间这段路,到底有没有⼈盘查?

有。⽽且不是⼀个⼈,是⼀整排驿丞。

每个驿丞守⼀道关卡。客官进来时,他们⼀个接⼀个把国书翻开看⼀眼——加点料、记个账、查个违禁词、翻翻起居注;等回函从最⾥⾯出来,他们⼜按相反的顺序,⼀个接⼀个补盖回执。这排驿丞,就是 Spring AI 的 Advisor 责任链。

这⼀章我们就钻进这排驿丞的值房,看清三件事:他们是怎么排队的、队是怎么⼀个个"传下去"的、以及那个反直觉到能坑死⼈的 order 规则。

⼀、驿丞的家谱:从 Ordered 到双钩⼦先认⼈。整排驿丞同出⼀⻔,祖宗只有⼀个接⼝:

public interface Advisor extends Ordered {
int DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER = Ordered.HIGHEST_PRECEDENCE + 200;
String getName();
}

spring-ai-client-chat/.../advisor/api/Advisor.java:31-46家谱开篇就埋了两个伏笔。第⼀, Advisor extends Ordered ——每个驿丞天⽣带⼀个站位号( getOrder() ),这个号决定他在队伍⾥站哪。第⼆,接⼝本身只逼你交代⼀件事: getName() ,报个名号。⾄于"你到底要盘查什么",祖宗⼀句没问——那是⼦接⼝的事。

往下分两⽀:

public interface CallAdvisor extends Advisor {
ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain);
}

spring-ai-client-chat/.../advisor/api/CallAdvisor.java:30-34

CallAdvisor 管同步盘查( adviseCall ),它的孪⽣兄弟 StreamAdvisor 管流式盘查( adviseStream ,返回 Flux<ChatClientResponse> )。注意这两个⽅法的

签名:它们都把"链本身"当参数收进来( CallAdvisorChain / StreamAdvisorChain )。这是整个机制的命⻔,我们下⼀节细讲——⼀个驿丞想把客官放进更⾥⾯,得⾃⼰动⼿喊"下⼀个"。

可问题来了:如果你只想写个最朴素的驿丞——进⻔加句话、出⻔记笔账——难道还要同步、流式各写⼀遍 adviseCall / adviseStream ?那也太累了。于是有了第三个⻆⾊,也是全书你最常打交道的那个: BaseAdvisor 。

public interface BaseAdvisor extends CallAdvisor, StreamAdvisor {
Scheduler DEFAULT_SCHEDULER = Schedulers.boundedElastic();

@Override

default ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) {
ChatClientRequest processedChatClientRequest = before(chatClientRequest, callAdvisorChain);
ChatClientResponse chatClientResponse = callAdvisorChain.nextCall(processedChatClientRequest);
return after(chatClientResponse, callAdvisorChain);
}

// ...

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

spring-ai-client-chat/.../advisor/api/BaseAdvisor.java:42-89看那个 adviseCall 的默认实现——它替你把"around 拦截"这件事拆成了三步,⼯整得像⼀⾸五⾔绝句:

1. before(...) :进⻔盘查,你改国书。

2. chain.nextCall(...) :放⾏,往更⾥⾯⼀层送。

3. after(...) :回程盘查,你改回函。

于是,继承 BaseAdvisor 你只需填 before() 和 after() 两个钩⼦,中间那句"放⾏"框架替你写好了。⼀个守卫同时具备了同步与流式两套盘查能⼒——这就是后⾯要夸的爽点,先按下不表。

🦴 ⻣灰级细节: before 和 after 拿到的是 AdvisorChain ,不是 CallAdvisorChain

留意 before/after 的第⼆个参数类型:是⽗接⼝ AdvisorChain ,⽽不是具体的 CallAdvisorChain 或 StreamAdvisorChain 。这是故意的——BaseAdvisor 想让你的 before/after 逻辑同步流式两边复⽤同⼀份代码,所以只给你⼀个两边都有的最⼩公约数接⼝( AdvisorChain 只暴露getObservationRegistry() )。换句话说,在 before ⾥你拿不到 nextCall ,也调不了"下⼀关"——框架不允许你在钩⼦⾥私⾃推进链。推进这件事,只能交给上⾯那个 adviseCall 模板去做。这是⼀道刻意焊死的栏杆,防你越界。

⼆、双 Deque、push、再反转:驿丞是怎么排队的驿丞认完了,接下来看他们怎么列队。这活⼉全在⼀个类⾥: DefaultAroundAdvisorChain 。

打开值房第⼀眼,你会看到它揣着两副队列:

private final Deque<CallAdvisor> callAdvisors;
private final Deque<StreamAdvisor> streamAdvisors;

spring-ai-client-chat/.../advisor/DefaultAroundAdvisorChain.java:69-71为什么是两副?因为同⼀个驿丞,可能既会同步盘查、⼜会流式盘查(⽐如 BaseAdvisor 的⼦类),也可能只会其中⼀种(⽐如终结驿丞 ChatModelCallAdvisor 就只实现了 CallAdvisor )。⼊队时按"你会哪⻔⼿艺"分别归档:

public Builder pushAll(List<? extends Advisor> advisors) {

// ...

List<CallAdvisor> callAroundAdvisorList = advisors.stream()
.filter(a -> a instanceof CallAdvisor)
.map(a -> (CallAdvisor) a)
.toList();
if (!CollectionUtils.isEmpty(callAroundAdvisorList)) {
callAroundAdvisorList.forEach(this.callAdvisors::push);
}

// ...同理把 StreamAdvisor塞进 streamAdvisors ...

this.reOrder();
return this;
}

spring-ai-client-chat/.../advisor/DefaultAroundAdvisorChain.java:247-272⼀个会两⻔⼿艺的驿丞,会同时出现在两副队列⾥;只会⼀⻔的,就只进对应那副。所以那个终结的 ChatModelCallAdvisor 只蹲在 callAdvisors ⾥,流式的ChatModelStreamAdvisor 只蹲在 streamAdvisors ⾥——它俩明明被加进了同⼀份名单,却因为各⾃只会⼀⻔⼿艺,被⾃动分到了两⼝锅,互不串味。这是个安静⽽漂亮的设计。

然后是关键的排队动作。注意上⾯⽤的是 Deque::push —— push 是往队⾸塞,顺序是乱的。真正定序的是紧跟其后的 reOrder() :

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

// streamAdvisors同理

}

spring-ai-client-chat/.../advisor/DefaultAroundAdvisorChain.java:277-287OrderComparator.sort 是 Spring 的⽼相识,升序排列:order 数值⼩的排前⾯。排完⽤ addLast 灌回 Deque,于是队⾸是 order 最⼩的那个。

⽽队列的消费,我们下⼀节会看到是从队⾸ pop() 开始的。两件事⼀拼,结论就出来了——

⚠ 暗礁:order 越⼩,越在外层(反直觉)

直觉会告诉你"优先级⾼ = 重要 = 离模型近"。错。 order 数值越⼩, OrderComparator 把它排得越靠前, pop() 越早把它弹出来,它就越早进⻔、越晚出⻔——也就是站在整排驿丞的最外层。

这意味着: MessageChatMemoryAdvisor (order = HIGHEST_PRECEDENCE + 200 ,⼀个很⼩的负数附近)站得很外; ToolCallingAdvisor (order =

HIGHEST_PRECEDENCE + 300 )⽐它稍内⼀点;⽽真正⼲活的终结驿丞 order = LOWEST_PRECEDENCE ( Integer.MAX_VALUE ),稳稳钉在最⾥。记忆驿丞包着⼯具驿

丞,⼯具驿丞包着模型调⽤——这个包裹关系不是巧合,是 order 数值精⼼算出来的。⼀旦你⾃定义 advisor 时把 order 拍脑袋写⼤写⼩,记忆和⼯具循环的内外关系就会错位,⾏为直接诡异。框架⾃⼰都得为这事打补丁: autoRegisterToolCallingAdvisor ⾥专⻔有⼀段判断"当存在 order 更⼤(更内层)的 MemoryAdvisor 时,关掉⼯具 advisor ⾃⼰的内部历史"( DefaultChatClient.java:1217-1238 )——可⻅这套 order 语义连官⽅都得⼩⼼翼翼地伺候。

三、 pop 与递归:队伍是怎么⼀个个传下去的排好了队,怎么⾛?这是全章最精彩的机关。先看同步那条路:

@Override

public ChatClientResponse nextCall(ChatClientRequest chatClientRequest) {
if (this.callAdvisors.isEmpty()) {
throw new IllegalStateException("No CallAdvisors available to execute");
}
var advisor = this.callAdvisors.pop();

// ...包⼀层 Micrometer observation...

.observe(() -> {
var chatClientResponse = advisor.adviseCall(chatClientRequest, this);
observationContext.setChatClientResponse(chatClientResponse);
return chatClientResponse;
});
}

spring-ai-client-chat/.../advisor/DefaultAroundAdvisorChain.java:97-121把噪⾳擦掉,核⼼就⼀句话: pop() 弹出队⾸驿丞,然后调 advisor.adviseCall(req, this) ——注意最后那个 this ,传进去的还是同⼀个链实例。

这就是整套递归推进的灵魂。想想 BaseAdvisor 的模板:它的 adviseCall ⾥会调 callAdvisorChain.nextCall(...) 。⽽这⾥传给它的

callAdvisorChain 就是 this (同⼀个链)。于是:
1. 链 nextCall → pop 出 1 号驿丞 → 调 1号.adviseCall(req, this) 。
2. 1 号在 before 改完国书,调 this.nextCall(...) → pop 出 2 号 → 调 2号.adviseCall(req, this) 。
3. 2 号⼜调 this.nextCall(...) → pop 出 3 号……

每⼀次"放⾏",都是在同⼀个 Deque 上 pop 掉下⼀个⼈;调⽤栈⼀层层往⾥加深,直到队列⾥最后⼀个——那个 order 最⼤的终结驿丞——它不再调 nextCall ,⽽是直接喊翻译官:

@Override

public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) {
ChatClientRequest formattedChatClientRequest = augmentWithFormatInstructions(chatClientRequest);
ChatResponse chatResponse = this.chatModel.call(formattedChatClientRequest.prompt());
return ChatClientResponse.builder()
.chatResponse(chatResponse)
.context(Map.copyOf(formattedChatClientRequest.context()))
.build();
}

spring-ai-client-chat/.../advisor/ChatModelCallAdvisor.java:52-64ChatModelCallAdvisor 是这排驿丞的最⾥⼀关。它压根没收 callAdvisorChain 来⽤——它不往⾥传了,它就是终点。 this.chatModel.call(prompt) 这⼀句,才是整本书⾥"国书真正递到番邦翻译官⼿⾥"的那个瞬间。它的 order 是 LOWEST_PRECEDENCE ( ChatModelCallAdvisor.java:109-111 ),保证排序后永远垫底,做名副其实的链尾。

然后回函开始往外冒:终结驿丞 return 给 3 号的 after ,3 号 after 完 return 给 2 号的 after ……调⽤栈⼀层层弹回,每个驿丞在回程补上⾃⼰那句话。进去时按 order 从⼩到⼤,出来时⾃动逆序——这正是 around 拦截器最经典的洋葱模型,⽽这⾥它是⽤"递归 + 同⼀个 Deque"天然实现的,没有任何显式的索引游标。

✨ 爽点时刻:⼀套 advisor,同步流式通吃

流式那条 nextStream ⾛的是同⼀套⻣架,只是裹了层 reactor:

@Override

public Flux<ChatClientResponse> nextStream(ChatClientRequest chatClientRequest) {
return Flux.deferContextual(contextView -> {
if (this.streamAdvisors.isEmpty()) {
return Flux.error(new IllegalStateException("No StreamAdvisors available to execute"));
}
var advisor = this.streamAdvisors.pop();

// ...observation⽗⼦ span处理...

Flux<ChatClientResponse> chatClientResponse = Flux.defer(() -> advisor.adviseStream(chatClientRequest, this) ...);
return CHAT_CLIENT_MESSAGE_AGGREGATOR.aggregateChatClientResponse(chatClientResponse, ...);
});
}

spring-ai-client-chat/.../advisor/DefaultAroundAdvisorChain.java:123-166Flux.deferContextual 把"pop 下⼀个驿丞"这件事推迟到真正有⼈订阅时才做——保证响应式的惰性语义不被破坏;同时它还能从 reactor context ⾥捞出⽗observation,把流式的 span ⽗⼦关系接对。⻣架还是那个 pop-递归⻣架,你写⼀个 BaseAdvisor ⼦类, before/after ⼀套逻辑, adviseCall 和adviseStream 两条路同时受益。横切关注点(⽇志、记忆、RAG、安检)在这套机制下彻底插件化、互不耦合——这是 Spring AI 整个 ChatClient 设计⾥最值得拍⼤腿的⼀处。

但这份优雅是有代价的,代价就藏在那个 pop() ⾥。

四、 pop 的代价与 copy 的补救

⚠ 暗礁:链是消费式的,⼀次就耗尽,不可重放

pop() 是真把⼈从队列⾥弹⾛的。 nextCall ⾛⼀遍, callAdvisors 这个 Deque 就空了。你想拿同⼀个 DefaultAroundAdvisorChain 再跑⼀次?⻔都没有

—— nextCall 上来就检查 isEmpty() ,空了直接甩 IllegalStateException("No CallAdvisors available to execute") ( :101-103 )。

那 ChatClient 为什么还能反复 call() ?因为它根本不复⽤链。每次 call()/stream() 都重新 buildAdvisorChain() 造⼀条全新的链( DefaultChatClient.java:1189-1203 )。ChatClient ⾃⼰保持⽆状态、可复⽤、线程安全;⼀次性、易耗尽的脏活全压给临时造出来的链对象。这个分⼯很⼲净,但也提醒你:别试图缓存或共享⼀个 advisor chain 实例,它⽣来就是⽤完即弃的。

可这⾥⽴刻冒出⼀个⽭盾:⼯具调⽤要循环(模型说"我要调差役"→执⾏→把结果回填→再问模型,可能转好⼏圈)。如果链 pop ⼀次就空,⼯具驿丞ToolCallingAdvisor 想在循环⾥反复调"⾃⼰之后的那段链",怎么办?

答案是 copy :

private DefaultAroundAdvisorChain copyAdvisorsAfter(List<? extends Advisor> advisors, Advisor after) {
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());
return DefaultAroundAdvisorChain.builder(this.getObservationRegistry())
.observationConvention(this.observationConvention)
.pushAll(remainingStreamAdvisors)
.build();
}

spring-ai-client-chat/.../advisor/DefaultAroundAdvisorChain.java:178-195

ToolCallingAdvisor 在它的循环⾥每⼀轮都调 callAdvisorChain.copy(this).nextCall(...) ( advisor/ToolCallingAdvisor.java:152 )——

copy(this) 找到"⾃⼰"在链⾥的位置,把⾃⼰之后的那段⼦链复制出⼀条全新的、满⾎的链来,这样既能反复跑,⼜绕开了⾃⼰(否则 copy 出来还含⾃⼰,⼯具驿丞会⽆限递归调⾃⼰)。每转⼀圈复制⼀条新⼦链,转 N 圈就造 N 条临时链。

🦴 ⻣灰级细节: copy 复制的是"原始名单",不是被 pop 空的 Deque

copy 调的是 getCallAdvisors() / getStreamAdvisors() ,⽽这两个⽅法返回的是 originalCallAdvisors / originalStreamAdvisors ——构造函数⾥⽤

List.copyOf(...) 拍下的不可变快照( :87-88 、 :198-205 ),不是那两个会被 pop 掏空的⼯作 Deque。所以哪怕主链已经 pop 到⻅底, copy(this) 依然能从那份原始快照⾥精确切出"我之后"的⼦链。⼀个易耗尽的⼯作队列 + ⼀份只读的原始名单,两者并存——这是让"链消费式推进"和"⼯具循环可重放⼦链"能够共存的那块关键拼图。少了这份快照, copy 根本⽆从下⼿。

五、值房点名:这排驿丞都有谁机制看透了,最后扫⼀眼这排驿丞的花名册,你会更直观地感到 order 排布的⽤意:

驿丞 类型 order ⼲的活 file:line

ChatModelCallAdvisor Call(终结) LOWEST_PRECEDENCE

调chatModel.call ,链尾advisor/ChatModelCallAdvisor.java:43-136

ChatModelStreamAdvisor Stream(终结) LOWEST_PRECEDENCE

调chatModel.stream ,链尾advisor/ChatModelStreamAdvisor.java:39-94ToolCallingAdvisor Call+Stream+Tool HIGHEST_PRECEDENCE+300把⼯具循环搬进链,⾃动注册advisor/ToolCallingAdvisor.java:64-593MessageChatMemoryAdvisor 记忆 HIGHEST_PRECEDENCE+200before 注⼊历史、存

user;after 存

assistantadvisor/MessageChatMemoryAdvisor.java:47-207

SimpleLoggerAdvisor Call+Stream 0(可配)

DEBUG 打印request/responseadvisor/SimpleLoggerAdvisor.java:41-156

SafeGuardAdvisor Call+Stream 0(可配)

命中敏感词直接短路,不调模型advisor/SafeGuardAdvisor.java:46-146StructuredOutputValidationAdvisor Call+Stream 可配校验 JSON 输出,失败追加错误重试advisor/StructuredOutputValidationAdvisor.java:64-

QuestionAnswerAdvisor (RAG) BaseAdvisor 0(可配)

before 向量检索拼进

prompt;after 回填命

中⽂档vectorstore/QuestionAnswerAdvisor.java:55-222SafeGuardAdvisor 是个有意思的反例:它不⼀定调 nextCall ——命中敏感词时直接返回固定的 failureResponse ,把后⾯整排驿丞连同番邦翻译官⼀起短路掉。这正说明"是否放⾏"的决定权完全攥在每个驿丞⾃⼰⼿⾥,这才是责任链的精髓:不是流⽔线,是每⼀关都能拦、能改、能掉头。

⚠ 暗礁:流式的 after 只在 finishReason 那⼀帧触发

回看 BaseAdvisor 的流式默认实现, after 不是每帧都调,⽽是裹了个判断:

return chatClientResponseFlux.map(response -> {
if (AdvisorUtils.onFinishReason().test(response)) {
response = after(response, streamAdvisorChain);
}
return response;
})

spring-ai-client-chat/.../advisor/api/BaseAdvisor.java:68-72onFinishReason() 只在响应⾥出现⾮空 finishReason 时返回 true( AdvisorUtils.java:40-49 )——也就是流的最后⼀帧。这意味着:如果你写了个BaseAdvisor ,指望它的 after 对流式⾥每⼀个 token ⽚段都执⾏⼀次(⽐如逐帧脱敏、逐帧统计),你会失望——它只在收尾那帧跑⼀次。这是同步语义("after 处理完整响应")硬套到流式上的⼀个折中: after 拿到的是"已经到结尾的那帧",⽽不是中间任意⼀帧。要做逐帧处理,你得绕开 BaseAdvisor ,⾃⼰直接实现StreamAdvisor.adviseStream ,在 Flux 算⼦⾥⾃⼰掌控每⼀帧。这条边界,⽂档⾥说得相当含蓄,踩过的⼈才知道疼。

说句公道的批评:这套 order "数值⼩=外层"的语义,加上链消费式 pop、 copy ⼦链、流式 after 单帧触发这⼏条规则,组合起来的⼼智负担确实偏⾼。优雅是真优雅,但它把不少隐性约定(order 内外关系、⼦链复制时机、after 触发点)压在了"你得读源码才懂"的层⾯,光看 Javadoc 很难拼出全貌。这是 Spring AI 为"同步流式⼀

套 advisor 通吃"所付的学习成本——值不值,得看你折腾的横切逻辑有多复杂。

🎯 三句带⾛

1. Advisor 三层: Advisor (带 order)→ CallAdvisor.adviseCall / StreamAdvisor.adviseStream (收链做参数,⾃⼰决定是否
nextCall / nextStream )→ BaseAdvisor (把 around 简化成 before() / after() 双钩⼦,同步流式共⽤)。
2. DefaultAroundAdvisorChain ⽤两副 Deque 分装 call/stream advisor; OrderComparator 升序排,order 数值越⼩越外层; nextCall 每次 pop() 队⾸
并把同⼀个链实例( this )传给 advisor,advisor 内再调 nextCall 即递归推进,终结驿丞 ChatModelCallAdvisor (order = LOWEST_PRECEDENCE )直接调

chatModel.call 。

3. 链 pop ⼀次即耗尽、不可重放,故每次 call()/stream() 都重建链;⼯具循环靠 copy(after) 从原始快照切出"⾃⼰之后"的⼦链反复跑; BaseAdvisor流式 after 只在 finishReason 那⼀帧执⾏。

承上启下这⼀章⾥,有个驿丞我们故意没拆开看—— ToolCallingAdvisor 。它最特别:别的驿丞盘查完就放⾏,它却会在⾃⼰这⼀关原地打转,反复copy(this).nextCall(...) ,因为模型常常不是⼀句话就答完,⽽是先回⼀句"等等,我得差⼈去办件事"。那"办事的⼈"是谁?是怎么被招募、怎么被调度、办完⼜怎么把回执塞回链⾥的?下⼀章,我们就⾛进差役调度房——「第六章 · 招募差役:ToolCallback 与 @Tool」。