AI图鉴

AI图鉴首页 › AI 资讯 › Spring AI 2.1 ChatMemory 已过时用什么替代:迁移到 Se

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

来源:掘金 · 发布时间:10-27 · 由 AI图鉴每小时自动聚合

Spring AI 2.1 把 ChatMemory 标为待删除,替代品是 Session Memory。本文用 2.1.0-RC1 实测迁移步骤、MySQL 表结构、按轮压缩和过期清理。

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

[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 时代最常见的写法:

// 2.0 写法:消息窗口记忆 + MessageChatMemoryAdvisor

ChatMemory chatMemory = MessageWindowChatMemory.builder()

.chatMemoryRepository(new InMemoryChatMemoryRepository())

.maxMessages(20)

.build();

ChatClient chatClient = ChatClient.builder(chatModel)

.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())

.build();

完整的影响范围:

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

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

三、Session Memory 和 ChatMemory 差在哪

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

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

四、迁移步骤

1. 换依赖

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

<!-- 版本由 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:

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

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 的:

// 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 照样能拼出历史:

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

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

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

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

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 内存库。同一个用户连问两句,再换一个用户问一句:

chatService.chat("u1001", "c1", "订单 A123 什么时候发货?");

chatService.chat("u1001", "c1", "那能改地址吗?");

chatService.chat("u2002", "c1", "我要退货");

模型收到的 prompt:

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

另一个用户 prompt:2 条 [SYSTEM:你是售后客服助手,回答要简短][USER:我要退货]

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

再看不传会话 ID 会怎样:

chatClient.prompt().user("没带会话 ID").call().content();

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

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 个问题,每轮后打印视图和日志的大小:

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());

}

实测输出:

第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 里

可选的触发器和窗口:

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

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

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

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

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

建出来的表:[AI_SESSION, AI_SESSION_EVENT, AI_SESSION_INDEX]

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

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 内脚本):

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 审脚本时大概会问到这几点,我先列出来:

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

八、过期会话要自己清

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

expiresAt 距现在约 119 分钟

现在清理:0 个

3 小时后清理:1 个

清理后还能查到吗:false

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

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 这类调度平台只跑一个实例,不然几台机器同时删同一批数据,虽然结果一样但白白多打几次库。

九、速查表

十、常见问题

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:只想让某一次调用少带点历史?

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

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 还是向量库?迁移时遇到别的问题,欢迎评论区一起聊。

本文由 AI图鉴自动抓取并翻译,版权归原作者所有;如需查看原文请点下方链接。

相关 AI 工具

相关免费羊毛

同源资讯

阅读原文(juejin.cn)

标题、摘要与正文均来自该信源的公开页面,版权归原作者所有;本站自动聚合与翻译,仅作出处跳转。

← 返回 AI 资讯列表AI工具图鉴首页

返回 AI图鉴首页 · 提交工具 · 商务合作 · 关于本站 · 593 款 AI 工具分类导航 · 每日 AI 热门项目与动态