遗忘是人类的本能,却常被当成一个缺点。
如果一个人一直记着和前女友的惨痛分手,那他大概率干不好今天的工作。
没错,其实遗忘是一种能力。
心无旁骛是成事的关键,而遗忘,是心无旁骛的前提。
人之所以能专心做好眼前的事,靠的就是忘掉无关的细节。
这个道理,对 Agent 一样成立。
毕竟 Agent 的最终目的,是成为一个人;人身上的好特质,它都该有,包括遗忘。
Agent 的"脑海"就是上下文:它的记性、它的注意力、它此刻能想起的一切,都装在这扇窗口里。
这扇窗口的大小是固定的,聊得越久、干的活越多,它就越接近装满。这时候便开始健忘、敷衍、流口水。
让 agent 学会遗忘,是上下文工程里绕不开的环节。
一、失控:上下文是怎么被撑爆的
上下文里最占地方的,往往不是对话,而是工具调用。
一次文件读取、一条命令回显、一个 MCP 响应,动辄几万字符。一轮任务跑下来,十成上下文里九成是工具结果,这是经验之谈。
而它被撑爆的方式,其实只有两种。
- 第一种,单条过大——一条几万字符的工具结果,比如一次网页抓取、一次全量日志回显,自己就能吃掉大半预算。
- 第二种,全局超长——单条都不大,就是几十轮攒下来:沙粒堆成了沙丘。
两种撑爆方式,正面解法就两套:单条靠裁剪、全局靠摘要。下面按从小到大的顺序展开。
在动手之前,先交代一个让整套设计成立的底层原则:存储和请求分离。
对于会话历史里的事件来说,我们本地存储的事件和最终发送给大模型的事件,是分离的。会话的原始事件永久保存,只追加、不修改;每次发请求时,我们从原始事件拼出一份消息副本——这份副本,就是最终发给大模型的东西。所有的压缩、裁剪、摘要,都发生在副本上,发完即弃。换句话说,压掉的只是"这次请求带没带它",数据本身一直都在。
记住这点——后面所有大胆的机制,底气都在这里:我们从来不是真的删掉什么,而是挑着带,每次只带用得上的。
二、压缩:四层机制
入口:能不进,就不进
最彻底的一种"省",是根本不让它进来。
典型的例子,是那些"说明书"式的长文:一段完整的 skill 说明、一份 MCP 工具的 schema,动辄几百上千 token——但它们并不是时时刻刻都用得上。不少实现的做法是:平时只常驻一行"名字 + 一句话描述",先让模型知道"有这么个能力";等真需要它干活了,模型会调用一个专门的工具,把完整内容加载进来。
这套思路叫渐进式加载:上下文里常驻的只有"目录",细节按需展开。好处很直白——没被用到的,最多花一行;被用到的,才付它的全价。
委派:能外包,就外包
渐进式加载,省的是"常量"——说明书本身的体量。还有一类更要命的东西,是"过程":为了查清一件事,翻了一堆文件、读了一堆网页、跑了一串命令,最后真正有用的结论,可能就三行。
这些过程,值不值得在窗口里全程留档?不值得。
更省的做法是整件事打包派出去——交给一个 subagent 去干。它有自己的上下文窗口:脏活累活在它那边发生,读了多少、翻了多少,主窗口一概不沾;干完活,只往回收一份摘要。
于是主窗口从头到尾只收到一句"结论是 X",中间那几十次读取,一次都没进过门。
这么做,一半是为了省地方,另一半常被忽略——装得越满,越容易变笨。窗口里噪音一多,模型的注意力就被摊薄,越容易抓不住重点。哪怕还没装满,一身用不上的过程内容也是纯负担。所以一件事只要"过程冗杂、结论简单",就值得外包出去。
单条:先对工具结果动手
工具结果是重灾区,原因很朴素:它不由你控制。模型的输出再啰嗦也有边界,但一条 shell 命令的回显、一个网页抓取的响应,来了就是几万字符,一次就能把预算吃光。
各家做法的共同点:原文不丢,上下文里只放"引用"。
我们用的 Agent 框架(trpc-agent-go)里,工具结果的压缩有三个 Pass——都叫"压缩",干的活各不相同:
Pass 1:旧结果换成占位符。最近的几轮对话处在保护期里——正在被使用的内容,压缩不碰。滑出保护期之后,超过阈值的历史工具结果会被整个替换成一个约 40 token 的占位符——里面带着 event_id 和 tool_call_id,外加"怎么把原文捞回来"的指引。模型真需要原文时,可以自己调工具按 id 找回。原文没有丢,只是从"常驻"变成了"按需"。
Pass 2:超长结果掐头去尾。所有超过阈值的结果——包括本轮刚产生的,没有豁免——只保留开头和结尾,中间挖掉。为什么留头尾?头是定义和结构,尾是报错和结论,这两头信息密度最高;中间的"肉",往往可以不要。
万一真的需要中间那部分呢?没关系,完整的事件已经存下来了,模型可以调用专门的工具,按偏移量一块一块地读进来。
Pass 3:按工具点名。我们可以针对某些工具强制要求它必须裁剪,或者对某些重要的工具强制要求它不裁剪——点名"必清"的,结果一律压成占位符,不等它超限;点名"必留"的,压缩全程不碰它。哪些该忘、哪些必须留,在工具这一级就能定下来。
把视角拉远,同一件事各家有四种招式:
- Cursor——官方博客写得很直白:对第三方工具(shell、MCP)的大输出,不截断,直接写进临时文件,让 Agent 用
tail先看结尾、需要再读更多。原文一字不少,上下文里只有一个文件路径。按他们的说法,这"显著减少了不必要的摘要触发"; - LangChain——两层机制:
ClearToolUsesEdit把旧的工具输出清掉、只保留最近 N 条;另有"工具调用外置"(Tool Call Offloading),头尾留在上下文,全文写入文件系统,模型需要时自己读; - 字节 Eino——ADK 的
Reduction中间件,职责就一条:超长工具输出截断 / 写入文件系统,防止 token 超限; - Claude Code——每一轮 API 调用前做轻量清理:保留最近五条工具结果,其余统统替换成一句
[Old tool result content cleared]。不读文件、不留引用——最激进,也最省。
看下来只有四招:留引用、留文件、留头尾、留最近。招数不同,方向一致——让大结果从"一直背着",变成"放在仓库,要用再取"。
全局:摘要
工具结果压完、裁剪备好,还剩最后一头大象:会话历史本身。聊得越久,历史越大,直到逼近窗口。这时候唯一优雅的出路是摘要——把旧对话交给模型,压成一段更短的话,让历史轻装继续。
摘要是四层里最重的一块:什么时候压、写什么、谁来写、压丢了怎么办,都是实打实的工程决策。下一章,单独拆开讲。
三、摘要:时机、内容、谁来做
什么时候压
摘要不是免费的——它要重写历史,而重写历史意味着前缀缓存全废。所以它应该像一笔大开销:不轻易花,要花就一次花到位——而不是动不动就来一次。
触发线要卡在"确实快满了"的位置——别真贴着窗口边画。我们的线是输入空间的 80%;Claude Code 则给窗口留了两级安全垫:压缩线画在"减 13000"处,警告线再往前 2 万。道理不难懂:估算是会抖的(下文会提到),而摘要请求自己也要发得出去、装得下。
当然,如果某些模型能力比较拉,在上下文窗口还没有特别紧张的时候,就已经开始出现大量的幻觉和失忆,开始流口水了。那么就别再拖延,早压缩早解脱。
这需要对特定的模型型号有深入的研究,属于精细化调优的范畴。
写什么
摘要的质量,主要取决于给模型的模板。目前见过最清爽的版本来自 OpenCode——把"该保留什么"拆成六个问题:
- 做了什么;
- 当前正在做什么;
- 改了哪些文件;
- 下一步要做什么;
- 用户的关键要求与约束;
- 重要的技术决策,以及为什么。
Claude Code 的模板更细,九段结构——意图、技术概念、涉及文件、错误与修复、所有用户消息、待办、当前工作……还有个细节:让模型先把对话推理一遍再落笔,这段推理草稿最后会被丢掉,只为提高摘要的质量。
Codex CLI 则是最轻的一层:单层的"交接摘要"(handoff summary)——提示词开门见山:"给接棒的另一个 LLM 写一份交接摘要",点名四件事:进展与决策、约束与偏好、还剩的活、关键数据;生成后附上一段固定前缀,作为新上下文的开场。
横向看,所有好模板都在保留同一类东西:决策、理由、约束、下一步。可以放心丢的,是过程细节——探索性讨论、已经解决的调试过程,这些留在历史里只会占地方。
怎么压
比较好的姿势是异步:每轮收尾时把摘要任务丢进后台队列,用户毫无感知;同时留一条同步兜底路径,在真正发请求前检查一次,超了就就地补一刀。
那么,让哪张嘴来写摘要?
- Cursor:用一个比主模型更小更快的 flash 模型——总结这种活,不需要贵模型;
- Claude Code:反着来,用同一个主模型,但复用完全相同的系统提示词和消息前缀,只把"请你总结"追加在末尾——为的是命中缓存。按源码分析的实测,另起一套提示词会有 98% 的缓存 Miss,代价巨大;
- Codex:也不换模型——就地交给它自己写。
用哪个模型总结,是一场成本与质量的权衡。提醒一句:总结本身也要烧钱——这正是"尽量推迟"的原因。
还有一个工程细节:好的摘要是增量式的——"旧摘要 + 新增量"合并,而不是每次从头重读整段历史——压缩模型本身也有窗口上限,历史太长,它根本读不完。
留多少
压完摘要,不要只留摘要。保留最近几轮原文几乎是标配:
- LangChain:保留的最近消息数是可配项;
- OpenCode:给"最近的上下文"单独留 token 预算;
- Claude Code:尾部保留最近一段消息原文——切点选在用户消息处,不切散工具调用;压完还会把最近读过的文件重新注入,防止失忆。
道理很简单:刚发生的事,模型正在使用中——摘要得再好,也不如原文准。保留最近几轮,模型会显得"聪明一点",说的其实就是这件事。
另一个容易忽略的细节:摘要自己也要有长度上限。不然滚动几轮之后,摘要自己滚成了新的大块上下文——压缩了个寂寞。
压丢了怎么办
回到那个底层原则:存储和请求分离。原始事件一直在,压掉的只是"这次带没带"。从系统层面说,什么都不会真丢。
Cursor 甚至做了双保险:摘要生成之前,先把完整聊天记录写进文件。摘要负责高效,完整历史文件负责"万一想不起来还能查"。
正因为整体可逆,压缩才敢做得激进。
四、Token 估算:一把不太准的尺子
前面所有机制——压、裁、摘要——都建立在同一个数上:当前用了多少 token。这个数算得准不准,直接决定系统靠不靠谱。
估小了,以为还有余量、实际已经逼近上限,压缩迟迟不触发,直到请求被模型一巴掌打回来;估大了,明明还有空间却过早触发压缩——摘要重写历史、前缀缓存全废、白花一笔钱——两头都是麻烦。
而偏偏,这个数很难算准:分词器(tokenizer)在模型那一侧。GPT 一代一套词表,Claude 独有一套,Gemini、Qwen、DeepSeek 各切各的——你的 Agent 框架根本不知道对面到底怎么分。
面对这把"不太准的尺子",市面上的做法分成四个流派。
四个流派
流派一:不猜,交给服务端。把压缩直接搬进服务端——Anthropic 的服务端压缩(beta):请求里传一个 trigger_tokens,服务端用真实 token 数触发摘要——客户端连自己多重都不用知道,最小触发阈值 5 万 token。OpenCode 已经接上了这个协议。
OpenAI 也在试同一条路:Responses API 挂一个 compact_threshold,超了服务端自己动手压、压缩块随响应流一起回来;Codex CLI 的新版本还带了个实验性的上下文管理模式(默认关着,要手动开)。思路很直白:谁有分词器谁说了算,服务端有,就让服务端数。
流派二:锚点 + 增量——拿一个准确的数当锚,新增的部分再估。这是最主流的聪明做法,公式出奇地一致:
当前上下文 ≈ 最后一次 API 响应的精确 usage + 之后新增内容的估算(粗略字符比例)
Claude Code 里叫 tokenCountWithEstimation():找最近一条带 usage 的响应,取其精确计数,再加上后续消息的估算。字节 Eino 的默认计数器一模一样:最后一条 assistant 消息的 TotalTokens 做底,之后的增量按"4 字符 ≈ 1 token"估。
妙在哪?每一轮响应,都白送一个准确的数字——估算要管的,只剩"上次响应之后新增的那一点点",错了也错不远。这比每次全量精确分词便宜得多,也比纯估算准得多。
流派三:估算,但按内容类型调比例,宁可高估。同样是"字符 ÷ 4",算得讲究的会分类型:
- Claude Code:默认 4 字符 / token;JSON / JSONL 用 2——大括号引号密集,单字符 token 多,真实比例接近 2;图片 / PDF 固定按 2000,防止一张图的 base64 撑出天文数字,也防止低估;
- Eino:图片、音频、视频、文件统一按 2000 算;
- trpc-agent-go:按字符(rune)÷ 4 而不是字节 ÷ 4,比例本身可配。
Claude Code 源码里有两句注释,值得原样引用:一句是 "an underestimate can let an oversized tool result slip into the conversation"——低估会让超大工具结果溜进对话;另一句更直白:图片按 2000 算,是为了 "avoid underestimating and triggering auto-compact too late"——别因为低估,让自动压缩触发得太晚。
流派四:不折腾了,用一个固定比例,承认它不准。代表是 Cursor——官方在论坛亲口承认,界面里的 token 数是估算,"roughly characters divided by 4",而且对中文尤其不准。LangChain 默认借 tiktoken 分词,对非 OpenAI 模型提供 tiktoken_model_name 参数——借一个"最像的"已知模型名来分词;社区实测,拿 tiktoken 近似 Claude 或 Gemini,偏差能到 20-40%。OpenCode 的实现最直白:round(字符数 / 4)。
四个流派看完,规律很清楚:能拿到准确数就去拿;拿不到,就用"准确数 + 估算"打配合;从头到尾纯估算的,只够打底。
怎么才能算得更准
如果不想躺平,想让尺子更准,从强到弱有三个办法:
- 让官方帮你数——Anthropic 的
count_tokens、Bedrock 的 CountTokens、Vertex 的对应接口,按模型精确计数。Claude Code 首选它,还加了录制回放缓存,避免重复调用; - 按模型换词表——tiktoken 家里就分好几套词表:
o200k_base(GPT-4o / 5 系)、cl100k_base(GPT-4 系)、p50k_base(更老)。对接哪代模型,就挂哪套词表; - 用锚点自动修正——不知道对面怎么分词?没关系,用每轮响应带回来的准确数字纠偏(流派二)。
中文的坑
最后单独说中文,它值得。
"字符 ÷ 4"对英文和代码很合适,对中文却是低估得厉害:4 个汉字算 1 个 token,而中文的真实比例大约是 1 个字 ≈ 0.6~1 个 token。也就是说,对中文内容,这把尺子可能把体积量成实际的四分之一——"压缩迟迟不触发、请求忽然被拒"最典型的来源就是它。
Cursor 官方承认"对中文尤其不准",就是栽在这个坑里。
修法也简单:按语言分系数。trpc-agent-go 的估算器留了个参数——把"每 token 对应几个字符"从 4 调成 1~1.5,中文场景基本就对得上。或者干脆上"锚点 + 增量",让准确的数字来兜底。
五、兜底:估算会错,压缩会败
到这里,机制、时机、尺子都齐了。但工程里从来没有"万无一失"——最后一个问题必须回答:都失灵的时候,怎么办?
压迟了:先裁一刀
先说最急的一种情况:请求马上要发出去,压缩却迟迟没发生——异步任务没轮到,或者干脆失败了——而历史确实装不下。请求不能不发,发了又一定超限报错。
这时候只能临时裁一刀:按预算成批地删消息。这是所有招数里最不该用的一招,因为裁剪不可逆——压缩可以反悔(原文都在,能捞回来),裁剪不行:被裁掉的内容悄悄消失,不留占位符、不留 id,模型既看不到,也捞不回。所以它是最后一道闸——只用来兜底,平时不该被触发。
怎么裁也有讲究。最粗暴的做法是从最老的开始删,更合理的策略是"从中间删":
- 头部:系统提示词、摘要、冻结的上下文——删了伤筋动骨;
- 尾部:刚发生的对话轮次——正在被使用,删了模型立刻变傻;
- 中间:已经处理过的中间轮次——才是可以牺牲的。
我们框架里的策略叫 MiddleOut:按"用户消息"把对话切成轮次,从最中间那轮开始删,删到够为止;头部连续的系统消息和最后一轮永远保留。
它也有软肋:它只按"轮次"出手。如果消息里只有一条(比如单独的摘要请求),它就无从下手——找不到"中间"可删。这能解释一个反直觉的现象:有些场景下,给请求配裁剪是无效的。
算错了:让错误反向修正
估算是有可能错的——尺子那章说过,对中文尤甚。错到什么程度会暴露?请求被模型拒绝的那一刻:Vertex 回 413,直连 API 回 400。
平庸的做法是把错误抛给用户。好一点的做法是——拿错误当反馈。Claude Code 被拒之后,会从错误信息里解析出真实的 token 数和差值(parsePromptTooLongTokenCounts),算出"还差多少",精确补一刀再发。估算错了不要紧——被拒这一回,系统就拿到了真实数字,自己把自己的账修平了。
再配一道保险:连续压缩失败 3 次就退出这套流程(MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES),别让用户在死循环里干等。
压败了:三道防线
如果压缩一直没成功、上下文无限增长呢?完善的设计有三道防线(以摘要请求自身超预算为例):
- 截前缀:找一条"结构安全的边界"——不会把工具调用和工具结果劈开的切点——截出装得下的最大一段,只摘要它,剩下的下次再来;
- 拒绝:连最小的完整前缀都装不下时,直接报错放弃,而不是硬发;
- 上游接住:摘要失败后,请求照常发给主模型,由前面那道裁剪兜底——悄悄删掉一些历史,继续服务。
这里有个我觉得很漂亮的设计取舍:摘要失败时的错误信息是 "refusing to omit unsummarized conversation"——拒绝漏掉任何没被摘要的对话。因为摘要一成功,边界就会往前推进,被漏掉的那段历史就永远拿不回来了。它宁可这次不做,也不含糊地少摘一段。
宁可少做,不可做错——这句话值得放进每一个做数据变动的系统里。
六、遗忘
"记住"从来不是难事——全都带上就行了。难的是"忘":什么该丢、什么时候丢、丢得干不干净。
Agent 的记性有限,要干的活无限。这道题没有一劳永逸的答案——它每多跑一轮,就要重新答一次。
好在它忘得起:所有原始记录都在,被"忘"的,只是这一次没带上。
所以,学会遗忘不是记性变差——恰恰相反,是它终于分得清:什么值得记,什么可以忘。把窗口留给眼前最要紧的事。