跳到主要内容

数据截至 (上游 commit 0004b748b71c)

上下文经济学:prompt 怎么拼、超了怎么砍、缓存怎么保

30 秒导读: 每一次调模型,Kilo Code 都要现场组装一份"请求体"。这一章讲那坨字节从哪来(五段 system + 每轮注入的环境块 + 一整条历史)、太大了怎么砍(剪老工具输出 → 模板化摘要 → 分块摘要),以及为什么它宁可多写几行代码也要让前缀"一个字节都别变"(prompt 缓存)。

本章只讲上下文这笔账。模型怎么选、协议怎么发,见 06-model-layer;主循环转几圈见 02-agent-loop;工具输出是怎么产生的见 03-tools-and-permission


1. 先讲清楚:这一章在解决什么问题

1.1 一句话定义

上下文管理 = 决定"这一次 HTTP 请求里到底放哪些字节"的那套规则。

1.2 为什么它是个难题

模型的上下文窗口是硬墙:撞上去不是变慢,是直接报错、整轮对话作废。

而编码 agent 是所有 agent 里最容易撞墙的一类——读一个文件 8000 token,跑一次测试 5 万字日志,十几轮下来窗口就满了。

于是同一份历史要同时满足三笔账:

账目要什么撞了会怎样
容量账总 token < 窗口上限provider 直接 400,整轮失败
成本账少发重复内容每轮几十万 token 全价重算,钱包爆炸
缓存账前缀字节稳定不变缓存全失效,成本和延迟都翻数倍

这三笔账互相打架。省容量的最快办法是"删历史",但删历史就会改前缀、打爆缓存。Kilo Code 的整套设计,本质上是在这三者之间反复找平衡点。

1.3 一句话直觉

把上下文窗口当一张会议桌

  • 桌上摊开的资料 = 你真正发出去的 prompt,位置有限。
  • 靠边码好、没人动过的那摞 = 缓存前缀,只要不去翻它,下次开会不用重新搬。
  • 压缩 = 把桌角那堆老资料换成一页纪要。
  • 剪枝(prune) = 老资料先别扔,只把里面几十页的附件抽掉,留个封面。

**关键直觉:动桌尾比动桌头便宜。**Kilo Code 的每一个裁剪动作都尽量往"历史深处"下手,而不是碰刚放上去的东西——因为深处一动,缓存就得从那里往后全部重建。


2. 顶层全景:一次请求的字节从哪来

2.1 装配流水线

这张图从上到下是一次 LLM 调用的组装顺序,每一格都可能增删字节。图里的 ① ② ③ 对应下面三节。

一轮循环开始(SessionPrompt.runLoop)

┌──────────────────────────┴───────────────────────────┐
│ 从 SQLite 读历史 + 投影 │
│ filterCompactedEffect → 队列裁剪 → trimBeforeLastSummary │
└──────────────────────────┬───────────────────────────┘

┌──────────────────────────┴──────────────┐
│ ② 每轮注入(临时、不落库或标 synthetic) │
│ · <environment_details>(EnvCache 缓存)│
│ · plan / code-switch 提醒 │
│ · 用户插话包 <system-reminder> │
└──────────────────────────┬──────────────┘

┌──────────────────────────┴──────────────┐
│ ③ 降维 toModelMessagesEffect │
│ part 体系 → AI SDK 的 ModelMessage │
└──────────────────────────┬──────────────┘

┌──────────────┴──────────────┐
│ 保险 A:字节数 > 1.25 MB? │──是─→ prune 老工具输出,重来一遍
└──────────────┬──────────────┘

┌──────────────────────────┴──────────────┐
│ ① system 三段(本轮现算) │
│ env + AGENTS.md 类规则 + skills 清单 │
└──────────────────────────┬──────────────┘

┌──────────────────────────┴──────────────┐
│ LLMRequestPrep.prepare 再前置两段 │
│ soul + 按模型族选的那份 *.txt │
│ 然后把 system 压成【恰好 2 块】 │
└──────────────────────────┬──────────────┘

┌──────────────────────────┴──────────────┐
│ ProviderTransform.message → applyCaching │
│ 在 system 前 2 块 + 尾部 2 条打缓存断点 │
└──────────────────────────┬──────────────┘

发给 provider

2.2 部件一句话职责

部件干什么在哪个文件
SystemPrompt.provider按模型族挑一份基础提示词 .txtpackages/opencode/src/session/system.ts:49
SystemPrompt.environment生成静态 <env> 块(模型名/平台/配置路径)packages/opencode/src/session/system.ts:111
Instruction.systemAGENTS.md / CLAUDE.md 一类项目规则packages/opencode/src/session/instruction.ts:188
SystemPrompt.skills把可用 skill 列成一段清单packages/opencode/src/session/system.ts:152
KiloSessionPrompt.injectEditorContext每轮往最后一条 user 塞 <environment_details>packages/opencode/src/kilocode/session/prompt.ts:363
MessageV2.toModelMessagesEffectpart 体系 → ModelMessage 的降维packages/opencode/src/session/message-v2.ts:254
SessionCompaction.process模板化摘要压缩packages/opencode/src/session/compaction.ts:321
SessionCompaction.prune抹掉老工具调用的 outputpackages/opencode/src/session/compaction.ts:269
ProviderTransform.message出门前最后一道变换,含 applyCachingpackages/opencode/src/provider/transform.ts:520

3. ① 拼:system prompt 是五段,不是三段

3.1 主循环只管三段

主循环里 system 的组装只有一行,非常直白:

const system = [...env, ...instructions, ...(skills ? [skills] : [])]

packages/opencode/src/session/prompt.ts:1755。三段的来源是上面一次 Effect.all 并发取的(packages/opencode/src/session/prompt.ts:1726-1730)。

