Spring AI 的 Advisor 链——我用了 3 个月才搞明白它到底是什么
Spring AI 的 Advisor 链——我用了 3 个月才搞明白它到底是什么
三个月前,我在项目里第一次看到 Advisor 这个概念,脑子里第一反应是:这不就是 Spring AOP 的马甲吗?
然后我就犯了一个错误,一个让我在 code review 上被同事鄙视了整整半个月的错误。
我把 QuestionAnswerAdvisor 当成了切面,把 LoggingAdvisor 当成了日志拦截器,甚至还洋洋得意地跟组里说:「Spring AI 这个设计挺好的,AOP 思想用得很溜。」
同事当时没说什么,只是给我发了一句话:「你去看一下 AdvisedRequest 和 AdvisedResponse 里面装了什么东西。」
我去看了,然后沉默了大概五分钟。
Advisor 不是拦截器,我当时理解错了什么
AOP 拦截器做的事情是:在方法执行前后插入逻辑,但核心是不修改方法本身的输入输出(当然你可以强行改,但那不是设计意图)。
Advisor 做的事情完全不同。它的核心是:在请求到达模型之前,修改这个请求;在响应回来之后,修改这个响应。更重要的是,它维护着一个上下文,这个上下文在整条 Advisor 链上共享、传递、累积。
换句话说,Advisor 是一条上下文增强链,不是执行监控链。
来看一下 Advisor 接口的定义:
public interface CallAroundAdvisor extends Advisor {
AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain);
}注意这里——aroundCall 接收的是 AdvisedRequest,返回的是 AdvisedResponse。这两个对象里面装的不只是原始的 prompt 和 response,还有一个 Map<String, Object> adviseContext,这个 Map 就是那条链上的共享上下文。
每个 Advisor 都可以往这个 context 里写东西,也可以读前面的 Advisor 写进去的东西。这就是「链」的真正含义——不是简单的前后插入,而是有状态传递的增强管道。
我当时把 Advisor 当 AOP 用,最直接的后果就是:我写了一个「日志 Advisor」,只记录了请求进来的时间和出去的时间,完全没利用 context。后来加 token 统计的时候,我发现根本拿不到前面 Advisor 处理后的中间状态,只能在最外层再包一层,代码丑得一批。
三个内置 Advisor 到底有什么区别
搞明白了 context 机制之后,再来看 Spring AI 内置的几个 Advisor,就清楚多了。
LoggingAdvisor
最简单的一个,官方实现差不多就是这样:
public class SimpleLoggerAdvisor implements CallAroundAdvisor, StreamAroundAdvisor {
private static final Logger logger = LoggerFactory.getLogger(SimpleLoggerAdvisor.class);
@Override
public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) {
logger.debug("BEFORE: {}", advisedRequest);
AdvisedResponse advisedResponse = chain.nextAroundCall(advisedRequest);
logger.debug("AFTER: {}", advisedResponse);
return advisedResponse;
}
@Override
public String getName() {
return this.getClass().getSimpleName();
}
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}
}这个 Advisor 基本上是记录日志用的,不往 context 里写东西,只是观察。getOrder() 返回最低优先级,意味着它在链的最外层——能看到最原始的请求和最终的响应。
QuestionAnswerAdvisor
这个才是 RAG 的关键。它做的事情是:在请求到达模型之前,去向量数据库检索相关文档,然后把检索结果注入到 prompt 里。
// 简化版,展示核心逻辑
@Override
public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) {
// 1. 从 advisedRequest 里拿到用户的问题
String userMessage = advisedRequest.userText();
// 2. 去向量数据库检索
SearchRequest searchRequest = SearchRequest.builder()
.query(userMessage)
.topK(this.defaultSearchRequest.getTopK())
.build();
List<Document> documents = this.vectorStore.similaritySearch(searchRequest);
// 3. 把检索结果格式化,注入到系统 prompt 里
String documentContext = documents.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n"));
// 4. 修改 advisedRequest,加入文档上下文
AdvisedRequest processedRequest = AdvisedRequest.from(advisedRequest)
.withSystemText(advisedRequest.systemText() + "\n\nContext:\n" + documentContext)
.build();
// 5. 把检索到的文档放进 context,供后续 Advisor 或调用方使用
processedRequest.adviseContext().put("qa_retrieved_documents", documents);
return chain.nextAroundCall(processedRequest);
}注意第 5 步——它把检索到的文档放进了 context。这样调用方后面可以从响应里拿到这些文档,用于溯源、展示引用来源等。这就是 context 机制的价值,纯 AOP 做不到这个。
PromptChatMemoryAdvisor
这个负责对话历史管理。它会从 ChatMemory 里读取历史对话,拼到当前请求的 prompt 里。
// 使用示例
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(
new PromptChatMemoryAdvisor(new InMemoryChatMemory())
)
.build();
// 每次调用时指定 conversationId
String response = chatClient.prompt()
.user("你好,我叫老张")
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "user-123"))
.call()
.content();它会把历史对话注入成这样的格式(进 system prompt 或者 user message,取决于具体实现):
以下是之前的对话历史:
用户:你好,我叫老张
助手:你好,老张!有什么可以帮你的吗?
用户:[当前问题]Advisor 的执行顺序——order 值越小越先执行
这里有个坑我踩过。Spring AI 的 Advisor 链,order 值越小,越接近「外层」,也就是越先处理请求、越后处理响应。
用 Mermaid 画一下:
这个顺序是有讲究的:
QuestionAnswerAdvisor应该靠近模型,因为它修改的是发给模型的 promptPromptChatMemoryAdvisor在它外面,因为历史对话要在 RAG 检索之前就准备好LoggingAdvisor在最外面,这样它记录的是最终状态,不是中间过程
如果顺序搞反了,比如把 QuestionAnswerAdvisor 放最外面,它看到的用户问题还没有被历史对话上下文增强,检索效果会变差。
实际配置:
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(
new SimpleLoggerAdvisor(), // order = Integer.MAX_VALUE (最外)
new PromptChatMemoryAdvisor(chatMemory), // order = 0 (默认)
new QuestionAnswerAdvisor(vectorStore, // order = 0 (默认,但在内存之后加)
SearchRequest.defaults())
)
.build();自己踩过的另一个坑:Streaming 场景
我以为实现了 CallAroundAdvisor 就够了,结果上了流式接口直接出问题。
Spring AI 的流式接口走的是另一条链:StreamAroundAdvisor。两个接口是分开的:
public interface StreamAroundAdvisor extends Advisor {
Flux<AdvisedResponse> aroundStream(AdvisedRequest advisedRequest, StreamAroundAdvisorChain chain);
}如果你的自定义 Advisor 只实现了 CallAroundAdvisor,在 streaming 场景下它会被完全跳过,没有任何报错,静默失效。
我排查了大半天,最后在 debug 里发现 Advisor 链里根本没有我的 Advisor,才想起来去看接口定义。
教训:自定义 Advisor 一定要同时实现两个接口,除非你确定你的业务场景不用流式。
重头戏:自定义 Advisor 实现 Token 用量统计
说了这么多,来一个完整的实战案例。我们要实现一个 Token 用量统计的 Advisor,功能:
- 记录每次请求的 token 消耗(prompt tokens + completion tokens)
- 按用户维度累计统计
- 支持按天/按月聚合查询
先定义数据存储接口:
public interface TokenUsageStore {
void record(String userId, String conversationId, int promptTokens, int completionTokens);
TokenUsageSummary queryByUser(String userId, LocalDate date);
}
@Data
public class TokenUsageSummary {
private String userId;
private int totalPromptTokens;
private int totalCompletionTokens;
private int totalTokens;
private LocalDate date;
}然后是核心的 Advisor 实现:
@Component
public class TokenUsageAdvisor implements CallAroundAdvisor, StreamAroundAdvisor {
private static final Logger log = LoggerFactory.getLogger(TokenUsageAdvisor.class);
// context 里存 userId 的 key,调用方传入
public static final String USER_ID_KEY = "token_usage_user_id";
public static final String CONVERSATION_ID_KEY = "token_usage_conversation_id";
private final TokenUsageStore tokenUsageStore;
public TokenUsageAdvisor(TokenUsageStore tokenUsageStore) {
this.tokenUsageStore = tokenUsageStore;
}
@Override
public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) {
// 先往下走,拿到响应
AdvisedResponse response = chain.nextAroundCall(advisedRequest);
// 从响应里拿 usage 信息
recordUsage(advisedRequest, response.response());
return response;
}
@Override
public Flux<AdvisedResponse> aroundStream(AdvisedRequest advisedRequest, StreamAroundAdvisorChain chain) {
// 流式场景稍微复杂,需要在流结束时统计
// 用 AtomicReference 收集最终的 usage
AtomicReference<Usage> usageRef = new AtomicReference<>();
return chain.nextAroundStream(advisedRequest)
.doOnNext(advisedResponse -> {
// 流式响应里,usage 在最后一个 chunk 里
if (advisedResponse.response() != null
&& advisedResponse.response().getMetadata() != null) {
Usage usage = advisedResponse.response().getMetadata().getUsage();
if (usage != null && usage.getTotalTokens() > 0) {
usageRef.set(usage);
}
}
})
.doOnComplete(() -> {
Usage usage = usageRef.get();
if (usage != null) {
doRecord(advisedRequest, usage);
}
});
}
private void recordUsage(AdvisedRequest request, ChatResponse chatResponse) {
if (chatResponse == null || chatResponse.getMetadata() == null) {
return;
}
Usage usage = chatResponse.getMetadata().getUsage();
if (usage == null) {
log.warn("ChatResponse 里没有 Usage 信息,可能模型不支持或配置有问题");
return;
}
doRecord(request, usage);
}
private void doRecord(AdvisedRequest request, Usage usage) {
// 从 context 里拿 userId 和 conversationId
Map<String, Object> context = request.adviseContext();
String userId = (String) context.getOrDefault(USER_ID_KEY, "anonymous");
String conversationId = (String) context.getOrDefault(CONVERSATION_ID_KEY, "default");
int promptTokens = usage.getPromptTokens() != null ? usage.getPromptTokens() : 0;
int completionTokens = usage.getCompletionTokens() != null ? usage.getCompletionTokens() : 0;
log.info("Token usage - userId: {}, conversationId: {}, prompt: {}, completion: {}, total: {}",
userId, conversationId, promptTokens, completionTokens, promptTokens + completionTokens);
tokenUsageStore.record(userId, conversationId, promptTokens, completionTokens);
}
@Override
public String getName() {
return "TokenUsageAdvisor";
}
@Override
public int getOrder() {
// 放在比较外层,能看到完整的 request/response
return Ordered.LOWEST_PRECEDENCE - 100;
}
}调用方使用:
@Service
public class ChatService {
private final ChatClient chatClient;
public ChatService(ChatModel chatModel, TokenUsageAdvisor tokenUsageAdvisor,
PromptChatMemoryAdvisor memoryAdvisor) {
this.chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(
tokenUsageAdvisor,
memoryAdvisor
)
.build();
}
public String chat(String userId, String conversationId, String message) {
return chatClient.prompt()
.user(message)
.advisors(spec -> spec
.param(TokenUsageAdvisor.USER_ID_KEY, userId)
.param(TokenUsageAdvisor.CONVERSATION_ID_KEY, conversationId)
.param(ChatMemory.CONVERSATION_ID, conversationId)
)
.call()
.content();
}
}简单的 Redis 实现 TokenUsageStore:
@Component
public class RedisTokenUsageStore implements TokenUsageStore {
private final RedisTemplate<String, Object> redisTemplate;
public RedisTokenUsageStore(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public void record(String userId, String conversationId, int promptTokens, int completionTokens) {
String dateKey = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String key = "token:usage:" + userId + ":" + dateKey;
// 用 Hash 结构存储
redisTemplate.opsForHash().increment(key, "promptTokens", promptTokens);
redisTemplate.opsForHash().increment(key, "completionTokens", completionTokens);
redisTemplate.opsForHash().increment(key, "totalTokens", promptTokens + completionTokens);
redisTemplate.opsForHash().increment(key, "requestCount", 1);
// 保留 90 天
redisTemplate.expire(key, Duration.ofDays(90));
}
@Override
public TokenUsageSummary queryByUser(String userId, LocalDate date) {
String dateKey = date.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String key = "token:usage:" + userId + ":" + dateKey;
Map<Object, Object> data = redisTemplate.opsForHash().entries(key);
TokenUsageSummary summary = new TokenUsageSummary();
summary.setUserId(userId);
summary.setDate(date);
summary.setTotalPromptTokens(getIntValue(data, "promptTokens"));
summary.setTotalCompletionTokens(getIntValue(data, "completionTokens"));
summary.setTotalTokens(getIntValue(data, "totalTokens"));
return summary;
}
private int getIntValue(Map<Object, Object> data, String key) {
Object val = data.get(key);
if (val == null) return 0;
return Integer.parseInt(val.toString());
}
}Advisor 组合使用的完整示例
把上面这些组合起来,一个生产级的 ChatClient 配置:
@Configuration
public class ChatClientConfig {
@Bean
public ChatClient chatClient(
ChatModel chatModel,
VectorStore vectorStore,
ChatMemory chatMemory,
TokenUsageAdvisor tokenUsageAdvisor) {
return ChatClient.builder(chatModel)
.defaultSystem("""
你是一个专业的企业内部知识助手。
回答要准确、简洁,如果不确定请说明。
""")
.defaultAdvisors(
new SimpleLoggerAdvisor(),
tokenUsageAdvisor,
new PromptChatMemoryAdvisor(chatMemory),
new QuestionAnswerAdvisor(vectorStore,
SearchRequest.builder().topK(5).build())
)
.build();
}
}这里的顺序设计是:
SimpleLoggerAdvisor(最外):记录最终的完整请求和响应TokenUsageAdvisor:统计 token 消耗,放在内存 Advisor 外面,能看到加了历史记录之后的完整 token 数PromptChatMemoryAdvisor:注入历史对话QuestionAnswerAdvisor(最内):RAG 检索,基于包含历史上下文的完整问题进行检索
我现在对 Advisor 的理解
用一句话总结:Advisor 是一条有状态的请求/响应变换管道,不是观察者,是参与者。
每个 Advisor 都可以:
- 修改进入模型的 prompt(增加内容、替换内容、注入上下文)
- 修改从模型出来的响应(后处理、过滤、格式化)
- 往共享 context 里写数据供链上其他节点使用
- 从共享 context 里读前面节点写入的数据
这套机制让 Spring AI 的扩展性非常好,你可以组合任意数量的 Advisor,每个只做一件事,整条链实现复杂功能。
我花了三个月才真正理解这个,如果你刚开始用 Spring AI,希望这篇文章能让你少走一些弯路。
下一篇我打算写 Structured Output 的踩坑记录,那个坑比这个还深,模型一高兴给你返回带 markdown 代码块的 JSON,直接就炸了。
