前言

如果你用 Spring AI 写过超过一个 Demo 级别的应用,大概率会碰到这样的窘境:第一轮对话模型还记得用户说过的需求,第二轮就开始「失忆」;想给大模型挂一份企业内部的私有知识库,却不知道该在哪一层注入检索结果;想在每次调用前后统一打日志、做敏感词过滤、限流审计,又不想把业务代码搅得一团糟。这些问题的共同根源在于:Chat Model 的 API 是无状态的、一次性的,而真实业务是有状态、有流程、有治理要求的。

Spring AI 给出的答案是 Advisors API。官方文档开篇就把它定义为「拦截、修改和增强 Spring 应用中 AI 交互的一种灵活而强大的方式」,它的核心价值有三点:把反复出现的生成式 AI 模式(会话记忆、RAG、日志、内容安全、工具循环)封装成可复用的组件;在发往大模型的数据和从大模型返回的数据之间做双向变换;并且让这些能力可以跨模型、跨场景移植——今天你对接 OpenAI,明天切换到 Ollama 本地模型,Advisor 链完全不用动。

这篇文章就是一次官方文档带读。我们逐段啃完 Advisors API 与 ChatClient 两章文档(对应 Spring AI 2.0.1),把链式拦截的调用顺序、ChatMemory 的会话记忆实现、RAG 系列 Advisor、日志与安全类 Advisor、自定义 Advisor 的写法,以及多轮对话下的上下文长度治理,全部串成一条完整的主线。所有 API 名称、配置项、默认值均以你读到的当前版本官方文档为准,文中会明确标注文档中的原始结论。

整体架构与核心概念

先把全景看清楚。Advisor 体系的核心是一组位于 ChatClient 与 ChatModel 之间的拦截器链。官方文档明确说明:API 由非流式场景的 CallAdvisor 与 CallAdvisorChain、流式场景的 StreamAdvisor 与 StreamAdvisorChain 组成;ChatClientRequest 表示尚未封装的 Prompt 请求,ChatClientResponse 表示 Chat Completion 响应,两者都携带一个 advise-context,用于在整个 Advisor 链中共享状态。链的最后一个元素由框架自动追加,负责把请求真正发给 Chat Model。

            ┌─────────────────────────────────────────────────┐
            │                  ChatClient                      │
            │  .prompt().user(...).advisors(...).call()        │
            └──────────────────────┬──────────────────────────┘
                                   │ ChatClientRequest (+ 空 context)
                                   ▼
            ┌─────────────────────────────────────────────────┐
            │              Advisor Chain (按 getOrder() 排序)    │
            │  ┌───────────────────────────────────────────┐  │
            │  │ Advisor A (order=0)  before → 改写请求     │  │
            │  └──────────────────────┬────────────────────┘  │
            │  ┌──────────────────────▼────────────────────┐  │
            │  │ Advisor B (order=100) before → 追加上下文  │  │
            │  └──────────────────────┬────────────────────┘  │
            │  ┌──────────────────────▼────────────────────┐  │
            │  │ Advisor C (order=200) 可短路/放行           │  │
            │  └──────────────────────┬────────────────────┘  │
            │         (可短路:不调用 next 则自行填充响应)       │
            └──────────────────────┬──────────────────────────┘
                                   ▼
            ┌─────────────────────────────────────────────────┐
            │   框架自动追加的最后一个 Advisor → ChatModel       │
            └──────────────────────┬──────────────────────────┘
                                   │ ChatModel 响应
                                   ▼
            响应沿链反向回传,每个 Advisor 可处理/修改 ChatClientResponse
                                   ▼
            最终 ChatClientResponse 返回给客户端(可提取 ChatCompletion)

理解这张图的关键有三处。第一,这是一个栈式结构:最先处理请求的 Advisor,最后处理响应——和 Servlet Filter 链、AOP 拦截器链的语义完全一致。第二,每个 Advisor 都有机会「短路」:它不调用链上的下一个元素,而是自己负责填充响应,官方文档称之为 block the request。第三,advise-context 是贯穿全链的共享黑板,比如 RAG Advisor 检索到的相关文档、记忆 Advisor 读出的会话历史,都可以挂在上面,后面的 Advisor 和消费方都能拿到——调用 chatClientResponse() 时返回的 ChatClientResponse 恰好就包含这个执行上下文,文档里举的例子正是「访问 RAG 流程中检索到的相关文档」。

你现在应该掌握了:Advisor 是位于 ChatClient 与 ChatModel 之间的双向拦截层,链按 getOrder() 排序、呈栈式执行,请求与响应都携带可共享的 advise-context,且任何 Advisor 都可以选择短路请求。

