从0打造编码Agent 阅读 + 实验 ≈ 40 分钟 页内实验 ×2
A5 上下文:对话太长装不下怎么办? 第 2 课

直接截掉最早的消息,行不行?

截掉最早的,行不行
拿真实运行第一次超预算的那一刻(12 条消息、2,009 token、预算 1,800)做实验:从头删,删掉的头两条正是 system prompt 和任务说明;整轮截断保住了开头,却丢了读文件那一轮,模型凭记忆改出 + 1 + 1。改进是先裁不要钱的工具输出,再花钱摘要——可按行数裁的工具输出,正好裁掉了唯一关键的那行报错。
❓

本课一问:上一课看清了:每一轮请求只增不减,迟早超预算。最省事的办法是超了就把最早的消息删掉——行不行?

这一课先不碰:「超了」怎么判断、估算准不准(第 3 课);删不掉的部分交给模型写摘要时,摘要写什么(第 4 课)。

从已有出发

上一课的账:一轮请求 = 工具定义 + system prompt + 任务 + 一轮轮的「调用 + 结果」,其中工具结果越积越多。

从已有出发 · 累计数据流 点已学环节看它回答什么
每一轮循环都经过

这一课的实验对象,是贯穿任务里一个真实发生过的时刻:pd-v2-truncate-s1 第 6 轮请求之前。本课项目 miniagent 把预算设成 1,800,这时历史里有 12 条消息,按它自己的口径一共 2,009 token,第一次超了。真实运行里它在这里做了第一次截断;下面把同一份历史交给几种不同的删法。

先自己设计:超了就从最早的一条开始删

最直觉的删法:从列表头上一条一条删,直到放得下。

  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案超了就从最早的一条消息开始删
朴素方案:超了就从最早的消息开始删
压缩之前:12 条消息(pd-v2-truncate-s1,第 6 轮请求前的真实历史)
压缩之后

口径: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,看「压缩之前」一栏里划掉的是哪两条;② 切到「保留 system」,把预算慢慢拖到 1,950,找到那条带红框的工具结果——它的调用被删了,结果还在;③ 把预算拖到 2,000 以上,两种删法都什么也不删:朴素删法只在超了之后才动手,它不知道「快满了」。

改进一:整轮截断,保住开头

本课项目 miniagent 的 truncate 就是按上面两条规矩写的:保留前两条,从最旧的一轮开始整轮丢,直到放得下,再插一句「删了 N 轮」的说明。它在真实运行里做了 2 次截断:

第几轮请求之前压缩前 → 后(miniagent 口径)消息数丢掉
62,009 → 1,74612 → 92 轮(累计 2)
71,906 → 1,47711 → 91 轮(累计 3)

机械上它没出错:请求合法、放得下、任务说明还在。问题出在丢掉的是哪几轮。第 7 轮之前那次截断丢掉的正是第 3 轮——read_file 读回的 dateparse.py 源码。第 7 轮请求里已经没有源码,模型只能凭前面 diff 里的几行记忆去改:

真实运行 pd-v2-truncate-s1:截断之后 请求模型回复工具调用工具结果harness 注入

数据:本课项目 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 的三层压缩就是按成本排的:

