在AI对话系统开发中,相信很多开发者都遇到过这样的困境:随着用户与助手的对话不断深入,历史消息越积越多,Token消耗呈线性暴涨,最终导致模型无法处理请求,甚至出现服务卡顿、响应超时的问题。这不仅影响用户体验,还会增加服务部署成本——毕竟Token消耗直接与调用成本挂钩。

今天,我们就来详细拆解一套高效、安全、可落地的会话记忆压缩策略,从“为什么需要压缩”到“如何落地实现”,再到“效果验证”,全方位解析其核心逻辑,帮你轻松解决长对话场景下的Token难题。

一、痛点直击:为什么必须做会话记忆压缩?

在AI对话系统中,模型的上下文窗口是有限的(比如GPT-3.5 Turbo的上下文窗口通常为4k/8k Token),而对话历史会持续占用Token空间。其核心痛点可以总结为一句话:对话越长,历史消息越多 → Token耗尽 → 模型无法处理请求

举个真实场景:用户通过智能助手咨询产品使用问题,从初始咨询、功能疑问,到故障排查、后续优化建议,持续对话50轮以上。如果不做任何压缩,历史消息会占用近10000 Token,远超普通模型的上下文限制,直接导致对话中断。

针对这个痛点,我们设计了一套简洁高效的解决方案:将冗长的对话历史进行摘要压缩,仅保留核心信息,同时保留最近几轮对话原文(保证近期交互的连贯性),具体逻辑如下:

对话历史:[用户1] [助手1] [用户2] [助手2] [用户3] [助手3] ...
       ↓                   ↓                   ↓
     摘要1              摘要2           摘要3
       ↓                   ↓                   ↓
压缩成: [摘要1] [摘要2] [摘要3] [最近3轮对话]

这样一来,既解决了Token爆炸的问题,又能保证模型准确理解对话上下文,兼顾效率与体验。

二、核心配置:4个参数搞定压缩规则

会话记忆压缩的核心的是通过可配置参数,灵活适配不同场景的需求(比如开发环境调试、生产环境部署)。以下是核心配置参数的详细解读,基于YAML配置文件,通俗易懂且可直接复用:

rag:
  memory:
    # 保留最近几轮对话原文(1 user + 1 assistant = 1轮)
    history-keep-turns: 8
    
    # 是否启用摘要压缩(开发环境可关闭,便于调试)
    summary-enabled: false
    
    # 超过多少轮开始生成摘要(需大于history-keep-turns)
    summary-start-turns: 9
    
    # 摘要最大字符数(控制Token消耗,避免摘要过长)
    summary-max-chars: 200

为了更清晰地理解每个参数的作用,我们整理了参数对照表,明确默认值和核心含义:

参数 默认值 含义
historyKeepTurns 8 保留最近8轮对话原文,保证近期交互的连贯性,无需压缩
summaryStartTurns 9 当对话总轮数达到9轮时,开始对超出8轮的部分进行摘要压缩
summaryMaxChars 200 单个摘要的最大字符数,避免摘要本身占用过多Token
summaryEnabled false 是否启用摘要压缩功能,开发环境关闭便于调试历史消息

小贴士:生产环境中,建议将summaryEnabled设为true,同时根据模型上下文窗口大小,调整history-keep-turns和summary-max-chars的数值——比如模型上下文窗口较小,可适当减小history-keep-turns,保证压缩后Token不超标。

三、压缩触发流程:异步执行,不阻塞主交互

压缩策略的核心优势之一,是异步执行压缩逻辑,不会阻塞用户与助手的实时交互,保证响应速度。以下是完整的触发流程,结合时序图和核心代码,帮你快速理解:

3.1 完整时序图

用户提问(User)
    ↓
助手回答(Assistant)  ←─── compressIfNeeded 在这里被触发
    ↓
┌─────────────────────────────────────────────────────────────┐
│  compressIfNeeded() 异步执行摘要压缩                          │
└─────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────┐
│  检查条件:                                                   │
│  1. summaryEnabled = true?                                 │
│  2. 当前消息角色 = ASSISTANT?                               │
│  3. 总消息数 >= summaryStartTurns (9)?                      │
└─────────────────────────────────────────────────────────────┘
    ↓ 条件满足
┌─────────────────────────────────────────────────────────────┐
│  doCompressIfNeeded() 执行压缩                              │
└─────────────────────────────────────────────────────────────┘
    ↓
[生成新摘要] + [存储到数据库]

3.2 核心触发条件代码