3.2 但另外两段在更下游被前置

真正发出去的 system,是在 LLMRequestPrep.prepare 里补齐的(packages/opencode/src/session/llm/request.ts:69-83):

const system = [[
...(isOpenaiOauth ? [] : [SystemPrompt.soul()]),
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
...input.system,
...(input.user.system ? [input.user.system] : []),
].filter(x => x).join("\n")]

所以最终顺序是五段,从最稳到最易变:

内容变化频率
1soul()身份/人格定义(kilocode/soul.txt几乎不变
2agent 自定义 prompt 模型族 .txt行为主体规范换 agent / 换模型才变
3env<env> 静态环境块换模型/换项目才变
4instructionsAGENTS.md 等项目规则改文件才变
5skills可用 skill 清单装卸 skill 才变

**这个顺序不是随手排的。**越靠前越稳定,缓存断点打在它后面时命中率才最高(见 §8)。

3.3 按模型族选提示词:一个 switch 加一串 includes

session/prompt/ 目录下按模型族分了十几份 .txt。选哪份由 SystemPrompt.provider 决定(packages/opencode/src/session/system.ts:49-91),两级判断:

第一级:模型元数据里显式声明了 model.prompt 字段
codex / gemini / beast / anthropic / trinity /
anthropic_without_todo / ling / gpt55
(枚举定义在 packages/kilo-gateway/src/api/constants.ts:92 PROMPTS)
│ 命中即返回
↓ 没声明
第二级:按 model.api.id 字符串猜
gpt-4 / o1 / o3 ──→ beast.txt
gpt + codex ──→ codex.txt
gpt ──→ gpt.txt
gemini- ──→ gemini.txt
claude ──→ anthropic.txt
trinity / kimi / ling ──→ 各自的 .txt
都不中 ──→ default.txt

为什么要分文件? 因为不同模型族对"工具怎么用、话怎么说、什么时候停"的响应差别很大。beast.txt(11 KB)里是给 GPT-4 系写的高强度自驱指令,anthropic.txt(8 KB)则默认模型自带 todo 纪律。用一份通用 prompt 喂所有模型,效果会明显退化。

一个可读性细节: anthropic_without_todo 这个枚举值映射到的是 default.txtsystem.ts:44-45),不是某个同名文件——枚举名描述的是语义("要 Anthropic 风格但别启用 todo"),不是文件名。

3.4 项目规则:AGENTS.md 怎么被读进来

Instruction.system() 负责这段(packages/opencode/src/session/instruction.ts:188-207)。三个来源,读完各自加一行 Instructions from: <路径> 前缀:

来源规则出处
全局KILO_CONFIG_DIR/AGENTS.md → 全局 config 目录 → ~/.claude/CLAUDE.md取第一个存在的就停instruction.ts:64-70, 119-124
项目从 cwd 向 worktree 根 findUpAGENTS.md / CLAUDE.md / CONTEXT.md(已废弃),第一个匹配的文件名胜出instruction.ts:15-19, 127-137
配置config.instructions 里的 glob 和 http(s) URL(URL 有 5 秒超时,失败静默返回空串)instruction.ts:139-153, 99-107

break 那两处很关键:注释写得很明白——不要把每一层祖先目录的 AGENTS.md 全叠上去instruction.ts:126)。否则一个 monorepo 深处的文件会拖来五六份规则。

还有一条按需加载的支路:Instruction.resolveinstruction.ts:183)在模型 read 某个文件时,从该文件所在目录往上走,把沿途的 AGENTS.md 附加进去,并用 claims 这个 Map<MessageID, Set<string>> 保证同一条 assistant 消息内每份规则只附加一次。这是"读到哪儿、规则加载到哪儿"的懒加载。

3.5 最后一步:把 system 压成恰好 2 块

prepare 里有一段乍看很怪的代码(packages/opencode/src/session/llm/request.ts:91-95):

if (system.length > 2 && system[0] === header) {
const rest = system.slice(1)
system.length = 0
system.push(header, rest.join("\n"))
}

插件可以通过 experimental.chat.system.transform 往 system 数组里追加内容。这段做的事是:只要插件加多了,就把第 2 块之后的全部合并回第 2 块,让 system 永远只有 1 或 2 个元素。

为什么? 因为 applyCaching 只给前 2 条 system 打缓存断点(transform.ts:385)。压成 2 块,等于保证所有 system 内容都在缓存范围内。这是一处"上游为了下游缓存策略而妥协"的设计,不看 §8 会完全看不懂。


4. ② 注:每轮临时塞进去的东西

这一节讲的都是不属于历史、但每轮都要出现的内容。它们的共同特点是:加得晚、加在末尾、加完不影响前缀。

4.1 静态 <env> 与动态 <environment_details>:一刀切两半

同一批"环境信息",Kilo Code 按变化频率劈成了两块,放在两个位置:

放哪装什么为什么放这
<env>system prompt模型 ID、是不是 git 仓库、平台、今天日期、配置目录、默认 shell一整轮对话基本不变,放 system 里能被缓存
<environment_details>最后一条 user 消息当前时间戳(秒级)、工作目录、活动文件、可见文件、打开的标签页用户随时切文件,必须新鲜

划分逻辑写在 packages/opencode/src/kilocode/editor-context.ts:13-17 的注释里,两个构造函数分别是 staticEnvLines(:15)和 environmentDetails(:41);<env> 块的拼装在 packages/opencode/src/kilocode/system-prompt.ts:20

这是全章最值得抄的一个决策:如果把秒级时间戳放进 system prompt,那么每一次请求的 system 前缀都不同,缓存永远 0 命中。劈成两半后,易变的那半被挤到消息尾部,前面几十 KB 的前缀纹丝不动。

4.2 EnvCache:连"一轮之内"都要保证字节相同

