大家好,我是HLAIA光子。
我自己的项目 OpenMMV,默认接的 AI 是 DeepSeek。最近有件事挺困扰我的:ds 的缓存命中率特别低,有时候只有 20% 到 30%。
DeepSeek 的前缀缓存,命中的部分价格非常便宜。但 OpenMMV 的缓存命中率很低,调 API 的费用就会多出很多了。
我在 L 站,B 站的 AI 区,时常看到人们讨论一个叫 Reasonix 的 agent harness,这是专门针对 deepseek 适配的 agent,用 ds 跑的时候,缓存命中率一般都能达到到 99% 。
同样是用 DeepSeek,为什么我的项目命中率这么拉?于是我仔细研究了 Reasonix 一番。
DeepSeek 的官方缓存机制
DeepSeek API 的 prompt caching 是服务端自动的,不用像用 Anthropic 那样显式打 cache_control 标记,只要多次请求的 prompt 前缀完全一致,命中部分就按缓存价计费。
命中的规则就是,从 prompt 的第一个 token 开始逐个往下比,中间任何一个 token 不一样,从那个位置起后面全部 miss。
那其实不难想到,想让命中率高,就要让 prompt 的前缀部分在每一轮请求里保持不变,有变化的部分都放到 prompt 的末尾。
记忆约束
Reasonix 在它的项目记忆文件 REASONIX.md 里写着一条约束:
Cache-first: the system-prompt prefix (base prompt + tools + memory) must stay byte-stable across turns so DeepSeek's automatic prefix cache stays warm. Never mutate it mid-session.
翻译:
缓存优先:系统提示词前缀(基础提示词 + 工具 + 记忆)在多轮对话中必须保持字节级稳定,以确保 DeepSeek 的自动前缀缓存保持命中状态。切勿在中途对其进行任何修改。
这就是 Reasonix 的对 ds 缓存的命中的工程约束了。
然后看 Reasonix 的 boot 阶段,internal/boot/boot.go 的 Build() 函数,前缀的组装顺序是怎么设计的:
// boot.go
sysPrompt, err := cfg.ResolveSystemPromptForRoot(root)
// output style 折叠进 base prompt,只组装一次,进入 cache-stable prefix
if st, ok := outputstyle.Resolve(cfg.Agent.OutputStyle, outputstyle.Dirs()); ok {
sysPrompt = outputstyle.Apply(sysPrompt, st)
}
sysPrompt += "\n\n" + config.LanguagePolicy
// 持久记忆在这里折叠进系统提示,一次
mem := memory.Load(memory.Options{CWD: root, UserDir: config.MemoryUserDir()})
sysPrompt = memory.Compose(sysPrompt, mem)
// skill 只放索引(名字+描述),正文按需加载,不进 prefix
skills := skillStore.List()
if !tokenEconomy {
sysPrompt = skill.ApplyIndex(sysPrompt, skills)
}
base prompt 放最前面,这个正常来讲是不会变的。即使记忆跨 session 变了,base prompt 仍然是一个合法的缓存前缀。
skill 只塞名字和描述进前缀,正文不进 prefix,用到的时候才加载,这个应该是常识了。
这个 sysPrompt 组装完之后传给 agent.NewSession,变成 Session.Messages[0],之后这个 session 里,它就不会再有变动。
工具 schema 也是一样,启动时注册进 *tool.Registry,session 内不变。连 plan 模式这种功能切换都不改工具列表
// agent.go
// planMode, when true, refuses any tool call whose ReadOnly() is false.
// The cache-friendly bits — system prompt, tools schema, message history —
// are left untouched, so the toggle costs nothing in cache hits.
// agent.go // 当 planMode(计划模式)为 true 时,将拒绝任何 ReadOnly() 为 false 的工具调用。 // 而那些缓存友好的部分 —— 系统提示词、工具定义(schema)以及历史消息 —— // 均保持原样(未受影响),因此切换该模式不会带来任何缓存命中率上的损耗。
也就是说,进入 plan 模式不会从 schema 里删掉写工具,而是在执行时进行拦截,让模型看到错误自己适应。
这就是细节啊
为了顺应开源精神,我决定 OpenMMV 直接把这个方案给抄过来
xml拼接
如果用户在会话进行过程中,改了计划模式、临时加了条记忆等等,这些状态变化模型必须知道。
肯定不能直接写进 system prompt,不然前缀变了,缓存命中直接 gg
Reasonix 的解法设计是,所有中途有变化的 都作为 XML 块,拼到当前 user 消息的最前面。
源码实现在 internal/control/input.go 的 Compose() 里
// input.go —— Compose
func (c *Controller) Compose(text string) string {
plan := c.planMode
notes := c.pendingMemory
goal, goalStatus, goalResearchMode := c.goals.snapshot()
// active goal 块,拼在 user turn 前面
if strings.TrimSpace(goal) != "" && goalStatus == GoalStatusRunning {
text = activeGoalBlock(goal, goalResearchMode) + "\n\n" + text
}
//plan mode 标记,拼在 user turn 前面
if plan {
text = PlanModeMarker + "\n\n" + text
}
//推理语言块,瞬时 user-turn 上下文
text = agent.WithReasoningLanguage(text, reasoningLanguage)
//中途加的记忆,搭 turn
if len(notes) > 0 {
var b strings.Builder
b.WriteString("<memory-update>\n")
b.WriteString("The following project-memory changes were just made and apply from now on:\n")
for _, n := range notes {
b.WriteString("- " + n + "\n")
}
b.WriteString("</memory-update>\n\n")
text = b.String() + text
}
//后台任务完成通知,搭 turn
if c.jobs != nil {
if note := c.jobs.DrainCompletedNoteForSession(c.parentSessionID()); note != "" {
text = "<background-jobs>\n" + note + "\n</background-jobs>\n\n" + text
}
}
return text
}
这些 <active-goal>、PlanModeMarker、<reasoning-language>、<memory-update>、<background-jobs> 块,全部拼在最新 user 消息前面,处于 prompt 的尾部。
前缀部分是稳定的,能命中缓存;尾部是变化的。
注释里写了策略:
Memory added mid-session rides the turn (never the cached system prefix), so it takes effect now without invalidating the prompt cache. It folds into the system prefix on the next session, where it costs nothing per turn.
会话中途添加的记忆会附着在当前对话轮次中(绝不触碰已缓存的系统前缀),因此它不仅能立即生效,还不会导致提示词缓存失效。在下一次(全新的)会话中,它会被合并到系统前缀里,届时(由于缓存机制)它在每一轮交互中都不会再产生额外的计算成本。
可以看出 Reasonix 把会话状态和缓存前缀做了隔离,最新状态变化拼到 xml 里,不会直接拼进 system prompt,不会破坏前缀。
协议层防 cache miss
前面都是 harness 层的设计;在 provider 适配层,Reasonix 还针对 DeepSeek 做了几个直接避免缓存失效和请求失败的处理
DeepSeek 的 thinking 模式有个容器 cache miss 的地方,当你要把历史里某条"带工具调用"的 AI 消息重新发给 DeepSeek 时,必须把这条消息当初的思维链reasoning_content也一起发回去,不然 DeepSeek 直接返回 400。
Reasonix 在 buildRequest 里专门处理了
// openai.go —— buildRequest
// DeepSeek thinking mode 400s a tool_calls turn whose reasoning_content was
// dropped on a cache-miss replay, so round it back — but only on the turn
// that carries the tool calls.
if c.deepseek && m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {
cm.ReasoningContent = m.ReasoningContent
}
注意它只在带 tool_calls 的那一轮回传。这样既避免了 400,又不把所有 reasoning 都塞回去,因为重新发送 reasoning 会有多余的话费。
这样看起来像是边边角角一样的逻辑判定,其实就是缓存命中策略的细节。把这些容易踩坑的地方都填平了,缓存命中率才会高上去。
工程纪律
命中率要做到 99% 点几,光代码里写好不够,还得保证后续改动不退化,这一层是很多项目缺失的,Reasonix为此做了几个工程化的规矩。
第一,缓存诊断。 agent 的 run loop 里每一轮都取前缀快照,跟上一轮对比,把结果挂到 Usage 事件上。诊断信息里带了 PrefixChanged、PrefixChangeReasons,告诉你这一轮 miss 是因为 system 变了、工具变了、还是 log 被压缩重写了。
这样Reasonix 就能让你能看到为什么这一轮 miss 了,因为看不见就管不住嘛。
而且它用的是会话级的累计命中率,不是单轮的比率,压缩时也不重置累计值,所以压缩不会让显示的命中率突然暴跌。
第二,CI 里的 cache-guard。 它有两个脚本。一个跑 TestReleaseCacheHitGuard 测试,这是"发版前缓存命中率不能退化"的一个检测脚本。另一个脚本的作用,就是当一个 PR 改了缓存敏感文件,比如 internal/boot、internal/tool、internal/provider 这些,就强制要求 PR body 里写明 Cache-impact: 和 Cache-guard: 两行,值用 n/a、none、todo 这种敷衍词会被直接拒绝。
这样就杜绝了很多劣质pr。
第三,写进项目记忆的 PR 规则。 REASONIX.md 把这套规范固化成对所有贡献者的约束。
也就是说,"这个改动会不会影响 DeepSeek 缓存命中率"是每个 PR 必须显式回答的问题。
像这样组织级的工程纪律,我觉得这就是 Reasonix 的缓存命中率能长期维持 99% 点几的护城河之一。
独立 session 历史
Reasonix 支持双模型,planner 计划加 executor 执行的协作。这是一个很容易缓存崩坏的场景,如果在同一个会话里切模型,system、tools、历史的格式全变,缓存直接清零。
Reasonix 的做法是让 planner 和 executor 各自的 Session 独立,不共享历史。planner 用自己的 session 产出计划,计划作为结构化文本交给 executor,executor 在自己独立的 session 执行。
The sessions never mix, so neither model's prefix is disturbed by the other's turns.
各个会话完全独立,所以两个模型的提示词前缀互不交织,各自的对话轮次也不会互相打扰。
不共享历史,两个 session 各自的缓存前缀是稳定增长的。
请求结构
把前面这几层串起来,一次发给 DeepSeek 的请求,prompt 大概是这个结构:

上半段是缓存命中区,逐字节稳定,整个 session 不变:base prompt、output style、language、memory、skill 索引,加上所有工具 schema,再加上 append-only 增长的历史。
下半段是缓存未命中区,每轮新增,只是尾部一小段:那些 <active-goal>、<reasoning-language>、<memory-update>、<background-jobs> 块,加上 Plan mode 标记,最后才是用户真正输入的文本。
DeepSeek 从头匹配,上面整块前缀和上一轮是一样的,会大面积命中;只有最底下这轮新拼的尾巴是 cache miss。
写在最后
有时候我在想,为什么 OpenMMV 的缓存命中这么小,然后我分析了下,因为 OpenMMV 的 AI 消息的 token 量太少了,前缀本身就很小,加之每轮的新增消息占比比较大,缓存命中率自然不会太大。
前缀占比越大,命中率就越逼近 100%,Reasonix 的长会话里 system 加 tools 加历史可能有几万甚至几十万 token,而尾巴那一小段只有几百到几千 token,这样缓存命中率就比较大。
高缓存命中率不是某一行代码的技巧,而是从项目记忆到 CI 脚本一以贯之的工程纪律。
如果你觉得这篇文章有帮助,点赞关注,点点赞~