内容概览
- 短期记忆:会话上下文窗口管理、滑动窗口策略
- 长期记忆:持久化到 Redis/MySQL,支持跨会话检索
- 三层上下文压缩:摘要压缩 → Assistant 裁剪 → 滑动窗口
- 多会话隔离:不同用户/不同会话的记忆完全独立
- Redis Chat Memory 并发问题处理
一、核心概念:为什么需要对话记忆?
大模型本身是无状态的,每次 API 调用都是独立的。
比如,第一轮你说"我叫小明",第二轮问"我叫什么",模型不会记得。
ChatGPT 能"记住",是因为每次请求时把历史消息都带进了上下文。
Spring AI 将对话记忆管理拆分为三层架构,各司其职:
| 层次 | 组件 | 职责 |
|---|---|---|
| 存储层 | ChatMemoryRepository | 消息的增删查改(内存、JDBC、Redis 等) |
| 记忆策略层 | ChatMemory | 决定保留哪些消息、何时裁剪 |
| 拦截器层 | MessageChatMemoryAdvisor | 请求自动注入历史,响应后保存新消息 |
数据流示意:
用户消息 → MessageChatMemoryAdvisor
├── 请求前:从 ChatMemory 加载历史消息 → 注入到 Prompt
├── 调用模型 → 得到响应
└── 响应后:将本轮用户消息+AI 回复 → 写入 ChatMemory
二、记忆类型详解
1. MessageWindowChatMemory(滑动窗口,推荐首选)
- 维护固定大小的消息窗口(默认 20 条),超限时自动移除旧消息,始终保留 SystemMessage。
java
@Bean
public ChatMemory chatMemory() {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(new InMemoryChatMemoryRepository())
.maxMessages(20)
.build();
}
maxMessages 选择建议:
| 场景 | 建议值 | 说明 | | --- | --- | --- | | 简单问答 | 10-20 | 5-10 轮对话足够 | | 客服对话 | 30-50 | 需要记住更多上下文 | | 代码助手 | 40-60 | 代码讨论通常较长 | | 文档分析 | 20-30 | 重点在检索内容 |
⚠️ 省钱提醒:maxMessages 直接影响每次请求的 Token 消耗,历史越多每次越贵。
2. TokenWindowChatMemory(基于 Token 数量限制)
- 按 Token 数量裁剪窗口,更精确控制上下文长度,防止超出模型限制。
- 适用于消息长度差异较大的场景(如代码/长文本)。
3. 摘要记忆(对长对话进行语义压缩)
- 对超出阈值的历史对话调用 LLM 生成摘要,用摘要替代原始消息,大幅节省 Token,同时保留关键信息。
工作流程:
对话消息达到阈值(如 20 条)
↓
保留最近 6 条原始消息
↓
将前面消息调用 LLM 压缩为摘要
↓
后续上下文 = 摘要 + 最近 6 条原始消息
↓
对话继续增长,再次触发摘要压缩
核心代码实现(部分展示):
java
/**
* 摘要聊天记忆
*/
@Slf4j
public class SummarizingChatMemory implements ChatMemory {
private final ChatClient ragMemoryChatClient;
private final ChatMemory chatMemory;
/**
* 触发摘要的阈值
*/
private int summaryThreshold = 20;
/**
* 保留最近N条
*/
private int keepRecent = 6;
public SummarizingChatMemory(ChatClient ragMemoryChatClient, ChatMemory chatMemory,
int summaryThreshold, int keepRecent) {
this.ragMemoryChatClient = ragMemoryChatClient;
this.chatMemory = chatMemory;
this.summaryThreshold = summaryThreshold;
this.keepRecent = keepRecent;
}
@Override
public void add(@NotNull String conversationId, @NotNull List<Message> messages) {
chatMemory.add(conversationId, messages);
// 检查是否需要压缩
List<Message> allMessages = chatMemory.get(conversationId);
if (allMessages.size() >= summaryThreshold) {
summarizeOldMessages(conversationId, allMessages);
}
}
@Override
public List<Message> get(@NotNull String conversationId) {
List<Message> all = chatMemory.get(conversationId);
if (all.size() <= keepRecent) {
return all;
}
List<Message> result = new ArrayList<>();
// 首先是摘要消息(如果有)
all.stream()
.filter(m -> "SUMMARY".equals(m.getMetadata().get("type")))
.findFirst()
.ifPresent(result::add);
// 然后是最近的N条原始消息
result.addAll(all.subList(all.size() - keepRecent, all.size()));
return result;
}
@Override
public void clear(@NotNull String conversationId) {
chatMemory.clear(conversationId);
}
/**
* 将旧消息压缩为摘要
*/
private void summarizeOldMessages(String conversationId, List<Message> allMessages) {
List<Message> toSummarize = allMessages.subList(0, allMessages.size() - keepRecent);
String conversation = toSummarize.stream()
.map(m -> m.getMessageType() + ": " + m.getText())
.collect(Collectors.joining("\n"));
String summary = ragMemoryChatClient.prompt()
.system("请将以下对话压缩为简洁的结构化摘要(不超过500字)," +
"包含关键决策、用户偏好、未解决问题。")
.user(conversation)
.call()
.content();
// 将摘要作为SystemMessage加入,并清除旧消息
chatMemory.clear(conversationId);
SystemMessage systemMessage = SystemMessage.builder()
.text(summary)
.metadata(Map.of("type", "SUMMARY"))
.build();
chatMemory.add(conversationId, List.of(systemMessage));
// 保留最近的原始消息
chatMemory.add(conversationId, allMessages.subList(allMessages.size() - keepRecent, allMessages.size()));
}
}
4. 向量记忆(语义检索历史)
- 每条消息向量化存入 VectorStore,检索时用语义搜索找到最相关的历史消息。
- 适合超长对话(几百条消息),可"回忆"早期内容。
三、存储实现:从开发到生产
1. 内存存储(开发测试)
- 零配置,重启丢失,适合开发调试。
- Spring AI 默认使用 InMemoryChatMemoryRepository + MessageWindowChatMemory。
java
@Bean
public ChatMemory chatMemory() {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(new InMemoryChatMemoryRepository())
.maxMessages(20)
.build();
}
2. JDBC 持久化(生产环境推荐)
- 支持 MySQL、PostgreSQL、SQL Server 等主流数据库,自动建表。
依赖与配置:
java
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>
Bean 配置:
xml
spring:
ai:
chat:
memory:
repository:
jdbc:
initialize-schema: always
3. Redis 持久化(高并发场景)
- 适合高并发、低延迟场景,支持 TTL 自动过期。
依赖与配置:
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-redis</artifactId>
</dependency>
核心代码实现(部分展示):
java
/**
* Redis消息记忆持久化
* <p>
* TTL 通过配置项 spring.ai.chat.memory.repository.redis.time-to-live 控制(Duration 格式:24h / 30d / 60s),
* 不配置则永不过期。
*/
@Repository
public class RedisChatMemoryRepository implements ChatMemoryRepository {
@Resource
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "ai:memory:";
/** 从配置读取TTL,未配置则为null(永不过期) */
@Value("${spring.ai.chat.memory.repository.redis.time-to-live:#{null}}")
private Duration timeToLive;
@Override
public void saveAll(@NotNull String conversationId, List<Message> messages) {
String key = KEY_PREFIX + conversationId;
for (Message message : messages) {
String value = message.getMessageType().name() + "||" + message.getText();
stringRedisTemplate.opsForList().rightPush(key, value);
}
// 只保留最近50条
stringRedisTemplate.opsForList().trim(key, -50, -1);
// 设置TTL(每次写入都刷新过期时间)
if (timeToLive != null && !timeToLive.isZero() && !timeToLive.isNegative()) {
stringRedisTemplate.expire(key, timeToLive);
}
}
@Override
public List<String> findConversationIds() {
// 生产环境用SCAN替代KEYS,避免阻塞Redis主线程
Set<String> keys = stringRedisTemplate.execute((RedisCallback<Set<String>>) connection -> {
Set<String> result = new HashSet<>();
ScanOptions options = ScanOptions.scanOptions()
.match(KEY_PREFIX + "*")
.count(100) // 每次扫描建议100条,平衡迭代次数和网络开销
.build();
try (Cursor<byte[]> cursor = connection.keyCommands().scan(options)) {
while (cursor.hasNext()) {
result.add(new String(cursor.next(), StandardCharsets.UTF_8));
}
}
return result;
});
if (keys == null || keys.isEmpty()) {
return List.of();
}
return keys.stream()
.map(key -> key.substring(KEY_PREFIX.length()))
.toList();
}
@Override
public List<Message> findByConversationId(@NotNull String conversationId) {
String key = KEY_PREFIX + conversationId;
List<String> values = stringRedisTemplate.opsForList().range(key, 0, -1);
if (values == null) {
return List.of();
}
return values.stream().map(v -> {
String[] parts = v.split("\\|\\|", 2);
String role = parts[0];
String content = parts.length > 1 ? parts[1] : "";
return switch (MessageType.valueOf(role)) {
case USER -> (Message) new UserMessage(content);
case ASSISTANT -> (Message) new AssistantMessage(content);
case SYSTEM -> (Message) new SystemMessage(content);
default -> (Message) new UserMessage(content);
};
}).toList();
}
@Override
public void deleteByConversationId(@NotNull String conversationId) {
stringRedisTemplate.delete(KEY_PREFIX + conversationId);
}
}
生产环境建议设置 TTL(如 24 小时自动过期):
xml
spring:
ai:
chat:
memory:
repository:
redis:
time-to-live: 24h
四、会话隔离:多用户多会话支持
- 通过 CONVERSATION_ID 参数隔离不同用户/会话的记忆。
java
@GetMapping("/chat")
public String chat(@RequestParam String message, @RequestParam(defaultValue = "default") String conversationId) {
return chatClient.prompt()
.user(message)
.advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, conversationId))
.call()
.content();
}
典型隔离模式:
| 模式 | conversationId 生成方式 | 适用场景 |
|---|---|---|
| 按用户隔离 | userId | 每用户一个长期会话 |
| 按会话隔离 | UUID.randomUUID() | 每次对话独立 |
| 按用户+日期隔离 | userId + ":" + LocalDate.now() | 每天一个新会话 |
五、上下文压缩策略对比
| 策略 | 是否调 LLM | 原理 | 适用场景 | 成本 |
|---|---|---|---|---|
| SlidingWindow | 否 | 保留最近 N 条 | 短对话、成本敏感 | 零额外 Token |
| TurnWindow | 否 | 按回合数保留 | 回合结构清晰的对话 | 零额外 Token |
| TokenCount | 否 | 按 Token 预算保留 | 硬性上下文窗口限制 | 零额外 Token |
| RecursiveSummarization | 是 | 滚动摘要压缩 | 信息密度高的长会话 | 每次压缩消耗 1 次 LLM |
选型建议:
- 轮次少(<10 轮):不压缩
- 轮次多但信息密度低:用 SlidingWindow
- 轮次多且信息密度高:用 RecursiveSummarization
- 递归摘要的 overlapSize 建议 1-3,太大浪费 Token,太小摘要之间易断片
六、三层记忆架构(生产级推荐)
推荐架构:短期 + 中期 + 长期分层
- 长期记忆:AutoMemoryTools 跨会话持久化:用户偏好、关键决策、历史事实(Markdown 文件)
- 中期记忆:自定义摘要策略 / Session API (孵化中) 对话摘要压缩,或 VectorStoreChatMemoryAdvisor 做语义检索
- 短期记忆:MessageWindowChatMemory+MessageChatMemoryAdvisor 当前会话最近 N 条消息,保证即时连贯性
七、Session API:下一代记忆方案(孵化中)
截至 2026 年 4 月,Spring AI 官方正在孵化 Session API,用"事件溯源 + 回合原子 + 分支隔离 + 关键字召回"解决 ChatMemory 的结构性缺陷。
ChatMemory 的三个硬伤:
- 按消息条数截断,可能切断工具调用回合
- 消息无身份标识(没有 UUID、没有时间戳)
- 不支持多 Agent 分支隔离
Session API 核心改进:
- 事件溯源:每条消息是 SessionEvent,有 UUID 和时间戳
- 回合原子:一问一答+工具调用组成不可拆分的 Turn
- 分支隔离:多 Agent 场景下,每个 Agent 有独立分支
- 压缩策略:支持 SlidingWindow、TurnWindow、TokenCount、RecursiveSummarization
- 关键字召回:conversation_search 工具让模型主动搜索早期对话