深入拆解:如何为 LLM 智能体组装上下文

打开 Claude Code、Cursor 或者任何别的智能体编码 CLI。让它修一个 bug。看着它调一次工具,再调一次,又调一次。在底下,每一次 LLM 轮次里,这个 CLI 都在做一个你从来看不见的决定:到底把什么当作对话历史送给模型。整段转录、只有最后几条消息、更早内容的一份摘要、原始任务加上最新一次文件读取——每一种都造出一个不同的智能体。CLI 已经替你做好了这个决定,编译期就烤进去了,不读源码就看不到。

这篇文章是一次对 LLM 智能体上下文组装策略设计空间的巡游——也就是一套外壳(harness)在每一轮里对「往提示词里放什么」所做的那些看不见的决定。我们建立一套选择的分类法(压缩发生在哪里、压缩什么、什么时候触发),从源码里考察生产级 CLI 实际是怎么解决这个问题的,并在一个修 bug 的固定用例上跑一个小实验,比较四种有代表性的策略。

实验的载体是 pi——一个把上下文组装暴露成「你自己写的一个函数」的智能体框架,因此换策略非常容易。文章最后给出一个小小的算法提议——按新旧程度截断工具结果——这是我们找到的最便宜的一行式策略,既压住了成本,又不丢掉智能体当下正在推理的东西。

为什么上下文是智能体的核心控制问题

任何一套智能体编码配置都由三部分组成:模型(生成 token 的 LLM)、智能体(在某个环境里反复调用模型的控制循环)以及外壳(围绕它、负责管理上下文、工具、提示词、状态和控制流的软件)。Sebastian Raschka 有一篇很好的讲解,说明这三者如何组合成一个可用的编码智能体。这里要紧的一点是:一场会话里许多看起来像「模型质量」的东西,其实是上下文质量——而上下文质量是外壳的活儿。

智能体的循环,剥到只剩本质,是这样:

  1. 把消息历史发给 LLM。
  2. 拿回来的要么是文本回复,要么是工具调用。
  3. 如果是工具调用:执行它们,把结果追加进历史,回到第 1 步。
  4. 如果是文本:结束。

现在想想一场真实会话里第 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_controlOpenAI 的提示词缓存在结构上工作方式相同:提供商对你请求开头的那段字节序列做哈希,在最近的请求里找匹配,找到了就对这些 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 调用使用——截断、摘要、打桩、丢弃,策略要干什么都行。对话日志永远保留每一个字节;被缩减的只是模型在这一轮里看到的视图。我们跑分的四种策略(baselinetruncate-500age-truncate-500-keep-3compact-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。不久之后出现了几篇拆解(StraikerKaran 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 是:家族 = compactat-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 referenceAll strategies side-by-side. Click the icon to expand.
strategymodewhat it doesimplementationcache behaviorcost shapewhen to use itwho ships it
baselinenone (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-500per-turnKeep 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-3per-turnSame 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-structuredat thresholdsTrack 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 回复,以及先前若干轮堆下来的工具结果:

  1. transformContext 遍历整段对话历史——包括躺在里面、原封未动的 50KB 工具结果——并产出要发送的消息列表。策略决定对每一块做什么:原样通过(baseline)、统一截到 500 字符(truncate-500)、只截较旧的结果(age-truncate-500-keep-3)、把较旧历史折叠成摘要(compact-at-12000-structured),等等。pi 把得到的列表发给 LLM。LLM 看到的是策略的视图,不是原件。
  2. LLM 回应——文本、工具调用意图,或者两者都有。
  3. 如果 LLM 发出了工具调用,pi 逐个执行。每个工具返回它的完整输出(例如 50KB 的文件内容)。pi 把 LLM 的回复和每个工具结果都追加进对话历史。
  4. 回到第 1 步。
  5. 重复,直到 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_filewrite_filelist_filesrun_tests),任务是把套件跑绿。

外壳里用到的这四个工具是我们自己实现的薄封装。 每一个都是几行 Node 的 fs 加上一份注册到 pi 的 AgentTool 接口的 JSON schema。 它们是刻意不设上限的:read_file 返回整个文件内容,没有每次调用的上限、没有分页、没有每行限制;write_file 就是直接覆盖;list_files 返回完整的目录清单。如果 read_file 在工具层就限了输出(像 opencode 那样),truncate-500age-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 表示什么都没被缓存,你对整个提示词付了全价。这两个数字我们都直接取自提供商的逐次调用用量明细,然后按轮计算比值。
metric:bug-01 · four strategies side-by-side

你也可以在下面逐轮翻看这四次运行中的任意一次。先说几个术语。

就是一次 LLM 调用。围绕每一轮的循环是:

  1. transformContext 遍历智能体累积的对话历史。
  2. 用它的结果去调用 LLM。
  3. LLM 发出一个回复——文本和/或工具调用意图。
  4. 智能体分发这些工具调用,逐个执行,并把结果追加进历史。

这一轮到此结束。下一轮就是下一次 LLM 调用。到第 N 轮,对话大约增长到 1 + 2(N-1) 条消息——初始提示词,加上此前每一轮交替出现的一对「LLM 回复 / 工具结果」。

点击左侧导航里任意一个 Turn N 来聚焦到它。这个组件展示的是那一轮 LLM 调用之前的那一刻:Before strategy 那一侧是截至第 N-1 轮工具结果为止累积的一切(此时第 N 轮的回复还没发生);After strategy 那一侧是 transformContext 从这份输入产出的东西——也就是 LLM 实际看到的提示词。默认的 Diff 视图给变化上色:红色行是被策略丢掉的,绿色行是新增或替换的,灰色行是两侧一致的。切到 Cards 可以看按消息组织的结构化视图。对 baseline,两侧逐位相同——那是对照组。 对另外三种策略,这份差异就是本文核心问题的字面呈现。

baseline · ✗ fail · $0.0614 · 27 LLM calls
Turns
Diff: before → after — red = removed by strategy, green = added/replaced
No changes — the strategy returned the conversation unchanged for this turn.
Before strategy
After strategy
=== 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)。newcached 两列同样是每次运行的均值——一次典型的单次试验在未缓存与缓存输入 token 上各付了多少。

策略npass轮数成本峰值提示词newcached
baseline33/313±3$0.016±0.0066,705±1,83027,16418,224
truncate-50030/312±6$0.098±0.0125,851±3,18727,16714,050
age-truncate-500-keep-333/313±3$0.017±0.0065,744±1,44429,08211,394
compact-at-12000-structured33/312±4$0.016±0.0075,212±1,17022,83414,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 侧区间:当窟窿在惩罚智能体的思考预算、而一个连贯的压缩视图更占优时,一个按压缩目标训练出来(而不是被劝出来)的压缩器,就是下一个该伸手去拿的东西。