<environment_details> 里有秒级时间戳。而主循环一轮 user 消息可能要转十几步(每次工具调用都是一步)。如果每步都现算,同一轮之内时间戳就会跳变,把刚建好的缓存又打掉。

EnvCache 就是为这个而生(packages/opencode/src/kilocode/session/prompt.ts:281-288):

export interface EnvCache {
block?: string
user?: string // 缓存对应的 user 消息 ID
}

injectEditorContext 的第一件事是比对 cache.user !== lastUser.id——只有换了新的 user 消息才重算环境块(kilocode/session/prompt.ts:369-382)。缓存实例在 runLoop 开头创建(packages/opencode/src/session/prompt.ts:1473),生命周期正好是一整轮。

注释写得很直接:"caches the result per user message ID so repeated loop iterations produce byte-identical messages (prompt caching)"。

注入的 part 带 synthetic: true 标记(kilocode/session/prompt.ts:396),UI 不显示它,模型看得到。

4.3 提醒(reminders):模式切换时补一句话

SessionReminders.applypackages/opencode/src/session/reminders.ts:15)在每次调模型前跑,往最后一条 user 消息尾部追加 synthetic 文本:

  • plan 提醒 —— agent 是 plan 类时,塞入 native-plan-prompt.txt 并告知计划文件路径(kilocode/session/prompt.ts:416 insertPlanReminders)。
  • code-switch —— 历史里出现过 plan agent、而现在切到了 code agent,塞入 session/prompt/code-switch.txtreminders.ts:43-61);实验版路径还会检测计划文件是否存在,存在就多加一句"照着它执行"(reminders.ts:68-77)。

4.4 用户中途插话:包进 <system-reminder>

这是个很有意思的小机制。用户在模型跑工具的中途又发了一条消息——这条消息在时间上晚于上一条已完成的 assistant,但它不该被当成"新一轮的开场白",而应该被当成"打断"。

主循环的做法是就地改写它的文本(早期 packages/opencode/src/session/prompt.ts 的 1618-1638 一带;⚠️ 当前 commit 已把这段改写整体移除,opencode/src 里搜不到 The user sent the following message 了——中途插话的文本不再被包成 <system-reminder>,下文按旧版机制讲解,仅作历史参考):

p.text = [
"<system-reminder>",
"The user sent the following message:",
p.text,
"",
"Please address this message and continue with your tasks.",
"</system-reminder>",
].join("\n")

触发条件是 step > 1 且该 user 消息在时间上排在 finishedMessage 之后(用 KiloSessionMessageOrder.compare 比时序,不是比 ID);syntheticignored 的 part 跳过。

妙在哪: 不新建消息、不动消息结构,只在文本外面套一层标签。对模型来说这是明确的"有人插话了,处理完继续原任务",对缓存来说是"只有末尾变了"。

4.5 步数上限:一条不落库的尾部 user 消息

agent 可以配 steps 上限。到达上限的那一步,主循环会在消息数组末尾临时追加一条 user 消息packages/opencode/src/session/prompt.ts:1775):

messages: [...modelMsgs, ...(isLastStep ? [{ role: "user" as const, content: MAX_STEPS_PROMPT }] : [])]

MAX_STEPS_PROMPT 的内容(packages/core/src/session/runner/max-steps.ts:1)是一段严厉的指令:工具已禁用,只准输出文本,总结做了什么、剩下什么。注意它曾经是伪造的 assistant 消息(assistant prefill),但 Kilo 改成了 user 消息——行内注释写明原因是 "avoid provider-incompatible assistant prefill":不是所有 provider 都接受以 assistant 结尾的请求。


5. ③ 降维:从 part 体系到 ModelMessage

5.1 内部消息模型:一条消息 = 一串 part

Kilo Code 内部不用"消息就是一段文本"这种模型,而是 message + parts。part 的联合类型定义在 packages/schema/src/v1/session.ts:402(自当前 commit 起从 message-v2.ts 迁到了 schema 包):

part 类型装什么会进 prompt 吗
text文本;带 synthetic(不给人看) / ignored(不给模型看) 两个开关看开关
reasoning思维链,可能带 Anthropic 签名
file附件,有 mime 和 url是(媒体可被剥离)
tool工具调用全生命周期:input / output / attachments / time.compacted是(output 可被抹)
step-start / step-finish步边界step-start
snapshot / patch影子 git 快照与补丁(见 04-edit-and-snapshot
compaction压缩标记,带 auto / overflow / tail_start_id转成一句 "What did we do so far?"
subtask / agent / retry子任务、agent 切换、重试标记部分

这个设计是整章的地基:因为裁剪动作(抹工具输出、剥离媒体、截断)全部是"改某个 part 的某个字段",而不是"改一段文本"。粒度对了,裁剪才做得精准。

5.2 toModelMessagesEffect:翻译时顺便做取舍

packages/opencode/src/session/message-v2.ts:254 是那个翻译器,签名带两个可选开关:

options?: { stripMedia?: boolean; toolOutputMaxChars?: number }

它做的事远不止格式转换,几个关键取舍:

  • 抹掉的工具输出换成占位符。 part.state.time.compacted 有值时,output 变成 "[Old tool result content cleared]",attachments 清空(message-v2.ts:417-423)。
  • stripMedia 把图片/PDF 变成一行文字[Attached image/png: foo.png]message-v2.ts:336-340)。压缩时用这个开关。
  • 按 provider 能力搬运媒体。 supportsMediaInToolResultmessage-v2.ts:270-282)逐个 provider 判断能不能在 tool result 里放媒体——Bedrock 只收图片不收 PDF、Gemini 只有 3 系才行。不支持的就把媒体抽出来,改发成独立的 user 消息。
  • 有错误的 assistant 消息整条丢弃message-v2.ts:371-379),除非是"中断但已经产出了内容"的情况。
  • 一个 Anthropic 专属的丑补丁:带签名的 reasoning 之间那条空 text part 不能删也不能留空——删了会打乱签名块位置,留空会被 AI SDK 过滤掉,最后用一个空格顶上(message-v2.ts:382-396,注释里承认"不清楚这个形状是哪一层产生的")。

