前言
如果你用 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。它的行为在文档中写得很清楚,我们逐条拆解:
- 维护一个消息窗口,最大容量有上限,默认 20 条消息;
- 消息数超过上限时,淘汰较旧的消息,但 system message 会被保留;
- 如果新加入一条 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 均已更名重构,旧教程里的类名不要照抄。
总结
- Advisors API 是 Spring AI 的拦截层标准答案:它把记忆、RAG、日志、内容安全、工具循环等横切关注点从业务代码中剥离,封装为可复用、可排序、可移植的链式组件。
- 链是栈:
getOrder()值越小越先处理请求、越后处理响应;顺序即数据流,记忆 Advisor 必须排在 RAG Advisor 之前。 - 会话记忆 =
ChatMemory接口 +MessageWindowChatMemory滑动窗口(默认 20 条,保 system message)+ChatMemoryRepository存储抽象(六种实现任选);CONVERSATION_ID必填且无默认值。 - RAG 有朴素与模块化两档:
QuestionAnswerAdvisor快速落地,RetrievalAugmentationAdvisor基于 rag 包组件构建模块化管线。 ToolCallingAdvisor默认自动注册于HIGHEST_PRECEDENCE + 300;自定义工具 Advisor 实现ToolAdvisor标记接口即可抑制重复注册。- 自定义 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》(官方文档引用)