会话记忆压缩策略揭秘,轻松解决Token爆炸难题
在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摘要生成质量等,让压缩策略更智能、更适配多样化场景。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)