跳到主要内容

招牌:提示词缓存的记忆布局(~98% 命中)

30 秒导读: Whale 每轮都要把「系统提示 + 工具定义 + 整段对话历史」重发给模型。 这段前缀可以长达几万 token。如果每轮都按原价付费,coding agent 根本用不起。 Whale 的招牌做法是:把上下文在内存里切成一段永不改动的前缀一条只追加、 从不改中段的历史,于是相邻两轮请求的绝大部分字节逐字节相同——正好命中 DeepSeek 这类 provider 的「前缀缓存」,命中部分按约 1/10 价计费。这一章讲清楚 这个布局是怎么设计的、怎么用指纹守住它、以及遥测怎么统计命中率。

本章是 01-turn-loop(回合循环)的姊妹篇:回合循环讲「一轮怎么跑」, 本章讲「一轮请求的字节是怎么摆的、为什么这么摆最省钱」。循环控制不在这里重复。


1. 这是什么(零基础也能懂)

先说清楚「提示词缓存」是什么

调用大模型时,你付的钱几乎和「输入 token 数」成正比。coding agent 的输入里, 每一轮都在重复同一批东西:一大段系统提示、全部工具的 JSON schema、以及到目前为止 的所有对话。第 20 轮请求里,前 19 轮的内容一字没变地又发了一遍。

provider(这里是 DeepSeek)提供了一个优化:前缀缓存(prompt / context cache)。 它的规则很简单——

如果这次请求的开头一段(前缀)和你上一次请求的开头逐字节完全相同, 这部分就直接命中缓存,按大幅折扣计费(DeepSeek 的缓存命中价约为未命中价的 1/10)。

关键词是逐字节、从头开始。缓存匹配的是「最长公共前缀」:从第 0 个字节往后比, 一比到第一个不同的字节就停,后面全算未命中。只要你在前缀中间改动一个字符, 它后面的所有内容(哪怕原样没动)都会掉出缓存、按全价重算。

一句话类比

把它想成磁带:provider 记得你上次录到哪。这次只要开头和上次的磁带从头对得上, 对上的部分不用重录(便宜);一旦某处对不上,从那个点往后全部得重录(全价)。 所以省钱的唯一办法是:别去动磁带前面已经录好的部分,只在末尾接着录。

Whale 要解决的小问题

怎么组织内存中的上下文,才能让每一轮请求的前缀和上一轮尽可能逐字节相同?

Whale 的答案是把内存布局切成三段,各有各的「可变性纪律」:

内容纪律结果
不可变前缀 ImmutablePrefix系统提示里稳定的部分一经建立,字节永不重写永远命中
只追加历史 AppendOnlyLog全部对话消息只在末尾 Append,从不改中段已发过的字节不动 → 继续命中
易变便签 VolatileScratch本轮的推理、工具参数计数等每轮 ResetTurn 清空根本不进请求,不污染前缀

招牌效果:因为前缀锁死、历史只增不改,第 N 轮请求相对第 N-1 轮,变的只有「末尾新追加 的那几条消息」。前面成千上万 token 全部逐字节相同 → 前缀缓存几乎全中,于是有了标题里 那个 ~98% 命中的经济优势。

本节不碰代码。记住一件事:省钱 = 让前缀别变。 下面看它怎么在代码里落地。


2. 顶层全景(三段布局怎么拼成一次请求)

Whale 用 RuntimeState 把三段捆在一起,每轮由它组装出发给 provider 的完整消息序列 (internal/memory/runtime.go:9 RuntimeState)。

怎么读下面这张图: 从上到下就是发给 provider 的消息顺序;越靠上越稳定(越容易命中缓存), 越靠下越新(是本轮真正变化的部分)。