触发压缩的关键的是三个条件:压缩功能开启、当前消息是助手回复(只有助手回复后,才算完整一轮对话)、对话总轮数达标。以下是核心代码实现(基于Java):

// MySQLConversationMemorySummaryService.java

@Override
public void compressIfNeeded(String conversationId, String userId, ChatMessage message) {
    // 条件1:摘要功能开启
    if (!memoryProperties.getSummaryEnabled()) {
        return;
    }
    
    // 条件2:必须是助手回复(只有回复后才算完整一轮)
    if (message.getRole() != ChatMessage.Role.ASSISTANT) {
        return;
    }
    
    // 异步执行压缩,不阻塞主流程(关键:避免影响用户交互响应速度)
    CompletableFuture.runAsync(() -> doCompressIfNeeded(conversationId, userId), ...);
}

这里的核心设计是“异步执行”——通过CompletableFuture.runAsync()将压缩逻辑放到子线程中执行,主线程继续处理用户的下一次提问,确保用户不会感受到任何卡顿。

四、核心算法:增量摘要+范围控制,兼顾效率与准确性

压缩算法是整个策略的核心,我们采用“增量摘要+范围控制”的思路,既保证摘要的准确性(不丢失关键信息),又提升压缩效率(避免重复处理已有摘要)。以下从数据结构、算法步骤、压缩范围三个维度详细拆解:

4.1 核心数据结构

我们需要两张核心数据表,分别存储对话消息和摘要信息,确保数据可追溯、可复用:

┌─────────────────────────────────────────────────────────────┐
│                   对话消息表 (t_conversation_message)        │
├─────────────────────────────────────────────────────────────┤
│  id | role   | content       | create_time                  │
│  1  | user   | 今天天气如何?  | 2025-01-01 10:00:00        │
│  2  | assist | 今天是晴天      | 2025-01-01 10:00:01        │
│  3  | user   | 适合出门吗?    | 2025-01-01 10:00:02        │
│  4  | assist | 适合出门       | 2025-01-01 10:00:03        │
│  ...                                                       │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                   摘要表 (t_conversation_summary)            │
├─────────────────────────────────────────────────────────────┤
│  id | conversation_id | content      | last_message_id    │
│  1  | xxx             | 用户问天气...  | 4                  │
│  2  | xxx             | 用户问出门...  | 10                 │
└─────────────────────────────────────────────────────────────┘

说明:摘要表中的last_message_id用于标记该摘要对应的最后一条对话消息ID,便于后续增量压缩时,快速定位需要压缩的消息范围。

4.2 压缩算法步骤

压缩算法的核心是“增量处理”——每次压缩时,只处理新增的、未被压缩的对话消息,结合已有摘要生成新摘要,避免重复处理。以下是完整的算法步骤(结合Java代码):

private void doCompressIfNeeded(String conversationId, String userId) {
    long startTime = System.currentTimeMillis();
    
    // ========== 步骤1:前置条件检查 ==========
    int triggerTurns = memoryProperties.getSummaryStartTurns();  // 9(触发压缩的轮数)
    int maxTurns = memoryProperties.getHistoryKeepTurns();      // 8(保留原文的轮数)
    
    // ========== 步骤2:分布式锁(防止并发压缩,避免数据冲突) ==========
    String lockKey = SUMMARY_LOCK_PREFIX + buildLockKey(conversationId, userId);
    RLock lock = redissonClient.getLock(lockKey);
    if (!tryLock(lock)) {
        return;  // 已有其他线程在压缩,跳过
    }
    
    try {
        // ========== 步骤3:判断是否需要压缩 ==========
        long total = conversationGroupService.countUserMessages(conversationId, userId);
        if (total < triggerTurns) {  // 总轮数 < 9,不压缩
            return;
        }
        
        // ========== 步骤4:获取已有的摘要 ==========
        ConversationSummaryDO latestSummary = 
            conversationGroupService.findLatestSummary(conversationId, userId);
        
        // ========== 步骤5:确定要压缩的消息范围 ==========
        // 保留最近 maxTurns 轮,压缩更早的消息
        List<ConversationMessageDO> latestUserTurns = 
            conversationGroupService.listLatestUserOnlyMessages(conversationId, userId, maxTurns);
        
        // cutoffId:压缩范围的截止点(最近maxTurns轮的起始ID)
        Long cutoffId = resolveCutoffId(latestUserTurns);
        
        // afterId:从哪个消息之后开始压缩(已有摘要的最后一条消息ID)
        Long afterId = resolveSummaryStartId(conversationId, userId, latestSummary);
        
        // ========== 步骤6:提取要压缩的消息 ==========
        List<ConversationMessageDO> toSummarize = 
            conversationGroupService.listMessagesBetweenIds(conversationId, userId, afterId, cutoffId);
        
        // ========== 步骤7:调用 LLM 生成摘要 ==========
        String existingSummary = latestSummary == null ? "" : latestSummary.getContent();
        String summary = summarizeMessages(toSummarize, existingSummary);
        
        // ========== 步骤8:存储摘要 ==========
        createSummary(conversationId, userId, summary, lastMessageId);
    } finally {
        lock.unlock(); // 释放锁,避免死锁
    }
}

