news 2026/9/28 6:01:10

Spring AI 多轮对话实战:用 ChatMemory 实现记忆持久化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring AI 多轮对话实战:用 ChatMemory 实现记忆持久化

接入大模型之后,我做的第一件正事,就是把多轮对话跑通。

你打开任意一家大模型厂商的接口文档就会发现,模型本身不记事。你问一句“帮我安排一下杭州出差两天的行程”,它回一段看似靠谱的计划;你再追问“第二天下午的会能安排在西湖附近吗”,它就一脸茫然,好像你从没提过杭州这回事。这个问题的官方解法,在 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, List<Message> messages); List<Message> get(String conversationId, int lastN); void clear(String conversationId); }
  • add:往某个会话 ID 下追加消息。
  • get:取出某个会话最近 N 条消息。
  • clear:删除某个会话的全部消息。

所谓“多轮对话”,就是你的业务代码按照固定流程走一遍:用户新消息先 add 进去,再从 get 取回一部分历史,连同当前问题一起交给模型,等模型返回后再把回答 add 回去。ChatMemory 不负责调模型,它只负责把“对话记忆”这份数据管好,就像数据库的读写接口一样。

默认情况下,Spring AI 会提供一个InMemoryChatMemory实现,底层就是一个ConcurrentHashMap<String, List<Message>>。你的 conversationId 就是 key,消息列表就是 value。这个实现足够跑通开发环境,但绝对不应该是生产环境里唯一的记忆载体。

1.3 为什么不能把所有历史都一股脑塞回给模型

刚理解 ChatMemory 时,我犯过一个错误:为了“周全”,我把某个会话从创建以来的所有消息全部取出来丢给模型。结果并没有更好,反而出现一堆问题。

问题具体影响
上下文窗口限制模型对输入长度有硬性上限,历史过长直接报错或被静默截断
成本上升Token 按量计费,每次请求多带几万历史字符,费用会快速膨胀
注意力漂移模型对过近的内容更敏感,早期信息太多反而会干扰当前意图判断

所以“取最近 N 条”这个 N 不能拍脑袋乱填。我的建议是先按条数截断,比如 20 条;再按总字符数做二次截断,比如一次请求的全部消息字符数尽量控制在 4000 到 8000 以内,具体取决于你用的模型上下文窗口。后面我会给一个可用的裁剪方法。

2. 第二步:用 InMemoryChatMemory 跑通最小闭环

原理讲完,直接上代码。

我假设你已经有一个能跑起来的 Spring Boot 工程,并且引入了 Spring AI 的模型 Starter。以 OpenAI 为例:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-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. 取回最近的历史消息 List<Message> 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,第二次请求被负载均衡打到实例 B,B 的内存里没有任何历史,对话又断了。

所以内存版只适合本地开发和功能验证。真要放到业务里,持久化记忆是绕不开的一步。

3. 第三步:手写 JdbcChatMemory,把记忆存进 MySQL

Spring 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  • conversation_id用来区分不同用户、不同会话。
  • message_type用来区分 USER、ASSISTANT、SYSTEM。
  • content存的是消息文本。
  • created_time用于排序和清理。

关键索引是(conversation_id, created_time)这个组合索引。没有它,一次查询会扫描整个表,数据量上来之后查询性能会很难看。

3.2 实现 JdbcChatMemory