Advisor 链式拦截模型与调用顺序

核心接口

Advisor 相关接口都在 org.springframework.ai.chat.client.advisor.api 包下。根基接口非常薄:

public interface Advisor extends Ordered {
    String getName();
}

它只要求一个唯一名字,排序能力继承自 Spring 的 Ordered。两个真正的子接口分别面向同步与响应式场景:

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

public interface StreamAdvisor extends Advisor {
    Flux<ChatClientResponse> adviseStream(
        ChatClientRequest chatClientRequest, StreamAdvisorChain streamAdvisorChain);
}

继续链条靠 CallAdvisorChain.nextCall() 和 StreamAdvisorChain.nextStream()。官方文档在 CallAdvisorChain 接口注释里写得很直白:nextCall 用给定的请求调用链中下一个 CallAdvisor。也就是说,一个 Advisor 的完整生命周期是:检查尚未封装的 Prompt 数据 → 定制和增强 Prompt → 调用链上的下一个实体 → 检查 Chat Completion 响应 → 返回或抛异常。流式版本返回的是 Flux<ChatClientResponse>,天然适配 WebFlux 的背压模型。

getOrder() 的栈式语义

这是全文最容易踩坑的部分,官方文档用了相当长的篇幅专门解释。结论先摆出来:

  • order 值越小越先执行(数值小 = 优先级高,与 Spring Ordered 接口语义一致);
  • 链是栈:第一个处理请求的 Advisor,最后一个处理响应;
  • Ordered.HIGHEST_PRECEDENCE 是 Integer.MIN_VALUE,LOWEST_PRECEDENCE 是 Integer.MAX_VALUE;
  • 多个 Advisor 的 order 相同时,执行顺序不保证;
  • 如果你需要某个逻辑在请求侧和响应侧都「最先」执行,正确做法是拆成两个 Advisor,配不同的 order,用 advise-context 在二者之间传递状态。

文档里特意解释了表观矛盾从何而来:最高优先级的 Advisor 位于栈顶,栈展开时它第一个处理请求,栈回收时它最后一个处理响应。一个具体且重要的实例是 ToolCallingAdvisor——它默认注册在 Ordered.HIGHEST_PRECEDENCE + 300,即非常靠近链首的位置,因为工具调用的循环必须尽早介入。

注册位置:构建期 defaultAdvisors 与运行期 advisors

官方文档建议通过 builder 的 defaultAdvisors() 在构建期注册 Advisor,运行期再用不带 default 前缀的 advisors() 方法覆盖或追加。运行时配置走 AdvisorSpec 接口,它支持逐个加参数、批量加参数、追加一个或多个 Advisor:

ChatClient.builder(chatModel)
    .build()
    .prompt()
    .advisors(a -> a
        .advisors(
            MessageChatMemoryAdvisor.builder(chatMemory).build(),
            QuestionAnswerAdvisor.builder(vectorStore).build()
        )
        .param(ChatMemory.CONVERSATION_ID, conversationId))
    .user(userText)
    .call()
    .content();

文档对这段代码有一段非常关键的解读,值得逐字体会:MessageChatMemoryAdvisor 先执行,把会话历史加入 Prompt;随后 QuestionAnswerAdvisor 基于「用户问题 + 已注入的历史」做检索,因此可能得到更相关的结果。这告诉我们:Advisor 的排列顺序不是随便的,前一个 Advisor 对 Prompt 的修改会原样传给后一个,顺序即数据流。

你现在应该掌握了:Advisor/CallAdvisor/StreamAdvisor 三层接口的继承关系、nextCall/nextStream 的续链方式、getOrder() 的栈式语义,以及「构建期 defaultAdvisors + 运行期 advisors(Consumer)」这一标准注册姿势。

ChatMemory 与 MessageWindowChatMemory 的会话记忆实现

大模型 API 是无状态的:你告诉它名字,下一次调用它就忘了。会话记忆必须由应用在每一次请求时随 Prompt 带上,这正是 Chat Memory 章节开篇强调的第一性原理。

ChatMemory 接口与唯一的内置实现

ChatMemory 接口代表对话记忆的存储,提供三个操作:向会话添加消息、从会话读取消息、清空会话历史。当前版本内置实现只有一个:MessageWindowChatMemory。它的行为在文档中写得很清楚,我们逐条拆解:

  1. 维护一个消息窗口,最大容量有上限,默认 20 条消息;
  2. 消息数超过上限时,淘汰较旧的消息,但 system message 会被保留;
  3. 如果新加入一条 system message,之前所有的 system message 都会被移除。

第三条是个极容易被忽略的细节:窗口内永远只保留最新一条系统消息。这为「运行期动态切换系统提示」提供了一个隐含的、但可靠的行为保证——你不用手动清理旧的 system message。