发给 provider 的一次请求 = BuildProviderHistory() 的输出
┌──────────────────────────────────────────────┐
│ ① 不可变前缀 ImmutablePrefix │ ← 字节锁死,指纹守护
│ (合并成 1 条 system 消息) │ 每轮都逐字节相同 → 命中
├──────────────────────────────────────────────┤
│ ② 运行时块 RuntimeBlocks │ ← 分类为 "dynamic",但
│ (工作区路径 / OS / 技能 / 项目记忆) │ 一个会话内通常也不变 → 也命中
├──────────────────────────────────────────────┤
│ ③ 只追加历史 AppendOnlyLog │ ← 头部是老消息(命中),
│ user / assistant / tool 逐条 Append │ 尾部是本轮新增(未命中)
└──────────────────────────────────────────────┘

易变便签 Scratch 不在这里——它只是本轮 UI/控制用的临时态,永不进请求

三段各自住在哪个文件、干什么:

部件职责文件 · 符号
ImmutablePrefix装系统提示稳定部分;算指纹、验指纹internal/memory/prefix.go:11 ImmutablePrefix
AppendOnlyLog装全部对话消息;只追加,例外时整体重写internal/memory/log.go:12 AppendOnlyLog
RuntimeState把前缀 + 运行时块 + 日志 + 便签捆一起,组装请求internal/memory/runtime.go:9 RuntimeState
VolatileScratch本轮易变态(推理/工具参数),每轮清internal/memory/scratch.go:16 VolatileScratch
HydrateRuntime从历史消息重建 RuntimeState(恢复会话)internal/memory/hydrate.go:5 HydrateRuntime

组装的主线走一遍(高层)

BuildProviderHistory 就是把三段按顺序拼出来(internal/memory/runtime.go:41):

// internal/memory/runtime.go:41 BuildProviderHistory —— 组装最终请求
out = append(out, r.Prefix.ToMessages()...) // ① 不可变前缀 → 1 条 system
if len(r.runtimeBlocks) > 0 { // ② 运行时块 → 另 1 条 system
out = append(out, core.Message{Role: core.RoleSystem, Text: strings.Join(r.runtimeBlocks, "\n\n")})
}
out = append(out, r.Log.Entries()...) // ③ 只追加历史,逐条

注意顺序:前缀在最前、日志在最后。这正是为缓存服务的——最稳定的东西放在字节序列的 最前面,provider 的「最长公共前缀」才能吃到最多。Scratch 不出现在这里,它只是给 TUI 和回合控制看的临时态。


3. 核心原理(逐个机制,由浅入深)

3.1 不可变前缀:字节锁死 + 指纹守护

要解决的小问题: 前缀是缓存的地基。只要它变一个字符,后面全部掉缓存。怎么保证 它在整个会话里不被谁悄悄改了?

思路: 两手一起上——(a)结构上让它「只读」:一旦 Refresh 建好就不再往里写; (b)运行时取指纹(把全部系统块拼起来求 SHA-256),每轮开头重算一遍对比, 不一致就报警。指纹是「前缀有没有漂移」的廉价探针。

真实实现: 指纹就是把所有系统块用 "\n\n" 拼起来做 SHA-256 (internal/memory/prefix.go:60 fingerprintSystemBlocks):

// internal/memory/prefix.go:60 内容变了指纹必变
func fingerprintSystemBlocks(systemBlocks []string) string {
sum := sha256.Sum256([]byte(strings.Join(systemBlocks, "\n\n")))
return hex.EncodeToString(sum[:])
}

NewImmutablePrefix 建立时算一次并存下来(prefix.go:16Refresh prefix.go:22)。 之后 VerifyFingerprint用当前存着的系统块重新算一遍,和最初存下的比 (internal/memory/prefix.go:34):

// internal/memory/prefix.go:34 VerifyFingerprint —— 检测 prefix drift
func (p *ImmutablePrefix) VerifyFingerprint() (string, bool) {
fresh := fingerprintSystemBlocks(p.systemBlocks)
return fresh, fresh == p.fingerprint // 不等 = 前缀被动过 = 缓存要失效
}

