Skip to main content

聊了50轮Token爆了记忆该压缩还是该丢

开篇引言

上一篇聊了记忆系统的存储与加载,核心机制是一个滑动窗口——historyKeepTurns 默认保留最近 8 轮对话原文,通过 CompletableFuture 并行拉取摘要和历史,最后按固定顺序注入 Prompt。看起来挺稳的,8 轮原文够用吗?

来看一个场景。假设你在一家企业做 IT 采购知识库助手,有个员工连续聊了十几轮:

第 1 轮:我们部门想采购一批笔记本电脑,预算大概 5000 元一台。 第 2 轮:总共要 50 台,需要支持 Windows 11。 第 3 轮:有没有推荐的品牌? 第 48 轮:围绕联想、华为、戴尔几个型号展开对比讨论。 第 911 轮:讨论了几款型号的售后保修政策。 第 12 轮:那还有没有其他符合预算的型号推荐一下?

此时 historyKeepTurns=8,窗口只保留第 512 轮的对话,第 14 轮已经被淘汰了。问题来了——预算 5000 元/台、数量 50 台、Windows 11 这三个关键约束全在第 1~2 轮,现在全没了。模型看到第 12 轮的问题,不知道用户的预算是多少,也不知道要采购几台,推荐出来的型号可能不靠谱。

直接丢弃早期对话的代价很直观:用户需要把说过的约束重复一遍,对话体验割裂。但反过来,如果把所有历史原文都保留呢?50 轮对话下来,对话历史的 Token 轻松突破一两万,上下文窗口是有限的,历史占多了,检索结果的空间就被挤压,模型生成答案的空间也不够。

能不能把早期对话浓缩成一小段摘要?不丢关键信息,又不占太多 Token。这就是本篇要讲的摘要压缩策略。

滑动窗口为什么不够用

1. 一个让系统失忆的场景

把刚才的场景画得更具体一点。用对话轮次来标注,看看滑动窗口在第 12 轮时的覆盖情况:

轮次:  1    2    3    4    5    6    7    8    9    10   11   12
│ │ │ │ │ │ │ │ │ │ │ │
预算 数量 品牌 型号A 型号B 配置 续航 外观 保修A 保修B 保修C 追问
5000 50台 推荐 对比 对比 讨论 讨论 讨论 政策 政策 政策 预算

←────── historyKeepTurns=8 ──────→
窗口保留:第 5~12 轮
第 1~4 轮已被淘汰 ↑

第 12 轮用户问“还有没有符合预算的型号”,窗口里没有预算信息。模型只能看到型号对比和保修讨论的上下文,预算 5000 元/台这个核心约束消失了。

这不是极端情况。在实际的企业客服场景中,用户经常在对话开头提出核心约束(预算、时间、数量、身份),然后花好几轮围绕细节展开讨论。等讨论深入了,窗口恰好把那些约束滑出去了。

2. 三种记忆策略的取舍

面对这个问题,有三条路可以走:

策略Token 占用信息完整度适用场景
完整历史随轮数线性增长,不可控100%短对话(< 10 轮)
滑动窗口固定上限(historyKeepTurns × 2 条消息)只有最近 N 轮不太关心早期上下文的场景
摘要 + 滑动窗口(混合策略)固定上限 + 小额摘要开销早期信息浓缩保留长对话、有约束条件的场景

完整历史在短对话里没问题,但聊多了 Token 预算必然爆掉。纯滑动窗口 Token 可控,但早期信息直接丢了。Ragent 选的是第三条路——混合策略:最近 x 轮保留原文,更早的对话用 LLM 压缩成一段摘要。

这段摘要在上一篇讲过的 attachSummary 方法里,作为 SYSTEM 消息注入到历史列表头部。回到采购的场景,第 12 轮时模型看到的上下文大致是这样的:

[0] SYSTEM:    "对话摘要:用户咨询了办公笔记本电脑推荐(已解答)。
约束:预算5000元/台;数量50台;系统Windows 11。关键词:笔记本采购"
[1] USER: "那这几款的续航怎么样?" ← 第 5 轮(窗口内最早的一轮)
[2] ASSISTANT: "这几款的续航分别是..."
...
[M] USER: "那还有没有其他符合预算的型号推荐一下?" ← 第 12 轮(当前问题)

摘要只有一行,大概 100 来个字,Token 开销很小,但预算、数量、系统要求这三个关键约束都保住了。模型看到摘要里的预算 5000 元/台,再结合最后一条用户消息,就知道该推荐什么价位的型号了。

压缩什么时候触发

摘要不是每条消息来了都做的。Ragent 设了三道门槛,全部满足才会真正发起压缩。

1. 触发条件:三道门槛

1.1 功能开关:summaryEnabled

第一道门槛是全局开关。summaryEnabled 默认是 false,需要在配置里显式开启:

rag:
memory:
summary-enabled: true