4.3 压缩范围图解(直观理解)

为了更直观地理解压缩范围,我们用时间线图解展示,清晰区分“保留原文”和“压缩摘要”的范围:

消息时间线:
──────────────────────────────────────────────────────────────────→

[ msg 1 ] [ msg 2 ] [ msg 3 ] ... [ msg 10 ] [ msg 11 ] [ msg 12 ]
    ↓          ↓          ↓          ↓           ↓           ↓
   user      assist     user       assist      user       assist
    │                    │           │           │           │
    │                    │           │           │           │
    │                    │           │           │    ┌─────┴─────┐
    │                    │           │           │    │ 最新摘要  │
    │                    │           │    ┌─────┴────┤ 截断这里  │
    │                    │    ┌─────┴────┤ 截断这里  │           │
    │                    │    │ 截断这里  │           │           │
    │                    │    │           │           │           │
    └────────────────────┴────┴───────────┴───────────┘           │
    ↑                                                            ↑
摘要1范围(已压缩)                                        保留原文范围
                                                        (最近8轮)

总结:每次压缩时,只处理“已有摘要之后、保留原文之前”的消息,既不重复压缩,也不丢失关键信息,兼顾效率与准确性。

五、LLM摘要生成:精准可控,避免信息偏差

摘要的质量直接影响模型对上下文的理解,因此我们需要通过合理的Prompt设计和合并逻辑,确保摘要精准、简洁、不偏离原意。以下是LLM生成摘要的核心实现:

5.1 Prompt设计(核心:明确规则,控制输出)

Prompt设计的关键是“明确约束”——告诉LLM摘要的要求(字符限制、格式、关键信息保留),同时结合已有摘要进行增量合并。核心代码如下:

private String summarizeMessages(List<ConversationMessageDO> messages, String existingSummary) {
    List<ChatMessage> summaryMessages = new ArrayList<>();
    
    // 系统 Prompt:设定摘要规则,约束LLM输出
    String summaryPrompt = promptTemplateLoader.render(
        CONVERSATION_SUMMARY_PROMPT_PATH,
        Map.of("summary_max_chars", String.valueOf(summaryMaxChars))  // 传入最大字符数限制
    );
    summaryMessages.add(ChatMessage.system(summaryPrompt));
    
    // 如果有旧摘要,追加进去(增量合并,避免重复)
    if (StrUtil.isNotBlank(existingSummary)) {
        summaryMessages.add(ChatMessage.assistant(
            "历史摘要(仅用于合并去重,不得作为事实新增来源;\n" +
            "若与本轮对话冲突,以本轮对话为准):\n" + existingSummary.trim()
        ));
    }
    
    // 添加要压缩的对话历史
    summaryMessages.addAll(histories);
    
    // 用户 Prompt:要求合并去重,严格遵守字符限制
    summaryMessages.add(ChatMessage.user(
        "合并以上对话与历史摘要,去重后输出更新摘要。\n" +
        "要求:严格≤" + summaryMaxChars + "字符;仅一行。"
    ));
    
    // 调用 LLM(低温度,保证输出稳定,不偏离原意)
    ChatRequest request = ChatRequest.builder()
        .messages(summaryMessages)
        .temperature(0.3D)  // 低温度(0.1-0.3),减少随机性
        .topP(0.9D)
        .build();
    
    String result = llmService.chat(request);
    return result;
}

5.2 Prompt模板(可直接复用)

以下是Prompt模板内容,明确了摘要的核心要求,可根据实际场景调整:

# templates/conversation_summary_prompt.txt

你是一个对话摘要生成助手。
请将下面的对话内容压缩成一个简洁的摘要。

要求:
1. 严格不超过 {summary_max_chars} 字符
2. 只输出一行摘要
3. 保留关键信息:用户目标、已完成的操作、待解决的问题
4. 删除重复内容和礼貌性用语(如“你好”“谢谢”“好的”等)

