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

什么时候触发压缩?按百分比、按余量,还是按估算?

什么时候触发
CoreCoder v0.4.0 用「字符数 ÷ 3」估算、按窗口百分比触发:一段真实中文 118 个 token,它估成 60,触发就晚一半;它的计数里还没有 system prompt 和工具定义。四家的触发规则被搬进浏览器,在同一条真实曲线上比:百分比、固定余量、以 API 回报的 usage 为准,以及一个容易忽略的维度——什么时候检查。
❓

本课一问:上一课的每一种压缩都从「超了」开始。可「超了」要先量出来:什么时候量、拿什么尺子量、线画在哪?按窗口的百分比,按留出的余量,还是按本地估算?

这一课先不碰:压缩本身怎么做(第 2 课)、摘要写什么(第 4 课);这些 token 花多少钱(A8)。

从已有出发

上一课本课项目 miniagent 的截断,是在「按自己的口径估出来超过 1,800」时动手的。那把尺子本身准不准,决定了压缩是来得及还是来晚了。

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

先自己设计:按字符数估,按百分比触发

最省事的尺子:不调 tokenizer,字符数除以一个常数。CoreCoder v0.4.0 就是这么做的,再按窗口的 50 / 70 / 90% 分三层触发:

context.py CoreCoder@v0.4.0:corecoder/context.py:22-43需联网 MIT 最新版已变化 · 勘误表 K5
22def _approx_tokens(text: str) -> int:
23 """Rough token count, roughly 3 chars per token for mixed en/zh content."""
24 return len(text) // 3
25
26
27def estimate_tokens(messages: list[dict]) -> int:
28 total = 0
29 for m in messages:
30 if m.get("content"):
31 total += _approx_tokens(m["content"])
32 if m.get("tool_calls"):
33 total += _approx_tokens(str(m["tool_calls"]))
34 return total
35
36
37class ContextManager:
38 def __init__(self, max_tokens: int = 128_000):
39 self.max_tokens = max_tokens
40 # layer thresholds (fraction of max_tokens)
41 self._snip_at = int(max_tokens * 0.50) # 50% -> snip tool outputs
42 self._summarize_at = int(max_tokens * 0.70) # 70% -> LLM summarize
43 self._collapse_at = int(max_tokens * 0.90) # 90% -> hard collapse
👆 带下划线的行可以点:点哪行,讲哪行。
  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案字符数 ÷ 3 估算,按窗口百分比触发(CoreCoder v0.4.0)
同一段文本,六个估算器 vs 录制模型的真实 token自检中…
换成你自己的文本

真实 token:录制模型 Qwen/Qwen3-8B-MLX-4bit@383413e 自带的 tokenizer(与服务端 usage 同一套,见 a05-context.json);括号里是站内 Qwen3.8 词表的数,两份词表不同,数会有出入。估算器:CoreCoder v0.4.0 context.py:22-24(字符 ÷ 3,下取整)、v0.7.0 context.py:28-35(CJK 按 1.5 字符 / token 向上取整、符号占比 > 25% 按 2.8、其余 3.4);Pi compaction.ts:266-292(字符 ÷ 4 向上取整);OpenCode packages/core/src/util/token.ts(字符 ÷ 4 四舍五入);Codex utils/string/src/truncate.rs:4,71-74(UTF-8 字节 ÷ 4 向上取整);本课项目 miniagent/tokens.py HeuristicCounter(ASCII ÷ 3.5 + 其他字符各 1;录制时实际用的是真 tokenizer,不是它)。

🎯

试三件事:① 在上面的点图里只看三行中文:所有「字符 ÷ 4」的点都落在红色的低估区;② 点「Python 源码」那一行,看哪几个估算器反而高估了;③ 在自由输入框里贴一段你平时和 agent 说的话(中英混杂),先看估算,再点「用真 tokenizer 数一下」。

改进:四家的触发规则

