Spring AI 多轮对话实战:用 ChatMemory 实现记忆持久化
发布时间:2026/9/28 6:01:12来源:尧图网络
接入大模型之后我做的第一件正事就是把多轮对话跑通。你打开任意一家大模型厂商的接口文档就会发现模型本身不记事。你问一句“帮我安排一下杭州出差两天的行程”它回一段看似靠谱的计划你再追问“第二天下午的会能安排在西湖附近吗”它就一脸茫然好像你从没提过杭州这回事。这个问题的官方解法在 Spring AI 里叫 ChatMemory。我写这篇文章的原因是因为网上大多数 Spring AI 教程都在讲怎么调通一次问答很少把多轮对话的完整链路讲透。所以我把自己的实现路径整理成三步先把 ChatMemory 要解决的“消息列表”问题看明白第二步用内存版 ChatMemory 跑一个最小闭环第三步把记忆持久化到 MySQL。如果你是正在用 ChatClient 做 AI 应用、同时又对“机器为什么会忘事”感到困惑的 Java 后端这篇应该能给你省点时间。1. 第一步把“多轮对话”拆成消息列表问题多轮对话听起来很玄本质就是“消息列表管理”。大模型对外提供的接口核心载荷就是一组 messages 数组数组里每个元素有两个关键字段role 和 content。role 表示是谁说的content 表示说的内容。你调一次接口把整个数组传过去模型基于这些内容生成下一条回复。它自己没有任何内部状态你传什么它就基于什么作答。我见过不少刚接触 AI 的同事习惯把模型比喻成“金鱼”七秒记忆。这个比喻其实不太对。模型更像一个每次上班都换新人的客服它完全没见过你但如果你把此前的聊天记录整理好放在它面前它能表现得很懂你。关键在于这份记录必须由你来准备。1.1 模型没有记忆记忆全在请求体里一个最简单的对话请求长这样[ { role: system, content: 你是旅行规划助手 }, { role: user, content: 帮我安排杭州出差两天的行程 }, { role: assistant, content: 好的你需要我优先考虑交通还是酒店 }, { role: user, content: 先把酒店定在西湖附近吧 } ]模型读到这串消息后才知道“杭州”“出差”“两天”“西湖”这些关键词是同一个场景下的信息。如果你只传最后一条“先把酒店定在西湖附近吧”它根本不知道你在说什么地方的酒店更不知道出差两天这件事。很多新手写的“多轮对话”其实是一次性问答用户每次提问代码都用chatClient.prompt().user(message).call()直接发出去完全没有携带历史。模型当然每一次都像第一次见面。这不是模型的问题是你没有给它“记忆”。1.2 ChatMemory 的角色记住、取回、清理Spring AI 里的 ChatMemory 就是帮你管理这份消息列表的组件。它做的事情非常朴素接口长这样public interface ChatMemory { void add(String conversationId, ListMessage messages); ListMessage get(String conversationId, int lastN); void clear(String conversationId); }add往某个会话 ID 下追加消息。get取出某个会话最近 N 条消息。clear删除某个会话的全部消息。所谓“多轮对话”就是你的业务代码按照固定流程走一遍用户新消息先 add 进去再从 get 取回一部分历史连同当前问题一起交给模型等模型返回后再把回答 add 回去。ChatMemory 不负责调模型它只负责把“对话记忆”这份数据管好就像数据库的读写接口一样。默认情况下Spring AI 会提供一个InMemoryChatMemory实现底层就是一个ConcurrentHashMapString, ListMessage。你的 conversationId 就是 key消息列表就是 value。这个实现足够跑通开发环境但绝对不应该是生产环境里唯一的记忆载体。1.3 为什么不能把所有历史都一股脑塞回给模型刚理解 ChatMemory 时我犯过一个错误为了“周全”我把某个会话从创建以来的所有消息全部取出来丢给模型。结果并没有更好反而出现一堆问题。问题具体影响上下文窗口限制模型对输入长度有硬性上限历史过长直接报错或被静默截断成本上升Token 按量计费每次请求多带几万历史字符费用会快速膨胀注意力漂移模型对过近的内容更敏感早期信息太多反而会干扰当前意图判断所以“取最近 N 条”这个 N 不能拍脑袋乱填。我的建议是先按条数截断比如 20 条再按总字符数做二次截断比如一次请求的全部消息字符数尽量控制在 4000 到 8000 以内具体取决于你用的模型上下文窗口。后面我会给一个可用的裁剪方法。2. 第二步用 InMemoryChatMemory 跑通最小闭环原理讲完直接上代码。我假设你已经有一个能跑起来的 Spring Boot 工程并且引入了 Spring AI 的模型 Starter。以 OpenAI 为例dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency如果你用的是智谱、通义或者其他国内模型只需要换成对应的 Spring AI Starter核心代码完全不用改。Spring AI 的这套抽象就是让你不被某一家模型绑死。2.1 一个能记住上下文的 ChatService先把ChatClient和ChatMemory注入进来写一个专门处理对话的服务类。Service public class ChatService { private final ChatClient chatClient; private final ChatMemory chatMemory; public ChatService(ChatClient.Builder builder, ChatMemory chatMemory) { this.chatClient builder.build(); this.chatMemory chatMemory; } public String chat(String conversationId, String userMessage) { // 1. 先把用户消息存进记忆 chatMemory.add(conversationId, List.of(new UserMessage(userMessage))); // 2. 取回最近的历史消息 ListMessage history chatMemory.get(conversationId, 20); // 3. 把整个消息列表交给模型 String answer chatClient.prompt() .messages(history) .call() .content(); // 4. 模型回复后把 assistant 消息也存进记忆 chatMemory.add(conversationId, List.of(new AssistantMessage(answer))); return answer; } }这段代码就是多轮对话的最小闭环。你看ChatClient 本身不碰 ChatMemory它只是个消息搬运工。把历史取出来、把新回复存回去全部由你自己控制。2.2 一个最常见的错误又传 messages 又传 user很多人在第 4 行容易写歪变成了这样chatClient.prompt() .messages(history) .user(userMessage) // 错误示范历史和 user 重复了 .call() .content();当你已经通过messages(history)把完整历史里面已经包含了当前用户消息传给模型时不要再调用.user(userMessage)。否则模型会看到两条同样的用户消息而历史里又混着重复内容回复质量会明显下降。正确的顺序是先chatMemory.add把新消息写进去再用.messages(history)把“包含新消息在内的整个上下文”交给模型。如果你先 get 再 add模型就看不到当前这轮用户问题等于白问。2.3 实测连续提问把 ChatService 暴露成一个简单的 REST 接口后我用 curl 做了一组连续提问curl -X POST http://localhost:8080/api/chat \ -H Content-Type: application/json \ -d {conversationId:demo-1,message:帮我安排杭州出差两天的行程}第一次回复通常是“好的我先帮你列一个两天的行程框架请问你是从几点开始……”之类的内容。接着再发curl -X POST http://localhost:8080/api/chat \ -H Content-Type: application/json \ -d {conversationId:demo-1,message:第二天下午的会能安排在西湖附近吗}如果模型端没抽风第二轮的回复里会带上“杭州”“出差”“两天”这些关键词因为它已经通过 ChatMemory 看到了第一轮对话。到这里内存版的多轮对话就通了。2.4 内存版最大的问题重启即失忆这个版本跑通之后你会很快遇到两个明显问题。第一程序一重启所有对话记录全部消失。第二生产环境如果部署了两个实例用户第一次请求打进实例 A第二次请求被负载均衡打到实例 BB 的内存里没有任何历史对话又断了。所以内存版只适合本地开发和功能验证。真要放到业务里持久化记忆是绕不开的一步。3. 第三步手写 JdbcChatMemory把记忆存进 MySQLSpring AI 官方默认没有提供 MySQL 版本的 ChatMemory。常见的官方实现里有基于 Cassandra 的但实际项目中用 MySQL 的场景显然更多。自己实现一个成本很低还能完全掌控表结构和查询逻辑。3.1 建一张 chat_memory 表设计表结构时我只需要四个核心字段会话 ID、消息类型、消息内容、创建时间。对话消息本身是纯文本不需要复杂的规范化设计。CREATE TABLE chat_memory ( id BIGINT AUTO_INCREMENT PRIMARY KEY, conversation_id VARCHAR(128) NOT NULL, message_type VARCHAR(16) NOT NULL, content TEXT NOT NULL, created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_conversation_time (conversation_id, created_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;conversation_id用来区分不同用户、不同会话。message_type用来区分 USER、ASSISTANT、SYSTEM。content存的是消息文本。created_time用于排序和清理。关键索引是(conversation_id, created_time)这个组合索引。没有它一次查询会扫描整个表数据量上来之后查询性能会很难看。3.2 实现 JdbcChatMemorySpring Boot 项目里JdbcTemplate 可以直接注入。代码如下Repository public class JdbcChatMemory implements ChatMemory { private final JdbcTemplate jdbcTemplate; public JdbcChatMemory(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } Override public void add(String conversationId, ListMessage messages) { String sql INSERT INTO chat_memory(conversation_id, message_type, content) VALUES (?, ?, ?) ; for (Message message : messages) { String type message.getMessageType().getValue(); String content message.getText(); jdbcTemplate.update(sql, conversationId, type, content); } } Override public ListMessage get(String conversationId, int lastN) { String sql SELECT message_type, content FROM chat_memory WHERE conversation_id ? ORDER BY id DESC LIMIT ? ; ListMessage messages jdbcTemplate.query(sql, (rs, rowNum) - { String type rs.getString(message_type); String content rs.getString(content); return toMessage(type, content); }, conversationId, lastN); // 数据库取出来是倒序要反转成正序再返回 Collections.reverse(messages); return messages; } Override public void clear(String conversationId) { jdbcTemplate.update(DELETE FROM chat_memory WHERE conversation_id ?, conversationId); } private Message toMessage(String type, String content) { return switch (type) { case USER - new UserMessage(content); case SYSTEM - new SystemMessage(content); default - new AssistantMessage(content); }; } }如果你用的 Spring AI 版本里没有getMessageType()、getText()这些方法名换成MessageType.fromMessage(message).getValue()以及message.getContent()即可。Spring AI 不同小版本的 API 有一些命名差异但核心思路完全一致只需要把 type 和 content 这两个字段存进去再取出来还原成 Message 对象。3.3 最容易踩的反序坑get方法里那一句Collections.reverse(messages)是我第一版实现漏掉的东西。数据库查出来的顺序是按id DESC最新消息在前。但对话历史应该按时间正序排列也就是最早的对话在最前面。如果直接把倒序列表丢给模型等于让模型倒着读对话记录。模型不会跟你吵架但它会变得思路混乱尤其在多轮之后几乎一定会答非所问。这个坑很隐蔽因为单轮对话完全没有感觉多轮之后才暴露。建议你在写完 JdbcChatMemory 后专门写一个测试插入三条消息再读取确认顺序是 1、2、3 而不是 3、2、1。3.4 要不要上 Redis很多同学会问接个 Redis 是不是更快我的看法是不要一上来就上 Redis。如果你的并发量还没到每秒几十次对话MySQL 完全扛得住。对话历史读取的频率虽然高但单条数据量小又有组合索引兜底性能问题不明显。真到了需要 Redis 缓存的时候更合理的方案是用 Redis 缓存最近 N 条热消息MySQL 做全量存储两者在同一个 ChatMemory 实现里协作。直接全量塞 Redis反而要额外处理 Redis 持久化和淘汰策略。4. 多轮对话实战会话隔离、并发锁、Token 裁剪与过期清理持久化做完你的多轮对话已经具备上线基础了。但在生产环境里还有四个高频问题必须在动手前想清楚。没有一个线上就会出事故。4.1 会话隔离别让所有人共用一份记忆ChatMemory 的全部秘密都在 conversationId 上。如果后端代码里偷懒把 conversationId 写成固定值default那么所有用户都会共享同一个对话上下文。用户 A 说的敏感信息用户 B 的下一次提问里就可能被模型引用出来这是很严重的事故。我见过一个真实项目测试的时候没问题上线后被用户投诉“别人知道我之前说了什么”。定位到最后就是 controller 里会话 ID 硬编码了。conversationId 不能完全信任前端传上来的任意字符串尤其是用户 ID 要从登录态里取不能只靠请求参数。如果无法保证登录态至少也要做一层服务端映射让客户端只能拿到一个无法伪造的随机 ID。4.2 并发写同一个会话同时请求记忆容易错位InMemoryChatMemory底层是ConcurrentHashMap单个 add 和单个 get 确实是线程安全的。但你的业务代码是“先 add 用户消息再 get 历史最后 add 模型回复”这一整个复合操作不原子。假设同一个用户在同一秒内发送了两条消息两个线程同时执行 ChatService.chat()就可能出现线程 A 刚取完历史准备调模型线程 B 已经把新的用户消息存进去了最后两条消息的顺序在记忆里和真实发生顺序不一致。最简单的解决方案是给 ChatService 的 chat 方法按会话维度加锁。我一般用ConcurrentHashMapString, Object维护每个会话的锁对象private final ConcurrentHashMapString, Object locks new ConcurrentHashMap(); public String chat(String conversationId, String userMessage) { synchronized (locks.computeIfAbsent(conversationId, k - new Object())) { return doChat(conversationId, userMessage); } }这样同一会话的请求会被串行化不同会话之间互不影响。锁对象集合会随着会话增多而膨胀你可以配合定时清理或者直接接受它在业务量可控时的内存开销。4.3 Token 裁剪lastN 不能只看条数chatMemory.get(conversationId, 20)里的 20 是“消息条数”不是“Token 数”。如果每条消息都是几百字的长文本20 条照样能把上下文窗口撑爆。我习惯在取出历史后再做一次字符级别的二次裁剪。粗估算的思路是典型中文场景下一个汉字大约对应 1 到 2 个 Token英文一个单词大约 1 到 2 个 Token。不需要精确分词只需要做一个总长度上限的兜底。private ListMessage trimToFit(ListMessage messages, int maxChars) { ListMessage result new ArrayList(messages); int totalChars result.stream() .mapToInt(m - m.getText().length()) .sum(); while (totalChars maxChars result.size() 1) { Message removed result.remove(0); totalChars - removed.getText().length(); } return result; }maxChars建议设置为模型上下文窗口的四分之一左右。比如上下文窗口是 8000 TokenmaxChars 设成 4000 到 6000 都算安全。这样既保留了最近的对话又不会因为历史太长导致请求失败。4.4 过期清理不清理的话表会变成不断膨胀的“垃圾场”持久化之后数据库里的对话历史会无限增长。如果不做清理半年后这张表可能有几千万行查询再快也会被拖垮。常见做法是给会话增加一个最后活动时间或者直接用 chat_memory 表里每个会话的最大创建时间作为判断依据。定期删除超过 30 天没有任何新消息的会话记录DELETE FROM chat_memory WHERE conversation_id IN ( SELECT conversation_id FROM ( SELECT conversation_id FROM chat_memory GROUP BY conversation_id HAVING MAX(created_time) DATE_SUB(NOW(), INTERVAL 30 DAY) ) AS expired_convos );如果你的业务需要长期留存对话数据做分析那就不要 DELETE改成归档表或者给表加一个 archived 字段。原则是线上热表只保留活跃会话冷数据挪走。5. 流式输出时记忆更新的写法跟普通接口不一样很多应用在接入大模型后会追求流式输出让用户看到“打字机”一样的效果。但流式返回和普通接口的返回机制差别很大如果你用同步接口的思路去更新 ChatMemory会吃不少苦头。5.1 问题出在你拿不到一个完整的 String普通接口里chatClient.prompt().call().content()返回的是一个完整字符串。你可以等它结束后把整个回答存进 ChatMemory。但流式接口返回的是FluxString内容是一个一个抵达的增量片段根本没有一个整体字符串让你直接存。如果直接在拿到 Flux 后立刻chatMemory.add一条 AssistantMessage你会发现存进去的是空字符串或者一段残缺内容因为模型还没输出完。5.2 用 doOnNext 收集增量在 complete 时入库正确做法是在流式过程中用 StringBuilder 把每个片段拼起来等整个流结束后再写库public FluxString chatStream(String conversationId, String userMessage) { chatMemory.add(conversationId, List.of(new UserMessage(userMessage))); StringBuilder answerBuilder new StringBuilder(); return chatClient.prompt() .messages(chatMemory.get(conversationId, 20)) .stream() .content() .doOnNext(answerBuilder::append) .doOnComplete(() - chatMemory.add(conversationId, List.of(new AssistantMessage(answerBuilder.toString()))) ); }doOnNext只是拿到一个片段就追加到 builderdoOnComplete表示所有内容输出完毕此时 builder 里才是完整回答再写进记忆。这里还有一个容易被忽略的点Flux 是异步的用户在流式输出过程中如果又发来一条新消息理论上存在“上一轮 assistant 回复还没完全写入下一轮请求已经开始读历史”的窗口期。极端情况下这个窗口期会导致上下文缺一部分。要彻底解决还是得回到按会话加锁那条路保证同一会话的读写请求被串行执行。5.3 我的建议流式接口与普通接口共用同一份 ChatService不要为流式单独再写一套 ChatMemory 操作逻辑。把“取历史、存消息、裁剪”这些公共逻辑抽出来同步接口走 String 返回流式接口走 Flux 返回两者都复用同一个 ChatService 核心方法。我之前犯过的一个错误就是在流式接口里重新实现了一遍消息管理结果两套逻辑越走越偏后来不得不花时间统一。最后说一点我自己的体会。我一开始总把多轮对话的实现重心放在 ChatClient 的用法上一直想找到某个能自动带上记忆的 API后来发现真正的重心应该在 ChatMemory 上。ChatClient 是消息搬运工ChatMemory 才是记忆本体。你把这个认知扭转过来之后再去看 Redis 缓存、向量召回、敏感词过滤这些扩展全都是站在同一套消息列表数据结构上的小更新。实践顺序上我的建议是先用 InMemoryChatMemory 快速验证产品交互再按本文的方法换成 JdbcChatMemory 落地持久化最后再考虑并发锁和 Token 裁剪。每一步都不难但顺序搞反了后面要返工的成本会高很多。如果你在实际实现时遇到了和我这里说法不一致的 API 差异多半是 Spring AI 版本迭代造成的按你自己的版本来调就好。
网站建设高端定制企业官网