ChatMemoryRepository 存储抽象

MessageWindowChatMemory 本身不存数据,它背后是 ChatMemoryRepository 抽象。当前可用的实现包括:InMemoryChatMemoryRepository、JdbcChatMemoryRepository、CassandraChatMemoryRepository、Neo4jChatMemoryRepository、MongoChatMemoryRepository、RedisChatMemoryRepository。一张表看清选型:

实现 数据存放位置 适合场景 重启后记忆是否保留
InMemoryChatMemoryRepository JVM 堆内存 本地开发、单实例 Demo、测试 否
JdbcChatMemoryRepository 关系型数据库表 生产环境、需要事务与运维 familiarity 的团队 是
RedisChatMemoryRepository Redis 低延迟读取、已有 Redis 基础设施 是
MongoChatMemoryRepository MongoDB 已用 Mongo 技术栈 是
CassandraChatMemoryRepository Cassandra 大规模分布式写入 是
Neo4jChatMemoryRepository Neo4j 图数据库技术栈 是

典型的内存模式装配代码如下:

ChatMemory chatMemory = MessageWindowChatMemory.builder()
    .chatMemoryRepository(new InMemoryChatMemoryRepository())
    .maxMessages(40)   // 覆盖默认的 20 条窗口
    .build();

会话 ID:没有默认值

文档用加粗的 required 强调:ChatMemory.CONVERSATION_ID 是所有记忆类 Advisor 的必填参数,必须在每次调用时通过 .param(ChatMemory.CONVERSATION_ID, conversationId) 传入;没有默认会话 ID,遗漏会在运行期抛出 IllegalArgumentException。在多租户或多端场景下,会话 ID 通常就是「用户 ID + 设备 ID」或业务会话主键,它决定了记忆隔离的粒度——两个会话 ID 不同,记忆就互不可见。

你现在应该掌握了:ChatMemory 的三元操作、MessageWindowChatMemory 的滑动窗口语义(默认 20 条、淘汰旧消息但保留 system message、新系统消息替换旧系统消息)、六种 ChatMemoryRepository 存储选型,以及 CONVERSATION_ID 必填且无默认值这一硬性约束。

记忆类 Advisor:MessageChatMemoryAdvisor 与 VectorStoreChatMemoryAdvisor

有了存储,还需要有人把它接进 Advisor 链,这就是内置的记忆类 Advisor。文档列出的 Chat Memory Advisors 有两个,行为差异本质而重要:

Advisor 注入方式 优点 注意事项
MessageChatMemoryAdvisor 将记忆作为一批消息集合加入 Prompt,保持对话历史的结构 多轮结构完整,角色(system/user/assistant)清晰 并非所有 AI 模型都支持这种方式
VectorStoreChatMemoryAdvisor 从 VectorStore 检索记忆,注入到 Prompt 的 system 文本中 适合从海量历史中高效搜索、只取相关内容 语义检索存在召回质量依赖

MessageChatMemoryAdvisor.builder(chatMemory).build() 是最常用的起手式。而 VectorStoreChatMemoryAdvisor 解决的是另一个量级的问题:当历史会话动辄数千条,把所有消息塞进窗口既贵又慢,向量化后按相似度取 Top-N 片段注入 system 文本,是把「记忆」从时间轴检索切换到语义检索的关键手段。

另外文档还交代了一个自动注册机制:ChatClient 在自动注册逻辑中用 MemoryAdvisor 标记接口检测下游的记忆 Advisor。MemoryAdvisor 由 BaseChatMemoryAdvisor 扩展;如果你写的自定义记忆 Advisor 没有继承 BaseChatMemoryAdvisor,但希望参与检测,就应当自己实现 MemoryAdvisor 接口。这类标记接口(marker interface)在 Advisor 体系里反复出现,后面讲 ToolAdvisor 时还会见到。

装配示例(记忆 + RAG 组合,即官方文档首页的那个例子):

ChatMemory chatMemory = MessageWindowChatMemory.builder().build();
VectorStore vectorStore = /* 初始化你的向量库 */;

var chatClient = ChatClient.builder(chatModel)
    .defaultAdvisors(
        MessageChatMemoryAdvisor.builder(chatMemory).build(), // 记忆 Advisor
        QuestionAnswerAdvisor.builder(vectorStore).build()    // RAG Advisor
    )
    .build();

String response = chatClient.prompt()
    .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "678"))
    .user(userText)
    .call()
    .content();

注意最后一步:即使 Advisor 在构建期注册,会话 ID 这种运行期参数仍然每次都要显式传入。