为什么默认关?因为摘要压缩需要额外调一次 LLM,有成本也有延迟。短对话场景(比如每次聊不超过 5 轮的简单问答)完全不需要摘要,开着反而浪费。

1.2 消息角色:只有 ASSISTANT 触发

第二道门槛是消息角色。只有 ASSISTANT 消息到来时才触发压缩检查,USER 消息不触发。

为什么?一轮完整的对话是 1 条 USER + 1 条 ASSISTANT。如果在 USER 消息时就触发压缩,ASSISTANT 的回复还没来,压缩出来的摘要就少了半轮对话。等 ASSISTANT 回复完毕,这一轮才算结束,这时候去检查要不要压缩才有意义。

来看 compressIfNeeded 的入口代码:

@Override
public void compressIfNeeded(String conversationId, String userId, ChatMessage message) {
if (!memoryProperties.getSummaryEnabled()) {
return; // 门槛 1:开关没开,直接返回
}
if (message.getRole() != ChatMessage.Role.ASSISTANT) {
return; // 门槛 2:不是 ASSISTANT 消息,直接返回
}
CompletableFuture.runAsync(
() -> doCompressIfNeeded(conversationId, userId),
memorySummaryExecutor
).exceptionally(ex -> {
log.error("对话记忆摘要异步任务失败 - conversationId: {}, userId: {}",
conversationId, userId, ex);
return null;
});
}

注意这里是异步执行——压缩逻辑在独立的线程池 memorySummaryExecutor 上跑,不阻塞当前请求。用户问完问题拿到回答,不用等后台的压缩完成。

1.3 轮数阈值:summaryStartTurns

第三道门槛在 doCompressIfNeeded 内部。它会统计当前会话的 USER 消息总数,如果总数还没达到 summaryStartTurns(默认 9),就不压缩。

打个比方,对话才聊了 5 轮,滑动窗口(8 轮)还能完整覆盖,没有任何消息被滑出去,这时候压缩毫无意义。得等到对话轮数超过窗口大小,有消息开始滑出去了,压缩才有价值。

2. summaryStartTurnshistoryKeepTurns 的配合

这两个参数有一个强制约束:summaryStartTurns 必须大于 historyKeepTurns。违反这个约束,应用启动直接报错。上一篇已经详细讲过这个交叉校验规则的设计意图,这里看一下具体的校验代码:

public boolean isValid(MemoryProperties config, ConstraintValidatorContext context) {
if (Boolean.TRUE.equals(config.getSummaryEnabled())) {
Integer summaryStartTurns = config.getSummaryStartTurns();
Integer historyKeepTurns = config.getHistoryKeepTurns();

if (summaryStartTurns <= historyKeepTurns) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
String.format(
"当启用摘要功能时,summaryStartTurns (%d) 必须大于 historyKeepTurns (%d)," +
"否则永远不会触发摘要。建议配置至少:summaryStartTurns = historyKeepTurns + 1",
summaryStartTurns, historyKeepTurns
)
).addConstraintViolation();
return false;
}
}
return true;
}

一句话概括这个约束的意义:保证消息滑出窗口之前,摘要压缩已经把它的信息保住了。 默认配置 historyKeepTurns=8summaryStartTurns=9,第 9 轮触发压缩时,第 1 轮刚好滑出窗口,正好被压缩成摘要。

3. 异步执行,不阻塞主流程

compressIfNeeded 的代码可以看到,真正的压缩逻辑包在 CompletableFuture.runAsync 里,跑在专用的 memorySummaryExecutor 线程池上。

这意味着:

  • 压缩不阻塞用户的当前请求。用户第 9 轮的问题正常回答,后台同时在压缩第 1 轮的消息。
  • 即使 LLM 调用超时或者报错,exceptionally 兜住了异常,只记录日志,不影响主流程。
  • 压缩生成的摘要要到下一次 load 时才会被加载出来——也就是第 10 轮的请求才能看到刚刚生成的摘要。

异步增强——压缩是一个增强项而不是必要项,有它更好,没有也不影响基本功能。如果为了保障稳定性,可以使用消息队列进行消费压缩。

水位线机制:怎么知道哪些消息已经压缩过

压缩不只做一次。从第 9 轮开始,每一轮 ASSISTANT 消息都会触发压缩检查。这就带来一个问题:怎么保证同一段消息不会被重复压缩?

1. lastMessageId——压缩的书签

Ragent 用一个叫 lastMessageId 的字段来解决这个问题,存在 t_conversation_summary 表里。它的作用就像看书时夹的书签——记录上一次压缩读到哪条消息了,下次从书签后面继续。

核心设计:

  • 每次压缩完成后,把压缩窗口中最后一条消息的 ID 记为 lastMessageId
  • 下次压缩时,从这个 ID 之后开始,不会重复压缩同一段对话
  • 如果没有历史摘要(第一次压缩),lastMessageId 为 null,从最早的消息开始

t_conversation_summary 表的结构:

