多轮对话场景下的上下文压缩:从爆 token 到稳定运行的工程实践
Zhongjun Qiu 元婴开发者

在构建一个真实的多轮对话系统时,上下文管理是绕不开的痛点。本文记录了我们在 CookHero 项目中如何从”对话几轮就爆 token”走向”稳定运行”的完整过程,包括踩过的坑、做过的权衡,以及最终的压缩策略设计。

问题背景:对话几轮就炸了

CookHero 是一个基于 RAG 的智能烹饪助手。用户可以通过多轮对话咨询菜谱、烹饪技巧,系统会结合历史上下文给出个性化建议。

项目初期,我们采用了最简单的方案:每次请求把完整历史对话全部塞给 LLM。但问题很快暴露:

  1. Token 爆炸:一个菜谱推荐场景,用户聊了 10 轮后,上下文直接超过模型限制
  2. 成本飙升:即使没超限制,每轮对话都带着全量历史,API 成本线性增长
  3. 响应变慢:上下文越长,LLM 推理延迟越高

更麻烦的是,菜谱类内容本身就很长(步骤详细、食材列表、注意事项),RAG 检索出的文档动辄几百上千字。加上用户历史,一个普通对话很容易逼近 8k、16k 的边界。

第一次尝试:简单截断

最直接的想法是只保留最近 N 轮对话。我们设置 N=10,丢弃更早的历史。

1
2
# 简单粗暴的截断
history = history[-10:]

问题立刻出现:

  • 用户在第 3 轮说”我对花生过敏”,到第 15 轮问”再推荐一道菜”时,系统完全不记得过敏信息
  • 上下文丢失导致 LLM 重复推荐禁忌食材,用户体验极差

结论:简单截断不可行,关键信息会丢失。

第二次尝试:全量压缩

既然不能直接丢,那就用 LLM 把历史压缩成摘要。每次请求前,把全部历史发给 LLM 生成一个简短摘要,再用摘要替代原始历史。

这个方案听起来优雅,实际上踩了更大的坑:

  1. 延迟翻倍:每次请求多了一次 LLM 调用(压缩 + 生成)
  2. 成本没降:压缩本身也消耗 token,且每次都要处理全量历史
  3. 信息失真:单次压缩很难保证关键细节不丢失

而且最离谱的是,压缩调用本身也可能超 token 限制——历史太长,压缩请求都发不出去。

最终方案:增量滚动压缩

经过几轮折腾,我们最终设计了一个增量滚动压缩策略。核心思路是:

  1. 不是每次都压缩,只在积累到阈值时触发
  2. 不压缩全量,只压缩”新增的一批”消息,和已有摘要合并
  3. 保留最近几轮原文,确保当前对话的即时性和准确性

核心数据结构

我们引入两个关键字段:

  • compressed_summary:已压缩消息的摘要文本
  • compressed_count:已压缩的消息数量

这样,完整历史 = 摘要覆盖的部分 + 未压缩的原始消息。

1
2
3
4
5
6
7
8
# 上下文组装逻辑
uncompressed_messages = history[compressed_count:] # 未压缩的消息
context = [
SystemMessage(system_prompt),
SystemMessage(compressed_summary), # 压缩摘要
*uncompressed_messages, # 原始消息
SystemMessage(rag_context), # RAG 检索结果
]

压缩触发条件

我们设置了两个参数:

  • compression_threshold = 6:每次压缩的消息数
  • recent_messages_limit = 10:保留的未压缩消息数

未压缩数量 >= compression_threshold + recent_messages_limit 时触发压缩。

这意味着:

  • 前 16 轮不会压缩(等待积累)
  • 第 17 轮触发,压缩前 6 条,保留后 10 条
  • 之后每积累 6 条就压缩一次
1
2
3
4
5
6
7
8
9
10
async def maybe_compress(self, conversation_id, repository):
total = await repository.get_message_count(conversation_id)
_, compressed_count = await repository.get_compressed_summary(conversation_id)
uncompressed = total - compressed_count

trigger = self.compression_threshold + self.recent_messages_limit
if uncompressed < trigger:
return False

# 执行压缩...

增量摘要合并

