接入大模型之后,我做的第一件正事,就是把多轮对话跑通。
你打开任意一家大模型厂商的接口文档就会发现,模型本身不记事。你问一句“帮我安排一下杭州出差两天的行程”,它回一段看似靠谱的计划;你再追问“第二天下午的会能安排在西湖附近吗”,它就一脸茫然,好像你从没提过杭州这回事。这个问题的官方解法,在 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 版本迭代造成的,按你自己的版本来调就好。