深入拆解:如何为 LLM 智能体组装上下文
打开 Claude Code、Cursor 或者任何别的智能体编码 CLI。让它修一个 bug。看着它调一次工具,再调一次,又调一次。在底下,每一次 LLM 轮次里,这个 CLI 都在做一个你从来看不见的决定:到底把什么当作对话历史送给模型。整段转录、只有最后几条消息、更早内容的一份摘要、原始任务加上最新一次文件读取——每一种都造出一个不同的智能体。CLI 已经替你做好了这个决定,编译期就烤进去了,不读源码就看不到。
这篇文章是一次对 LLM 智能体上下文组装策略设计空间的巡游——也就是一套外壳(harness)在每一轮里对「往提示词里放什么」所做的那些看不见的决定。我们建立一套选择的分类法(压缩发生在哪里、压缩什么、什么时候触发),从源码里考察生产级 CLI 实际是怎么解决这个问题的,并在一个修 bug 的固定用例上跑一个小实验,比较四种有代表性的策略。
实验的载体是 pi——一个把上下文组装暴露成「你自己写的一个函数」的智能体框架,因此换策略非常容易。文章最后给出一个小小的算法提议——按新旧程度截断工具结果——这是我们找到的最便宜的一行式策略,既压住了成本,又不丢掉智能体当下正在推理的东西。
为什么上下文是智能体的核心控制问题
任何一套智能体编码配置都由三部分组成:模型(生成 token 的 LLM)、智能体(在某个环境里反复调用模型的控制循环)以及外壳(围绕它、负责管理上下文、工具、提示词、状态和控制流的软件)。Sebastian Raschka 有一篇很好的讲解,说明这三者如何组合成一个可用的编码智能体。这里要紧的一点是:一场会话里许多看起来像「模型质量」的东西,其实是上下文质量——而上下文质量是外壳的活儿。
智能体的循环,剥到只剩本质,是这样:
- 把消息历史发给 LLM。
- 拿回来的要么是文本回复,要么是工具调用。
- 如果是工具调用:执行它们,把结果追加进历史,回到第 1 步。
- 如果是文本:结束。
现在想想一场真实会话里第 1 步会变成什么样。用户的提示词很小——顶多几百个 token。系统提示词是固定且有界的。在任意一轮里,模型看到的上下文几乎全都是工具调用和它们的结果。 一次文件读取返回 2000 个字符的源码,一次测试运行返回 1500 个字符的失败输出,一次 ls 返回 500 个。智能体可能走三十轮,每一轮都往历史里再追加一批这些东西。
到第三十轮,你每次调用光是工具输出就要送 ~100,000 个 token,每一次调用都是。
工具的输入输出主导了上下文大小——因而也主导了成本、延迟,以及留给模型真正去推理的空间还剩多少。
正因如此,大多数上下文组装策略在骨子里都是工具输出处理策略:什么时候原样保留工具结果,什么时候截断,什么时候换成占位桩,什么时候把一批更旧的折叠成一份摘要。少数策略是在整条消息的粒度上操作(滑动窗口、整段历史压实),但设计空间的主体——以及本文里几乎全部的成本差异信号——来自每种策略对智能体一场会话里累积的那几兆工具输出有多狠。
如果我们干脆让历史无节制地长下去,就会撞上四个问题:
- 成本。 每一轮的每一个输入 token 你都要付钱。三十轮的运行里成本随历史线性增长,意味着最后几轮是你做过的最贵的调用。
- 延迟。 提供商会流式输出,但不会流式输入。一个 10 万 token 的提示词,在第一个响应 token 到来之前,光是提交和分词就要花掉可观的墙钟时间。
- 上下文窗口大小的限制。 Gemini 2.5 Flash 上限是 100 万 token;Claude Sonnet 是 100 万;大多数其他模型是 20 万。撞到天花板,调用就直接失败。
- 长上下文退化。 即使还在窗口以内,模型对提示词开头和结尾的注意力也强于中间(Liu 等,2023)。对智能体来说,这意味着最早的几轮(目标、系统提示词)和最近的几轮(最新的工具结果)会被好好照顾,而中间——过期的工具输出、放弃掉的修复尝试、只做了一半的推理——不管窗口还剩多少,都会被稀释。
每个智能体都必须对如何应对这些压力做出选择。这个选择——显式的或隐式的——就是它的上下文组装策略。有些策略完全无视这些压力(每一轮都把全部内容发出去),让用户去买单。有些则激进地重写历史。大多数 CLI 在这条谱系上挑一个点,然后就那样发布了。
一个上下文组装策略不是单一决定,而是一叠相互作用的选择:
- 丢掉或重写什么?
- 什么时候做——每一轮,还是只在跨过阈值时?
- 在栈的哪一层做——工具层、策略层,还是两层都做?
- 有多激进——按 token、按字符,还是按消息?
- 摘要什么——更旧的历史、整段对话,还是某些类型的工具?
- 怎么摘要——自由发挥、结构化模板,还是多轮串联?
- 缓存怎么办——你的变换是尊重前缀,还是每一轮都让它失效?
每一项都是真实的选择,而组合数很快就变得复杂。对短的单 bug 会话完美的策略,在多 bug 的探索型任务上可能是灾难;在小文件上帮忙的工具层上限,到大文件上就会主动藏起 bug;能抓住单个 bug 修复的摘要模板,遇到四个 bug 时可能会丢线索。
全文中我们会依赖一小组数字来具体地谈每种策略。它们不是基准分数——这个固定用例是刻意做小的,而且每种策略我们只跑 3 次(k=3),所以这里没有任何东西算得上正式评测。把这些数字当作一套共同词汇:一种指出每种策略如何表现、它的失效模式在哪儿冒出来、钱花出去时花在哪儿的方式。 我们最常回头看的两个指标是通过率和成本,底下还有几个诊断数字,帮助解释一种策略为什么会便宜或者昂贵。
| 指标 | 它告诉我们什么 | 我们怎么测 |
|---|---|---|
| 通过率 | 任务做成了吗?没成功,其余数字其实都不重要。 | 每次运行二值判定;每种策略 3 次试验(k=3)。 |
| 成本中位数 | 一次典型运行要花多少钱。 | 每次调用成本之和;按三次试验的中位数报告。 |
| 最坏情况成本 | 一次糟糕的运行长什么样。值得标出来,因为按中位数挑选可能掩盖掉 20 次里出现 1 次的 4 美元灾难。 | 三次试验里的最大成本。 |
| 轮数 | 延迟的替身——LLM 调用越多,墙钟时间越长。 | 每次运行里助手轮次的数量。 |
| 每轮提示词大小 | 诊断项。解释成本为什么是现在这个数。 | 每次 LLM 调用的 input_tokens + cached_tokens。 |
| 缓存命中率 | 诊断项。如果策略每一轮都让缓存失效,每轮很小的提示词照样可能贵得像个大提示词。 | 每次调用的 cached_tokens / total_tokens。 |
后面读表格时,自然的观看顺序是:先看通过率(一个不能可靠修好 bug 的策略其实没进入比赛),再看最坏情况成本(它告诉你中位数平均掉的那个失效模式),如果想搞清楚原因,再看诊断项。
策略分类法
指标定下来了,接着看它们将被应用到的设计空间。出现在生产级 CLI 里的策略,沿着两条轴分成少数几个家族:压缩发生在哪里,以及什么被压缩。
最简单的策略是什么都不做——每次 LLM 调用都把整段对话原样发出去。我们把它叫作 baseline,它是显而易见的起点:零实现成本、完美的缓存稳定性,而且在短会话上它跟任何更花哨的做法都不相上下。在你撞上模型的上下文窗口、撞上预算,或者长上下文退化开始在要紧的事情上让你损失准确率之前,baseline 都挺好。
其他每一种策略都是实现压缩的一种方式。压缩可以发生在三个地方:
- 每一轮。 在每次 LLM 调用时对消息列表施加一个变换,随着对话增长塑造每一轮的提示词。
- 只在阈值处。 在对话越过某个大小限制之前不去动它,然后对较旧的部分触发一次性操作(通常是摘要),并把结果冻结到整场运行结束。
- 在工具层。 在工具的返回值进入对话历史之前就把它限流或重写。它位于另外两者下面一层,并且和它们可以组合——我们在后面详细讲。
在策略层的两种模式之间,谁也不能压倒谁,而且经常被合起来用。每一轮更便宜(没有额外的 LLM 调用),从第 1 轮起就对缓存稳定,而且诚实——插进提示词里的标记,比如 …[truncated 1500 chars],会告诉模型有东西缺了,而摘要即便漏掉了 bug 也可能看起来很完整。在阈值处给对话设了界(压实会缩短消息列表,所以多轮版本可以一直跑下去),在短会话上完全不付压缩成本,而且回收了注意力预算——压实之后的提示词小到每个位置又都能被好好照顾。经验法则:短会话偏向「每一轮」,长会话偏向「在阈值处」,而任何值得发布的生产级 CLI 最后都会把两者结合。
有一种策略不需要压缩——滑动窗口——它只保留最后 K 条消息,其余全部丢掉。 但它在构造上就是与缓存为敌的,而且被丢掉的消息永远回不来了——这让它成为这套阵容里最激进的有损策略。在多 bug 任务上它是灾难性的:早先的修复滚出窗口,智能体再遇到它们时当成陌生代码,把它们撤销掉,然后陷入循环。 所以我们没有把滑动窗口放进实验——下文提到它,只是在它能用来说明「与缓存为敌」这个概念的地方,任何有生产参考价值的比较都不会带着它上路。
既然每种策略都得压缩,下一个问题就是压缩什么。五种常见模式:
- 丢掉旧轮次。 只保留最后 N 条消息。经典的滑动窗口。变体:把原始的用户提示词钉在最前面。
- 丢掉旧的工具结果。 保留工具调用(从而保住推理轨迹),但丢掉或截断它们的输出。
- 截断工具输出。 每条消息都保留,但把每个工具结果限制在一个最大字符数以内——要么在策略层做(每一轮重新施加),要么在工具的实现里做(在执行时限一次,然后就以被限后的形式存起来)。
- 摘要旧轮次。 一旦历史超过阈值,就发起另一次 LLM 调用,产出一份紧凑摘要来顶替被丢掉的那些轮次。opencode 和 pi 自带的 coding-agent 在溢出时都是这么做的。
- 按需检索。 保留完整日志,为每一轮做向量嵌入,每次调用只把与当前目标最相关的 top-k 轮次放进去。据我们所知,还没有人在真实 CLI 里把这个发布出来。
这些模式并不互斥——大多数生产策略会叠两三种,比如:截断每个工具结果,然后摘要较旧的轮次,然后丢掉摘要之前的内容。
缓存稳定性
有一个属性无论压缩发生在哪一层都主导着成本,因此极其重要、务必做对。那个属性就是缓存稳定性。每一个现代 LLM API 对最近见过的 token 和第一次见到的 token 收费不同。
Gemini 的隐式前缀缓存、Anthropic 的 cache_control 块
和 OpenAI 的提示词缓存在结构上工作方式相同:提供商对你请求开头的那段字节序列做哈希,在最近的请求里找匹配,找到了就对这些 token 收取低得多的费率(通常是未缓存价格的 10–25%;细节各家不同)。
被缓存的那部分必须是一个前缀——从第 0 个字节开始的一段连续且完全一致的序列。第一个不同的字节会让它之后的一切都失效。
如果一个策略最多只在已知位置上有限次地改动前缀,它就是缓存稳定的。 具体来说:策略可以把某个位置变换一次——例如截断一个刚刚滑出最近 K 窗口的工具结果,或者在压实时用冻结的摘要替换较旧的历史——但一旦变换过,那个位置的字节就再也不变了。每次变换在那个位置上花掉一次缓存写入;之后的每一轮都落在缓存命中上。最严格的版本是 baseline,其中每条消息从被追加那一刻起就保持逐位相同,根本不做任何变换。策略并不需要这么僵硬才能对缓存友好:只要变换发生在已知的点上、之后保持冻结,缓存就能便宜地摊销掉。
智能体的对话是单调增长的:用户 → 工具调用 → 工具结果 → 助手 → 工具调用 → 工具结果 → 助手。到第 30 轮,你每次调用都在发送 ~10 万 token 的、大部分稳定的历史,每一次调用都是。如果你的策略保持前缀稳定,这 10 万 token 大多是缓存命中,你只对尾部那几百个新 token 付全价。如果你的策略每一轮都改动前缀,同样这 10 万 token 就全都没缓存,账单要高出 4–10 倍,而行为上没有任何好处。
一个简单的缓存稳定例子是对工具结果设上限:每一轮策略都走一遍带完整工具输出的对话,把每个工具结果截断到 500 字符。因为规则是确定性的,而底层的工具结果文本不会变,所以截断后的版本在后续每次调用中、在同一位置上都是逐位相同的。
反面的例子——我们称之为与缓存为敌——就是滑动窗口:它每一轮都重排落在每个位置上的内容,于是缓存命中在初始提示词之后就全面崩塌,每个 token 都按未缓存费率计费。这里没有「此后冻结」——只有持续的搅动。缓存稳定与与缓存为敌之间的差别,是各次运行中主导成本的因素。
推论是:策略实际上只有两种对缓存友好的形状:只追加(永远只改尾部——baseline 和各种截断变体都属于这类,因为一个工具结果一旦被截到 N 个字符,那 N 个字符就不再变了)或者冻结一次(做一次大重写——通常就是压实——然后再也不动它,于是重写之后的前缀成为新的稳定冻结前缀)。
其他任何做法——周期性重新压实、以当前轮次为键的动态窗口驱逐、原地重写摘要——都会一轮又一轮地改动前缀,默认就是与缓存为敌。
生产级 CLI 确实会周期性重新压实却不用付这个代价,但那只是因为它们让先前的摘要保持冻结、追加新的摘要块而不是重写旧的,并且使用显式的缓存断点(Anthropic 的 cache_control、OpenAI 的 prompt_cache_key),好让提供商知道稳定前缀到哪里为止。
「缓存」的两种含义:前缀缓存与语义缓存
上面讲的全都是前缀缓存——提供商侧的那套机制,让反复重发一段长而稳定的对话变得便宜。值得把它跟智能体系统里另一个也被叫作「缓存」的东西分开:语义缓存,它坐在模型前面,试图把调用整个跳过。
两者容易混淆,因为都承诺「更便宜的 LLM 调用」,但它们工作在不同的层,失效模式也不同:
- 前缀缓存以精确的 token 前缀为键——从位置 0 开始、逐字节一致的连续序列。命中意味着提供商在这些 token 上跳过 prefill,并按正常费率的 10–25% 收费;模型照样运行,照样生成一份新的补全。这纯粹是计算上的优化,所以它总是正确的——输出跟不缓存时完全相同。这正是本文中每个策略隐含在优化的东西,而它活在推理提供商内部。
- 语义缓存以请求的含义为键——你把进来的提示词做嵌入,在过往提示词的存储里做向量检索,如果有东西的相似度超过阈值,就直接返回它存好的响应,压根不调用模型。命中省掉的是整次调用,而不只是 prefill。但它是近似的:阈值太松会返回另一个问题的答案,而任何依赖时间或上下文的东西都会过期。它活在你的应用里(Redis、向量数据库、GPTCache),阈值、TTL 和失效逻辑都归你管。
| 前缀缓存 | 语义缓存 | |
|---|---|---|
| 所在层 | 推理提供商 | 你的应用 |
| 键 | 精确的 token 前缀 | 请求的嵌入 |
| 命中时 | prefill 更快,仍是新生成 | 返回存好的响应,不调用模型 |
| 节省 | 部分——只省 prefill | 全部——完全不推理 |
| 正确性 | 总是精确 | 近似;可能错误或过期 |
| 你要管的 | 缓存断点、前缀稳定性 | 阈值、驱逐、失效、分区 |
对编码智能体来说,语义缓存基本上是用错了工具:轮次很少在语义上可互换(每一轮都以仓库当下的确切状态为条件),而一次错误命中意味着按一个关于「此后已经变了的代码」的过期答案去行动。它在更窄的场景里才划算——FAQ 式助手、分类端点,以及任何「同样那几个问题在很多用户之间反复出现、且正确答案不依赖可变状态」的地方。两者在都适用时能干净地组合:前面放一个语义缓存来短路重复问题,底下用前缀缓存来照顾所有真正抵达模型的请求。本文余下部分讲的是后者——让前缀保持稳定,好让你确实要做的那些调用保持便宜。
有损存储 vs 有损视图
单独摆着的一个问题是压缩发生在哪里——也就因此决定了对话里存下来的是什么。两个选项,对模型而言可观察到的效果相同:
- 在工具层(有损存储)。 工具在结果进入对话之前就限制或重写它的返回值。被压缩过的文本才是追加进历史、并永远留在那里的东西。opencode 的
read_file就是这么做的——关于工具输出设计的那一节后面会讲到。一旦工具返回了 50KB 的一个切片,文件的其余部分在本地哪儿都没有;要拿回来,智能体只能用不同的offset再调一次工具。 - 在策略层(有损视图、无损存储)。 工具返回它的完整输出。完整文本被追加进历史。每一轮,一个轮级钩子(在 pi 里叫
transformContext,等讲到我们的实现时再细说)会基于完整历史重新导出一个压缩视图,只供这一次 LLM 调用使用——截断、摘要、打桩、丢弃,策略要干什么都行。对话日志永远保留每一个字节;被缩减的只是模型在这一轮里看到的视图。我们跑分的四种策略(baseline、truncate-500、age-truncate-500-keep-3、compact-at-12000-structured)全都是这种方式。
这个差别在 LLM 的提示词里看不出来——两种设计产出同样的文本。它体现在留在磁盘上的东西上:
- 可恢复性。 策略层压缩是可逆的:中途换一个策略(或者以后用另一种策略重放日志),完整文本就回来了。工具层压缩不再调一次工具就不可逆。
- 可组合性。 策略层让你可以在同一份底层日志上试验不同的视图。工具层把数据永久冻结在它最初的形态里。
- 运行时成本。 策略层每一轮都要做压缩工作(便宜,但非零)。工具层只做一次。
这种分野也反映在智能体框架暴露什么钩子上。工具层压缩根本不需要框架钩子——工具就是你自己写的函数,所以在工具层设上限就是把上限写进工具的实现里。 策略层不同:它每一轮都要对着一个移动的目标(不断增长的对话)运行,所以框架必须为它暴露一个入口点。
我们的实验刻意只用策略层压缩,正是为了让完整对话被保留下来——每次运行都记录未经修改的工具输出(通过我们在 pi 的事件流之上加的一个 logger——pi 把对话保存在内存里,但自己不持久化任何东西),而各个策略变体都是对同一份数据的纯重放。
工具输出的设计:另一半图景
到目前为止的一切都发生在上下文组装层——transformContext 作用在一份已经到手的消息列表上。但下面一层还有一个平行的设计空间:工具自己选择返回什么。一个直接倾倒原始输出的工具,会把所有活儿都推给你的策略。一个自己给输出设界的工具,会让你策略要干的活儿变小——有时小到几乎没有。
工具层上有两个维度要紧:
- 每次调用的上限。 工具在一次调用里最多会返回的大小。opencode 的
read_file上限是总共约 50KB / 每行 2000 字符。Claude Code 的读取工具在文件侧上限是 256KB,在渲染输出上是 25K token。超出上限的内容会从返回值里省略掉,智能体不再问就看不到。 - 分页。 智能体能不能要下一片。opencode 和 Claude Code 的读取工具都接受
offset/limit参数,于是一个 200KB 的文件变成四次连续的read_file调用,而不是一次被截断的读取。对话里最后留下的是四个小而缓存稳定的工具结果,而不是一个又大又被部分截断的结果。
它与上下文组装层的互动是直接的:如果你的工具自己就有界,你的策略要做的事就更少。 opencode 把整段对话都留在上下文里(默认路径里既没有消息级丢弃,也没有对话级截断),而它能这么干,是因为每个工具结果在构造上就已经很小了。在 opencode 的工具之上跑 baseline 策略,跟在一个会返回 1MB 原始文本的工具之上跑 baseline,行为会非常不同——尽管那是同一个 baseline。
会分页的工具在真实意义上做的是无损压缩:没有东西被永久丢弃,文件其余部分还在磁盘上,智能体可以按需取。只设上限而不分页的工具做的是有损压缩:上限之外的东西一直不可见,除非工具的契约改了。
真实的 CLI 是怎么处理上下文的
在敲定我们要测量哪些策略之前,值得先看看生产级 CLI 实际是怎么解决这个问题的。每一家都从分类法里挑了自己那一份特定的混合,有时还受其目标提供商 API 所提供能力的影响。下面是从源码里能看到的情况的快速巡览。
Claude Code
2026 年 3 月,Anthropic 发布的 @anthropic-ai/claude-code v2.1.88 带了一份约 60 MB 的 source map,暴露出约 51.2 万行 TypeScript。不久之后出现了几篇拆解(Straiker、Karan Prasad)以及一份逐字的提示词存档(Piebald-AI/claude-code-system-prompts)。这让 Claude Code 成为本文里迄今为止最有经验依据的参照点——它是唯一一个我们能真正读到实现的大型闭源智能体 CLI。
缓存稳定性显然是 Claude Code 最在意的事情之一——泄露的代码显示出多处刻意的、彼此强化的手法,为的是让前缀在各次请求之间保持逐位稳定。
其中最显眼的一手是静态 / 动态提示词分割。Claude Code 的提示词被组织成一个位置固定的布局,其中靠前的块——系统提示词、工具描述、工作区摘要、CLAUDE.md 的内容——在会话中每个请求里都逐位相同。它们被显式标上 Anthropic 的 cache_control: { type: "ephemeral" },以告诉 API 这段前缀是可缓存的。
可变的块跟在后面,位置也是已知的。示意上,每个请求都长得像下面的例子——切换标签页看看压实触发时形状怎么变:
// Early in a session, before history has crossed the compaction threshold.
await client.messages.create({
model: "claude-...",
system: [
// ─── STATIC: bit-identical across turns ─────────────────
{ type: "text", text: SYSTEM_PROMPT }, // ~5KB, never changes
{ type: "text", text: TOOL_DESCRIPTIONS }, // ~8KB, never changes
{ type: "text", text: workspaceSummary }, // computed once at session start
{ type: "text", text: CLAUDE_MD_CONTENTS,
cache_control: { type: "ephemeral" } }, // 👈 cache breakpoint #1
// (everything above this point is cached)
],
messages: [
// ─── frozen prefix: just the seed user message ─────
{ role: "user", content: SEED_USER_MESSAGE,
cache_control: { type: "ephemeral" } }, // 👈 cache breakpoint #2 (on the seed)
// ─── tail: every turn so far, appended ─────────────
...allTurnsSoFar,
],
});// After compaction has fired at least once: the older portion of the
// conversation has been replaced by a frozen summary, and breakpoint #2
// has shifted forward to land on it.
await client.messages.create({
model: "claude-...",
system: [
// ─── STATIC: bit-identical across turns ─────────────────
{ type: "text", text: SYSTEM_PROMPT }, // ~5KB, never changes
{ type: "text", text: TOOL_DESCRIPTIONS }, // ~8KB, never changes
{ type: "text", text: workspaceSummary }, // computed once at session start
{ type: "text", text: CLAUDE_MD_CONTENTS,
cache_control: { type: "ephemeral" } }, // 👈 cache breakpoint #1
],
messages: [
// ─── frozen prefix: bit-stable from compaction onward ─────
{ role: "user", content: SEED_USER_MESSAGE }, // first user message in the run
{ role: "assistant", content: FROZEN_SUMMARY,
cache_control: { type: "ephemeral" } }, // 👈 cache breakpoint #2 (last frozen item)
// ─── tail: appended each turn since compaction ───────────
...recentKMessages,
],
});两个断点,位置固定。新 token 只对追加在尾部的内容计费。为什么是两个而不是一个?因为每个 cache_control 标记都会创建一条独立的缓存条目——这让提示词的不同部分能以不同速率失效(静态块~永不改变,摘要之后那段每次压实改一次,尾部每轮都改),并且当某条条目的 TTL 比其他的先过期时还有回退命中可用。
两个标签页之间的形状转换,是整场会话里断点 #2 之前的前缀唯一会变的地方。压实触发时,两件事同时发生:尾部较旧的部分坍缩成新的 FROZEN_SUMMARY 块,而断点 #2 从「落在初始消息上」往前挪到「落在冻结摘要上」。这一次转换要付一次缓存失效——API 必须在新边界上写一条新条目——但此后每一轮都落在新的、更长的缓存前缀上。
那下一次压实,以及再下一次呢?无论压实重复多少次,请求里始终带着同样的两个缓存断点——cache_control 是按请求生效的,所以只有当前这次调用里的标记才算数。断点 #1 一直锚在 CLAUDE.md 的末尾;断点 #2 落在请求那一刻最新的稳定项上。你不会随着会话推进不断加断点——Anthropic 每请求 4 个的上限是一份预算,而不是一个随会话长度增长的计数器。
各次压实之间确实可能不同的,是在那唯一一个断点 #2 标记后面,冻结内容是怎么摆放的。两种合理的设计:
- 轮换式(Claude Code 的选择)。 永远只有一个
FROZEN_SUMMARY槽位。每次后续压实,都把先前的摘要加上新的尾部重新摘要成一个全新的单块,替换掉旧的。FROZEN_SUMMARY位置上的字节变了——所以断点 #2 的缓存条目每次都得重写。每次压实一次缓存失效,但提示词保持紧凑(不管会话多长,形状都是[seed + summary + recent])。 - 链式。 每份新摘要追加在先前那些冻结摘要之后;断点 #2 前移落在最新的那一份上。前缀会增长——
[seed, summary_1, summary_2, ..., recent]——但每一份先前的摘要都保持字节稳定,所以更老的缓存条目(仍在提供商的池子里、来自更早的请求)即使当前请求里不再标记它们,也能作为回退命中。对缓存更友好,但提示词随压实次数线性增长,所以最终你还得去压实这条链本身。
Claude Code 选择轮换,是拿偶尔的缓存失效换提示词的紧凑。这笔账偏向紧凑,因为相对于轮次,压实是稀有事件——你可能几十轮才触发一次,吞下一次缓存失效,然后靠着新条目吃上好几千个缓存 token 直到下一次压实。代价是:每一次重新摘要都是在一份已经有损的摘要之上再做一次有损操作,所以一场长会话下来,细节会累积性地被冲刷掉。
Anthropic 的提示词缓存文档讲了完整机制。简版是:每条 cache_control 指令都会在缓存里创建一条新条目,锚在第 0 个字节,止于该标记所在位置。所以上面那个两断点的例子会写入两条嵌套条目——一条止于 CLAUDE.md 边界,一条止于冻结摘要。下一个请求到来时,API 先尝试命中最长的缓存前缀,若更长的那条不再匹配,就回退到更短的。这让提示词的不同部分能以不同速率失效,也让你落在部分命中上,而不是全有或全无。
Anthropic 的内联标记模式是各大提供商里最干净的一种。OpenAI 是纯自动的——由 API 自己决定在哪里写条目(TTL ~5 分钟);你可以用 prompt_cache_key 给请求分组,但没法标记位置。Gemini 两者都提供:一个自动的隐式缓存,外加一个显式的 cachedContents API,你先创建一个带可配置 TTL 的缓存资源,然后在后续调用里按名字引用它(与 Anthropic 的请求内标记的手感不同)。Anthropic 允许你在每个请求里内联标记最多 4 个字节位置,还可选 1 小时 TTL(写入附加费更高)。OpenAI/Codex 那一味——自动加 prompt_cache_key——我们在下一节再说。
Claude Code 对提示词所做的每一次变换,都是关于良定义状态(轮数、工具调用参数、消息位置)的纯函数,从不依赖诸如当前分支 HEAD 哈希这类会飘的信号。任何一轮一变的东西都会迫使前缀在每个请求里都改变,从而摧毁缓存。这就是为什么一场 30 轮的编码会话,尽管每次调用要送 ~10 万 token,成本却能接近 baseline。
按工具的老化策略
Claude Code 维护着一份硬编码的工具名列表——在泄露里名为 COMPACTABLE_TOOLS——它们要接受逐轮老化。按我们的分类法,这是策略层上的一个每一轮策略,尽管名字里带着「compactable」(这词暗示的是阈值处压实——那是 Claude Code 另外还有的一套机制)。不在列表上的工具豁免:它们的结果永远原样保留。
这里有意思的一手,是从处理工具输出的那两个常见极端里跳出来:永远原样(大多数策略默认落到这里)和永远限流或分页(opencode 的 read_file 在工具层做的事)。老化加进第三个选项,把两者合起来——最近的结果原样保留,更旧的往下老化。同一个工具,按结果有多陈旧而区别对待。这解决了两个极端各自制造的问题:永远原样会让过期的 50KB 大块一直堆积;永远限流可能把智能体刚打开的文件切掉(这正是下文 truncate-500 的失效模式)。
Claude Code 还把这个想法再推进一步:不只是何时老化,还有如何老化。列表上的每个工具都有不同的规则,按该工具的输出随时间保值的方式来调:
| 工具 | 老化规则 |
|---|---|
read_file | 一旦比最近 3 次读取更旧,就把结果换成一行写着路径的占位桩 |
list_files | 一旦比最近 2 次列目录更旧,就截断到 200 字符 |
write_file | 永远原样保留 |
run_tests | 永远原样保留 |
关于「比最近 3 次读取更旧」到底是什么意思,有个小澄清:它数的是同一个工具的调用次数,不是轮次。举个具体例子,假设智能体到目前为止的调用历史是:
turn 1: read_file(api.ts) ← 1st read
turn 2: list_files(./src)
turn 3: read_file(api.ts) ← 2nd read
turn 4: run_tests()
turn 5: read_file(storage.ts) ← 3rd read
turn 6: read_file(api.ts) ← 4th read
turn 7: read_file(serializer.ts) ← 5th read第 7 轮之后,只看 read_file 调用(第 1、3、5、6、7 轮的那些),最近的 3 次是第 5、6、7 轮。于是:
- 第 1 轮和第 3 轮的读取 → 打成占位桩。
- 第 5、6、7 轮的读取 → 原样保留。
如果第 8 轮是 run_tests()(不是读取),什么都不变。等到第 9 轮又是一次 read_file,第 5 轮那次读取就老化了——它变成第 4 新的读取——于是被打成占位桩。每个工具各自按调用新旧排名,各自排名的前 K 名保持原样。
每一行背后的道理,用大白话说:
- 过期的
list_files几乎是纯噪声——智能体一旦不再探索那个目录,旧的列目录结果后续价值近乎为零。所以对它截得又狠又快。 - 过期的
read_file结果更微妙:智能体可能还想要那个文件。Claude Code 不去截它的文本,而是换成一个写着路径的占位桩;智能体可以用同样的参数再调一次read_file取回来。从「什么都没丢、只是推迟了」的意义上说,这是无损的。 - 过期的
write_file代表的是智能体做过的一个动作——它改了那个文件。忘了自己写过什么,正是下一轮换个写法再写一遍的配方。原样保留。 - 过期的
run_tests携带的是权威的测试套件状态,智能体常常要拿它来反复对照。原样保留。
它的形状跟我们的 age-truncate-500-keep-3 相同——缓存稳定、以新旧为键、确定性。它的推广在于按工具的粒度,而不是对所有工具结果用一条统一规则。在很多工具被调用很多次的长会话里,这种粒度是划算的,因为每个工具的驱逐都贴合它真实的保值曲线。而在我们 10–20 轮的运行里,智能体对每个工具只调用寥寥几次,这份细腻没有施展空间——统一的按龄截断已经拿到了大部分节省。
结构化摘要模板
Claude Code 最后一个值得拎出来的设计,是摘要提示词本身——也就是压实触发时发给模型的那个系统提示词。泄露里它以 system-prompt-context-compaction-summary.md 的形式存在。我们的 compact-at-12000-structured 策略复用了这个提示词的一个改写版本——同样的五个小节、同样的约束——所以它产出的摘要跟 Claude Code 的形状一致:
You produce continuation summaries for coding agents that have run out of context.
Output the summary wrapped in <summary></summary> tags, with the following five sections
as level-2 markdown headings, in order:
## Task Overview — what the user asked for, in one or two sentences.
## Current State — files created, modified, or analyzed, listed with their full paths;
state of the test suite; open work.
## Important Discoveries — key facts the agent learned, including approaches that did
NOT work and why.
## Next Steps — the immediate action the continuing agent should take.
## Context to Preserve — user preferences, promises made, constraints that must not
be violated.
Be specific. Cite exact filenames. No filler. No conversational framing.这个设计有三个细节让它奏效:
- 小节顺序对应人类的工作交接。
Task Overview → Current State → Discoveries → Next Steps → Context to Preserve大致就是一名工程师向接手任务的同事交底的顺序:目标是什么、我们到哪儿了、学到了什么、接下来干什么、什么东西不能掉在地上。压实之后的智能体也是这么读它的。 - 「没有奏效的方法」放在 Discoveries 里。 没有这条显式要求,摘要往往只聚焦已经达成的事,悄悄丢掉死胡同——于是压实之后的智能体又去重试那些失败过的方法,把轮次烧掉。就这一句话,防住了自由式摘要提示词偶尔会踩的一个特定失效模式。
- 「Cite exact filenames. No filler. No conversational framing.」 这个模板逼着摘要变成可执行的,而不是叙事性的。文件名在多次重新生成之间也是逐位稳定的,如果后来某个多轮实现要做增量重新摘要,这能保住缓存命中。
在普通的「把转录摘要一下」提示词上加三行约束,产出的摘要就更锋利、更小,也更便于压实后的智能体据此行动。可推广的教训是:摘要提示词本身就是策略的一部分——不是可以丢给模型默认行为的细节。
Codex
Codex 用简单得多的办法处理同一个问题。Claude Code 和 Codex 都极其在意缓存稳定性——只是它们用了不同分量的机械装置来达成。Claude Code 把它做成显式契约:请求里的 cache_control 断点、确定性的驱逐、手工调过的提示词结构。Codex 则依赖只追加的历史加上 OpenAI 的前缀缓存——由 API 自己决定在哪里写缓存条目、写多久,而 Codex 只是不去碍事。
用我们分类法的话说,Codex 的稳态行为就是「什么都不做」策略——原样发送对话、只追加、不做逐轮重写、前缀单调增长。有意思的补充只在作为兜底时才启动。其中两个是串联工作的:
- 阈值处压实是主要兜底——主动型。它在对话越过一个由 Codex 控制、设得低于模型实际上下文窗口的阈值时触发,所以会在 API 会拒绝之前就先动手。机制:额外一次 LLM 调用摘要较旧的部分,对话被重建为
[summary, recent...]。代价是一次摘要调用,但为被丢掉的细节留下了一个连贯的替身。 - 紧急从头修剪是恐慌按钮——被动型。它只在压实已经发生之后、正常请求仍然返回
ContextWindowExceeded时才触发(这可能发生在压实之后尾部又长回来,或者连压实自己的输出都太大的情况下)。机制:丢掉最旧的一条消息,重试;再丢下一条最旧的,重试;循环直到请求装得下。不需要 LLM 调用,但那些条目彻底消失、没有摘要,而且每次重试都是一次白花钱的请求。
在任何一场会话的大部分时间里——第一次压实触发之前——Codex 在策略层上的行为就是恒等变换。上面那些兜底机制,是防止「什么都不做」的做法在长会话里崩掉的东西,而不是它处理稳态成本的方式。(Codex 也有工具层上限,下面单独讲。)
给好奇者的策略层细节。 缓存稳定性靠的不止是只追加的前缀——Codex 还设了按对话的缓存键(prompt_cache_key = conversation_id),把 OpenAI 的缓存限定在这场会话内。压实实现在它自己的模块里;它的摘要提示词是一个结构化模板——被框定为「给另一个 LLM 的交接摘要」,跟上面 Claude Code 的五小节模板是同一个概念性手法,只是结构没那么严格。两个生产级 CLI 学到了同一课:自由式的「把转录摘要一下」不够;你想要一份关于摘要必须包含什么的契约。压实之后的尾部上限是 2 万 token。紧急修剪路径带着一条写明理由的注释:「to preserve cache (prefix-based) and keep recent messages intact.」
工具层细节。 Codex 的两个工具层机制都在执行时运行,所以对话里存的永远只是已经被截断的版本——跟 opencode 的 read_file 50KB 上限占的是同一个架构位置,也就是前面工具输出的设计那节讲的有损存储那一味。第一,shell 工具有一个1 MiB 的硬输出上限;超过之后模型只能自己用 sed -n '...p' 之类的办法凑出分页。第二,记录下来的 shell 输出会经过一个中段截断的 TruncationPolicy——中段截断原样保留开头 N 个字节和结尾 M 个字节,把中间那一段换成 ...[truncated K bytes]... 标记,赌的是对 shell 输出而言,开头的命令回显和结尾的退出状态才是带信号的字节。
opencode
opencode 把整段对话都留在上下文里,不做对话级截断。压缩发生在两层:
- 工具层。 工具在执行时自我设界:
read_file上限是约 50KB / 每行 2000 字符,grep 的匹配结果分页,等等。对话里积累的是很多小结果,而不是少数几个巨大的。 - 策略层。 当对话越过阈值时,opencode 跑多轮摘要(形状与 Claude Code 和 Codex 相似)来压实较旧的历史。
分工是:有界的工具承担了大部分尺寸压力,压实收拾剩下的。opencode 的赌注是:对每个工具的精心设计,能让策略层保持轻量。
pi-coding-agent
裸的 Agent(我们实验用的那个)不带任何默认策略——这正是它便于干净比较各种策略的原因。构建在 Agent 之上的更高层包 pi-coding-agent 则在溢出时提供多轮摘要,形状类似 opencode 和 Codex。
工具是按智能体定义的;自带的 read_file 不强加自己的上限。
我们实现的那些策略
看过生产级 CLI 实际都在做什么之后,我们就可以挑一小组有代表性的策略来测量了。本文中每个策略都要跟 baseline 比——一个恒等策略,什么都不丢也不重写,前缀随轮次单调增长,因此缓存命中处在理论最大值。
每种策略跑的是同一份工作负载——同一个固定用例(下文描述)、同一个模型、同样的提示词。每种策略我们把智能体跑 3 次(k=3)——因为即便在 temperature=0 下,Gemini Flash 的轨迹也会在不同运行之间发散,所以我们为每种策略收集 3 个数据点来算中位数/离散度,而不是押在一次试验上。我们测量通过率、总成本、轮数、峰值提示词大小,以及输入 token 里有多少是按缓存费率、多少是按未缓存费率计费的。
我们会详细走一遍一个固定用例——一个单文件的修 bug 任务,我们称之为主固定用例——并给出它的各策略结果、转录以及逐轮差异。我们为每种模式挑了一个典型实例,而不是做参数扫描;目标是教会形状,不是把每个设定都跑个分。下表每一行都对应上面生产级 CLI 里的一种模式:
| 模式 | 策略 | 方式 |
|---|---|---|
| 不做变换(对照) | baseline | — |
| 截断工具输出(统一) | truncate-500 | 每一轮 |
| 截断工具输出(按龄) | age-truncate-500-keep-3 | 每一轮 |
| 摘要较旧轮次(结构化) | compact-at-12000-structured | 在阈值处 |
关于命名的说明。每个策略名遵循 <家族>-<参数>[-<修饰>] 的形状。所以 age-truncate-500-keep-3 读作:家族 = age-truncate(按龄截断),500 = 对较旧结果的字符上限,keep-3 = 最近 3 个工具结果原样通过。同样,compact-at-12000-structured 是:家族 = compact,at-12000 = 对话越过 12,000 字符时触发,structured = 使用结构化摘要模板(相对于自由式变体)。每个数字 token 的含义,都由它所参数化的那个家族旋钮给出。
分类法里剩下的模式——丢掉旧轮次(sliding-N)、按需检索、把旧读取换成占位桩(Claude Code 的 COMPACTABLE_TOOLS 那种形状)、自由式摘要——是有意排除在外的。sliding-N 和检索,因为它们在构造上就与缓存为敌(前缀每一轮都变)。旧读取打桩和自由式压实,因为它们是我们已经测的那些模式的变体(旧读取打桩是 age-truncate 的按工具形态;自由式压实跟结构化压实是同一个形状,只是摘要提示词不同)。至于生产级的多轮压实,因为那是另一个工程问题(选哪个阈值、什么原样保留、要不要把摘要串成链),值得单独写一篇。
下面这张四策略表是同一组,按方式分组——本文成本分析所围绕的那条轴:
每一轮(在每次 LLM 调用时施加变换)。
| 策略 | 丢掉什么 | 实现 |
|---|---|---|
truncate-500 | 每个工具结果里 500 字符之后的文本 | 对工具结果做 map |
age-truncate-500-keep-3 | 只在较旧的工具结果里丢 500 字符之后的文本 | 感知位置的截断 |
在阈值处(对话越过大小限制时触发一次,随后冻结)。
| 策略 | 丢掉什么 | 实现 |
|---|---|---|
compact-at-12000-structured | 固定切分点之前的所有轮次,用 Claude Code 五小节模板生成的 LLM 摘要替代 | 一次 LLM 调用,冻结摘要 |
每种策略的细节——它做什么、一行实现、缓存行为、成本形状、什么时候用、谁在生产里发布了它——见下面的参考表。 点击图标可展开全屏,方便并排阅读。
| strategy | mode | what it does | implementation | cache behavior | cost shape | when to use it | who ships it |
|---|---|---|---|---|---|---|---|
baseline | none (reference) | transformContext returns the message list unchanged. The model sees the entire conversation on every call. | messages => messages. Three characters of code. | Maximally cache-friendly. Prompt prefix grows monotonically — every byte from position 0 is identical across turns. | Linear in conversation length. Per-turn cost grows with each appended message; the last few turns of a 30-turn run are the most expensive of the run. | Short sessions (under ~20 turns) where the conversation fits in budget and window. Also: any session where the cost of losing information would exceed paying for the full prompt. | Every CLI ships baseline implicitly when no strategy is configured. Default in pi (when transformContext is omitted), opencode, and Claude Code before compaction fires. |
truncate-500 | per-turn | Keep every message, but cap each tool result's text at 500 characters with a `…[truncated K chars]` marker. Uniform cap, applied to every tool result regardless of age. | Walk the message list; for each toolResult whose text exceeds 500 chars, replace the tail with the marker. | Cache-stable. The cap is deterministic — once truncated to 500 chars, it stays exactly 500 chars on every subsequent turn. | Smaller prompts than baseline, cache preserved. Cheap in the median when it works. Failure mode: if a bug lives past character 500 of a file the agent reads, the agent never sees it. Fails 0/3 on the main fixture and 0/3 on the multi-file fixture. | Almost never at this aggressive a cap — it actively hides bugs. Demonstrates that the *uniform* truncation pattern is dangerous without an age qualifier. | Variants of this pattern with larger caps (opencode caps tool output at ~2,000 chars at the tool layer); 500 is the failure-mode example we added. |
age-truncate-500-keep-3 | per-turn | Same 500-char cap as truncate-500, but applied only to older tool results. The 3 most recent tool results are kept verbatim regardless of length. | Index tool results oldest → newest. Truncate text past 500 chars on all but the last 3. Recent results pass through unchanged. | Cache-stable. The decision depends only on a tool result's position in the message list, which is monotonic — once a result becomes 'old enough', it stays truncated. | Caps cumulative growth without hiding the file the agent is currently looking at. Ties baseline on cost, passes 3/3. On the multi-file fixture: 50% cheaper than baseline at the same pass rate; on the four-bug fixture: the only non-baseline strategy that passes 3/3. | The article's default recommendation for short coding-agent sessions (10–50 turns). Avoids truncate-500's 'hide the bug' failure while still tightening the middle. | Not directly. A generalization of Claude Code's per-tool eviction policy. We propose it explicitly because it's the lightest strategy satisfying cache stability, working-set preservation, and bounded growth. |
compact-at-12000-structured | at thresholds | Track conversation size. When it crosses 12,000 chars, fire one extra LLM call to summarize the older portion using Claude Code's 5-section template (Task Overview / Current State / Important Discoveries / Next Steps / Context to Preserve), wrapped in <summary> tags. Replace history with [first user message, frozen summary, recent messages]. Fires once per run; summary is frozen forever. | Count chars across messages. If past threshold, slice off older portion, call summarizer model with the structured prompt, save the result. From that turn on, return trimmed-and-summarized list every time. | Cache-stable after first compaction (summary is bit-stable once generated). Before compaction, identity. The single transition is the only place the prefix changes shape. | Extra LLM call costs something. On short sessions: ties baseline ($0.016 on the main fixture) because the post-compaction prompt is small enough that the savings absorb the summarization cost. The structured template is shorter and more actionable than freeform alternatives. | Sessions that reliably cross the threshold. The CC template is a strict improvement over freeform compaction — same infrastructure, more focused prompt. | Paraphrased from system-prompt-context-compaction-summary.md in the leaked Claude Code source. opencode and pi-coding-agent both run compaction at thresholds (production versions are multi-round; ours is single-shot to isolate first-fire behavior). |
pi 是怎么把它们接上的
上面这些策略与智能体框架无关——它们描述的是拿消息列表做什么。要在我们的实验里真正跑起来,需要一个把它们插进去的地方。我们用 pi,因为跟大多数智能体 CLI 不同,它把上下文组装暴露成一等的扩展点——一个你自己写的函数——这让策略可以被轻松替换以作比较。
pi 的结构是这样的:在智能体循环的每一次迭代里——就在把消息历史发给 LLM 之前、在最新的工具结果已经追加进那份历史之后——智能体会在「当前转录」和「LLM 实际看到的东西」之间调用两个可由用户覆盖的钩子:
new Agent({
initialState: { systemPrompt, model, tools, thinkingLevel: "off" },
// Structural layer: prune, summarize, or inject messages.
transformContext: async (messages) => { /* ...your logic... */ },
// Mapping layer: filter or translate custom message types.
convertToLlm: (messages) => messages.filter(/* ... */),
});transformContext 是决定智能体「应该拥有怎样一段对话」的钩子。它接收整段对话作为 AgentMessage[]——这是 pi 给那个涵盖用户、助手和工具结果消息的统一消息类型起的名字——并返回一个(可能被修改过的)AgentMessage[]。进出同一个类型。上面分类法里的每种策略都活在这里:把每个工具结果限到 N 字符(truncate-500)、只限较旧的(age-truncate-500-keep-3)、到阈值就摘要(compact-at-12000-structured),等等。
convertToLlm 是把这段对话打包上线的钩子。它作用在 transformContext 的输出上,把内部的 AgentMessage[] 翻译成真正发往 Anthropic、Gemini 或 OpenAI 的、特定于提供商的 Message[]——过滤掉提供商不认识的自定义消息类型、为不支持附件的模型修正内容块,等等。
本文中我们让 convertToLlm 保持默认(对标准消息角色的恒等过滤),完全聚焦在 transformContext 上。
在 pi 的架构里,一个上下文组装策略就是一个函数:
type Strategy = (messages: AgentMessage[]) => Promise<AgentMessage[]>;这个签名就是全部的扩展面。因为它是代码(而不是一坨配置),策略可以做任意工作:调用另一个 LLM 去摘要旧轮次、把过往消息做嵌入并按相似度检索、从磁盘读文件、通过闭包在轮次之间维护状态。我们上面走过的那些策略,从三行(baseline)到几十行(compact-at-N-structured)都有,但它们共享同一个形状——而且都可以通过给同一个钩子传入不同的函数来替换。
为了具体些,下面是 pi 在单独一轮里做的事——从会话中途接起来,此时对话历史里已经有用户的初始提示词、若干轮 LLM 回复,以及先前若干轮堆下来的工具结果:
transformContext遍历整段对话历史——包括躺在里面、原封未动的 50KB 工具结果——并产出要发送的消息列表。策略决定对每一块做什么:原样通过(baseline)、统一截到 500 字符(truncate-500)、只截较旧的结果(age-truncate-500-keep-3)、把较旧历史折叠成摘要(compact-at-12000-structured),等等。pi 把得到的列表发给 LLM。LLM 看到的是策略的视图,不是原件。- LLM 回应——文本、工具调用意图,或者两者都有。
- 如果 LLM 发出了工具调用,pi 逐个执行。每个工具返回它的完整输出(例如 50KB 的文件内容)。pi 把 LLM 的回复和每个工具结果都追加进对话历史。
- 回到第 1 步。
- 重复,直到 LLM 给出一个不含工具调用的回复——那是智能体该停下的信号。
关键在于,在 pi 这一侧,原始的完整工具输出从不离开对话日志。它们对 LLM 不可见(因为策略在提示词里把它们摘要掉或截断掉了),但它们是可恢复的——中途换个策略,或者以后重放日志,完整文本就回来了。这就是上一节说的「无损存储」性质的具体化。
有个细节值得明说:在 pi 里,裸的 Agent 类没有默认策略。如果你 new Agent({...}) 而不提供 transformContext,你得到的就是 baseline 的恒等行为——每一轮都发送整段对话。构建在 Agent 之上的更高层包 pi-coding-agent 则确实带了默认策略(溢出时多轮摘要,形状类似 opencode)。这些实验里我们用裸的 Agent,好让对比中的每种策略都是我们显式写出来的,没有任何内置的东西需要控制。
实验设置
我们用来测试不同策略的固定用例,是一个 TODO Web 应用的服务与存储层。
干活的是两个类:TaskStore 在内存里维护一份任务列表,Api 是一个薄薄的分发层,接收请求对象并把它们路由到存储:
export type ApiRequest =
| { action: "add"; payload: { title: string } }
| { action: "complete"; payload: { id: unknown } }
| { action: "get"; payload: { id: unknown } }
| { action: "list" };
export class Api {
constructor(private store: TaskStore) {}
handle(request: ApiRequest): ApiResponse {
switch (request.action) {
case "complete": {
// 👇 The bug. `payload.id` is typed `unknown` and arrives as a string
// when the request comes from JSON. The cast silences TypeScript
// but does no runtime coercion — so `markComplete("1")` reaches
// `t.id === id` where t.id is a number, and the lookup misses.
const found = this.store.markComplete(request.payload.id as number);
return { ok: true, data: { completed: found } };
}
// …other cases…
}
}
}export class TaskStore {
private tasks: Task[] = [];
markComplete(id: number): boolean {
// 👇 Strict equality. If `id` arrives as a string ("1"), this returns
// undefined even when a Task with id 1 exists. Combined with the
// missing coercion in api.ts, this is what breaks the test.
const task = this.tasks.find((t) => t.id === id);
if (!task) return false;
task.completed = true;
return true;
}
// …add, get, list, clear…
}这个 bug 横跨 src/api.ts(分发器)和 src/storage.ts(带类型的存储)——一个文件里的类型断言加上另一个文件里的严格相等,才产生了那个失败的测试。
修复是在 API 边界上加一次 Number() 调用。尽管 bug 很浅,它却位于 api.ts 第 500 个字符之后——而这正是下文 truncate-500 会灾难性失败的原因:智能体压根看不到需要修改的那一行。
跟每个模型实验室拿来报告成绩的那些严肃开放评测相比,我们这个固定用例极其基础——SWE-bench(及其 Verified / Live 变体)做全仓库修 bug、τ-bench 做工具使用正确性、TerminalBench 做 shell 任务、BigCodeBench 做真实的用库写代码、Aider 的多语种基准做跨语言编辑——但它更适合我们的目的。那些基准都是把外壳固定、变换模型,为每个模型产出一个数字:用来排名有用,对「为什么」却不透明。这篇文章反过来做:模型固定(Gemini 2.5 Flash),外壳策略变化,固定用例刻意做得足够小,让你能把每一份转录从头读到尾。 一旦你能在一份 13 轮的转录里看清这些机制,你就能推理它们在一份 200 轮的转录里会做什么。
测试在 test/tasklist.test.ts 里端到端地跑遍整个接口面:添加任务、列出它们、通过 JSON 编码的请求标记完成、按 id 取回。3 个源文件、1 个失败的测试、单文件修复。
// the failing test, abridged
import { test } from "node:test";
import assert from "node:assert/strict";
import { Api, parseRequest } from "../src/api.ts";
import { TaskStore } from "../src/storage.ts";
test("complete via API with a JSON payload marks the task completed", () => {
const store = new TaskStore();
const api = new Api(store);
api.handle({ action: "add", payload: { title: "buy milk" } });
// Clients serialize ids as strings (JSON over HTTP, URL path params, etc.).
const raw = JSON.stringify({ action: "complete", payload: { id: "1" } });
const request = parseRequest(raw);
const res = api.handle(request);
assert.equal(res.ok, true);
if (!res.ok) return;
assert.deepEqual(res.data, { completed: true });
});
// …also: "add creates a task with an id", "list returns all tasks"这个测试套件是通过一次 JSON 往返来驱动 API 的——JSON.stringify({...}) 之后跟 parseRequest()——而不是直接拿一个带类型的对象去调 handle()。正是那次往返让 bug 变得可达:这是 payload.id 在运行时以字符串形式到达的唯一途径。这个库的「用户」就是这个测试套件;智能体被丢进来,给了四个工具(read_file、write_file、list_files、run_tests),任务是把套件跑绿。
外壳里用到的这四个工具是我们自己实现的薄封装。
每一个都是几行 Node 的 fs 加上一份注册到 pi 的 AgentTool 接口的 JSON schema。
它们是刻意不设上限的:read_file 返回整个文件内容,没有每次调用的上限、没有分页、没有每行限制;write_file 就是直接覆盖;list_files 返回完整的目录清单。如果 read_file 在工具层就限了输出(像 opencode 那样),truncate-500 和 age-truncate-500-keep-3 在小文件上就会表现得无从区分——工具的上限会替策略干了本该策略干的活。把工具保持最小,就迫使实验里每一处可观察到的差异都只来自上下文组装策略本身。
下面能看到这四个工具的主体,被剥到只剩 execute 路径(为清晰起见省略了 schema、标签和工作目录解析):
// no per-call cap, no pagination, no per-line limit
async (_id, args) => {
const abs = resolveInWorkdir(workdir, args.path);
const contents = fs.readFileSync(abs, "utf8");
return textResult(contents);
}// plain overwrite — no diff, no validation, no edit-tolerance policy
async (_id, args) => {
const abs = resolveInWorkdir(workdir, args.path);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, args.content, "utf8");
return textResult(`wrote ${args.content.length} bytes to ${args.path}`);
}// recursive walk; returns every file path joined by newlines
async (_id, args) => {
const rel = args.path ?? ".";
const abs = resolveInWorkdir(workdir, rel);
const entries: string[] = [];
const walk = (dir: string) => {
for (const name of fs.readdirSync(dir)) {
const full = path.join(dir, name);
if (fs.statSync(full).isDirectory()) {
if (name === "node_modules" || name === ".git") continue;
walk(full);
} else {
entries.push(path.relative(workdir.root, full));
}
}
};
walk(abs);
entries.sort();
return textResult(entries.join("\n") || "(empty)");
}// shells out to `node --test`; returns full stdout + stderr + exit code
async () => {
const testFiles = fs.readdirSync(path.join(workdir.root, "test"))
.filter((n) => n.endsWith(".test.ts"))
.map((n) => path.join("test", n));
const result = spawnSync(
"node",
["--experimental-strip-types", "--test", ...testFiles],
{ cwd: workdir.root, encoding: "utf8", timeout: 30_000 },
);
return textResult(
`exit_code: ${result.status ?? -1}\n` +
`--- stdout ---\n${result.stdout}\n` +
`--- stderr ---\n${result.stderr}`,
);
}下面的图把四种策略逐轮画了出来——每种形状各取一个代表:baseline(对照——不做变换)、age-truncate-500-keep-3(逐轮的按龄截断)、compact-at-12000-structured(用 Claude Code 模板的阈值处压实),以及 truncate-500(灾难性失效模式——激进到会藏起 bug 的统一截断)。可以在三个指标之间切换:
- 提示词大小——那一轮发出的输入 token 总数,新的和缓存的都算(提供商用量报告里的
input_tokens + cached_tokens)。它决定了 LLM 要读多少活儿。 - 累计成本——到这一轮为止(含本轮)每轮账单(Gemini Flash 的提示词 + 缓存 + 输出)的美元累加。
- 缓存命中率——这一轮的
cached_tokens / (cached_tokens + input_tokens)。1.0 表示每个输入 token 都来自前缀缓存;0.0 表示什么都没被缓存,你对整个提示词付了全价。这两个数字我们都直接取自提供商的逐次调用用量明细,然后按轮计算比值。
你也可以在下面逐轮翻看这四次运行中的任意一次。先说几个术语。
一轮就是一次 LLM 调用。围绕每一轮的循环是:
transformContext遍历智能体累积的对话历史。- 用它的结果去调用 LLM。
- LLM 发出一个回复——文本和/或工具调用意图。
- 智能体分发这些工具调用,逐个执行,并把结果追加进历史。
这一轮到此结束。下一轮就是下一次 LLM 调用。到第 N 轮,对话大约增长到 1 + 2(N-1) 条消息——初始提示词,加上此前每一轮交替出现的一对「LLM 回复 / 工具结果」。
点击左侧导航里任意一个 Turn N 来聚焦到它。这个组件展示的是那一轮 LLM 调用之前的那一刻:Before strategy 那一侧是截至第 N-1 轮工具结果为止累积的一切(此时第 N 轮的回复还没发生);After strategy 那一侧是 transformContext 从这份输入产出的东西——也就是 LLM 实际看到的提示词。默认的 Diff 视图给变化上色:红色行是被策略丢掉的,绿色行是新增或替换的,灰色行是两侧一致的。切到 Cards 可以看按消息组织的结构化视图。对 baseline,两侧逐位相同——那是对照组。
对另外三种策略,这份差异就是本文核心问题的字面呈现。
=== USER === A test in test/tasklist.test.ts is failing. Find the bug in the source code under src/, fix it, and make the whole test suite pass.
=== USER === A test in test/tasklist.test.ts is failing. Find the bug in the source code under src/, fix it, and make the whole test suite pass.
每次运行我们都把固定用例复制到一个隔离的临时目录,给智能体那四个工具,让它一直做到自己停下。verify() 再跑一次 node --test,记录测试套件是不是绿的。
模型:Gemini 2.5 Flash,temperature = 0(通过 pi 的 onPayload 钩子注入,因为 Agent 并不直接暴露温度)。即便是零,Flash 在实践中也并非完全确定——批处理推理和浮点噪声会让智能体的轨迹在不同运行间发散,所以我们每个格子跑 k=3 而不是 k=1。
每次运行我们测量:
pass——最后测试套件变绿了吗?turns——助手轮次的数量(也就是 LLM 调用次数)。cost——美元,按这次运行里每次调用累加(Gemini Flash 的提示词 + 输出 + 缓存)。peak prompt——智能体发送过的最大提示词大小(新增 + 缓存 token)。new input tokens——未缓存的提示词 token 总和。也就是你要付全价的那些。cached input tokens——由 Gemini 的隐式前缀缓存提供的 token。
cached 这一列是缓存边界的信号:数值高说明策略在轮次之间保住了前缀;数值低说明它在让前缀失效。
各策略的结果
下面的数字是每种策略 3 次试验(k=3)的均值;± 后面是样本标准差(所以 13±3 表示均值 13 轮、各次运行间 σ ≈ 3)。new 和 cached 两列同样是每次运行的均值——一次典型的单次试验在未缓存与缓存输入 token 上各付了多少。
| 策略 | n | pass | 轮数 | 成本 | 峰值提示词 | new | cached |
|---|---|---|---|---|---|---|---|
| baseline | 3 | 3/3 | 13±3 | $0.016±0.006 | 6,705±1,830 | 27,164 | 18,224 |
| truncate-500 | 3 | 0/3 | 12±6 | $0.098±0.012 | 5,851±3,187 | 27,167 | 14,050 |
| age-truncate-500-keep-3 | 3 | 3/3 | 13±3 | $0.017±0.006 | 5,744±1,444 | 29,082 | 11,394 |
| compact-at-12000-structured | 3 | 3/3 | 12±4 | $0.016±0.007 | 5,212±1,170 | 22,834 | 14,462 |
有三件事跳了出来。
truncate-500 灾难性地失败了——而且很贵。 0/3 通过,成本大约是 baseline 的 6 倍。api.ts 里的 bug 位于智能体所读文件的第 500 个字符之后,所以对工具结果设 500 字符上限,字面意义上把有 bug 的代码切掉了。智能体读了文件,看到一段看起来很完整的 import 块和类型定义,信了它,找不到 bug,开始瞎改,把钱烧掉。过于激进的截断不只是有损——它是误导性地有损,因为智能体没有任何办法知道自己缺了相关的那一段。这是本文最尖锐的反模式。
age-truncate-500-keep-3 是最好的平衡。 3/3 通过,平均成本 $0.017(基本与 baseline 打平),缓存命中被保住(1.1 万缓存 token)。这个策略的想法——最后 K 个工具结果原样保留、更旧的截断——避开了「把 bug 藏起来」的失效模式(智能体总能看到完整的新鲜读取),同时仍然收紧了对话的中段。它还是确定性的、位置固定的,所以通得过缓存稳定性这条规则。如果你需要一个默认选择,就是这个形状。
压实也有效。 compact-at-12000-structured 以 $0.016 拿到 3/3——尽管多了一次用于产出摘要的 LLM 调用,也基本与 baseline 打平,因为压实之后的提示词小到足以让节省吸收掉摘要的成本。阈值很关键:触发太早(bug 的诊断还没在对话里定下来)摘要就只抓到探索、没抓到结论;触发太晚,账单大头已经付掉了。对这个固定用例来说,12,000 字符正好在甜点上;生产级 CLI 用带自适应阈值的多轮压实来应对一般情况。
这四者的更大图景是: 四种策略里有三种以基本相同的成本拿到 3/3。这本身就是一个发现:在一个 10–20 轮的修 bug 任务上,只要你的策略缓存稳定、并且不摧毁智能体正在用的信息,你差不多可以随便挑。成本和可靠性只有在策略违反这两条原则之一时才会急剧分岔——truncate-500 违反了(它摧毁了工作集),其余的没有。
一个自拟算法的提议:按龄截断工具结果
大多数 CLI 和参考实现把上下文管理看作要么「丢掉旧东西」、要么「摘要旧东西」。两者都有我们在数据里看到的问题。丢掉旧东西(滑动窗口)会杀死缓存。摘要旧东西(压实)要多花一次 LLM 调用,而且对摘要质量敏感——摘要要是漏掉了 bug 的诊断,智能体就丢了线索。
还有第三个选项,我们的数据表明它被低估了:把旧工具结果的尾巴截掉,最近的原样保留,其他什么都不改。 具体地:
export function makeAgeAwareTruncate({ keepRecent, maxChars }) {
return async function(messages: AgentMessage[]) {
const resultIndices = messages
.map((m, i) => (m.role === "toolResult" ? i : -1))
.filter((i) => i !== -1);
const keepFromIndex =
resultIndices.length > keepRecent
? resultIndices[resultIndices.length - keepRecent]
: -1;
return messages.map((msg, i) => {
if (msg.role !== "toolResult") return msg;
if (i >= keepFromIndex) return msg; // recent — leave verbatim
return truncateTextBlocks(msg, maxChars);
});
};
}它为什么效果好:
- 它遵守缓存边界规则。 是否截断只是消息位置和文本长度的纯函数——确定性的、跨轮稳定的。位置 5 上的工具结果一旦被截,在之后每次调用里都以同样的方式保持被截。前缀缓存像 baseline 一样命中。
- 它从不藏起 bug。 最后 K 个工具结果——智能体当前推理所依赖的那些——完全不被碰。如果智能体刚读了一个文件,它看到的是整个文件。
- 它便宜。 纯进程内工作;不像压实那样需要额外的 LLM 调用。
- 它无聊。 ~15 行代码,一条清晰的不变式。不需要做阈值调参(像 3 这样的小 K 在各种任务上都管用)。也没有「摘要里该放什么?」这个子问题。
主固定用例上的数据支持这个说法:3/3 通过,$0.017——尽管每一轮都在跑这个上限,也基本与 baseline 打平。上限并不伤害智能体,因为尾部——智能体正在积极推理的那些新鲜读取和测试输出——保持完整。直觉是:上限限制了任何一轮的提示词能长到多大,而新鲜的工作集被原样保留,所以智能体当下需要的东西没有被藏起来。
在真正的长对话上它压不过压实——过了某个地平线,即便被截断的旧工具结果也会挤占有用的上下文——但对覆盖了大多数单任务编码会话的 10–50 轮区间来说,它是我们找到的最好默认选择。
这与 opencode 的两层做法有何不同
opencode 从相反方向处理同一个问题:它在工具层限制工具输出(read_file 最多返回约 50KB / 每行 2000 字符,grep 分页),并把长会话交给压实处理。按龄截断工具结果则活在策略层。这个选择带来两处实质差别:
- 存储模型。 opencode 做的是有损存储——工具一旦返回被限过的结果,文件其余部分在本地哪儿都没有;智能体只能带
offset/limit再调一次read_file才能拿到更多。age-truncate 做的是有损视图、无损存储——对话日志永远保留每个工具的完整输出,策略在每一轮重新导出一个压缩视图。中途换策略,完整字节就回来了,不用再调工具。更容易做实验;磁盘占用更大。 - 新鲜工作集。 opencode 的工具层上限是统一的:你刚打开的那个文件同样被限在 50KB。如果 bug 住在上限之外,智能体只能去要下一片——正是
truncate-500在我们固定用例上以微缩形式演示过的失效模式(它统一限在 500 字符)。opencode 用大得多的上限和显式分页来缓解这一点。age-truncate 把这套纪律反过来:最近 K 个结果无论多大都原样通过,只有更旧的结果才被限。所以你刚做的一次 100KB 读取是完全可见的;同样一次 10 轮之前的 100KB 读取已经被截到 500 字符。
这两种做法是互补的,不是竞争的。opencode 的工具层上限管住任何单次的巨量倾倒(1MB 的日志文件、失控测试打出的调用栈);age-truncate-500-keep-3 则防止原样保留的结果数量随着轮次无界增长。一个生产级的栈两者都会做:在工具层给每个工具的输出设界(让单次倾倒保持合理),然后在策略层跑按龄截断(让工作集不会一直堆积),并把压实留给真正的长会话场景。
age-truncate 之所以能在我们的实验里不靠工具层上限也管用,是因为我们刻意去掉了那些上限,好把策略的效果隔离出来。在生产里你会想要两者都有。
还有另一条值得知道的工作线:习得的压缩器。本文里每个压实策略用的都是提示词驱动的摘要器——同一个模型,只是被要求写一份摘要。训练出来的压缩器则把提示词换成一个小模型,专门微调以命中目标压缩率、同时保住下游任务表现。Cmprsr(Zakazov 等,2026)就是用 SFT + GRPO 训练 Qwen3-4B 来干这个的,其中包括一种「与问题无关」的模式,其输出可以在后续追问之间复用——也就是在第 2 节所关心的意义上是缓存稳定的。产品化的版本(compresr.ai)把它做成一个 compress() 的 SDK 调用,你可以塞进 transformContext 钩子里。它天然契合我们那次翻转所暴露出的 Pro 侧区间:当窟窿在惩罚智能体的思考预算、而一个连贯的压缩视图更占优时,一个按压缩目标训练出来(而不是被劝出来)的压缩器,就是下一个该伸手去拿的东西。