直接截掉最早的消息,行不行?
本课一问:上一课看清了:每一轮请求只增不减,迟早超预算。最省事的办法是超了就把最早的消息删掉——行不行?
这一课先不碰:「超了」怎么判断、估算准不准(第 3 课);删不掉的部分交给模型写摘要时,摘要写什么(第 4 课)。
从已有出发
上一课的账:一轮请求 = 工具定义 + system prompt + 任务 + 一轮轮的「调用 + 结果」,其中工具结果越积越多。
这一课的实验对象,是贯穿任务里一个真实发生过的时刻:pd-v2-truncate-s1 第 6 轮请求之前。本课项目 miniagent 把预算设成 1,800,这时历史里有 12 条消息,按它自己的口径一共 2,009 token,第一次超了。真实运行里它在这里做了第一次截断;下面把同一份历史交给几种不同的删法。
先自己设计:超了就从最早的一条开始删
最直觉的删法:从列表头上一条一条删,直到放得下。
- ① 朴素方案
- ② 它在哪失败
- ③ 改进方案
口径:token 按本课项目 miniagent 的计数方式(录制模型 tokenizer 数消息内容,每条另加 4,再加工具定义 609),与它录制时的压缩事件逐条一致;显示的文本已脱敏。朴素的两种删法是本页写的对照算法;截断 / L1 / 分层移植自项目源码 miniagent/context.py(项目自己的实现,不是 pin 仓库);CoreCoder Layer 1 移植自 he-yufeng/CoreCoder@v0.4.0 corecoder/context.py:69-95(MIT)。
两个失败各对应一条规矩:
- 开头不能删。system prompt 和任务说明是「这件事是什么、有什么约束」,删了模型就在没有题目的情况下答题。本课项目的截断永远保留前两条;learn-claude-code s08 的
snip_compact同样保住开头 3 条,只把中间一段存档到磁盘。 - 调用和它的结果不能拆开。OpenAI 兼容接口要求每条 tool 消息前面都有发起它的
tool_calls。CoreCoder 专门写了一个函数把切点往前挪:
Pi 的写法是反过来说的:切点只能落在 user 或 assistant 消息上,「Never cut at tool results」(docs/compaction.md:117)。本课项目按「整轮」删,一轮 = 一条 assistant + 它引出的全部结果,天然不会拆开。
试三件事:① 预算停在 1,800,看「压缩之前」一栏里划掉的是哪两条;② 切到「保留 system」,把预算慢慢拖到 1,950,找到那条带红框的工具结果——它的调用被删了,结果还在;③ 把预算拖到 2,000 以上,两种删法都什么也不删:朴素删法只在超了之后才动手,它不知道「快满了」。
改进一:整轮截断,保住开头
本课项目 miniagent 的 truncate 就是按上面两条规矩写的:保留前两条,从最旧的一轮开始整轮丢,直到放得下,再插一句「删了 N 轮」的说明。它在真实运行里做了 2 次截断:
| 第几轮请求之前 | 压缩前 → 后(miniagent 口径) | 消息数 | 丢掉 |
|---|---|---|---|
| 6 | 2,009 → 1,746 | 12 → 9 | 2 轮(累计 2) |
| 7 | 1,906 → 1,477 | 11 → 9 | 1 轮(累计 3) |
机械上它没出错:请求合法、放得下、任务说明还在。问题出在丢掉的是哪几轮。第 7 轮之前那次截断丢掉的正是第 3 轮——read_file 读回的 dateparse.py 源码。第 7 轮请求里已经没有源码,模型只能凭前面 diff 里的几行记忆去改:
数据:本课项目 miniagent 的真实运行(src/data/project/agent-traces.json,2026-09-26 导出;模型 Qwen/Qwen3-8B-MLX-4bit,本机 mlx_lm.server)。token 数取自服务端 usage;思考截取约 600 字、正文与工具输出约 900 字(保留头尾)。耗时受本机同时运行的其他任务影响,只能粗看;token 与轮数才可比。
第 7 轮的 old_string 是 {badEdit.old.split('\n')[1] ? badEdit.old.split('\n')[1].trim() : badEdit.old.trim()}——它恰好是已经修好那一行的前半截,唯一匹配照样通过,那一行变成了 + 1 + 1。下一轮模型自己在思考里看出来了:So the error is in the latest edit where they added 1 twice.
,却在说完「要回退」之后没有再调用工具就停了。验收测试退出码 1。
它不知道自己缺了什么。截断时 miniagent 插了一句「删了 N 轮」,可没说删的是哪几轮、里面有什么;这次运行全程只读过 1 次文件,截断之后一次都没再读。对照下面分层压缩那条运行:它把旧的读文件结果裁成了头尾各 3 行,模型看得见「这里被裁了」,于是前后读了 4 次——能找回来,代价是多花轮次。
改进二:先便宜后贵
截断的毛病是「一删就是整轮」,连同那一轮里最有用的东西。能不能先动最占地方、又最容易再拿到的那一块?上一课的账说得很清楚:涨的主要是工具结果。
CoreCoder 的三层压缩就是按成本排的:
50 / 70 / 90 这三个数,是 CoreCoder 作者自选的设计值。「免费 → 一次推理 → 推理加上有损」这个成本顺序才是结构上的事实:三个函数的签名就写明了谁会调用模型。
下面把同一份 12 条历史交给几种「改进后」的策略。「L1 裁剪」照本课项目的参数(超过 400 字符的旧工具结果留头尾各 3 行,最近 2 轮不动);也可以换成 CoreCoder 的门槛 1,500 字符对比。
口径:token 按本课项目 miniagent 的计数方式(录制模型 tokenizer 数消息内容,每条另加 4,再加工具定义 609),与它录制时的压缩事件逐条一致;显示的文本已脱敏。朴素的两种删法是本页写的对照算法;截断 / L1 / 分层移植自项目源码 miniagent/context.py(项目自己的实现,不是 pin 仓库);CoreCoder Layer 1 移植自 he-yufeng/CoreCoder@v0.4.0 corecoder/context.py:69-95(MIT)。
试三件事:① 预算 1,800,选「分层:L1 → L3」:什么都没删,只裁了两份旧工具结果就放下了(从 2,009 到 1,496);再看下面的清单——「测试报错原文」那一行是 ✗;② 展开「看被裁剪后的工具结果」,数一数测试输出原来 17 行,留下的头尾 6 行里有没有 AssertionError;③ 选「CoreCoder Layer 1」:一条都没裁,因为这段历史里最长的工具结果只有 1,302 字符,够不着它的 1,500 字符门槛。
这就是本课的第二个失败:按位置裁剪不懂内容。测试输出(17 行)里唯一有用的是倒数第 6 行的 AssertionError: datetime.date(2024, 5, 3) != datetime.date(2024, 3, 5),它正好在被裁掉的中段。本课项目的 L1 裁完之后,这份输出只剩:
别家的裁剪也各有盲区,只是门槛不同:
- OpenCode 的 prune(
compaction.ts:273-317)从最新往回数,先保护最近 40,000 token 的工具输出,更早的累计超过 20,000 才清空。它默认是关着的(配置说明写着 default: false);而且它从末尾往前数用户消息,不满两条就跳过——像我们这样「一句任务跑到底」的会话,它一条都不会清。这段历史的工具输出按它的估算合计才 809 token。 - learn-claude-code s08 四步走:单批工具结果超过 200,000 字符先落盘、消息超过 50 条把中段存档、总字符超过 50,000 才把旧结果换成「已存到某文件」,最后才让模型摘要。常量都在一处:
- Anthropic 把「清掉旧工具结果」称为最安全、最轻的一种压缩(《Effective context engineering for AI agents》),并在 Claude Developer Platform 上做成了功能。
极小真数字:三层各自的账
真实运行 pd-v2-layered-s1 把同一个任务放在同样的 1,800 预算下,21 轮里压缩了 26 次。三层各自省了多少、花了多少,是上面那个组件底部的瀑布图;写成一行:
CoreCoder 的模块注释写着「Claude Code uses a 4-layer strategy」,_snip_tool_outputs 的注释说它「mirrors Claude Code’s HISTORY_SNIP」。这是 CoreCoder 作者对闭源产品的逆向说法,Anthropic 没有证实;50 / 70 / 90 是 CoreCoder 自选的阈值。可以学它的结构(先便宜后贵),不要把数字当成谁的标准。
🔍 为什么本课项目的 L1 要放过最近 2 轮?
刚拿到的工具结果,模型还没来得及用。本课项目的决策记录(MILESTONES.md A5)写的理由是:最近一轮的输出如果被裁,模型等于没看见。CoreCoder v0.4.0 的 Layer 1 不分新旧,只看长度;learn-claude-code 的 micro_compact 只动「模型已经回应过」的结果,并留最近 3 条。三种做法都在回答同一个问题:哪些工具结果已经「用完了」。
证据三问
| 问 | 本课的回答 |
|---|---|
| ① 它解决什么问题? | 超预算时删掉一部分,同时保证请求合法、题目还在 |
| ② 为什么这样排序? | 工具结果最占地方、也最可再生;裁它不调模型。成本顺序由函数结构决定 证据等级 · 数学可证明 在写明的前提下可以严格推导出来,不依赖实验。只要前提成立,换数据、换规模仍然成立。 本条Layer 1 是不接收 llm 的 staticmethod;Layer 2、3 都调用 _get_summary。浏览器里的 L1 与本课项目截断、分层的移植,与原函数在 24 组输入上逐条一致(组件右上角)。 出处CoreCoder@v0.4.0 context.py:45-95(源码直证) ↗需联网 ;阈值 50 / 70 / 90 是经验选择 证据等级 · 有理论动机 有数学或机制上的理由说明它可能有效,但没有证明它一定更好。最后是否采用,仍然要看实验。 本条CoreCoder 作者自选;没有公开实验说明为什么是这三个数。 出处待补 |
| ③ 实验上呢? | 本课项目:整轮截断那条运行失败(+ 1 + 1);分层那条 21 轮后成功,期间重读文件 4 次 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条项目实测:单一模型、单一任务、各一次运行,预算人为调低;只能说明现象存在,不能说明比例。 出处待补 |
演进地图:你在这里
- A1 的循环(所有 harness 的底层事实:模型 API 不记得上一轮)解决了实现最简单,模型每轮都看得到全部经过。卡在pd-v2-cap20-s1 从第 1 轮的 947 token 涨到第 11 轮的 2,618;第 1 轮里工具定义就占 72%,到最后一轮工具结果占 44%。只增不减,总有撞墙的一轮。
- 本课项目 miniagent 的 truncate(整轮丢,保留 system 与任务)、learn-claude-code s08 的 snip_compact(中段存档)解决了零成本,还能保证 tool 调用与结果不被拆散(按整轮丢)。卡在pd-v2-truncate-s1 截断 2 次共丢 3 轮,读文件那一轮也在其中;第 7 轮模型凭记忆改,那一行变成
+ 1 + 1,验收测试退出码 1。 - CoreCoder Layer 1、learn-claude-code micro_compact、OpenCode prune(默认关)、Anthropic tool result clearing解决了工具结果是最占地方、也最可再生的一块;裁它不用调模型。卡在按位置裁,不懂内容:分层运行里被裁过的 0 份测试输出,0 份丢了唯一关键的 AssertionError 行。另一头,本任务最长的工具输出 1,302 字符,CoreCoder 的 1,500 字符门槛一次都没碰到。
- CoreCoder 50 / 70 / 90%、Pi 窗口 − 16,384、OpenCode 窗口 − min(20,000, 最大输出)、Codex 90%解决了阈值定在撞墙之前,给压缩本身和下一次回复留出余地。卡在本地估算在中文上偏低:一段真实中文 118 token(录制模型的 tokenizer),CoreCoder v0.4.0 估成 60,按「字符数 ÷ 4」估成 45。只靠本地估算触发,就会晚。
- 再花钱 让模型把旧轮次写成摘要CoreCoder Layer 2、本课项目 miniagent L2解决了比截断保留更多:旧轮次不是消失,而是被压成一段话。卡在pd-v2-layered-s1 的 17 份摘要里,写下确切报错的 0 份,编出不存在的「timezone」的 16 份;第 10 轮模型把改对的一处改了回去。21 轮、73,168 token(摘要另花 11,832)才完成,是同组不压缩成功运行最少的 4.3 倍。
- Codex(交接摘要 + 保留用户原话)、Pi(结构化模板 + 文件清单)、OpenCode(五节模板)解决了把「必须留下什么」写进模板:目标、约束、进度、确切报错、下一步、相关文件;完整历史留在磁盘上。卡在仍然有损。Codex 每次压缩后提醒用户:「Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible to keep threads small and targeted.」→ 下一章:会话存在哪、明天还记得吗。
数字口径:本课项目 miniagent 用 Qwen/Qwen3-8B-MLX-4bit 本机录制的真实运行(2026-09-26),每轮发出的消息由重放得到并与录制的压缩事件、服务端 usage 逐轮核对一致;估算器的输出由 CoreCoder、Pi 的原实现在 pin 源码上跑出(a05-context.json,2026-09-26)。耗时不作比较(本机有其他任务同时占用)。
一句话
先停在这里:记住两条规矩(开头不删、调用与结果不拆)和两个失败(整轮丢会丢掉源码、按行裁会裁掉报错)。
再深一层:去实验课在六条真实运行上换策略;或者看第 4 课,摘要到底留下了什么。
下一个缺口
什么时候触发压缩?按百分比、按余量,还是按估算?估算准不准? → 第 3 课
这一课的所有策略都有一个前提:「超了」。可「超了」是按谁的尺子量的?本课项目的尺子比服务端短一截,CoreCoder 的尺子连 system prompt 都不量。
你可能会问
- 「截断时插的那句说明,为什么不写清楚删了什么?」 可以写,本课项目目前只写了轮数。写清楚(比如「删掉了第 3 轮读回的 dateparse.py」)能提示模型重新读,但说明本身也占 token——这正是第 4 课「交接文档」要解决的问题。
- 「裁剪时能不能按内容留行,比如留下所有含 Error 的行?」 能,而且 CoreCoder 在没有模型可用时的兜底摘要就是这么做的(
_extract_key_info,context.py:188-210:抽出文件路径和含 error 的行)。代价是规则要为每种输出单独写。