压缩时,我们不是重新生成全量摘要,而是把新消息和已有摘要增量合并

1
2
3
4
5
6
7
8
9
10
11
12
if existing_summary:
prompt = f"""
【之前的对话摘要】
{existing_summary}

【新增的对话内容】
{new_messages_text}

请将新增内容与已有摘要整合,生成更新后的综合摘要。
"""
else:
prompt = f"请为以下对话生成简洁摘要:\n{new_messages_text}"

这样每次只处理 6 条新消息,LLM 调用成本可控。

摘要 Prompt 的迭代

压缩效果很大程度取决于 Prompt 设计。我们迭代了多版,最终的要点是:

  1. 明确领域:强调”烹饪助手”场景,让模型知道保留什么
  2. 定义优先级:用户偏好、饮食禁忌 > 具体菜品 > 闲聊内容
  3. 格式约束:使用第三人称客观描述,避免对话体
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
COMPRESSION_SYSTEM_PROMPT = """
你是 CookHero 的「对话上下文摘要助手」,将对话压缩为简洁摘要。

【必须保留的信息】
1. 用户的明确需求与目标(想做什么菜、使用场景)
2. 饮食偏好、限制、禁忌(过敏、素食、减脂等)
3. 已推荐过的菜品和用户反馈

【可以忽略的内容】
- 闲聊、寒暄、情绪性表达
- 重复信息
- 已被明确放弃的方案

【表达要求】
- 使用第三人称客观描述
- 语言简洁、信息密集
"""

一些实现细节

为什么不用 LangChain 的 ConversationSummaryBufferMemory?

LangChain 有现成的 ConversationSummaryBufferMemory,但我们没用,原因是:

  1. 无法精细控制:它的压缩时机和粒度不可配置
  2. 无法持久化:我们的会话需要跨请求保持,而 LangChain Memory 是 in-memory 的
  3. 无法增量合并:它每次都生成全量摘要

最终我们自己实现,虽然代码量多了点,但获得了完全的控制权。

压缩失败怎么办?

LLM 调用可能失败(网络问题、限流等)。我们的策略是:

  1. 失败不阻塞主流程:压缩失败后返回 False,正常对话继续
  2. 保留原始摘要:如果有旧摘要就用旧的,没有就走无摘要路径
  3. 下次重试:下一轮对话可能再次触发压缩
1
2
3
except Exception as e:
logger.error("Compression failed: %s", e)
return existing_summary or "" # 降级处理

上下文组装的顺序

我们测试了多种顺序,最终确定为:

1
2
3
4
5
1. 用户个性化设置(偏好、禁忌)
2. 系统主 Prompt
3. 压缩摘要
4. 未压缩的原始消息
5. RAG 检索结果

把用户个性化放在最前面是因为我们发现 LLM 对 System Message 开头的内容权重更高。饮食禁忌这类关键信息放在前面,遗忘概率更低。

仍然存在的问题

这个方案不是完美的:

  1. 摘要会有信息损失:再好的压缩也不可能 100% 保真,极端情况下细节会丢
  2. 压缩调用有成本:虽然比全量处理低很多,但还是有开销
  3. 参数需要调优compression_thresholdrecent_messages_limit 的最佳值取决于具体场景
  4. 基于Token的感知:我们目前按消息数触发压缩,未来可以考虑按 token 数量更精细地控制

我们目前还在考虑的方向:

  • 重要性评估:不是机械地”前 6 条”压缩,而是根据消息重要性选择性压缩
    • 例如”用户偏好”类消息永远不压缩
    • 使用向量数据库评估消息重要性,只压缩低重要性消息
  • 分层摘要:对于超长对话,考虑多层级摘要结构

总结

多轮对话的上下文管理看起来简单,实际上充满工程取舍。我们的经验是:

  1. 先定义不变量:每条消息要么在摘要里,要么在原文里,不能凭空消失
  2. 增量优于全量:避免每次都处理全量历史
  3. 降级要优雅:压缩失败不能让整个对话崩溃
  4. Prompt 要迭代:压缩质量 = 算法 + Prompt,后者往往更关键
 REWARD AUTHOR
 Comments
Comment plugin failed to load
Loading comment plugin