深入拆解:如何为 LLM 智能体组装上下文
每次调用模型时,智能体运行框架都要选择发送哪些上下文:完整对话、选定消息、摘要、工具结果和指令。这些选择影响模型可用的信息、调用成本,以及之前的工作能否继续被利用。
我们在一个小型修复任务上比较四种上下文组装策略,并通过保存的运行记录分析其行为。这个实验用于说明取舍,并不建立通用排名。
实验使用 pi,其 transformContext 钩子允许在每次调用模型前调整消息视图。其中一种策略完整保留近期工具结果,并缩短较早的结果。
为什么上下文是智能体的核心控制问题
编程智能体由模型、控制循环及管理工具和上下文的运行框架组成。框架决定每次调用可用的信息,因此即使模型不变,上下文处理方式也可能改变行为。
智能体的循环,剥到只剩本质,是这样:
- 将当前上下文发送给模型。
- 接收文本、工具调用或两者。
- 执行工具调用,追加结果,再重复。
- 如果响应不包含工具调用,结束本次智能体循环。
工具输出可能占据编程会话上下文的很大一部分。大小取决于文件、命令和调用次数,三十轮并不对应固定的 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 | mode | what it does | implementation | cache behavior | cost and results | when to use it | scope |
|---|---|---|---|---|---|---|---|
baseline | none (reference) | Send the full message history unchanged. | messages => messages | An 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-500 | per-turn | Keep 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-3 | per-turn | Keep 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-structured | at thresholds | At 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 回复,以及先前若干轮堆下来的工具结果:
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 里,裸的 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。
你也可以在下面逐轮翻看这四次运行中的任意一次。先说几个术语。
一轮就是一次 LLM 调用。围绕每一轮的循环是:
transformContext遍历智能体累积的对话历史。- 用它的结果去调用 LLM。
- 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,通过 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 上各付了多少。
| 策略 | 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 |
统一截断的三次试验均未通过,记录成本约为基线的六倍。相关代码位于第 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 的两层做法有何不同
工具上限限制进入对话的内容,按时间构建的视图则限制之后发送的内容。两种情况下文件都可能仍在磁盘上,但只有保存的完整结果才能保留当时读取的确切内容。两种机制可以结合使用,不过分页和访问省略内容的能力很重要。