· liyu · rag-agent · 17 min read

AI 大模型 Ragent 项目:长会话 Token 爆炸?会话摘要压缩策略与水位线机制深度解析

当多轮对话聊到 30 甚至 50 轮,滑动窗口滑走了开头的关键约束、全量保留又会挤爆 Token 上下文,RAG 系统该如何破局?深入剖析 Ragent 记忆系统中的增量摘要压缩算法、lastMessageId 水位线推进机制、攒批压缩优化(summaryBatchSize)、Prompt 绝对禁止记录答案的设计哲学以及 Redisson 分布式锁防重实践。

当多轮对话聊到 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 系统的长会话管理中:

  1. 滑动窗口保短期流畅,增量摘要保长期约束
  2. lastMessageId 水位线确保消息不重不漏,自愈容错
  3. 攒批压缩(Batching)将 LLM 调用成本降低 60% 以上
  4. 摘要只记话题与约束、严禁记录答案,彻底根除版本冲突与幻觉诱导

这套设计经受住了生产级高并发与长周期的考验,让大模型在经历漫长交互后,依然能够保持条理清晰、记忆不失。

技术专栏 · 总分体系

AI 大模型 Ragent 实战

已收录 3 篇深度解析(当前第 3 篇)
📍 阅读脉络:总览篇阶段 1 进阶:摘要压缩算法AI 大模型 Ragent 项目:长会话 Token 爆炸?会话摘要压缩策略与水位线机制深度解析
Share:
Back to Blog

Related Posts

View All Posts »
AI 大模型 Ragent 项目:会话记忆系统设计与多轮对话状态管理实践
阶段 1:会话记忆系统

AI 大模型 Ragent 项目:会话记忆系统设计与多轮对话状态管理实践

大语言模型 API 天然是无状态的,如何让每次独立的请求表现为连贯自然的连续对话?深入剖析 Ragent 问答流水线阶段一(loadMemory)背后的三层记忆架构、异步并发拉取与容错降级、滑动窗口规整(normalizeHistory)、loadAndAppend 时序避坑以及结构化 Prompt 上下文注入顺序。

阿里开源 TTL 原理解析:跨线程池上下文透传与 RAG 效率/成本追踪实战

阿里开源 TTL 原理解析:跨线程池上下文透传与 RAG 效率/成本追踪实战

深入剖析阿里巴巴开源 TransmittableThreadLocal (TTL) 的底层机制(Capture、Replay、Restore 与 Holder 弱引用字典),彻底破解线程池复用下的上下文丢失与污染困境。结合生产级 RAG 架构,实战解析如何利用全局 traceId 实现多路并发检索归因、分阶段耗时(TTFT/TBT)拆解与单次请求 Token 级成本精细化核算。