分层压缩上下文管理:大模型长对话防遗忘实战
做 AI 应用最容易被低估的不是模型的智商而是上下文管理。我接手某客服项目时模型能力完全够用但在真实多轮会话里每聊十几轮模型就开始“选择性失忆”最早的用户需求被忘得干干净净。查到最后问题出在一个地方上下文窗口满了。后来我把方案从“完整塞入”改成了“分层压缩”才真正把长会话成本稳住也让模型记住了那些真正要紧的信息。这篇文章不聊玄学只聊可落地的工程实践一次请求里的 token 是怎么被消耗的、为什么直接截断最不可靠、分层压缩到底要分哪几层以及我在实际项目里用的触发参数、质量验证方法和踩过的坑。适合正在做对话机器人、大模型应用、长文档分析或者被上下文长度和账单双重折磨的工程同学参考。1. 上下文窗口是一本明账token 花在哪儿直接决定了你能聊几轮1.1 一次请求里token 的四个主要去处很多人以为上下文窗口只是“历史对话”的容量其实一次请求的 token 消耗通常由四部分叠加组成系统提示与指令用来定义角色、输出格式、业务规则。少则两三百 token多则一两千。外部工具与函数定义如果模型需要调用工具每个函数签名、参数说明都会被编码成 token。工具多了之后这部分经常悄悄吃掉一两千 token。历史对话之前的用户消息和模型回复这是大头多轮会话里可以轻松占到几千甚至上万 token。当前用户输入与模型输出当前问题不长但模型输出上限同样占用窗口空间。很多人只算输入忘了输出预算。假设一个模型上下文窗口是 16000 token系统提示 300、工具定义 1200、历史对话 8000、当前输入 200光是这几项加起来就是 9700。再扣掉输出上限 2000实际输入预算只有 14000而你已经用了 9700剩下 4300远没有想象中那么多。所以第一步不是写代码而是先给请求做个账本固定开销是多少、历史增长有多快、输出预算留多少。只要把这三个数搞清楚后面所有压缩策略才有依据。1.2 窗口超限后的三种表现都不好排查窗口超限不是只有报错一种表现我在项目里遇到过三种情况第一种是显式报错最常见的提示大意是“请求超过最大上下文长度”。这种情况虽然粗暴反而最好排查因为服务端直接告诉你超了。第二种是服务端静默截断。某些网关或模型接口会主动丢掉最早的消息好让请求继续通过。表面上一轮对话还能正常返回实际上模型已经看不到早期内容了。项目里表现就是用户反复问“我一开始是不是说过要开发票”模型却答非所问。第三种是上下文污染。截断可能发生在任意位置如果切断了系统提示或工具定义模型的行为会突然变得不可控比如不再遵守输出格式、开始编造不存在的功能。一句话总结上下文窗口不是内存更像一块容易溢出的短期工作记忆。你不主动管理它它就会用最粗暴的方式“管理”你。2. 为什么“直接截掉最早消息”是最贵的省法2.1 “保留最近 N 轮”的幸存者偏差面对长会话很多人的第一反应是“我只把最近 5 轮或最近 10 轮传给模型”。这个思路符合直觉但它建立在一条隐含假设上越晚出现的信息越重要。真实对话往往不是这样。客服场景里用户第一句话可能是“我要退款订单号是 A123”后面十几轮都在聊物流、地址、发票。如果只保留最近 10 轮模型完全不知道退款诉求是什么后面所有的回答都会跑偏。我把这种问题叫作“幸存者偏差式截断”你保住了最近的信息却也保住了大量可能无关的寒暄却把最关键的决策起点给丢了。还有一个更隐蔽的问题模型对长上下文的注意力并不是均匀分布的。即使上下文还没超限较早的信息在深层网络里也可能被“稀释”。所以只做尾部截断本质上是在赌博赌关键信息恰好落在尾部。2.2 压缩的本质是降维保留不是抹除我后来换了个思路任何一条历史消息要么留在当前上下文中要么被转换成某种更紧凑的形态而不是被直接删除。打个比方。正常人工作桌上只放近期文件归档柜里放的是按项目整理的资料还有一个索引本记录每份资料的位置。你要找上个月的发票不会把整柜子搬过来而是查索引、取所需。分层压缩就是把这套思路搬到上下文管理里热数据最近几轮对话原样保留保证即时交互。结构化事实订单号、地址、偏好、承诺抽出来存成字段机器可读token 极小。摘要层把较早的会话压缩成一段语义摘要保住因果脉络。检索索引把历史片段转成向量用户真正问到旧话题时按需召回。这套设计的核心原则是信息不是被删掉了而是换了存储形态。需要时还能找回来不需要时也不占用窗口。2.3 它和缓存不是一回事有人会问直接做一层 Redis 缓存把之前的模型回复存下来也算上下文管理吗算但那只是性能优化不是语义管理。缓存放的是“原样返回的数据”上下文压缩处理的是“如何从对话里提炼出模型决策所需的关键信息”。一个订单号、一条用户承诺、一个审批状态这些信息可能散落在多轮对话里不是缓存能直接解决的。当你把视角从“存储对话”切换到“提炼信息”才会真正进入分层压缩的工程实践。3. 分层压缩的四层设计从热数据到可召回索引3.1 第一层原始消息热区热区就是直接放进模型请求的那段最近对话。它要保持原始形态不做任何改写因为模型需要准确看到用户最近说了什么才能做出符合语境的回应。热区大小怎么定我见过有人设 3 轮有人设 10 轮。实际取决于你的业务复杂度和 token 单价。客服类项目我建议 4 到 8 轮既能覆盖大多数追问场景也不会让输入预算快速膨胀。注意热区只是“展示层”不是“记忆层”。它的作用是让模型有足够的即时上下文而不是指望它背下所有历史。真正的长期记忆要由下面几层负责。3.2 第二层结构化事实层这是我在客服场景里收益最大的一层。做法是持续从对话中抽取关键实体和状态输出为 JSON 或数据库字段。比如{ order_id: A123, user_level: vip, shipping_address: 上海市某区某路某号, intention: 退款, promised_action: 48小时内处理退款, last_updated: 2025-06-18 15:30 }这段信息大概不到 80 token却能替代几千 token 的原始对话。更关键的是它是机器可读的模型可以直接依据这些字段做决策。抽取动作本身也用模型完成。我会给一个独立小调用extract_prompt 请从下面的客服对话中抽取事实字段只输出 JSON。 字段包括order_id, user_level, shipping_address, intention, promised_action。 没有的信息填空字符串不要编造。 response llm.generate(systemextract_prompt, messagesconversation_slice)抽取结果采用“覆盖式更新”同一字段新值覆盖旧值。这个设计的理由是用户往往会在后续对话中纠正信息比如“地址改成另一个”。覆盖式更新能保证最终字段是对话收敛后的结果而不是中间态。3.3 第三层滚动摘要层不是所有信息都能结构化成字段。比如“用户为什么生气”“客服承诺过什么补偿”“事情是怎么一步步发展到退款的”这些过程性内容需要语义摘要。滚动摘要的机制是旧摘要 新对话 - 新摘要。假设会话已经聊了 30 轮前 20 轮被压成一段 200 token 的摘要第 21 到 25 轮继续保留在热区。这时热区又满了就把“旧摘要 第 21 到 25 轮”一起交给模型生成一段新的摘要再替换旧摘要。摘要不是越长越好。我会给摘要设置一个严格的输出上限比如 150 到 240 token。太长就失去了压缩意义太短又容易丢掉关键细节。你可以根据业务复杂度调整但一定要设上限否则模型会越写越长。3.4 第四层向量召回层到了这一层你会面对一个完全不同的场景用户突然问“你当时不是说要送我优惠券吗”而这条信息既不在热区也没被抽成结构化字段只存在于某段历史摘要或某条原始消息里。这时候就需要向量召回。实现上并不复杂把每条历史消息或每个会话片段都做一次向量化存入向量检索库。当前用户问题到达时也用同样的向量化接口转成向量检索出相似度最高的前 3 到 5 条片段拼进上下文。它与一般 RAG 的核心区别在于检索源是对话历史不是外部知识库。而且召回对象往往很“小”可能只是一句话、一个承诺。正因为召回的是细粒度信息压缩导致的信息溶洞才不会出现。当然第四层不是所有项目都要上。如果你们的会话普遍只有十轮以内结构化事实加摘要就够用了。只有当“旧话题回切”成为高频需求时才值得引入向量检索的成本。4. 落地案例某客服项目的上下文管理器怎么搭4.1 核心接口设计我建议不要把压缩逻辑散落在业务代码里而是收敛成一个独立的上下文管理器。下面是我在项目里简化后的骨架class ContextManager: def __init__(self, system_prompt, max_recent_turns6, compress_threshold0.7): self.system_prompt system_prompt self.max_recent_turns max_recent_turns self.compress_threshold compress_threshold self.history [] self.summary self.facts {} self.vector_index None def add_message(self, role, content): self.history.append({role: role, content: content}) def count_tokens(self, text): # 用模型供应商提供的 tokenizer 估算不要自己拍脑袋算 return approximate_token_count(text) def used_ratio(self): fixed_cost self.count_tokens(self.system_prompt) 800 history_cost sum(self.count_tokens(m[content]) for m in self.history) return (fixed_cost history_cost) / context_window_size def should_compress(self): return self.used_ratio() self.compress_threshold def compress(self): # 1. 抽出热区外的最早部分做滚动摘要 # 2. 抽取结构化字段覆盖 self.facts # 3. 把淘汰片段写入向量索引 pass def recall(self, query, k3): if not self.vector_index: return [] return self.vector_index.search(query, top_kk)类的设计看着简单但有两个容易忽略的细节。第一used_ratio一定要把输出预算算进去。很多项目只算输入 token等模型生成到一半才发现超限这种问题特别难查。我给固定开销里预留下 800 token相当于给输出和潜在工具调用做个缓冲。第二compress里的执行顺序很重要。我的建议是先做结构化抽取再做滚动摘要最后把要被淘汰的原始片段写入向量索引。原因是结构化信息是可验证的适合保底摘要适合保住过程脉络而向量索引只负责低概率、高价值的旧话题回切。4.2 压缩触发策略与动作压缩不是等到 100% 才动手而是设好阈值分级处理。我在项目里用了三个等级等级触发条件动作L0token 用量 70%不压缩保持完整上下文L1token 用量 70%把热区外最早 5 轮做滚动摘要结构化事实层更新L2token 用量 85%再次压缩旧摘要启用向量召回淘汰低分段这里有个经验值阈值设 70% 不是拍脑袋。预留出的 30% 空间一部分给模型输出一部分给工具调用的中间结果。如果你把阈值调到 90%表面上看更省 token但模型大概率会在生成阶段撞到上限反而得不偿失。具体参数我建议整理成一个独立配置context_config { context_window_size: 16000, output_budget: 2000, compress_threshold: 0.7, hard_drop_threshold: 0.85, max_recent_turns: 6, summary_max_tokens: 200, fact_extract_interval: 5, vector_top_k: 3, recall_score_threshold: 0.6, }recall_score_threshold尤其重要。向量召回的相似度低于阈值时宁可不召回也不要硬塞进上下文否则会引入噪音。4.3 给模型的最终拼接顺序压缩并不是把所有层随便拼在一起我会按固定顺序构造最终上下文def build_llm_context(self, user_query): retrieved self.recall(user_query, top_k3) return [ {role: system, content: self.system_prompt}, {role: system, content: 用户事实字段 json.dumps(self.facts, ensure_asciiFalse)}, {role: system, content: 前期会话摘要 self.summary}, *retrieved, *self.history[-self.max_recent_turns:], {role: user, content: user_query}, ]顺序上我把“用户事实字段”放在系统提示后面紧接着是摘要然后是召回片段最后是热区对话。这么做有个好处模型先读全局事实和摘要建立背景再看到最近对话和当前问题决策链路更顺畅。如果检索出来的片段跟当前问题强相关我会在片段前加一行标注{role: system, content: 以下是从历史会话中召回的参考信息可能与当前问题相关}这能够减少模型对召回内容的误解避免它把历史片段当成新指令。4.4 一个真实的 token 压缩样例客服项目上线后我做过一次抽样统计。压缩前一个 30 轮会话的单次请求平均消耗 18000 token采用分层压缩后系统提示加事实字段加摘要加热区平均降到 6500 token 左右。压缩率大约 64%。更直观的成本变化是同样量的线上会话模型账单一个月下降了一半以上延迟也明显缩短。原因很简单输入 token 变少首 token 生成时间和计费都会降低。但我要提醒一句这个数字只能作为参考。压缩效果取决于你的对话里有多少可结构化的信息以及摘要压缩多大比例。如果你们的对话全是散文式闲聊没有太多实体结构化事实层的收益就会很小。5. 压缩后的质量验证比压缩本身更重要5.1 用“问题回放”做回归测试压缩策略上线之前我会做一组“问题回放”测试。方法很简单找 20 条真实会话每条只取前 80% 的长度模拟“会话进行到一半时的上下文”然后让系统完成压缩再用几个关键问题去问模型。比如客服会话我会定义这样一组检查项用户最初提出的诉求是否还能被准确回答订单号、地址、承诺时间是否从事实字段里能被直接读出压缩摘要里是否出现了原始会话不存在的细节如果用户回切到第 8 轮讨论过的话题模型能否接住每通过一项记一分低于 80 分就要重新调压缩策略。这套回归测试的价值是把压缩质量的波动从“感觉还行”变成可量化的数据。5.2 三类失真模式与修正压缩过程中最常见的失真有三种。第一是摘要幻觉。模型在生成摘要时补上了某个原始对话里根本没出现过的细节。比如用户没说“要加急”摘要里却多了一句“用户要求加急发货”。这类问题的根源是摘要模型在自由生成时带了脑补倾向。我的修正方法是把摘要生成的温度参数调到 0 或接近 0同时在 prompt 里明确要求“不要补充任何事实”。第二是摘要同质化。滚动摘要滚动几次后模型倾向于只保留大框架把很多具体细节给洗掉。表现为摘要越来越像“客服安抚用户并处理问题”这种空话。我的修正方法是在摘要输出模板里强制包含字段列表比如“必须保留订单号、时间节点、承诺动作”。第三是结构化字段提取错误。抽取模型把地址抽错、把退款金额搞混会导致后续决策基于错误信息。我的修正办法是对敏感字段做二次校验比如金额必须是数字、订单号必须匹配已知格式不满足就回退到原始消息。失真类型表现修正手段摘要幻觉摘要出现原始对话没有的事实温度调低禁止补充内容摘要同质化细节被大框架覆盖摘要模板强制保留关键字段字段提取错误JSON 里的值与原意不符格式校验不通过则回退原文5.3 长期监控三个指标质量验证不能只在上线前做一轮要在线上持续监控。我长期看三个指标压缩率1 - 压缩后 token / 原始 token过低说明压缩没起作用过高要担心信息丢失。关键信息召回率抽样会话里人工标注的 10 个关键信息点系统压缩后能被模型正确回答的比例。业务成功指标客服场景就是首答满意率、问题解决率。这个最重要因为压缩策略好不好最终要看业务结果而不是只看 token 省了多少。有一个容易被忽视的坑只看压缩率容易陷入“为了省而省”的误区。压缩率从 64% 提到 80% 确实漂亮但如果关键信息召回率从 90% 掉到 70%这个交易就不值得做。5.4 成本收益的边界压缩当然有收益token 少成本低延迟短。但也有代价每次压缩都是一次信息有损变换摘要层的抽象可能让模型失去对某些细节的直接感知。所以我最终的判断标准是只有当上下文快要超出窗口或者账单增长到负面影响业务时才考虑进一步压缩。如果当前请求还在安全范围内就不要为了那一点 token 把摘要切得七零八落。6. 踩坑记录那些让我差点回滚的几个问题6.1 压缩得太狠模型把“最近需求”当成了全部第一次上线时我把热区只保留 3 轮结果线上出现了一个很诡异的现象用户明明在第一轮说“我要退款”但系统一直引导用户“您是否需要取消订单”两边聊不到一起去。原因很典型第一轮的信息被压进了摘要而摘要里写的是“用户表达对商品不满希望取消订单”。模型把“取消订单”当成了唯一意图忽略了退款这个金额相关的后续诉求。后来我在结构化事实层里增加了intention字段并且每次压缩都重新抽取意图热区也调回 6 轮问题才消失。这件事让我意识到分层压缩不只是一个存储问题更是一个语义结构问题。你不仅要保存信息还要保存“关系的优先级”。意图、状态、承诺这类字段必须被显式建模不能指望摘要模型顺手就写好。6.2 系统提示被当成历史上下文清理掉另一个让我半夜爬起来查的坑是系统提示被整体清空。当时为了控制 token我写了一个通用逻辑当历史超过阈值就把最早的一批消息删掉。结果系统提示作为会话里的第一条消息也被这同一套逻辑误删了。系统提示删掉之后模型的输出格式立刻崩掉不再遵守 JSON、开始出现口语化废话、工具调用也频繁报错。排查了很久才发现是“清历史”把“指令区域”也给清了。修正方案很简单把消息分成两个区指令区和历史区。指令区的系统提示、工具定义永远不参与淘汰只能由开发者显式替换。这个教训看起来低级但在很多自动化网关里非常容易踩中因为系统提示和普通消息在接口层长得一模一样。6.3 摘要生成温度过高事实被悄悄改写压缩摘要时我最初用的是普通对话生成参数温度偏默认结果摘要里出现了大量“可能”“似乎”这种模糊语气甚至把“今天发货”写成“尽快发货”。后来我把摘要调用整理成独立的低温度链路。所有摘要生成、字段抽取都单独配置 temperature 接近 0并关闭随机采样。简单说压缩这类任务要的是确定性不是创造性千万不要用聊天参数的默认值。6.4 向量召回了不相关内容反而污染上下文向量召回上线后我遇到一种奇怪现象有时候模型反而更“糊涂”了。查下来发现召回返回的旧消息跟当前问题只是表面相似比如都提到了“发票”但实际上一个是“开发票”一个是“发票丢了”语义完全不同相似度却可能不低。解决方法是给召回添加两层限制相似度阈值必须设得足够高低于阈值的片段不进入上下文。召回的 TopK 保持 3 条以内不要贪多一旦超过 5 条上下文会被大量半相关片段填满反而挤压热区空间。这一层的核心原则是召回是补漏的不是主力。模型的主要判断依据仍然是热区和结构化事实召回只负责把被压缩掉的细节捞回来。6.5 不要试图用上下文管理解决所有记忆问题最后还有个边界认知。上下文管理解决的是“窗口有限、信息太多”的问题但如果你的需求是让模型理解一部三百页的文档那就不是分层压缩能解决的而是检索增强生成RAG的范畴。我见过一些项目把压缩摘要越写越长最后一段摘要顶两三千 token这其实已经背离了压缩的初衷。真正常态应该是一套组合拳窗口内放精简摘要和事实窗口外放可检索的完整数据。你在窗口内塞得越多能给模型留下的判断余量就越少。回看整个改造过程我最大的体会是上下文压缩不是把对话变短而是把对话中真正影响决策的信息换成一种更紧凑、更可信的形式。不要期待模型记住所有细节而要设计一套机制让细节按需出现让关键事实始终在线。如果你刚开始做这一步不要一上来就堆复杂模型或向量库先拿一周真实会话跑一遍 token 分布和关键字段把最小可行的压缩链路跑通再逐步加层。分层压缩的每一步都是在和遗忘做权衡而不是消灭遗忘。