在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为了更清晰地理解每个参数的作用我们整理了参数对照表明确默认值和核心含义参数默认值含义historyKeepTurns8保留最近8轮对话原文保证近期交互的连贯性无需压缩summaryStartTurns9当对话总轮数达到9轮时开始对超出8轮的部分进行摘要压缩summaryMaxChars200单个摘要的最大字符数避免摘要本身占用过多TokensummaryEnabledfalse是否启用摘要压缩功能开发环境关闭便于调试历史消息小贴士生产环境中建议将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 轮压缩更早的消息 ListConversationMessageDO latestUserTurns conversationGroupService.listLatestUserOnlyMessages(conversationId, userId, maxTurns); // cutoffId压缩范围的截止点最近maxTurns轮的起始ID Long cutoffId resolveCutoffId(latestUserTurns); // afterId从哪个消息之后开始压缩已有摘要的最后一条消息ID Long afterId resolveSummaryStartId(conversationId, userId, latestSummary); // 步骤6提取要压缩的消息 ListConversationMessageDO 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(ListConversationMessageDO messages, String existingSummary) { ListChatMessage 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 分布式锁实现基于Redissonprivate 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 ListChatMessage load(String conversationId, String userId) { long startTime System.currentTimeMillis(); // 并行加载摘要和历史记录关键提升加载速度 CompletableFutureChatMessage summaryFuture CompletableFuture.supplyAsync( () - loadSummaryWithFallback(conversationId, userId) ); CompletableFutureListChatMessage historyFuture CompletableFuture.supplyAsync( () - loadHistoryWithFallback(conversationId, userId) ); // 等待两者完成合并结果 return CompletableFuture.allOf(summaryFuture, historyFuture) .thenApply(v - { ChatMessage summary summaryFuture.join(); ListChatMessage 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 tokensToken消耗减少75%50 轮对话10000 tokens~800 tokensToken消耗减少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摘要生成质量等让压缩策略更智能、更适配多样化场景。