第三章:4、补充:对话记忆与会话管理

内容概览

  • 短期记忆:会话上下文窗口管理、滑动窗口策略
  • 长期记忆:持久化到 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 的三个硬伤:

  1. 按消息条数截断,可能切断工具调用回合
  2. 消息无身份标识(没有 UUID、没有时间戳)
  3. 不支持多 Agent 分支隔离

Session API 核心改进:

  • 事件溯源:每条消息是 SessionEvent,有 UUID 和时间戳
  • 回合原子:一问一答+工具调用组成不可拆分的 Turn
  • 分支隔离:多 Agent 场景下,每个 Agent 有独立分支
  • 压缩策略:支持 SlidingWindow、TurnWindow、TokenCount、RecursiveSummarization
  • 关键字召回:conversation_search 工具让模型主动搜索早期对话
相关推荐
弈栈录1 小时前
用户、会话与消息表应该如何设计
数据库·后端
maozhenzhong1 小时前
接口加解密与安全通信方案
后端
god_cvz1 小时前
第三章:5、MCP协议入门
后端·ai编程
RaaS1001 小时前
AI网关选型指南:MAI Gateway(魔芋企业级AI网关)vs 开源方案全面对比
前端·后端
明月_清风1 小时前
ClickHouse 实战指南:为什么它能做到"亿级数据秒级查询"?
后端·数据分析
Wx-bishekaifayuan1 小时前
springboot铜仁非遗之家76938-计算机课程设计、毕业设计
java·spring boot·后端·spring·django·课程设计·旅游
唐青枫2 小时前
薛定谔的猫详解:一只猫如何揭开量子测量之谜
后端
锋行天下2 小时前
langgraph 循环节点的静态和动态实现
前端·后端
打工仔折腾 AI2 小时前
在线PDF工具隐私焦虑?把BentoPDF部署到极空间NAS本地处理
人工智能·后端·python·pdf·ai agent 实战