用户:我要请假
助手:好的,请问您要请什么类型的假期?
用户:年假
助手:年假需要提前3天申请,请问您计划哪天开始?
...

5.3 摘要合并逻辑(示例)

增量摘要的核心是“合并去重”,避免重复信息,同时保留新增内容。以下是一个实际示例:

旧摘要:"用户询问请假流程,助手回答需要提前申请"

新对话:
用户:我想请年假
助手:好的,请问您计划哪天开始?

合并后:
"用户询问请假和年假申请,计划开始日期待确认"

可以看到,合并后的摘要既保留了旧摘要的核心信息,又新增了“年假申请”和“待确认日期”的关键内容,简洁且不遗漏重点。

六、并发安全:分布式锁避免数据冲突

在高并发场景下(比如用户快速发送多条消息,助手连续回复),可能会出现多个线程同时执行压缩逻辑的情况,导致摘要重复生成、数据不一致。因此,我们需要通过分布式锁来保证并发安全。

6.1 分布式锁实现(基于Redisson)

private static final String SUMMARY_LOCK_PREFIX = "ragent:memory:summary:lock:";
private static final Duration SUMMARY_LOCK_TTL = Duration.ofMinutes(5);  // 锁过期时间5分钟

private void doCompressIfNeeded(String conversationId, String userId) {
    String lockKey = SUMMARY_LOCK_PREFIX + buildLockKey(conversationId, userId);
    RLock lock = redissonClient.getLock(lockKey);
    
    // 尝试获取锁,最多等0秒(不等待,直接返回),锁自动5分钟后过期
    if (!lock.tryLock(0, SUMMARY_LOCK_TTL.toMillis(), TimeUnit.MILLISECONDS)) {
        return;  // 获取不到锁,说明有其他线程在压缩,直接退出
    }
    
    try {
        // 执行压缩逻辑...(确保同一时间只有一个线程处理该对话的压缩)
    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();  // 释放锁,避免死锁
        }
    }
}

6.2 为什么需要分布式锁?(场景对比)

没有分布式锁的情况下,高并发场景会出现严重的数据冲突:

场景:用户快速发送多条消息,助手连续回复3次,触发3次压缩

无锁情况:
┌─────────────────────────────────────────────────────────────┐
│  线程A: 读取消息1-10 → 生成摘要A                             │
│  线程B: 读取消息1-12 → 生成摘要B(基于A)                    │
│  线程C: 读取消息1-14 → 生成摘要C(基于B)                    │
│                                                             │
│  结果:可能丢失消息,或者摘要不一致,甚至出现重复存储的情况!  │
└─────────────────────────────────────────────────────────────┘

有锁情况:
┌─────────────────────────────────────────────────────────────┐
│  线程A: 获取锁 → 读取消息1-10 → 生成摘要 → 释放锁           │
│  线程B: 等待锁...                                           │
│  线程C: 等待锁...                                           │
│                                                             │
│  结果:串行执行,不会冲突,摘要数据一致!                    │
└─────────────────────────────────────────────────────────────┘

小贴士:锁的过期时间设置为5分钟,是为了避免线程异常退出时,锁无法释放导致死锁——即使线程异常,5分钟后锁也会自动过期,不影响后续压缩逻辑。

七、对话加载:并行加载,提升响应速度

压缩后的对话历史需要快速加载到模型上下文,因此我们采用“并行加载”的方式,同时加载摘要和最近的对话历史,大幅提升加载速度。

7.1 并行加载实现

@Override
public List<ChatMessage> load(String conversationId, String userId) {
    long startTime = System.currentTimeMillis();
    
    // 并行加载摘要和历史记录(关键:提升加载速度)
    CompletableFuture<ChatMessage> summaryFuture = CompletableFuture.supplyAsync(
        () -> loadSummaryWithFallback(conversationId, userId)
    );
    CompletableFuture<List<ChatMessage>> historyFuture = CompletableFuture.supplyAsync(
        () -> loadHistoryWithFallback(conversationId, userId)
    );
    
    // 等待两者完成,合并结果
    return CompletableFuture.allOf(summaryFuture, historyFuture)
        .thenApply(v -> {
            ChatMessage summary = summaryFuture.join();
            List<ChatMessage> history = historyFuture.join();
            
            log.debug("加载对话记忆 - 摘要: {}, 历史消息数: {}, 耗时: {}ms",
                summary != null, history.size(), System.currentTimeMillis() - startTime);
            
            return attachSummary(summary, history);
        })
        .join();
}

