Spring AI 2.1 ChatMemory 已过时用什么替代:迁移到 Session Memory 实战(JDBC 存储与会话压缩)

导读 :把 Spring AI 升到 2.1 之后,编译日志里会冒出一串"ChatMemory 已过时, 且标记为待删除"。替代品叫 Session Memory,2.1.0-RC1 刚带进来。我在临时项目里把迁移完整跑了一遍,这篇讲怎么改、改完数据存哪、多轮对话变长后怎么压缩,以及几个文档里不太显眼的坑。

适用版本:Spring AI 2.1.0-RC1(2026-10-23 发布)、Spring Boot 4.2.0-RC1、JDK 17。文中输出都来自本机实跑,模型用一个不联网的假 ChatModel 代替,只看记忆怎么拼进 prompt。


一、先说清楚:对话记忆是干什么的

大模型本身不记得上一句话,每次调用都是独立的。用户先问"订单 A123 什么时候发货",再问"那能改地址吗",模型并不知道"那"指的是哪个订单。要做多轮对话,程序就得每次把之前的问答一起发过去。

Spring AI 里负责这件事的组件,2.0 叫 ChatMemory:调用模型前,把这个会话的历史拼进请求;调用完,把新的一问一答存下来。历史可以放内存,也可以放数据库。

2.1 换了一套新实现,叫 Session Memory,干的还是同一件事,主要改进有三点:

  • 历史只追加、不删除,出了问题能回查用户说过什么
  • 对话太长时按"一问一答"整轮裁剪,不会把半轮对话发给模型
  • 会话自带过期时间

对你有没有影响,看自己的情况:

你的情况 要不要动
没用 Spring AI,或者还在 1.x / 2.0 不用管
已经升到 2.1 老代码照样能跑,只是编译时报过时警告;3.0 会删掉老 API
对话记忆存在 Redis、MongoDB 里 先别迁,新实现还没有这类存储

二、哪些东西过时了

Spring AI 2.1 的 PR #7186 把整套聊天记忆 API 标成了 @Deprecated(since = "2.1.0", forRemoval = true),计划在 3.0.0 删除。升级后用老写法编译,会看到这样的警告(本机实测输出):

text 复制代码
[WARNING] LegacyMemory.java:[14,9] org.springframework.ai.chat.memory.ChatMemory 已过时, 且标记为待删除
[WARNING] LegacyMemory.java:[15,43] org.springframework.ai.chat.memory.InMemoryChatMemoryRepository 已过时, 且标记为待删除
[WARNING] LegacyMemory.java:[14,33] org.springframework.ai.chat.memory.MessageWindowChatMemory 已过时, 且标记为待删除
[WARNING] LegacyMemory.java:[19,34] org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor 已过时, 且标记为待删除

触发这些警告的就是 2.0 时代最常见的写法:

java 复制代码
// 2.0 写法:消息窗口记忆 + MessageChatMemoryAdvisor
ChatMemory chatMemory = MessageWindowChatMemory.builder()
        .chatMemoryRepository(new InMemoryChatMemoryRepository())
        .maxMessages(20)
        .build();
ChatClient chatClient = ChatClient.builder(chatModel)
        .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
        .build();

完整的影响范围:

类别 过时的东西 替代
核心接口 ChatMemory、MessageWindowChatMemory SessionService + 压缩策略
存储 ChatMemoryRepository、InMemoryChatMemoryRepository SessionRepository、InMemorySessionRepository
Advisor MessageChatMemoryAdvisor SessionAdvisors(内含两个 advisor)
JDBC 存储 JdbcChatMemoryRepository、spring-ai-starter-model-chat-memory-repository-jdbc JdbcSessionRepository、spring-ai-starter-model-session-jdbc
Redis / MongoDB / Cassandra / Neo4j 存储 对应的 ChatMemory 仓库和自动配置 暂时没有,官方说 3.0.0 之前补上

