最近在搞大模型应用的人,应该都遇到过这个尴尬场景:用户问你问题,你调一次模型答一次,看起来很顺,但用户第二句问“刚才我让你记住的手机尾号是多少”,模型直接一脸懵。问题就出在会话没有记忆,或者说,记忆只存在于当前请求的内存里,下一次请求就归零了。
如果你正被“Spring AI + MySQL 会话记忆持久化”这个问题卡住,这篇文章值得看完。我会从 Spring AI 的记忆模型讲起,分析为什么要落到 MySQL,然后贴出可以直接套用的建表 SQL、ChatMemory 实现类、Spring 配置和调用示例,最后把生产环境常见的掉坑点一次性讲完。思路对普通对话、RAG、NL2SQL 这类多轮交互都适用,哪怕你用的是 DashScope、OpenAI 或自研模型,只要走 Spring AI 的模型适配层,这套记忆机制就能复用。
1. 会话记忆为什么要持久化:先搞懂 Spring AI 的记忆模型
1.1 记忆的本质是消息列表,而不是一句截断的字符串
很多初学者有一个误区:觉得模型记忆就是“把最近几轮文本拼接起来塞给模型”。真这么做了,第一版需求确实能跑,但后面会越来越难受。长对话拼接出来的字符串非常难控制长度,中间夹着系统提示词、工具返回、不同角色的消息时,要么截断丢消息,要么顺序错乱。
Spring AI 对记忆的抽象,不是“一段历史文本”,而是一组结构化的 ChatMessage。每次对话结束时,把用户消息和助手回复追加到某个会话 ID 下;下一次请求开始时,从这个会话 ID 下取最近 N 条消息,再连同当前这轮用户问题一起组装成一个完整的 Prompt 交给模型。
这种按“消息”组织的模式,和你在数据库里按“行”存记录天然契合。一条用户消息是一行,一条助手回复是一行,带 conversation_id 字段区分归属,带 message_type 字段区分角色,再加上时间戳排序。这样模型拿到的历史是“活的”:可以精确控制拿多少条,可以过滤掉某一角色的消息,也可以按业务需要保留完整轮次后做统计。
1.2 内存方案能跑通 Demo,但撑不起重启和多实例
Spring AI 默认的 InMemoryChatMemory 从实现层面看非常轻量,它就是内部一个 ConcurrentHashMap,把 conversationId 映射到消息列表。你本地写个单元测试、开一个 Controller 自己点击两下,完全够用。但一旦你准备上测试环境甚至生产环境,三个问题立刻暴露:
第一,重启即失忆。Java 进程一重启,Map 里所有数据清空,用户早上聊到一半的业务诉求,下午就找不回来了。
第二,多实例无法共享。应用水平扩展成两台实例后,用户第一次请求打到 A 实例,记忆存在 A 进程里;第二次请求负载均衡分到 B 实例,B 查自己内存发现没有这个会话,等于两个人各自抱着一半记忆过日子。除非你配置粘滞会话,否则多实例场景下内存方案根本没法定论。
第三,无法排查和审计。在线排查时,你想看看某个用户最近和模型聊了什么,直接查数据库很顺手,但让你去一个运行中的 Java 进程里 dump 内存找消息列表,就很不现实。
落地到生产,最稳妥的路线就是把会话记忆持久化。持久化方案可以选 MySQL、Redis、MongoDB,甚至只写文件。具体怎么选,我放在下一节讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案对比:为什么 MySQL 比 Redis、文件更合适
2.1 Spring AI 留给我们的扩展点:ChatMemory 接口
Spring AI 之所以适合做这种定制,是因为它把记忆能力抽象成了接口。我们一般只需要关注 ChatMemory 和它的配套 Advisor:
ChatMemory 负责会话记忆的增删查,核心方法大致是 add(conversationId, List<ChatMessage>)、get(conversationId, lastN)、clear(conversationId)。你不需要关心模型底层怎么调用历史消息,只要把自己实现的类注册成 Bean,然后在构建 ChatClient 时挂上 MessageChatMemoryAdvisor,框架就会自动在每次调用前取历史、在调用后写入本轮消息。
这个设计带来的最大好处是模型供应商无关。你用 OpenAI、用 DashScope、用本地 Ollama,记忆这块的逻辑完全一致;如果你当前项目引入的是 spring-ai-alibaba 这类上层封装,底层记忆接口仍然沿用这套规范,所以下面的 MySQL 方案可以直接复用,不用因为换了模型 starter 就重写。
2.2 怎么在 Redis、文件、MySQL 之间做取舍
我在项目里分别调研过几种存储介质,总结如下表:
| 存储方案 | 优点 | 主要问题 | 适合场景 |
|---|---|---|---|
| 内存 Map | 实现简单、性能最好 | 重启丢失、多实例不共享 | 本地调试、单元测试 |
| Redis | 读写快、支持 TTL 自动过期 | 需要额外维护中间件,大文本 JSON 会占内存;消息结构复杂时序列化成本高 | 高频短会话、强实时性场景 |
| 本地文件 | 实现成本低 | 多实例不共享、磁盘扩展麻烦 | 单机小工具、日志级存储 |
| MySQL | 结构清晰、方便排查、易扩展、生命周期可控 | 高并发下需要关注连接池和索引 | 业务系统通用、多实例、需审计审计 |
如果你的团队本身就有 MySQL,为这个需求再引一套 Redis 中间件,运维成本是翻倍的。MySQL 天然支持事务,一条会话里的多条消息可以分批写;支持复杂查询,你可以按时间、按用户、按关键词排查记录,也可以写定时任务清理过期会话。对多数业务系统来说,MySQL 是性价比最高的会话记忆存储方案。
我并不是说 Redis 方案不行。如果你们要做一个高吞吐、对话很短、允许丢历史的营销机器人,Redis 加 TTL 可能更合适。但凡对话内容涉及业务数据、工单流程、用户信息确认这类“丢了会出事”的场景,MySQL 的持久化能力就体现出优势了。
3. 数据库准备与表结构设计
3.1 本地 MySQL 环境怎么快速搭起来
不推荐为了做示例在本地手动编译安装 MySQL,太浪费时间。开发环境直接用 Docker 拉一个官方 MySQL 8.0 镜像最快:
bash复制docker run -d \
--name mysql8 \
-p 3306:3306 \
-e MYSQL_ROOT_PASSWORD=root123456 \
-e MYSQL_DATABASE=chatdb \
mysql:8.0
这里通过环境变量指定了 root 密码和初始数据库。启动后验证一下:
bash复制docker exec -it mysql8 mysql -uroot -proot123456 chatdb
能进入 mysql> 提示符就说明环境没问题。如果是公司网络拉不动官方镜像,就换公司内网镜像源,不要在这种环境细节上卡太久。
3.2 不要只存一段文本,要按角色拆行存
会话记忆的表设计,我见过两种典型写法。一种是整个会话存一行,里面放一个大 JSON,每次取出全部消息再整体覆盖写回。这种写法在消息很短、单会话并发极低的场景能用,但有两个明显隐患:一是 MySQL 对单行更新有行锁,不同轮次同时写会互相阻塞;二是消息越长,整体读写成本越高,后续想按时间清理某几条消息也很别扭。
我更推荐按“一条消息一行”存储。建表语句直接给出来:
sql复制CREATE TABLE `ai_chat_message` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`conversation_id` VARCHAR(64) NOT NULL COMMENT '会话ID,业务侧生成并传入',
`message_type` VARCHAR(32) NOT NULL COMMENT '消息角色:USER/ASSISTANT/SYSTEM/TOOL',
`content` MEDIUMTEXT NOT NULL COMMENT '消息文本内容',
`metadata_json` JSON DEFAULT NULL COMMENT '消息元数据,如messageId、工具调用等',
`created_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间,毫秒精度',
`updated_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_conversation_id_id` (`conversation_id`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI 会话消息记忆表';
几个关键点单独说一下。
conversation_id 设计成 VARCHAR(64),不要用 INT。会话 ID 往往带有业务语义,比如 user:10001、order:90231,或者客户端传上来的 UUID。用字符串更通用。如果你用自增数字当会话 ID,会话数超过 21 亿可能撞到 INT 上限,用 BIGINT 也是同理,这是团队里真实出现过的问题。
content 字段用 MEDIUMTEXT 而不是 TEXT。大模型回答经常超过 64KB,TEXT 类型最大只有 65535 字节,一旦消息变长就会写入失败。MEDIUMTEXT 上限是 16MB,正常对话完全够用。
charset 必须用 utf8mb4。不要用 utf8,MySQL 里的 utf8 实际只能存 3 字节的字符,遇到 emoji 或者生僻字会直接报错或乱码。这条经验对 AI 对话场景尤其重要,你永远不知道用户会不会在输入框里发一个表情。
3.3 时间字段为什么用 DATETIME(3),索引为什么建双列
DATETIME(3) 后面的 (3) 表示毫秒精度。AI 对话在极端情况下可能同一毫秒内产生多条消息,如果只精确到秒,排序时可能会出现先写进去的消息反而排在后面的情况。用毫秒精度后,实际查询几乎不会出现时间相同的记录。
索引那里大家容易纠结,要不要分别给 conversation_id 和 created_time 建两个独立索引?我实测下来,最佳选择是建复合索引 idx_conversation_id_id (conversation_id, id)。因为我们查询固定是“查某个会话、按时间倒序、取最后 N 条”,落在 SQL 上就是 WHERE conversation_id = ? ORDER BY id DESC LIMIT ?。id 本身自增趋势和时间一致,所以按 id 倒序就等价于按创建时间倒序,比排序 created_time 更快。之前用 MySQL 5.7 不会支持时间戳降序索引,用 (conversation_id, id) 这种升序索引也能被优化器反向扫描,效果很好。
不需要单独为 message_type 建索引,因为查询很少单独按角色过滤。如果你以后要做“删除某用户所有消息”的运维操作,再按 conversation_id 批量删即可。
4. 核心代码实现:从零写一个 MySQLChatMemory
4.1 引入依赖:Spring AI、JDBC、MySQL 驱动
假设你用的 Spring Boot 3.x,Maven 工程需要引入 Spring AI BOM 和对应模型 starter。这里以 OpenAI 接口为例,实际操作中换成团队自己的模型 starter 不影响记忆模块。
xml复制<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
如果你当前项目的具体版本号和示例不一致,建议先确认一下 ChatMemory 接口在你依赖版本中的方法签名。Spring AI 从早期版本到现在整体接口没有翻天覆地的变化,但小版本间可能有重载方法差异,照着接口提示补全即可。
4.2 核心实现类:MySQLChatMemory
我们直接实现 ChatMemory 接口。设计思路一句话概括:add 把消息列表批量写入表,get 按会话 ID 倒序取回最后 N 条并翻转,保证消息从旧到新返回给 Advisor。
java复制package com.example.chat.memory;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import javax.sql.DataSource;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.ChatMessage;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
import org.springframework.util.StringUtils;
public class MySQLChatMemory implements ChatMemory {
private static final String TABLE_NAME = "ai_chat_message";
private final NamedParameterJdbcTemplate jdbcTemplate;
private final ObjectMapper objectMapper = new ObjectMapper();
public MySQLChatMemory(DataSource dataSource) {
this.jdbcTemplate = new NamedParameterJdbcTemplate(dataSource);
}
@Override
public void add(String conversationId, List<ChatMessage> messages) {
if (!StringUtils.hasText(conversationId) || messages == null || messages.isEmpty()) {
return;
}
String sql = "INSERT INTO " + TABLE_NAME
+ " (conversation_id, message_type, content, metadata_json) "
+ " VALUES (:conversationId, :messageType, :content, :metadataJson)";
SqlParameterSource[] batchParams = messages.stream()
.map(message -> new MapSqlParameterSource()
.addValue("conversationId", conversationId)
.addValue("messageType", message.getMessageType().name())
.addValue("content", message.getContent())
.addValue("metadataJson", toMetadataJson(message)))
.toArray(SqlParameterSource[]::new);
jdbcTemplate.batchUpdate(sql, batchParams);
}
@Override
public List<ChatMessage> get(String conversationId, int lastN) {
if (!StringUtils.hasText(conversationId) || lastN <= 0) {
return List.of();
}
String sql = "SELECT message_type, content FROM " + TABLE_NAME
+ " WHERE conversation_id = :conversationId"
+ " ORDER BY id DESC LIMIT :limit";
List<ChatMessage> rows = jdbcTemplate.query(sql,
Map.of("conversationId", conversationId, "limit", lastN),
(rs, rowNum) -> buildMessage(
MessageType.valueOf(rs.getString("message_type")),
rs.getString("content")));
// 倒序查出来的是最近 N 条,需要翻转成时间正序再交给模型
Collections.reverse(rows);
return rows;
}
@Override
public void clear(String conversationId) {
if (!StringUtils.hasText(conversationId)) {
return;
}
String sql = "DELETE FROM " + TABLE_NAME + " WHERE conversation_id = :conversationId";
jdbcTemplate.update(sql, Map.of("conversationId", conversationId));
}
@Override
public void clear() {
// 无参会话清空操作风险极大,这里直接拒绝,避免线上误清数据
throw new UnsupportedOperationException("禁止清空全表,请按 conversationId 定向清理");
}
private ChatMessage buildMessage(MessageType messageType, String content) {
switch (messageType) {
case USER:
return new UserMessage(content);
case ASSISTANT:
return new AssistantMessage(content);
case SYSTEM:
return new SystemMessage(content);
default:
return null;
}
}
private String toMetadataJson(ChatMessage message) {
if (message.getMetadata() == null || message.getMetadata().isEmpty()) {
return "{}";
}
try {
return objectMapper.writeValueAsString(message.getMetadata());
} catch (JsonProcessingException e) {
throw new RuntimeException("序列化消息 metadata 失败", e);
}
}
}
这段代码里几个细节值得展开。
NamedParameterJdbcTemplate 比直接使用 JdbcTemplate 的 ? 占位符更好维护,批量参数多了以后不容易弄错位置。
add 里用的是 batchUpdate 批量写。一次多轮对话的写入通常是两三条消息,批量写可以把多次网络 IO 合并成一次,对数据库连接池的压力也小很多。如果以后消息量增长,你还可以在服务层把批次拆小,每 100 条一批提交,避免一次事务持锁时间太长。
get 里先 ORDER BY id DESC LIMIT ? 取了最后 N 条,再在代码里 Collections.reverse 翻回来。原因是消息返回给 Spring AI 时必须保持时间正序,否则模型看到的历史是倒着的,回答逻辑会非常混乱。
MessageType 是 Spring AI 内置的枚举,一般包含 SYSTEM、USER、ASSISTANT、TOOL 等。buildMessage 里我只还原了最常见的三种。TOOL 消息如果要完整还原,通常还需要从 metadata_json 里恢复工具调用 ID,这一块依赖具体的模型工具协议,属于进阶改造。纯文本对话场景下,这个简版实现已经够用。
4.3 注册 Bean 并接入 Advisor
实现类写完,接下来要做两件事:把它注册成 Spring Bean,再配置 ChatClient 的默认 Advisor。这样每次调用模型时,Spring AI 会自动先查询 MySQL 历史、组装消息,调用结束后回写本轮对话。
java复制package com.example.chat.config;
import javax.sql.DataSource;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.example.chat.memory.MySQLChatMemory;
@Configuration
public class ChatConfig {
@Bean
public MySQLChatMemory mySQLChatMemory(DataSource dataSource) {
return new MySQLChatMemory(dataSource);
}
@Bean
public ChatClient chatClient(ChatClient.Builder builder, MySQLChatMemory chatMemory) {
return builder
.defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory))
.build();
}
}
注意这里 MessageChatMemoryAdvisor 是 Spring AI 内置的记忆织入组件。它的工作逻辑是:请求发起前,根据当前 conversationId 从 ChatMemory 里取出最近消息,拼到 Prompt 里;模型返回后,再把本轮的用户消息和助手消息写入 ChatMemory。所以我们不需要在业务代码里手动调 add 和 get,只要保证每次请求都正确传递了 conversationId。
4.4 最小可运行验证:两次请求确认记忆生效
为了验证记忆真的被持久化了,写一个简单的 Controller:
java复制package com.example.chat.controller;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.api.Advisor;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ChatController {
private final ChatClient chatClient;
public ChatController(ChatClient chatClient) {
this.chatClient = chatClient;
}
@PostMapping("/chat")
public String chat(@RequestBody ChatRequest request,
@RequestHeader("X-Conversation-Id") String conversationId) {
return chatClient.prompt()
.user(request.getMessage())
.advisors(advisor -> advisor.param("conversationId", conversationId))
.call()
.content();
}
public record ChatRequest(String message) {
}
}
测试请求一:
bash复制curl -X POST http://localhost:8080/chat \
-H "Content-Type: application/json" \
-H "X-Conversation-Id: user-10001" \
-d '{"message":"请记住,我的手机尾号是 8890"}'
然后请求二:
bash复制curl -X POST http://localhost:8080/chat \
-H "Content-Type: application/json" \
-H "X-Conversation-Id: user-10001" \
-d '{"message":"我刚才让你记的手机尾号是多少?"}'
如果第二次模型能准确回答 8890,说明记忆链路已经打通。你直接去 MySQL 里 SELECT * FROM ai_chat_message WHERE conversation_id = 'user-10001',应该能看到两轮共四条消息。
注意一个容易忽略的细节:ChatRequest 我用了 record,项目中如果你没有开启 Java 17 以上语法,需要改成普通 POJO,这里只做演示。
5. 真实场景注意点:多会话、流式返回和消息窗口
5.1 会话 ID 的生成与传递:稳定且带业务语义
实现打通之后,最容易翻车的就是会话 ID 设计。我在联调时见过不少同学把 conversationId 每次请求都生成一个新的 UUID,结果模型永远记不住上一句话,因为数据库里永远只有一条消息。
正确做法是让会话 ID 具备稳定性,并且携带能定位用户的业务语义。单用户系统里可以使用用户表主键当 conversationId;开放接口服务里,建议由前端或客户端在发起会话时生成一次,后续所有请求都带上同一个 ID。即使 WebSocket 断线重连,也不要重新生成。
实际项目里我比较喜欢在网关层解析用户身份并写入 Header,下游业务从 Header 读会话 ID,不在业务代码里靠猜的。这样既避免用户在请求体里篡改 ID,也方便统一做多会话隔离。
5.2 流式返回时的消息写入顺序问题
很多 AI 应用已经把对话从“一次性返回”改成了“打字机流式输出”。用 ChatClient 切换成流式很简单:
java复制Flux<String> flux = chatClient.prompt()
.user(message)
.advisors(advisor -> advisor.param("conversationId", conversationId))
.stream()
.content();
如果你用的是上面配置的 ChatClient Bean,defaultAdvisors 会同时作用于普通调用和流式调用。Spring AI 的 Advisor 会等流式内容全部结束后,再整体写入完整的助手消息,所以你不需要自己手动落库。
这里最需要提醒的是:不要在流式回调里重复做消息保存。曾经有同事觉得 Advisor 没保存完整,又在 doOnNext 里逐段存文本,结果用户问题被存了三次,助手回复被切成几十段写入,数据库里全是碎片消息。写这套代码之前,一定先确认自己客户端是否启用了 Advisor,避免重复记账。
5.3 窗口是“消息条数”,不是“对话轮数”
get(conversationId, lastN) 里的 lastN 表示消息条数,这一点官方文档写得比较隐晦。我和刚接触 Spring AI 的同事交流时,十个里有七八个会误以为传 20 就是最近 20 轮对话。
一个完整的人机问答轮次由一条用户消息和一条助手消息组成,也就是两条记录。如果 ChatMemoryAdvisor 的窗口设置成 20,实际上模型只能看到最近 10 轮对话。如果模型中还穿插着工具调用消息、系统消息,窗口占用量会更大。
这就是我坚持在表中按“消息条数”而不是“会话轮次”来设计的原因之一。真要按轮