关键细节(诚实说明): systemBlocksRefresh 之后不会被重新赋值——所以 正常路径下 VerifyFingerprint 恒为 true。它是一道防御性不变量检查:万一未来有人 改代码在会话中途 mutate 了前缀切片,这道检查会在下一轮开头立刻抓到并发 PrefixDrift 事件(见 §4),而不是让缓存悄悄失效、账单悄悄变贵。它守的是「前缀该锁死」这条纪律本身。

3.2 只追加历史:已缓存的字节一个都别动

要解决的小问题: 对话是不断长的。要是每轮都「重新整理」历史(比如插一句、改一句), 被改动那条之前累积的缓存全废。怎么保证已经发过、已经被缓存的历史字节永不变动?

思路: 给历史一个「只追加」的数据结构。对外只暴露 Append / Extend(往末尾加), 没有「改第 3 条」「删中间」这类 API。新消息只能落在末尾,末尾以前的字节天然不动。

真实实现: AppendOnlyLog 只有追加口(internal/memory/log.go:20 Appendlog.go:24 Extend),Entries 返回克隆副本供组装(log.go:30)。

唯一的例外,是明写出来的: RewriteWithReason(internal/memory/log.go:34)允许 整体替换日志,但只接受两个理由,别的一律拒绝:

// internal/memory/log.go:34 RewriteWithReason —— 只有这两种理由能重写历史
switch reason {
case RewriteReasonCompact, RewriteReasonRecovery: // 压缩 / 灾后恢复
default:
return false // 其它一切:拒绝
}
l.entries = cloneMessages(replacement)

这把「重写历史」这件必然炸缓存的事,收敛成两个显式、可审计的时刻:

  • RewriteReasonCompact:auto-compact 把长历史换成一段摘要(见 §5 边界)。
  • RewriteReasonRecovery:从损坏状态里恢复。

平时的追加从不走这里,所以平时缓存稳如泰山;只有这两个理由能主动「掀桌重来」。

3.3 易变便签:把「每轮都在变」的东西挡在请求之外

要解决的小问题: 一轮之内有很多天生易变的状态——模型的推理流、每个工具调用 攒了多少参数字符、警告、等待用户输入的门控。这些要是混进发给 provider 的历史里, 每轮都不一样,前缀立刻被搅乱。

思路: 给它们一个独立、易失的容器 VolatileScratch,并且根本不放进 BuildProviderHistory(回看 §2 的图:Scratch 不在请求里)。每轮开头 ResetTurn 清空 (internal/memory/scratch.go:38),会话级重置直接复用它(scratch.go:47 ResetSession)。

直觉: 把「稳定、要缓存的」和「易变、只给本地用的」在类型上就物理隔离。 易变态永远碰不到请求字节,自然污染不了前缀。这是本设计里很干净的一刀。

3.4 从历史重建:HydrateRuntime

要解决的小问题: 会话可以中断后再打开。重开时怎么把「三段布局」重新立起来?

思路: HydrateRuntime(internal/memory/hydrate.go:5)拿一个前缀 + 一串历史消息, 新建 RuntimeState,然后把历史整段 Extend 进只追加日志:

// internal/memory/hydrate.go:5 从历史重建运行时
func HydrateRuntime(prefix *ImmutablePrefix, history []core.Message) *RuntimeState {
rt := NewRuntimeState(prefix)
rt.Log.Extend(history) // 老历史一次性灌进只追加日志的末尾
return rt
}

重建后,老历史仍是一段稳定字节;新一轮继续往末尾追加。只要恢复出的前缀和之前逐字节相同, provider 侧的缓存跨会话也可能继续命中。


4. 关键设计取舍:什么进「稳定前缀」,什么进「易变运行时块」

这是命中率的核心工程判断,发生在 internal/agent/system_prompt.go。系统提示不是一整块, 而是被故意切成两堆,分别由两个函数产出:

函数产出什么进哪一段稳定性
buildImmutableSystemBlocks模式契约、委派策略、工具使用策略、日期策略、决策策略① 不可变前缀一个会话内恒定
buildRuntimeSystemBlocks可用技能、项目记忆、输出风格、运行时上下文(路径/OS/shell)② 运行时块可能随环境变化

