A5 上下文:对话太长装不下怎么办? 第 3 课
什么时候触发压缩?按百分比、按余量,还是按估算? 什么时候触发
CoreCoder v0.4.0 用「字符数 ÷ 3」估算、按窗口百分比触发:一段真实中文 118 个 token,它估成 60,触发就晚一半;它的计数里还没有 system prompt 和工具定义。四家的触发规则被搬进浏览器,在同一条真实曲线上比:百分比、固定余量、以 API 回报的 usage 为准,以及一个容易忽略的维度——什么时候检查。
❓ 本课一问: 上一课的每一种压缩都从「超了」开始。可「超了」要先量出来:什么时候量、拿什么尺子量、线画在哪?按窗口的百分比,按留出的余量,还是按本地估算?
这一课先不碰: 压缩本身怎么做(第 2 课)、摘要写什么(第 4 课);这些 token 花多少钱(A8)。
从已有出发
上一课本课项目 miniagent 的截断,是在「按自己的口径估出来超过 1,800」时动手的。那把尺子本身准不准,决定了压缩是来得及还是来晚了。
从已有出发 · 累计数据流 点已学环节看它回答什么
任务 贯穿任务 → ① 编排选择 A0 → 每一轮循环都经过 ② 循环 A1 → ③ 工具层 A2 → ④ 权限闸 A3 → ⑤ 沙箱 A4 → ⑥ 上下文管理本课 A5
→ ⑦ 记忆与会话 A6 → ⑧ 子 agent A7 → ⑨ 模型接入与计费 A8 → ⑩ 验证与评测 A9
先自己设计:按字符数估,按百分比触发
最省事的尺子:不调 tokenizer,字符数除以一个常数。CoreCoder v0.4.0 就是这么做的,再按窗口的 50 / 70 / 90% 分三层触发:
context.py CoreCoder@v0.4.0:corecoder/context.py:22-43 需联网 MIT 最新版已变化 · 勘误表 K5 22 def _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
27 def 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
37 class 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
👆 带下划线的行可以点:点哪行,讲哪行。
① 朴素方案 ② 它在哪失败 ③ 改进方案 朴素方案 字符数 ÷ 3 估算,按窗口百分比触发(CoreCoder v0.4.0)
同一段文本,六个估算器 vs 录制模型的真实 token 自检中…
换成你自己的文本
用真 tokenizer 数一下(本地 Qwen3.8 词表)
真实 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,不是它)。
改进方案 触发看 API 回报的 usage,本地只估新增部分;线画在「窗口减去留出的余量」
两处都得改。尺子 :CoreCoder 自己在 v0.7.0 改成按内容分档,docstring 写明了理由——
context.py CoreCoder@v0.7.0:corecoder/context.py:24-45 需联网 MIT 24 _CJK_RE = re.compile(r"[一-鿿㐀-䶿豈- -〿-・]")
25 _SYMBOL_RE = re.compile(r"[^\w\s]")
26
27
28 def _approx_tokens(text: str) -> int:
29 """Rough token count: CJK ~1.5 chars/token, symbol-dense code ~2.8,
30 other prose ~3.4. Flat 3-chars counting reads a Chinese-heavy session at
31 half its real size, so compression would trigger too late; round up."""
32 cjk = len(_CJK_RE.findall(text))
33 rest = len(text) - cjk
34 dense = rest > 0 and len(_SYMBOL_RE.findall(text)) / len(text) > 0.25
35 return math.ceil(cjk / 1.5) + int(rest / (2.8 if dense else 3.4))
36
37
38 def estimate_tokens(messages: list[dict]) -> int:
39 total = 0
40 for m in messages:
41 if m.get("content"):
42 total += _approx_tokens(m["content"])
43 if m.get("tool_calls"):
44 total += _approx_tokens(str(m["tool_calls"]))
45 return total
👆 带下划线的行可以点:点哪行,讲哪行。
同一段中文它估成 109(0.92×)。口径与画线 :生产级 harness 不再只靠本地估算,而是拿上一轮 API 回报的真实 usage 当基数,本地只估「那之后新增的部分」;线也不画在百分比上,而是画在「窗口减去要留给回复和压缩本身的余量」上。下面看四家怎么写。
✗ 失败点 一段真实中文(CoreCoder 自己的中文文章第 3 段):录制模型的 tokenizer 数出 118 个 token,「字符 ÷ 3」估成 60 (0.51×),「字符 ÷ 4」只有 45(0.38×)。以中文为主的会话,按这把尺子量出来只有实际的一半,50% 的线要等到实际 100% 才碰到。口径也短一截:pd-v2-cap20-s1 第 1 轮服务端报告 947 token,CoreCoder 的估算只数到 70 。
看它在哪失败 换成改进方案 重来一遍
🎯 试三件事:① 在上面的点图里只看三行中文:所有「字符 ÷ 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 的最后一轮
contextTokens > contextWindow − reserveTokens
👆 公式里带下划线的每一项都可以点 ——点哪项,讲哪项。
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 */
202 export 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 */
235 export 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 1 import type { Config } from "@/config/config"
2 import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
3 import { SessionV1 } from "@opencode-ai/core/v1/session"
4 import type { Provider } from "@/provider/provider"
5 import { ProviderTransform } from "@/provider/transform"
6 import type { MessageV2 } from "./message-v2"
7
8 const COMPACTION_BUFFER = 20_000
9
10 export 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
22 export 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 463 impl 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 }
👆 带下划线的行可以点:点哪行,讲哪行。
极小真数字:百分比与余量,差在窗口大小
同一条规则,换一个窗口,线占窗口的比例会变。按各家默认参数现算:
窗口 CoreCoder Codex Pi(reserve 16,384) OpenCode(模型最大输出 32,000,未声明 limit.input) OpenCode(声明了 limit.input) 40,960(录制模型) 50 / 70 / 90% 90% 60.0% 21.9% 51.2% 200,000 50 / 70 / 90% 90% 91.8% 84.0% 90.0% 1,000,000 50 / 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 为准,本地估算的误差只作用在新增的一小段上 可证明 证据等级 · 数学可证明 可证明 理论动机 实验验证 在写明的前提下可以严格推导出来,不依赖实验。只要前提成立,换数据、换规模仍然成立。 本条 算法性质:四家规则的触发轮次在同一输入下是确定的;浏览器里的移植与原实现(python / bun / rustc 在 pin 源码上跑出的结果)逐条一致,见组件右上角。 出处 待补 ③ 实验上呢? 估算误差是本课项目实测:三段真实中文上,「字符 ÷ 4」只有真实的 0.37–0.38 倍 实验验证 证据等级 · 实验验证 可证明 理论动机 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条 项目实测:录制模型 Qwen3-8B 的 tokenizer;10 段文本、单一词表。换 tokenizer(例如 GPT 或 Claude 的)比例会变,中文被低估的方向不变。 出处 待补 ;各家的默认参数(16,384、20,000、90%)是经验选择,没有公开实验 理论动机 证据等级 · 有理论动机 可证明 理论动机 实验验证 有数学或机制上的理由说明它可能 有效,但没有证明它一定更好。最后是否采用,仍然要看实验。 本条 源码直证其取值,未见任何一家给出选择依据的实验。 出处 待补
演进地图:你在这里
上下文管理的演进:每一代都被上一代的某个真数字逼出来
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.」→ 下一章:会话存在哪、明天还记得吗。
旁支 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 课:压缩成摘要、整个重置,还是写交接文档?
触发时机定好了,压缩本身还是那三样:裁、摘要、删。上一课看过裁和删丢了什么;下一课看摘要。
你可能会问
「那本课项目该用哪种阈值?」 这是本章的里程碑决策。本课项目预算只有 1,800 时,工具定义和 system 就占了一半,按百分比几乎每轮都要压(分层运行 21 轮压了 26 次)。用你自己的 trace 在上面的组件里拖窗口、调参数,把结论写进 DECISIONS.md。
「Codex 的 90% 和 CoreCoder 的 90% 一样吗?」 线的位置一样,尺子不一样:Codex 的读数以服务端 usage 为基数,CoreCoder v0.4.0 全靠本地「字符 ÷ 3」,还不数 system 和工具定义。