在构建一个真实的多轮对话系统时,上下文管理是绕不开的痛点。本文记录了我们在 CookHero 项目中如何从”对话几轮就爆 token”走向”稳定运行”的完整过程,包括踩过的坑、做过的权衡,以及最终的压缩策略设计。
问题背景:对话几轮就炸了
CookHero 是一个基于 RAG 的智能烹饪助手。用户可以通过多轮对话咨询菜谱、烹饪技巧,系统会结合历史上下文给出个性化建议。
项目初期,我们采用了最简单的方案:每次请求把完整历史对话全部塞给 LLM。但问题很快暴露:
- Token 爆炸:一个菜谱推荐场景,用户聊了 10 轮后,上下文直接超过模型限制
- 成本飙升:即使没超限制,每轮对话都带着全量历史,API 成本线性增长
- 响应变慢:上下文越长,LLM 推理延迟越高
更麻烦的是,菜谱类内容本身就很长(步骤详细、食材列表、注意事项),RAG 检索出的文档动辄几百上千字。加上用户历史,一个普通对话很容易逼近 8k、16k 的边界。
第一次尝试:简单截断
最直接的想法是只保留最近 N 轮对话。我们设置 N=10,丢弃更早的历史。
1 | # 简单粗暴的截断 |
问题立刻出现:
- 用户在第 3 轮说”我对花生过敏”,到第 15 轮问”再推荐一道菜”时,系统完全不记得过敏信息
- 上下文丢失导致 LLM 重复推荐禁忌食材,用户体验极差
结论:简单截断不可行,关键信息会丢失。
第二次尝试:全量压缩
既然不能直接丢,那就用 LLM 把历史压缩成摘要。每次请求前,把全部历史发给 LLM 生成一个简短摘要,再用摘要替代原始历史。
这个方案听起来优雅,实际上踩了更大的坑:
- 延迟翻倍:每次请求多了一次 LLM 调用(压缩 + 生成)
- 成本没降:压缩本身也消耗 token,且每次都要处理全量历史
- 信息失真:单次压缩很难保证关键细节不丢失
而且最离谱的是,压缩调用本身也可能超 token 限制——历史太长,压缩请求都发不出去。
最终方案:增量滚动压缩
经过几轮折腾,我们最终设计了一个增量滚动压缩策略。核心思路是:
- 不是每次都压缩,只在积累到阈值时触发
- 不压缩全量,只压缩”新增的一批”消息,和已有摘要合并
- 保留最近几轮原文,确保当前对话的即时性和准确性
核心数据结构
我们引入两个关键字段:
compressed_summary:已压缩消息的摘要文本compressed_count:已压缩的消息数量
这样,完整历史 = 摘要覆盖的部分 + 未压缩的原始消息。
1 | # 上下文组装逻辑 |
压缩触发条件
我们设置了两个参数:
compression_threshold = 6:每次压缩的消息数recent_messages_limit = 10:保留的未压缩消息数
当
未压缩数量 >= compression_threshold + recent_messages_limit
时触发压缩。
这意味着:
- 前 16 轮不会压缩(等待积累)
- 第 17 轮触发,压缩前 6 条,保留后 10 条
- 之后每积累 6 条就压缩一次
1 | async def maybe_compress(self, conversation_id, repository): |
增量摘要合并
压缩时,我们不是重新生成全量摘要,而是把新消息和已有摘要增量合并:
1 | if existing_summary: |
这样每次只处理 6 条新消息,LLM 调用成本可控。
摘要 Prompt 的迭代
压缩效果很大程度取决于 Prompt 设计。我们迭代了多版,最终的要点是:
- 明确领域:强调”烹饪助手”场景,让模型知道保留什么
- 定义优先级:用户偏好、饮食禁忌 > 具体菜品 > 闲聊内容
- 格式约束:使用第三人称客观描述,避免对话体
1 | COMPRESSION_SYSTEM_PROMPT = """ |
一些实现细节
为什么不用 LangChain 的 ConversationSummaryBufferMemory?
LangChain 有现成的
ConversationSummaryBufferMemory,但我们没用,原因是:
- 无法精细控制:它的压缩时机和粒度不可配置
- 无法持久化:我们的会话需要跨请求保持,而 LangChain Memory 是 in-memory 的
- 无法增量合并:它每次都生成全量摘要
最终我们自己实现,虽然代码量多了点,但获得了完全的控制权。
压缩失败怎么办?
LLM 调用可能失败(网络问题、限流等)。我们的策略是:
- 失败不阻塞主流程:压缩失败后返回
False,正常对话继续 - 保留原始摘要:如果有旧摘要就用旧的,没有就走无摘要路径
- 下次重试:下一轮对话可能再次触发压缩
1 | except Exception as e: |
上下文组装的顺序
我们测试了多种顺序,最终确定为:
1 | 1. 用户个性化设置(偏好、禁忌) |
把用户个性化放在最前面是因为我们发现 LLM 对 System Message 开头的内容权重更高。饮食禁忌这类关键信息放在前面,遗忘概率更低。
仍然存在的问题
这个方案不是完美的:
- 摘要会有信息损失:再好的压缩也不可能 100% 保真,极端情况下细节会丢
- 压缩调用有成本:虽然比全量处理低很多,但还是有开销
- 参数需要调优:
compression_threshold和recent_messages_limit的最佳值取决于具体场景 - 基于Token的感知:我们目前按消息数触发压缩,未来可以考虑按 token 数量更精细地控制
我们目前还在考虑的方向:
- 重要性评估:不是机械地”前 6
条”压缩,而是根据消息重要性选择性压缩
- 例如”用户偏好”类消息永远不压缩
- 使用向量数据库评估消息重要性,只压缩低重要性消息
- 分层摘要:对于超长对话,考虑多层级摘要结构
总结
多轮对话的上下文管理看起来简单,实际上充满工程取舍。我们的经验是:
- 先定义不变量:每条消息要么在摘要里,要么在原文里,不能凭空消失
- 增量优于全量:避免每次都处理全量历史
- 降级要优雅:压缩失败不能让整个对话崩溃
- Prompt 要迭代:压缩质量 = 算法 + Prompt,后者往往更关键