7.2 最终返回格式(给模型的上下文)

加载完成后,会将“摘要+最近对话原文”合并,作为模型的上下文输入,格式如下:

┌─────────────────────────────────────────────────────────────┐
│  发送给模型的 messages:                                      │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  [0] {"role": "system", "content": "对话摘要:用户咨询请假..."} │
│                                                             │
│  [1] {"role": "user", "content": "年假怎么休?"}             │
│  [2] {"role": "assistant", "content": "年假需要提前3天..."}  │
│  [3] {"role": "user", "content": "那病假呢?"}               │
│  [4] {"role": "assistant", "content": "病假需要医院证明..."}  │
│  [5] {"role": "user", "content": "我下周一想请假"}            │
│                                                             │
└─────────────────────────────────────────────────────────────┘

这种格式既节省了Token,又能让模型快速理解整个对话的上下文,同时保留了最近几轮对话的细节,保证交互的连贯性。

八、配置示例(开发/生产环境适配)

不同环境的需求不同,以下是开发环境和生产环境的配置示例,可直接复制使用:

# 开发环境:关闭摘要,便于调试历史消息(查看完整对话)
rag:
  memory:
    summary-enabled: false
    history-keep-turns: 10  # 保留更多轮原文,便于调试

# 生产环境:开启摘要,节省Token,提升性能
rag:
  memory:
    summary-enabled: true
    summary-start-turns: 9       # 第9轮开始压缩
    history-keep-turns: 8       # 保留最近8轮原文
    summary-max-chars: 200       # 摘要不超过200字
    ttl-minutes: 60             # 缓存60分钟,减少数据库查询

九、效果对比:Token消耗大幅降低

我们通过实际场景测试,对比了“无压缩”和“有压缩”的Token消耗情况,结果如下:

场景 无压缩 有压缩 效果
10 轮对话 2000 tokens ~500 tokens Token消耗减少75%
50 轮对话 10000 tokens ~800 tokens Token消耗减少92%
100 轮对话 Token 爆炸 ❌ ~1200 tokens ✅ 避免模型无法处理,保证服务稳定

可以看到,随着对话轮数的增加,压缩策略的优势越来越明显——不仅能大幅降低Token消耗,还能避免Token爆炸导致的服务异常,同时保证模型对上下文的理解准确性。

十、相关文件说明(便于开发落地)

为了方便开发者快速落地该策略,以下是核心文件的分工说明,明确每个文件的作用:

文件 作用
ConversationMemoryService.java 会话记忆服务接口,定义加载和压缩的核心方法
DefaultConversationMemoryService.java 接口默认实现,协调对话历史加载和摘要压缩的逻辑
MySQLConversationMemorySummaryService.java 摘要压缩核心逻辑,实现压缩触发、摘要生成和存储
MySQLConversationMemoryStore.java 对话历史的存储和加载,操作t_conversation_message表
MemoryProperties.java 配置参数类,映射yaml中的压缩相关配置
ConversationGroupService.java 对话组查询服务,用于统计对话轮数、查询消息范围
ConversationMessageService.java 对话消息CRUD服务,提供消息查询、新增、删除等操作

十一、总结:压缩策略的核心亮点

这套会话记忆压缩策略,核心是通过“异步执行、增量摘要、范围控制、并发安全”四大设计,解决长对话场景下的Token爆炸问题,同时兼顾效率、准确性和用户体验。其核心亮点可总结为:

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  1. 异步压缩:不阻塞主流程,保证用户交互响应速度              │
│  2. 增量摘要:新对话 + 旧摘要 → 新摘要,避免重复处理,提升效率 │
│  3. 范围控制:只压缩超过historyKeepTurns的部分,保留近期对话  │
│  4. 并发安全:分布式锁防止重复压缩,保证数据一致性            │
│  5. Token 节省:200字摘要代替几千字对话,大幅降低消耗          │
│                                                             │
└─────────────────────────────────────────────────────────────┘

这套策略已经在实际项目中落地应用,适配了高并发、长对话的场景,有效解决了Token爆炸和服务卡顿的问题。无论是智能客服、AI助手,还是其他需要长对话交互的AI系统,都可以直接复用这套方案,只需根据自身场景调整配置参数即可。

后续,我们还可以进一步优化:比如动态调整压缩触发轮数、根据对话内容自动调整摘要长度、优化LLM摘要生成质量等,让压缩策略更智能、更适配多样化场景。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