四家移植进浏览器后,放在同一条真实曲线上比:本课项目那条不压缩的运行 pd-v2-cap20-s1(也可以切到 pd-v2-unlimited-s1)。每一家画自己的「尺子」读数和自己的线,四张小图共用同一个坐标。

同一条真实曲线,四家各在哪一轮触发压缩 自检中…

口径:本课项目 miniagent 用 Qwen/Qwen3-8B-MLX-4bit(窗口 40,960,单次回复上限 4,096)本机录制,2026-09-26。柱高 = 服务端返回的 usage.prompt_tokens;分块的 token 数用录制模型的 tokenizer 与 chat template 重算,每一轮都与服务端的数逐条一致;「模板」是聊天模板加的角色标记等特殊 token。显示的文本已脱敏(本机路径换成占位符)。触发规则:CoreCoder、Pi、OpenCode、Codex 从 pin 源码移植,期望值由原实现在 pin 副本上跑出(gen_a05_context.py / .ts,2026-09-26)。

🎯

试三件事:① 默认窗口 40,960(录制模型的真实窗口):四家都不触发——这个任务太短,真实窗口根本不会满;② 点「2,400」:Pi 和 OpenCode 一上来就越线了,因为 Pi 的 reserveTokens 默认 16,384、OpenCode 要给回复留 4,096,都比整个窗口还大;③ 把两个参数按 200K 窗口的比例缩小(Pi 的 reserve 填 197,OpenCode 的最大输出填 384),再比四家各在哪一轮触发——注意第 8、9 轮那两根特别高的柱子。

四家的规则,逐一看源码:

Pi:余量公式。线画在「窗口 − reserveTokens」,默认留 16,384。

Pi 的 shouldCompact(compaction.ts:235-238),代入录制模型的窗口与 pd-v2-cap20-s1 的最后一轮
> −
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
2,848 > 40,960 − 16,384 = 24,576?不成立,不触发。只有 settings.enabled 为假时整个函数直接返回 false(:236)。
compaction.ts pi@31b513e3:packages/coding-agent/src/core/compaction/compaction.ts:198-238需联网 MIT 最新版已变化 · 勘误表 P4
198/**
199 * Estimate context tokens from messages, using the last assistant usage when available.
200 * If there are messages after the last usage, estimate their tokens with estimateTokens.
201 */
202export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEstimate {
203 const usageInfo = getLastAssistantUsageInfo(messages);
204
205 if (!usageInfo) {
206 let estimated = 0;
207 for (const message of messages) {
208 estimated += estimateTokens(message);
209 }
210 return {
211 tokens: estimated,
212 usageTokens: 0,
213 trailingTokens: estimated,
214 lastUsageIndex: null,
215 };
216 }
217
218 const usageTokens = calculateContextTokens(usageInfo.usage);
219 let trailingTokens = 0;
220 for (let i = usageInfo.index + 1; i < messages.length; i++) {
221 trailingTokens += estimateTokens(messages[i]);
222 }
223
224 return {
225 tokens: usageTokens + trailingTokens,
226 usageTokens,
227 trailingTokens,
228 lastUsageIndex: usageInfo.index,
229 };
230}
231
232/**
233 * Check if compaction should trigger based on context usage.
234 */
235export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
236 if (!settings.enabled) return false;
237 return contextTokens > contextWindow - settings.reserveTokens;
238}
👆 带下划线的行可以点:点哪行,讲哪行。

但 Pi 什么时候检查这条线?pin 版本里,阈值检查挂在「一次任务跑完之后、下一次用户输入之前」:

agent-session.ts pi@31b513e3:packages/coding-agent/src/core/agent-session.ts:1950-1962需联网 MIT
1950 /**
1951 * Check if compaction is needed and run it.
1952 * Called after agent_end and before prompt submission.
1953 *
1954 * Two cases:
1955 * 1. Recoverable failure: LLM returned context overflow or stopped below its desired output limit;
1956 * remove the assistant message, compact, and auto-retry once
1957 * 2. Threshold: Context over threshold, compact, NO auto-retry (user continues manually)
1958 *
1959 * @param assistantMessage The assistant message to check
1960 * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
1961 */
1962 private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise<boolean> {
👆 带下划线的行可以点:点哪行,讲哪行。

也就是说,一个「一句任务跑二十轮工具」的会话,Pi 在中途不按阈值压缩;中途真撞墙了,服务端报溢出错误,它才压缩并重试一次(它靠上一课看过的那张报错字符串表认出溢出)。

OpenCode:绝对余量。每一步结束后、下一步开始前各查一次:

overflow.ts opencode@v1.18.32:packages/opencode/src/session/overflow.ts:1-34需联网 MIT
1import type { Config } from "@/config/config"
2import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
3import { SessionV1 } from "@opencode-ai/core/v1/session"
4import type { Provider } from "@/provider/provider"
5import { ProviderTransform } from "@/provider/transform"
6import type { MessageV2 } from "./message-v2"
7
8const COMPACTION_BUFFER = 20_000
9
10export function usable(input: { cfg: ConfigV1.Info; model: Provider.Model; outputTokenMax?: number }) {
11 const context = input.model.limit.context
12 if (context === 0) return 0
13
14 const reserved =
15 input.cfg.compaction?.reserved ??
16 Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax))
17 return input.model.limit.input
18 ? Math.max(0, input.model.limit.input - reserved)
19 : Math.max(0, context - ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax))
20}
21
22export function isOverflow(input: {
23 cfg: ConfigV1.Info
24 tokens: SessionV1.Assistant["tokens"]
25 model: Provider.Model
26 outputTokenMax?: number
27}) {
28 if (input.cfg.compaction?.auto === false) return false
29 if (input.model.limit.context === 0) return false
30
31 const count =
32 input.tokens.total || input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write
33 return count >= usable(input)
34}
👆 带下划线的行可以点:点哪行,讲哪行。

Codex:窗口的 90%。内置模型窗口 272,000 时阈值是 244,800;配置里另给了上限就取小的那个:

openai_models.rs codex@8cabf5a:codex-rs/protocol/src/openai_models.rs:463-479需联网 Apache-2.0
463impl ModelInfo {
464 pub fn resolved_context_window(&self) -> Option<i64> {
465 self.context_window.or(self.max_context_window)
466 }
467
468 pub fn auto_compact_token_limit(&self) -> Option<i64> {
469 let context_limit = self
470 .resolved_context_window()
471 .map(|context_window| (context_window * 9) / 10);
472 let config_limit = self.auto_compact_token_limit;
473 if let Some(context_limit) = context_limit {
474 return Some(
475 config_limit.map_or(context_limit, |limit| std::cmp::min(limit, context_limit)),
476 );
477 }
478 config_limit
479 }
👆 带下划线的行可以点:点哪行,讲哪行。

它的计数同样以 usage 为准:最后一次回复的 total_tokens,加上「那之后新增条目」的估算(字节 ÷ 4)。检查发生在每次采样之后、模型还要继续干活时(turn.rs:430-464),所以长任务中途就会压缩。

history.rs codex@8cabf5a:codex-rs/core/src/context_manager/history.rs:321-341需联网 Apache-2.0
321 /// When true, the server already accounted for past reasoning tokens and
322 /// the client should not re-estimate them.
323 pub(crate) fn get_total_token_usage(&self, server_reasoning_included: bool) -> i64 {
324 let last_tokens = self
325 .token_info
326 .as_ref()
327 .map(|info| info.last_token_usage.total_tokens)
328 .unwrap_or(0);
329 let items_after_last_model_generated_tokens = self
330 .items_after_last_model_generated_item()
331 .iter()
332 .map(estimate_item_token_count)
333 .fold(0i64, i64::saturating_add);
334 if server_reasoning_included {
335 last_tokens.saturating_add(items_after_last_model_generated_tokens)
336 } else {
337 last_tokens
338 .saturating_add(self.get_non_last_reasoning_items_tokens())
339 .saturating_add(items_after_last_model_generated_tokens)
340 }
341 }
👆 带下划线的行可以点:点哪行,讲哪行。

极小真数字:百分比与余量,差在窗口大小

同一条规则,换一个窗口,线占窗口的比例会变。按各家默认参数现算:

窗口CoreCoderCodexPi(reserve 16,384)OpenCode(模型最大输出 32,000,未声明 limit.input)OpenCode(声明了 limit.input)
40,960(录制模型)50 / 70 / 90%90%60.0%21.9%51.2%
200,00050 / 70 / 90%90%91.8%84.0%90.0%
1,000,00050 / 70 / 90%90%98.4%96.8%98.0%

百分比的线是平的;绝对余量的线随窗口变大越来越靠近 100%。两种写法在回答不同的问题:百分比问「用掉了多大比例」,余量问「还剩不剩够一次回复加一次压缩」。窗口从 128K 涨到 1M 时,后者不用改参数。上面组件最下方那张图就是这张表的连续版本。

⚠
常见误解一:「估算必须上精确的 tokenizer。」

OpenCode 的 pin 源码里没有任何 tokenizer,token 估算只有一处:Math.round(input.length / 4)(packages/core/src/util/token.ts)。它照样不会撞墙,因为触发看的是 API 回报的真实 usage,估算只用在「挑出最近多少轮原样保留」这种不致命的地方;安全边际来自余量。本课项目的真 tokenizer 估算也比服务端少 81–198 token(不数模板 token)——精确到个位本来就不可能,也不必要。

常见误解二:「Pi 每一轮都检查要不要压缩。」

pin 版本只在一次任务跑完后按阈值检查(agent-session.ts:1952);任务中途,只有服务端真的报了溢出才压缩并重试一次。选哪种时机是设计取舍:中途压缩能让长任务不断线,代价是在任务进行中改写模型眼前的历史;Pi 的选择把按阈值的压缩留在两次用户请求之间。

🔍 「以 usage 为准」也有坑:思考 token

Pi 和 OpenCode 用的是上一次回复的 total(输入 + 输出)。pd-v2-cap20-s1 第 8 轮模型想了 4,096 token 没写出调用,total 冲到 6,511;可第 9 轮真正发出的只有 2,456——本课项目不回传思考内容。上面四张小图里第 8、9 轮那两根高柱就是它们。对不回传思考的 harness,用 total 当「下一轮的上下文」会高估;Codex 的计数函数专门区分了服务端是否已经把推理 token 算进去(history.rs:321-341)。

证据三问

问本课的回答
① 它解决什么问题?在撞墙之前动手,并且给回复和压缩本身留出空间
② 为什么这样画线?余量直接对应「还剩不剩够一次回复」;以 usage 为准,本地估算的误差只作用在新增的一小段上
③ 实验上呢?估算误差是本课项目实测:三段真实中文上,「字符 ÷ 4」只有真实的 0.37–0.38 倍 ;各家的默认参数(16,384、20,000、90%)是经验选择,没有公开实验

演进地图:你在这里

上下文管理的演进:每一代都被上一代的某个真数字逼出来
  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)。耗时不作比较(本机有其他任务同时占用)。

一句话

📌
估算只求不致命,安全边际来自预留;真实数字以 API 回报为准。
🚦

先停在这里:记住三件事:百分比与余量随窗口变大的差别、「以 usage 为准只估新增」、检查时机(每轮 vs 任务结束)也是设计的一部分。
再深一层:走岔路「Pi 少送 3 倍上下文」该怎么读?,练一次读别人数字的口径。

下一个缺口

压缩了之后,丢了什么? → 第 4 课:压缩成摘要、整个重置,还是写交接文档?

触发时机定好了,压缩本身还是那三样:裁、摘要、删。上一课看过裁和删丢了什么;下一课看摘要。

你可能会问