用Spring AI实现多轮对话记忆,别再让AI每次都"失忆"
做AI对话功能,最容易被用户吐槽的一句话是什么?
"你刚才不是说过了吗?怎么又问我?"
大模型天生是"金鱼记忆"------每次调用都是独立的,上一次说了什么,它完全不记得。
这个问题我上周刚解决。用的Spring AI自带的ChatMemory机制,20行代码搞定。今天把方案拆干净。
问题有多蠢
先看一个真实场景。用户跟AI聊天:
用户:我叫张三,做Java开发的
AI:你好张三!Java开发挺不错的。
用户:推荐几本适合我的书?
AI:请问您是从事什么行业的呢?
用户直接炸了:我刚说了我做Java的你没听到?
这不是模型笨,是架构问题。每次调API,你只传了当前这一句话进去,历史对话根本没带。模型看不到之前的内容,当然不记得。
最蠢的解法:自己拼字符串
很多人第一反应是:把聊天记录拼起来不就行了?
java
// 别这么干
List<String> history = new ArrayList<>();
public String chat(String userId, String userInput) {
history.add("用户:" + userInput);
// 把所有历史拼成一个长字符串
String fullPrompt = String.join("\n", history);
String response = chatClient.prompt()
.user(fullPrompt)
.call()
.content();
history.add("AI:" + response);
return response;
}
能用。但3个问题:
- token爆炸 --- 聊了50轮后,每次调用都带上全部历史,token费用线性增长
- 没隔离 --- 用户A和用户B的对话混在一起(上面代码是全局变量,真实场景更隐蔽)
- 没上限 --- 聊了500轮,prompt长度超过模型上下文窗口,直接报错
Spring AI的正解:ChatMemory
Spring AI内置了一套对话记忆机制,核心就3个东西:
| 类 | 作用 |
|---|---|
ChatMemory |
存储对话历史的接口 |
MessageChatMemoryAdvisor |
自动把历史注入Prompt的拦截器 |
InMemoryChatMemory |
内存版实现(生产环境换持久化版) |
第一步:配置ChatMemory
java
@Configuration
public class AiConfig {
// 对话记忆存储(内存版,重启丢失)
@Bean
public ChatMemory chatMemory() {
return new InMemoryChatMemory();
}
// ChatClient配置,挂上记忆拦截器
@Bean
public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) {
return builder
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory)
.chatMemoryRetrieveSize(20) // 最多带最近20条消息
.build()
)
.build();
}
}
关键参数是chatMemoryRetrieveSize(20)------每次调用最多带最近20条消息(10轮对话),防止token爆炸。
第二步:对话时传入conversationId
java
@Service
public class ChatService {
@Autowired
private ChatClient chatClient;
public String chat(String userId, String userInput) {
return chatClient.prompt()
.user(userInput)
.advisors(a -> a.param(
ChatMemory.CONVERSATION_ID, // 关键:用userId做会话隔离
userId
))
.call()
.content();
}
}
CONVERSATION_ID就是会话隔离的关键。不同userId对应不同的对话历史,互不干扰。
第三步:验证效果
ini
用户(userId=1001):我叫张三,做Java开发的
AI:你好张三!Java开发挺好的方向。
用户(userId=1001):推荐几本适合我的书?
AI:根据你的Java开发背景,推荐以下几本:
1.《Spring实战》------ Spring生态必读
2.《Effective Java》------ 进阶必备
...
这次AI记住了。因为Spring AI自动把上一轮的对话历史注入到了Prompt里。
生产环境:换成持久化存储
InMemoryChatMemory重启就丢数据。生产环境必须持久化。
Spring AI提供了一个简洁的接口,自己实现就行:
java
public class JdbcChatMemory implements ChatMemory {
@Autowired
private JdbcTemplate jdbcTemplate;
@Override
public void add(String conversationId, List<Message> messages) {
// 把消息存到数据库
for (Message msg : messages) {
jdbcTemplate.update(
"INSERT INTO chat_memory (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
conversationId, msg.getMessageType().getValue(), msg.getText(), LocalDateTime.now()
);
}
}
@Override
public List<Message> get(String conversationId, int lastN) {
// 查最近N条消息
return jdbcTemplate.query(
"SELECT role, content FROM chat_memory WHERE conversation_id = ? ORDER BY created_at DESC LIMIT ?",
(rs, rowNum) -> {
String role = rs.getString("role");
String content = rs.getString("content");
if ("user".equals(role)) {
return UserMessage.builder().text(content).build();
}
return AssistantMessage.builder().text(content).build();
},
conversationId, lastN
);
}
@Override
public void clear(String conversationId) {
jdbcTemplate.update("DELETE FROM chat_memory WHERE conversation_id = ?", conversationId);
}
}
建张表就行:
sql
CREATE TABLE chat_memory (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
conversation_id VARCHAR(64) NOT NULL,
role VARCHAR(16) NOT NULL,
content TEXT NOT NULL,
created_at DATETIME NOT NULL,
INDEX idx_conv_time (conversation_id, created_at)
);
换掉Bean配置就完事了:
java
@Bean
public ChatMemory chatMemory() {
return new JdbcChatMemory(); // 替换InMemoryChatMemory
}
其他代码一行不用改。这就是接口的价值。
我踩的3个坑
坑1:System Prompt被历史消息"冲掉"
加了ChatMemory后,我发现AI的性格变了------之前System Prompt设定的"你是一个Java技术专家"不管用了。
原因:ChatMemory的消息列表里,System消息排在最前面,但历史消息多了之后,System消息的权重被稀释了。
解决 :用defaultSystem()而不是手动拼System消息。Spring AI会确保System消息始终在消息列表的最前面。
java
@Bean
public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) {
return builder
.defaultSystem("你是一个Java技术专家,回答要简洁直接") // 这个始终在最前面
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build()
)
.build();
}
坑2:流式响应和记忆冲突
用.stream()做流式输出时,ChatMemory拿不到完整的AI回复------因为回复是一个token一个token回来的,不是一次性返回的。
结果:历史记录里存了一条空的AI回复。下一轮对话时,AI看到的上一轮回复是空的。
解决 :Spring AI 1.0+的MessageChatMemoryAdvisor已经处理了这个问题,会自动等流式输出完成后存入完整内容。但如果用的是老版本(0.9.x),需要自己处理:
java
// 老版本需要手动存历史
public Flux<String> streamChat(String userId, String userInput) {
// 先手动添加用户消息到记忆
chatMemory.add(userId, List.of(new UserMessage(userInput)));
StringBuilder fullResponse = new StringBuilder();
return chatClient.prompt()
.user(userInput)
.stream()
.content()
.doOnNext(token -> fullResponse.append(token))
.doOnComplete(() -> {
// 流结束后手动存入AI回复
chatMemory.add(userId, List.of(
new AssistantMessage(fullResponse.toString())
));
});
}
坑3:并发用户的线程安全
InMemoryChatMemory底层用ConcurrentHashMap,线程安全没问题。但我自己写的JdbcChatMemory一开始没加事务,高并发下出现了消息顺序错乱。
解决 :add()方法加事务,保证同一会话的消息按顺序写入:
java
@Transactional
public void add(String conversationId, List<Message> messages) {
for (Message msg : messages) {
jdbcTemplate.update(...);
}
}
实际效果
我用这套方案做了一个内部知识库问答助手,200人同时使用。
| 指标 | 无记忆 | 有记忆 |
|---|---|---|
| 用户满意度 | 3.2分 | 4.6分 |
| "你刚才说了"类投诉 | 每天15+ | 0 |
| 平均对话轮次 | 2.3轮 | 8.7轮 |
| 单次调用token | 波动大(无上下文) | 稳定(固定窗口) |
结论很明显:对话记忆不是可选功能,是必备功能。
什么场景必须加记忆
我的经验,这几类场景不加记忆就没法用:
- 客服/助手类 --- 用户会连续追问,上下文关联紧密
- 教学类 --- 学生问问题,AI要根据学生的水平调整回答
- 代码审查类 --- 先给了一段代码,后面问的问题依赖前面的上下文
- 多步骤任务 --- 先配置A,再配置B,最后一起部署
相反,如果是"一问一答"的工具型场景(翻译、摘要、格式转换),不加也行。
小结
Spring AI的ChatMemory方案总结:
- 开发环境 :用
InMemoryChatMemory+MessageChatMemoryAdvisor,20行代码搞定 - 生产环境 :实现
ChatMemory接口,换成数据库/Redis存储 - 关键参数 :
chatMemoryRetrieveSize控制历史消息数量,防止token爆炸 - 会话隔离 :用
CONVERSATION_ID区分不同用户/会话
我整理了一个 Spring AI 起步模板,clone下来改3行配置就能跑: 👉 GitHub搜 spring-ai-starter(lincyang720) 想聊Java转AI的具体路线,加我微信 newboy2004 备注"AI"