你现在应该掌握了:两种记忆 Advisor 的注入机制差异、结构式注入的模型兼容性限制、语义式注入的适用边界,以及 MemoryAdvisor 标记接口在自动注册检测中的作用。

RAG Advisor:QuestionAnswerAdvisor 与 RetrievalAugmentationAdvisor

RAG(Retrieval-Augmented Generation)是 Advisor 体系的第二大支柱。文档在 ChatClient 一章中把 QuestionAnswerAdvisor 描述为「通过向 Prompt 追加与用户文本相关的上下文信息来实现 RAG 模式」,在 Advisors 一章中则进一步明确:它基于向量库提供问答能力,实现的是朴素 RAG(Naive RAG)模式。它的默认用法已经在上文出现:QuestionAnswerAdvisor.builder(vectorStore).build()。

更进一步,文档列出了 RetrievalAugmentationAdvisor:它使用 org.springframework.ai.rag 包中定义的基础组件实现常见的 RAG 流程,遵循模块化 RAG 架构(Modular RAG Architecture)。两者的定位差异可以概括为:

维度 QuestionAnswerAdvisor RetrievalAugmentationAdvisor
RAG 范式 朴素 RAG(检索 → 拼进 Prompt) 模块化 RAG 架构
构建材料 直接以 VectorStore 为参数 复用 rag 包的检索、增强等基础组件
适用阶段 快速落地、场景简单 需要定制检索/增强管线的复杂场景

无论用哪一个,有一个调用链上的技巧值得反复用:让记忆 Advisor 排在 RAG Advisor 之前。这样 RAG 检索的查询条件里就包含了对话历史,「它」这类指代词才有机会被解析成真正的实体。这就是顺序即数据流的最佳注脚。

消费侧,文档提示了两个入口:非流式用 chatClientResponse() 拿到带执行上下文的 ChatClientResponse,流式用 chatClientResponse() 的 Flux 版本,二者都能让你访问 Advisor 执行期间产生的附加数据——文档给的示例恰恰是 RAG 流程检索到的相关文档。这对可观测性和结果审计非常有用:你不仅可以拿到模型说了什么,还能拿到它「看了什么」之后才这么说的。

你现在应该掌握了:QuestionAnswerAdvisor 的朴素 RAG 定位、RetrievalAugmentationAdvisor 的模块化 RAG 定位、记忆先于检索的排序理由,以及通过 ChatClientResponse 上下文回溯检索证据的消费方式。

日志与安全类 Advisor:SimpleLoggerAdvisor 与 SafeGuardAdvisor

SimpleLoggerAdvisor:官方出品的日志 Advisor

自己造日志 Advisor 之前,先看看官方的 SimpleLoggerAdvisor。它记录 ChatClient 的 request 与 response 数据,用于调试和监控 AI 交互。文档建议把它加在链条靠近末尾的位置:

ChatResponse response = ChatClient.create(chatModel).prompt()
    .advisors(new SimpleLoggerAdvisor())
    .user("Tell me a joke?")
    .call()
    .chatResponse();

要看到日志,需要把 advisor 包的日志级别调到 DEBUG,写进 application.properties 或 application.yaml:

logging:
  level:
    org.springframework.ai.chat.client.advisor: DEBUG

它支持用构造函数自定义记录内容:SimpleLoggerAdvisor(requestToString, responseToString, order),两个函数分别把 ChatClientRequest 和 ChatResponse 转成你想记录的字符串。文档同时给了一句生产环境警示:当心记录敏感信息。请求里可能带着用户的个人隐私、鉴权上下文,原样 DEBUG 落盘在多数公司过不了安全审计——自定义 requestToString 只提取必要字段,是标准做法。

SafeGuardAdvisor:内容安全守门员

SafeGuardAdvisor 在文档中的定位只有一句话:一个用于阻止模型生成有害或不适当内容的简单 Advisor。它的价值在于位置——放在链上,它在响应回传路径上拥有否决权。不过文档没有展开它的底层判定机制,具体实现细节以当前版本官方文档与源码为准。

ToolCallingAdvisor:不得不提的隐形常驻者

严格说它是工具类而非日志安全类,但它的自动注册机制必须在这一节交代清楚:ChatClient 总是自动注册一个 ToolCallingAdvisor(除非显式禁用),即使调用时没有配置任何静态工具,也能保证运行期由其他 Advisor 动态注入的工具被正确执行;它执行模型请求的工具调用并把结果回送,迭代直到不再有工具调用。默认 order 是 Ordered.HIGHEST_PRECEDENCE + 300。

禁用方式有两种。全局禁用,写配置:

spring.ai.chat.client.tool-calling.enabled=false