不可变那堆是纯静态文本(internal/agent/system_prompt.go:23 buildImmutableSystemBlocksWithTools),例如模式契约、renderToolPolicyBlock (system_prompt.go:189)——注意它的注释特意点明:工具名走 DisplayToolName 静态映射, 输出字节稳定,不影响 prompt cache(system_prompt.go:190-192)。这堆就是缓存地基, 放进 ImmutablePrefix,由指纹守护。

运行时那堆(system_prompt.go:39 buildRuntimeSystemBlocks)包含 renderRuntimeBlock(system_prompt.go:133,吐出工作区根、OS、shell)、发现到的技能、 项目记忆等——这些依赖环境,理论上会变,所以被单独拎成「运行时块」。

取舍的精髓:

  • 越多东西塞进不可变前缀,可缓存的地基越大、越省钱;但代价是这些内容一个会话内 绝不能变
  • 真正可能变的(换了工作区、改了技能目录)必须放运行时块,否则前缀指纹一旦漂移, 地基整块作废。
  • 微妙之处: 运行时块虽被分类为 dynamic,但在一个会话内其实通常也不变 (工作区路径、OS、shell 都固定)。所以它同样逐字节稳定、同样命中缓存——把它单列出来 只是为了在它真变时能干净地失效,而不是每轮都当它会变。这就是 ~98% 能成立的原因: ①②两段实际上都稳,真正未命中的只有③日志末尾那几条新消息。

回合循环每轮都重新取一次运行时块并 SetRuntimeBlocks(internal/agent/turn_loop.go:172), 所以如果环境真变了,它会自然反映进去。


5. 遥测:把请求切成 CacheShape,统计到底命中了多少

光设计得好还不够,得能测internal/agent/cache_shape.go 负责把每次请求切成一张 「形状图」CacheShape,标出每段的哈希、字节数、稳定性,喂给遥测。

怎么切:头尾分离 + 逐段分类

buildCacheShapeForRequest(internal/agent/cache_shape.go:51)→ ...WithRuntime(cache_shape.go:55)遍历历史,把 system 消息和对话日志分开,再把日志 切成头 + 尾:尾巴固定取最后 8 条(cacheShapeTailMessages = 8,cache_shape.go:15):

// internal/agent/cache_shape.go:88 头 = 稳定老历史;尾 = 最近 8 条(最可能变)
tailLen := min(cacheShapeTailMessages, len(log))
head := log[:len(log)-tailLen]
tail := log[len(log)-tailLen:]

为什么头尾分开? 头部是老历史,和上轮逐字节相同(命中);尾部是最近几条,最可能是 本轮新增(未命中)。分别求哈希,遥测就能看出「变化确实集中在尾巴」这个健康信号。

每个系统块打上稳定性标签

classifySystemBlock(internal/agent/cache_shape.go:233)按内容前缀,给每个系统块贴 immutabledynamic 标签:

块开头文本归类 name稳定性
Tool use policy.tool_policyimmutable
Mode contract.mode_contractimmutable
Delegation policydelegation_policyimmutable
Current Whale runtime:runtime_contextdynamic
Available skillsskillsdynamic
# Project Memoryproject_memorydynamic

有了这张标签表,遥测能精确回答「哪一段字节把缓存搅了」——如果某个本该 immutable 的段 哈希变了,就是 bug 或误配。

哈希:短而稳的段指纹

每段的哈希用 hashJSON(internal/agent/cache_shape.go:387):JSON 序列化后 SHA-256, 只取前 16 个 hex 字符当段指纹,便于日志里比对:

// internal/agent/cache_shape.go:387 hashJSON —— 段级短指纹
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])[:16]

整张 CacheShape 汇总出 SystemSegments / ToolSegments / LogHeadHash / LogTailHash 以及一个总的 PrefixHash(cache_shape.go:96-136),每个 CacheShapeSegmentIndex / Name / Stability / Hash / Bytes——这就是给运维看「这一轮的字节都摆成什么样、 哪段该稳的稳了没」的显微镜。

