对话记忆这种东西,平时不觉得,一旦用户开始依赖上下文连续追问,丢失一次就特别败坏体验。我之前接的一个项目,客户端是普通浏览器会话,后台接了大模型接口,最开始图省事,把历史消息一股脑塞在内存里,需求开发倒是快,测试也没问题。结果部署到生产环境,服务一重启,用户之前聊的好几轮内容全没了,会话直接断片,用户回来发现助手已经完全不知道前面聊了什么,体验崩得很难看。这其实是典型的“对话记忆没有持久化”问题。
后来换成了Spring AI框架来做这块,核心思路就是把对话记忆从内存挪进关系型数据库,用JDBC存起来。这套方案落地之后,服务重启、多实例部署、水平扩展都不丢上下文,效果稳定很多。这篇就从头梳理一下我实际踩完坑之后的实现思路,重点是Spring AI里的记忆机制、JDBC存储的实现方式,以及几个容易翻车的细节。
1. 对话记忆的丢失问题到底出在哪一环
很多团队在刚开始接大模型应用时,都不会第一时间考虑对话记忆的持久化,因为原型阶段用一个全局Map存聊天记录,确实又简单又直观。问题在于,这种方案把记忆和应用进程的生命周期绑死在了一起。
1.1 内存态记忆的两个致命缺陷
第一个缺陷是服务重启即失忆。进程一退出,Map里的数据全部归零。哪怕只是发版重启,用户的历史会话也会被清空。很多人说那我用Redis存,Redis确实能扛重启,但本质上也依赖部署环境,而且如果架构里还没有Redis,为了一个对话记忆去多引入一个中间件,运维成本和数据一致性都要重新考量。
第二个缺陷是多实例部署时记忆不共享。生产环境为了高可用,应用通常会起多个实例,前面挂负载均衡。用户第一次请求打到实例A,记忆存在A的内存里;第二次请求被负载均衡分到实例B,B完全没有这段上下文,模型收到的对话历史是残缺的,回答质量立刻垮掉。这个问题在单体原型阶段根本测不出来,只有上了生产、流量一大才会暴露。
1.2 Spring AI里记忆模块的分工逻辑
Spring AI把对话记忆做成了相对独立的能力。简单来说,它负责在每次调用模型之前,把之前的历史对话拼装进Prompt上下文,再把新的对话追加回记忆存储。它本身不关心记忆数据存在哪里,而是通过一个统一的接口抽象来访问底层存储。
这个接口核心就是ChatMemory,对应的方法基本围绕几个动作:向指定会话追加消息、取出某个会话的历史消息、清理指定会话。框架内部还有一个ConversationId的概念,用来区分不同的对话窗口,相当于给每段会话一个独立ID,存取都靠它来定位。
这套设计与模型无关,不管底层接的是哪家大模型接口,记忆逻辑都能复用。更重要的是,因为存储层被抽象成了接口,替换实现非常方便。默认情况下,Spring AI给的是InMemoryChatMemory,也就是内存实现,用于本地调试很顺手,但正如前面所说,不达到持久化要求。这也是为什么我们需要换到JDBC存储。
1.3 为什么选JDBC而不是其他存储
选择JDBC存储,最直接的原因是大多数业务系统本身就已经有数据库了,比如MySQL、PostgreSQL、H2这类。这意味着不需要额外引入Redis、MongoDB等组件,部署架构零新增依赖。
关系型数据库还有一个很实在的优势:对话历史是结构化数据,会话ID、消息角色、消息内容、时间戳都有清晰字段,拿SQL查询、统计、清理都很方便。比如排查问题时可以直接写一条SQL查某个会话到底存了哪些消息,而不是去连一个内存管理器或者序列化文件里翻。
另外,JDBC存储天然支持事务。虽然单个对话消息的写入对事务要求不算高,但在批量写入历史记录、或者在写入失败时需要回滚的场景下,数据库事务能避免数据不一致。
2. JDBC记忆仓库的表结构设计与运行机制
Spring AI官方提供了一个基于JDBC的ChatMemory实现,叫JdbcChatMemory。它的设计思路是:先用一个Schema脚本来建表,然后通过JdbcTemplate操作这张表,实现对话记录的增删查。如果你对Spring的JdbcTemplate熟悉,用起来会非常顺畅。
2.1 最小可用的表结构拆解
Spring AI的JDBC实现依赖一张核心表,主要用于保存对话消息。不同版本的表结构略有差异,但核心字段是稳定的,我这里以主流版本为例:
CREATE TABLE IF NOT EXISTS chat_memory ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, conversation_id VARCHAR(36) NOT NULL, message_type VARCHAR(64) NOT NULL, content CLOB NOT NULL, timestamp TIMESTAMP NOT NULL, INDEX idx_conversation_timestamp (conversation_id, timestamp) );几个字段的作用要理解清楚:
- conversation_id:会话的唯一标识,对应Spring AI中的ConversationId。所有属于同一段对话的消息共享同一个值,这是记忆检索的核心键。
- message_type:消息角色类型,一般包括user和assistant。框架在拼对话历史时,需要知道哪条消息是用户说的、哪条是助手回答的,才能按照对话顺序组装Prompt。
- content:消息正文。考虑到消息可能比较长,用CLOB或TEXT类型更稳妥,不要用VARCHAR限制过短。
- timestamp:消息时间戳,用于对话排序和过期清理。
这里有个容易踩坑的地方:不同版本的Spring AI对这个表的字段命名不完全一致。有的版本用messageType,有的版本用type字段承载角色信息。如果直接用官方脚本建表,问题不大;如果你是手写SQL,最好先确认当前引入版本实际执行的建表语句,避免字段对不上导致启动时报错。
2.2 JdbcChatMemory的工作流程
JdbcChatMemory本身并不复杂,它的核心操作就是SQL。当用户发来一条新消息时,业务流程大致如下:
- 业务层收到用户输入,带上conversationId。
- 调用ChatMemory的add方法,把用户这条消息写入chat_memory表。
- 调用ChatMemory的get方法,查询该conversationId下按时间升序排列的全部历史消息。
- 把历史消息组装成Message数组,连同当前问题一起传给模型调用接口。
- 模型返回结果后,再把助手回复通过add方法写入表里。
也就是说,任何一次模型调用之前,系统都会先去数据库里把之前的对话全部捞出来。这带来的第一个直接变化就是:即使应用重启,这些记录还躺在数据库里,下次继续用同一个conversationId,上下文依然完整。多实例部署时,也是一样的效果,因为所有实例查的是同一张表。
2.3 和内存实现的性能差异
很多人担心:每次请求都查一次数据库,会不会拖慢响应速度?
实测下来,只要表结构建了合适索引,数据量在万条级别内,单次查询返回全部历史消息的开销通常都在几毫秒以内,相比大模型接口动辄数秒的响应时间,基本可以忽略。
真正需要注意的反而是历史消息过多的问题。一段超长对话如果持续累积,历史消息可能堆积到几十上百条,Prompt长度越来越大,不仅浪费Token,也拖慢模型响应。这个问题靠持久化解决不了,要靠后续的裁剪策略,后面我会单独说。
3. 实测落地:从JdbcChatMemory到完整记忆闭环
理论部分讲完,直接上可以跑的代码。我在项目里用的技术栈是Spring Boot 3.x + Spring AI,数据库用的MySQL,下面这段配置和代码都经过实际验证。
3.1 引入依赖与数据源配置
第一步,在pom.xml里加入Spring AI的JDBC相关依赖。这里要注意,Spring AI的模块划分比较细,需要同时引入核心模块和JDBC模块。
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-core</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-jdbc</artifactId> <version>1.0.0</version> </dependency>具体版本号以你的Spring Boot和Spring AI版本对应关系为准,不要照抄一个固定版本就完事,不同大版本之间API有调整。
数据源配置就不用多说了,标准JDBC配置即可。关键是确保Spring Boot自动配置能识别到DataSource的Bean,这样JdbcTemplate才能正常注入。
3.2 用配置文件指定记忆存储类型
Spring AI提供了一个开关,用来切换ChatMemory的默认实现。在application.yml里加上:
spring: ai: memory: jdbc: enabled: true table-name: chat_memory这段配置的意义在于,当spring-ai-jdbc在classpath下,并且设置enabled为true时,框架会自动创建JdbcChatMemory实例并注册为ChatMemory的Bean。后续业务代码里直接注入ChatMemory,拿到就是JDBC实现,不需要自己手工new。
这里有个细节值得注意:默认情况下,Spring AI在启动时会自动执行schema建表脚本。如果你不想让框架自动建表,而是使用自己预先定义的表结构,可以按版本关闭初始化脚本,或者直接在代码里声明一个自定义的DataSourceInitializer。我个人的习惯是让框架自动建表,省事,而且表结构跟当前版本完全同步,不会出现字段对不上的问题。
3.3 核心业务代码:注入ChatMemory并组装对话链路
接下来是在业务代码中使用。先定义一个Service,注入ChatMemory。
@Service public class ChatService { private final ChatMemory chatMemory; private final OpenAiChatModel chatModel; public ChatService(ChatMemory chatMemory, OpenAiChatModel chatModel) { this.chatMemory = chatMemory; this.chatModel = chatModel; } public String chat(String conversationId, String userMessage) { // 1. 先把用户消息写入记忆存储 chatMemory.add(conversationId, List.of(new UserMessage(userMessage))); // 2. 取出该会话全部历史消息 List<Message> history = chatMemory.get(conversationId, Integer.MAX_VALUE); // 3. 构建Prompt并调用模型 Prompt prompt = new Prompt(history); ChatResponse response = chatModel.call(prompt); // 4. 模型回复写入记忆存储 String assistantMessage = response.getResult().getOutput().getContent(); chatMemory.add(conversationId, List.of(new AssistantMessage(assistantMessage))); return assistantMessage; } }这几个步骤串起来就是一个完整的记忆闭环。第2步这里先简单取全部历史,实际项目中不会直接把所有历史都丢给模型,一般会做窗口截取,下一节会展开。
3.4 控制器层的会话标识传递
控制器层的核心任务是把conversationId传进来,同时兼顾两种情况:前端明确传了会话ID,或者完全没有会话ID需要新建。
@RestController @RequestMapping("/chat") public class ChatController { private final ChatService chatService; public ChatController(ChatService chatService) { this.chatService = chatService; } @PostMapping public Map<String, String> chat(@RequestBody ChatRequest request) { String conversationId = request.getConversationId(); // 如果为空则生成新的UUID if (conversationId == null || conversationId.isBlank()) { conversationId = UUID.randomUUID().toString(); } String reply = chatService.chat(conversationId, request.getMessage()); return Map.of("conversationId", conversationId, "reply", reply); } }从这个实现能看出来,持久化记忆的key完全由服务端控制。前端每次请求带上同一个conversationId,服务端就能把消息串起来。如果前端把conversationId弄丢了,用户视角就是“换了新对话”,这是合理预期,不算bug。
4. 过期间隔、并发会话与模型无关的边界处理
JDBC存储解决了“重启不丢”的问题,但实际生产环境里,还有几类问题需要额外处理,不然会以另一种方式恶心你。
4.1 历史消息无限增长怎么办
持久化带来的副作用就是表数据只会越来越多。如果一段对话连续聊了几天,历史消息几百条,每次请求都把这些全部扔给模型,Token开销巨大,还可能超出模型的上下文窗口。
这个问题不能回避,因为Spring AI自带的ChatMemory接口设计相对简单,get方法虽然接收一个消息数量上限,但那是按“最近N条”还是“全部后截断”来取,取决于具体实现。我实测下来,更可靠的做法是在自己的Service里做窗口截取,而不是完全依赖底层实现。
一个实用的策略是:只取最近20条历史消息拼接进Prompt。对于绝大多数对话场景,20条已经足够维持上下文连贯性,同时把Token消耗控制在合理范围内。
// 取最近20条,按时间升序排列 List<Message> history = chatMemory.get(conversationId, 20);如果你对消息顺序有严格要求,比如一定要确保系统消息在最前、用户消息和助手消息交替正确,可以在Service层对取出的消息按时间戳自行排序,再组装Prompt。
4.2 会话过期的自动清理
存储只增不减的话,表会越来越大。对话记忆不是永久刚需,一段会话超过一定天数基本没有复用价值,因此应该定期清理。
处理思路有两个层次。
第一个层次是查询侧过滤。在取出历史消息时,SQL层面过滤掉超过30天的旧消息。如果使用JdbcChatMemory默认实现,框架不一定给你开放这个过滤开关,那就需要在Service层做时间判断。
第二个层次是存储侧清理。写一个定时任务,定期删除超期会话:
@Component public class MemoryCleanupTask { private final JdbcTemplate jdbcTemplate; public MemoryCleanupTask(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Scheduled(cron = "0 0 3 * * ?") public void cleanExpiredMemories() { jdbcTemplate.update("DELETE FROM chat_memory WHERE timestamp < ?", LocalDateTime.now().minusDays(30)); } }注意,这个定时任务是基于表结构直接操作,没有走ChatMemory接口,所以对表结构有硬性依赖。如果表名或字段名与你当前版本不完全一致,执行时会报错。
4.3 多用户多会话的数据隔离
conversationId如果只是UUID,理论上存在一种风险:两个不同用户如果拿着同一个conversationId,会共享同一段对话记忆。这在单机Demo里无所谓,但生产系统通常有用户体系,必须保证会话归属正确。
比较稳妥的做法是,conversationId由服务端生成,并且关联用户标识,例如“userId + 随机串”的组合。这样即便前端请求中出现相同的随机串,不同用户也不会串线。
还有一种更严格的做法是改造表结构,增加user_id字段,查询时同时限定conversationId和userId。这需要你自定义ChatMemory实现,或者绕开框架直接操作JdbcTemplate,灵活度更高,但代码量也上去了。我的建议是:如果你的系统已有登录体系,直接新增user_id字段,从根上避免会话串线问题。
4.4 模型无关:换模型不用改记忆代码
Spring AI这套记忆设计的另一个好处是模型无关。之前用的是OpenAI接口,后面如果切换到其他大模型接口,只需要替换ChatModel的实现,Service层的记忆逻辑一行都不用改。
因为记忆模块操作的对象是统一抽象的Message类型,包括UserMessage、AssistantMessage、SystemMessage等。这些类型不依赖具体模型厂商的协议,而是框架层面的通用表示。只要替换了ChatModel,记忆的读写流程完全不变。
这条特性在项目后期特别省心。我实际经历过一次因为成本原因切换模型的情况,当时只改了配置和模型实现,对话记忆部分零改动,用户侧基本无感。
5. 实测踩坑记录:从配置到运行的完整排查链路
任何技术方案都有坑,JDBC记忆存储也不例外。我把自己实际遇到的几个问题整理出来,每一个都带完整的排查思路,方便你遇到类似问题时能快速定位。
5.1 启动报错:Table not found
第一次集成时,应用启动直接报错,提示找不到chat_memory表。
排查链路如下:
先确认框架版本是否真的启用了自动建表。检查日志里有没有执行schema.sql的记录。如果没有,说明自动建表没有触发。
然后看是否在classpath下引入了正确的JDBC模块。Spring AI的自动建表逻辑是放在spring-ai-jdbc模块里的,如果你的启动类扫描不到这个模块的自动配置,表当然不会自动创建。
最后看数据源权限。如果你的数据库账号只有DML权限,没有DDL权限,自动建表同样会失败。这种情况下,需要找DBA手动执行建表脚本,或者提供一个高权限账号完成初始化后,再切回低权限账号。
我当时遇到的就是权限问题。解决方式是先用管理员账号建好表,再改回普通账号运行应用。
5.2 消息写入不生效:JdbcTemplate注入失败
有次代码写完了,启动时倒是没报错,但一调用add方法就抛异常,提示JdbcTemplate没有注入。
排查思路很简单:先看JdbcChatMemory的构造函数依赖什么。它依赖DataSource或者JdbcTemplate。如果你项目里同时存在多个数据源,并且没有用@Primary标注默认数据源,Spring容器在注入时就会因为分不清该用哪个而失败。
解决办法是在数据源配置上明确主数据源:
@Bean @Primary public DataSource primaryDataSource() { return DataSourceBuilder.create().build(); }如果项目里只有一个数据源,这个坑基本不会遇到。
5.3 历史顺序错乱:时间戳精度不足
有一次线上反馈,助手回答时上下文顺序混乱,明明先问的先回答,结果模型收到的历史里,后问的内容排在了前面。
我查了一遍代码,逻辑上没有排序错误。后来看表数据发现,多条消息的timestamp完全相同,精确到秒的精度不够,导致同一秒内的并发达消息排序不稳定。
解决办法是把timestamp的精度从秒级提升到毫秒级,或者加一个自增id作为排序依据。实际上之前给出的建表SQL里,id本身就是自增主键,按id排序也能保证写入顺序。如果使用框架自动建表,时间戳字段默认精度是否足够,需要看版本实现,不够就自己加个排序字段兜底。
5.4 长文本截断:CLOB类型与实际内容不匹配
还有一个偏冷门的问题。有次插入一条很长的助手回复,数据库报错,提示数据超出字段长度。原因是建表时content字段用了VARCHAR(255),而模型回复经常超过这个长度。
排查过程很直接:查看表结构,字段类型确实是VARCHAR(255)。数据库在严格模式下,超出长度直接报错;非严格模式下虽然能插入,但内容会被截断,表现就是回复内容不完整。
修复方式就是把content字段改成TEXT或CLOB类型。这也是为什么前面表中我直接用了CLOB,而不是VARCHAR。
5.5 跨版本升级带来的存储结构变化
Spring AI迭代速度比较快,不同小版本之间,JdbcChatMemory的表结构可能调整。比如消息角色字段、会话ID字段的长度限制,都可能变化。
如果是从旧版本升级,并且数据库里已经有存量数据,启动前一定要先看新版建表脚本和旧版的差异。如果差异不大,手工执行ALTER语句即可;如果差异很大,可能需要数据迁移脚本。
我在一次版本升级中遇到过字段长度不匹配的问题,旧版conversation_id是VARCHAR(64),新版脚本要求VARCHAR(128),直接把表删掉重建当然最简单,但有存量数据就不能这么干,最后是执行ALTER TABLE修改字段长度解决的。
6. 几个仍有争议的进阶话题
到这里,JDBC存储的完整链路已经能跑通了。不过在实际项目里,还有一些进阶问题没有标准答案,我给出我自己的取舍。
6.1 要不要把记忆直接存进向量数据库
很多人到了对话记忆这一步,会想到用向量数据库存历史消息。因为向量检索能基于语义找回相似的历史内容,而不是单纯靠时间顺序。
我的结论是:向量数据库适合做“长期记忆”或者“知识检索”,但不适合做“短期对话上下文”。短期对话上下文强调的是顺序和完整性,必须严格按照对话发生的先后顺序拼装,这一点关系型数据库的天然有序性更适合。向量检索更适合在大量历史知识中找回相关片段,然后把这些片段作为附加上下文提供给模型,属于另一个层面的能力。
因此我的方案是:短期上下文用JDBC存,长期知识检索另走向量存储,两者互补,不互相替代。
6.2 消息内容加密是否有必要
对话内容往往涉及用户隐私。如果存到数据库,数据安全问题就需要考虑。
如果你的数据库本身有透明数据加密能力,或者磁盘加密,那不需要在业务层再处理。如果没有这些能力,又对隐私要求高,可以对content字段加密后存储。代价是无法直接在数据库里通过SQL模糊查询消息内容,但在对话记忆场景里,本来也不太需要这种查询。
我目前在多数项目里,采用的是数据库层加密,不加业务逻辑层加密,原因是省事且满足合规要求。
6.3 记忆裁剪策略如何与Token成本平衡
前面提到只取最近20条历史,这个数字不是拍脑袋定的,需要结合模型上下文窗口和业务场景调整。比如简单问答场景,10条以内就够;多轮复杂推理场景,可能需要30到50条。
更精细的做法是估算Token。每条消息的Token数大致可以按中文字符数与英文单词数粗估,在Service层动态计算,超过阈值就停止添加更早的消息。这比固定条数更灵活,但代码复杂度也更高。如果项目预算紧张,先用固定条数完全够用,别一上来就做动态估算。
7. 最终落地建议:先小步验证,再重构
最后说点实在的落地建议。如果你正在改造成JDBC记忆持久化,不要一上来就把所有功能模块都切换过去,建议按三步走。
第一步,在一个独立接口上试点。先让这个接口走完整的“写历史-取历史-调模型-写入回复”链路,验证重启后记忆是否真的不丢。
第二步,把conversationId的生成、透传规范定下来。和前端约定好,每次请求必须带服务端返回的conversationId,否则当新会话处理。这个约定直接影响记忆的连续性。
第三步,再考虑清理策略和多实例部署。多实例部署时,不需要任何额外配置,因为所有实例连同一个数据库,天然共享记忆。只要不对单实例内存做读写,就不会出现会话漂移问题。
还有个很小的细节但很多人忽略:JDBC持久化后,数据库连接池的大小要稍微关注一下。每次对话请求涉及两次写入和一次查询,虽然单次耗时短,但如果你的应用QPS比较高,连接池默认的10个连接可能在瞬时压力下不够用。可以在压测时观察连接池活跃数,不足就调大。
这套方案落地至今,生产环境再没出现过对话记忆丢失的情况。重启、发版、横向扩容,对用户来说都是无感的。如果你也为对话记忆的问题头疼,直接参考这篇把JDBC存储接上,大概率能少走不少弯路。