注意文档的精确措辞:此属性置为 false 后,工具仍会作为定义发送给模型,但响应中的工具调用不会自动执行——你需要用「用户受控的工具执行」自行驱动循环。单次调用禁用,则用运行期参数:

chatClient.prompt("What day is tomorrow?")
    .tools(new DateTimeTools())
    .advisors(AdvisorParams.toolCallingAdvisorAutoRegister(false))
    .call()
    .content();

或者传入你自己的 ToolCallingAdvisor(或任何实现 ToolAdvisor 标记接口的 Advisor),链中已有 ToolAdvisor 时自动注册会被自动抑制。

你现在应该掌握了:SimpleLoggerAdvisor 的启用姿势与 DEBUG 配置、自定义脱敏日志的构造函数用法、SafeGuardAdvisor 的链上否决位定位,以及 ToolCallingAdvisor 的自动注册、默认 order 与两种禁用路径。

自定义 Advisor 的写法

官方文档给了两个完整范例:观察型(Logging Advisor)和增强型(Re2 Advisor),两者覆盖了自定义 Advisor 的绝大多数套路。

观察型:SimpleLoggerAdvisor 的实现剖析

文档中的简化版日志 Advisor 同时实现了 CallAdvisor 与 StreamAdvisor,这是官方建议的写法——「为你的 Advisor 同时实现流式和非流式版本,以获得最大的灵活性」:

public class SimpleLoggerAdvisor implements CallAdvisor, StreamAdvisor {

    private static final Logger logger = LoggerFactory.getLogger(SimpleLoggerAdvisor.class);

    @Override
    public String getName() {
        return this.getClass().getSimpleName();  // (1) 提供唯一名字
    }

    @Override
    public int getOrder() {
        return 0;                                 // (2) 值越小越先执行
    }

    @Override
    public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
        logRequest(req);
        ChatClientResponse resp = chain.nextCall(req);
        logResponse(resp);
        return resp;
    }

    @Override
    public Flux<ChatClientResponse> adviseStream(ChatClientRequest req, StreamAdvisorChain chain) {
        logRequest(req);
        Flux<ChatClientResponse> responses = chain.nextStream(req);
        return new ChatClientMessageAggregator()
            .aggregateChatClientResponse(responses, this::logResponse);  // (3)
    }

    private void logRequest(ChatClientRequest request) {
        logger.debug("request: {}", request);
    }

    private void logResponse(ChatClientResponse response) {
        logger.debug("response: {}", response);
    }
}

三个注释点值得吃透。(1) getName() 要求全链唯一,用类简单名是最省心的约定。(2) order 显式返回 0,别依赖默认值。(3) 流式场景下用 ChatClientMessageAggregator(MessageAggregator 工具类)把一串串 Flux 响应聚合成单个 ChatClientResponse 再记录,适合需要观察完整响应而非单个流式片段的场景;文档特别提醒,MessageAggregator 中的响应是只读的,不能借此修改响应。

增强型:ReReadingAdvisor 的改写套路

Re2(Re-Reading)来自论文《Re-Reading Improves Reasoning in Large Language Models》,做法是把用户输入改写为「原始问题 + Read the question again: 原始问题」。文档中的实现展示了增强类 Advisor 的标准动作——before 阶段改写请求、after 阶段原样放行:

public class ReReadingAdvisor implements BaseAdvisor {

    private static final String DEFAULT_RE2_ADVISE_TEMPLATE = """
        {re2_input_query}
        Read the question again: {re2_input_query}
        """;

    private final String re2AdviseTemplate;
    private int order = 0;

    @Override
    public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) {
        String augmentedUserText = PromptTemplate.builder()
            .template(this.re2AdviseTemplate)
            .variables(Map.of("re2_input_query",
                chatClientRequest.prompt().getUserMessage().getText()))
            .build()
            .render();
        return chatClientRequest.mutate()
            .prompt(chatClientRequest.prompt().augmentUserMessage(augmentedUserText))
            .build();
    }

    @Override
    public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) {
        return chatClientResponse;
    }

    @Override
    public int getOrder() {
        return this.order;
    }

    public ReReadingAdvisor withOrder(int order) {
        this.order = order;
        return this;
    }
}

这里出现了一个文档前文没有细讲但范例中直接使用的 BaseAdvisor,它把 adviseCall/adviseStream 拆成了 before/after 两个更顺手的方法。改写的核心是 mutate():不改动原对象,而是基于原请求构建一个注入了增强文本的新 ChatClientRequest,这与新版 API 不可变上下文的设计理念一脉相承——文档在 Breaking Changes 一节明确写过,1.0 M3 起 context map 变为不可变,更新要用生成新不可变 map 的方式。流式 Advisor 如果需要在 before/after 段做阻塞操作,文档给出的范式是 Mono.just(request).publishOn(Schedulers.boundedElastic()) 切换调度器后再操作。

