Spring AI 对话记忆:MessageChatMemoryAdvisor 最小接入

Spring AI 对话记忆:MessageChatMemoryAdvisor 最小接入

假设你做了一个客服 AI。

用户第一轮问:

线上服务怎么申请扩容?

模型回答完以后,用户第二轮接着问:

我刚才问的是什么?

如果这时模型说"不知道",不是它记性差。

而是大模型本身是无状态的。

第二次调用时,你没有把上一轮对话带进去,它当然接不上前文。

很多 Java 开发者会在 Service 里手动处理:

text 复制代码
查历史消息
→ 拼到 prompt
→ 调模型
→ 保存本轮问题和回答

能跑,但项目一大就麻烦。

客服助手要记忆,知识库问答要记忆,日志分析 Agent 也要记住上下文。每个入口都手写一遍,最后业务代码会变成"对话记忆处理中心"。

Spring AI 里的 MessageChatMemoryAdvisor,就是为了解决这个问题。

它不是让模型真的拥有长期记忆,而是在每次调用前后,帮你维护当前会话需要的上下文。


一、先别手动拼历史消息

最常见的手写方式大概是这样:

java 复制代码
List<Message> history = chatHistoryRepository.findRecentMessages(userId, 10);

List<Message> messages = new ArrayList<>();
messages.addAll(history);
messages.add(new UserMessage(question));

Prompt prompt = new Prompt(messages);
ChatResponse response = chatModel.call(prompt);

chatHistoryRepository.save(userId, question, response);

这段代码的问题不是不能跑,而是不适合长期维护。

第一,容易重复。

每个 AI 入口都要查历史、拼消息、保存回复。

第二,容易漏。

有的接口只查了历史,忘了保存本轮回复;有的只按 userId 查,用户开多个会话就串了。

第三,策略不好统一。

今天保留最近 5 轮,明天改成 10 轮,后天想做摘要压缩。如果逻辑散在各个 Service 里,改起来很累。

更合理的做法是:

业务代码只负责提问,对话记忆交给统一的 Advisor 处理。


二、Spring AI 怎么拆这件事

Spring AI 的对话记忆不是一个类包办,而是几层分工:

text 复制代码
MessageChatMemoryAdvisor
  → 在 ChatClient 调用前后介入

ChatMemory
  → 决定给模型带哪些历史消息

MessageWindowChatMemory
  → 按窗口保留最近消息

ChatMemoryRepository
  → 负责存储和读取消息

这里最容易混的是 ChatMemoryChatMemoryRepository

ChatMemory 管的是"给模型看的上下文"。

它关心的是:

下一次调用模型时,要带哪些历史消息?

ChatMemoryRepository 管的是消息存储。

Spring AI 1.1.7 默认会自动配置:

text 复制代码
MessageWindowChatMemory + InMemoryChatMemoryRepository

MessageWindowChatMemory 默认最多保留 20 条消息。

注意,是 20 条消息,不是 20 轮对话。

一轮对话通常包含一条用户消息和一条助手消息,所以 20 条消息大概就是最近 10 轮左右。超过窗口后,较早的消息会被移出,但 system message 会保留。

InMemoryChatMemoryRepository 是内存存储,适合 Demo。

生产环境别直接依赖它。应用一重启,历史就没了;多实例部署时,每个实例也各存各的。


三、最小接入方式

如果你的项目已经能正常注入 ChatModel,接入对话记忆主要三步。

1. 准备 ChatMemory

只跑 Demo,可以先用内存版:

java 复制代码
@Configuration
public class ChatMemoryConfig {

    @Bean
    public ChatMemory chatMemory() {
        return MessageWindowChatMemory.builder()
            .maxMessages(20)
            .chatMemoryRepository(new InMemoryChatMemoryRepository())
            .build();
    }
}

如果你不声明自己的 ChatMemory,Spring AI 也会按默认规则自动配置一套。

这里显式写出来,是为了看清两个配置:

  • maxMessages(20):控制窗口大小;
  • chatMemoryRepository(...):控制消息存在哪里。

2. 配置 MessageChatMemoryAdvisor

接着把 MessageChatMemoryAdvisor 配到 ChatClient

java 复制代码
@Configuration
public class ChatClientConfig {

    @Bean
    public ChatClient chatClient(ChatModel chatModel, ChatMemory chatMemory) {
        return ChatClient.builder(chatModel)
            .defaultAdvisors(
                MessageChatMemoryAdvisor.builder(chatMemory).build()
            )
            .build();
    }
}

配置完成后,每次通过这个 ChatClient 调用模型,Advisor 都会参与。

它会在调用前读取历史消息,把历史作为 Message 注入请求,同时记录本轮用户消息;调用后,再把模型回复写回记忆。

这点很重要:

MessageChatMemoryAdvisor 不是把历史拼成一大段字符串,而是把历史作为消息列表交给模型。

3. 调用时传 conversationId

业务代码可以保持很干净:

java 复制代码
@Service
public class ChatService {

    private final ChatClient chatClient;

    public ChatService(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    public String chat(String conversationId, String question) {
        return chatClient.prompt()
            .user(question)
            .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
            .call()
            .content();
    }
}

重点是这一行:

java 复制代码
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))