没过时的:VectorStoreChatMemoryAdvisor(向量检索式记忆)和 BaseChatMemoryAdvisor,Session 这边还没有语义召回的对应物。

⚠️ 警告只是提示,2.1 里老 API 的运行时行为没变,不改也能跑。


三、Session Memory 和 ChatMemory 差在哪

对照官方文档和 PR 的说明,两者最大的区别在于历史怎么存 。先解释两个后面会反复出现的词:日志 是存下来的全部历史;视图是这一次真正发给模型的那部分。新实现压缩时只缩小视图,不删除日志。

对比项 ChatMemory Session Memory
存什么 每个会话一份消息列表 一份只追加的事件日志(每条消息是一个 SessionEvent)
窗口裁剪 MessageWindowChatMemory 直接丢掉旧消息 压缩只追加一个 COMPACTION 边界,日志里旧消息还在
会不会切断一轮对话 按条数截,可能把工具调用和结果拆开 窗口和压缩都按"轮"切,不拆开一轮
会话 ID 不传 2.1 实测抛 IllegalArgumentException: conversationId cannot be null 抛 IllegalStateException,报错里直接给出正确写法
过期 没有过期概念 默认 60 天过期,需要自己定时清理

对后端来说,第二行和第三行最有用:日志不删,出了问题能回查用户到底说过什么;按轮切,工具调用和结果不会被拆散。


四、迁移步骤

1. 换依赖

把 BOM 升到 2.1.0-RC1,用 Session 的 JDBC starter 替换原来的 chat-memory starter:

xml 复制代码
<!-- 版本由 spring-ai-bom 2.1.0-RC1 管理 -->
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-session-jdbc</artifactId>
</dependency>
<!-- 删掉:spring-ai-starter-model-chat-memory-repository-jdbc -->

这个 starter 带了 spring-ai-session、spring-ai-session-advisor、JDBC 存储和两个自动配置。只要容器里有一个 DataSource,就会自动建出 JdbcSessionRepository 和 SessionService 两个 Bean。

💡 Spring AI 2.x 依赖 Spring Boot 4.x,2.1.0-RC1 的源码 pom 里是 Boot 4.2.0-RC1,我的验证项目也用的这个版本。

2. 换 Advisor

advisor 可以理解成 ChatClient 的拦截器,类似 Spring MVC 的 HandlerInterceptor,在调用模型前后插一段逻辑,记忆就是靠它拼历史、存回复的。把 MessageChatMemoryAdvisor 换成 SessionAdvisors,它一次返回两个 advisor 的列表,直接塞给 defaultAdvisors:

java 复制代码
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.session.SessionService;
import org.springframework.ai.session.advisor.SessionAdvisors;
import org.springframework.ai.session.compaction.TurnCountTrigger;
import org.springframework.ai.session.compaction.WindowCompactionStrategy;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ChatConfig {

    @Bean
    ChatClient chatClient(ChatModel chatModel, SessionService sessionService) {
        return ChatClient.builder(chatModel)
                .defaultSystem("你是售后客服助手,回答要简短")
                .defaultAdvisors(SessionAdvisors.builder(sessionService)
                        // 视图里超过 3 轮就压缩,只保留最近 2 轮(演示用,生产按 20/10 起步)
                        .compaction(new TurnCountTrigger(3),
                                WindowCompactionStrategy.builder().lastTurns(2).build())
                        .build())
                .build();
    }
}

两个 advisor 分工不同:SessionMemoryAdvisor 每次调模型前把历史拼进 prompt、调完把回复存下来;SessionTurnAdvisor 包在整次调用外面,负责首次建会话和调用结束后的压缩。官方文档说只注册前者会没有压缩,所以别拆开用,直接用 SessionAdvisors。

3. 每次调用带上会话 ID

java 复制代码
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.session.advisor.SessionAdvisors;
import org.springframework.stereotype.Service;

@Service
public class CustomerChatService {

    private final ChatClient chatClient;

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