你现在应该掌握了:观察型 Advisor 的「记前记后」骨架、流式聚合的 MessageAggregator 只读约束、增强型 Advisor 的 mutate-改写套路、before/after 风格接口的用法,以及流式场景切换 boundedElastic 调度器的范式。

多轮对话上下文长度治理与 token 预算

把记忆 Advisor 挂上,多轮对话就能跑了——但「能跑」和「跑得稳」之间隔着上下文长度治理这一道坎。官方文档没有单列一章讲 token 预算,但散落在 Chat Memory 章节的行为描述,组合起来就是一套完整的治理模型,我们把它们显式推演一遍。

窗口机制的边界效应

MessageWindowChatMemory 默认只留 20 条消息。按一问一答一对消息算,20 条只覆盖 10 轮对话;超过之后,最早的用户消息和助手回复被成对淘汰。这带来三个工程推论:其一,窗口是「消息条数」而非「token 数」的粗粒度控制,一条粘贴了五百行日志的用户消息也只吃一个名额——真正的 token 级预算仍需你在 Advisor 层或模型 options 层兜底。其二,system message 永不因淘汰而丢失,且新系统消息会清除旧的,这保证了角色设定与动态指令始终新鲜。其三,maxMessages 可在构建期覆盖,调大窗口的代价是每轮请求携带的历史变长、延迟与费用同步上升。

场景推演:十次轮询后的请求长什么样

假设默认配置(窗口 20),用户已连续对话 12 轮。第 13 次调用时,MessageChatMemoryAdvisor 从仓库取出最近 20 条消息(约 12 轮问答的完整配对,因尚未超限全部保留)整体注入 Prompt,再拼上第 13 条用户输入与系统提示。若换成 VectorStoreChatMemoryAdvisor,注入的则是与当前问题语义相近的若干条历史片段,长度更可控,但丢掉了严格的时序结构。若再叠一个 QuestionAnswerAdvisor,最终 Prompt = 系统提示 + 会话历史 + 检索到的知识片段 + 当前问题。这个加法公式里的每一项都是成本项,治理的本质就是给每一项设上限。

组合策略:条数窗口 + 语义检索 + 模型侧上限

治理手段 作用层面 控制对象 文档依据
maxMessages 窗口 ChatMemory 存储层 注入消息条数(默认 20) MessageWindowChatMemory 语义
新 system message 替换旧 ChatMemory 存储层 系统提示唯一性 「新 system message 会移除所有旧 system message」
VectorStoreChatMemoryAdvisor Advisor 层 语义筛选后的历史规模 「高效地从大数据集中搜索和检索相关信息」
模型 maxTokens / options ChatModel 层 模型可接收/生成上限 options(ChatOptions.Builder) 配置入口
检索结果条数与模板裁剪 RAG Advisor 层 注入知识片段规模 RAG Advisor 的检索增强机制(具体参数以当前版本官方文档为准)

实践中的推荐配比是:用 maxMessages 兜住条数底线,用向量记忆压缩超长历史,用自定义 Advisor 在 before 阶段对超长单条消息做截断或摘要,必要时配合模型侧 options 设置硬上限。

治理要有度量,否则只是拍脑袋调参。文档在 call() 返回值一节交代了一个现成入口:chatResponse() 返回的 ChatResponse 带有响应元数据,其中就包括生成这次响应消耗了多少 token。把每轮调用的 token 用量与消息条数、窗口大小一起记录到监控里,才能回答「窗口从 20 调到 40 到底多花多少钱」「换成向量记忆后注入长度下降了几成」这类问题,maxMessages 的取值也才有依据。此外文档还有一个容易被忽视的实现说明:内置 Advisor 在标准调用中执行阻塞操作、在流式调用中执行非阻塞操作,且每个 Advisor 类 Builder 上可配置流式调用所用的 Reactor Scheduler——高并发场景下,记忆仓库换成 Redis/JDBC 并留意线程模型,是容量规划的一部分而非可选项。

你现在应该掌握了:消息条数窗口的边界效应、三种历史注入方式的组合公式、五层治理手段的分工,以及内置 Advisor 阻塞/非阻塞线程模型的实现约束。

版本演进与迁移注意:别被旧教程带偏

