上下文为什么会满?每一轮到底发了多少?
本课一问:循环转起来之后,上下文为什么会满?每一轮真正发给模型的,到底是哪些东西、各占多少?
这一课先不碰:满了之后怎么删、怎么压(第 2 课);什么时候触发(第 3 课);发出去的 token 花多少钱(A8)。
从已有出发
A1 的循环里有一个容易被略过的事实:模型 API 不记得上一轮。所以 harness 每一轮都要把 system prompt、工具定义、到目前为止的全部消息整份重发一遍;A2 的岔路 B1 用真实请求验证过,第 n+1 次请求的开头原封不动就是第 n 次请求。
这一课用的仍是贯穿任务的真实运行。选 pd-v2-cap20-s1:本课项目 miniagent 用本机的 Qwen3-8B 修 dateparse.py,不做任何压缩,11 轮后测试通过——它是六条运行里最长的一条「原样重发」,正好用来看上下文怎么自己长大。
先自己设计:不管它,每轮原样重发
最省事的做法是什么都不做:历史有多长就发多长,模型窗口够大就行。
- ① 朴素方案
- ② 它在哪失败
- ③ 改进方案
每一轮新增的是「模型上一轮写的调用 + 工具返回的结果」,旧的一样不少。逐轮增量(服务端 usage 之差):+43、+272、+443、+88、+177、+271、+174、+41、+41、+121。录制模型的真实窗口是 40,960,这个短任务撞不上;把任务拉长、换成更大的仓库,只是撞墙的轮数往后推。
撞墙时服务端会直接拒绝请求。各家的报错长什么样,Pi 在源码里收集了一份(它要靠这些字符串认出「溢出」,再压缩重试):
问题不在「会不会满」——只要循环不停,一定会满——而在满之前,你知不知道窗口里装的是什么。删什么、裁什么、什么时候动手,都得先有这张账。下面把每一轮拆开。
改进:先看清一轮请求由什么组成
下面是这次运行的逐轮回放。柱高是服务端返回的 usage.prompt_tokens;每根柱按来源分块着色,分块的 token 数用录制模型的 tokenizer 和 chat template 重算,每一轮都与服务端的数一致。红虚线是假设窗口只有 1,800。
把本机 Claude Code 的会话记录(~/.claude/projects/…/*.jsonl)拖到这里,或点下面的按钮选择文件。文件只在你的浏览器里解析,不会上传到任何地方;关掉页面就没了。每条 assistant 消息的 input_tokens + cache_read_input_tokens + cache_creation_input_tokens 就是那一轮真正发给模型的上下文大小。
口径:本课项目 miniagent 用 Qwen/Qwen3-8B-MLX-4bit(窗口 40,960,单次回复上限 4,096)本机录制,2026-09-26。柱高 = 服务端返回的 usage.prompt_tokens;分块的 token 数用录制模型的 tokenizer 与 chat template 重算,每一轮都与服务端的数逐条一致;「模板」是聊天模板加的角色标记等特殊 token。显示的文本已脱敏(本机路径换成占位符)。
试三件事:① 先看第 1 轮:整根柱 947 token,浅紫色的工具定义占了多少?用户写的任务(蓝色)又有多少?② 点最后一轮,在下方逐条列表里找出哪一个工具结果吃掉了最多上下文(带 ★ 的那条),再往回翻,看它是第几轮进来的、之后每一轮是不是都还在;③ 切到 pd-v2-truncate-s1,看柱子第一次变矮的那一轮,顶上标着什么。
回放里能读出三件事,数字都来自这次运行:
- 第 1 轮就不只是对话。947 token 里,工具定义 685(72%)、system prompt 197、用户写的任务只有 52,其余 13 是聊天模板加的角色标记。
- 之后涨的主要是工具结果。到第 11 轮,2,618 token 里工具结果合计 1,144(44%),已经超过工具定义;模型写的调用只有 268。单个最大的是第 3 轮
read_file读回的dateparse.py,409 token,从第 4 轮起每一轮都跟着重发。 - 模型「想」的不算进来。第 8 轮模型写满了 4,096 token(全是思考,一个调用都没写出来就撞到了回复上限),第 9 轮的请求却只比第 8 轮多 41——多出来的是一条空的 assistant 消息和 harness 补的一句「回复被截断了,接着干」。本课项目不把思考内容回传给模型,只回传它写下的调用与正文。
Pi 的压缩文档里有一句概括:「tool results (especially from read and bash) are typically the largest contributors to context size」(docs/compaction.md:269)。我们这条短运行印证了后半程;开头的大头则是每轮都要付的工具定义——A2 的岔路 B1 专门算过它。
第 1 轮模型还没说一个字,上下文里已经有 947 token,对话只有任务那 52 个。system prompt、工具定义、项目指令文件(本项目没有;Claude Code 的 CLAUDE.md、Codex 的 AGENTS.md 都是每轮注入)都在窗口里占位置,而且每一轮都在。
常见误解二:「命中缓存的 token 不算上下文。」第 2 轮的 990 token 里,服务端报告 989 个命中了前缀缓存。缓存省的是计算和钱(A8),不省窗口:这 989 个 token 照样全部在模型眼前。
极小真数字:一轮的上下文 = 各块之和
把第 1 轮的请求拆成四块,加起来正好是服务端报告的数:
如果你用的是 Anthropic 的接口(Claude Code 就是),一轮的上下文要把 usage 里三个字段加起来,因为命中缓存的部分被单独计数了:
🔍 窗口满了,只是 API 报错吗?
报错是最明显的一种。更隐蔽的代价在模型内部:每多一个 token,推理时注意力就要多看一个位置、KV cache 就要多存一份——LLM 课 C3·KV cache 用锚点模型算过每个 token 要存多少。另一个代价是「窗口没满,模型先变差了」,见本课的岔路。
证据三问
| 问 | 本课的回答 |
|---|---|
| ① 它解决什么问题? | 看清一轮请求的构成,才知道满了之后该先动哪一块 |
| ② 为什么一定会满? | 模型 API 无状态,每轮必须自带全部上下文;循环每转一轮只追加、不删除,单调增长 证据等级 · 数学可证明 在写明的前提下可以严格推导出来,不依赖实验。只要前提成立,换数据、换规模仍然成立。 本条算法性质:没有压缩时,第 n+1 轮的消息列表 = 第 n 轮的列表 + 新的调用与结果。本页回放 11 轮全部与服务端 usage 核对一致。 出处待补 |
| ③ 实验上呢? | 这次运行的构成:第 1 轮工具定义占 72%,第 11 轮工具结果占 44% 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条项目实测:单一模型(Qwen3-8B)、单一短任务。工具更多、文件更大、任务更长时比例会变;「工具结果是最大的一块」在长会话里更明显,Pi 文档的说法是经验概括。 出处待补 |
演进地图:你在这里
- 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)。耗时不作比较(本机有其他任务同时占用)。
一句话
先停在这里:记住构成(工具定义、system、任务、调用、工具结果、模板)和「只增不减」。
再深一层:把你自己的 Claude Code 会话拖进上面的回放,找出你的会话里上下文最大的那一轮;或者走岔路:上下文越长,模型真的越差吗?
下一个缺口
最省事的办法:截掉最早的消息,行不行? → 第 2 课:直接截掉最早的消息,行不行?
你可能会问
- 「为什么不干脆把工具定义删了,省下那 685 个 token?」 模型每轮都要知道能调什么、参数叫什么;A2 的岔路 B1 讲过,少发就等于没有。能省的办法是按需加载(A2 第 4 课的 deferred 工具)和让不变的前缀命中缓存(A8)。
- 「拖进来的 JSONL 会被上传吗?」 不会。文件用浏览器的 FileReader 在本机读,解析完只在这个页面里画图,关掉页面就没了。解析规则:同一条 assistant 消息会被拆成多行写,按 message.id 去重;子 agent 的 sidechain 默认不算。
- 「一轮的上下文和这一轮花的钱是一回事吗?」 不是。上下文是模型读了多少;钱还要看其中多少命中缓存、输出多长。A8 用同样的 usage 字段算账单。