truncateToolOutputmessage-v2.ts:80)负责 toolOutputMaxChars 的截断,用 TextStream.safeSlice 避免把多字节字符切成半个,尾部补一句 [Tool output truncated for compaction: omitted N chars]

5.3 filterCompacted:读历史时的重排

packages/opencode/src/session/message-v2.ts:12filterCompacted) 从数据库倒着读消息流,边读边找"完成的压缩点",找到就停——于是天然只返回压缩之后的部分。

但它还做了一件更绕的事(message-v2.ts:683-712):重排。当压缩点带了 tail_start_id(表示"从这条起的近期消息要保留原文")时,返回的顺序被改成:

[compaction-user, summary-assistant, ...保留的原文尾巴..., 后续消息]
↑ "What did we do so far?" ↑ 结构化摘要 ↑ 最近 N 轮的真实对话

摘要被提到了尾巴前面。这样模型先读到"之前干了什么"的总结,再读到最近几轮的完整细节,顺序符合直觉。

代价:返回数组的位置不再是时间顺序。所以 latest()message-v2.ts:716)必须靠单调递增的 MessageID 取最新,而不能取数组末尾——这条注释(message-v2.ts:714-715)说明了原因,Kilo Code 还额外加了 KiloSessionMessageOrder 一套时序标注来兜底。

Kilo Code 在这之上又补了 trimBeforeLastSummarypackages/opencode/src/kilocode/session/prompt.ts:556),因为 filterCompacted 只在"摘要的父消息带 compaction part"时才截断。手动 /compact 产生的摘要,其父消息是普通文本 user——那种情况下老逻辑会把压缩前的全部历史一起带上。注释里点名了后果:"这就是那个 session 每轮重发几 MB base64 图片的原因"(kilocode/session/prompt.ts:546-551)。


6. 砍:三件套

三个工具,按"从轻到重"排:

手段动谁省多少缓存代价什么时候用
prune老工具调用的 output 文本中(几万 token)中——改了历史中段载荷过大 / 压缩后清扫 / 用户显式开启
compaction整段老历史 → 一页摘要大(几十万 token)大——前缀基本重建token 超阈值
chunked compaction连"要被摘要的那坨"都装不下时,分块递归摘要兜底摘要本身也 overflow

6.1 先说"什么时候算超了"

判定分成两条独立的线

线 A:回头看(基于上一轮的真实 token 数) —— session/overflow.ts

usable = model.limit.input
? limit.input - reserved
: limit.context - maxOutputTokens(model)

reserved = cfg.compaction.reserved
?? min(20_000, maxOutputTokens(model)) # COMPACTION_BUFFER

packages/opencode/src/session/overflow.ts:11-21isOverflow(:23 附近)拿上一条 assistant 的真实 token 统计去比这个上限。

Kilo Code 在这里插了两处改动(packages/opencode/src/kilocode/session/overflow.ts):

  • count(:30)把 input + output + reasoning + cache 读写全部加起来,而不是只看 input。
  • limit(:35)支持 compaction.threshold_percent 配置,取 min(usable, context × percent%)——允许比默认更早压缩。

线 B:往前看(发送前先估一遍) —— KiloSessionOverflow.measurekilocode/session/overflow.ts:46)。

这是个纯估算器,三个细节值得看:

  1. token 估算是 字符数 / 4packages/core/src/util/token.ts:3-5),非常粗。所以 measure 统一乘 1.3 倍系数兜底,注释说明原因:"Token.estimate 相对真实 tokenizer 少算 15–30%,尤其是代码和 JSON"(kilocode/session/overflow.ts:7-8)。
  2. 媒体走两套账JSON.stringify 时用 replacer 把 base64 数据替换成 "[encoded media]",同时按 byteLength / 4 单独累加 extra。于是 normalized(媒体折算成占位符)和 raw(媒体按实际大小)两个数字并存——前者用于判断要不要压缩,后者用于算输出预算。
  3. continued 短路(:17):如果最后一条 user 之后还有 tool 消息,说明这是一轮工具往返的中途,此时不做预检压缩(:94)。中途压缩会把没配对的 tool_call/tool_result 拆散,直接触发 provider 报错。

预检的触发点在 packages/opencode/src/session/llm.ts:153-165:超了就抛 PreflightError,由上层转成一次压缩。

6.2 SessionCompaction.process:模板化摘要

入口 packages/opencode/src/session/compaction.ts:321。走一遍:

1. 校验:压缩的 parent 必须是 user 消息(:377)
2. overflow 模式下回溯:把上一条真实 user 摘出来当 replay,
其余作为待摘要的 history(:391-408)
3. 挑 compaction agent 和模型(:411-414)
4. 隐藏历史上已完成的压缩对(:417-419)——不摘要"摘要"
5. select():切出 head(要摘要的)和 tail(保留原文的)
6. 序列化 head,强制 stripMedia + toolOutputMaxChars=2000(:434-437)
7. 调模型生成摘要
8. 摘要落库、写回 tail_start_id、发续跑消息、prune 清扫(:520-686)

第 5 步的 select 是核心compaction.ts:258-313):

turns() 把消息按 user 消息切成一个个「轮」(compaction 标记的 user 不算)
│ compaction.ts:156-172

取最后 tail_turns 轮(默认 2)
│ DEFAULT_TAIL_TURNS = compaction.ts:46

budget = cfg.preserve_recent_tokens
?? clamp(usable × 0.25, 2_000, 8_000)
│ MIN/MAX_PRESERVE_RECENT_TOKENS = compaction.ts:47-48

从最新那轮往回累加,塞得下就整轮保留

├─ 塞不下 ──→ splitTurn():在这一轮内部逐条往后找切点,
│ 找到"从这里到轮尾刚好放得进剩余预算"的位置
│ compaction.ts:174-197

keep.start 之前 = head(要被摘要掉),keep.id = tail_start_id

巧在 splitTurn 保留粒度不是"整轮或不留",而是能在一轮内部找到切点。一轮里如果有个 20 万字的测试日志在开头、几条关键结论在结尾,它会只保留结尾那几条。

第 6 步的三重降级也值得注意:摘要请求本身也可能超窗口。所以送去摘要的那份历史,媒体全剥(stripMedia: true)、每个工具输出砍到 2000 字符(TOOL_OUTPUT_MAX_CHARScompaction.ts:44)。

摘要模板 SUMMARY_TEMPLATEcompaction.ts:49-84)是一份固定的 Markdown 骨架,八个小节:

小节装什么
Goal一句话任务概述
Constraints & Preferences用户提的约束/偏好/规格
Progress(Done / In Progress / Blocked)三态进度
Key Decisions决策 + 为什么
Next Steps有序的下一步
Critical Context技术事实、报错、未决问题
Relevant Files路径 + 为什么相关

配套四条规则,其中两条特别关键:"空的小节也要保留"(结构稳定,便于下次增量更新)、"原样保留文件路径、命令、报错字符串、标识符"(这些一个字都不能变形,否则模型后面会照着错的去操作)。

增量摘要: 不是每次从零写。buildPromptcompaction.ts:134-145)发现有上一份摘要时,会包成 <previous-summary> 并要求模型"保留仍然成立的、删掉过时的、并入新事实"。压缩因此是滚动更新一份文档,而不是一层层套娃摘要。

压缩之后要能继续跑: 分两种路径(compaction.ts:528-633)。overflow 触发的会重放上一条真实 user 消息(replay,并把媒体换成占位符);普通自动压缩则插一条 synthetic 的续跑提示 "Continue if you have next steps, or stop and ask for clarification..."

6.3 prune:外科手术式地抹掉老工具输出

packages/opencode/src/session/compaction.ts:269-318。它不删消息、不改结构,只把老 tool part 的 state.time.compacted 打上时间戳;序列化时那些 output 就变成 "[Old tool result content cleared]"

倒着扫的四道闸门:

从最后一条消息往前走

├─ 遇到 user 消息 turns++;turns < 2 → 跳过
│ ⇒ 最近两轮完全不碰

├─ 遇到 summary assistant → break,不越过压缩点

├─ tool 名在 PRUNE_PROTECTED_TOOLS(["skill"]) 里 → 跳过
│ ⇒ skill 加载的指令是"活的规则",抹了模型就失忆

├─ 已经 compacted 过 → break,前面必然都处理过了

└─ 累计 output 未超 PRUNE_PROTECT(40_000 token) → 保留
超出的部分才进待抹清单

最后:待抹总量 > PRUNE_MINIMUM(20_000 token) 才真动手

最后那道 PRUNE_MINIMUM 闸门是成本理性:抹东西一定会打掉从该位置往后的所有缓存。省不下 2 万 token,就不值得付这个代价。

触发时机分三档compaction.ts:322-325):

reason默认跑吗谁触发
"normal",要显式 compaction.prune: true一轮结束后的后台清扫(prompt.ts:1778
"payload-limit"发送前载荷超 1.25 MB(prompt.ts:1658
"post-compaction"压缩刚完成(compaction.ts:684

设计意图很清楚:日常别乱动缓存;但已经要为别的原因重建缓存了,就顺手把该清的清了。 "post-compaction" 那处的注释直说:"compaction already invalidates cache, so collapse stale tool outputs too"(compaction.ts:635)。

6.4 兜底:分块摘要

摘要请求本身也可能装不下。KiloCompactionChunkspackages/opencode/src/kilocode/session/compaction-chunks.ts)是最后一层:

  • needed(:66)事前判断:估算 × 1.3 + 输出上限 > usable 就直接走分块,不必先撞墙。
  • eligible(:61)事后判断:结果是 "compact",或者 "stop" 且错误是 ContextOverflowError,就启用分块兜底。
  • split(:139)按 budgetusable × RATIO,下限 1000 token)把消息切块,逐块摘要再合并。
  • replay(:76)连"要重放的那条 user 消息"太大时,也把它单独摘要一遍再重放。

三层都失败,才在 compaction.ts:510-517 挂上 ContextOverflowError 收工。


7. 主循环里的两道保险

压缩和剪枝都依赖"token 估算",而估算会错。所以主循环里另有两道跟 token 无关的硬保险

7.1 保险 A:按字节数的发送前预剪枝

REQUEST_PRUNE_BYTES = 1_250_000packages/opencode/src/session/prompt.ts:120),单位是字节,不是 token

流程(packages/opencode/src/session/prompt.ts:1733-1754):

序列化 modelMsgs → Buffer.byteLength(JSON.stringify(...))

├─ ≤ 1.25 MB ──→ 直接发

└─ > 1.25 MB ──→ prune(reason: "payload-limit")
重新读历史 + 重新做队列裁剪/摘要裁剪
重新注入环境块 + 剥离历史媒体
重新序列化

├─ 还是超 ──→ log.warn,照发不误
└─ 降下来 ──→ 发

为什么要一条按字节的独立防线? 因为网关有 HTTP body 大小限制。这个限制跟 token 没关系——一张 5 MB 的 base64 图片可能只折算几千 token,却能把 body 撑爆。token 账算得再准,也管不了这个。

注意最后那一步:降不下来也照发。这是刻意的——本地判断不如让 provider 给出真实错误,真实错误还能触发压缩。

7.2 剥离历史媒体

maybeStripHistoricalMediapackages/opencode/src/kilocode/session/prompt.ts:622)是 body 超限的另一半解法。逻辑:

  • 只在历史里已经有完成的摘要时才动手hasCompletedSummary,:369)。没压缩过的会话不剥,保守。
  • 切分点 = 最后一条"带非 synthetic part"的 user 消息(stripHistoricalMedia,:423-425)。切分点及之后的媒体原样保留——用户刚发的图,模型必须看得见。
  • 切分点之前:file part 变成 [Attached image/png: foo.png] 文本;已完成 tool part 的 attachments 只丢媒体、保留非媒体。
  • 全程浅拷贝,不改原对象

切分点为什么要排除 synthetic-only 的 user? 注释解释得很细(:410-417):子任务收尾的 "Summarize the task tool output above…"、压缩后的续跑提示,都是系统生成的 user 消息。如果拿它们当切分点,用户在那之前刚发的附件就会被误剥。

7.3 保险 B:压缩次数封顶

压缩失败 → 还是 overflow → 再压缩,这是个天然的死循环。

MAX_COMPACTION_ATTEMPTS = 3packages/opencode/src/kilocode/session/prompt.ts:505),计数器 compactionAttemptsrunLoop 的局部变量(packages/opencode/src/session/prompt.ts:1475),每轮归零

guardCompactionAttemptkilocode/session/prompt.ts:514-531)在两个入口都被调用:

入口位置场景
循环顶部prompt.ts:1513上一条 assistant 的 token 数已经超限
结果处理prompt.ts:1723本次请求返回 "compact"

用尽时它做三件事:把 close reason 标成 "error"、往 assistant 消息挂 ContextOverflowError、返回 { exhausted: true } 让调用方 break。挂错误用的是 ??= 而不是 =(:359-360),注释说明是"只填空,不覆盖调用方已设的错误"。

为什么是 3? 注释给了答案(:333-337):"足够覆盖一次正常的 overflow 压缩,加上一次摘要自身溢出的重试,又不至于无限打转。"


8. 保:缓存断点

8.1 为什么前面所有设计都在迁就它

Anthropic 的 prompt 缓存按前缀匹配:从头开始逐字节比对,第一个不同的字节之后,全部失效

所以前面几节那些看似别扭的设计,动机是同一个:

设计迁就缓存的地方
system 五段按稳定度排序最稳的在最前
时间戳劈到 user 消息里不让 system 前缀每秒都变
EnvCache 按 user ID 缓存一轮之内多步请求字节完全相同
system 压成恰好 2 块保证全部内容落在缓存断点覆盖范围内
PRUNE_MINIMUM 闸门省得少就不值得打破缓存
"normal" 剪枝默认关闭平时别乱动历史
"post-compaction" 剪枝默认开启反正缓存已经废了,顺手清干净

8.2 applyCaching:AI SDK 路径

packages/opencode/src/provider/transform.ts:384-462。断点位置只有一句话:

const system = msgs.filter(m => m.role === "system").slice(0, 2) // 前 2 条 system
const final = msgs.filter(m => m.role !== "system").slice(-2) // 最后 2 条非 system

前 2 条 + 后 2 条,去重后逐个打标记。 前两条锁住稳定前缀,后两条锁住"当前这一轮的末尾"——因为一轮之内工具往返十几次,末尾不变,每次往返都能命中整个前缀。

标记形态按 provider 分(transform.ts:388-419):anthropic / openrouter / alibabacacheControl: {type:"ephemeral"}bedrockcachePointopenaiCompatible 用 snake_case 的 cache_controlcopilot 用私有的 copilot_cache_control

还有个放置层级的坑(transform.ts:422-458):Anthropic 和 Bedrock 把标记放在消息级 providerOptions;其他 provider 得放在最后一个 content part 上。判断错了 provider 会静默忽略标记。

调用只在 Anthropic 系模型上(transform.ts:527-545),且排除 @ai-sdk/gateway

8.3 cache: "auto":自研 LLM 层的策略

packages/llm 是那套自研原生运行时(详见 06-model-layer),它的缓存策略更成体系。

默认就是开的。 packages/llm/src/cache-policy.ts:18-22

const AUTO: CachePolicyObject = {
tools: true, // 最后一个工具定义
system: true, // 最后一个 system part
messages: "latest-user-message", // 最新的 user 消息
}

三个断点位置的选择在文件头注释里有论证:最新 user 消息在一轮内是不动的,而一轮会炸开成很多次 assistant/tool 往返——把断点打在那个边界,轮内每次调用都能吃到完整前缀。

为什么敢默认开? README 给了算术(packages/llm/README.md:44):Anthropic 5 分钟缓存写入是基础价 1.25×,读取是 0.1×,5 分钟内复用一次就已经回本。低于最小可缓存 token 数的一次性请求在网线上静默 no-op,最坏情况无害。

只对认这套标记的协议生效cache-policy.ts:42):

协议cache: "auto" 的效果
Anthropic Messages最多 3 个 cache_control(协议上限 4 个)
Bedrock Converse最多 3 个 cachePoint
OpenAIno-op —— 服务端隐式前缀缓存
Geminino-op —— 2.5+ 隐式缓存,显式 CachedContent 走带外接口

手写的 CacheHint 优先。 markLastTool / markLastSystem / markMessageAt 都先检查目标位置是否已有 cache 字段,有就原样返回(:50, :57, :74)——自动策略只填空,不覆盖。

一个不起眼的性能细节:markMessageAt 特意用 slice() + 下标赋值而不是 .map(),注释说明"长对话每次请求都会走这里,.map() 的闭包分发和整体身份拷贝在 profiling 里能看见"(:77-80)。


9. 输出侧:留多少、超了怎么截

进来的字节要省,出去的字节也要管——因为输出预算是从同一个上下文窗口里扣的

9.1 输出上限:三层收敛

OUTPUT_TOKEN_MAX = 32_000 provider/transform.ts:20
│ 可被环境变量 KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX 覆盖

maxOutputTokens(model) = min(model.limit.output, cap) || cap
│ provider/transform.ts:1389

capOutputTokens:按实测输入再收一次
available = context - 估算输入 - SAFETY(2048)
├─ available ≤ 0 → 原样返回,让 provider 报真实错误
├─ available ≥ 配置值 → 用配置值
└─ 否则 → max(available, MIN_OUTPUT=1024)
kilocode/session/llm.ts:49-69

第三层是 Kilo Code 加的,注释点了具体场景(kilocode/session/llm.ts:42-47(注释现表述为 "accounting for the context the outgoing request will consume")):很多小模型(如 32K 上下文的 qwen 7B)默认 max_output 就写 32K,加上工具 schema 后输入根本没地方放。不收一刀,请求必然被 provider 拒。

available ≤ 0不强行压到 1024,而是返回原值——注释解释了理由:输入本身已经超窗口了,与其自己瞎猜,不如让 provider 抛真实的 overflow 错误,那个错误能触发压缩,而 compactionAttempts 会保证最终停下来。

同一个 maxOutputTokens 还被 overflow.ts:15,18 拿去算"要给输出预留多少",两边用的是同一个函数——输入预算和输出预算共用一把尺

9.2 工具输出截断

工具产出的大文本走 Truncate.outputpackages/opencode/src/tool/truncate.ts:87-143),双阈值:

常量默认可配置项
MAX_LINES2000 行tool_output.max_lines
MAX_BYTES50 KBtool_output.max_bytes

超了的处理不是"丢掉",而是转存 + 留路标

全文写进截断目录(文件名是单调递增的 ToolID)


返回给模型的是:预览片段 + "...N lines truncated..." + 一段提示

├─ agent 有 task 权限 → "用 Task 工具让 explore agent 去处理这个文件,
│ 别自己整份读,省上下文"
└─ 没有 → "用 Grep 搜、或 Read 带 offset/limit 看局部"

direction 支持 "head" / "tail"——日志类输出看尾部更有用。字节累加时逐行算,并算上换行符(:105, :115)。

截断文件保留 7 天,靠一个 forkScoped 的后台任务每小时清一次,启动延迟 1 分钟(truncate.ts:14, 144-152)。清理靠 ID 里编码的时间戳判断新旧(truncate.ts:56-58),不用读文件元数据。

设计上的一致性: 这跟 prune 是同一个思路——不销毁信息,只把它移出上下文,并留下取回的路径。工具输出转存到磁盘留路径,老工具结果抹成 [Old tool result content cleared] 但 part 结构还在。


10. 巧妙之处(可以直接抄走的)

  1. 按变化频率给上下文分层。 同一批环境信息,静态的进 system、动态的进 user 尾部(kilocode/editor-context.ts:13-17)。这一刀让几十 KB 的前缀从"每秒都变"变成"整轮不变"。

  2. EnvCache 用 user 消息 ID 当缓存键。 不是时间窗口、不是 TTL,而是"业务上的一轮"。天然对齐了 prompt 缓存的复用边界(kilocode/session/prompt.ts:285-382)。

  3. 为了下游的缓存策略,上游主动把 system 压成 2 块session/llm/request.ts:91-95)。跨层协作,但注释不写就没人看得懂。

  4. 剪枝有最小收益门槛。 PRUNE_MINIMUM = 20_000:省不下 2 万 token 就不值得打破缓存(compaction.ts:358)。把"缓存失效"当成一项可量化的成本来算。

  5. 剪枝按 reason 区分默认值。 平时("normal")默认关,缓存反正要重建时("post-compaction" / "payload-limit")默认开(compaction.ts:322-325)。

  6. 摘要是滚动更新,不是层层套娃。 <previous-summary> + "保留仍成立的、删掉过时的、并入新的"(compaction.ts:134-145),避免了摘要摘要摘要之后信息糊成一团。

  7. splitTurn 让保留粒度小于一轮。 一轮内部找切点,能只留结论、丢掉开头那坨巨型日志(compaction.ts:174-197)。

  8. token 估算 × 1.3。 承认 字符数/4 会少算 15–30%,与其追求精确不如统一加保守系数(kilocode/session/overflow.ts:7-8)。

  9. 一条按字节的独立防线。 token 账管不了 HTTP body 大小,所以另设 REQUEST_PRUNE_BYTESprompt.ts:98)。两种资源,两把尺。

  10. 降不下来就照发。 预剪枝失败只 warn 不阻断(prompt.ts:1667)——本地瞎猜不如让 provider 报真实错误,真实错误能驱动后续修复。

  11. PRUNE_PROTECTED_TOOLS = ["skill"] 大部分工具输出是"看过就行"的数据,skill 加载的却是"持续生效的规则",抹了模型就失忆(compaction.ts:45, 347)。区分"数据"和"指令"。

  12. 用户插话就地包 <system-reminder> 不新建消息、不改结构,只在文本外套标签(prompt.ts:1628-1635)。语义清晰,且只影响末尾字节。

  13. 压缩次数封顶写死为 3,并解释为什么是 3kilocode/session/prompt.ts:501-505)。魔数配注释,是这个代码库的普遍习惯。


11. 边界与局限

