最近在搞大模型应用的人,应该都遇到过这个尴尬场景:用户问你问题,你调一次模型答一次,看起来很顺,但用户第二句问“刚才我让你记住的手机尾号是多少”,模型直接一脸懵。问题就出在会话没有记忆,或者说,记忆只存在于当前请求的内存里,下一次请求就归零了。
如果你正被“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> 提示符就说明环境没问题。如果是公司网络拉不动官方镜像,就换公司内网镜像源,不要在这种环境细节上卡太久。
