去年我做了一个 AI Agent,能跑几十轮对话完成任务。前 10 轮惊艳,第 50 轮开始犯蠢,第 80 轮已经没法看了。
不是模型变笨了。是我塞了太多历史进去,把模型淹没了。
这篇文章想聊一个反直觉的事:AI Agent 的记忆不是越多越好,学会「遗忘」才是关键。
上下文窗口不是硬盘,是桌面
我跟很多开发者聊过这个问题,大部分人的第一反应是:把所有历史都塞进上下文不就行了?
这个思路错在哪?错在把上下文窗口当成了硬盘。
上下文窗口是工作台,不是档案室。模型推理的时候,所有信息都摊在这个台子上。台子就那么大,东西堆多了,重要的资料就被埋住了。
我见过最夸张的情况:一个 agent 跑了 200 多轮,上下文里塞了 12 万 token 的历史记录。模型把 80% 的推理能力花在"从一大堆文字里找到有用的那几句"上,真正干活的脑力所剩无几。
你可以做个简单实验:给 Claude 或 GPT 一个 10 轮对话的历史,准确率可能是 95%。把同样的任务放到 100 轮对话的历史里,准确率能降到 70% 以下。不是模型变差了,是噪音太多。
遗忘的三种方式
先说清楚三件事,因为很多人把它们混为一谈。
清除(Clearing)
/clear 一下,历史全没了。干净利落,但代价也大。切换任务时用可以,中途来一下,agent 会断片。
截断(Trimming)
删掉最早的消息,保留最近的。简单、快、便宜。但它的问题也很明显:最早的那条消息,可能恰恰是整个任务的关键决策。
压缩(Compaction)
用模型把大量历史改写成一个更短的摘要,保留精髓,丢掉细节。这是这篇想重点聊的。
它们的区别可以用一个例子说明:
Agent 帮用户排查了一个数据库连接问题,花了 20 轮才定位到是 SSL 证书过期。Trimming 会删掉前面的 18 轮——包括"SSL 证书过期"这个核心结论。Clearing 会全删——包括用户刚刚提到的"还需要检查 Redis 连接"。Compaction 会把 20 轮对话压缩成一句:"用户已排查并修复 SSL 证书过期问题,现在需要检查 Redis 连接。"
哪一种保留的信息更有用,一目了然。
压缩的四个坑
Compaction 听起来简单——"加个 summarize 不就好了?"——但实际做起来全是坑。
Drew Breunig 对长上下文退化有一个简洁的分类,而这四种退化都可能因为粗糙的压缩被引入:
1. 毒化(Poisoning)
压缩时模型产生了幻觉,把错误信息写进了摘要。然后这个摘要又被当成事实依据,被后续的推理使用。
我见过一个真实案例:Agent 在早期对话中猜测"可能是数据库连接池满了",后来验证发现是索引问题。但压缩时,模型把"可能是数据库连接池满了"这句话留了下来。结果 30 轮之后,agent 还在围绕"连接池满了"做排查。
应对:只压缩"确定的信息",把推测和假设分开处理。
2. 膨胀(Distraction)
压缩完和原来差不多长——恭喜你,白干了。
这通常是因为压缩时保留了太多"可能有用"的信息。50K 的对话压缩到 48K,没意义。
应对:给压缩设定明确的长度目标,比如"压缩到不超过原始长度的 20%"。
3. 混乱(Confusion)
摘要里塞了太多不必要的细节,把模型的注意力引向无关的方向。
比如一个压缩器认为"用户在第 5 轮提到过他用的是 MacBook Air"很重要(其实跟当前任务完全无关),结果每次推理时模型都会分心考虑"MacBook Air 有没有兼容性问题"。
应对:压缩时基于当前任务目标做筛选,不是做全量摘要。
4. 冲突(Clash)
摘要和最新消息矛盾了,模型需要在两个"事实"之间做选择。
常见场景:Agent 早期决定要实现方案 A,做了大半。用户中途改主意要换方案 B。老摘要里全是"方案 A 的进展",新消息里是"用方案 B 重新开始"。模型夹在中间纠结。
应对:压缩时标注"已废弃"、"已被覆盖"的信息,优先采用最新的指令。
最大的坑:累积侵蚀
上面四个问题,在单次压缩里问题不大。但如果你反复压缩,问题就来了。
第一次压缩损失 20% 的信息,第二次损失 20%,第三次再损失 20%。三轮下来,原始信息的有效内容只剩一半。十轮之后,agent 在跟自己玩"传话游戏"——原始意图已经严重失真。
这不是理论推演,这是我的 agent 项目真实踩过的坑。跑了几天之后回头看日志,发现 agent 已经在执行一个完全变了形的任务。原因很简单:每一次 compaction 都是 lossy 的,我把它凑合用了 50 次。
实战:我实现的四种遗忘策略
下面是我在自己项目中验证过的几种方案,各有各的适用场景。
策略一:滑动窗口 + 渐进压缩
最简单的实现方式。维护一个消息队列:
原始窗口:最近的 10 轮完整消息
压缩区域:更早的消息压缩成摘要
每次新消息进来,检查窗口大小。如果超过阈值,把最老的一轮消息移出窗口,加入到待压缩区。待压缩区积累到一定量后,进行一次压缩。
class SlidingWindowMemory:
def __init__(self, window_size=10, compression_threshold=20):
self.recent = [] # 原始消息窗口
self.summary = "" # 压缩后的历史摘要
self.buffer = [] # 待压缩的消息
self.window_size = window_size
self.compression_threshold = compression_threshold
def add(self, message):
self.recent.append(message)
self.buffer.append(message)
# 窗口超出,压缩
if len(self.recent) > self.window_size:
moved = self.recent.pop(0)
# 缓冲区积攒足够,执行压缩
if len(self.buffer) >= self.compression_threshold:
self._compact()
def _compact(self):
# 用 LLM 把 buffer 里的消息压缩成摘要
compressed = llm.summarize(self.buffer)
# 合并到已有的摘要中
self.summary = llm.merge_summaries(self.summary, compressed)
self.buffer = []
def get_context(self):
return {
"history_summary": self.summary,
"recent_messages": self.recent
}
优点是实现简单,开销可控。缺点是无法避免累积侵蚀。建议设置最大压缩轮数,超过就做一次"全量回顾"来修正失真。
策略二:语义剪枝
不让模型"总结"历史,而是让模型"判断每段信息是否还有用"。
def semantic_prune(messages, current_task):
"""判断每条历史消息是否与当前任务相关"""
for msg in messages:
relevance = llm.judge(
f"这条消息与当前任务 '{current_task}' 的相关性(0-10):\n{msg}"
)
if relevance < 3:
messages.remove(msg) # 直接丢弃
return messages
比起全量压缩,语义剪枝更轻量——因为不需要生成新内容,只是做二元判断。适合那些历史消息量大但大部分已经没用的场景。
缺点是每次调用都有模型开销。另外如果任务上下文发生了变化,之前被认为"不相关"的信息可能又重新变得重要——所以别删太狠,考虑用"归档"而不是"丢弃"。
策略三:基于遗忘曲线的记忆衰减
人脑的遗忘是有规律的——我们不会均匀地忘记所有事情。刚发生的记得清楚,久了就模糊。艾宾浩斯遗忘曲线描述的就是这个规律。
把这个思路用到 agent 上:
class DecayMemory:
def __init__(self):
self.memories = [] # (content, timestamp, importance)
def add_memory(self, content, importance=0.5):
self.memories.append({
"content": content,
"timestamp": time.now(),
"importance": importance,
"access_count": 0
})
def recall(self, query, threshold=0.3):
results = []
for m in self.memories:
# 计算记忆强度
hours_elapsed = (time.now() - m["timestamp"]).hours
# 重要性越高的记忆,衰减越慢
strength = m["importance"] * 0.9 ** (hours_elapsed / (24 * m["importance"]))
# 被访问的次数越多,记忆越强
strength *= (1 + 0.1 * m["access_count"])
if strength > threshold:
results.append(m)
return results
def access_memory(self, memory):
memory["access_count"] += 1
memory["timestamp"] = time.now() # 重新复习,延长记忆
这个实现最接近人类的记忆方式:重要的记得久,频繁访问的记得牢,长期不用的慢慢淡出。
但它的缺点是依赖人工标注重要性。我的做法是让模型在每次对话结束时自动标注:"本轮对话中的关键信息(1-10分)"。关键信息包括:用户明确的决策、配置参数、错误根因等。
策略四:主动遗忘
以上三种都是被动遗忘——等到记忆快满了再处理。主动遗忘是在执行过程中就让 agent 自己判断"这个信息值不值得记住"。
在每条消息进入记忆系统之前,agent 先问自己三个问题:
- 这条信息未来的任务还会用到吗?
- 它是一个决策还是一个临时状态?
- 如果丢了它,最坏后果是什么?
def active_filter(message, context):
"""在消息进入记忆前就做筛选"""
assessment = llm.analyze(
f"评估这条信息是否值得长期记住:\n"
f"消息:{message}\n"
f"当前任务上下文:{context}\n"
f"请返回:[记住/丢弃/暂存] + 理由"
)
return assessment
主动遗忘是最聪明的策略,因为它让 agent 有了"元认知"——意识到自己在记住什么、为什么记住。但这样做的代价是每次消息都多了一次 LLM 调用。
我的使用经验是:只在关键决策点开启主动遗忘过滤。比如用户修改配置、改变策略方向、确认问题根因这些时刻。日常对话的零碎信息走滑动窗口就行。
混合策略:我的推荐配置
实际项目中,我通常组合使用以上策略:
| 层级 | 策略 | 容量 | 成本 |
|---|---|---|---|
| 短期 | 滑动窗口(最近 10 轮) | ~5K tokens | 0 |
| 中期 | 语义剪枝(每 5 轮清理一次) | ~10K tokens | 低 |
| 长期 | 遗忘曲线衰减 | ~50K tokens | 中 |
| 关键点 | 主动遗忘 | ~1K tokens | 高,只关健时刻 |
每次 agent 构建上下文时,按这个优先级组装:
def build_memory_context(agent):
context = []
# 1. 关键记忆(主动遗忘策略保留的)
key_memories = agent.key_memories.recall(query=agent.current_task)
context.append(format_section("关键信息", key_memories))
# 2. 长期记忆(衰减后仍保留的)
long_term = agent.decay_memory.recall(query=agent.current_task)
if long_term:
context.append(format_section("历史信息", long_term))
# 3. 短期记忆(最近的对话)
recent = agent.sliding_window.get_recent(10)
context.append(format_section("最近对话", recent))
return "\n".join(context)
踩坑总结
最后分享几个我在记忆系统上踩过的坑:
坑 1:摘要里混入了代码语境
压缩器把一段代码示例压缩成了"用户讨论了一个函数实现",结果 agent 完全丢失了代码细节。后面需要复用这段代码时,agent 只能重新写一遍——大概率写得不一样。
解决:代码片段不压缩,只压缩自然语言讨论。代码改成引用 (reference) 模式。
坑 2:忘了保留"否决"记录
Agent 和用户讨论过方案 A,最终决定用方案 B。压缩后只保留了"使用方案 B",没保留"否决方案 A"。过了几轮,agent 又自己提出方案 A,浪费了十多轮对话。
解决:额外保留一个"已否决"列表,每次 proposal 先查一下。
坑 3:记忆系统本身成了瓶颈
记忆系统太复杂了——又要衰减、又要剪枝、又要主动过滤。每次构建上下文要调用 3-4 次 LLM,延迟增加了 5 秒。
解决:把记忆系统拆成同步和异步两部分。同步部分只做最轻量的滑动窗口,异步部分在后台做压缩、衰减、主动遗忘,下次对话时再生效。
最后
回到题目:为什么「记得少」比「记得多」重要?
因为 AI Agent 的上下文窗口是有限的工作台,不是无限容量的仓库。学会遗忘——有策略地、有选择地遗忘——不是能力缺陷,是设计智慧。
好的记忆系统,不是把所有东西都记住。而是在需要的时候,能想起该想起的东西。
这也是人脑工作的方式。你的大脑不会记住过去三个月的每一封邮件,但它知道现在该想什么。
所以,教你的 agent 忘记。教它忘记的分寸,就是教它记住的水平。