Skip to content
返回

KiloCode 上下文组装与提示缓存深度解析

引言

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 列表

soulkilocode/soul.txt):Kilo 的核心身份提示,约 15 行,定义 Agent 人格、代码风格、工具使用规范。这是纯静态文本。

provider-promptsession/system.ts:38-80):根据 model.prompt 字段选取对应的 .txt 文件。目前支持 anthropic、codex、gemini、gpt55、beast、ling、trinity、kimi 等。每个文件针对特定模型编写,优化工具使用格式、缓存行为等。例如 anthropic 版会包含 XML 标签使用规范,而 codex 版更接近纯指令风格。

envkilocode/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.platformnew Date().toDateString(),意味着日期变化会导致 env 块变化。不过在单次会话中这不会变。

memkilocode/system-prompt.ts:33-83):从 KiloMemory.context() 获取之前会话的持久记忆,包含 15 条记忆使用指南 + 实际记忆块。这是一个关键的性能优化点——它被”pinned”了(见下文)。

instructionssession/instruction.ts:159-173):向上遍历目录树找到 AGENTS.md / CLAUDE.md,读取内容并附带路径标记 Instructions from: /root/proj/foo/AGENTS.md。也支持从 URL 获取远程指令。

skillssession/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_Nuser_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)都支持服务端提示缓存:

哪些内容是字节级稳定的

组件稳定性保障缓存优势
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: sessionIDprovider/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 新鲜度

系统提示的稳定性对缓存至关重要,但越稳定的东西越不新鲜。典型取舍:

压缩时机

Preflight 在发请求前估算 token,超限则压缩后重试。代价是估算有误差(1.3 系数偶尔导致过度压缩),但相比每次等 provider 报错再重试,代价更小。

Cache 标记粒度

applyCaching() 只标记前 2 条 system 和后 2 条非 system。这是适配 Anthropic 的缓存策略——Anthropic 允许标记任意消息为 cache point,但每个标记都有微小开销。标记太多反而降低收益。

对于长对话,只有系统提示(最稳定的部分)和最近 2 条消息被标记缓存。中间的历史虽然不被显式标记,但只要前缀匹配(前 N 条消息字节相同),也能从 Anthropic 的自动缓存机制受益。

总结

KiloCode 的上下文组装核心设计理念可以概括为一句话:

系统提示越稳定越好,变化越靠后越好。

具体策略是四层递进:

  1. 纯静态(soul、provider-prompt):永远不变
  2. 会话静态(env、instructions、skills、pinned memory):会话内不变
  3. 消息级动态(editor context、工具调用结果):每次追加到末尾,不影响前缀缓存
  4. 压缩(Preflight Overflow):发请求前预测超限,自动压缩历史再重试

这种分层稳定策略让 Agent 的每一次 API 调用都尽可能多地命中 Provider 侧的 prompt cache。系统提示字节不变、历史消息只增不改、最近一轮的缓存失效不波及之前的历史——三层保障叠加,使得长对话中大部分 token 按 Cache Read 价格计费,而非原始 Input 价格。


KiloCodeLLMPrompt EngineeringSystem Design