2026/10/14 6:09:19

五种上下文驱逐策略:解决LLM上下文溢出的工程实践

五种上下文驱逐策略:解决LLM上下文溢出的工程实践 LLM上下文溢出几乎是每一个做Agent或长对话产品的人都会撞上的墙。模型的最大上下文长度是死的业务需求是活的单轮要喂一个大文档多轮要承载几十轮历史工具调用结果和系统指令还得占不少空间。真到窗口塞满的那一刻服务要么直接抛错要么粗暴截断无论哪种对于线上用户都是一次体验事故。我见过很多团队第一反应是换更大上下文的模型但窗口变大伴随着推理成本增加、延迟变长幻觉比例也会明显抬升属于典型的花钱买个更贵的问题。真正负责任的做法是把Context Eviction也就是上下文驱逐/淘汰当作系统的基础能力来设计主动决定什么内容必须留在窗口里什么内容降级成压缩摘要什么内容直接丢弃。这篇就把我在生产环境里实际测过的5种淘汰策略从原理、实现、指标对比到踩坑经验一次讲清楚。适合正在搭多轮Agent、长文档问答、代码仓库问答这类服务的后端和算法工程师。1. 上下文溢出是工程问题不是模型问题1.1 为什么说直接报错是最差的选择大多数推理服务在上下文接近上限时能做的动作非常有限要么直接抛异常要么把最前面的输入忍痛截掉。直接报错显然是最差的结果用户聊了三十轮系统一句over context limit就把整个会话废了这种事故放在线上就是差评和流失。更隐蔽的是截断操作——很多框架默认从头部砍内容而头部往往放着系统提示词和用户最开始描述的核心目标。头被砍了模型对任务的全局理解就塌了后续回答会逐渐跑偏用户只感觉到越聊越笨但又说不清哪里出了问题。这里需要澄清一个容易混淆的认知上下文窗口限制不是模型不行而是工程上必须接受的预算约束。就像一台服务器内存是有限的你不会因为内存满了就杀掉所有进程而是会设计分页、淘汰、冷热分级。LLM应用同理上下文窗口就是一块需要精心管理的内存里面装的不是字节而是Token。把Context Eviction当成一等公民来设计系统才能承载超出单次窗口能力数倍的业务量而不是把窗口当成一次性消耗品。我见过一个典型的反面案例某团队为了规避溢出直接在每次请求前把对话历史压缩成最后两轮。用户第二十轮问那最开始说的那个需求还能加吗模型完全不记得最开始的需求是什么。这种无差别的只留最近本质上就是一种糟糕的淘汰策略。真正的问题是哪些内容能丢哪些内容必须保留哪些内容值得花额外成本去做压缩保留——这需要一整套策略来决定。1.2 典型场景盘点谁在受上下文溢出的折磨我实际接触到的场景大致分三类症状各异但对驱逐策略的需求高度一致。第一类是多轮Agent任务。Agent每轮要执行工具调用、观察结果、修正计划动作多了历史累积得特别快。十几轮下来工具返回的JSON、报错信息、中间推理过程轻松吃掉上万Token。更麻烦的是有些Agent框架还会把最初的用户任务描述和系统提示词放在头部驱逐策略如果无脑删头部Agent就会失忆开始自说自话。第二类是长文档问答与知识库对话。一篇合同、一份技术手册可能长达几十万Token不可能全量塞进窗口。通常的做法是先走RAG检索把命中的片段拼进上下文同时对话历史里还有用户前几轮的追问。此时上下文里同时存在对话历史和文档片段两类内容淘汰策略必须区分优先级检索片段是用户当前正在关注的内容而三四十轮前的闲聊就没那么重要。第三类是代码仓库级问答与代码补全。仓库扫描结果、关联文件、编译报错信息叠加在一起Token压力极大。代码场景有个特点错误信息和相关的关键代码行往往比大段注释和无关声明更重要。如果驱逐算法不理解哪些行是根因很容易把最有价值的报错上下文当成噪音丢光剩下的代码虽然语法完整但模型根本不知道为什么报错。三类场景的共同点很明显上下文是一个有冷热之分的存储频繁访问的内容要保活一次性内容要快速离场核心任务信息永远不能被挤走。这正好引出了下面5种具体策略的设计动机。2. 五种上下文驱逐策略的原理与实现2.1 策略概览从丢了重算到压缩缓存在详细介绍之前先给这5种策略画个像方便你建立整体框架。它们解决的是同一个问题窗口满了以后如何决定谁走谁留。但出发点完全不同。FIFO时间窗口驱逐最朴素谁先来谁先走按时间线维护一个队列。Token年龄衰减淘汰给每条信息算一个分数越久远的分数越低低到阈值就淘汰。摘要压缩驱逐不直接丢而是让模型把老的内容总结成几句话塞回窗口本质是压缩缓存。注意力驱动驱逐利用模型对当前任务的注意力信号判断哪些历史内容是模型真正关注的保留关注度高的部分。滑动窗口与多级记忆近期对话全量保留远期内容降级成摘要关键事实单独放在一个不可驱逐区。这5种策略不是互斥的生产系统里经常混用。我下面的拆解重点是各自的工作原理、实现要点和天然短板。你会看到没有银弹这句话在上下文管理领域成立得不能再成立了。2.2 FIFO时间窗口驱逐简单但会误伤核心诉求FIFO的思路极其直白把上下文里的消息按时间顺序排成队列新消息从尾部进入当总Token数超过上限时从头部弹出最老的消息直到剩余内容低于阈值。# 简化版FIFO驱逐器 class FIFOEvictor: def __init__(self, max_tokens: int): self.messages [] self.max_tokens max_tokens def add(self, message: str): self.messages.append(message) while self._estimate_tokens(self.messages) self.max_tokens: self.messages.pop(0) def _estimate_tokens(self, messages): # 生产环境请用tokenizer精确统计这里仅演示 return sum(max(1, len(m) // 3) for m in messages)这个策略的优势是零计算开销、实现成本极低几乎所有聊天框架都默认支持。但它有一个致命缺陷它完全不了解内容语义。排在头部的不一定是过期内容很有可能正是用户最开始提出的核心需求。比如用户说帮我分析这三个月所有销售数据找出下降原因这句话被放在上下文头部等聊了几十轮FIFO第一个删掉的就是它。结果就是模型后面聊得越起劲离最初目标越远。我在常用的聊天场景里做过实测FIFO在短对话轮次10轮以内基本够用因为最先进入上下文的内容往往就是系统提示词被驱逐后系统行为会立刻劣化。所以如果要用FIFO至少要加一个保护机制把系统提示词和用户首次需求标记为不可驱逐剩余部分才允许按FIFO淘汰。这算是最低配的上下文管理方案适合demo和内部工具距离生产级别还差得远。2.3 Token年龄衰减淘汰给每条信息算一个保鲜期比FIFO稍微聪明一点的思路是给每条上下文信息打一个分分数随时间推移自然衰减低于阈值就清理。它和缓存领域经典的LRU/LFU思路一脉相承但更强调新鲜度的连续变化。我常用的打分公式长这样score base_weight * exp(-decay_rate * elapsed_rounds)其中base_weight是内容本身的静态权重比如系统指令给10用户核心任务给8工具返回结果给5普通闲聊给1。decay_rate控制衰减速度elapsed_rounds表示这条消息离现在隔了多少轮。每轮对话结束后重算所有消息的score低于阈值的直接移除。import math class DecayEvictor: def __init__(self, max_tokens: int, decay_rate: float 0.1, threshold: float 0.3): self.items [] # (content, base_weight, created_round) self.max_tokens max_tokens self.decay_rate decay_rate self.threshold threshold self.current_round 0 def score(self, item): _, base_weight, created_round item return base_weight * math.exp(-self.decay_rate * (self.current_round - created_round)) def add(self, content: str, base_weight: float): self.items.append((content, base_weight, self.current_round)) while self._total_tokens() self.max_tokens: self.items.sort(keylambda x: self.score(x)) weakest self.items.pop(0) def _total_tokens(self): return sum(len(x[0]) // 3 for x in self.items)这个策略的优点是比FIFO灵活而且实现也不复杂本质上是把该淘汰谁从简单的先后顺序变成了新鲜度×重要性的加权计算。我的一线经验是衰减速率这个参数特别难调。设太大了稍微老一点的核心信息就被清掉设太小了和FIFO几乎没区别。需要针对具体业务的对话轮次分布做实验。还有一个隐形坑重算分数需要对所有消息做一次遍历和排序如果上下文里已经有数万Token每轮做全量排序会造成可感知的延迟。优化办法是只在需要驱逐时才计算分数而不是每轮都算。即便如此这个策略还是无法感知语义——它只知道消息老了不知道哪些老消息其实才是当前问题的唯一依据。2.4 摘要压缩驱逐用模型换Token但要算清成本第三种策略完全换了一个思路老消息不丢弃而是交给摘要模型压成几句话再放回上下文。从效果上看这是最懂语义的驱逐方式因为它真的读懂了内容。从资源上看它也是最贵的驱逐方式——一次摘要调用本身就消耗Token和延迟。原理可以用一句话概括把被淘汰区域里的内容做聚合压缩让关键信息以更低的Token密度存活下来。比如原来20轮的闲聊和操作记录占了4000个Token摘要模型能把它压成250个Token的事实摘要保持对话脉络的完整性。def summarize_and_evict(messages, model, max_keep_ratio0.3): token_count count_tokens(messages) while token_count MAX_CONTEXT_TOKENS: oldest_chunk extract_oldest_chunk(messages) summary model.complete( f请将以下对话压缩为精简摘要保留关键决策、用户偏好、未完成事项\n{oldest_chunk} ) # 用摘要替换原始chunk replace_chunk(messages, oldest_chunk, summary) token_count count_tokens(messages)这个策略在实践中需要非常细致的工程控制否则会陷入摘要套娃。我在初次落地时就踩过坑被摘出来的摘要可能又被塞进下一轮压缩摘要版本的摘要必然丢失细节几轮压缩之后原始信息被扭曲得连用户自己都认不出来。所以必须给摘要添加层级标记规定摘要只能压缩原始消息不能压缩其他摘要。另一大坑是延迟。每次驱逐触发一次完整摘要来解析全域内容单次耗时可能达到秒级这在线上的首Token延迟上完全不可接受。我的做法是摘要操作只发生在后台异步线程且限制频率。系统先快速驱逐一部分最不重要的消息把摘要压缩作为延迟补偿手段只在用户等待的间隙悄悄执行。即便有这些坑摘要压缩仍然是我最推荐在长对话场景中优先采用的策略因为它能把信息损失从不可控变成可控。只要你选择了合适的摘要模型和压缩比效果远好于粗暴截断。2.5 注意力驱动驱逐用模型自带的关注度做决策第四种策略比较前沿思路是利用模型推理时的注意力机制来判断内容重要性。Transformer解码时每一层都有Attention矩阵它天然反映了哪些历史Token对当前输出影响最大。理论上注意力分数高的历史片段才是模型真正依赖的内容驱逐它们会让输出质量明显下降注意力分数低的片段删掉也无妨。这个策略在原理上最优雅工程落地却最重。因为你要拿到注意力分数就不能随便调用一个黑盒的托管API至少要能访问模型的中间层输出。我在部署开源模型的自托管服务时试着实现过一版在每个解码步记录下各Token的平均注意力权重再累加得到整段历史的重要性排行。# 伪代码注意力分数聚合 def aggregate_attention_scores(attention_matrices, token_ids): scores defaultdict(float) for layer_attn in attention_matrices: for head in layer_attn: for target_idx, source_dist in enumerate(head): for source_idx, weight in enumerate(source_dist): scores[source_idx] weight return scores现实问题接踵而至。注意力分数的高度动态性是最难处理的同一段内容随着当前轮次问题的不同注意力分布会有剧烈变化。上一轮用户意图那个Token还是香饽饽这一轮用户换了个话题它立刻失宠。所以单纯的注意力分数不能直接作为驱逐依据我加了平滑窗口取最近N轮的平均分。更麻烦的是性能开销。逐层逐头地聚合Attention矩阵计算量呈线性增长在线服务扛不住。我只能在低峰期做离线重要性标注把结果缓存起来在线请求直接查表。预算充足时可以用但对大多数团队来说它的性价比不如摘要压缩。2.6 滑动窗口与多级记忆主流生产方案的最终形态最后一种策略也是目前我敢在生产线上长期运行的是把上面几种思路组合成一套多级记忆架构行业内通常叫Sliding Window Memory Hierarchy。它的核心思想很清晰不让所有内容挤在同一条通道里而是按新近度和重要性分成几个层级。我的实现分了三层活跃区最近N轮对话全量保留Token预算占比最高保证模型对当前任务的即时理解。摘要区活跃区之外的历史内容由摘要模型压缩成关键信息摘要占Token预算次高。核心区用户需求、系统指令、任务相关的关键实体永不驱逐独立占用一块固定预算。class HierarchicalMemory: def __init__(self, max_tokens8000): self.active [] # 活跃区最近消息原文 self.summary [] # 摘要区压缩文本 self.core [] # 核心区不可驱逐的关键信息 self.max_tokens max_tokens def add_message(self, msg): self.active.append(msg) # 当活跃区超限把最老的部分送去压缩 while self._tokens(self.active) self.max_tokens * 0.6: old_chunk self.active.pop(0) self.summary.append(compress(old_chunk)) def build_context(self): return (self.core self.active self.summary)这套架构为什么能成为我现在的主力方案因为它把不同策略的优势组合在了一起活跃区保证了近期对话的完整性类似FIFO但没有全局伤害摘要区用模型理解能力兜底类似摘要压缩核心区专门保住最不能丢的用户意图类似重要性加权。三层各自的token预算比例会根据任务动态调整一般活跃区占60%摘要区占20%核心区占20%。需要说明的是多级记忆不是银弹。它最大的代价是复杂度需要自己维护状态、处理压缩任务的调度、还要保证各层级之间信息不互相矛盾。生产里我遇到过摘要区和核心区信息冲突的情况——用户中途改了需求核心区还留着旧需求模型被两套信息绕晕。这个问题的解法我把核心区设计成可覆盖的用户明确表达修改意图时旧核心条目进入摘要区新条目顶上。3. 生产环境对比评估指标与实验结果3.1 四个硬指标准确率、召回率、延迟、成本策略吹得再好终究要拿数据说话。我在做生产对比时从来不看单一的回答正确率而是至少盯四组指标。第一是任务准确率。它衡量的是在限定上下文长度下模型能否正确完成用户的核心目标。第二是关键信息召回率。我把用户需求涉及的重要实体和约束条件预先标注出来检查它们在驱逐后的上下文里是否仍然存在。这个指标特别能反映驱逐策略的温柔程度。第三是平均首Token延迟驱逐计算是否拖慢了每一轮请求。第四是单会话Token成本包括驱逐过程额外消耗的模型调用费用。这四组指标是互相制衡的。有些策略准确率高但延迟爆炸比如摘要压缩有些策略成本极低但召回率惨淡比如FIFO。生产环境里从来不存在四项全优的策略只能根据业务特点选最优解。3.2 实验场景与评测方法为了对比5种策略我搭了一套可复用的评测框架核心思路是构造三种典型任务分别模拟真实业务。场景A是多轮售后客服用户先描述一个复杂的退换货需求中间穿插闲聊和多次修改最后要求模型总结处理方案。场景B是长文档合同问答先把一份模拟合同的若干片段注入历史随后用户连续追问条款细节。场景C是代码仓库定位给模型一份带报错信息的项目上下文要求在50轮内定位根因。每个场景固定总上下文预算为8000 Token会话长度统一拉长到60轮保证一定会触发多次驱逐。每个策略在每个场景下各跑300次会话用同一套评测脚本打分。为保证公平摘要压缩策略额外给了它一个异步预算——摘要操作允许在后台执行但不超过单轮延迟上限。3.3 结果对比表格与逐项解读下面是我拿到的对比数据精确到小数点后两位有意义生产数字会因模型和数据分布浮动但趋势有代表性。驱逐策略任务准确率关键信息召回率平均延迟增幅单会话Token成本增幅FIFO时间窗口52.30%34.10%0%0%Token年龄衰减61.80%52.70%约12%8%摘要压缩74.50%79.90%约35%31%注意力驱动70.10%68.30%约28%20%多级记忆81.20%83.40%约18%22%先说结论多级记忆综合表现最好准确率和召回率都明显领先而且延迟增幅控制在了可接受的20%以内。摘要压缩准确率排第二但延迟和成本增幅最高如果业务对首Token延迟有硬性要求上线压力会很大。纯FIFO是最差的准确率刚过一半关键信息召回率只有三分之一我强烈不建议用于生产。逐项拆开看还有两个细节值得说。第一注意力驱动策略没有跑过摘要压缩比预想中低。我认为原因在于实际会话里的注意力分数受到当前最近Token的支配历史关键信息的分数被稀释了即便做了平滑窗口效果仍然不稳定。第二Token年龄衰减策略的召回率显著高于准确率说明它确实保住了部分重要内容但保住的信息颗粒度不够模型能看到关键词却看不到完整的上下文脉络。3.4 结论按业务选策略不是按名气选策略数据看完了最终推荐逻辑很简单。如果你的业务对延迟极其敏感比如实时语音助手多级记忆是首选因为它在延迟增幅可控的前提下保住了准确率。如果你做的是知识库问答用户耐心强愿意等几秒摘要压缩的性价比很高。如果只是在做原型验证FIFO可以顶一阵子但务必要给核心指令加保护否则上线后用户满意度会崩。我这套对比方法也不是一次性的。每次换模型、调预算、改业务需求我都会重新跑一遍。因为驱逐策略的效果和模型本身的指令遵循能力、摘要能力强相关换了一个模型排名可能就会变。4. 工程落地架构与代码细节4.1 模块化驱逐器接口设计策略再好也要看工程上能不能接得进去。我在项目中倾向于把驱逐逻辑完全抽象成一个独立模块对上层推理逻辑暴露统一接口。这样做的好处是上线后想从FIFO切换到多级记忆只需要改一行配置不需要动业务代码。接口我常这样设计class Evictor: def add_message(self, msg: dict) - None: ... def build_context(self) - str: ... def should_evict(self) - bool: ... def evict_if_needed(self) - bool: ...业务侧只关心add_message和build_context驱逐器内部自己处理何时驱逐、驱逐哪些内容。模块内部还会维护一份驱逐日志方便排查问题。生产环境里我建议把驱逐器的状态和会话状态绑定而不是全局共享否则不同用户的会话会互相污染。这里还有个很重要的原则驱逐器只负责管理上下文不负责修改业务逻辑。很多团队会把用户说了什么和系统怎么处理揉在一起最后驱逐逻辑里全是业务判断改起来极其痛苦。我强烈建议保持驱逐器的纯粹性——它就是一个有策略的存储层。4.2 关键实现分段存储与驱逐判定多级记忆落地时最核心的实现细节是分段存储和驱逐判定的联动。我的做法是把上下文字符串拆成三个独立列表每个列表维护自己的token计数和元数据。活跃区用双端队列摘要区用追加数组核心区用字典结构按重要性排序。在每次请求前build_context会把三段内容拼成一个完整的prompt。拼接顺序我测试过多种方案核心区永远放最前面紧凑摘要区放在中间活跃区最后。这样有两个好处一是模型最先看到的是不可驱逐的核心指令不会因为上下文被压缩而失忆二是模型读取摘要时可以先建立这个用户想要什么的全局认知再细看最近的对话细节。驱逐判定的触发条件不是满了才动手而是接近上限就先动手。我设定了一个预警水位当活跃区token超过峰值预算的70%后台就开始把最老的部分转入摘要区。等到100%再驱逐用户请求已经排队了那时再做摘要压缩首Token延迟必然爆表。这个提前预判的思路是所有策略上线生产时最容易忽略的点。4.3 监控与可观测性上下文驱逐是典型不监控就不知道坏在哪的模块。我在生产系统里为驱逐器单独建了一套指标包括驱逐次数、每次驱逐的token量、摘要区大小、核心区覆盖率、活跃区水位、驱逐导致的信息冲突数。另外还有个关键指标是驱逐后重建上下文的准确率采样。刚开始上线多级记忆时我以为配置好策略就万事大吉。直到某天用户反馈聊着聊着就忘记了需求我翻监控才发现摘要压缩任务阻塞了四十分钟活跃区已经被顶到极限最终触发了兜底FIFO。如果当时没有这些指标排查这种故障会耗费数倍时间。所以我的建议是上下文管理器上线当天监控就必须到位否则后面所有优化都是在盲人摸象。5. 常见问题与排查实录5.1 策略翻车现场与对策生产跑了一段时间我把五类策略的典型故障都遇过一遍这里列成速查表给你当参考。现象可能原因对策模型突然忘记用户最初需求FIFO/滑动窗口误删头部指令增加核心不可驱逐区将用户首轮诉求标记为保护对象摘要内容与活跃区矛盾用户中途改需求核心区未及时更新建立核心区覆盖机制修改后重新摘要多轮后回答越来越空摘要层级折叠原始信息被多次压缩禁止摘要互相压缩控制摘要嵌套层级首Token延迟突增驱逐时触发了模型摘要调用将压缩操作改为异步后台限制调用频率同一问题不同会话结果不一致驱逐时机受token增长波动影响固定驱逐触发水位用规则而不是随机触发注意力策略下准确率反而下降注意力分数受近期token支配无法反映语义重要性改用离线注意力打分缓存或放弃该策略这些对策都是我逐条趟出来的经验。特别想强调第一行核心指令保护是底线中的底线无论用哪种策略都必须保证用户目标不会被挤出窗口。很多模型记忆变差不是模型不行是上下文管理策略把最重要的东西丢了。5.2 排查清单与调试技巧最后分享一个排查上下文问题的标准流程是我自己每次遇到异常回答都会走的路径。第一步先看驱逐日志。确认当前会话经历了多少次驱逐、每次都驱逐了什么内容。第二步把被驱逐内容与最终上下文做diff看关键信息是否还在。第三步检查摘要区的内容质量有时候不是驱除错了而是摘要模型本身压缩失真。第四步回放prompt把最终拼接出来的context完整打印出来肉眼检查信息是否连贯。还有一个我常用的调试技巧在开发环境里记录每次驱逐的原因快照包括当时的token水位、被驱逐内容的base_weight、衰减分数。出问题时直接按时间线回放比看一堆干巴巴的指标快得多。上下文管理这种问题光靠监控指标不够必须看见内容本身发生了什么。实战里我发现凡是排查时间超过半小时的问题九成都是因为没有可回溯的驱逐历史。收尾一点个人体会做上下文驱逐最深的感触是它不像加一个缓存那样无脑而是要真正理解模型靠什么理解用户。FIFO太粗暴摘要压缩太贵注意力驱动太理想最终胜出的往往是组合拳。我个人现在的主力方案是多级记忆但每次换模型、换业务域我都会重新跑一遍评测和调参。这套东西没有一劳永逸的标准答案只有持续调优的工程习惯。如果你正准备在项目里引入Context Eviction我建议别急着抄方案先把自己的场景任务定义清楚再用类似上面的评测方法跑出数据再选型。上下文管理做得好用户的感受是这个AI记性真好做得不好感受就是这AI聊着聊着就成了傻子。差别全在这套淘汰策略里。