Spring AI Agent 漫画实战
第 04 话 SOURCE DIVE

责任链复活:Advisor 拦截机制源码解剖 它不是 Filter —— 它是双向环绕的,而且必须能穿过响应式流

记忆、RAG、日志、护栏、成本审计、缓存,这些横切关注点在 Spring AI 里全部收敛到一个抽象: Advisor。这一话我们把它的接口层次、链的推进算法、 order 语义拆开看,最后手写一个可以直接上生产的成本审计 Advisor。

老 K
不就是 Servlet Filter 吗?chain.doFilter(req, resp) 我写了十年。
小 S
形似,神不似。Filter 的参数是请求 + 响应两个对象;Advisor 是请求进、响应出、在同一个方法里 —— 典型的环绕(Around)切面。而且它还要处理 Flux,这是 Filter 从没面对过的。

一、类比与差异:Filter / Interceptor / WebFilter / Advisor

机制形态关键差异
Servlet FilterdoFilter(req, resp, chain)双向靠同一对对象;无法拿到"模型返回的东西"做二次加工(resp 已被提交)。
MVC HandlerInterceptorpreHandle / postHandle / afterCompletion 三段钩子三个独立回调,状态靠 HttpServletRequest attribute 传;不能改写入参。
WebFlux WebFilterfilter(exchange, chain): Mono<Void>响应式环绕,最接近 Advisor;但语义绑定 HTTP。
Spring AI AdvisoraroundCall(req, chain): AdvisedResponse
aroundStream(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()终端真正使用的模型实例。
advisorParams vs adviseContext vs 构造器字段

三、链是怎么推进的:DefaultAroundAdvisorChain

源码解剖 · 简化后的核心逻辑
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
    }
}

三个必须知道的结论:

  1. 链的推进靠创建新 chain 对象(index+1),而不是可变游标 —— 所以 chain 天然线程安全,同一个 ChatClient 并发调用互不干扰。
  2. aroundCall 里不调用 chain.nextAroundCall() 就等于"短路"(缓存命中、护栏拦截都靠这个)。
  3. 终端节点是 ChatModelCallAdvisor,它的 getOrder() 返回 Ordered.LOWEST_PRECEDENCE(Integer.MAX_VALUE),排在最内层,且它的 aroundCall 不再调用 chain,而是直接 chatModel.call(prompt)。
order = 0 记忆改写 / 会话上下文(外层,最先看到用户原文) order = 100 检索增强(QuestionAnswerAdvisor / RetrievalAugmentationAdvisor) order = 900 日志 / 护栏 / 成本审计(内层,看到的是"最终要发出的请求") ChatModelCallAdvisor(order = LOWEST_PRECEDENCE) ChatModel → 厂商 HTTP API AdvisedRequest 由外向内逐层变换AdvisedResponse 由内向外逐层回传 口诀:order 越小越外层;外层改"语义",内层看"真相";最内层只有一个 —— ChatModelCallAdvisor。

四、order 语义与分层放置策略

为什么要这样分层

五、内置 Advisor 一览

Advisor默认 order(1.0.x)作用与使用提示
MessageChatMemoryAdvisor靠外层
(LOWEST_PRECEDENCE - N)
把历史消息并入 messages 列表,保留角色与工具结构。推荐默认用它。
PromptChatMemoryAdvisor同上把历史拼成文本塞进 system prompt。会丢失角色/工具结构,仅用于兼容老模型。
QuestionAnswerAdvisor0(可指定)经典 RAG:检索 → 拼接 → 追问。1.0 起标记 deprecated,新项目用下面这个。
RetrievalAugmentationAdvisor0(可指定)模块化 RAG(检索/增强/生成三段可替换),第 07 话主角。
SimpleLoggerAdvisor靠内层打印请求与响应。只建议在 dev 开;生产要用它请先做脱敏。
SafeGuardAdvisor靠内层敏感词拦截,命中后短路返回预设话术,不调用模型。
ReReadingAdvisor0Re2 技巧:把问题重复一遍再问,对推理类任务有稳定增益(代价是多花一点 token)。
ChatModelCallAdvisorLOWEST_PRECEDENCE你不用手动加:框架自动追加在最内层,真正调用 ChatModel。
注意

具体 order 常量在不同小版本间有微调,请以你依赖版本的源码为准(搜 AbstractChatMemoryAdvisor#DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER 之类的常量)。 不要硬编码数字,配置文件里给个可覆盖的 order 参数更稳。

六、手写生产级 Advisor:CostAuditAdvisor

为什么用 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 唯一的表达力缺口

想做"命中缓存就直接返回、不调用模型"的 CacheAdvisor? BaseAdvisor 做不到。

缓存短路要绕开 before/after,直接实现 CallAroundAdvisor

在 aroundCall 里先查缓存:命中就 return 一个自建的 AdvisedResponse( new AdvisedResponse(ChatResponse.builder()...build(), ctx)), 不命中才 chain.nextAroundCall(request) 并在返回后 cache.put(key, text)。原因:before() 的签名 必须返回一个 request,没有"我不走了"这个出口。同时请在 adviseContext 里放一个命中标记, 避免流式路径上 Flux 被重复订阅时把同一份结果写两次缓存。

流式 Advisor 的三个坑

踩坑
  1. 不要在 before() 里做阻塞 IO。before 在 call 与 stream 两条路径上都会被调用, 一次 200ms 的同步 Redis 查询会把首字延迟(TTFT)直接顶上去。要查远端请异步化或前置到本地缓存。
  2. 不要在 after() 里读整个 body。流式场景下"读整个响应"意味着你必须先聚合完所有 chunk, 这就把流式降级成了阻塞。BaseAdvisor 的默认流式实现只在最后一个非空 chunk 上调用 after, 请保持 after 是纯副作用、不看正文的逻辑(统计、埋点、写审计)。
  3. Flux 可能被订阅多次。如果你在 aroundStream 里用 doOnNext 累加计数,一旦上游重试或下游重复订阅,计数会翻倍。对策: 把累加器放进 adviseContext 并用原子类,或用 Flux.defer 保证每次订阅重建状态; 不要用一个 Advisor 实例字段当累加器(Advisor 是单例)。
源码解剖 · BaseAdvisor 的流式默认实现
@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 统计放在结束帧,而这个结束帧不带正文。

小 S
对。记忆 Advisor 本质上就是"往 messages 里塞历史"的一个拦截器 —— 而"塞多少、怎么塞"这件事上,Spring AI 的默认实现有一个能把线上打挂的坑。下次揭晓。
← 上一话:Prompt 即契约 目录 下一话:记忆与会话状态 →