对话记忆管理深度实战:让AI真正记住你们聊过的内容
2026/7/27大约 7 分钟对话记忆ChatMemorySpring AI多轮对话RedisJava
对话记忆管理深度实战:让AI真正记住你们聊过的内容
一、一个让用户崩溃的体验
小李是某SaaS公司的研发,他们上线了一个AI客服助手。
上线第三天,客服团队反馈:"用户投诉AI太笨,问了上下文相关的问题,AI根本不记得前面说过什么,要重复说好几遍。"
小李打开后台看了几条对话记录,发现问题出在哪了——
用户:你们的产品支持导出Excel吗? AI:支持,可以在数据页面点击导出按钮。 用户:那支持PDF格式吗? AI:您好,请问您有什么需要帮助的?
第二条消息,AI把之前的上下文全忘了,重新打招呼,把用户当成了新用户。
这不是AI的问题,是对话记忆没做好。大模型本身是无状态的,每次调用都是全新的——你不传历史,它就没有历史。
这篇文章,从最简单的实现出发,一直到生产级别的对话记忆管理方案。
二、对话记忆的本质:上下文注入
理解对话记忆之前,先理解大模型的工作方式。
大模型API每次调用都是独立的——它不会自动记住你上次说了什么。所谓"多轮对话",本质上是把历史消息列表一起发给模型:
所以"对话记忆"的核心工作是:管理历史消息的存储、读取和裁剪,在每次调用时把相关历史注入到请求中。
三、Spring AI 内置记忆方案
3.1 最简单的 InMemoryChatMemory
@Configuration
public class ChatMemoryConfig {
@Bean
public ChatMemory chatMemory() {
// 内存存储,应用重启后消失
// 适合开发调试,不适合生产
return new InMemoryChatMemory();
}
@Bean
public ChatClient chatClientWithMemory(ChatModel chatModel, ChatMemory chatMemory) {
return ChatClient.builder(chatModel)
.defaultAdvisors(
// 自动管理对话历史
new MessageChatMemoryAdvisor(chatMemory,
"default-session", // 默认会话ID
20) // 保留最近20条消息
)
.build();
}
}@RestController
@RequiredArgsConstructor
public class MemoryChatController {
private final ChatClient chatClient;
@PostMapping("/chat")
public String chat(@RequestParam String message,
@RequestParam String sessionId) {
return chatClient.prompt()
.user(message)
// 每次请求传入会话ID,实现会话隔离
.advisors(a -> a.param(
AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY,
sessionId))
.call()
.content();
}
}3.2 生产级:Redis 持久化对话记忆
/**
* 基于Redis的对话记忆实现
* 支持跨重启持久化、会话过期、分布式部署
*/
@Component
@RequiredArgsConstructor
public class RedisChatMemory implements ChatMemory {
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;
// 会话过期时间:7天
private static final Duration SESSION_TTL = Duration.ofDays(7);
// 每个会话最多保留消息数
private static final int MAX_MESSAGES_PER_SESSION = 50;
private static final String KEY_PREFIX = "chat:memory:";
@Override
public void add(String conversationId, List<Message> messages) {
String key = KEY_PREFIX + conversationId;
for (Message message : messages) {
try {
String json = objectMapper.writeValueAsString(
MessageDTO.from(message));
// 追加到Redis List的右端(最新消息在末尾)
redisTemplate.opsForList().rightPush(key, json);
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to serialize message", e);
}
}
// 裁剪:只保留最新的N条
long size = redisTemplate.opsForList().size(key);
if (size != null && size > MAX_MESSAGES_PER_SESSION) {
redisTemplate.opsForList().trim(key,
size - MAX_MESSAGES_PER_SESSION, -1);
}
// 刷新过期时间
redisTemplate.expire(key, SESSION_TTL);
}
@Override
public List<Message> get(String conversationId, int lastN) {
String key = KEY_PREFIX + conversationId;
// 获取最后N条消息
List<String> jsonList = redisTemplate.opsForList()
.range(key, Math.max(0, -lastN), -1);
if (jsonList == null || jsonList.isEmpty()) {
return Collections.emptyList();
}
return jsonList.stream()
.map(json -> {
try {
MessageDTO dto = objectMapper.readValue(json, MessageDTO.class);
return dto.toMessage();
} catch (JsonProcessingException e) {
log.error("Failed to deserialize message", e);
return null;
}
})
.filter(Objects::nonNull)
.collect(toList());
}
@Override
public void clear(String conversationId) {
redisTemplate.delete(KEY_PREFIX + conversationId);
}
/**
* 获取会话统计信息
*/
public ConversationStats getStats(String conversationId) {
String key = KEY_PREFIX + conversationId;
Long size = redisTemplate.opsForList().size(key);
Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
return new ConversationStats(
conversationId,
size != null ? size : 0,
ttl != null ? ttl : 0
);
}
public record ConversationStats(
String conversationId,
long messageCount,
long remainingTtlSeconds
) {}
}四、上下文窗口管理:解决"记太多撑爆"的问题
对话历史越来越长,会遇到两个问题:
- 超过模型的上下文窗口限制(比如128K tokens)
- 成本随历史长度线性增长
4.1 滑动窗口策略
最简单的策略:只保留最近N条消息。
/**
* 滑动窗口记忆:只保留最近N轮对话
*/
@Service
@RequiredArgsConstructor
public class SlidingWindowMemoryService {
private final ChatClient chatClient;
private static final int WINDOW_SIZE = 10; // 保留最近10条消息(5轮对话)
public String chat(String sessionId, String message) {
return chatClient.prompt()
.user(message)
.advisors(a -> a
.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY,
sessionId)
.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_RETRIEVE_SIZE_KEY,
WINDOW_SIZE))
.call()
.content();
}
}4.2 摘要压缩策略:长对话不丢失语义
滑动窗口的问题是丢失了早期的上下文信息。更好的方案是:当历史过长时,把早期内容压缩成摘要。
/**
* 带摘要压缩的对话记忆服务
* 历史超过阈值时,把旧消息压缩成摘要,释放上下文空间
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class SummaryMemoryService {
private final ChatClient chatClient;
private final RedisChatMemory redisChatMemory;
private static final int SUMMARY_THRESHOLD = 20; // 超过20条触发压缩
private static final int KEEP_RECENT = 6; // 压缩后保留最近6条
/**
* 带记忆的对话,自动触发摘要压缩
*/
public String chat(String sessionId, String userMessage) {
// 检查是否需要压缩
maybeCompressMemory(sessionId);
// 获取当前历史(包含摘要)
List<Message> history = redisChatMemory.get(sessionId, Integer.MAX_VALUE);
// 发起对话
String response = chatClient.prompt()
.messages(history)
.user(userMessage)
.call()
.content();
// 保存本轮对话
redisChatMemory.add(sessionId, List.of(
new UserMessage(userMessage),
new AssistantMessage(response)
));
return response;
}
/**
* 检查并执行记忆压缩
*/
private void maybeCompressMemory(String sessionId) {
List<Message> history = redisChatMemory.get(sessionId, Integer.MAX_VALUE);
if (history.size() < SUMMARY_THRESHOLD) {
return; // 不需要压缩
}
log.info("Compressing memory for session {}, current size: {}",
sessionId, history.size());
// 取出需要压缩的旧消息(保留最近N条不压缩)
int compressUntil = history.size() - KEEP_RECENT;
List<Message> toCompress = history.subList(0, compressUntil);
List<Message> toKeep = history.subList(compressUntil, history.size());
// 用LLM生成摘要
String conversationText = toCompress.stream()
.map(m -> (m instanceof UserMessage ? "用户" : "AI") + ": " + m.getContent())
.collect(Collectors.joining("\n"));
String summary = chatClient.prompt()
.system("你是一个对话摘要专家。请用简洁的语言总结以下对话的关键信息," +
"包括:主要话题、用户的需求和关键结论。摘要不超过200字。")
.user("请总结以下对话:\n\n" + conversationText)
.call()
.content();
// 重建记忆:摘要 + 最近N条
redisChatMemory.clear(sessionId);
redisChatMemory.add(sessionId,
List.of(new SystemMessage("【对话历史摘要】\n" + summary)));
redisChatMemory.add(sessionId, toKeep);
log.info("Memory compressed for session {}: {} -> {} messages",
sessionId, history.size(), 1 + toKeep.size());
}
}五、多会话管理与权限隔离
生产环境中,不同用户的对话必须严格隔离:
/**
* 多租户对话管理
* 确保用户只能访问自己的会话
*/
@Service
@RequiredArgsConstructor
public class MultiTenantChatService {
private final ChatClient chatClient;
private final RedisChatMemory chatMemory;
private final ConversationRepository conversationRepo;
/**
* 创建新会话
*/
public Conversation createConversation(String userId, String title) {
String sessionId = UUID.randomUUID().toString();
Conversation conv = Conversation.builder()
.sessionId(sessionId)
.userId(userId)
.title(title)
.createdAt(LocalDateTime.now())
.build();
return conversationRepo.save(conv);
}
/**
* 发消息(带权限校验)
*/
public String sendMessage(String userId, String sessionId, String message) {
// 权限校验:确保该会话属于该用户
validateOwnership(userId, sessionId);
// 使用用户隔离的key:userId:sessionId
String isolatedSessionId = userId + ":" + sessionId;
return chatClient.prompt()
.user(message)
.advisors(a -> a.param(
AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY,
isolatedSessionId))
.call()
.content();
}
/**
* 获取用户的所有会话列表
*/
public List<ConversationSummary> getUserConversations(String userId) {
return conversationRepo.findByUserIdOrderByUpdatedAtDesc(userId)
.stream()
.map(conv -> ConversationSummary.builder()
.sessionId(conv.getSessionId())
.title(conv.getTitle())
.messageCount(chatMemory.getStats(
userId + ":" + conv.getSessionId()).messageCount())
.lastUpdated(conv.getUpdatedAt())
.build())
.collect(toList());
}
private void validateOwnership(String userId, String sessionId) {
boolean isOwner = conversationRepo.existsBySessionIdAndUserId(
sessionId, userId);
if (!isOwner) {
throw new AccessDeniedException(
"User " + userId + " does not own session " + sessionId);
}
}
}六、对比:不同记忆策略的选择建议
| 策略 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| InMemoryChatMemory | 开发/测试 | 零配置,简单 | 重启丢失,单机 |
| RedisChatMemory | 生产环境 | 持久化,分布式 | 需要Redis |
| 滑动窗口 | 长对话,成本敏感 | 成本可控 | 丢失早期上下文 |
| 摘要压缩 | 长对话,上下文重要 | 不丢失语义 | 摘要有额外成本 |
| 向量检索记忆 | 超长历史 | 相关性检索 | 实现复杂 |
大多数生产场景的推荐方案:RedisChatMemory + 滑动窗口(20-30条)+ 超长时触发摘要压缩。
七、记忆管理系统架构图
八、总结
对话记忆管理看起来简单,但做好不容易。核心要点:
- 大模型无状态,记忆全靠你存储和注入
- Spring AI 的
MessageChatMemoryAdvisor是最便捷的入口 - 生产环境用 Redis 持久化,别用内存
- 超长对话用摘要压缩,不要无限堆叠消息
- 多用户场景做好隔离,避免信息泄露
把这套做好,你的AI对话产品才算真正好用。