    public String chat(String userId, String conversationId, String question) {
        // 会话 ID 带上用户 ID,避免不同用户传同一个 conversationId 串历史
        String sessionId = userId + ":" + conversationId;
        return chatClient.prompt()
                .user(question)
                .advisors(SessionAdvisors.session(sessionId))
                .call()
                .content();
    }
}

会话不存在时,第一次调用会自动创建。建议会话 ID 拼上服务端拿到的用户 ID:前端传上来的 conversationId 不可信,不拼的话,别人猜到 ID 就能读到别人的历史。

4. 老代码里的 CONVERSATION_ID 不用改

如果你原来是这样传会话 ID 的:

java 复制代码
// 2.0 写法,2.1 里常量挪到了 MemoryAdvisor,值没变
chatClient.prompt()
        .user("第二句")
        .advisors(a -> a.param(MemoryAdvisor.CONVERSATION_ID, "legacy-1"))
        .call()
        .content();

2.1 把 CONVERSATION_ID 从 ChatMemory 挪到了 MemoryAdvisor,字符串值还是 chat_memory_conversation_id,和 Session advisor 读的键是同一个。我实测用老写法传 ID,Session advisor 照样能拼出历史:

text 复制代码
用老的 CONVERSATION_ID 键:4 条 [SYSTEM:你是售后客服助手,回答要简短][USER:第一句][ASSISTANT:第6次回复,收到2条消息][USER:第二句]

所以业务代码里大量 .param(ChatMemory.CONVERSATION_ID, id) 的项目,可以先只换 advisor,import 慢慢改。


五、实测:记忆是怎么拼进 prompt 的

为了不依赖真实大模型,我写了一个假的 ChatModel,把每次收到的 prompt 记下来,回复里写上"收到几条消息":

java 复制代码
import java.util.ArrayList;
import java.util.List;

import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;

// 不调真实大模型:记录每次收到的 prompt,回一句"收到 N 条消息"
public class EchoChatModel implements ChatModel {

    final List<List<Message>> prompts = new ArrayList<>();

    @Override
    public ChatResponse call(Prompt prompt) {
        prompts.add(prompt.getInstructions());
        String reply = "第" + prompts.size() + "次回复,收到" + prompt.getInstructions().size() + "条消息";
        return new ChatResponse(List.of(new Generation(new AssistantMessage(reply))));
    }
}

测试里把它注册成 ChatModel Bean,数据库用 H2 内存库。同一个用户连问两句,再换一个用户问一句:

java 复制代码
chatService.chat("u1001", "c1", "订单 A123 什么时候发货?");
chatService.chat("u1001", "c1", "那能改地址吗?");
chatService.chat("u2002", "c1", "我要退货");

模型收到的 prompt:

text 复制代码
第二轮 prompt:4 条 [SYSTEM:你是售后客服助手,回答要简短][USER:订单 A123 什么时候发货?][ASSISTANT:第8次回复,收到2条消息][USER:那能改地址吗?]
另一个用户 prompt:2 条 [SYSTEM:你是售后客服助手,回答要简短][USER:我要退货]

两个用户都用了 conversationId = c1,因为会话 ID 拼了用户 ID,历史没有串。

再看不传会话 ID 会怎样:

java 复制代码
chatClient.prompt().user("没带会话 ID").call().content();

直接抛异常,报错信息把正确写法都给了:

text 复制代码
IllegalStateException: No session ID found in advisor context. Set SESSION_ID_CONTEXT_KEY on every request: .advisors(a -> a.param(SessionMemoryAdvisor.SESSION_ID_CONTEXT_KEY, sessionId))

官方文档给的理由是:如果有一个共用的默认会话,不同用户的历史会被悄悄合并。我在 2.1 上顺手试了老的 MessageChatMemoryAdvisor,不传 ID 同样报错(conversationId cannot be null),所以迁移前后这一点行为一致,只是新的报错信息更好懂。

