朝は iOS の壁紙アプリのリポジトリ、昼はウェブ側、夕方にまた Xcode へ戻ります。個人開発で複数のプロダクトを並行させていると、一日のうちに作業ディレクトリを何度も移ります。そのたびに Claude Code が会話の先頭から読み直している気配があって、待たされる時間が積み重なっていました。
Claude Code v2.1.242 で promptCacheTtl と subagentPromptCacheTtl という二つの設定が入りました。プロンプトキャッシュの寿命を5分から1時間へ延ばせる、という説明を読んだとき、私が最初に思ったのは「離席の多い自分の働き方には効きそうだ」でした。
けれど設定を書く前に仕様を追ってみると、判断の軸は離席の長さではありませんでした。キャッシュが生きたまま迎えてくれるかどうかは、どれだけ待ったかよりも、どこへ戻るか で決まります。そして戻る場所が変わっていれば、TTL を何時間に延ばしても一度も読まれません。
つまみは二つ、バケットも二つです
Claude Code は cache_control を自分で置かせてくれません。キャッシュの位置も境界も Claude Code が決めます。利用者が触れるのは、リクエストを二つのバケットに分けたうえでの寿命指定だけです。
どちらも受け付ける値は 5m と 1h の二つだけで、それ以外は無視されます。30m と書いても弾かれるのではなく、静かに既定へ戻ります。この「知らぬ間に戻る」性質が後で効いてくるので、覚えておいてください。
{
"promptCacheTtl" : "1h" ,
"subagentPromptCacheTtl" : "5m"
}
そして既定値は、課金の経路によって変わります。
サブスクリプションでプラン内の利用に収まっている間は、メイン会話が自動で1時間になります。プランの上限を超えて使用クレジットに入った時点で、課金が発生するぶん Claude Code は安いほうの5分へ落とします。API キーでサインインしている場合とクラウドプロバイダー経由の場合は、最初から全部5分です。
つまり promptCacheTtl を明示的に書く意味があるのは、API キーまたはクラウドプロバイダーを使っている場合 と、サブスクリプションで上限を超えたあとも1時間を維持したい場合 の二つです。プラン内で収まっている人が "1h" と書いても、既にそうなっています。
サブエージェントは、契約に関係なく5分から始まります
ここが最初につまずいたところでした。サブスクリプションで作業していると、メイン会話は1時間キャッシュで走っています。ところがサブエージェントを呼んだ瞬間、その子は5分バケットに入ります。並列でファンアウトさせる場面ほど、いちばんキャッシュに助けてほしい側が冷たいまま始まる、という構図です。
理由は仕様を読むと納得できます。サブエージェントは自分専用のシステムプロンプトとツールセットで、親とは別の会話を始めます。プレフィックスが違うので、そもそも親のキャッシュは読めません。自分のターンを重ねる中で自前のキャッシュを温めていくだけです。
fork はここが逆になります。fork は親のシステムプロンプト・ツール・会話履歴をそのまま引き継ぐので、最初のリクエストが親のキャッシュを読みます。同じ「作業を分ける」でも、キャッシュの観点では別物です。/fork と /subtask の性格の違いについては分岐は手元から切り離し、委譲はセッションに残す で整理していますが、キャッシュだけを見ても選択が変わる場面があります。
では subagentPromptCacheTtl も素直に "1h" にすべきかというと、私はここを既定の5分のままにしています。1時間の書き込みは5分の書き込みより高いレートで課金されます。サブエージェントは寿命が短く、数ターンで役目を終えるものが大半です。書き込み代を回収する前に消えていく相手に、高いほうの書き込みを選ぶ理由がありません。
この判断が変わるのは、同じ定義のサブエージェントを1時間以内に何度も呼び直す運用をしているときです。そこだけは、あとで出す損益の式に当てはめてから決めています。
キャッシュが紐づいているのは、時間ではなく場所です
これが記事を書こうと思った理由そのものです。
Claude Code のキャッシュは、実質的に一台のマシンの一つのディレクトリ にスコープされています。システムプロンプトの中に、作業ディレクトリ、プラットフォーム、シェル、OS のバージョン、自動メモリのパスが埋め込まれているからです。ディレクトリが違えばプレフィックスが違い、プレフィックスが違えば別のキャッシュになります。
ここから、いくつか実運用に直接刺さる帰結が出てきます。
同じリポジトリの worktree どうしも、互いのキャッシュを読みません。worktree はそれぞれ別の作業ディレクトリだからです
同じディレクトリで並行して 動かしているセッションどうしは、同じプレフィックスを組み立てるので互いのキャッシュを読みます
同じディレクトリで続けて 開いたセッションは、起動時の git 状態のスナップショットが一致したときだけプレフィックスを共有します。システムプロンプトがブランチと直近のコミットも取り込むためです
三つめが曲者でした。私の場合、ウェブ側のリポジトリで記事を1本足してコミットしてから席を立ち、1時間以内に戻ってきます。TTL の観点では間に合っています。けれど直近のコミットが変わっているので、次に開いたセッションのプレフィックスは前と同じではありません。1時間キャッシュを買っておいて、読みにいく先が消えている状態です。個人開発では、こうした「効いているつもり」を横から指摘してくれる人がいません。自分で数字を見にいくしかありませんでした。
離席の長さを見て "1h" を決めるのは、順番として逆でした。先に見るべきは、同じディレクトリの、同じ git 状態に戻ってくるのか です。戻らないなら、TTL を延ばした分は書き込みレートの割増だけが残ります。
複数リポジトリを行き来する運用そのものをどう組むかは、キャッシュを温存したまま複数リポジトリを横断する運用設計 のほうが具体的です。この記事はその手前、「延ばすかどうか」の判断に絞ります。
読みと書きの比を、手元で見えるようにする
判断の前に、いまの自分の状態を数字で見ておきたくなります。API のレスポンスは毎回二つのトークン数を返していて、ステータスラインのスクリプトから読めます。
次のスクリプトを ~/.claude/cache-ratio.sh に置いて実行権限を付けます。読み取りが入力全体に占める割合と、書き込み1に対する読み取りの倍率を出します。
#!/bin/bash
# Claude Code ステータスライン: 直近の API 呼び出しのキャッシュ収支を表示する。
# 標準入力にはセッション JSON が渡される。
input = $( cat )
MODEL = $( echo " $input " | jq -r '.model.display_name // "?"' )
# current_usage はセッション最初の API 呼び出し前と /compact 直後に null になる。
# フォールバックを付けないと、後段の算術が空文字で落ちる。
READ = $( echo " $input " | jq -r '.context_window.current_usage.cache_read_input_tokens // 0' )
WRITE = $( echo " $input " | jq -r '.context_window.current_usage.cache_creation_input_tokens // 0' )
FRESH = $( echo " $input " | jq -r '.context_window.current_usage.input_tokens // 0' )
TOTAL = $(( READ + WRITE + FRESH ))
if [ " $TOTAL " -eq 0 ]; then
echo "[ $MODEL ] cache: --"
exit 0
fi
# このターンの入力のうち、キャッシュから来た割合(整数演算のみ)
HIT = $(( READ * 100 / TOTAL ))
# 書き込み1に対する読み取りの倍率。10倍して小数第1位を残す
if [ " $WRITE " -gt 0 ]; then
RATIO = $(( READ * 10 / WRITE ))
RATIO_STR = "$(( RATIO / 10 )).$(( RATIO % 10 ))x"
else
RATIO_STR = "inf"
fi
echo "[ $MODEL ] cache ${ HIT }% hit | read/write ${ RATIO_STR } | fresh ${ FRESH }"
設定ファイル側はこうなります。
{
"statusLine" : {
"type" : "command" ,
"command" : "~/.claude/cache-ratio.sh"
}
}
current_usage が context_window の下にぶら下がっている点に注意してください。トップレベルではありません。ここを間違えると jq は知らぬ間に null を返し、フォールバックが効いて cache: -- が出続けます。動いていないのに動いているように見える、いちばん困る壊れ方です。
配置する前にモック入力で確かめておくと安心です。手元で4通り流した結果を載せます。
$ echo '{"model":{"display_name":"Opus"},"context_window":{"current_usage":
{"input_tokens":800,"output_tokens":1200,
"cache_creation_input_tokens":5000,"cache_read_input_tokens":48000}}}' | ./cache-ratio.sh
[Opus] cache 89% hit | read/write 9.6x | fresh 800
# プレフィックスが壊れた直後のターン
$ echo '{"model":{"display_name":"Opus"},"context_window":{"current_usage":
{"input_tokens":800,"output_tokens":1200,
"cache_creation_input_tokens":52000,"cache_read_input_tokens":0}}}' | ./cache-ratio.sh
[Opus] cache 0% hit | read/write 0.0x | fresh 800
# current_usage が null(初回 API 呼び出し前 / compact 直後)
$ echo '{"model":{"display_name":"Opus"},"context_window":{"current_usage":null}}' | ./cache-ratio.sh
[Opus] cache: --
# context_window ごと欠けている場合
$ echo '{"model":{"display_name":"Sonnet"}}' | ./cache-ratio.sh
[Sonnet] cache: --
見るべきは2番目のケースです。読み取りがゼロで書き込みだけが大きいターンが続くなら、TTL の問題ではなくプレフィックスが毎回変わっています。そこで TTL を延ばしても改善しません。
計測そのものをもう少し踏み込んで組みたい場合は、cache_read を計測してヒット率の穴を塞ぐ運用メモ に別の角度から書いています。
間隔が寿命を超えた瞬間、キャッシュは割増料金に変わります
ここが、私が事前に予想していたのと逆だった部分です。
課金レートは、基準となる入力トークン価格に対して、5分の書き込みが1.25倍、1時間の書き込みが2倍、読み取りが0.1倍です。書き込みが基準より高いということは、一度も読まれなければキャッシュは純粋な割増 だという意味になります。
どのくらい割増になるのかを、正規化した計算モデルで見てみました。1セッション8ターン、開始時点のプレフィックスが4万トークン、毎ターン1,500トークンずつ会話が伸びる、という前提を置いています。実際の請求書ではなく、公開されている倍率から計算した値です。
# 入力トークン1個の基準価格を 1.0 として正規化した比較
W5, W1H , READ = 1.25 , 2.0 , 0.1
def session_cost (turns, gap_min, prefix0, delta, ttl_min, write_mult):
"""会話が毎ターン delta ずつ伸びる前提で、1セッション分の入力コストを積む"""
prefix, total = prefix0, 0.0
for i in range (turns):
warm = (i > 0 ) and (gap_min <= ttl_min) # 直前のターンから TTL 以内か
if warm:
total += READ * prefix + write_mult * delta # 既存分は読み、増分だけ書く
else :
total += write_mult * (prefix + delta) # プレフィックス全体を書き直す
prefix += delta
return total
ターン間隔だけを変えて回した結果です。
10分間隔の行を見てください。5分 TTL の 467,500 は、キャッシュなしの 374,000 より高くなっています。5分ごとにキャッシュが切れるので毎ターンが書き直しになり、その書き直しが基準の1.25倍で課金されるからです。一度も読まれないキャッシュは、25%の上乗せそのもの でした。
そして API キーでサインインしている場合、この5分がメイン会話の既定値です。10分おきに手を動かして席を離れる、という働き方をしている人が、既定のまま25%多く払っている可能性があります。私が「効きそうだ」と直感で思ったのは正しかったのですが、理由は「1時間が得だから」ではなく「5分が損だったから」でした。
90分間隔の行はもう一段ひねくれています。1時間へ延ばしたのに、キャッシュなしの2倍まで悪化しています。1時間でも間に合わない間隔では全ターンが2倍の書き直しになるので、延ばしたぶんだけ深く沈みます。TTL は「長ければ安全」ではありません。
損益分岐は次の式で出せます。書き込み倍率を W、読み取り倍率を 0.1 とすると、キャッシュが元を取るのに必要なリクエスト回数は (W − 0.1) ÷ 0.9 を超える最小の整数です。5分なら1.28、つまり2回目のリクエストで元が取れます。1時間なら2.11で、3回目まで待たされます。1時間キャッシュは、同じプレフィックスが最低2回読まれて初めて意味を持ちます。
なお、この表を根拠に「キャッシュを切る」判断へ飛ぶのは早いと考えています。公式は通常の利用では有効のままを推奨していますし、キャッシュは費用だけでなく応答の速さにも効きます。私が受け取ったのは、切るべきという結論ではなく、間隔と寿命が噛み合っていない状態は放置すると知らぬ間に高くつく という事実のほうです。API を直接叩いてブレークポイントを自分で置ける場合の設計は、TTL を二段に分ける設計 のほうが自由度が高くなります。
私が TTL を決めるときの順番
以上を、実際に使っている順番へ落とすとこうなります。上から見ていき、途中で止まったらそこで決めます。
2番目を1番目に近い位置へ置いているのが、この表で私が変えたところです。以前は3番目の「間隔」から考えていて、それだと延ばす判断ばかりが増えていきました。
長い会話を抱えたまま席を離れる場合には、もう一つ選択肢があります。Pro や Max のプランでは、大きなセッションを長い休憩のあとで再開するときに、要約から再開するかを尋ねてくれます。以降のリクエストが履歴全体を運ばなくなるので、キャッシュを延ばすより素直に効く場面があります。
「設定したのに効かない」を先に潰す
TTL の指定は複数の場所から入ります。効いていない原因の多くは、値が間違っているのではなく、上位の指定に負けていることです。優先順位は次の通りで、上から最初に一致したものが採用されます。
FORCE_PROMPT_CACHING_5M=1 — 両方のバケットを5分に強制する
そのバケットの環境変数(CLAUDE_CODE_PROMPT_CACHE_TTL / CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL)
そのバケットの設定キー(promptCacheTtl / subagentPromptCacheTtl)
ENABLE_PROMPT_CACHING_1H=1 — 両方のバケットに1時間を要求する
バケットごとの既定値
設定キーは上から3番目です。組織の管理設定や環境変数に古い指定が残っていれば、settings.json に何を書いても届きません。逆に FORCE_PROMPT_CACHING_5M=1 はすべてに優先するので、二つの TTL を比べる実験をするときや、管理設定で入った長い TTL を一時的に打ち消したいときに使えます。
そもそも1時間が使えない環境もあります。
Claude apps ゲートウェイ経由のセッションでは、1時間 TTL は利用できません
Amazon Bedrock では、キャッシュ対応の可否、キャッシュ可能な最小プレフィックス長、1時間 TTL の可否がいずれもモデルによって変わります。キャッシュのトークン数がゼロのままなら、まず対応モデルと対応リージョンを確認してください
独自の ANTHROPIC_BASE_URL や LLM ゲートウェイを挟んでいる場合、キャッシュが効くかはゲートウェイ次第です
そして冒頭に書いた性質がここで効いてきます。値は 5m と 1h しか受け付けず、それ以外は無視されます。"60m" と書いても警告は出ません。既定に戻って動き続けるだけです。設定を書き換えたあとは、値そのものではなくステータスラインの読み書き比が変わったかどうか で判定してください。設定ファイルを眺めても、効いているかは分かりません。
最初に確かめる一つのこと
TTL の設定を書き足す前に、cache-ratio.sh を配置して半日ぶん眺めてみてください。読み取りが書き込みの数倍で安定しているなら、いまの設定は噛み合っています。書き込みばかりが続くなら、直すべきは寿命ではなく、毎回変わってしまっているプレフィックスのほうです。
私自身、この順番を取り違えて設定だけ先に書いていました。数字を先に見ていれば、延ばす必要がなかったと分かったはずです。同じ遠回りをせずに済む方がいれば嬉しく思います。お読みいただきありがとうございました。