记忆、RAG、日志、护栏、成本审计、缓存,这些横切关注点在 Spring AI 里全部收敛到一个抽象:
Advisor。这一话我们把它的接口层次、链的推进算法、
order 语义拆开看,最后手写一个可以直接上生产的成本审计 Advisor。
chain.doFilter(req, resp) 我写了十年。Flux,这是 Filter 从没面对过的。| 机制 | 形态 | 关键差异 |
|---|---|---|
Servlet Filter | doFilter(req, resp, chain) | 双向靠同一对对象;无法拿到"模型返回的东西"做二次加工(resp 已被提交)。 |
MVC HandlerInterceptor | preHandle / postHandle / afterCompletion 三段钩子 | 三个独立回调,状态靠 HttpServletRequest attribute 传;不能改写入参。 |
WebFlux WebFilter | filter(exchange, chain): Mono<Void> | 响应式环绕,最接近 Advisor;但语义绑定 HTTP。 |
Spring AI Advisor | aroundCall(req, chain): AdvisedResponsearoundStream(req, chain): Flux<AdvisedResponse> | 既能改入参也能改出参,且入参/出参都是不可变对象;同时要提供 call 与 stream 两套实现。 |
因为 AdvisedRequest / AdvisedResponse 是值对象(不可变),
每个 Advisor 都必须"接收旧的 → 返回新的"。这杜绝了 Filter 时代"多个 Filter 抢着 mutate 同一个 request"的混乱,
代价是每次改写都要 copy 一次 Map —— 高频链路下这是唯一值得注意的开销(量级很小,别过早优化)。
public interface Advisor extends Ordered { //!hl
String getName(); // 用于日志、观测、链路追踪 //!hl
}
public interface CallAroundAdvisor extends Advisor {
AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain); //!hl
}
public interface StreamAroundAdvisor extends Advisor {
Flux<AdvisedResponse> aroundStream(AdvisedRequest request, StreamAroundAdvisorChain chain); //!hl
}
// 模板类:同时实现上面两个,把"环绕"拆成 before / after 两段 //!hl
public interface BaseAdvisor extends CallAroundAdvisor, StreamAroundAdvisor { //!hl
AdvisedRequest before(AdvisedRequest request); //!hl
AdvisedResponse after(AdvisedResponse response); //!hl
}
AdvisedRequest 上有哪些字段| 字段 / 访问器 | 说明与典型用途 |
|---|---|
userText() / systemText() | 本轮原始文本。做输入改写、敏感词过滤在这里改。 |
messages() | 最终的 List<Message>。记忆 Advisor 就是把历史并进这个列表。 |
conversationId() | 会话标识。记忆、缓存、审计的分组键(第 05 话详述)。 |
advisorParams() | 每次请求动态下发的参数,如 tenantId、userId。 |
adviseContext() | 链内传递的中间态。放 traceId、开始时间、命中标记,下游 Advisor 可读。 |
media() / toolCallbacks() | 多模态附件、本轮可用的工具(第 06 话)。 |
chatOptions() | 模型参数(temperature、maxTokens、模型名)。做模型路由就改它。 |
chatModel() | 终端真正使用的模型实例。 |
.advisors(a -> a.param("tenantId", x)) 里下发的请求级入参,只读。public class DefaultAroundAdvisorChain
implements CallAroundAdvisorChain, StreamAroundAdvisorChain {
private final List<Advisor> advisors; // 已按 order 升序排好 //!hl
private final int index; // 当前推进位置(不可变,靠 new 推进) //!hl
public DefaultAroundAdvisorChain(List<Advisor> advisors) {
// 排序:Ordered 语义,值小的在前(= 更外层)
this.advisors = advisors.stream().sorted(AnnotationAwareOrderComparator.INSTANCE).toList(); //!hl
this.index = 0;
}
private DefaultAroundAdvisorChain(List<Advisor> advisors, int index) {
this.advisors = advisors;
this.index = index;
}
@Override
public AdvisedResponse nextAroundCall(AdvisedRequest request) { //!hl
if (this.index >= this.advisors.size()) {
throw new IllegalStateException("No more advisors to call"); //!hl
}
var advisor = this.advisors.get(this.index);
var next = new DefaultAroundAdvisorChain(this.advisors, this.index + 1); //!hl
return ((CallAroundAdvisor) advisor).aroundCall(request, next); //!hl
}
}
三个必须知道的结论:
aroundCall 里不调用 chain.nextAroundCall() 就等于"短路"(缓存命中、护栏拦截都靠这个)。ChatModelCallAdvisor,它的 getOrder() 返回
Ordered.LOWEST_PRECEDENCE(Integer.MAX_VALUE),排在最内层,且它的 aroundCall
不再调用 chain,而是直接 chatModel.call(prompt)。Advisor extends Ordered,因此可以直接用 @Order 注解或
org.springframework.core.Ordered 常量;排序由 AnnotationAwareOrderComparator 完成。@Order 完全一致。| Advisor | 默认 order(1.0.x) | 作用与使用提示 |
|---|---|---|
MessageChatMemoryAdvisor | 靠外层 ( LOWEST_PRECEDENCE - N) | 把历史消息并入 messages 列表,保留角色与工具结构。推荐默认用它。 |
PromptChatMemoryAdvisor | 同上 | 把历史拼成文本塞进 system prompt。会丢失角色/工具结构,仅用于兼容老模型。 |
QuestionAnswerAdvisor | 0(可指定) | 经典 RAG:检索 → 拼接 → 追问。1.0 起标记 deprecated,新项目用下面这个。 |
RetrievalAugmentationAdvisor | 0(可指定) | 模块化 RAG(检索/增强/生成三段可替换),第 07 话主角。 |
SimpleLoggerAdvisor | 靠内层 | 打印请求与响应。只建议在 dev 开;生产要用它请先做脱敏。 |
SafeGuardAdvisor | 靠内层 | 敏感词拦截,命中后短路返回预设话术,不调用模型。 |
ReReadingAdvisor | 0 | Re2 技巧:把问题重复一遍再问,对推理类任务有稳定增益(代价是多花一点 token)。 |
ChatModelCallAdvisor | LOWEST_PRECEDENCE | 你不用手动加:框架自动追加在最内层,真正调用 ChatModel。 |
具体 order 常量在不同小版本间有微调,请以你依赖版本的源码为准(搜
AbstractChatMemoryAdvisor#DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER 之类的常量)。
不要硬编码数字,配置文件里给个可覆盖的 order 参数更稳。
为什么用 BaseAdvisor 而不是裸 CallAroundAdvisor:
裸接口只覆盖 call(),一旦业务改成 .stream(),你的成本统计静默失效 ——
没有报错,只是账单对不上。BaseAdvisor 已经替你把 before/after 适配到了流式分支(只在最后一个有效 chunk 上触发 after),
写一份代码两种模式都生效。
public class TokenBudgetExceededException extends RuntimeException { //!hl
public TokenBudgetExceededException(String m) { super(m); }
}
public class CostAuditAdvisor implements BaseAdvisor { //!hl
public static final String CTX_TRACE = "costAudit.traceId";
public static final String CTX_START = "costAudit.startNanos";
public static final String CTX_TENANT = "costAudit.tenantId";
private final MeterRegistry meterRegistry;
private final long maxEstimatedPromptTokens;
private final int order;
public CostAuditAdvisor(MeterRegistry r, long maxEstimatedPromptTokens, int order) {
this.meterRegistry = r; this.order = order;
this.maxEstimatedPromptTokens = maxEstimatedPromptTokens;
}
@Override public int getOrder() { return this.order; }
@Override public String getName() { return this.getClass().getSimpleName(); }
@Override
public AdvisedRequest before(AdvisedRequest request) { //!hl
Map<String, Object> ctx = new HashMap<>(request.adviseContext());
ctx.put(CTX_TRACE, MDC.get("traceId"));
ctx.put(CTX_START, System.nanoTime());
ctx.put(CTX_TENANT, request.advisorParams().getOrDefault("tenantId", "unknown")); //!hl
long estimate = request.messages().stream().mapToLong(m -> estimateTokens(m.getText()))
.sum() + estimateTokens(request.userText()) + estimateTokens(request.systemText());
if (estimate > this.maxEstimatedPromptTokens) { //!hl
throw new TokenBudgetExceededException(
"estimated prompt tokens " + estimate + " > budget " + this.maxEstimatedPromptTokens);
}
return AdvisedRequest.from(request).adviseContext(ctx).build(); //!hl
}
@Override
public AdvisedResponse after(AdvisedResponse response) { //!hl
long start = (long) response.adviseContext().getOrDefault(CTX_START, System.nanoTime());
Usage usage = response.response().getMetadata().getUsage();
long total = usage != null ? usage.getTotalTokens() : 0;
String tenant = String.valueOf(response.adviseContext().getOrDefault(CTX_TENANT, "unknown"));
this.meterRegistry.counter("ai.tokens.total", "tenant", tenant).increment(total); //!hl
this.meterRegistry.timer("ai.call.latency", "tenant", tenant)
.record(System.nanoTime() - start, TimeUnit.NANOSECONDS); //!hl
log.info("ai-call traceId={} tenant={} model={} tokens={}",
response.adviseContext().get(CTX_TRACE), tenant,
response.response().getMetadata().getModel(), total);
return response;
}
/** 粗估:中文按字符数 / 1.5,英文按 / 4。精确计费请引入 JTokkit。 */
private static long estimateTokens(String text) {
if (text == null || text.isEmpty()) return 0;
long cjk = text.chars().filter(c -> c > 0x2E80).count();
return Math.round(cjk / 1.5 + (text.length() - cjk) / 4.0) + 4;
}
}
@Configuration
class AgentConfig {
@Bean
ChatClient chatClient(ChatClient.Builder builder,
ChatMemory chatMemory,
MeterRegistry meterRegistry) {
return builder
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build(), // order 靠外层 //!hl
new CostAuditAdvisor(meterRegistry, 8_000, 900)) // 内层 //!hl
.build();
}
}
// 调用侧下发运行时参数
chatClient.prompt()
.user(q)
.advisors(a -> a.param("tenantId", tenantId).param("userId", userId)) //!hl
.call()
.content();
想做"命中缓存就直接返回、不调用模型"的 CacheAdvisor?
BaseAdvisor 做不到。
在 aroundCall 里先查缓存:命中就
return 一个自建的 AdvisedResponse(
new AdvisedResponse(ChatResponse.builder()...build(), ctx)),
不命中才 chain.nextAroundCall(request) 并在返回后
cache.put(key, text)。原因:before() 的签名
必须返回一个 request,没有"我不走了"这个出口。同时请在 adviseContext 里放一个命中标记,
避免流式路径上 Flux 被重复订阅时把同一份结果写两次缓存。
before() 里做阻塞 IO。before 在 call 与 stream 两条路径上都会被调用,
一次 200ms 的同步 Redis 查询会把首字延迟(TTFT)直接顶上去。要查远端请异步化或前置到本地缓存。after() 里读整个 body。流式场景下"读整个响应"意味着你必须先聚合完所有 chunk,
这就把流式降级成了阻塞。BaseAdvisor 的默认流式实现只在最后一个非空 chunk 上调用 after,
请保持 after 是纯副作用、不看正文的逻辑(统计、埋点、写审计)。Flux 可能被订阅多次。如果你在 aroundStream 里用
doOnNext 累加计数,一旦上游重试或下游重复订阅,计数会翻倍。对策:
把累加器放进 adviseContext 并用原子类,或用 Flux.defer 保证每次订阅重建状态;
不要用一个 Advisor 实例字段当累加器(Advisor 是单例)。@Override
default Flux<AdvisedResponse> aroundStream(AdvisedRequest req, StreamAroundAdvisorChain chain) {
Assert.notNull(req, "advisedRequest cannot be null");
AdvisedRequest processed = before(req); //!hl
return chain.nextAroundStream(processed).map(res -> {
if (res.response() != null
&& res.response().getResult() != null
&& res.response().getResult().getOutput() != null
&& StringUtils.hasText(res.response().getResult().getOutput().getText())) {
return after(res); //!hl
}
return res;
});
}
看清楚 if 里的条件:只有拿到了"带正文的最终 chunk"才会调用 after。
这也是为什么流式下 usage 常常为 null —— 很多厂商把 token 统计放在结束帧,而这个结束帧不带正文。
第 05 话 —— 记忆的代价。
MessageWindowChatMemory 的窗口裁剪源码、为什么工具消息必须"整组"保留、
JdbcChatMemoryRepository 建表与配置、conversationId 的多租户设计,以及长期记忆的三层策略。