作者:互联网 时间: 2026-07-22 08:15:56
做AI对话功能,最容易被用户吐槽的一句话是什么?

"你刚才不是说过了吗?怎么又问我?"
大模型天生是"金鱼记忆"——每次调用都是独立的,上一次说了什么,它完全不记得。
这个问题我上周刚解决。用的Spring AI自带的ChatMemory机制,20行代码搞定。今天把方案拆干净。
先看一个真实场景。用户跟AI聊天:
复制代码用户:我叫张三,做Java开发的
AI:你好张三!Java开发挺不错的。
用户:推荐几本适合我的书?
AI:请问您是从事什么行业的呢?
用户直接炸了:我刚说了我做Java的你没听到?
这不是模型笨,是架构问题。每次调API,你只传了当前这一句话进去,历史对话根本没带。模型看不到之前的内容,当然不记得。
很多人第一反应是:把聊天记录拼起来不就行了?
复制代码// 别这么干
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个问题:
Spring AI内置了一套对话记忆机制,核心就3个东西:
| 类 | 作用 |
|---|---|
ChatMemory | 存储对话历史的接口 |
MessageChatMemoryAdvisor | 自动把历史注入Prompt的拦截器 |
InMemoryChatMemory | 内存版实现(生产环境换持久化版) |
复制代码@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爆炸。
复制代码@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对应不同的对话历史,互不干扰。
复制代码用户(userId=1001):我叫张三,做Java开发的
AI:你好张三!Java开发挺好的方向。用户(userId=1001):推荐几本适合我的书?
AI:根据你的Java开发背景,推荐以下几本:
1.《Spring实战》—— Spring生态必读
2.《Effective Java》—— 进阶必备
...
这次AI记住了。因为Spring AI自动把上一轮的对话历史注入到了Prompt里。
InMemoryChatMemory重启就丢数据。生产环境必须持久化。
Spring AI提供了一个简洁的接口,自己实现就行:
复制代码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);
}
}
建张表就行:
复制代码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配置就完事了:
复制代码@Bean
public ChatMemory chatMemory() {
return new JdbcChatMemory(); // 替换InMemoryChatMemory
}
其他代码一行不用改。这就是接口的价值。
加了ChatMemory后,我发现AI的性格变了——之前System Prompt设定的"你是一个Java技术专家"不管用了。
原因:ChatMemory的消息列表里,System消息排在最前面,但历史消息多了之后,System消息的权重被稀释了。
解决:用defaultSystem()而不是手动拼System消息。Spring AI会确保System消息始终在消息列表的最前面。
复制代码@Bean
public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) {
return builder
.defaultSystem("你是一个Java技术专家,回答要简洁直接") // 这个始终在最前面
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build()
)
.build();
}
用.stream()做流式输出时,ChatMemory拿不到完整的AI回复——因为回复是一个token一个token回来的,不是一次性返回的。
结果:历史记录里存了一条空的AI回复。下一轮对话时,AI看到的上一轮回复是空的。
解决:Spring AI 1.0+的MessageChatMemoryAdvisor已经处理了这个问题,会自动等流式输出完成后存入完整内容。但如果用的是老版本(0.9.x),需要自己处理:
复制代码// 老版本需要手动存历史
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())
));
});
}
InMemoryChatMemory底层用ConcurrentHashMap,线程安全没问题。但我自己写的JdbcChatMemory一开始没加事务,高并发下出现了消息顺序错乱。
解决:add()方法加事务,保证同一会话的消息按顺序写入:
复制代码@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 | 波动大(无上下文) | 稳定(固定窗口) |
结论很明显:对话记忆不是可选功能,是必备功能。
我的经验,这几类场景不加记忆就没法用:
相反,如果是"一问一答"的工具型场景(翻译、摘要、格式转换),不加也行。
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"