⚠️ 系统提示词默认不存进会话 (spring.ai.session.allow-system-messages 默认 false),每次调用由 defaultSystem 重新带上。上面输出里每轮都只有一条 SYSTEM,就是这个原因。不要为了"让模型记住人设"去打开它。


六、对话变长怎么办:按轮压缩

MessageWindowChatMemory 的 maxMessages(20) 是按条数硬截,Session 这边换成"触发器 + 策略":触发器决定什么时候压,策略决定留多少。上面配置的是"超过 3 轮就压缩,只留最近 2 轮",同一个会话连问 5 个问题,每轮后打印视图和日志的大小:

java 复制代码
for (int i = 1; i <= 5; i++) {
    chatService.chat("u3003", "c9", "第" + i + "个问题");
    String id = "u3003:c9";
    System.out.printf("第%d轮后:视图 %d 条,日志 %d 条消息,事件 %d 个%n", i,
            sessionService.getActiveMessages(id).size(),
            sessionService.getMessages(id).size(),
            sessionService.getEvents(id).size());
}

实测输出:

text 复制代码
第1轮后:视图 2 条,日志 2 条消息,事件 2 个
第2轮后:视图 4 条,日志 4 条消息,事件 4 个
第3轮后:视图 6 条,日志 6 条消息,事件 6 个
第4轮后:视图 4 条,日志 8 条消息,事件 9 个
第5轮后:视图 6 条,日志 10 条消息,事件 11 个
第 5 轮发给模型的 prompt:6 条 [SYSTEM:你是售后客服助手,回答要简短][USER:第3个问题][ASSISTANT:第3次回复,收到6条消息][USER:第4个问题][ASSISTANT:第4次回复,收到8条消息][USER:第5个问题]

几个要点:

  • 第 4 轮结束时视图有 4 轮,超过 3,触发压缩,视图缩成最近 2 轮(4 条)
  • 日志一条没删,还是 8 条消息;事件数多出 1 个,就是那个 COMPACTION 边界
  • 第 5 轮发给模型的只有第 3、4 轮和本轮问题,第 1、2 轮已经不在 prompt 里

可选的触发器和窗口:

类型 写法 含义
触发器 new TurnCountTrigger(20) 视图里超过 20 轮
触发器 TokenCountTrigger.builder().threshold(4000).build() 视图估算 token 达到 4000(默认用 JTokkit 估算)
触发器 CompositeCompactionTrigger.anyOf(a, b) 任一满足
窗口 WindowCompactionStrategy.builder().lastEvents(20).build() 留最近 20 个事件(默认)
窗口 WindowCompactionStrategy.builder().lastTurns(10).build() 留最近 10 轮
窗口 WindowCompactionStrategy.builder().tokenBudget(4000).build() 留能装进 4000 token 的最近部分

官方文档特别强调,不管用哪种窗口,切点都会挪到一轮的开头,不会出现保留了工具结果却丢了触发它的用户问题的情况。按条数截断时最怕的就是这个:只剩一条孤零零的 tool result,有的模型会直接拒绝请求。

💡 压缩默认在调用返回前同步执行。如果你的策略里带了摘要(CompactionSummarizer,会额外调一次模型),可以在 builder 上加 .asyncCompaction(true),让压缩在回复返回之后跑,不拖慢响应。


七、落到 MySQL:表结构和建表

JDBC 存储支持 PostgreSQL、MySQL、MariaDB、H2 四种,方言从数据库元数据自动识别。其他数据库(Oracle、SQL Server 等)启动时直接报 IllegalStateException,官方说 3.0.0 之前补方言。

H2 这类内嵌库会自动建表,我在测试里查到建出了 3 张表:

text 复制代码
建出来的表:[AI_SESSION, AI_SESSION_EVENT, AI_SESSION_INDEX]

MySQL 默认不会 自动建表(initialize-schema 默认 embedded)。开发环境可以打开:

yaml 复制代码
spring:
  ai:
    session:
      time-to-live: 30d
      repository:
        jdbc:
          initialize-schema: always

