Spring AI 源码解析:MessageChatMemoryAdvisor 是如何让大模型"记住你"的
本文基于 Spring AI 1.0.0 源码,深入分析
MessageChatMemoryAdvisor的设计思路与实现细节。
一、背景:大模型天生"健忘"
LLM 本身是无状态的。每次调用都是一次全新的推理,不携带任何上下文。这意味着如果你问完"我叫张三",下一句问"我叫什么",模型一无所知。
要实现真正意义上的"多轮对话",开发者必须在每次请求时手动把历史消息带上。这个过程繁琐、易出错,而且和业务代码高度耦合。
Spring AI 通过 Advisor 机制 将这个问题优雅地封装起来,MessageChatMemoryAdvisor 就是这套机制下的核心实现之一。
二、Advisor 机制:Spring AI 的 AOP
在理解 MessageChatMemoryAdvisor 之前,必须先理解 Spring AI 的 Advisor 设计。
Advisor 是 Spring AI 对 LLM 调用链的横切关注点抽象,与 Spring AOP 的思想一脉相承:
css
用户请求
↓
[Advisor1.before] → [Advisor2.before] → ... → LLM 调用
↓
[Advisor1.after] ← [Advisor2.after] ← ... ← LLM 响应
核心接口定义在 BaseAdvisor:
java
// BaseAdvisor.java
public interface BaseAdvisor extends CallAdvisor, StreamAdvisor {
// 请求前拦截
ChatClientRequest before(ChatClientRequest request, AdvisorChain chain);
// 响应后拦截
ChatClientResponse after(ChatClientResponse response, AdvisorChain chain);
// 默认使用 boundedElastic 调度器(流式场景)
Scheduler DEFAULT_SCHEDULER = Schedulers.boundedElastic();
}
adviseCall 的默认实现骨架就是 before → 下一个 → after 的责任链:
java
default ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
ChatClientRequest processed = before(request, chain);
ChatClientResponse response = chain.nextCall(processed);
return after(response, chain);
}
三、整体架构
MessageChatMemoryAdvisor 的完整继承链如下:
scss
Ordered (Spring)
└── Advisor
└── CallAdvisor / StreamAdvisor
└── BaseAdvisor ← 定义 before/after 骨架
└── BaseChatMemoryAdvisor ← 提供 getConversationId()
└── MessageChatMemoryAdvisor ← 本文主角
涉及的存储层类图:
scss
ChatMemory (interface)
└── MessageWindowChatMemory ← 滑动窗口实现,默认最多 20 条消息
└── uses ──→ ChatMemoryRepository (interface)
└── InMemoryChatMemoryRepository ← ConcurrentHashMap 存储
└── (Redis / JDBC 等自定义实现)
这个分层设计非常清晰:ChatMemory 负责业务语义(窗口管理、消息淘汰),ChatMemoryRepository 负责物理存储,两者通过接口解耦,存储层随时可替换。
四、源码深度解析
4.1 before() --- 请求注入记忆
java
@Override
public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) {
String conversationId = getConversationId(chatClientRequest.context());
// Step 1: 从记忆存储中取出当前会话的历史消息
List<Message> memoryMessages = this.chatMemory.get(conversationId);
// Step 2: 历史消息 + 当前请求消息,合并成新的消息列表
List<Message> processedMessages = new ArrayList<>(memoryMessages);
processedMessages.addAll(chatClientRequest.prompt().getInstructions());
// Step 3: 确保 SystemMessage 始终排在第一位
for (int i = 0; i < processedMessages.size(); i++) {
if (processedMessages.get(i) instanceof SystemMessage) {
Message systemMessage = processedMessages.remove(i);
processedMessages.add(0, systemMessage);
break;
}
}
// Step 4: 构造新的 ChatClientRequest(不可变对象,mutate 模式)
ChatClientRequest processedRequest = chatClientRequest.mutate()
.prompt(chatClientRequest.prompt().mutate().messages(processedMessages).build())
.build();
// Step 5: 将当前用户消息(或 ToolResponse)存入记忆
Message userMessage = processedRequest.prompt().getLastUserOrToolResponseMessage();
this.chatMemory.add(conversationId, userMessage);
return processedRequest;
}
几个值得关注的设计细节:
① 历史在前,新消息在后
memoryMessages 先加,currentMessages 后加。这符合大多数模型对消息顺序的要求:先看历史上下文,最后处理当前输入。
② SystemMessage 置顶保证
合并后的消息列表中,SystemMessage(系统提示词)可能因为历史消息插入而"跑偏"。这里有一次 O(n) 扫描,找到后移到 index 0,确保模型角色设定始终生效。
③ 存入的是用户消息,不是所有消息
注意 Step 5,调用 getLastUserOrToolResponseMessage() 只存入最后一条用户消息或工具响应,而不是整个 processedMessages。这是因为历史消息已经在 ChatMemory 里,不需要重复存储。
④ 不可变对象 + mutate 模式
ChatClientRequest 是不可变的,通过 .mutate() 创建新实例,这是函数式/响应式编程中经典的不可变数据流模式,避免了并发状态共享问题。
4.2 after() --- 响应保存记忆
java
@Override
public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) {
List<Message> assistantMessages = new ArrayList<>();
if (chatClientResponse.chatResponse() != null) {
assistantMessages = chatClientResponse.chatResponse()
.getResults()
.stream()
.map(g -> (Message) g.getOutput()) // Generation → AssistantMessage
.toList();
}
this.chatMemory.add(this.getConversationId(chatClientResponse.context()), assistantMessages);
return chatClientResponse;
}
逻辑极简:从响应里提取 AssistantMessage 列表,存入 ChatMemory。下次 before() 调用时,这些消息就会被取出注入到新请求中。
4.3 adviseStream() --- 流式场景的特殊处理
流式场景下(SSE/Streaming),模型的回答是分 token 逐步推送的,不能在每个分片到来时就调用 after(),否则每个 token 都会存一次记忆,造成数据混乱。
MessageChatMemoryAdvisor 重写了 adviseStream():
java
@Override
public Flux<ChatClientResponse> adviseStream(ChatClientRequest chatClientRequest,
StreamAdvisorChain streamAdvisorChain) {
return Mono.just(chatClientRequest)
.publishOn(scheduler) // 切换到 boundedElastic 线程
.map(request -> this.before(request, streamAdvisorChain)) // 注入记忆
.flatMapMany(streamAdvisorChain::nextStream) // 下发到模型,得到 Flux<chunk>
.transform(flux -> new ChatClientMessageAggregator()
.aggregateChatClientResponse(flux,
response -> this.after(response, streamAdvisorChain))); // 聚合完再存
}
关键在 ChatClientMessageAggregator:它会收集所有 chunk,等 Flux 完成后聚合成完整响应,然后才触发 after()。这保证了存入记忆的永远是完整的 AI 回复。
对比 BaseAdvisor 的默认流式实现:
BaseAdvisor 的默认实现是在 onFinishReason 触发时调 after()(即收到 [DONE] 信号时)。而 MessageChatMemoryAdvisor 选择通过 Aggregator 聚合后统一处理,语义更清晰,也便于处理多个 Generation 结果的场景。
4.4 ChatMemory 存储层:MessageWindowChatMemory
MessageWindowChatMemory 是 Spring AI 内置的滑动窗口实现,有几个值得关注的行为:
java
private static final int DEFAULT_MAX_MESSAGES = 20; // 默认最多保留 20 条
private List<Message> process(List<Message> memoryMessages, List<Message> newMessages) {
// 如果新消息包含 SystemMessage,清除旧的所有 SystemMessage(角色切换场景)
boolean hasNewSystemMessage = newMessages.stream()
.filter(SystemMessage.class::isInstance)
.anyMatch(message -> !memoryMessagesSet.contains(message));
// 合并消息
...
// 超出上限时,优先淘汰旧的非 SystemMessage 消息
// SystemMessage 永远不会被淘汰(保护系统提示词)
for (Message message : processedMessages) {
if (message instanceof SystemMessage || removed >= messagesToRemove) {
trimmedMessages.add(message);
} else {
removed++;
}
}
}
两个重要语义:
SystemMessage永远不会被窗口淘汰,确保模型角色设定始终有效- 新加入的
SystemMessage会覆盖旧的,支持动态切换角色
五、完整使用示例
5.1 基础用法
java
@Configuration
public class ChatConfig {
@Bean
public ChatMemory chatMemory() {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(new InMemoryChatMemoryRepository())
.maxMessages(20) // 保留最近 20 条
.build();
}
@Bean
public ChatClient chatClient(ChatModel chatModel, ChatMemory chatMemory) {
return ChatClient.builder(chatModel)
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build()
)
.build();
}
}
java
@RestController
@RequestMapping("/chat")
public class ChatController {
private final ChatClient chatClient;
@PostMapping
public String chat(@RequestParam String sessionId,
@RequestParam String message) {
return chatClient.prompt()
.user(message)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))
.call()
.content();
}
}
5.2 流式对话
java
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestParam String sessionId,
@RequestParam String message) {
return chatClient.prompt()
.user(message)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))
.stream()
.content();
}
5.3 自定义 Redis 持久化存储
java
@Component
public class RedisChatMemoryRepository implements ChatMemoryRepository {
private final RedisTemplate<String, List<Message>> redisTemplate;
private static final Duration TTL = Duration.ofHours(24);
@Override
public List<Message> findByConversationId(String conversationId) {
List<Message> messages = redisTemplate.opsForValue().get(conversationId);
return messages != null ? messages : List.of();
}
@Override
public void saveAll(String conversationId, List<Message> messages) {
redisTemplate.opsForValue().set(conversationId, messages, TTL);
}
@Override
public void deleteByConversationId(String conversationId) {
redisTemplate.delete(conversationId);
}
@Override
public List<String> findConversationIds() {
return List.of();
}
}
// 注入自定义 Repository
@Bean
public ChatMemory chatMemory(RedisChatMemoryRepository redisRepo) {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(redisRepo)
.maxMessages(30)
.build();
}
5.4 自定义 Advisor 执行顺序
java
// Advisor 的 order 越小,越先执行(HIGHEST_PRECEDENCE = Integer.MIN_VALUE)
// DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER = Integer.MIN_VALUE + 1000
// 通常不需要修改,但多个 Advisor 配合时可能需要调整
MessageChatMemoryAdvisor.builder(chatMemory)
.order(Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER) // 默认值
.build()
六、与 PromptChatMemoryAdvisor 的对比
Spring AI 提供了两种记忆注入策略,适用不同场景:
| 维度 | MessageChatMemoryAdvisor |
PromptChatMemoryAdvisor |
|---|---|---|
| 注入方式 | 历史消息以独立 Message 对象加入消息列表 | 历史拼接成文本,注入到 SystemMessage 中 |
| 原生多轮支持 | 是,充分利用模型原生 role: user/assistant 格式 |
否,历史是文本块 |
| Token 效率 | 较高(模型原生理解) | 较低(文本拼接有冗余) |
| 适用模型 | 支持 Chat Completions 格式的模型 | 任意文本模型 |
| 可读性调试 | 消息结构清晰 | 历史混入 system prompt,调试稍复杂 |
结论: 现代 LLM(GPT-4、Claude、Qwen 等)均原生支持多轮消息格式,优先选择 MessageChatMemoryAdvisor。
七、生产环境注意事项
7.1 conversationId 的生命周期管理
conversationId 是记忆隔离的唯一依据,通常与用户的 Session 或对话 ID 绑定:
java
// 不要用固定字符串,每次对话都要用唯一 ID
advisors(a -> a.param(ChatMemory.CONVERSATION_ID, UUID.randomUUID().toString())) // 错误:每次调用都是新会话
// 正确:与用户 session 关联
advisors(a -> a.param(ChatMemory.CONVERSATION_ID, userSessionId))
7.2 内存泄漏风险
使用 InMemoryChatMemoryRepository 时,历史会话数据常驻内存,不会自动清理。建议:
- 使用 Redis 等带 TTL 的存储
- 对话结束时主动调用
chatMemory.clear(conversationId)
java
// 对话结束时清理
@DeleteMapping("/session/{sessionId}")
public void endSession(@PathVariable String sessionId) {
chatMemory.clear(sessionId);
}
7.3 maxMessages 与 Token 限制的关系
maxMessages 控制保留的消息条数,但每条消息的 token 数不可控。建议:
- 为业务场景估算平均消息长度
- 选择合适的
maxMessages,留出足够 token 空间给模型输出 - 或者实现基于 Token 计数的自定义
ChatMemory
7.4 线程安全
MessageWindowChatMemory 底层的 InMemoryChatMemoryRepository 使用 ConcurrentHashMap 保证线程安全,但 saveAll 是整体替换操作,高并发下同一 conversationId 的并发写入可能存在覆盖风险。生产环境建议使用数据库/Redis 并配合乐观锁或单会话串行处理。
八、扩展:自定义 ChatMemory 实现
如果需要对记忆进行更精细的控制(如按 Token 数裁剪、摘要压缩),可以直接实现 ChatMemory 接口:
java
public class SummarizingChatMemory implements ChatMemory {
private final ChatMemoryRepository repository;
private final ChatClient summarizer; // 用另一个 LLM 做摘要
private final int maxMessages;
@Override
public void add(String conversationId, List<Message> messages) {
List<Message> existing = repository.findByConversationId(conversationId);
List<Message> merged = new ArrayList<>(existing);
merged.addAll(messages);
if (merged.size() > maxMessages) {
// 对旧消息做摘要压缩
String summary = summarizer.prompt()
.user("请将以下对话总结为简短摘要:\n" + formatMessages(merged.subList(0, merged.size() - 5)))
.call().content();
List<Message> compressed = new ArrayList<>();
compressed.add(new SystemMessage("历史对话摘要:" + summary));
compressed.addAll(merged.subList(merged.size() - 5, merged.size()));
merged = compressed;
}
repository.saveAll(conversationId, merged);
}
// ... get / clear 实现
}
九、总结
MessageChatMemoryAdvisor 的设计体现了几个值得借鉴的工程思想:
- 关注点分离:Advisor 链将记忆管理与业务代码完全解耦,业务层无感知
- 存储与策略分离 :
ChatMemory(窗口策略)与ChatMemoryRepository(物理存储)两层抽象,存储可插拔 - 不可变数据流 :
ChatClientRequest通过 mutate 模式传递,天然线程安全,与响应式编程契合 - 流式与同步统一 :通过
Aggregator将流式回调统一收拢,对上层屏蔽异步细节 - 防御性排序 :合并消息后主动修正
SystemMessage位置,消除因消息顺序导致的模型行为不一致
对于想要在 Spring Boot 应用中快速落地多轮对话能力的开发者,MessageChatMemoryAdvisor + MessageWindowChatMemory + 自定义持久化存储是目前最工程化的组合方案。
参考代码路径(基于 spring-ai 主干):
spring-ai-client-chat/.../advisor/MessageChatMemoryAdvisor.javaspring-ai-client-chat/.../advisor/api/BaseAdvisor.javaspring-ai-model/.../chat/memory/MessageWindowChatMemory.javaspring-ai-model/.../chat/memory/InMemoryChatMemoryRepository.java