字段类型说明
idString(雪花 ID)主键
conversation_idString会话 ID
user_idString用户 ID
contentTEXTLLM 生成的摘要文本
last_message_idString水位线——本次摘要覆盖到的最后一条消息 ID
create_timeTimestamp自动填充
update_timeTimestamp自动填充
deletedSmallInt逻辑删除(0=有效)

2. 压缩窗口的计算

每次触发压缩时,需要确定两个边界:从哪条消息开始压缩(afterId),到哪条消息结束(cutoffId)。中间这段就是压缩窗口。

来看 doCompressIfNeeded 中确定压缩窗口的核心逻辑:

// 获取最新的摘要记录(如果有)
ConversationSummaryDO latestSummary = conversationSummaryService.getLatest(conversationId, userId);

// 获取最近 historyKeepTurns 条 USER 消息(倒序取)
List<ConversationMessageDO> recentUserMessages = conversationMessageService.listUserMessages(
conversationId, userId, maxTurns, ConversationMessageOrder.DESC
);

// cutoffId = 最近 N 条 USER 消息中最早的那条 ID(这些消息保留原文,不参与压缩)
String cutoffId = recentUserMessages.get(recentUserMessages.size() - 1).getId();

// afterId = 上一次摘要的 lastMessageId(水位线)
String afterId = (latestSummary != null) ? latestSummary.getLastMessageId() : null;

// 压缩窗口 = (afterId, cutoffId) 之间的所有消息

用一张图来表示:

全部消息:  [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] ... [18]
│ │ │
│ │ ←── 保留原文(最近 8 轮) ──→
│ cutoffId(窗口外最早的 USER 消息)
afterId = null
(第一次压缩,从最早开始)

压缩窗口 = [1] ~ [cutoffId 之前]
压缩完成 → lastMessageId = 压缩范围内最后一条消息的 ID

要点:cutoffId 之后(包含 cutoffId)的消息都在滑动窗口范围内,保留原文不压缩;cutoffId 之前且在 afterId 之后的消息进入压缩窗口。

如果计算出 afterId >= cutoffId,说明上次压缩已经覆盖到了当前窗口的起点,没有新消息需要压缩,直接返回。

3. 多次压缩的演进过程

用具体数字走一遍三次压缩的过程。假设 historyKeepTurns=8summaryStartTurns=9,每一轮是 1 条 USER + 1 条 ASSISTANT,消息 ID 递增。

第 1 次压缩(第 9 轮后触发):

消息:  U1 A1 U2 A2 U3 A3 U4 A4 U5 A5 U6 A6 U7 A7 U8 A8 U9 A9
│ │ │
afterId=null cutoffId=U2 当前轮
(最近 8 条 USER 中最早的是 U2)

压缩窗口 = [U1, A1](cutoffId 之前的所有消息)
→ LLM 生成摘要 A
→ lastMessageId = A1(压缩范围内最后一条消息的 ID)

第一次压缩只压缩了第 1 轮的 2 条消息。这很正常——第 9 轮才刚触发,只有 1 轮消息滑出了窗口。

第 2 次压缩(第 10 轮后触发):

消息:  ... U2 A2 U3 A3 ... U9 A9 U10 A10
│ │
cutoffId=U3 当前轮
(最近 8 条 USER 中最早的是 U3)

afterId = A1(上次摘要的水位线)

压缩窗口 = (A1, U3) → [U2, A2]
→ 把摘要 A + 新消息 [U2, A2] 一起喂给 LLM
→ LLM 合并去重 → 生成摘要 B
→ lastMessageId = A2

注意这里的关键操作:摘要 A(上一次的摘要)和新的待压缩消息一起送给 LLM,LLM 负责合并去重,生成一份新的摘要 B。不是在 A 上简单追加,而是重新归纳。

第 3 次压缩(第 11 轮后触发):

afterId = A2(上次摘要的水位线)
cutoffId = U4(最近 8 条 USER 中最早的)
压缩窗口 = (A2, U4) → [U3, A3]
→ 摘要 B + [U3, A3] 一起喂给 LLM → 生成摘要 C
→ lastMessageId = A3

以此类推。每新增一轮对话,滑动窗口向前滑一轮,恰好有 1 轮(2 条消息)滑出窗口进入压缩区。所以压缩的节奏是每轮触发、每次压缩 1 轮。水位线保证了每段消息只被压缩一次,不会重复。

上面展示的是理想情况——每轮压缩都成功执行。实际运行中,压缩是异步的,LLM 调用要几秒。如果上一轮的压缩还没结束,当前轮会因为获取不到分布式锁而跳过(后面会讲)。被跳过的消息不会丢,下一次成功获取锁时会一并压缩进去——比如跳过了第 10 轮,第 11 轮就会一次压缩 2 轮的消息。水位线机制保证了无论中间跳过几轮,最终结果都是对的。

压缩频率优化:攒批压缩

水位线机制跑通了,但有一个成本问题值得关注。

1. 每轮压缩的成本隐患

解锁付费内容,👉 戳