在 Spring AI 1.1.7 里,内置记忆 Advisor 必须传 ChatMemory.CONVERSATION_ID

不传会抛 IllegalArgumentException

原因很简单:Advisor 必须知道这次调用属于哪个会话,才能读取和维护对应的历史消息。


四、两轮对话时发生了什么

第一次调用:

java 复制代码
chatService.chat("conv-001", "线上服务怎么申请扩容?");

这时 conv-001 还没有历史。

Advisor 会把当前用户问题发给模型,并把这条用户消息记下来。模型返回后,再把助手回复写入同一个会话。

第二次调用:

java 复制代码
chatService.chat("conv-001", "我刚才问的是什么?");

这次 Advisor 会先取出 conv-001 的历史消息。

模型看到的就不只是当前问题,而是:

text 复制代码
上一轮用户问题
上一轮模型回复
当前用户问题

所以它才能回答:

你刚才问的是线上服务怎么申请扩容。

业务代码没有手动查历史,也没有手动拼 prompt。

这些都交给 Advisor 处理了。


五、conversationId 别乱传

conversationId 是对话记忆里最容易踩坑的点。

不建议直接用 userId

因为一个用户可能同时有多个会话。

比如他在客服助手里问扩容,在知识库助手里问报销,又在日志分析 Agent 里查异常。如果都用同一个 userId,历史就会混在一起。

更合适的做法是给每段连续对话一个稳定 ID:

  • Web 场景:chatIdsessionId
  • App 场景:threadIdconversationUUID
  • 多 Agent 场景:userId + agentType + chatId

也不要每次请求都重新生成一个新的 conversationId

那样每次都是新会话,模型当然接不上前文。

记住一句话:

同一段连续对话里,conversationId 必须稳定;不同会话之间,conversationId 必须隔离。


六、生产环境注意两件事

1. 换掉内存存储

InMemoryChatMemoryRepository 适合本地开发,不适合生产。

它有三个问题:

  • 应用重启后历史消息丢失;
  • 多实例部署时,每个实例各存各的;
  • 不方便统一排查和运维。

Spring AI 提供了多种 ChatMemoryRepository 实现,比如 JDBC、MongoDB、Neo4j、Cassandra、Cosmos DB。

如果你们公司已经有统一存储,也可以自己实现 ChatMemoryRepository

2. 不要把 ChatMemory 当完整聊天记录表

ChatMemory 管的是"给模型看的上下文"。

它不是完整聊天记录库。

如果你需要用户查看历史、后台审计、客服质检、数据分析,建议单独设计业务聊天记录表。

可以这样分工:

text 复制代码
ChatMemory
  → 给模型看的短期上下文

业务聊天记录表
  → 给用户、后台、审计和分析看的完整记录

这两个东西不要混在一起。


七、排查问题先看这三点

如果第二轮还是"记不住",先查三件事:

  • 两轮调用的 conversationId 是否一致;
  • ChatClient 有没有配置 MessageChatMemoryAdvisor
  • 是否用了内存存储,并且应用重启或部署了多个实例。

另外,maxMessages=20 不是 20 轮对话,而是 20 条消息。

记忆窗口也不是越大越好。

历史越多,上下文越长,成本越高,也更容易把模型带偏。一般先从默认窗口跑通,再根据实际效果调整。


写在最后

对话记忆的本质很简单:

每次调用前,把当前会话需要的历史消息带进去。

Spring AI 把这件事拆成了几层:

text 复制代码
ChatMemory
  → 管理记忆窗口

ChatMemoryRepository
  → 存储和读取消息

MessageChatMemoryAdvisor
  → 调用前后维护记忆

conversationId
  → 区分不同会话

业务代码不用在每个 Service 里查历史、拼 prompt、保存回复。

把记忆交给 MessageChatMemoryAdvisor

把会话隔离交给 conversationId

把生产存储换成持久化 ChatMemoryRepository

这套链路理清以后,Spring AI 的对话记忆就不难了。


我是 Dilee,11 年 Java 老兵,专注 AI 落地应用。

后续会继续更新 Spring AI、RAG、Memory、Tool Calling、MCP 等实战内容。

完整系列也会同步整理在公众号「AI Agent 实战有术」。

相关推荐
游码峰行1 小时前
游戏脚本挂攻防-在PoW中实现动态Hash策略及应用实践
后端
一条泥憨鱼1 小时前
苍穹外卖【day6|微信登录与商品浏览功能】
后端·mybatis·苍穹外卖
用户762352425911 小时前
Kafka客户端消息流转流程
后端
橘子星1 小时前
深入理解 AJAX 中的 JSON 序列化与 JS 异步处理
前端·javascript·后端
SimonKing1 小时前
Qoder 提供免费 Qwen3.7-Max,无需订阅
java·后端·程序员
IT_陈寒2 小时前
SpringBoot自动配置这么智能,为啥我写的Bean注入不了?
前端·人工智能·后端
Csvn2 小时前
日志管理与排查 — journalctl & 系统日志实战
后端
zhenlai20123 小时前
Vue3 + SpringBoot + AI:我做了一个股票分析工具(第1周复盘)
人工智能·spring boot·后端
Oneslide11 小时前
Ubuntu 26.04 完整安装 Fcitx5 中文拼音输入法指南(适配默认Wayland)
后端