☰
Context Eviction上下文淘汰:五种LLM记忆管理策略与工程实践
2026/10/20 5:59:24 网站建设 项目流程

去年秋天我维护的一个智能客服项目,因为上下文溢出问题被用户投诉了整整一周。用户连续追问几个问题之后,对话系统直接抛错,整个会话作废,客户再点一次"继续咨询",机器人已经不记得之前聊过什么了。当时我的第一反应是加大模型上下文窗口的配置,但很快发现这不只是窗口大小的问题——只要对话轮次一多,任何固定窗口都会被塞满。后来我把精力转向了上下文淘汰机制,也就是标题里说的 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, key=lambda 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, key=lambda 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 条消息组成一个 block,block 内维护聚合统计值(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 上线淘汰策略之后,你还需要做这件事

不要改完策略就不管了。我强烈建议保留一个"淘汰日志",记录每轮删掉了什么、压缩了什么、模型回答有没有偏离。这个日志可以做成异步落库,不影响线上主链路。

有了淘汰日志,你才能在用户投诉"模型不记得我说过什么"时,快速定位是哪个环节把关键信息丢掉的。我在几次排障中都是靠这个日志半小时内找到问题,否则根本无从下手。

最后再分享一个个人经验:如果你还在用原始的报错方案,尽快改成淘汰机制,即使是最朴素的截断式也比直接报错好一个量级。等流程跑顺之后,再逐步升级到摘要压缩和相关性打分,不要一开始就想搞一个完美架构——生产系统的稳定性,永远是先有能用,再有好用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询