生产环境我建议把脚本放进 Flyway 或 DBA 审核流程。脚本在 jar 里:spring-ai-session-jdbc-2.1.0-RC1.jar 的 org/springframework/ai/session/jdbc/schema-mysql.sql,核心是事件表(节选自 jar 内脚本):

sql 复制代码
CREATE TABLE IF NOT EXISTS AI_SESSION_EVENT (
    seq             BIGINT        NOT NULL AUTO_INCREMENT,
    id              VARCHAR(255)  COLLATE utf8mb4_bin NOT NULL PRIMARY KEY,
    session_id      VARCHAR(255)  COLLATE utf8mb4_bin NOT NULL,
    timestamp       DATETIME(6)   NOT NULL,
    event_type      VARCHAR(64)   NOT NULL DEFAULT 'MESSAGE',
    message_type    VARCHAR(20),
    message_content LONGTEXT,
    payload         LONGTEXT      NOT NULL,
    synthetic       TINYINT(1)    NOT NULL DEFAULT 0,
    metadata        LONGTEXT      NOT NULL,
    UNIQUE KEY uq_ai_session_event_seq (seq),
    INDEX idx_ai_session_event_session_seq (session_id, seq),
    INDEX idx_ai_session_event_session_type_seq (session_id, event_type, seq),
    CONSTRAINT fk_ai_session_event_session
        FOREIGN KEY (session_id) REFERENCES AI_SESSION (id) ON DELETE CASCADE
);

DBA 审脚本时大概会问到这几点,我先列出来:

关注点 情况
排序规则 ID 列用 utf8mb4_bin,会话 ID 区分大小写,和其他数据库行为一致
外键 事件表、索引表都有外键并且 ON DELETE CASCADE,删会话会连带删事件。公司规范禁外键的话要自己改脚本
大字段 payload、metadata、message_content 都是 LONGTEXT,消息全文和工具调用都在里面
只追加 压缩不更新、不删除行,表只会越来越大,靠删除会话来回收
老数据 不迁移:Session 不读 SPRING_AI_CHAT_MEMORY 表,老对话历史不会出现在新会话里

⚠️ 最后一条要提前跟产品说清楚:切换上线后,用户之前的对话上下文就"断"了。确实需要保留的话,得自己写脚本把老表的消息用 sessionService.appendMessage 灌进新会话,这一步官方没提供。


八、过期会话要自己清

会话默认 60 天过期(spring.ai.session.time-to-live),但官方文档写明了不会自动清理 ,要自己定时调 deleteExpiredSessions。我在测试里把过期时间设成 2 小时验证了一下:

text 复制代码
expiresAt 距现在约 119 分钟
现在清理:0 个
3 小时后清理:1 个
清理后还能查到吗:false

生产里加一个定时任务就行(启动类上要有 @EnableScheduling):

java 复制代码
import java.time.Instant;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.session.SessionService;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

// 过期会话不会自动清理,需要自己定时调用
@Component
public class SessionCleanupJob {

    private static final Logger log = LoggerFactory.getLogger(SessionCleanupJob.class);

    private final SessionService sessionService;

    public SessionCleanupJob(SessionService sessionService) {
        this.sessionService = sessionService;
    }

    @Scheduled(cron = "0 30 3 * * *")
    public int cleanExpired() {
        int removed = sessionService.deleteExpiredSessions(Instant.now());
        log.info("清理过期 AI 会话 {} 个", removed);
        return removed;
    }
}

多实例部署时,这个任务要么加分布式锁,要么交给 XXL-JOB 这类调度平台只跑一个实例,不然几台机器同时删同一批数据,虽然结果一样但白白多打几次库。


九、速查表

