Context Eviction上下文淘汰:五种LLM记忆管理策略与工程实践 去年秋天我维护的一个智能客服项目因为上下文溢出问题被用户投诉了整整一周。用户连续追问几个问题之后对话系统直接抛错整个会话作废客户再点一次继续咨询机器人已经不记得之前聊过什么了。当时我的第一反应是加大模型上下文窗口的配置但很快发现这不只是窗口大小的问题——只要对话轮次一多任何固定窗口都会被塞满。后来我把精力转向了上下文淘汰机制也就是标题里说的 Context Eviction才真正解决了问题。这篇内容适合正在做对话系统、Agent 任务编排、RAG 管道或者任何需要控制模型输入长度的工程同学。读完你可以直接拿去设计你自己的淘汰策略也能避开我踩过的那些坑。1. 上下文溢出为什么不能直接报错先算清楚这笔账很多人觉得上下文满了就报错是模型接口的限制是理所当然的。但其实在生产环境里报错的代价比你想象的大得多尤其是接入了多轮对话、Agent 工具调用或者长文本分析之后。我先算一笔账。假设你的对话模型上下文窗口是 4096 个 token一次多轮交互里用户连续提了 10 个问题平均每个问题加上历史回复占用 500 token那你到第 9 轮时上下文就已经逼近 4500系统一检测到超限就抛异常。此时用户正在等待一个完整回答实际发生的却是前 8 轮的 API 调用费用全部浪费因为后续无法基于它们继续生成有意义的回复用户需要重新描述需求体验断崖式下降如果这是 Agent 场景可能已经执行了 3 步工具调用、拿到中间结果但上下文一断任务状态直接丢失外部系统的数据操作也无法回滚。更麻烦的是报错的时机不可预知。有时候上下文在第 6 轮溢出有时候在第 11 轮取决于用户每条消息的长短。这种不确定性让前端产品根本没法做优雅的提示只能弹出会话超时之类的兜底文案。所以从系统工程的角度上下文满了不应该是一个必然的终点而应该是一个可干预的状态。Context Eviction上下文淘汰的思路就是在上下文逼近上限时主动删除、压缩或降级一些不那么关键的历史信息把有限的空间让给当前任务更需要的内容。这个思路很像操作系统的页面置换也像你整理桌面文件太多放不下你不会把整台电脑重启而是挑出最常用的留下把不常用的归档到压缩包里。LLM 上下文也一样历史信息对当前回答的贡献度并不均匀总有一部分是可以牺牲、压缩或者延后访问的。这套机制做得好用户的感知是这个机器人好像一直记得我说过什么尽管实际上你已经在内部悄悄淘汰掉了很多旧内容。2. 五种淘汰策略的原理与实现细节我最近在模拟项目 X 里完整对比了五类上下文淘汰策略。它们不是互相替代的关系而是对应不同约束条件和业务目标。下面我把每种策略的实现逻辑和关键代码都放出来你可以直接照着做。2.1 截断式淘汰从最旧处一刀切截断式淘汰是最朴素的做法当 token 总量超过窗口上限时从最早的 token 开始丢一直丢到剩余内容在窗口内为止。class TruncateEviction: def __init__(self, max_tokens: int): self.max_tokens max_tokens def evict(self, messages: list[dict]) - list[dict]: total 0 keep_end len(messages) for i in range(len(messages) - 1, -1, -1): total len(messages[i][content]) if total self.max_tokens: keep_end i 1 break return messages[keep_end:]这个做法的核心逻辑是从尾部往回数够放多少放多少。实现上要注意按 token 数而不是按消息条数来算因为用户一条消息可能长达上千 token。它的优点非常明显实现成本低、执行速度 O(n)、无额外推理开销。缺点是信息丢失不可控——你不能决定丢哪些内容只能依赖越早的信息越不重要这个朴素假设。我在实际项目里发现截断式淘汰在轮次较少比如 6 轮以内时效果还行但一旦业务需要依赖早期用户描述比如用户在第 2 轮说过我对花生过敏到第 12 轮你问吃什么时就会出事。系统丢掉了过敏信息给出的建议可能直接不适合用户。2.2 滑动窗口式淘汰固定窗格滚动滑动窗口其实是对截断式的一种改良。它不是等上下文塞满了才批量删除而是在每次请求前只保留最近 N 条消息或者最近 M 个 token 的内容。class SlidingWindowEviction: def __init__(self, window_messages: int): self.window_messages window_messages def evict(self, messages: list[dict]) - list[dict]: window messages[-self.window_messages:] return window这里的核心区别在于语义截断式淘汰是满了再丢滑动窗口是永远只留固定大小。在生产环境中滑动窗口更容易控制规则的确定性。比如你规定外部语言模型的历史只能取最近 8 轮那每次请求发过去的 prompt 长度是稳定的响应时延波动就小。缺点是窗口内的固定边界会在某些场景显得僵硬用户第 1 轮喂给你的是一整份 3000 字的项目背景第 2 轮开始逐条提问滑动窗口滚动后背景细节很快就被挤出去了。所以我一般建议滑动窗口做基础但窗口大小的设定要和业务场景绑定。不是越大越好需要实测。我自己的经验是客服场景窗口设 8 到 12 轮比较合适代码生成场景 6 到 8 轮就够——因为代码上下文里重复性的工具函数会占很多空间。2.3 时间衰减淘汰按消息新鲜度做权值时间衰减比滑动窗口聪明的地方在于它不是一刀切保留最近而是给每条消息打一个新鲜度分数旧消息只要足够关键也能留在上下文里。实现思路很直接每条消息都带时间戳系统按当前时间计算它们的有效价值价值低于阈值就淘汰。import time class TimeDecayEviction: def __init__(self, max_tokens: int, half_life_seconds: float): self.max_tokens max_tokens self.half_life_seconds half_life_seconds self.threshold 0.1 # 低于此分值的消息优先淘汰 def _score(self, msg: dict, now: float) - float: age now - msg[timestamp] return 0.5 ** (age / self.half_life_seconds) def evict(self, messages: list[dict]) - list[dict]: now time.time() while self._used_tokens(messages) self.max_tokens: oldest_lowest min(messages, keylambda m: self._score(m, now)) messages.remove(oldest_lowest) return messages这里有一个关键参数半衰期 half_life_seconds。它表示一条消息的价值减半所需的时间。半衰期太短会导致所有早一点的消息都被快速清出和滑动窗口效果差不多半衰期太长则淘汰力度不够上下文还是会撑爆。在我测试的某跨平台系统里半衰期设为 300 秒5 分钟比较合适。用户在短时间内连续提问时旧消息还活着如果中间停顿了 10 分钟再回来提问之前的消息大部分会被淘汰系统就表现出轻微失忆——这在很多非严格对话场景中是可以接受的。时间衰减的弱点在于新鲜度并不等于重要度。用户在第 3 轮说的一句我只选北京的产品过 20 分钟后依然重要但时间衰减可能已经把它清掉了。所以这种策略适合实时性要求高的场景比如调度系统、信息播报不适合强偏好记忆场景。2.4 相关性打分淘汰信息熵与任务关联度相关性打分是工程上最常用、也最灵活的方案。它的核心是为每条历史消息计算一个与当前任务的相关度得分每次淘汰得分最低的那条直到上下文符合大小限制。打分可以设计成多种信号的加权组合关键词重合度历史消息里出现的实体词和当前问题有哪些重叠时间新鲜度近期消息加分信息熵包含独特信息量大的消息例如用户明确说了一个数字条件加分任务阶段如果当前是在 Agent 的工具调用阶段那么工具返回的结果比早期的闲聊更值得保留。class RelevanceEviction: def __init__(self, max_tokens: int, weights: dict): self.max_tokens max_tokens self.weights weights self.threshold 0.2 def _score(self, msg: dict, current_query: str) - float: overlap len(set(msg[keywords]) set(current_query.split())) info_value msg.get(info_entropy, 0.0) recency 1.0 / (1.0 (time.time() - msg[timestamp]) / 60) return ( overlap * self.weights.get(overlap, 1.0) info_value * self.weights.get(entropy, 1.0) recency * self.weights.get(recency, 0.5) ) def evict(self, messages: list[dict], query: str) - list[dict]: while self._used_tokens(messages) self.max_tokens: lowest min(messages, keylambda m: self._score(m, query)) messages.remove(lowest) return messages这种方案最大的优点是可控。你随时可以通过调整权重改变系统更看重新鲜度还是更看重关键信息的行为。但它的工程成本也是最高的。每轮对话都需要保存额外元数据比如关键词列表、信息熵预估值打分过程本身 O(n) 遍历在上下文有几百条消息时会产生几毫秒延迟可以做缓存或批量计算。如果一个项目只能选一种策略又不确定选什么我个人会首选相关性打分。它虽然在实现上重一些但为后续优化留出了最大的调整空间。2.5 摘要压缩淘汰把旧内容折叠成高密度摘要摘要压缩和前四种完全不在一个维度。它不丢弃内容而是把一段旧内容交给小型语言模型或抽取式算法生成一段长度缩减很多但仍保留关键信息的摘要然后把原文替换成摘要。这是我在生产环境中最看好的方案。原因是它真正解决了重要但不新鲜的信息保存问题用户 60 分钟前说的过敏史可以被压缩成用户对花生过敏占用 8 个 token而不是原文的 150 个 token。摘要压缩的经典实现是分层摘要class SummaryEviction: def __init__(self, max_tokens: int, summarizer): self.max_tokens max_tokens self.summarizer summarizer self.summary_depth {} def evict(self, messages: list[dict]) - list[dict]: block messages[: min(len(messages), 64)] summary self.summarizer.summarize(block) messages messages[len(block):] [{role: summary, content: summary}] return messages实际项目中我不会一次性压缩全部而是把会话按时间切块块内先做首层摘要随着对话继续多个首层摘要再合并成二层摘要。这就是所谓的多级摘要树。它的缺点是摘要本身需要一次模型调用耗时会额外增加 1 到 3 秒如果要摘要的块很大token 开销也不低。而且摘要是有损压缩某些细节会被模型主观决定丢弃。如果摘要失真后面的回答就会出现记忆偏差。不过我的实测结果是摘要压缩带来的长期记忆连续性是其他四种策略完全比不上的。它特别适合长对话、长文档分析这类场景。2.6 五类策略的定位差异一张表看清策略淘汰粒度是否需要额外模型调用实现复杂度信息保留能力典型场景截断式消息级否极低弱快速原型、内部工具滑动窗口消息级否低较弱客服机器人、固定轮次交互时间衰减消息级否中中实时播报、调度类对话相关性打分消息级语义级否高强Agent 工具编排、复杂任务摘要压缩区块级是高最强长对话、长文档、会议纪要3. 生产环境实测五策略的量化对比策略不能只看实现还得用数据说话。我把五种策略分别接入了模拟项目 X一个多轮客服 Agent和某跨平台系统一个任务编排平台跑了 20 天线上流量回放测试。测试集是 5000 条真实脱敏对话覆盖了短咨询、长订单咨询、多轮退换货等典型场景。3.1 测试指标和对照设置我关心的指标有五个任务完成率用户是否在对话中成功办成目标事件比如下单选座、发起退款、查询到结果上下文超限率抛错或者触发硬截断的比例平均响应时延从请求发出到首 token 返回的时间信息回捞成功率对话第 10 轮以后还能不能正确想起第 3 轮前提到的关键约束人工评估满意度抽样 200 条会话由 3 名标注员按 1 到 5 分打分。对照组是不做任何处理、直接在超限时报错的原始系统。3.2 关键数据哪些指标真的被改善了策略任务完成率上下文超限率平均时延(ms)信息回捞成功率满意度(分)原始报错62.3%41.8%118035.6%2.4截断式74.7%6.2%96052.1%3.1滑动窗口76.1%4.5%94049.8%3.2时间衰减77.6%5.1%95560.3%3.4相关性打分82.4%1.8%98571.2%3.8摘要压缩84.9%0.9%121083.7%4.2几个值得注意的细节任务完成率方面摘要压缩最高但并非每个场景都该选它。摘要压缩的时延明显偏高比截断式多了 250ms 左右这在实时交互中很致命。信息回捞成功率这个指标相关性打分和摘要压缩明显拉开差距。原因很好理解因为它们都主动保留了关键信息截断式和滑动窗口则会把老内容直接切成碎片第 10 轮之后基本找不回早期约束。超限率方面所有淘汰策略都能把硬报错压到 6% 以下但相关性打分和摘要压缩更稳因为它们在删除前做了语义层面判断不会出现删完又满的震荡。3.3 数据背后的实际原因对比里最反直觉的发现是滑动窗口的任务完成率并没有比截断式高太多只有 76.1% 对 74.7%。我翻了几十条失败 Case发现滑动窗口丢失用户偏好的速度和截断式一样快因为只要窗口滚动出最近 8 轮早期偏好就被清掉了。而相关性打分虽然平均时延比截断式略高但任务完成率领先 7.7 个百分点。多出来的那二三十毫秒换来的是后期不再反复要用户澄清整体反而把总耗时拉短了。摘要压缩在时延上最难看但满意度最高。标注员的评价是它记得用户之前说过的细节哪怕过了很多轮。这种记忆连续性带来的好感远大于多等 200ms 的代价。我也测试过混合策略相关性打分 二级摘要任务完成率超过单一策略达到 86.3%时延比纯摘要压缩更低是我目前认为的最优生产配置。4. 工程落地要点元数据、索引与调度时机纸上谈兵没有意义真上线之后你会遇到一堆实现层面的问题。下面这三个点是我认为决定淘汰机制能不能长期稳定跑下去的关键。4.1 淘汰不能盲删先给每条消息建元数据档案不管用哪种淘汰策略上下文里的每条消息都不应该只是一段纯文本还应该有伴随的元数据。我在项目里给每条消息建立了如下结构message_metadata { message_id: uuid, role: user | assistant | tool, content: 原始文本, token_count: 248, timestamp: 1678901234.56, keywords: [退款, 订单号, 北京], info_entropy: 0.72, source: direct_input | rag_retrieval | tool_result, summary_ref: None }这些字段里最重要的是 info_entropy。它表示这条消息携带的信息量大小比如好的谢谢这类客套话熵值极低用户要求退款且发货地址在北京这类约束性语句熵值很高。淘汰排序时优先删除低熵消息可以大幅降低误删关键信息的概率。信息熵的估算不需要跑模型可以直接用实体数量和条件句长度做近似比如entropy ≈ 0.6 * num_entities 0.4 * len(important_clauses)成本极低。4.2 索引结构直接数组最省事但批量淘汰时很别扭如果你把上下文消息存在一个普通数组里每次淘汰都要扫描全表、删中间元素、再重建数组复杂度 O(n²) 都可能。生产环境消息量大了之后我会建议做分块索引把连续 16 条消息组成一个 blockblock 内维护聚合统计值token 总数、平均信息熵、最新时间戳。淘汰时先在 block 层面决定删哪些 block 或压缩哪些 block再对选中 block 内部做逐条处理。这样复杂度从 O(n) 降到 O(n/m)m 就是 block 大小。我实际测试过分块大小为 8 到 16 时效果较好。块太大淘汰精度下降——可能为了删掉一条旧消息被迫压缩了一整块有用的内容块太小又回到逐条扫描的老问题。4.3 调度时机别等满了才动手有一个很常见的错误等消息总量达到窗口上限才触发淘汰。我在生产里发现这样会带来两个问题超限前的最后一次请求已经可能超过模型限制直接导致报错如果一次请求同时命中多个淘汰操作单次响应时延会突然飙高时延波动大。标准做法是设置高水位和低水位两个阈值。比如窗口上限 8000 token高水位设 7000低水位设 5000。当消息总量超过 7000 时触发淘汰一直删到 5000 为止再停止。这中间留出的 2000 token 冗余空间用来吸收突发流量避免频繁淘汰。4.4 和 RAG 管道的联动检索结果也是上下文的一部分实践中有个很多人没考虑到的地方RAG 检索回来的文档片段也占上下文空间。它们同样面临淘汰问题而且淘汰它们的逻辑应该和普通对话消息不一样。检索片段的价值取决于与当前问题的相关度而不取决于时间新不新鲜。所以我给 RAG 片段单独设置了一个分数相关度得分高于阈值的片段即使很早就插进上下文也不淘汰低于阈值的一轮对话后直接丢弃。这么做的好处是避免模型看到一段长篇文档后因为上下文压力把它删掉结果下一步 Agent 需要引用它时找不到了。5. 踩坑记录与调优经验写到这里必须分享一些真实踩过的坑。每个坑我都花了不少时间来排查这里直接给你结论省得你再走弯路。5.1 坑一淘汰后模型失忆生成质量断崖下跌问题现象上线时间衰减淘汰后任务完成率反而比截断式低了 2 个百分点。排查过程我导出了失败会话的上下文快照发现时间衰减在用户沉默 5 分钟后把所有早期消息的分值都压到了阈值以下包括用户在第 2 轮明确说的不要推荐含咖啡因的饮品。系统在第 8 轮给出咖啡推荐用户直接给差评。解决办法把时间衰减阈值从 0.1 提到 0.3并对keywords中包含否定词 实体的消息做保护这类消息不参与时间衰减淘汰除非相关性打分示明确实无关。上线后满意度回升。这个坑的本质是新鲜度指标和关键信息保真度之间存在天然矛盾需要在两者之间加一个明确规则否则系统会在你的用户最需要记忆的时候失忆。5.2 坑二元数据不同步并发读写导致上下文错乱问题现象系统接入两个并发请求处理同一个用户会话时偶发出现用户最后一句是空的角色混乱这类脏数据。排查过程我追到一个关键细节淘汰模块在删消息的同时会话管理模块还在基于旧索引往上下文里追加新消息两个操作交错执行导致消息索引错位。解决办法对会话上下文加读写锁或者在追加和淘汰时走同一个串行事件队列。我在实现里选择了后者——所有上下文变更都变成事件流按顺序执行彻底避免了并发交叉修改。5.3 坑三摘要压缩的递归失真越压越离谱问题现象摘要压缩一开始效果很好但对话超过 40 轮后模型对用户原始诉求的理解渐渐走偏了。排查过程我检查了摘要树发现二级摘要是在一级摘要基础上生成的一级摘要里的某个小细节比如订单号是 abc123在二级摘要里被合并成了某个订单到第三级摘要时订单也没了。多层摘要层层丢失信息最后模型根本不知道用户在讨论哪个订单。解决办法将摘要树由纯粹压缩改成压缩 关键实体保真表。每次做摘要时把原文里的订单号、人名、日期、金额等关键实体单独提取出来附加在摘要后面。这样即使叙述性文本被压缩到很小关键实体也不会丢。5.4 坑四窗口太小幻觉率显著上升问题现象为了提升响应速度我把滑动窗口从 12 轮压到 6 轮结果标注员反馈模型经常一本正经地乱说。排查过程对比两种配置下生成的回答发现窗口缩小后模型缺乏足够历史依据经常用猜测填补信息空缺幻觉率从 8% 升到 19%。解决办法窗口不能只看速度和成本至少要保证关键约束信息能够在窗口内存活。我用相关性打分替代固定窗口后幻觉率降回 9%速度只多了 30ms。5.5 调参经验速查表参数建议范围参考设定说明高水位窗口上限的 75%-90%8000 窗口取 7000太高容易超限太低触发太频繁低水位窗口上限的 50%-65%8000 窗口取 5000与高水位的间隔决定冗余缓冲半衰期300-900 秒客服场景 600 秒交互密集场景可缩短相关性保护实体数每个 block 不超过 8 个4 个防止实体表过长占空间摘要块大小48-96 条消息64 条块太小摘要频繁块太大摘要成本高6. 选型建议结合具体场景的决策路径五个策略各有优点我最想避免的是你用错场景。根据我目前的实战经验选型时主要看你业务的三件事对话长度、信息时限敏感度、时延预算。6.1 不同场景的直接推荐如果你做的是短对话客服比如 6 轮内解决问题滑动窗口或截断式就够完全没必要上摘要压缩省心又便宜。如果你做的是长对话产品比如 AI 导师、长文分析、项目咨询摘要压缩几乎是必须的否则模型会在后半程严重失忆。可以考虑相关性打分定位 摘要压缩存储的混合方案先用相关性决定哪些块要压再用摘要把块折叠起来。如果你做的是 Agent 工具调用相关性打分优先。Agent 的核心是任务衔接工具结果、约束条件必须优先保留。时间衰减会误伤早期的任务指令滑动窗口则又可能在多步工具调用中把状态信息滚掉。如果你做的是实时播报类比如股票助手、天气助手时间衰减最合适——旧的信息本来就应该快速过期重要的是此刻的状态。6.2 混合升级我推荐的分层淘汰架构单一策略总会有盲区我目前的线上架构是三层组合第一层相关性打分负责精确驱逐决定谁该留下、谁该走第二层对犹豫区的消息做摘要折叠不直接删而是压成高密度信息块第三层用关键实体保真表保护订单号、姓名、日期等强约束内容。这个架构实现起来稍微复杂但它兼顾了精度、速度和记忆连续性。我一直认为上下文淘汰没有银弹每个项目都应该根据数据反馈不断调整组合。6.3 上线淘汰策略之后你还需要做这件事不要改完策略就不管了。我强烈建议保留一个淘汰日志记录每轮删掉了什么、压缩了什么、模型回答有没有偏离。这个日志可以做成异步落库不影响线上主链路。有了淘汰日志你才能在用户投诉模型不记得我说过什么时快速定位是哪个环节把关键信息丢掉的。我在几次排障中都是靠这个日志半小时内找到问题否则根本无从下手。最后再分享一个个人经验如果你还在用原始的报错方案尽快改成淘汰机制即使是最朴素的截断式也比直接报错好一个量级。等流程跑顺之后再逐步升级到摘要压缩和相关性打分不要一开始就想搞一个完美架构——生产系统的稳定性永远是先有能用再有好用。