官方文档专门用 Breaking API Changes 一节记录了 Advisors API 的更名史,这段历史对读旧文章的人极其重要。在 1.0 M2 时代,存在两个分离的接口:RequestAdvisor 在 ChatModel.call 与 ChatModel.stream 之前被调用,ResponseAdvisor 在这两个方法之后被调用。到了 1.0 M3,二者被合并替换为 CallAroundAdvisor 与 StreamAroundAdvisor,原先挂在 ResponseAdvisor 上的 StreamResponseMode 被移除。再到 1.0.0 正式版,又一次更名:CallAroundAdvisor 改为 CallAdvisor,StreamAroundAdvisor 改为 StreamAdvisor,两个 Chain 接口同步改名,承载请求响应的类型也从 AdvisedRequest/AdivsedResponse(注意文档原文里后者就是个拼写错误的痕迹)换成了 ChatClientRequest/ChatClientResponse。

上下文数据的传递方式同样经历过颠覆。M2 时代的 context map 是独立的可变方法参数,沿着链一路传递谁都能改;M3 起 map 并入 AdvisedRequest 与 AdvisedResponse record 且变为不可变,更新必须调用 updateContext 生成一个包含变更内容的新不可变 map。这解释了为什么当前范例里所有改写都走 mutate() 构建新对象——不可变不是洁癖,是框架在并发与可观测性两方面的刻意设计:同一次调用的请求快照可以被 trace 完整记录,而不用担心某个下游 Advisor 改掉了历史。

对应到迁移动作,自查清单很短:全文搜索旧接口名并替换为 CallAdvisor/StreamAdvisor;把所有直接改字段或改 map 的逻辑改写为 mutate()/updateContext 风格;检查是否依赖了已被移除的 StreamResponseMode;确认自定义 Advisor 实现了新的 getName() 契约。如果你的代码是从 M2 或 M3 一路升上来的,建议在升级后先跑一遍 Advisor 链的顺序断言测试——尤其是依赖相同 order 值「碰巧正确」的代码,文档明说相同 order 的执行顺序不保证,任何升级都可能洗牌。

你现在应该掌握了:RequestAdvisor/ResponseAdvisor → CallAroundAdvisor/StreamAroundAdvisor → CallAdvisor/StreamAdvisor 的三段更名脉络、可变 context map 到不可变 record 的设计转向,以及落地迁移时的四项自查动作。

踩坑经验

第一组:会话 ID

❌ 错误做法:注册了 MessageChatMemoryAdvisor,调用时却忘了传会话 ID,以为框架会自己生成一个默认值。

// 运行时直接炸:IllegalArgumentException
String r = chatClient.prompt().user("记住我的工号是 A1024").call().content();

✅ 正确做法:每次调用都显式传入,且 ID 的隔离粒度要符合业务。

String r = chatClient.prompt()
    .user("记住我的工号是 A1024")
    .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "user-42-session-1"))
    .call()
    .content();

第二组:顺序颠倒导致检索质量劣化

❌ 错误做法:把 QuestionAnswerAdvisor 排在 MessageChatMemoryAdvisor 之前,RAG 只能看到光秃秃的用户问题,「它」「刚才那个」全部失指。

.defaultAdvisors(
    QuestionAnswerAdvisor.builder(vectorStore).build(),      // 先检索:只有当前问题
    MessageChatMemoryAdvisor.builder(chatMemory).build()     // 后注入历史
)

✅ 正确做法:记忆在前、RAG 在后,让检索条件包含完整对话上下文。

.defaultAdvisors(
    MessageChatMemoryAdvisor.builder(chatMemory).build(),
    QuestionAnswerAdvisor.builder(vectorStore).build()
)

第三组:内存仓库上生产

❌ 错误做法:单实例用 InMemoryChatMemoryRepository 没问题,扩容到两个实例后用户轮流「失忆」——每台 JVM 各记一半会话。

ChatMemory chatMemory = MessageWindowChatMemory.builder()
    .chatMemoryRepository(new InMemoryChatMemoryRepository()) // 重启即失、多实例不共享
    .build();

✅ 正确做法:生产环境切换到持久化仓库,例如 JDBC 或 Redis(具体建表与依赖以当前版本官方文档为准)。

ChatMemory chatMemory = MessageWindowChatMemory.builder()
    .chatMemoryRepository(jdbcChatMemoryRepository) // JdbcChatMemoryRepository 等持久化实现
    .maxMessages(40)
    .build();

第四组:日志裸奔

❌ 错误做法:直接 new SimpleLoggerAdvisor() 后把 advisor 包日志开到 DEBUG,请求里的个人隐私、响应里的内部数据原样落盘。

logging:
  level:
    org.springframework.ai.chat.client.advisor: DEBUG  # 全量记录,敏感信息泄漏

✅ 正确做法:用三参构造函数自定义脱敏输出,只保留排查所需的字段。

