· liyu · rag-agent · 17 min read
AI 大模型 Ragent 项目:长会话 Token 爆炸?会话摘要压缩策略与水位线机制深度解析
当多轮对话聊到 30 甚至 50 轮,滑动窗口滑走了开头的关键约束、全量保留又会挤爆 Token 上下文,RAG 系统该如何破局?深入剖析 Ragent 记忆系统中的增量摘要压缩算法、lastMessageId 水位线推进机制、攒批压缩优化(summaryBatchSize)、Prompt 绝对禁止记录答案的设计哲学以及 Redisson 分布式锁防重实践。
一、前言:聊了 50 轮 Token 爆了,记忆该压缩还是该丢?
在上一篇中,我们探讨了会话记忆的存储与加载机制——通过滑动窗口(historyKeepTurns,默认保留最近 8 轮)与并行拉取,确保用户近期的连续问答具备上下文连贯性。
但在真实的企业级问答与 Agent 场景中,一个尖锐的矛盾很快浮出水面:8 轮滑动窗口真的够用吗?
1. 滑动窗口导致的“致命失忆”
设想在企业 IT 资产采购助手的交互场景中,员工连续提出了十几轮问题:
第 1 轮:我们部门想采购一批办公笔记本电脑,预算大概 5000 元/台。
第 2 轮:总共要 50 台,要求必须预装 Windows 11。
第 3 轮:有没有推荐的品牌和型号?
第 4~8 轮:围绕联想、华为、ThinkPad 等几款型号深入讨论配置与接口。
第 9~11 轮:逐一询问这几款机器的售后保修与上门维修条款。
第 12 轮:那还有没有其他符合预算的型号推荐一下?此时系统如果采用 historyKeepTurns = 8 的滑动窗口:
轮次: 1 2 3 4 5 6 7 8 9 10 11 12
│ │ │ │ │ │ │ │ │ │ │ │
预算 数量 品牌 对比 对比 配置 续航 外观 保修 保修 保修 追问
5000 50台 推荐 A/B A/B 讨论 讨论 讨论 政策 政策 政策 预算
←─── 第 1~4 轮已被淘汰 ───→ │ ◄──────── 窗口保留:第 5~12 轮 ────────►当第 12 轮提问到来时,“预算 5000 元/台”、“采购 50 台”、“Windows 11” 这三个最核心的全局约束,早已经在前几轮被滑动窗口无情滑走淘汰了!
大模型只能看到后半段的保修与外观讨论,根本不知道用户的真实预算与采购数量,推荐出来的机型必然货不对板。
2. 三种记忆策略的权衡与取舍
| 记忆策略 | Token 占用趋势 | 早期关键约束保留率 | 适用场景 |
|---|---|---|---|
| 全量历史(Full History) | 随轮数线性膨胀,极易超出 Context 限制 | 100% 完整保留 | 短对话(< 10 轮)的简单问答 |
| 纯滑动窗口(Sliding Window) | 固定上限($2N$ 条消息) | 早期信息直接丢弃,容易失忆 | 每次提问相对独立的瞬态对话 |
| 混合策略:摘要 + 滑动窗口 | 固定上限 + 少量摘要 Token(约 200~300) | 核心约束浓缩保留,长短期记忆兼顾 | 企业级知识问答、复杂 Agent 长任务 |
Ragent 选择了第三条路:最近 N 轮保留完整对话原文,更早滑出的历史通过 LLM 异步压缩为一段高信息密度的“前情提要摘要(Summary)”。
二、压缩触发机制:三道门槛与时序流转
会话摘要压缩涉及额外的大模型推理,绝不能盲目每时每刻都在跑。Ragent 设置了三道严密的门槛:
触发摘要压缩的三道门槛
[消息追加事件]
│
▼
【门槛 1: 全局功能开关】──(summaryEnabled == false?)──► [直接返回,不消耗算力]
│ (true)
▼
【门槛 2: 消息角色检查】──(Role != ASSISTANT?)───────► [直接返回,避免半轮对话]
│ (Role == ASSISTANT)
▼
【门槛 3: 轮数阈值判断】──(UserTurns < summaryStartTurns?) ► [直接返回,滑动窗口尚能覆盖]
│ (满足阈值)
▼
[异步线程池 memorySummaryExecutor ➔ 获取分布式锁 ➔ 执行增量压缩 ➔ 更新水位线]1. 门槛一:全局开关(summaryEnabled)
默认关闭。短会话场景(平均对话 < 5 轮)无需开启,避免徒增 LLM API 调用开销。
2. 门槛二:仅在 ASSISTANT 消息到达时触发
一轮完整的有效对话由 USER 提问与 ASSISTANT 回答配对构成。如果 USER 发送时就触发,模型的回复尚未产生,压缩出来的摘要就会缺失半轮,因此必须在助手回复完成后执行。
3. 门槛三:轮数阈值与交叉校验约束
在 Spring Boot 启动时,配置类必须满足强制校验规则:
// 强制约束:开始摘要的轮数必须大于滑动窗口保留轮数
if (summaryStartTurns <= historyKeepTurns) {
throw new IllegalArgumentException(
"当启用摘要功能时,summaryStartTurns 必须大于 historyKeepTurns,否则永远无法触发有效压缩!"
);
}- 核心逻辑:当对话还在 8 轮以内时,滑动窗口能够无损呈现全部历史,没有任何消息被滑出,生成摘要毫无意义;
- 只有到了第 9 轮、最初的第 1 轮即将被滑出窗口边缘时,启动压缩才有真正的工程价值。
三、水位线机制:lastMessageId 解决增量压缩与防重
当会话聊到第 15 轮、第 20 轮时,每一轮都在向前滑动,系统如何确保“已经压缩过的历史不会被重复压缩”?
Ragent 引入了**水位线(Watermark)**机制,在 t_conversation_summary 表中维护一个 last_message_id 字段(类似于看书时的书签):
压缩窗口与水位线定位
全部历史消息:[M1] [M2] [M3] [M4] [M5] [M6] [M7] [M8] ... [M18]
│ │ │
│ │ ◄── 滑动窗口保留原文 ──►
│ ▼
│ cutoffId(窗口内最早的 USER 消息 ID)
▼
afterId (last_message_id)
[上次压缩覆盖到的最后一条消息]
└───► 本次待压缩窗口 = (afterId, cutoffId) 之间的消息列表 ◄───┘增量压缩的演进全流程
// 确定压缩窗口核心算法(伪代码)
public void doCompress(String conversationId, String userId) {
// 1. 获取已有的最新摘要记录
ConversationSummaryDO latest = summaryStore.getLatest(conversationId, userId);
String afterId = (latest != null) ? latest.getLastMessageId() : null;
// 2. 获取滑动窗口内最早的 USER 消息 ID 作为 cutoffId
String cutoffId = messageStore.getOldestUserMessageIdInWindow(conversationId, historyKeepTurns);
// 3. 查询待压缩消息切片:(afterId, cutoffId)
List<ChatMessage> pendingMessages = messageStore.getMessagesBetween(afterId, cutoffId);
if (pendingMessages.isEmpty()) {
return; // 水位线已追平,无需压缩
}
// 4. 将【旧摘要内容 + 新滑出的消息】打包送入 LLM 进行合并归纳
String newSummaryText = llmService.summarize(latest != null ? latest.getContent() : null, pendingMessages);
// 5. 推进水位线:lastMessageId 更新为 pendingMessages 中最后一条消息的 ID
String newWatermark = pendingMessages.get(pendingMessages.size() - 1).getId();
summaryStore.saveOrUpdate(conversationId, userId, newSummaryText, newWatermark);
}- 增量迭代:每次压缩并非从第 1 轮重新全部读一遍,而是把 “上一版浓缩摘要 + 本次刚滑出的新消息” 共同喂给 LLM 进行合并归纳与去重;
- 防丢与容错:若某轮因并发锁或网络波动跳过了压缩,下一轮拉取
(afterId, cutoffId)时会自动将累积未压缩的多轮消息一次性合并,水位线机制天然自愈。
四、压缩频率优化:从“每轮压缩”到“攒批压缩(Batching)”
1. 每轮压缩的成本隐患
如果每滑出一轮就调用一次大模型:
- 对话聊到 30 轮,将触发 $30 - 8 = 22$ 次 LLM API 调用;
- 每次调用的有效载荷仅有 2 条消息,但 System Prompt 和网络往返等固定开销一次不少。
2. 攒批压缩方案(summaryBatchSize)
引入 summaryBatchSize 参数(例如设为 3),当滑出窗口的消息累积达到 3 轮时,才集中触发一次压缩:
攒批压缩执行时序 (BatchSize = 3)
第 9 轮: 第 1 轮滑出窗口,累积 1 轮 < 3 ➔ 暂存跳过
第 10 轮: 第 2 轮滑出窗口,累积 2 轮 < 3 ➔ 暂存跳过
第 11 轮: 第 3 轮滑出窗口,累积 3 轮 = 3 ➔ 集中压缩 [第 1~3 轮],推进水位线!
第 12 轮: 第 4 轮滑出窗口,累积 1 轮 < 3 ➔ 暂存跳过| 指标维度 | 每轮压缩 (batchSize = 1) | 攒批压缩 (batchSize = 3) |
|---|---|---|
| 30 轮对话 LLM 调用次数 | 22 次 | 仅 7~8 次(直接节省约 65% 成本) |
| 最大信息空窗期 | 1 轮 | 最多 3 轮(紧邻滑动窗口边界,影响极低) |
| LLM 归纳质量 | 单轮增量,容易琐碎 | 多轮整体观察,更容易提炼话题主线 |
五、摘要 Prompt 的设计哲学:只记话题索引与约束,严禁记录具体答案!
这是会话摘要设计中最核心的原则,也是最容易产生重大 Bug 的认知误区。
为什么绝对禁止在摘要中记录具体答案?
设想第 3 轮用户询问了年假政策,系统回答了 “入职满 1 年享 5 天年假”。 如果摘要记录为:“用户咨询了年假政策(满 1 年 5 天)”。
两个月后,公司制度更新,满 1 年改为 7 天年假。 当用户再次追问年假时:
- 知识库检索返回了最新文档:7 天;
- 历史摘要(作为 SYSTEM 消息)赫然写着:5 天。
大模型同时看到两个相互矛盾的输入,由于 SYSTEM 消息具有极高的注意力权重,模型极易被历史摘要带偏,向用户输出过时的错误答案!
❌ 错误示范:用户咨询了年假天数(入职满1年5天、满3年10天)
✅ 正确示范:用户咨询了年假天数计算规则(已解答)。约束:入职满1年。RAG 架构铁律:具体的业务事实与答案永远只能来自实时最新检索,历史摘要的职责仅仅是充当“话题目录索引”与“用户约束画像”。
状态标注规范与 Prompt 结构设计
在 Ragent 的 conversation-summary.st 模板中,定义了严密的输出规范:
┌─────────────────┬───────────────────────────────────────────────────────────┐
│ 状态标识 │ 含义与大模型提示语义 │
├─────────────────┼───────────────────────────────────────────────────────────┤
│ • 已解答 │ 助手基于知识库给出了有效回答,记录话题已处理 │
│ • 当时无记录 │ 强调“当时”二字!知识库可能随时更新,下次遇到该话题仍需检索│
│ • 部分解答 │ 提示多子问题中部分未解决,留存待追问线索 │
│ • 待确认 │ 需要用户补充关键参数或联系人工 │
└─────────────────┴───────────────────────────────────────────────────────────┘// 组装摘要 Prompt 的核心上下文顺序
public String buildSummaryPrompt(String existingSummary, List<ChatMessage> pendingMessages) {
List<ChatMessage> messages = new ArrayList<>();
// 1. System: 角色定义、严禁记录答案、最大字数约束 (summaryMaxChars = 200)
messages.add(ChatMessage.system(SUMMARY_SYSTEM_PROMPT));
// 2. Assistant: 传入历史旧摘要(放在 ASSISTANT 位置,提示模型这是你之前生成的,请在此基础上更新)
if (StringUtils.isNotBlank(existingSummary)) {
messages.add(ChatMessage.assistant("历史旧摘要(仅用于合并去重):\n" + existingSummary));
}
// 3. User & Assistant: 本次待压缩的新消息列表
messages.addAll(pendingMessages);
// 4. User: 触发合并指令
messages.add(ChatMessage.user("请合并以上对话与旧摘要,去重后输出更新摘要。严格单行且 ≤ 200 字。"));
return llmClient.chat(messages, /* temperature = */ 0.3);
}六、集群并发防护:Redisson 分布式锁与 Watchdog
在多节点集群部署下,用户的请求可能因重试或负载均衡被并发处理。为防止两个节点同时触发同一个会话的压缩导致数据冲突:
public void doCompressIfNeeded(String conversationId, String userId) {
String lockKey = "ragent:memory:summary:lock:" + userId + ":" + conversationId;
RLock lock = redissonClient.getLock(lockKey);
// 1. 非阻塞快速尝试获取锁(拿不到直接返回,不阻塞线程)
if (!lock.tryLock()) {
return; // 另一个节点正在压缩,本轮跳过,下轮自动补偿
}
try {
// 2. 执行核心增量压缩逻辑(Redisson Watchdog 自动续期,防长耗时死锁)
executeIncrementalCompress(conversationId, userId);
} finally {
// 3. 安全释放锁(仅释放当前线程持有的锁)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}- 非阻塞
tryLock():摘要压缩是异步增强功能,获取不到锁直接跳过,绝不阻塞工作线程池; - Watchdog 自动续期:默认 30s 租约并在存活时每 10s 自动续期,避免大模型生成偶尔慢于预期导致锁提前被释放。
七、上下文 Token 预算全景分配
在最终的阶段八中,所有上下文像拼积木一样组装到大模型的上下文窗口中:
上下文窗口 Token 预算全景分配
┌─────────────────────────────────────────────────────────────────────────────┐
│ 1. System Prompt (角色规则与行为准则) │ 200 ~ 500 Tokens │
├───────────────────────────────────────────────────────┼─────────────────────┤
│ 2. 对话摘要 (前情提要,受 summaryMaxChars 约束) │ 200 ~ 300 Tokens │
├───────────────────────────────────────────────────────┼─────────────────────┤
│ 3. 知识检索上下文 (KB 知识库 Chunk + MCP 工具数据) │ 1500 ~ 4000 Tokens │
├───────────────────────────────────────────────────────┼─────────────────────┤
│ 4. 最近 N 轮原文历史 (受 historyKeepTurns 约束) │ 1000 ~ 3000 Tokens │
├───────────────────────────────────────────────────────┼─────────────────────┤
│ 5. 当前用户提问 │ 50 ~ 200 Tokens │
├───────────────────────────────────────────────────────┼─────────────────────┤
│ 6. 生成空间 (剩余 Token 全部留给大模型吐字) │ 2000 ~ 4000 Tokens │
└─────────────────────────────────────────────────────────────────────────────┘通过将早期历史浓缩为 200 字(约 250 Tokens) 的摘要,系统以极小的 Token 开销锁定了长达数十轮的关键业务约束,为核心知识库检索与大模型深度推理预留出了充足的计算空间。
八、总结
在 RAG 系统的长会话管理中:
- 滑动窗口保短期流畅,增量摘要保长期约束;
lastMessageId水位线确保消息不重不漏,自愈容错;- 攒批压缩(Batching)将 LLM 调用成本降低 60% 以上;
- 摘要只记话题与约束、严禁记录答案,彻底根除版本冲突与幻觉诱导。
这套设计经受住了生产级高并发与长周期的考验,让大模型在经历漫长交互后,依然能够保持条理清晰、记忆不失。