命中率怎么算出来

真正的命中率来自 provider 返回的 usage。buildPrefixCacheMetrics (internal/agent/usage_budget.go:80)拿命中/未命中 token 一除:

// internal/agent/usage_budget.go:87 命中 token / (命中 + 未命中)
if denom := cached + missed; denom > 0 && cached > 0 {
ratio = float64(cached) / float64(denom)
}

这个 ratio 就是标题里那个百分比的来源。省下的钱有 EstimateCacheSavingsUSD (internal/telemetry/usage_log.go)按「未命中价 − 命中价」的差乘以命中 token 估出—— 命中价约为未命中价的 1/10,所以命中率越高、省得越狠。


6. 串联回合循环:每轮怎么用上这套布局

本章的机制不是孤立的,它们被 01-turn-loop 的主循环逐轮驱动。串起来看 (internal/agent/turn_loop.go):

怎么读: 下面是一轮循环里和缓存相关的动作,从上到下按执行顺序。

开一个会话:
HydrateRuntime(NewImmutablePrefix(buildImmutableSystemBlocks...)) turn_loop.go:121
rt.SetRuntimeBlocks(buildRuntimeSystemBlocks...) turn_loop.go:122

▼ 每轮循环:
┌─────────────────────────────────────────────────────────────┐
│ rt.Scratch.ResetTurn() 清空易变便签 turn_loop.go:162 │
│ rt.SetRuntimeBlocks(...) 刷新运行时块 turn_loop.go:172 │
│ [若超阈值] RewriteWithReason(Compact, ...) 压缩 turn_loop.go:183│ ← 主动炸缓存
│ VerifyFingerprint() 不 ok → 发 PrefixDrift 事件 turn_loop.go:209 │ ← 前缀漂移哨兵
│ streamAndHandle(...) → 拿到 usage + cacheShape │
│ buildPrefixCacheMetrics(...) → 发 PrefixCacheMetrics 事件 :232 │ ← 上报命中率
└─────────────────────────────────────────────────────────────┘

三个挂钩点:

  1. 前缀漂移哨兵(turn_loop.go:209):每轮请求前 VerifyFingerprint,不一致就 emit 一个 AgentEventTypePrefixDrift 事件,带上期望/实际指纹 (PrefixDriftInfo,internal/agent/agent.go:222)。这是「地基被人动了」的即时警报。

  2. 命中率上报(turn_loop.go:232):每轮从 provider 的 usage 里算出 PrefixCacheMetricsemit,让 TUI / 遥测实时看到本轮命中了多少。

  3. auto-compact 主动重写(turn_loop.go:183):当历史膨胀到超过阈值,循环会调 RewriteWithReason(RewriteReasonCompact, ...) 把日志换成摘要。这炸掉当轮缓存—— 是一次有意的、值得的让步:牺牲一轮命中,换回后续很多轮更短的上下文。压缩的判定、 摘要怎么生成属于 01-turn-loop 与其边界,本章不展开。