估算是粗的。 Token.estimate 就是 字符数 / 4packages/core/src/util/token.ts:3-5),对 CJK、base64、密集代码的误差都不小。1.3 倍系数是经验值,不是任何 tokenizer 的真实换算。所有"预检"判断都建立在这个粗估上。

压缩会丢信息,模板兜不住全部。 SUMMARY_TEMPLATE 是八个小节的固定骨架。落在这八格之外的细节——比如某次尝试为什么失败的完整推理链——压缩后就没了。规则里只强制"原样保留路径、命令、报错字符串、标识符"这四类。

压缩必然打爆缓存。 前缀整段被替换,压缩后的第一次请求要全价重建。代码里的应对是"既然都要重建了,顺手把该清的清干净"("post-compaction" prune),而不是避免它。

多轮压缩会累积漂移。 滚动更新比套娃好,但"保留仍成立的、删掉过时的"这个判断由模型做——第 5 次压缩时最初那份摘要还剩多少准确信息,代码里看不出,也没有测量机制。

REQUEST_PRUNE_BYTES = 1.25 MB 是硬编码的prompt.ts:98),没有配置项。不同网关的 body 限制不同,换网关可能需要改源码。

预剪枝可能白跑一遍。 超阈值时会重新读库、重新裁剪、重新序列化整份历史(prompt.ts:1657-1668)。如果剪不动(比如都是最近两轮的输出),这一整套是纯开销。

工具往返中途不做预检压缩。 continued()kilocode/session/overflow.ts:17-20)会短路。这是必要的(中途压缩会拆散 tool_call/tool_result 配对),但也意味着一轮里如果工具输出爆炸式增长,只能等这一轮走完

prune 的保护窗口是"最近 2 轮",写死在代码里compaction.ts:340-341),不像 tail_turns 那样可配置。


12. 代码地图

主题文件路径符号名
按模型族选提示词packages/opencode/src/session/system.tsproviderinstructionssoul
静态 <env>packages/opencode/src/session/system.tsInterface.environment
skill 清单段packages/opencode/src/session/system.tsInterface.skills
<env> 内容packages/opencode/src/kilocode/system-prompt.tsKilocodeSystemPrompt.environment
静态/动态环境分层packages/opencode/src/kilocode/editor-context.tsstaticEnvLinesenvironmentDetails
项目规则加载packages/opencode/src/session/instruction.tsInstruction.systemsystemPathsresolvefiles
system 三段合成packages/opencode/src/session/prompt.tsrunLoopconst system = [...env, ...instructions, ...skills]
五段最终合成 + 压 2 块packages/opencode/src/session/llm/request.tsLLMRequestPrep.prepare
每轮环境注入 + 缓存packages/opencode/src/kilocode/session/prompt.tsEnvCacheinjectEditorContext
模式切换提醒packages/opencode/src/session/reminders.tsSessionReminders.apply
plan 提醒packages/opencode/src/kilocode/session/prompt.tsinsertPlanReminders
用户插话包装packages/opencode/src/session/prompt.tsrunLoop<system-reminder> 分支)
步数上限 prefillpackages/opencode/src/session/prompt.tsMAX_STEPS
part 类型体系packages/opencode/src/session/message-v2.tsPartTextPartToolPartCompactionPart
消息降维packages/opencode/src/session/message-v2.tstoModelMessagesEffecttruncateToolOutputsupportsMediaInToolResult
压缩后历史投影packages/opencode/src/session/message-v2.tsfilterCompactedfilterCompactedEffectlatest
摘要前裁剪补丁packages/opencode/src/kilocode/session/prompt.tstrimBeforeLastSummaryhasCompletedSummary
历史媒体剥离packages/opencode/src/kilocode/session/prompt.tsstripHistoricalMediamaybeStripHistoricalMedia
可用窗口与溢出判定packages/opencode/src/session/overflow.tsusableisOverflowCOMPACTION_BUFFER
溢出估算与阈值packages/opencode/src/kilocode/session/overflow.tsmeasurelimitcountshouldCompactPreflightError
压缩主流程packages/opencode/src/session/compaction.tsprocessCompactionselectturnssplitTurnbuildPrompt
摘要模板与常量packages/opencode/src/session/compaction.tsSUMMARY_TEMPLATEDEFAULT_TAIL_TURNSMIN/MAX_PRESERVE_RECENT_TOKENS
工具输出剪枝packages/opencode/src/session/compaction.tsprunePRUNE_MINIMUMPRUNE_PROTECTPRUNE_PROTECTED_TOOLSPruneReason
分块摘要兜底packages/opencode/src/kilocode/session/compaction-chunks.tsKiloCompactionChunks.neededsplitreplaybudget
发送前字节保险packages/opencode/src/session/prompt.tsREQUEST_PRUNE_BYTES
压缩次数封顶packages/opencode/src/kilocode/session/prompt.tsMAX_COMPACTION_ATTEMPTSguardCompactionAttempt
缓存断点(AI SDK)packages/opencode/src/provider/transform.tsapplyCachingmessage
缓存策略(自研层)packages/llm/src/cache-policy.tsapplyCachePolicyAUTORESPECTS_INLINE_HINTS
输出上限packages/opencode/src/provider/transform.tsOUTPUT_TOKEN_MAXmaxOutputTokens
输出上限二次收敛packages/opencode/src/kilocode/session/llm.tsKiloLLM.capOutputTokensneedsEstimate
发送前预检packages/opencode/src/session/llm.tsLLM.stream(preflight 分支)
工具输出截断packages/opencode/src/tool/truncate.tsTruncate.outputMAX_LINESMAX_BYTEScleanup
token 估算packages/opencode/src/util/token.tsToken.estimateCHARS_PER_TOKEN
压缩配置项packages/opencode/src/config/config.tscompactionauto / threshold_percent / prune / tail_turns / preserve_recent_tokens / reserved