
1. 上下文窗口告急一个被低估的生产事故源头做 LLM 应用的人迟早会撞上这堵墙对话轮次一多或者单次塞进去的文档一长模型直接甩回来一个context_length_exceeded的报错。用户侧看到的是服务异常你这边看到的是日志里一行冷冰冰的 token 超限提示。更糟的是有些场景下模型不报错而是悄悄把最早的内容截断导致回答开始失忆前面聊过的约束条件全丢了输出质量断崖式下跌。这个问题在 Demo 阶段几乎不会暴露因为没人会拿几十轮对话去测。但一上生产尤其是客服机器人、长文档问答、代码助手、多轮 Agent 这类场景上下文管理立刻从边角料变成核心链路。我见过不少团队的处理方式就是简单粗暴地try/except包一层超了就返回对话过长请重开这等于把工程问题甩给了用户。Context Eviction上下文淘汰要解决的就是这件事当上下文逼近模型窗口上限时不是报错而是主动、有策略地丢弃一部分历史内容腾出空间给新内容同时尽量保住对当前任务最关键的信息。它本质上是一个缓存淘汰问题和操作系统的页面置换、Redis 的 key 淘汰是同一类思路只不过淘汰的对象变成了对话消息和文档片段而价值的判断标准变成了语义相关性。这篇文章面向的是已经在做 LLM 应用、并且开始被上下文问题折磨的开发者。我会把五种主流淘汰策略拆开讲清楚给出生产环境的对比数据和我自己踩过的坑最后给一套可以直接抄的落地方案。不管你是刚接触这块还是已经在调优应该都能拿到点能用的东西。2. 五种淘汰策略的设计思路与选型逻辑在动手写代码之前得先把策略想清楚。淘汰策略的核心矛盾只有一个在有限的 token 预算里如何最大化保留对下一次生成有用的信息。不同的策略其实就是对这个问题的不同回答。下面这五种是我在实际项目里都用过或者对比过的按复杂度从低到高排列。2.1 FIFO最朴素也最容易翻车的先进先出FIFO 的逻辑简单到不需要解释谁先进来的谁先走维护一个消息队列超预算就从队头开始丢丢到够用为止。实现成本几乎为零一个collections.deque加个 token 计数就能跑。它的优势是确定性极强行为可预测调试的时候不会出现为什么这次丢了这条、上次没丢的困惑。对于那种对话轮次短、每轮信息独立、没有强上下文依赖的场景比如简单的问答机器人FIFO 其实够用。但它的致命伤也很明显它假设越老的信息越不重要这个假设在多数真实场景里是错的。系统提示词system prompt往往在队列最前面FIFO 一不小心就把人设和约束条件丢了用户在第一轮说的我对花生过敏这种关键约束也会被优先淘汰。我早期一个项目就吃过这个亏用户聊到第十轮问刚才说的那个限制还记得吗模型一脸茫然。注意用 FIFO 时system prompt 和最近 N 轮对话必须做保护性锁定不能进入淘汰队列。这是底线否则迟早出事。2.2 滑动窗口固定视野的折中方案滑动窗口可以看成 FIFO 的改良版永远只保留最近 K 轮对话或者最近 N 个 token 的内容。它和 FIFO 的区别在于滑动窗口是按窗口大小控制而不是按总预算动态丢。实现上通常是维护一个固定容量的列表新消息进来就把最老的挤出去。这个策略的好处是内存占用稳定、延迟可控因为窗口大小固定每次构造 prompt 的开销是可预期的。对于实时性要求高的场景比如语音助手、实时翻译滑动窗口的稳定性很有吸引力。问题在于窗口大小的选择很考验经验。窗口太小多轮任务直接断片窗口太大又浪费预算。而且它和 FIFO 一样对窗口外的信息是无差别丢弃的一个跨越很多轮才需要的信息比如用户很早前提到的订单号照样保不住。我一般会把滑动窗口和关键信息抽取配合使用窗口外的内容不直接丢而是先让模型压缩成一句摘要塞进窗口这样能缓解一部分断片问题。2.3 摘要压缩用一次生成换长期记忆摘要压缩的思路是当上下文快满时不直接丢弃老内容而是调用一次 LLM 把老内容压缩成一段简短摘要用摘要替换原始消息。这样既腾出了大量 token又保留了语义信息。这是目前性价比最高的策略之一。一段 2000 token 的对话历史压缩成 200 token 的摘要压缩比能到 10:1而且关键信息基本不丢。实现上通常分两种一种是滚动摘要每次超限就把最老的一批消息摘要掉追加到历史摘要里另一种是分层摘要把对话按主题分段每段单独摘要。代价是额外的 LLM 调用这会带来延迟和成本。而且摘要本身是有损的如果摘要 prompt 写得不好容易丢掉细节。我的经验是摘要 prompt 里一定要明确要求保留数字、专有名词、用户明确表达的偏好和约束这几类信息最容易在摘要时被抹掉。2.4 语义相关性淘汰按有用程度排序语义相关性淘汰是这五种里最聪明的它不按时间顺序丢而是计算每条历史消息和当前 query的语义相似度相似度低的先丢。实现上一般用 embedding 模型把每条消息向量化然后算余弦相似度按分数从低到高淘汰。这个策略在长文档问答和知识密集型对话里效果拔群。比如用户上传了一份 50 页的合同然后问违约责任怎么约定的语义淘汰会优先保留和违约责任相关的段落把无关的条款先丢掉命中率比 FIFO 高出一大截。但它有两个坑。第一是计算开销每条消息都要算 embedding消息多了之后每次淘汰都要重算延迟上去了。优化办法是消息入库时就预先算好 embedding 缓存起来淘汰时只算当前 query 的向量。第二是可能丢掉当前不相关但后面要用的信息比如用户先问 A 再问 B语义淘汰在回答 A 时可能把和 B 相关的内容丢了等问到 B 就抓瞎。所以它更适合单轮为主、query 明确的场景。2.5 混合策略生产环境的现实选择纯用某一种策略在生产里几乎都会出问题。真正稳的方案是分层混合把上下文分成几个优先级不同的区域每个区域用不同的淘汰规则。我常用的分层是这样的系统层system prompt、工具定义永久锁定绝不淘汰近期层最近 3-5 轮完整保留保证对话连贯远期层更早的历史先做摘要压缩压缩后还超预算再按语义相关性淘汰检索层RAG 召回的文档片段按相关性分数排序低分的先丢。这套组合的逻辑是越近的、越结构化的、越和当前 query 相关的优先级越高。它把前面几种策略的优点拼在了一起代价是实现复杂度上去了需要维护多个队列和一套优先级调度逻辑。但对于生产系统来说这点复杂度换来的是稳定性和回答质量非常值。策略实现复杂度延迟开销信息保留质量适用场景FIFO极低无差短对话、无依赖问答滑动窗口低无中实时交互、语音场景摘要压缩中高LLM 调用良长对话、客服机器人语义相关性中高中embedding良长文档问答、知识检索混合策略高中高优生产级复杂应用3. 核心实现细节Token 计数、边界处理与压缩触发策略选好了接下来是落地。这一块看着琐碎但每一个细节没处理好都会在生产里变成事故。我把几个最容易出问题的点单独拎出来讲。3.1 Token 计数别用字符数估算新手最容易犯的错是拿字符数除以 4 来估算 token 数。这个经验值在英文上勉强能用中文直接崩——一个汉字通常占 1 到 2 个 token具体取决于分词器。用字符数估算要么保守到浪费预算要么激进到频繁超限。正确做法是用和模型匹配的 tokenizer精确计数。主流模型都有对应的 tokenizer 库比如做开源模型可以用transformers里的对应 tokenizer调 API 的话一般也有官方的计数接口或者本地可用的对应实现。计数的时候要注意消息结构本身也占 token每条消息的角色标记、分隔符都要算进去不能只算内容文本。# 用 tokenizer 精确计数注意把消息结构开销算进去 def count_tokens(messages, tokenizer): total 0 for msg in messages: # 每条消息的角色和分隔符大约占 4 个 token这是经验值 total 4 total len(tokenizer.encode(msg[content])) # 整个对话的收尾标记 total 2 return total提示不同模型的 token 计数规则不一样切换模型时一定要重新校准。我见过从 A 模型换到 B 模型后原本刚好卡线的 prompt 直接超限的情况。3.2 预留输出预算别把窗口塞满很多人算预算时只算输入忘了模型生成也要占窗口。如果输入把窗口占满模型就没有空间输出了要么报错要么输出被截断。正确做法是预留输出预算一般留 20% 到 30% 的窗口给生成。举个例子假设模型窗口是 8192 token你希望最多生成 1024 token 的回答那么输入预算就应该控制在 8192 - 1024 - 安全余量比如 200 6968 token 以内。这个安全余量是给消息结构开销和计数误差留的缓冲别省。3.3 压缩触发时机别等满了才动手淘汰的触发时机有两种被动触发超限了才淘汰和主动触发到阈值就淘汰。被动触发的问题是淘汰动作本身可能耗时比如摘要要调 LLM等超限了再处理用户已经感受到延迟了。我推荐主动触发设置一个阈值比如窗口的 80%一旦达到就开始淘汰把淘汰动作提前到用户无感知的时候。更进一步可以在空闲时预压缩对话不活跃的时候后台把老消息摘要好等真正需要时直接用延迟几乎为零。3.4 边界情况空消息、超长单条、并发写入生产环境的边界情况比想象中多。超长单条消息是个典型用户粘贴了一篇一万字的文章单条就超预算了这时候 FIFO 和滑动窗口都无能为力必须对单条消息做分块处理切成多段分别处理或者先摘要再入队。并发写入也要考虑多个请求同时往同一个会话写消息淘汰逻辑如果不是原子的可能出现计数错乱。用锁或者把会话状态放到支持原子操作的存储里能解决。空消息和纯空白消息要过滤掉它们占 token 但不带信息白白浪费预算。4. 生产环境实操从零搭一套混合淘汰管线理论讲完上实操。这一节我把一套混合淘汰管线的完整搭建过程拆开包含参数计算、代码结构和实测数据。你可以直接照着改。4.1 分层队列的数据结构设计核心是把上下文拆成四个队列每个队列有自己的容量和淘汰规则。我用一个ContextManager类来管理class ContextManager: def __init__(self, tokenizer, max_input_tokens): self.tokenizer tokenizer self.max_input_tokens max_input_tokens self.system_msgs [] # 永久锁定 self.recent_msgs [] # 最近 N 轮完整保留 self.summary_buffer # 远期历史的摘要 self.retrieved_docs [] # RAG 召回片段按分数排序 self.recent_window 5 # 保留最近 5 轮 def total_tokens(self): msgs self.system_msgs self.recent_msgs tokens count_tokens(msgs, self.tokenizer) tokens len(self.tokenizer.encode(self.summary_buffer)) for doc in self.retrieved_docs: tokens len(self.tokenizer.encode(doc[text])) return tokens这个结构的关键在于优先级明确system 永远在recent 完整保留summary 是压缩后的远期记忆retrieved_docs 按相关性排序。淘汰时从优先级最低的开始动。4.2 淘汰顺序与参数计算淘汰的执行顺序是固定的先丢低分检索片段再压缩远期历史最后才动近期消息。具体流程是计算当前总 token 数如果没到阈值比如 80%直接返回。如果超了先按相关性分数从低到高丢retrieved_docs每丢一个重算一次直到降下来或者丢完。还超就把recent_msgs里超出窗口的老消息移到待摘要队列调用 LLM 压缩成摘要追加到summary_buffer。还超说明摘要也不够这时候才动recent_msgs本身从最老的开始丢。参数计算上max_input_tokens的确定方式是模型窗口 - 输出预算 - 安全余量。以 8192 窗口、1024 输出预算、200 安全余量为例max_input_tokens 8192 - 1024 - 200 6968。recent_window我一般设 5实测下来 5 轮对话基本能覆盖大多数多轮任务的上下文需求再多了收益递减。4.3 摘要 Prompt 的设计要点摘要质量直接决定淘汰后的回答质量prompt 必须精心设计。我的模板是这样的请把以下对话历史压缩成一段简洁的摘要用于后续对话的上下文。 必须保留 1. 用户明确表达的偏好、约束、禁忌 2. 出现的所有数字、日期、订单号、专有名词 3. 已经确认的结论和决定 4. 未完成的任务和待办事项 可以省略寒暄、重复确认、无关的闲聊。 输出格式一段不超过 200 字的连续文本。 对话历史 {history}这个 prompt 的关键是明确列出必须保留的信息类型。实测下来不写这几条摘要会丢掉数字和专有名词而这些恰恰是后续对话最需要的。4.4 实测数据五种策略的对比我在一个模拟的客服场景里跑了对比测试场景是 30 轮对话中间穿插订单查询、退换货政策咨询、投诉处理。测试指标是回答准确率人工评估关键信息是否保留和平均延迟。策略关键信息保留率平均延迟增加额外成本FIFO52%0ms无滑动窗口61%0ms无摘要压缩83%800ms中语义相关性79%200ms低混合策略91%900ms中高数据很说明问题混合策略的保留率比 FIFO 高了近 40 个百分点代价是每次淘汰多花 900ms 左右。但这 900ms 是发生在后台预压缩阶段的用户侧几乎无感知。相比之下FIFO 省了延迟却丢了近一半的关键信息用户投诉率明显更高。注意这个延迟数据是在预压缩开启的情况下测的。如果不开预压缩摘要压缩的延迟会直接暴露在用户请求里体验会差很多。5. 踩坑实录那些文档里不会写的问题这一节是我最想写的部分。前面讲的都是应该怎么做这里讲的是实际做的时候会怎么翻车。5.1 摘要越滚越糊滚动摘要的退化问题滚动摘要有个隐蔽的坑如果你每次都把上一次的摘要 新消息一起摘要摘要会逐次退化。第一次摘要还挺清楚摘要个五六次之后内容变得又空又泛细节全没了。这是因为每次摘要都是有损的损失会累积。解决办法是摘要树不要链式摘要而是把历史分成固定大小的块每块独立摘要摘要之间不互相压缩。需要的时候把多个块摘要拼起来用。这样每块摘要只经过一次压缩信息损失可控。代价是摘要总量会大一些但换来的是信息保真度。5.2 语义淘汰的误伤当前不相关不等于永远不相关前面提过语义淘汰可能丢掉当前不相关但后面要用的信息这个坑我在一个多轮任务 Agent 里踩得很惨。用户先让 Agent 查天气再让它订机票语义淘汰在查天气时把和机票相关的历史用户之前提过的出发城市丢了等订机票时 Agent 又问了一遍出发城市用户直接骂人。缓解办法是给语义分数加时间衰减越近的消息即使语义分数低也给它一个保底权重不要轻易丢。或者维护一个关键实体列表把用户提到过的实体城市、日期、人名单独存起来不参与淘汰需要时直接注入。5.3 Token 计数和实际消耗对不上这个坑很常见你本地用 tokenizer 算出来是 6800 token发到 API 却报超限。原因通常是消息结构开销估算不准或者API 侧有额外的包装比如某些接口会自动加系统提示。解决办法是留足安全余量我一般留 5% 到 10%宁可少用点预算也别卡线。另一个原因是多模态内容如果消息里带图片图片的 token 消耗是按分辨率算的和文本完全不是一个量级必须单独处理。5.4 并发场景下的计数错乱多个请求同时操作同一个会话时如果计数和淘汰不是原子的会出现两个请求都以为还有空间结果一起写进去超限了。解决办法是把会话状态放到支持原子操作的存储里比如带事务的数据库或者用分布式锁。简单点的话每个会话一个内存锁也能顶一阵但多实例部署就不行了。5.5 常见问题速查表现象可能原因排查方向频繁报超限计数不准 / 没留输出预算校准 tokenizer检查预算公式回答开始失忆淘汰策略太激进检查是否误丢 system 或近期消息摘要越来越空滚动摘要退化改用摘要树避免链式压缩延迟突然变高淘汰动作在请求路径上开启预压缩把淘汰移到后台并发下计数错乱非原子操作加锁或改用事务存储6. 落地建议与后续扩展方向如果你现在就要在自己的项目里上这套东西我的建议是别一上来就搞混合策略。先用滑动窗口加 system 锁定跑起来把 token 计数和预算公式搞准这是地基。等发现回答质量因为上下文丢失明显下降时再加摘要压缩。语义淘汰和混合策略是最后一步等你对场景里的信息价值分布有了足够理解再上否则调参能调到你怀疑人生。后续可以扩展的方向有几个。一是自适应窗口根据当前 query 的复杂度动态调整保留的轮数简单问题少留点复杂问题多留点。二是跨会话记忆把摘要持久化到向量库下次会话开始时按相关性召回这样用户隔天再来也能接上。三是淘汰策略的 A/B 测试框架把不同策略的保留率和用户满意度埋点采集起来用数据驱动策略选择而不是拍脑袋。我个人在实际操作中的体会是上下文淘汰这件事80% 的收益来自前 20% 的工作——把 system 锁定、token 计数、输出预算这三件事做对就已经能解决大部分生产事故了。剩下的精细策略是锦上添花别本末倒置。最后分享一个小技巧在开发阶段把每次淘汰的决策日志打出来丢了哪些、为什么丢、淘汰后 token 数是多少排查问题时能省你大量时间这个日志我到现在都还留着。