プロンプトキャッシュを入れた初日のダッシュボードは、たいてい気持ちのいい数字になります。cache_read_input_tokens がきれいに伸び、入力コストがすっと下がる。問題はその次の週です。トラフィックの形が少し変わった、システムプロンプトに動的な値を一行混ぜた、デプロイでツール定義の順序が入れ替わった——どれも単独では些細なのに、キャッシュヒット率は静かに落ちていきます。しかも cache_read_input_tokens を毎回見ている人は少ないので、コストが元に戻っていることに月末の請求で気づく、ということが起こります。
私は Dolice Labs として4つの技術ブログを個人開発で回していて、その自動運用でこの機能をかなり酷使してきました。「効いていると思い込んでいたが、実は半分しかヒットしていなかった」という経験を何度かしています。本稿は機能の紹介ではなく、本番でヒット率を保つために何を監視し、どこで TTL を切り替え、節約額をどう実測するか、という運用の話です。
キャッシュが効く条件は「完全一致のプレフィックス」だけ
最初に押さえておきたいのは、プロンプトキャッシュが照合しているのは「意味の近さ」ではなく「バイト列としての完全一致するプレフィックス」だという点です。system・tools・messages を先頭から順に見ていき、キャッシュブレークポイントまでが一字一句同じなら、その区間がキャッシュ読み取り料金(通常入力の約10%)で処理されます。逆に言えば、先頭から数えて一文字でも違えば、そのブレークポイント以降はすべてキャッシュミスになります。
この「先頭一致」という性質が、本番でヒット率が落ちる原因のほとんどを説明します。プレフィックスのどこかに、リクエストごとに変わる値が紛れ込んでいるのです。
# ❌ ヒット率が出ない典型例:システムプロンプトに動的な値が混入
system = [
{
"type" : "text" ,
# 現在時刻を毎回埋め込むと、プレフィックスが毎回変わりキャッシュは絶対にヒットしない
"text" : f "あなたはサポートAIです。現在時刻は { datetime.now() } です。 \n\n ## ナレッジベース \n ..." ,
"cache_control" : { "type" : "ephemeral" },
}
]
# ✅ 動的な値は messages 側(ブレークポイントより後ろ)へ追い出す
system = [
{
"type" : "text" ,
"text" : "あなたはサポートAIです。 \n\n ## ナレッジベース \n ..." , # 安定部分だけ
"cache_control" : { "type" : "ephemeral" },
}
]
messages = [
{ "role" : "user" , "content" : f "(現在時刻: { datetime.now() } )直近の障害状況を教えてください" },
]
タイムスタンプは分かりやすい例ですが、実際にはもっと気づきにくい形で混入します。ユーザー名やセッションIDをシステムプロンプトに差し込んでいる、A/Bテストでプロンプトを出し分けていて分岐がプレフィックスの先頭にある、ナレッジベースを動的に組み立てていて配列の順序が安定していない——こうしたものはコードを読んだだけでは「変わっている」と気づきにくく、ログを取って初めて見えてきます。
プレフィックスを静かに壊す5つの要因
これまで自分がはまった、あるいは原因調査で見つけた「プレフィックスを壊す要因」を整理しておきます。
要因 何が起きるか 気づきにくさ
動的な値の混入(時刻・ID・乱数) 毎回ミス。キャッシュが一切効かない 高
ツール定義の順序ゆらぎ 辞書をループで組むと順序が変わりミス 高
ナレッジベースの非決定的な連結 set やdictの反復順序で末尾が変わる 高
TTL切れ(無アクセス放置) 5分超の間隔で毎回書き込みからやり直し 中
モデル文字列やbetaヘッダーの差し替え キャッシュ名前空間が変わり再書き込み 中
順序ゆらぎは特に厄介です。Python の辞書は挿入順を保つので問題は出にくいのですが、set から list を作る、複数のソースを dict でマージしてから JSON 化する、といった処理を挟むと、実行ごとに微妙に並びが変わることがあります。プレフィックスは「内容が同じ」では足りず「並びまで同じ」でなければならないので、キャッシュ対象を組み立てる箇所では明示的にソートしておくのが安全です。これは見落としやすい落とし穴で、明示的な並び替えを一行入れておくだけで回避できます。
# ツール定義・ナレッジ片など、キャッシュ対象を組み立てる箇所は順序を固定する
def build_cacheable_tools (tool_specs: dict[ str , dict ]) -> list[ dict ]:
# キー順でソートして、実行のたびに同じ並びになることを保証する
return [tool_specs[name] for name in sorted (tool_specs)]
5分TTLと1時間TTLをどう選ぶか
キャッシュには有効期限があります。標準の ephemeral キャッシュは最後にアクセスしてから約5分で失効し、その間にアクセスがあるたびに期限がリセットされます。つまり5分以内に次のリクエストが来続けるかぎりキャッシュは生き続けますが、間隔が空くと失効し、次のリクエストは再び書き込み(通常入力の約1.25倍)からやり直しになります。
2026年6月に Developer Platform へ最大1時間のプロンプトキャッシュが追加され、TTLを長く取れるようになりました。これは「リクエストが散発的にしか来ないが、同じ大きなプレフィックスを使い回す」ワークロードに効きます。ただし1時間キャッシュは保持コストが上がるため、なんでも1時間にすればよいわけではありません。判断はトラフィックの間隔で決めるのが実用的です。
リクエスト間隔 推奨TTL 理由
数秒〜数分(チャット・連続処理) 5分 アクセスのたびに期限がリセットされ、書き込みは初回だけで済む
5分〜1時間(散発的なAPI・低頻度ジョブ) 1時間 5分では失効してしまう。書き込みのやり直しを減らせる
1時間超(日次バッチなど) キャッシュなし or バッチ集約 どのTTLでも失効する。時間帯にまとめて投げ直す
私の運用だと、日中ずっと動いているチャット系のエンドポイントは5分で十分ヒットし続けます。一方、1〜2時間おきにしか走らない記事整合性チェックのような低頻度ジョブは、5分キャッシュだと毎回書き込みからになっていて意味がありませんでした。後者を1時間キャッシュに切り替えたところ、同じプレフィックスの再書き込みが目に見えて減りました。「常時動いているか、ぽつぽつ動くか」でTTLを分けるのが、いちばん素直な指針だと感じています。
「効いているはず」を実測に変える
ここがいちばん伝えたい部分です。プロンプトキャッシュの怖さは、壊れても例外が飛ばないことにあります。ミスしてもリクエストは普通に成功し、ただ静かに通常料金で処理されるだけ。だから「効いているはず」という思い込みを、usage から取れる実数に置き換える計装を最初から入れておく必要があります。
レスポンスの usage には3つの数字が並びます。cache_creation_input_tokens(書き込み)、cache_read_input_tokens(読み取り=ヒット)、input_tokens(キャッシュ対象外の通常入力)です。この3つを毎リクエスト記録するだけで、ヒット率も節約額も後から正確に出せます。
import anthropic
# Sonnet 4.6 の概算単価(USD / 1M tokens)。本番では最新の料金で更新する
PRICE_INPUT = 3.00 # 通常入力
PRICE_CACHE_WRITE = 3.75 # キャッシュ書き込み(1.25倍)
PRICE_CACHE_READ = 0.30 # キャッシュ読み取り(0.1倍)
def record_usage (usage) -> dict :
"""1リクエスト分の usage を、実コストと「キャッシュなしだったらいくらか」に換算する"""
read = getattr (usage, "cache_read_input_tokens" , 0 ) or 0
write = getattr (usage, "cache_creation_input_tokens" , 0 ) or 0
normal = usage.input_tokens
# 実際に払ったコスト
actual = (
normal * PRICE_INPUT
+ write * PRICE_CACHE_WRITE
+ read * PRICE_CACHE_READ
) / 1_000_000
# キャッシュを一切使わなかった場合の仮想コスト(read/write も通常単価で計算)
baseline = (normal + write + read) * PRICE_INPUT / 1_000_000
cached_tokens = read + write
hit_rate = read / cached_tokens if cached_tokens else 0.0
return {
"actual_usd" : round (actual, 6 ),
"baseline_usd" : round (baseline, 6 ),
"saved_usd" : round (baseline - actual, 6 ),
"cache_hit_rate" : round (hit_rate, 3 ),
}
この record_usage を全リクエストに通し、saved_usd と cache_hit_rate を時系列でログに流しておけば、「先週の火曜から急にヒット率が0.9から0.4へ落ちた」といった変化が目に見えます。私はこれを入れて初めて、あるデプロイでツール定義の組み立て順が変わってミスが増えていたことに気づきました。例外も警告も出ないので、計装がなければ請求が来るまで分からなかったはずです。
しきい値を決めてアラートにつなぐところまでやると、運用がぐっと楽になります。
def check_cache_health (metrics: list[ dict ], min_hit_rate: float = 0.6 ) -> str | None :
"""直近のメトリクスからヒット率の低下を検知する。問題があれば理由文字列を返す"""
recent = metrics[ - 50 :]
cached = [m for m in recent if m[ "cache_hit_rate" ] > 0 or m[ "actual_usd" ] > 0 ]
if not cached:
return None
avg_hit = sum (m[ "cache_hit_rate" ] for m in cached) / len (cached)
if avg_hit < min_hit_rate:
return f "キャッシュヒット率が低下: 直近平均 { avg_hit :.1% } (しきい値 { min_hit_rate :.0% } )"
return None
ヒット率の「正常値」はワークロードによって違うので、最初の1〜2週間で自分のシステムのベースラインを掴んでから、そこから明確に落ちたら鳴る、くらいの緩いしきい値にしておくのが現実的です。最初から厳しくすると、トラフィックの自然な揺れで鳴り続けて誰も見なくなります。
ブレークポイントは「変更頻度の階段」で置く
ヒット率を底上げするうえで効くのが、ブレークポイントを変更頻度の順に並べることです。キャッシュは最大4つのブレークポイントを置けますが、大事なのは数より順序で、「めったに変わらないもの」を先頭に、「よく変わるもの」を後ろに、という階段状の配置にします。
system = [
{
"type" : "text" ,
"text" : BASE_INSTRUCTIONS , # ほぼ不変。半年に一度しか触らない
"cache_control" : { "type" : "ephemeral" }, # BP1
},
{
"type" : "text" ,
"text" : weekly_knowledge_base, # 週次更新。前半より変わる
"cache_control" : { "type" : "ephemeral" }, # BP2
},
]
# tools は週単位で安定しているので、その末尾に3つ目のBPを置く
tools[ - 1 ][ "cache_control" ] = { "type" : "ephemeral" } # BP3
こうしておくと、週次のナレッジベースを更新して BP2 以降が無効になっても、BP1(基本指示)のヒットは維持されます。逆に全部を一つのブロックにまとめてしまうと、末尾を少し直しただけで全体が再書き込みになり、せっかくの安定部分まで巻き添えで失効します。「どの粒度で変わるか」でブロックを分けておくのが、長く効かせるコツです。
なお、Token-Efficient Tool Use のようなツール周りの最適化フラグを併用する場合、ヘッダーやベータ指定を変えるとキャッシュの名前空間が変わって再書き込みになることがあります。最適化フラグを切り替える検証は、本番のキャッシュが温まっている時間帯を避けて行うのが無難です。
運用に落とすときの最小チェック
最後に、新しいエンドポイントでプロンプトキャッシュを使い始めるときに自分が毎回確認している項目を挙げておきます。キャッシュ対象(system・tools・先頭側の messages)に動的な値が入っていないか、キャッシュ対象を組み立てる箇所で順序が固定されているか、リクエスト間隔に対してTTLの選択が噛み合っているか、そして usage の3数値をログに流して cache_hit_rate と saved_usd を可視化できているか。この4点が揃っていれば、ヒット率が落ちても請求ではなくダッシュボードで先に気づけます。
プロンプトキャッシュは、入れた瞬間の削減率より「数週間後もその削減率を保てているか」のほうがずっと難しい機能です。効かせ続けるための監視を一緒に組み込んでおくこと——同じところでコストを取りこぼした経験から、これがいちばん効くと考えています。お読みいただきありがとうございました。