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

每次调用模型时,智能体运行框架都要选择发送哪些上下文:完整对话、选定消息、摘要、工具结果和指令。这些选择影响模型可用的信息、调用成本,以及之前的工作能否继续被利用。

我们在一个小型修复任务上比较四种上下文组装策略,并通过保存的运行记录分析其行为。这个实验用于说明取舍,并不建立通用排名。

实验使用 pi,其 transformContext 钩子允许在每次调用模型前调整消息视图。其中一种策略完整保留近期工具结果,并缩短较早的结果。

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

编程智能体由模型、控制循环及管理工具和上下文的运行框架组成。框架决定每次调用可用的信息,因此即使模型不变,上下文处理方式也可能改变行为。

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

  1. 将当前上下文发送给模型。
  2. 接收文本、工具调用或两者。
  3. 执行工具调用,追加结果,再重复。
  4. 如果响应不包含工具调用,结束本次智能体循环。

工具输出可能占据编程会话上下文的很大一部分。大小取决于文件、命令和调用次数,三十轮并不对应固定的 token 数量。字符和 token 也是不同单位。

因此,本文重点讨论工具结果的处理:保留、截断,或对较早的历史生成摘要。这些选择也会影响消息选择和提示缓存。

如果我们干脆让历史无节制地长下去,就会撞上四个问题:

  • 成本:更长的提示可能增加输入成本,缓存折扣和输出用量也有影响。
  • 延迟:传输和处理大提示可能延迟响应。缓存预填充可以减少部分工作。
  • 容量:请求需要满足所选模型的上下文限制,并为输出留出空间。
  • 质量:在长上下文中,重要细节可能更难被利用,具体取决于模型、任务和信息位置。

一个上下文组装策略不是单一决定,而是一叠相互作用的选择:

  • 丢掉或重写什么?
  • 什么时候做——每一轮,还是只在跨过阈值时?
  • 在栈的哪一层做——工具层、策略层,还是两层都做?
  • 有多激进——按 token、按字符,还是按消息?
  • 摘要什么——更旧的历史、整段对话,还是某些类型的工具?
  • 怎么摘要——自由发挥、结构化模板,还是多轮串联?
  • 缓存怎么办——你的变换是尊重前缀,还是每一轮都让它失效?

我们在同一个小任务上为每种策略运行了三次。报告的均值和样本标准差描述的是这些运行;三次试验不足以可靠估计成功率或罕见高成本失败的频率。

指标含义
测试通过次数三次试验中成功的次数
报告成本已记录的智能体调用成本的均值和样本标准差,不包含摘要调用
轮数已记录的助手调用次数,不是直接的延迟测量
提示大小pi 标准化用量字段中的未缓存与已缓存输入 token 之和
缓存命中比例已缓存输入 token 除以全部输入 token

应结合任务成功情况、成本和提示大小来看结果。运行更便宜但任务失败,并不一定是改进。

策略分类法

指标定下来了,接着看它们将被应用到的设计空间。出现在生产级 CLI 里的策略,沿着两条轴分成少数几个家族:压缩发生在哪里,以及什么被压缩。

最简单的策略在每次调用时发送完整对话,我们称之为 baseline。不变的前缀可按供应商规则复用缓存,但历史仍会增长并接近上下文上限,而长上下文表现可能受相关信息位置的影响。

其他每一种策略都是实现压缩的一种方式。压缩可以发生在三个地方:

  • 每一轮。 在每次 LLM 调用时对消息列表施加一个变换,随着对话增长塑造每一轮的提示词。
  • 只在阈值处。 在对话越过某个大小限制之前不去动它,然后对较旧的部分触发一次性操作(通常是摘要),并把结果冻结到整场运行结束。
  • 在工具层。 在工具的返回值进入对话历史之前就把它限流或重写。它位于另外两者下面一层,并且和它们可以组合——我们在后面详细讲。

逐轮转换可以与达到阈值后触发的压缩结合使用。确定性截断不需要调用摘要模型,但可能隐藏必要文本。摘要可以缩短更多历史,却会增加成本,也可能遗漏细节。两者都不会自动保证良好的缓存复用。

滑动窗口保留近期消息并删除较早消息。它可能丢失有用历史并降低前缀复用,但也可能适合状态存放在外部的有限任务。本文没有评估它。

可以选择删除或压缩不同内容:

  • 删除较早轮次,必要时保留原始任务。
  • 用简短结果或可检索引用替换较早工具输出。
  • 限制工具结果长度,并保留截断标记。
  • 对较早历史生成摘要。
  • 按需从完整日志中检索相关信息。组装请求时,还需保持工具调用与结果之间的有效对应关系。

这些模式并不互斥——大多数生产策略会叠两三种,比如:截断每个工具结果,然后摘要较旧的轮次,然后丢掉摘要之前的内容。

缓存稳定性

前缀缓存复用模型输入开头未变化部分的计算。匹配规则、最小长度、保留时间和计费因供应商而异,详见 OpenAI、Anthropic 和 Gemini 文档。需要匹配的是实际提供给模型的上下文,而不一定是原始 HTTP 请求字节。

如果已经发送的内容保持不变,转换就有利于前缀复用。改写较早消息会使现有缓存条目从该处开始失去匹配;后续请求可能复用新形成的前缀。

一个简单的缓存稳定例子是对工具结果设上限:每一轮策略都走一遍带完整工具输出的对话,把每个工具结果截断到 500 字符。因为规则是确定性的,而底层的工具结果文本不会变,所以截断后的版本在后续每次调用中、在同一位置上都是逐位相同的。

滑动窗口通常会改变固定指令之后的对话前缀。按时间截断也会在某个结果首次移出保留范围时改变前缀。确定性只让缩短后的文本保持稳定,并不能阻止这次变化使后续缓存内容失去匹配。

压缩可以替换旧摘要,也可以追加摘要块。替换会减少部分前缀复用,但让提示更短;追加则保留更多历史,却会使其持续增长。缓存键不能让变化后的内容继续命中旧缓存:OpenAI 的 prompt_cache_key 不是 Anthropic 式的 cache_control 断点。

「缓存」的两种含义:前缀缓存与语义缓存

上面讲的全都是前缀缓存——提供商侧的那套机制,让反复重发一段长而稳定的对话变得便宜。值得把它跟智能体系统里另一个也被叫作「缓存」的东西分开:语义缓存,它坐在模型前面,试图把调用整个跳过。

两者容易混淆,因为都承诺「更便宜的 LLM 调用」,但它们工作在不同的层,失效模式也不同:

  • 前缀缓存复用匹配输入前缀的计算,模型仍然生成新回答。缓存并不保证两次生成完全相同。
  • 语义缓存为足够相似的请求返回已保存的回答,可以省去推理,但应用必须处理误匹配和过期答案。

编程智能体的语义缓存键需要考虑仓库状态、指令和工具结果,仅有相似措辞不足以安全复用旧答案。本文后续讨论前缀缓存。

有损存储 vs 有损视图

单独摆着的一个问题是压缩发生在哪里——也就因此决定了对话里存下来的是什么。两个选项,对模型而言可观察到的效果相同:

  • 工具层:工具在结果进入对话前限制输出。省略的文本不在该结果中,但可能仍保存在文件或独立日志里。
  • 策略层:运行框架保存完整结果,并为每次模型调用构建缩短的视图。本文的四种策略均采用这种方式。

这个差别在 LLM 的提示词里看不出来——两种设计产出同样的文本。它体现在留在磁盘上的东西上:

策略可以恢复日志中保留的文本,无需重新调用工具。工具层限制可能要求再次读取,而这次读取可能得到更新后的文件版本。两种方式都不保证旧信息永久可用。

这种分野也反映在智能体框架暴露什么钩子上。工具层压缩根本不需要框架钩子——工具就是你自己写的函数,所以在工具层设上限就是把上限写进工具的实现里。 策略层不同:它每一轮都要对着一个移动的目标(不断增长的对话)运行,所以框架必须为它暴露一个入口点。

每次试验都记录完整工具输出。各次试验是独立的智能体运行,并非重放同一段对话,因此行动和轨迹可能不同。

工具输出的设计:另一半图景

到目前为止的一切都发生在上下文组装层——transformContext 作用在一份已经到手的消息列表上。但下面一层还有一个平行的设计空间:工具自己选择返回什么。一个直接倾倒原始输出的工具,会把所有活儿都推给你的策略。一个自己给输出设界的工具,会让你策略要干的活儿变小——有时小到几乎没有。

这里有两个关键特性:单次输出上限和分页读取。例如,固定版本的 opencode read 实现限制输出字节数和行长度,并支持读取选定范围。分页让智能体可以请求被省略的部分。

工具上限约束单次结果,而非整个对话。分页是按需检索,并不等同于压缩;多次读取仍可能积累很长的历史。

真实的 CLI 是怎么处理上下文的

在敲定我们要测量哪些策略之前,值得先看看生产级 CLI 实际是怎么解决这个问题的。每一家都从分类法里挑了自己那一份特定的混合,有时还受其目标提供商 API 所提供能力的影响。下面是从源码里能看到的情况的快速巡览。

Claude Code

Claude Code 文档介绍了上下文填满时的自动压缩。下面的布局和工具策略是设计示例,并非经过验证的内部实现复原。

有利于缓存的布局会将稳定指令放在不断变化的历史之前。下面的伪代码展示了压缩前的初始消息和压缩后的摘要。在实际 Anthropic 请求中,消息里的缓存断点应放在内容块上。