Spring 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, List<Message> 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 List<Message> get(String conversationId, int lastN) { String sql = """ SELECT message_type, content FROM chat_memory WHERE conversation_id = ? ORDER BY id DESC LIMIT ? """; List<Message> 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 方法按会话维度加锁。我一般用ConcurrentHashMap<String, Object>维护每个会话的锁对象:

private final ConcurrentHashMap<String, 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 List<Message> trimToFit(List<Message> messages, int maxChars) { List<Message> 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 Token,maxChars 设成 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。但流式接口返回的是Flux<String>,内容是一个一个抵达的增量片段,根本没有一个整体字符串让你直接存。

如果直接在拿到 Flux 后立刻chatMemory.add一条 AssistantMessage,你会发现存进去的是空字符串或者一段残缺内容,因为模型还没输出完。

5.2 用 doOnNext 收集增量,在 complete 时入库

正确做法是在流式过程中用 StringBuilder 把每个片段拼起来,等整个流结束后再写库:

public Flux<String> 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只是拿到一个片段就追加到 builder,doOnComplete表示所有内容输出完毕,此时 builder 里才是完整回答,再写进记忆。

这里还有一个容易被忽略的点:Flux 是异步的,用户在流式输出过程中如果又发来一条新消息,理论上存在“上一轮 assistant 回复还没完全写入,下一轮请求已经开始读历史”的窗口期。极端情况下,这个窗口期会导致上下文缺一部分。要彻底解决,还是得回到按会话加锁那条路,保证同一会话的读写请求被串行执行。

5.3 我的建议:流式接口与普通接口共用同一份 ChatService

不要为流式单独再写一套 ChatMemory 操作逻辑。把“取历史、存消息、裁剪”这些公共逻辑抽出来,同步接口走 String 返回,流式接口走 Flux 返回,两者都复用同一个 ChatService 核心方法。我之前犯过的一个错误,就是在流式接口里重新实现了一遍消息管理,结果两套逻辑越走越偏,后来不得不花时间统一。

最后说一点我自己的体会。我一开始总把多轮对话的实现重心放在 ChatClient 的用法上,一直想找到某个能自动带上记忆的 API,后来发现真正的重心应该在 ChatMemory 上。ChatClient 是消息搬运工,ChatMemory 才是记忆本体。你把这个认知扭转过来之后,再去看 Redis 缓存、向量召回、敏感词过滤这些扩展,全都是站在同一套消息列表数据结构上的小更新。

实践顺序上,我的建议是先用 InMemoryChatMemory 快速验证产品交互,再按本文的方法换成 JdbcChatMemory 落地持久化,最后再考虑并发锁和 Token 裁剪。每一步都不难,但顺序搞反了,后面要返工的成本会高很多。如果你在实际实现时遇到了和我这里说法不一致的 API 差异,多半是 Spring AI 版本迭代造成的,按你自己的版本来调就好。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 6:00:43

葡萄叶病害检测数据集:VOC/YOLO双重标注与YOLOv8训练实践

简介&#xff1a;这份葡萄叶片病害检测数据集面向计算机视觉与农业智能识别方向的学习者&#xff0c;提供1609张单叶片的真实病害图像&#xff0c;涵盖Black Measles、Black Rot、blight fungus及healthy leaf四个类别&#xff0c;并同步给出Pascal VOC与YOLO两种标注格式&…

作者头像 李华
网站建设 2026/9/28 6:00:26

微服务认证授权实战:Spring Security 6 + OAuth2 + JWT 完整方案

从单体到微服务之后&#xff0c;很多团队第一个被搞崩的不是业务&#xff0c;而是登录。原来在单体应用里一套HttpSession躺平搞定的事&#xff0c;拆成十几个服务后立刻变得尴尬&#xff1a;Session在哪个服务里&#xff1f;用户明明登录了&#xff0c;另一个服务怎么不认识&a…

作者头像 李华
网站建设 2026/9/28 5:58:44

SSM社区居民便民服务关怀系统实战:从需求设计到面试亮点全解析

做这个项目的时候&#xff0c;我其实已经带过不少学生完成类似的 Java 课程设计了&#xff0c;但“社区居民便民服务关怀系统”这个名字背后要做的事&#xff0c;比一开始预想的多不少。它不只是一个 CRUD 练习&#xff0c;而是把“便民”和“关怀”落到了具体的业务场景里&…

作者头像 李华
网站建设 2026/9/28 5:58:32

VMware Workstation安装CentOS全流程:镜像下载到系统优化

做运维这些年&#xff0c;我几乎每天都要跟虚拟机打交道。尤其CentOS&#xff0c;哪怕到了2026年还是绕不开它&#xff1a;新人在练Linux基本功&#xff0c;开发要搭测试环境&#xff0c;老项目要复现生产配置&#xff0c;动不动就要先搞一台虚拟机。而vmware workstation加cen…

作者头像 李华
网站建设 2026/9/28 5:57:42

M.2 Key类型与协议兼容性深度解析:从NVMe SSD到AI加速卡选型实战

1. 项目概述&#xff1a;M.2不是一块板子&#xff0c;而是一套精密的“插头-插座-协议”协同系统你拆开笔记本、台式机或者工控盒子&#xff0c;看到那块细长的黑色小板子&#xff0c;第一反应可能是“哦&#xff0c;这是个M.2固态硬盘”。但如果你真这么想&#xff0c;就等于把…

作者头像 李华
网站建设 2026/9/28 5:57:42

贷中风险预测模型全流程实战:样本、特征与LightGBM落地要点

简介&#xff1a;这是面向金融风控场景的机器学习贷中风险预测项目包&#xff0c;源自“江苏银行杯”金融大数据建模挑战赛初赛方案&#xff0c;适合高校人工智能、金融科技相关专业学生、教师及从业者学习参考。压缩包共49个文件&#xff0c;约10.97MB&#xff0c;包含19个Pyt…

作者头像 李华