引言
KiloCode 是一个面向软件工程的 LLM Agent 框架。与 Cursor、Claude Code 等同类工具一样,它需要在每次 LLM 调用中组装大量上下文:系统提示、项目指令、历史消息、工具定义、编辑器状态等。如何让这些上下文高效缓存、避免重复计费 token,是影响用户体验和成本的关键。
本文基于 KiloCode 源码,深入拆解它的上下文组装流水线、缓存策略设计,以及 Preflight Overflow 预检机制。
总流程:从用户输入到 API 请求
用户输入
↓
SessionPrompt.prompt() ← 创建用户消息,入队
↓
SessionPrompt.runLoop() ← while(true) 多步循环
↓
[每步]
1. filterCompactedEffect() ← 从 SQLite 加载消息历史
2. sys.environment() ← 构建 <env> 块
3. memoryInject() ← 注入项目记忆(pinned)
4. instruction.system() ← 加载 AGENTS.md
5. sys.skills() ← 列出可用技能
6. injectEditorContext() ← 注入 <environment_details>
7. toModelMessagesEffect() ← 转 AI SDK 格式
8. SessionTools.resolve() ← 解析可用工具
9. LLMRequestPrep.prepare() ← 最终组装 system/消息/参数/头部
10. streamText() ← 发送请求
↓
handleEvent() ← 处理 stream(文本/工具调用/推理)
↓
检查 finish reason → tool-calls 则回到步骤 1,stop 则退出
上下文的分层组装
1. 系统提示(System Prompt)的构成
系统提示是消息列表中的第一条 role: "system" 消息,由五部分拼接而成,顺序严格固定:
[soul] ← "You are Kilo, a highly skilled software engineer..."
[provider-prompt]← 模型特定的行为规范(anthropic.txt / default.txt / ...)
[env] ← <env> 块(模型名、平台、日期、git 状态、配置路径)
[mem] ← Kilo 项目记忆块(来自之前会话的持久记忆)
[instructions] ← AGENTS.md / CLAUDE.md 内容
[skills] ← 可用 skill 列表
soul(kilocode/soul.txt):Kilo 的核心身份提示,约 15 行,定义 Agent 人格、代码风格、工具使用规范。这是纯静态文本。
provider-prompt(session/system.ts:38-80):根据 model.prompt 字段选取对应的 .txt 文件。目前支持 anthropic、codex、gemini、gpt55、beast、ling、trinity、kimi 等。每个文件针对特定模型编写,优化工具使用格式、缓存行为等。例如 anthropic 版会包含 XML 标签使用规范,而 codex 版更接近纯指令风格。
env(kilocode/system-prompt.ts:16-31):运行时环境的快照信息,格式为 <env> XML 块:
You are powered by the model named gpt-4o. The exact model ID is openai/gpt-4o
Here is some useful information about the environment you are running in:
<env>
Is directory a git repo: yes
Platform: linux
Today's date: Sun Jul 13 2026
Project config: .kilo/command/*.md, .kilo/agent/*.md, kilo.json, AGENTS.md...
Global config: /root/.config/kilo/ (same structure)
</env>
注意这里用的是 process.platform 和 new Date().toDateString(),意味着日期变化会导致 env 块变化。不过在单次会话中这不会变。
mem(kilocode/system-prompt.ts:33-83):从 KiloMemory.context() 获取之前会话的持久记忆,包含 15 条记忆使用指南 + 实际记忆块。这是一个关键的性能优化点——它被”pinned”了(见下文)。
instructions(session/instruction.ts:159-173):向上遍历目录树找到 AGENTS.md / CLAUDE.md,读取内容并附带路径标记 Instructions from: /root/proj/foo/AGENTS.md。也支持从 URL 获取远程指令。
skills(session/system.ts:105-117):列出所有可用的 skill 及其描述,格式为精简列表。
2. 最终组装:LLMRequestPrep.prepare()
拼装请求的地方(session/llm/request.ts:66-239):
const system = [
...(isOpenaiOauth ? [] : [SystemPrompt.soul()]),
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
...input.system, // env + mem + instructions + skills
...(input.user.system ? [input.user.system] : []),
]
.filter((x) => x)
.join("\n")
最终被合并为一个长字符串,作为唯一的 system 消息。然后构建 messages:
const messages = [
{ role: "system", content: system_string }, // 一个 system 消息
...converted ModelMessages, // user/assistant/tool 消息
]
3. 两种环境块:<env> 与 <environment_details>
KiloCode 向 LLM 提供两种环境信息,位置不同、内容不同、缓存行为不同。
系统提示中的 <env>(静态)
由 KilocodeSystemPrompt.environment()(kilocode/system-prompt.ts:16-31)构建,放在系统提示末尾:
You are powered by the model named gpt-4o. The exact model ID is openai/gpt-4o
Here is some useful information about the environment you are running in:
<env>
Is directory a git repo: yes
Platform: linux
Today's date: Sun Jul 13 2026
Project config: .kilo/command/*.md, .kilo/agent/*.md, kilo.json, AGENTS.md...
Global config: /root/.config/kilo/ (same structure)
</env>
内容稳定:模型标识、是否 git 仓、操作系统、日期(仅 toDateString(),无时分秒)、配置路径。日期变化次数极少,因此 <env> 在会话期间几乎不变。
用户消息末尾的 <environment_details>(动态)
由 environmentDetails()(kilocode/editor-context.ts:41-64)构建,注入到最新一条用户消息末尾:
<environment_details>
Current time: 2026-07-13T22:23:33+08:00
Working directory: /root/.cursor
Workspace root folder: /root/.cursor
</environment_details>
内容动态:ISO 时间戳(含时分秒)、工作目录、激活文件、可见文件、打开 tab。不持久化到 DB——每次发请求前在内存中追加。
| 属性 | <env>(系统提示) | <environment_details>(用户消息) |
|---|---|---|
| 位置 | 系统提示末尾 | 最新一条用户消息末尾 |
| 内容 | 模型/平台/配置 | 编辑器状态(文件/tab/时间) |
| 时间精度 | toDateString(),仅日期 | ISO 8601,含时分秒 |
| 持久化 | 是(系统提示的一部分) | 否,仅内存中追加 |
| 缓存行为 | 会话内不变,持续命中 | 每轮变化,仅最近一轮受影响 |
<environment_details> 的缓存失效模型
由于不持久化,消息在 DB 和 API 请求中不一致。一个典型的 N → N+1 轮转换如下(... 表示 system 与最后一条 user 之间的全部历史轮次):
第 N 轮(含多次工具调用,所有步共享同一个 env_N):
DB 里存的: ... , assistant_{N-1} , user_N
第 1 步 API 请求: ... , assistant_{N-1} , user_N+env_N → AI 返回 tool-call
第 2 步 API 请求: ... , assistant_{N-1} , user_N+env_N , tool_result → AI 继续
...(env_N 在同轮所有步中不变)
第 N+1 轮(用户发了新消息 user_{N+1}):
DB 里存的: ... , assistant_{N-1} , user_N , assistant_N , user_{N+1}
第 1 步 API 请求: ... , assistant_{N-1} , user_N , assistant_N , user_{N+1}+env_{N+1}
第 N 轮的请求中 user_N+env_N 出现,第 N+1 轮请求中同一位置是 user_N(无 env)。这意味着第 N 轮期间建立的所有 prompt cache 在第 N+1 轮全部失效——因为 ... 到 user_N 这段前缀变了(user_N+env_N → user_N)。
但第 N-1 轮及之前的缓存不受影响。展开 ...:
第 N-1 轮: system , user_1 , ... , user_{N-2} , assistant_{N-2} , user_{N-1}+env_{N-1}
第 N 轮: system , user_1 , ... , user_{N-2} , assistant_{N-2} , user_{N-1} , assistant_{N-1} , user_N+env_N
第 N+1 轮: system , user_1 , ... , user_{N-2} , assistant_{N-2} , user_{N-1} , assistant_{N-1} , user_N , assistant_N , user_{N+1}+env_{N+1}
system, user_1, ..., user_{N-2}, assistant_{N-2}, user_{N-1} 这段前缀从第 N 轮开始就固定了——第 N 轮是 user_{N-1}(无 env),第 N+1 轮也是 user_{N-1}(无 env),字节相同,缓存持续命中。变化只发生在 user_N 之后。
而在第 N+1 轮内部,EnvCache 确保同一个 user_{N+1} 下所有工具调用步复用相同的 env_{N+1},步之间不产生缓存变化。到第 N+2 轮时,user_{N+1} 失去 env,第 N+1 轮的缓存失效——但第 N 轮及之前的缓存不受影响。
设计意图:编辑器状态变化频繁(用户切换文件、打开新 tab),如果持久化到每条用户消息中,每一轮消息序列都会不同、缓存全部失效。不持久化 + 只追加到最新消息,使得缓存失效的范围始终被限制在最近一轮。
提示缓存(Prompt Caching)
KiloCode 花了大量精力确保”本轮之前的所有内容”字节级稳定,根本目的是最大化 provider 侧的 prompt cache 命中率。
什么是 Provider 侧的 Prompt Cache
主流 LLM Provider(Anthropic、OpenAI、Google)都支持服务端提示缓存:
- Anthropic Prompt Caching:标记消息的
cacheControl: {type: "ephemeral"},系统提示和早期历史会被缓存一段时间。后续使用相同前缀的请求只需要计算新增部分的 token,缓存部分按折扣价格计费。 - OpenAI Prompt Caching:API 会自动检测是否有匹配的前缀(相同 system + 相同早期消息),命中后缓存部分仅按半价计费。
- Google Context Caching:类似,但需要显式创建缓存对象。
哪些内容是字节级稳定的
| 组件 | 稳定性保障 | 缓存优势 |
|---|---|---|
| soul() | 硬编码文本文件 | 永远不变 |
| provider-prompt | 硬编码 txt 文件 | 永远不变 |
<env> 块(系统提示内) | InstanceState 全局状态 | 会话内不变 |
| AGENTS.md 指令 | 文件内容不重读就不变 | 会话内不变 |
| skills 列表 | session 内一次加载 | 会话内不变 |
| memory 块 | per-session pinned | 最关键 |
| 历史消息 | SQLite 写入后 immutable | 不变 |
<environment_details>(用户消息内) | 不持久化,每轮新注入 | 缓存失效仅限最新一轮 |
Per-session Pinned Memory
关键设计在 kilocode/session/prompt.ts:203-248。注释说明了动机:
Pin the injected memory block per session. Reading the live index every step/turn (each session digest rewrites it) busts the provider prompt cache for instructions + the whole history. Build once at session start and reuse the same block verbatim, which also excludes this session’s own digest from its index.
——将注入的记忆块按会话固定。如果每一步/每一轮都读取实时索引(每轮新的 session digest 会重写文件),就会破坏 Provider 对指令+全部历史的 prompt cache。在会话开始时构建一次,原样复用,同时排除本会话自己的 digest 不出现在索引中。
实现上是一个 Map<sessionID, PinnedMemory>(#209),限制了 512 个 session 的 LRU 淘汰。逻辑是:
memoryInject() 被调用时
→ 检查 pinnedMemory cache 中是否有该 session
→ 有 → 直接返回缓存的 blocks(byte-identical)
→ 无 → 从 KiloMemory.context() 构建新 blocks → 写入 cache
这意味着:无论 runLoop 迭代多少步,memory 块始终是一样的。而如果每次重新读取内存索引(每次新的 session digest 都会重写文件),那么 AGENTS.md 后面的所有指令文本都会因为这个字节变化而失去缓存命中。
<environment_details> 的缓存
编辑器上下文的注入由 injectEditorContext() 完成,也有自己的缓存机制(kilocode/session/prompt.ts:263-299):
// EnvCache: keyed by user message ID
export interface EnvCache {
block?: string
user?: string // 最后的用户消息 ID
}
injectEditorContext 被调用时
→ 检查 cache.user === lastUser.id
→ 相同 → 复用缓存的 block(字节一致)
→ 不同(新用户消息)→ 重建并写入 cache
这确保了在同一个用户消息的多个 LLM 步之间,editor context 块字节相等。Provider缓存不会因为当前步添加了工具调用结果而被 bust。
Cache Control 标记
applyCaching() 函数(provider/transform.ts:352-398)在消息上设置缓存标记:
const providerOptions = {
anthropic: { cacheControl: { type: "ephemeral" } },
openrouter: { cacheControl: { type: "ephemeral" } },
bedrock: { cachePoint: { type: "default" } },
openaiCompatible: { cache_control: { type: "ephemeral" } },
copilot: { copilot_cache_control: { type: "ephemeral" } },
alibaba: { cacheControl: { type: "ephemeral" } },
}
// 只标记前 2 条 system 消息 + 后 2 条非 system 消息
const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
for (const msg of [...system, ...final]) {
msg.providerOptions = mergeDeep(msg.providerOptions ?? {}, providerOptions)
}
由于系统提示只有一条({role:"system", content: big_string}),它总是被标记为可缓存。后 2 条非 system 消息通常是最近一轮的 user+assistant 对话,也标记为可缓存。
对于 OpenAI 和 Azure,则使用一个不同的机制——promptCacheKey: sessionID(provider/transform.ts:1166),让 OpenAI 在服务端做前缀匹配。
回合内(Intra-turn)多步循环的缓存稳定性
runLoop 的一个核心设计场景是:用户问一个问题,AI 返回一个工具调用(如搜索文件),工具执行后结果送回给 AI,AI 再基于结果回答。这涉及同一个用户消息下的多个 LLM 步。
步 1:系统提示(稳定)+ 用户消息 + 历史 → AI 返回 tool-call
步 2:系统提示(相同)+ 用户消息 + 历史 + 工具结果 → AI 继续推理
步 2 的系统提示与步 1 完全字节相同。这意味着 provider 缓存在步 1 建立的 system 前缀在步 2 中仍然有效——只有新增的 tool-result 消息需要计算和计费。
Preflight Overflow 预检
随着对话变长,token 数可能超过模型 context window。Preflight Overflow 在发请求之前预测 token 用量,若超限则触发历史压缩后重试,避免浪费 API 调用。measure() 估算 token(含 1.3 系数 + media 替换),shouldCompact() 决策是否压缩(含 continuation 检测——处理工具结果时不触发),压缩循环由 guardCompactionAttempt() 防止无限重试。
架构设计的权衡
稳定性 vs 新鲜度
系统提示的稳定性对缓存至关重要,但越稳定的东西越不新鲜。典型取舍:
- memory 块按会话固定(pinned):如果每轮都更新 memory 块来包含本轮的对话摘要,系统提示就会逐轮变化、缓存全部失效。代价是当前会话中产生的记忆不会被自己读到——本轮的决策和约定要到下一轮会话才作为 memory 注入。
压缩时机
Preflight 在发请求前估算 token,超限则压缩后重试。代价是估算有误差(1.3 系数偶尔导致过度压缩),但相比每次等 provider 报错再重试,代价更小。
Cache 标记粒度
applyCaching() 只标记前 2 条 system 和后 2 条非 system。这是适配 Anthropic 的缓存策略——Anthropic 允许标记任意消息为 cache point,但每个标记都有微小开销。标记太多反而降低收益。
对于长对话,只有系统提示(最稳定的部分)和最近 2 条消息被标记缓存。中间的历史虽然不被显式标记,但只要前缀匹配(前 N 条消息字节相同),也能从 Anthropic 的自动缓存机制受益。
总结
KiloCode 的上下文组装核心设计理念可以概括为一句话:
系统提示越稳定越好,变化越靠后越好。
具体策略是四层递进:
- 纯静态(soul、provider-prompt):永远不变
- 会话静态(env、instructions、skills、pinned memory):会话内不变
- 消息级动态(editor context、工具调用结果):每次追加到末尾,不影响前缀缓存
- 压缩(Preflight Overflow):发请求前预测超限,自动压缩历史再重试
这种分层稳定策略让 Agent 的每一次 API 调用都尽可能多地命中 Provider 侧的 prompt cache。系统提示字节不变、历史消息只增不改、最近一轮的缓存失效不波及之前的历史——三层保障叠加,使得长对话中大部分 token 按 Cache Read 价格计费,而非原始 Input 价格。