// 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: [{ type: "text", text: 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: [{ type: "text", text: FROZEN_SUMMARY,
      cache_control: { type: "ephemeral" } }] },                 // 👈 cache breakpoint #2 (last frozen item)
    // ─── tail: appended each turn since compaction ───────────
    ...recentKMessages,
  ],
});

这些标记指定的是嵌套前缀,而非独立缓存片段。修改前面的块会使后续内容失去匹配。因此,替换摘要后需要重新形成可匹配的前缀,但稳定的指令前缀可能仍可复用。

按工具处理旧结果的策略

按工具制定策略时,可以缩短旧文件读取结果,同时保留写入确认和测试结果。下表是针对本实验四个工具的示例策略,我们没有对其单独测评。

工具老化规则
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 名保持原样。

保留路径可以让智能体再次读取文件,但不能保留原来的文件内容。编辑之后,测试结果也可能过时。策略需要考虑任务依赖哪些历史信息。

结构化摘要模板

本实验的结构化压缩使用下面的五节摘要模板,要求保留任务、当前状态、发现、后续步骤,以及继续工作所需的准确细节。

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.

这种结构为交接提供了明确格式,但摘要仍可能遗漏或扭曲细节,因此需要通过后续任务检验模板是否有用。

Codex

Codex 分别提供自动压缩和工具输出保存的控制项。配置参考记录了 model_auto_compact_token_limit 和 tool_output_token_limit,它们处理不同的上下文增长来源。

opencode

opencode将工具输出限制与对话压缩结合使用。它们作用于不同层级;限制单次结果并不能免除历史管理。

pi-coding-agent

实验使用基础 Agent、自定义工具和明确设置的上下文策略。它与更高层的 pi coding agent不同,后者提供自己的工具和上下文管理。

我们实现的那些策略

我们比较四种策略,以发送原始历史的 baseline 为参照。

每种策略都使用相同任务、模型和初始提示进行三次独立试验,报告均值和样本标准差。三次试验能展示该任务中的行为,但不足以确定可靠的失败率或通用排名。

我们使用一个小型测试任务,每种策略采用一组参数。下面的名称标识实现及其参数。

模式策略方式
不做变换(对照)baseline—
截断工具输出(统一)truncate-500每一轮
截断工具输出(按时间)age-truncate-500-keep-3每一轮
摘要较旧轮次(结构化)compact-at-12000-structured在阈值处

age-truncate-500-keep-3 中的 500 表示每个被缩短文本块保留的前缀长度,3 表示完整保留的近期工具结果数,截断标记会增加额外字符。compact-at-12000-structured 在超过 12,000 字符阈值后进行一次结构化压缩。

滑动窗口、检索、按工具保留占位信息和重复压缩不在本实验范围内。它们的缓存表现取决于具体实现,不能从这四组条件推断。

下面这张四策略表是同一组,按方式分组——本文成本分析所围绕的那条轴:

每一轮(在每次 LLM 调用时施加变换)。

策略丢掉什么实现
truncate-500每个工具结果里 500 字符之后的文本对工具结果做 map
age-truncate-500-keep-3只在较旧的工具结果里丢 500 字符之后的文本感知位置的截断

在阈值处(对话越过大小限制时触发一次,随后冻结)。

策略替换内容实现
compact-at-12000-structured固定分割点之前的历史使用五节模板生成一次摘要

下表汇总四种实现及其局限。

Strategy referenceAll strategies side-by-side. Click the icon to expand.
strategymodewhat it doesimplementationcache behaviorcost and resultswhen to use itscope
baselinenone (reference)Send the full message history unchanged.messages => messagesAn unchanged prefix permits reuse, subject to provider cache rules.Depends on history length, output, caching, and the number of turns.A reference condition while the full history fits the context and budget.Identity transform in this experiment.
truncate-500per-turnKeep the first 500 characters of each tool-result text block, followed by a truncation marker.Shorten each oversized text block on every call.Each shortened block stays unchanged on later calls. The marker adds characters beyond the 500-character prefix.Passed 0/3 trials; logged agent cost was about six times baseline on this fixture.An example of a cap that hides relevant code when tools provide no pagination.Custom strategy in this experiment.
age-truncate-500-keep-3per-turnKeep the three newest tool results intact; shorten older text blocks to a 500-character prefix plus a marker.Locate the three newest tool results, then shorten older ones.When a result first ages out, its changed text breaks the existing prefix match from that point. It stays stable afterward.Passed 3/3 trials at $0.017 mean logged agent cost. History still grows.A candidate to evaluate when recent full results matter; three trials do not establish a general default.Custom strategy in this experiment.
compact-at-12000-structuredat thresholdsAt the 12,000-character threshold, summarize older history once using the five-section template. Retain the seed, frozen summary, and recent messages.Generate one summary, save the split point, then reuse the resulting view.Compaction changes the prefix. The frozen summary allows later requests to reuse the new prefix.Passed 3/3 trials at $0.016 mean logged agent cost. The separate summarizer call is excluded, so all-in cost is unknown.An example of single-shot compaction; repeated compaction and other tasks need separate evaluation.Custom strategy in this experiment.

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 将内部 AgentMessage[] 转成 pi 模型接口使用的消息格式,之后再由供应商适配器转换成各 API 的请求。