context.py CoreCoder@v0.4.0:corecoder/context.py:45-95需联网 MIT 最新版已变化 · 勘误表 K4
45 def maybe_compress(self, messages: list[dict], llm: LLM | None = None) -> bool:
46 """Apply compression layers as needed. Returns True if any compression happened."""
47 current = estimate_tokens(messages)
48 compressed = False
49
50 # Layer 1: snip verbose tool outputs
51 if current > self._snip_at:
52 if self._snip_tool_outputs(messages):
53 compressed = True
54 current = estimate_tokens(messages)
55
56 # Layer 2: LLM-powered summarization of old turns
57 if current > self._summarize_at and len(messages) > 10:
58 if self._summarize_old(messages, llm, keep_recent=8):
59 compressed = True
60 current = estimate_tokens(messages)
61
62 # Layer 3: hard collapse - last resort
63 if current > self._collapse_at and len(messages) > 4:
64 self._hard_collapse(messages, llm)
65 compressed = True
66
67 return compressed
68
69 @staticmethod
70 def _snip_tool_outputs(messages: list[dict]) -> bool:
71 """Layer 1: Truncate tool results over 1500 chars to their first/last lines.
72
73 This mirrors Claude Code's HISTORY_SNIP which replaces old tool outputs
74 with a one-line summary to reclaim context space.
75 """
76 changed = False
77 for m in messages:
78 if m.get("role") != "tool":
79 continue
80 content = m.get("content", "")
81 if len(content) <= 1500:
82 continue
83 lines = content.splitlines()
84 if len(lines) <= 6:
85 continue
86 # keep first 3 + last 3 lines
87 snipped = (
88 "\n".join(lines[:3])
89 + f"\n... ({len(lines)} lines, snipped to save context) ...\n"
90 + "\n".join(lines[-3:])
91 )
92 m["content"] = snipped
93 changed = True
94 return changed
95
👆 带下划线的行可以点:点哪行,讲哪行。

50 / 70 / 90 这三个数,是 CoreCoder 作者自选的设计值。「免费 → 一次推理 → 推理加上有损」这个成本顺序才是结构上的事实:三个函数的签名就写明了谁会调用模型。

下面把同一份 12 条历史交给几种「改进后」的策略。「L1 裁剪」照本课项目的参数(超过 400 字符的旧工具结果留头尾各 3 行,最近 2 轮不动);也可以换成 CoreCoder 的门槛 1,500 字符对比。

同一段真实历史,换几种压缩策略 自检中…
压缩之前:12 条消息(pd-v2-truncate-s1,第 6 轮请求前的真实历史)
压缩之后

口径: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 裁完之后,这份输出只剩:

exit code: 1 test_parse_date (test_dateparse.TestParseDate.test_parse_date) ... FAIL test_parse_iso (test_dateparse.TestParseDate.test_parse_iso) ... ok ... [snipped 11 lines to save context] ... Ran 3 tests in 0.001s FAILED (failures=1)

别家的裁剪也各有盲区,只是门槛不同:

code.py learn-claude-code@0dcafa2:s08_context_compact/code.py:247-253需联网 MIT
247class ContextCompactor:
248 CONTEXT_CHAR_LIMIT = 50000
249 TOOL_RESULT_BATCH_CHAR_LIMIT = 200000
250 LARGE_RESULT_CHAR_LIMIT = 30000
251 SUMMARY_INPUT_CHAR_LIMIT = 80000
252 KEEP_RECENT_RESULTS = 3
253 KEEP_RECENT_MESSAGES = 5

极小真数字:三层各自的账

真实运行 pd-v2-layered-s1 把同一个任务放在同样的 1,800 预算下,21 轮里压缩了 26 次。三层各自省了多少、花了多少,是上面那个组件底部的瀑布图;写成一行:

pd-v2-layered-s1 全程的压缩账(token 为 miniagent 口径,摘要成本为服务端 usage)
; , 花 11,832 ;
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
L2 这一栏:省下 783 个上下文 token,花掉 11,832 个摘要 token。按 token 数算,这次运行里摘要是亏的;它换来的是「旧轮次没有整个消失」,值不值要看摘要里留下了什么——第 4 课。
⚠
常见误解二:「50 / 70 / 90 是 Claude Code 的内部机制。」

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 条。三种做法都在回答同一个问题:哪些工具结果已经「用完了」。

证据三问

问本课的回答
① 它解决什么问题?超预算时删掉一部分,同时保证请求合法、题目还在
② 为什么这样排序?工具结果最占地方、也最可再生;裁它不调模型。成本顺序由函数结构决定 ;阈值 50 / 70 / 90 是经验选择
③ 实验上呢?本课项目:整轮截断那条运行失败(+ 1 + 1);分层那条 21 轮后成功,期间重读文件 4 次

演进地图:你在这里