SimpleLoggerAdvisor customLogger = new SimpleLoggerAdvisor(
    request -> "Custom request: " + request.prompt().getUserMessage(),
    response -> "Custom response: " + response.getResult(),
    0
);

第五组:重复注册 ToolCallingAdvisor

❌ 错误做法:不了解自动注册机制,手动往链上加第二个 ToolCallingAdvisor,或自己实现了工具循环又忘了禁用自动注册,工具被执行两轮。

// 错误:与自动注册的实例叠加,工具调用循环被驱动两次
.advisors(new ToolCallingAdvisor.Builder().build())

✅ 正确做法:要么信任自动注册什么都不加;要么自定义工具 Advisor 时实现 ToolAdvisor 标记接口让自动注册自动失效;要么按需显式禁用(全局 spring.ai.chat.client.tool-calling.enabled=false 或单次 AdvisorParams.toolCallingAdvisorAutoRegister(false))。

第六组:相同的 order 值

❌ 错误做法:给链上多个 Advisor 都默认 order=0,并默默依赖注册顺序作为执行顺序——文档明确说相同 order 的执行顺序不保证,升级版本后行为可能悄悄变化。

✅ 正确做法:为每个 Advisor 显式分配有设计意图的 order 值,需要插桩观察的靠后(接近 LOWEST_PRECEDENCE),需要优先改写请求的靠前,并在代码注释里写清楚排序理由。

最佳实践清单

  • 用 builder 的 defaultAdvisors() 在构建期注册通用 Advisor,运行期只通过 advisors() 传会话 ID 这类动态参数,保持「构建期结构稳定、运行期参数可变」。
  • 链上顺序即数据流:记忆 → RAG → 业务增强 → 日志;写入顺序前先推演「前一个 Advisor 的修改如何影响后一个」。
  • 每个 Advisor 显式实现 getName() 与 getOrder();名字唯一、order 有设计意图,绝不依赖默认值与巧合顺序。
  • 自定义 Advisor 同时实现 CallAdvisor 与 StreamAdvisor;流式侧用 ChatClientMessageAggregator 观察完整响应,但记住它是只读的。
  • 需要 before/after 两段式写法时优先继承 BaseAdvisor;需要跨 Advisor 共享状态时走 advise-context,而不是线程局部变量。
  • 记忆仓库按环境选型:开发用 InMemory,生产用 Jdbc/Redis 等持久化实现;窗口大小按轮次与 token 成本实测后设定。
  • 永远显式传 ChatMemory.CONVERSATION_ID,并把会话 ID 的隔离粒度写进设计文档。
  • 生产日志一律用 SimpleLoggerAdvisor(requestToString, responseToString, order) 自定义脱敏,再开 DEBUG。
  • 利用 Advisor 自带的可观测性:文档明确 Advisor 参与 Observability 体系,可查看执行相关的指标与链路追踪,排障时先查 trace 再查日志。
  • 升级前读一遍 Breaking Changes:1.0 M2 的 RequestAdvisor/ResponseAdvisor、1.0 M3 的 CallAroundAdvisor/StreamAroundAdvisor 均已更名重构,旧教程里的类名不要照抄。

总结

  1. Advisors API 是 Spring AI 的拦截层标准答案:它把记忆、RAG、日志、内容安全、工具循环等横切关注点从业务代码中剥离,封装为可复用、可排序、可移植的链式组件。
  2. 链是栈:getOrder() 值越小越先处理请求、越后处理响应;顺序即数据流,记忆 Advisor 必须排在 RAG Advisor 之前。
  3. 会话记忆 = ChatMemory 接口 + MessageWindowChatMemory 滑动窗口(默认 20 条,保 system message)+ ChatMemoryRepository 存储抽象(六种实现任选);CONVERSATION_ID 必填且无默认值。
  4. RAG 有朴素与模块化两档:QuestionAnswerAdvisor 快速落地,RetrievalAugmentationAdvisor 基于 rag 包组件构建模块化管线。
  5. ToolCallingAdvisor 默认自动注册于 HIGHEST_PRECEDENCE + 300;自定义工具 Advisor 实现 ToolAdvisor 标记接口即可抑制重复注册。
  6. 自定义 Advisor 的最优路径是:双接口实现、显式 name/order、mutate 不可变改写、advise-context 传状态——这套范式同时覆盖观察型与增强型需求。

参考资料

  • Advisors API — Spring AI Reference(2.0.1):https://docs.spring.io/spring-ai/reference/api/advisors.html
  • Chat Client API — Spring AI Reference(2.0.1):https://docs.spring.io/spring-ai/reference/api/chatclient.html
  • 文中提及的 Re2 技术论文:《Re-Reading Improves Reasoning in Large Language Models》(官方文档引用)