本文中我们让 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 里,裸的 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() 转换请求 ID。相关行位于 api.ts 第 500 个字符之后,超出了统一截断的范围。

这个任务远小于 SWE-bench 等仓库级评测。它的优势在于运行记录足够短,可以逐一检查。我们固定模型、改变上下文处理方式;更大的任务需要另行评估。

测试在 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"

测试序列化带字符串 id 的请求,再将它们传给 parseRequest()。JSON 会保留该字符串,而不是把数值 ID 转成字符串。智能体获得四个工具,任务是让测试通过。

四个自定义工具是通过 pi 的 AgentTool 接口注册的简单封装。文件读取刻意不设上限,也不提供分页,以免工具层限制干扰策略比较。独立运行仍可能采取不同轨迹。

下面能看到这四个工具的主体,被剥到只剩 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}`,
  );
}

图表展示每种策略的一次代表性运行,可切换提示大小、累计记录成本或缓存命中比例。

  • 提示大小:按 pi 归一化的用量字段,将新输入 token 与缓存输入 token 相加。
  • 累计记录成本:智能体调用成本之和,不包括单独的摘要调用。
  • 缓存命中比例:该次调用的缓存输入 token 除以总输入 token。
metric:bug-01 · costs exclude summarization

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

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

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

每次后续模型调用算作一轮。消息数量取决于每条助手响应包含多少次工具调用。

点击左侧导航里任意一个 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,通过 pi 的 onPayload 钩子设置 temperature = 0。即使如此,运行轨迹仍有差异,因此每组条件进行了三次试验。本实验没有确定这种差异的来源。

每次运行我们测量:

  • pass——最后测试套件变绿了吗?
  • turns——助手轮次的数量(也就是 LLM 调用次数)。
  • cost:记录的智能体调用成本,不含单独的摘要调用。
  • peak prompt——智能体发送过的最大提示词大小(新增 + 缓存 token)。
  • new input tokens——未缓存的提示词 token 总和。也就是你要付全价的那些。
  • cached input tokens——由 Gemini 的隐式前缀缓存提供的 token。

缓存 token 数同时受前缀复用和调用次数影响,不能单独用来衡量策略的缓存稳定性。

各策略的结果

下面的数字是每种策略 3 次试验(k=3)的均值;± 后面是样本标准差(所以 13±3 表示均值 13 轮、各次运行间 σ ≈ 3)。new 和 cached 两列同样是每次运行的均值——一次典型的单次试验在未缓存与缓存输入 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

统一截断的三次试验均未通过,记录成本约为基线的六倍。相关代码位于第 500 个字符之后。截断标记确实提示有内容缺失,但这些工具没有分页读取功能,无法在受限视图内补取该部分。

按时间截断通过了 3/3 次试验,平均记录成本为 0.017 美元。新结果保持完整,但旧信息可能从视图中消失。其缓存 token 为 1.1 万,少于基线的 1.8 万;即使转换是确定性的,结果首次变旧并被缩短时仍会改变前缀。

压缩通过了 3/3 次试验,平均记录的智能体调用成本为 0.016 美元。日志未计入摘要调用用量,因此这不是完整成本,也不能证明节省的费用抵消了摘要成本。摘要模型收到的记录已经预先截断:工具输出最多 1,500 个字符,调用参数最多 200 个字符。

实验展示了过于严格的上限在该任务中的失败,以及三个成功的替代方案,但没有确定哪种方案在各类编程任务中最好。

一个自拟算法的提议:按时间截断工具结果

按时间处理的策略完整保留最近三个工具结果,并缩短更早的结果:

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);
    });
  };
}
  • 近期结果无论大小都保持完整。
  • 旧结果可能丢失仍然相关的细节。
  • 结果首次被缩短时,会从该处改变前缀。
  • 转换无需额外模型调用,但历史仍会增长。

保留三个结果是本实验的参数,并非通用建议。更长的任务和大型新输出需要单独测试,并设置大小限制。

这与 opencode 的两层做法有何不同

工具上限限制进入对话的内容,按时间构建的视图则限制之后发送的内容。两种情况下文件都可能仍在磁盘上,但只有保存的完整结果才能保留当时读取的确切内容。两种机制可以结合使用,不过分页和访问省略内容的能力很重要。