上下文管理的演进:每一代都被上一代的某个真数字逼出来
  1. A1 的循环(所有 harness 的底层事实:模型 API 不记得上一轮)
    解决了实现最简单,模型每轮都看得到全部经过。
    卡在pd-v2-cap20-s1 从第 1 轮的 947 token 涨到第 11 轮的 2,618;第 1 轮里工具定义就占 72%,到最后一轮工具结果占 44%。只增不减,总有撞墙的一轮。
  2. 朴素 超了就截掉最早的 你在这里
    本课项目 miniagent 的 truncate(整轮丢,保留 system 与任务)、learn-claude-code s08 的 snip_compact(中段存档)
    解决了零成本,还能保证 tool 调用与结果不被拆散(按整轮丢)。
    卡在pd-v2-truncate-s1 截断 2 次共丢 3 轮,读文件那一轮也在其中;第 7 轮模型凭记忆改,那一行变成 + 1 + 1,验收测试退出码 1。
  3. 先便宜 先裁工具输出,不花一次推理 你在这里
    CoreCoder Layer 1、learn-claude-code micro_compact、OpenCode prune(默认关)、Anthropic tool result clearing
    解决了工具结果是最占地方、也最可再生的一块;裁它不用调模型。
    卡在按位置裁,不懂内容:分层运行里被裁过的 0 份测试输出,0 份丢了唯一关键的 AssertionError 行。另一头,本任务最长的工具输出 1,302 字符,CoreCoder 的 1,500 字符门槛一次都没碰到。
  4. CoreCoder 50 / 70 / 90%、Pi 窗口 − 16,384、OpenCode 窗口 − min(20,000, 最大输出)、Codex 90%
    解决了阈值定在撞墙之前,给压缩本身和下一次回复留出余地。
    卡在本地估算在中文上偏低:一段真实中文 118 token(录制模型的 tokenizer),CoreCoder v0.4.0 估成 60,按「字符数 ÷ 4」估成 45。只靠本地估算触发,就会晚。
  5. CoreCoder Layer 2、本课项目 miniagent L2
    解决了比截断保留更多:旧轮次不是消失,而是被压成一段话。
    卡在pd-v2-layered-s1 的 17 份摘要里,写下确切报错的 0 份,编出不存在的「timezone」的 16 份;第 10 轮模型把改对的一处改了回去。21 轮、73,168 token(摘要另花 11,832)才完成,是同组不压缩成功运行最少的 4.3 倍。
  6. 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.」→ 下一章:会话存在哪、明天还记得吗。
旁支 context reset:清空上下文,换一个新 agent 接手 作者已弃用
Anthropic《Harness design for long-running application development》(2026-03-24)
为治「context anxiety」(模型以为快满了就草草收尾)而设;同一篇文章写明:换到 Opus 4.5 后这种行为基本消失,作者把 reset 整个拿掉了。

数字口径:本课项目 miniagent 用 Qwen/Qwen3-8B-MLX-4bit 本机录制的真实运行(2026-09-26),每轮发出的消息由重放得到并与录制的压缩事件、服务端 usage 逐轮核对一致;估算器的输出由 CoreCoder、Pi 的原实现在 pin 源码上跑出(a05-context.json,2026-09-26)。耗时不作比较(本机有其他任务同时占用)。

一句话

📌
先裁不要钱的,再花钱摘要;永远别把 tool 调用和它的结果拆开。
🚦

先停在这里:记住两条规矩(开头不删、调用与结果不拆)和两个失败(整轮丢会丢掉源码、按行裁会裁掉报错)。
再深一层:去实验课在六条真实运行上换策略;或者看第 4 课,摘要到底留下了什么。

下一个缺口

什么时候触发压缩?按百分比、按余量,还是按估算?估算准不准? → 第 3 课

这一课的所有策略都有一个前提:「超了」。可「超了」是按谁的尺子量的?本课项目的尺子比服务端短一截,CoreCoder 的尺子连 system prompt 都不量。

你可能会问