2.0 写法 2.1 Session 写法
MessageChatMemoryAdvisor.builder(chatMemory).build() SessionAdvisors.builder(sessionService).build()
.param(ChatMemory.CONVERSATION_ID, id) .advisors(SessionAdvisors.session(id)),或 MemoryAdvisor.CONVERSATION_ID
MessageWindowChatMemory.maxMessages(20) .compaction(new TurnCountTrigger(20), WindowCompactionStrategy.builder().lastTurns(10).build())
chatMemory.get(id) sessionService.getActiveMessages(id)
chatMemory.add(id, messages) sessionService.appendMessage(id, message),会话要先 findOrCreate
chatMemory.clear(id) sessionService.delete(id)
spring-ai-starter-model-chat-memory-repository-jdbc spring-ai-starter-model-session-jdbc
表 SPRING_AI_CHAT_MEMORY 表 AI_SESSION / AI_SESSION_EVENT / AI_SESSION_INDEX

十、常见问题

Q:现在是 RC 版,要不要马上迁?

我的建议是新项目直接用 Session,老项目先升级到 2.1 不改代码(老 API 还能用),等 2.1.0 正式版出来再按上面的速查表迁。用 Redis、MongoDB 做记忆存储的,等官方补齐 Session 存储再说。

Q:数据库是 Oracle / SQL Server / 达梦,能用吗?

内置方言只有 PostgreSQL、MySQL、MariaDB、H2,其他库启动报错。可以自己实现 JdbcSessionRepositoryDialect 接口,再传给 JdbcSessionRepository.builder().dialect(...)。这次没有试写方言,工作量取决于你的库对 JSON 和分页的支持程度。

Q:只想让某一次调用少带点历史?

用请求级过滤器,不用改全局配置:

java 复制代码
chatClient.prompt()
        .user("帮我总结一下")
        .advisors(a -> a
                .param(SessionMemoryAdvisor.SESSION_ID_CONTEXT_KEY, sessionId)
                .param(SessionMemoryAdvisor.EVENT_FILTER_CONTEXT_KEY, EventFilter.lastN(5)))
        .call()
        .content();

lastN 窗口同样会扩到一轮的开头,不会拆开工具调用。

Q:敏感内容不想落库怎么办?

SessionMemoryAdvisor 有写入侧的 MessageFilter,被过滤的消息不会存,但这一次的 prompt 不受影响。注意工具调用和工具结果无论如何都会存,这是为了保证下一轮 prompt 合法。


参考链接


如果你正准备把项目升到 Spring AI 2.1,希望这篇能帮你少踩几个坑,觉得有用点个赞 👍 收藏 📌。

你们项目里的对话记忆存在哪,MySQL、Redis 还是向量库?迁移时遇到别的问题,欢迎评论区一起聊。

相关推荐
博风3 小时前
Springboot4.0.9,添加Filter(拦截器)
spring boot
Java内核笔记5 小时前
同一份知识库,4 种切分 × 3 种召回,纯关键词比纯向量高 13 个点
java·人工智能·spring boot
学长毕设7 小时前
基于springboot的社会志愿者管理系统-附源码
java·spring boot·后端
计算机编程-吉哥7 小时前
计算机毕业设计 基于 SpringBoot + Vue 的剧本杀系统 Java毕业设计【任务书、开题报告、中期报告、论文、源码】
java·vue.js·spring boot·毕业设计·课程设计·计算机毕业设计选题
这里是杨杨吖7 小时前
SpringCloud+Vue民宿房屋租赁系统 附带详细运行指导视频
vue.js·spring boot·spring cloud·房屋·民宿租赁
RuoyiOffice8 小时前
SpringBoot OA推荐:面向信创选型,看看这套企业协同平台
spring boot·vue3·信创·flowable·企业协同·ruoyi office·oa选型
jianxuan8 小时前
Spring Boot + Maven 接入 Allatori:从普通打包到可选混淆发布
spring boot·python·maven
RuoyiOffice10 小时前
llama.cpp + SpringBoot3:本地部署 Qwen3.8,接入 AgentOS 企业办公智能体
spring boot·springboot3·llama.cpp·spring ai·qwen3.8·agentos
学长毕设19 小时前
基于SpringBoot的青少年编程学习平台系统-附源码
spring boot·学习·青少年编程