· 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 系统的长会话管理中:

  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 »
阶段 1:会话记忆系统

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

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

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

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