7. 巧妙之处(可借鉴的技术)

  • 用类型系统强制缓存纪律。 「该稳的」和「该变的」不是靠约定,是靠不同的数据结构 物理隔离:ImmutablePrefix 没有写口、AppendOnlyLog 只有追加口、VolatileScratch 根本不进请求。想违反纪律得先改类型,门槛很高。(internal/memory/*.go)

  • 指纹 = 廉价的漂移哨兵。 一次 SHA-256 就能在每轮开头 O(1) 地断言「前缀没被动」, 比逐字段比对便宜,失败就发事件而非静默变贵。(prefix.go:34 VerifyFingerprint)

  • 把「炸缓存」收敛成两个具名理由。 重写历史必然失效缓存,于是 RewriteWithReason 只放行 compact / recovery 两种理由(log.go:34),把不可避免的失效变成显式、 可审计的事件,而不是散落各处的偶发改动。

  • 遥测按「段」而非「整块」看缓存。 头/尾分离(cache_shape.go:88)+ 逐段稳定性标签 (classifySystemBlock cache_shape.go:233)让你能定位「到底哪段把缓存搅了」, 而不是只知道「今天命中率低了」。

  • 运行时块单列,只为「真变时干净失效」。 明明会话内多半不变,却仍把它和不可变前缀 分开(§4),是为了在环境真的变化时让失效局部而明确,不牵连整个前缀。


8. 边界与局限(诚实说明)

  • VerifyFingerprint 在正常路径恒真。 因为 systemBlocks 建好后不再被重新赋值 (prefix.go),它抓不到「provider 侧缓存实际是否命中」——那要看 usage (§5)。它只守「本进程内前缀内存有没有被 mutate」这一个不变量。别把它当命中率探针。

  • 前缀缓存是 provider 侧行为,Whale 只能「配合」不能「保证」。 Whale 能做的是让字节 逐轮稳定;真正给不给折扣、缓存留存多久,由 DeepSeek 决定 (internal/llm/deepseek/client.go 读取 PromptCacheHitTokens)。跨会话若缓存已过期, 即使前缀逐字节相同也可能不命中。

  • auto-compact 必然炸一轮缓存。 RewriteWithReason(Compact)(turn_loop.go:183) 重写整段历史,当轮命中率会掉——这是设计上的取舍,不是 bug。

  • ~98% 是「稳态」下的目标,不是每轮保证。 首轮(冷缓存)、压缩后一轮、环境变化触发 运行时块改变的那轮,命中率都会低。真实数字看 PrefixCacheMetrics(§5)逐轮上报的值。


9. 横向对比

同一货架其它章的关切点各不相同,本章只谈「记忆布局与缓存经济」:

  • 01-turn-loop — 谁在驱动这套布局:每轮的重置、压缩判定、事件上报。
  • 03-tools-and-edit — 工具的 JSON schema 也是前缀的一部分;工具集 稳定同样有利缓存。
  • 06-provider-and-extensibility — DeepSeek provider 侧 怎么读回 prompt_cache_hit_tokens、缓存折扣的定价。

10. 代码地图(导航索引)

主题文件路径符号名
不可变前缀容器internal/memory/prefix.goImmutablePrefix
取指纹(内容变则变)internal/memory/prefix.gofingerprintSystemBlocks
验指纹 / 检测漂移internal/memory/prefix.goVerifyFingerprint
只追加历史internal/memory/log.goAppendOnlyLog, Append, Extend
例外:整体重写历史internal/memory/log.goRewriteWithReason, RewriteReasonCompact, RewriteReasonRecovery
三段捆绑 + 组装请求internal/memory/runtime.goRuntimeState, BuildProviderHistory
从历史重建internal/memory/hydrate.goHydrateRuntime
易变便签(不进请求)internal/memory/scratch.goVolatileScratch, ResetTurn, ResetSession
稳定前缀 = 哪些系统块internal/agent/system_prompt.gobuildImmutableSystemBlocks, renderToolPolicyBlock
易变运行时块 = 哪些系统块internal/agent/system_prompt.gobuildRuntimeSystemBlocks, renderRuntimeBlock
请求切形状(遥测)internal/agent/cache_shape.gobuildCacheShapeForRequest, buildCacheShapeForRequestWithRuntime
系统块稳定性分类internal/agent/cache_shape.goclassifySystemBlock
段级短指纹internal/agent/cache_shape.gohashJSON
命中率计算internal/agent/usage_budget.gobuildPrefixCacheMetrics
每轮挂钩(漂移/上报/压缩)internal/agent/turn_loop.goVerifyFingerprint(:209), buildPrefixCacheMetrics(:232), RewriteWithReason(:183)
漂移事件载荷internal/agent/agent.goPrefixDriftInfo, AgentEventTypePrefixDrift