五个 worktree 并跑,缓存一个都没命中

2 阅读11分钟

一个人同时开五个 agent,一个 worktree 一个,各自修一个 PR。这种用法在最近半年变成了默认姿势:并行、隔离、互不踩脚。

账单出来的时候,前四项成本都符合预期,最后一项不对:输入 token 的费用比「五个独立 session」的估算还高。

原因写在文档里的一句话:Claude Code 的缓存「实际上被限定在一台机器和一个目录内」。

一个 git worktree 是另一个目录。所以同一台机器、同一个仓库、同一个版本、同一个模型,五个 session 之间不共享缓存。每一个都从头写自己那份前缀。

前缀缓存是什么样的缓存

要理解这个结果,得先扔掉「缓存」这个词带来的联想。

它按内容寻址吗?不是。它做的是前缀匹配。

API 读一个请求的顺序是固定的:先是 tools(工具定义),然后是 system(系统提示),最后是 messages(对话)。缓存命中的条件是「到缓存断点为止,所有片段 100% 一致」。差一个字节,从那个字节往后全部重新处理。

这条规则解释了 Claude Code 为什么要精心安排内容顺序:把最不容易变的东西放在最前面。

层内容什么时候变
系统提示核心指令、工具定义加载的工具集变了
项目上下文CLAUDE.md、auto memory、未限定作用域的规则会话开始、/clear、/compact
对话你的消息、模型的回复、工具结果每一轮

缓存的价值在于第三层很短、前两层很长。一个典型 session 的第一轮请求里,工具定义加系统提示可能占几万 token,你的第一句话只有几十个 token。如果前两层能命中缓存,这一轮的成本就只按几十个 token 算。

所以命不命中,取决于前两层有没有逐字节对上。

worktree 卡在哪一层

这里有个反直觉的细节,也是整件事的关键。

auto memory 存在 ~/.claude/projects/<project>/memory/,而这个 <project> 是从 git 仓库推出来的。文档写的是:同一个仓库的所有 worktree 和子目录,共享一个 auto memory 目录。

auto memory 的位置会被写进系统提示里。所以同一个仓库的几个 worktree,系统提示是可以逐字节一致的。

反过来,独立的 clone 做不到。默认路径是按仓库在磁盘上的路径命名的,所以不同用户、不同机器、不同 checkout 各不相同。

那么 worktree 之间差在哪?差在对话层。

环境信息(工作目录、平台、shell、OS 版本)和 CLAUDE.md 的内容,都放在对话层,不放在系统提示里。对话层还带着启动时抓的一份 git status 快照:当前分支、最近几个 commit。

把这些拼起来就清楚了:两个 worktree、同一台机器、同版本同模型,tools 和 system 可以完全一样,然后第一句话里写着不同的工作目录,两个前缀从那一行开始分叉。

分叉点就是那行工作目录。它跟「有点不一样」不是一个量级,那是一条硬边界。

分歧的代价是 25 倍

价格表决定了「分叉」这件事值多少钱。

写缓存比普通输入贵:5 分钟 TTL 是 1.25 倍,1 小时 TTL 是 2 倍。

读缓存比普通输入便宜得多:多数模型是 0.1 倍,Opus 5.5 和 Sonnet 5.5 是 0.05 倍,Fable 5.1 是 0.025 倍。

拿 Opus 5.5、5 分钟 TTL 算:写一个 token 花 1.25,读一个 token 花 0.05,比值 25。换 1 小时 TTL,是 40 倍。

也就是说,那个本该被读的 4 万 token 前缀,在第二个 worktree 上是按 1.25 倍重写了一遍,而不是按 0.05 倍读一遍。同样的内容,成本差了 25 倍。

放到五个并行 session 上:本来该是 1 次写加 4 次读,实际成了 5 次写。

这笔钱只付一次,就是每个 session 的第一轮请求。之后每个 session 在自己的缓存上读,逐轮都命中。所以对长会话来说,它是可摊销的;对大量短会话(比如一次改一个文件就结束的任务),它就是主要成本。

还有什么会让前缀分叉

工作目录只是其中一个。文档里列的还有一串:

模型。 每个模型有自己的缓存。切换模型等于从零开始。

思考强度。 多数模型上,不同的 effort 级别不共享缓存。例外是 Opus 5.5、Sonnet 5.5、Haiku 5.5、Fable 5.1 这几个在 API key 或订阅下,改 effort 不丢缓存。

速度模式。 fast mode 和标准速度是两个独立的缓存。

启动时机。 这一条最容易踩:缓存条目要等第一次响应开始之后才可用。五个 session 在同一秒启动,五个都会写,哪怕它们在同一个目录里。

为此有个默认行为:同一前缀的 fan-out 里,除第一个 agent 外的其余 agent 会被压住最多 5 秒,等第一个的响应开始。

账号。 组织之间隔离,Claude API 上 workspace 之间也隔离。

版本。 升级 Claude Code 通常会改系统提示或工具定义,于是前缀变了。升级之后所有活着的 session 都在写新前缀。

把这些合起来看,「缓存命中」这件事的脆弱程度超出直觉:它不接受「内容差不多就行」,要求一整段前缀逐字节相同,且模型、速度、账号、版本全对齐。

怎么自己量

每一次 API 响应的 token 计数都会写进 transcript,路径是 ~/.claude/projects/<project>/<session-id>.jsonl,其中 <project> 是工作目录路径把非字母数字替换成短横线之后的结果。

每个 worktree 一个目录,所以目录名本身就告诉你 session 在哪跑的。

想看三个 session(同目录两个、另一个 worktree 一个)的第一轮请求分别花了多少,从普通 shell 里跑这段:

for f in $(ls -t ~/.claude/projects/*/*.jsonl | head -3); do
  jq -c -s --arg dir "$(basename "$(dirname "$f")")" '
    map(select(.type == "assistant" and .message.usage))
    | first // empty
    | {dir: $dir,
       model: .message.model,
       t: .timestamp,
       write: .message.usage.cache_creation_input_tokens,
       read: .message.usage.cache_read_input_tokens,
       fresh: .message.usage.input_tokens}
  ' "$f"
done

读输出的方式很直接:同目录的第二个 session,read 应该占大头,说明它读到了第一个写下的缓存;worktree 那个 session 的 write 会明显更大,因为从工作目录那一行往后全是新写的。

t 那一列也要看:两个启动时间差不到一秒的 session,即使在同一目录里,也可能都显示很大的 write,就是上面说的「缓存条目要等第一次响应开始」。

想看整个 session 的总量,得按 message.id 去重,否则同一条响应的多个条目会被重复计数:

for f in $(ls -t ~/.claude/projects/*/*.jsonl | head -3); do
  jq -c -s --arg dir "$(basename "$(dirname "$f")")" '
    map(select(.type == "assistant" and .message.usage))
    | group_by(.message.id) | map(.[0].message.usage)
    | {dir: $dir, requests: length,
       write: (map(.cache_creation_input_tokens // 0) | add),
       read: (map(.cache_read_input_tokens // 0) | add),
       fresh: (map(.input_tokens // 0) | add)}
  ' "$f"
done

总输入等于 read + write + fresh,read 除以总量就是缓存命中占比。

不想开终端的话,会话里 /usage 有一行 Prompt cache (main),显示同样的比例、未命中数,2.1.260 之后的版本还会给一个「上次未命中的可能原因」。要注意那一行只覆盖主对话,不含子 agent。

还有一个反直觉的推论:缓存命中率高的 session,未必省钱。 一个 session 如果频繁 /compact,每次压缩都会重写项目上下文层,前缀变了,之前攒下的缓存全部作废。所以「聊得越久越划算」这句话有前提——中间不要动前两层。

能控制的部分

版本统一。 所有 session 跑同一个 Claude Code 版本,升级后全部重启。这一条最容易被忽略:一个人升级了、别人没升,前缀就对不上。

优先 worktree,别用 clone。 这是本文唯一一个不需要额外成本就能拿到的优化:worktree 共享 auto memory 位置,系统提示可以一致;clone 在别的路径下,系统提示就不同了。

错开启动。 第一个 session 的响应开始之后,再起第二个。差几秒,省一份 25 倍。

模型和速度保持一致。 同一个任务链里不要在模型之间来回切。

对脚本化场景,还有两个开关:claude -p --exclude-dynamic-system-prompt-sections 会把 auto memory 位置从系统提示挪到第一条用户消息里,让系统提示更稳定;includeGitInstructions: false(或环境变量 CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS=1)能去掉 git 快照,但代价是内置的 commit 和 PR 指令也没了。

唯一改不了的是工作目录那一行。 一个 worktree 的对话层属于这个 worktree,每个 worktree session 的第一轮都会把它整段重写。

如果你自己搭 agent,能改的更多

这套分析里有一半的结论是 Claude Code 的实现细节,另一半是通用的。通用的那半,任何做 agent 系统的人都该记下。

把易变的东西放到最后。 前缀缓存的命中判定是「从前往后,第一个不同就断」。时间戳、请求 id、随机种子、当前用户的语言环境,这类每次都不一样的东西如果塞进系统提示,等于把缓存关掉。

工具定义要稳定。 工具列表通常在请求最前面。今天挂 12 个工具、明天挂 15 个,前缀就变了。按任务裁剪工具集是对的,但要让「同一类任务」拿到同一套工具,别让工具集随请求漂移。

别在系统提示里写会话级信息。 工作目录、分支、当前时间都属于这一类。它们对模型有用,代价是每个会话都要重写前缀。折中的做法是放到第一条用户消息里:仍然在缓存断点之后,但不会被当成「系统层」的一部分反复重算。

线程池式的 fan-out 要排队。 同一前缀并发起十个请求,十个都会写。串行起、或者等第一个响应开始再起后面的,能省下九份写。

这是不是设计错了

不一定。

工作目录进对话层,意味着模型知道自己在哪个目录里干活,这在多 worktree 场景下是必需的,否则它会改错分支。git 快照同理,知道当前分支和最近 commit 是让它不做出「基于过期代码」的判断的前提。

用缓存成本换来的,是正确性。

真正的取舍在别处:并行 session 带来的开发效率,和每个 session 多付一次前缀写。这两件事的比值取决于任务长度:长任务里那一次写被几十轮对话摊薄,短任务里它就是大头。

作者的判断我认同:他宁可「每个任务付一次缓存写」,也不要「两个 agent 改同一个 checkout」。前者是钱,后者是事故。

边界

这篇文章来自一个 IDE 的作者,写的是他自己的产品为什么这么设计,读的时候要带上这个前提。

他没有实际跑那几段 jq,明说了「我没有自己跑,也不会替你猜大小」,因为 CLAUDE.md 的体量、挂了几个 MCP server、Claude Code 的版本,都会改变 read 和 write 的具体分配。

transcript 的条目格式是 Claude Code 的内部格式,文档明确写了会随版本变化,所以那几段 jq 属于「现在能用」,不是稳定接口。

订阅用户的「1 小时 TTL」这个数字,指的是计入计划用量的方式,不是按美元计费。换算成钱只对 API key 用户成立。

还有一层没被讨论的:子 agent 和主对话的关系。文档说 Claude Code 会把 fan-out 里除第一个外的 agent 压住最多 5 秒,但子 agent 的缓存归属、以及它跟主对话前缀的重叠程度,这篇文章没有展开。

接下来看什么

两个可验证的点。

一是升级 Claude Code 之后顺手看一眼 /usage 里 Prompt cache (main) 的比例。如果它从 80% 掉到 20%,那说明系统提示或工具定义改了,跟你的用法无关,所有 session 都在重写。

二是观察「多 worktree 并行」这件事的成本结构会不会变。现在的设计是「一个目录一个缓存域」,如果哪天官方支持跨目录共享前缀(比如把工作目录从对话层挪到一个不参与缓存键的位置),这一整笔账就没了。在那之前,能做的优化就是错开启动、统一版本、别用 clone。

对每天开五个 agent 的人来说,今天最值钱的一句判断是:你以为的「并行省时间」,在缓存这一层是「并行付五次写」。 时间确实省了,钱没省。


来源: