先月の請求書を開いて、少し手が止まりました。システムプロンプトに cache_control を付けたのは3週間前。ヒット率のことは気にせず「これで9割引きになるはず」と思い込んでいたのですが、入力トークンの請求はほとんど変わっていません。
キャッシュは「有効にした/していない」の二値ではないのだと、このとき初めて腹に落ちました。付けてある。けれど読まれていない。その差は請求書の数字にしか現れず、コードを眺めても見えてこない。
この記事は、個人開発で回している要約バッチのキャッシュヒット率を 3割台から9割近くまで引き上げるまでの、計測と手直しの記録です。派手な最適化テクニックの話ではありません。まず計測し、穴を名指しし、ブレークポイントを1つずつ動かす。その地味な往復のメモです。
「有効にした」と「効いている」は別の状態です
プロンプトキャッシュは、リクエストの system・tools・messages の先頭(プレフィックス)をサーバー側に一時保存する仕組みです。同じプレフィックスが後続のリクエストで再利用されると、その部分は通常の入力料金の約1割で処理されます。
問題は、cache_control を書いた時点では何も保証されないという点です。実際にキャッシュが読まれたかどうかは、レスポンスの usage を見るまで分かりません。ここを見ていなかったことが、私の3週間の見落としの正体でした。
usage フィールド 意味 課金の目安(通常入力比)
cache_creation_input_tokens キャッシュへ書き込んだトークン(=この回はミス) 約 1.25 倍(5分キャッシュ)
cache_read_input_tokens キャッシュから読んだトークン(=この回はヒット) 約 0.1 倍
input_tokens キャッシュ対象外の通常入力 1 倍
読み方はシンプルです。cache_read_input_tokens が毎回それなりの値で返っていれば効いています。逆に、cache_creation_input_tokens ばかりが立ち続けているなら、キャッシュは「作られては捨てられ」を繰り返しています。付けたのに効かない、はほぼこの状態です。
まず計測ハーネスを1枚だけ挟みます
原因を推測で潰し始める前に、全リクエストの usage を記録する薄い層を入れました。最適化より前に、まず現在地を数字で知りたかったからです。
import time
import logging
from dataclasses import dataclass, field
logger = logging.getLogger( "cache" )
@dataclass
class CacheProbe :
"""cache_read / cache_creation を毎リクエスト記録する最小ハーネス"""
requests: int = 0
hits: int = 0 # cache_read > 0 の回数
rewrites: int = 0 # cache_creation > 0 の回数
read_tokens: int = 0
write_tokens: int = 0
# 直近のミスを原因分類するための痕跡
last_prefix_fingerprint: str | None = field( default = None )
def record (self, usage) -> None :
self .requests += 1
read = getattr (usage, "cache_read_input_tokens" , 0 ) or 0
write = getattr (usage, "cache_creation_input_tokens" , 0 ) or 0
self .read_tokens += read
self .write_tokens += write
if read > 0 :
self .hits += 1
if write > 0 :
self .rewrites += 1
@ property
def hit_rate (self) -> float :
# 「書き込みが発生しなかった」= ヒットとみなす
return ( self .hits / self .requests * 100 ) if self .requests else 0.0
@ property
def rewrite_rate (self) -> float :
return ( self .rewrites / self .requests * 100 ) if self .requests else 0.0
def snapshot (self) -> str :
return (
f "req= { self .requests } hit= { self .hit_rate :.1f } % "
f "rewrite= { self .rewrite_rate :.1f } % "
f "read_tok= { self .read_tokens :, } write_tok= { self .write_tokens :, } "
)
record() を呼ぶだけの層です。ここで見たかったのは1点だけ。書き込み率(rewrite_rate)が高止まりしていないか です。要約バッチで実測したところ、ヒット率は 34.8%、書き込み率は 61.2% でした。半分以上のリクエストがキャッシュを作り直している。これで請求が下がらない理由が、ようやく数字になりました。
なぜ「書き込み率」を主役にするかというと、ヒット率だけを見ていると原因が見えないからです。ヒットしていない = ミス、までは分かっても、ミスが「プレフィックスがずれた」のか「時間切れで消えた」のかを区別できません。書き込みが何回、どの間隔で起きているかを見ると、この切り分けが一気に進みます。
ヒットしない理由は、たいてい3系統に分かれます
計測を数日回して分かったのは、ミスには性格の違う3種類があるということでした。混ぜて眺めると打つ手が定まりませんが、分けると対処が具体になります。
系統 計測上のサイン 根っこ
① 非決定プレフィックス 毎回 cache_creation が立つ/間隔と無関係 先頭に毎回変わる文字列が混入
② TTL 失効 呼び出し間隔が空いた回だけ cache_creation 5分の生存期間を跨いだ
③ しきい値未満 read も creation も常に 0 ブレークポイントまでが最小トークン未満
① 非決定プレフィックス — 先頭に時刻を混ぜていました
私の犯人はこれでした。システムプロンプトの冒頭に「現在時刻」を差し込んでいて、そのせいでプレフィックスが毎リクエストで別物になっていたのです。1文字でも変わればキャッシュは別物として扱われます。
# 私がやっていた失敗 — 先頭が毎回変わる
system = [
{
"type" : "text" ,
"text" : f "現在時刻は { datetime.now().isoformat() } です。あなたは要約アシスタントで…" ,
"cache_control" : { "type" : "ephemeral" },
}
]
# 直した後 — 可変部分をキャッシュ境界の外へ出す
system = [
{
"type" : "text" ,
"text" : "あなたは要約アシスタントです。…(安定した指示とナレッジ)…" ,
"cache_control" : { "type" : "ephemeral" }, # ここまでは毎回同一
},
{
"type" : "text" ,
"text" : f "現在時刻は { datetime.now().isoformat() } です。" , # 境界の外=キャッシュ対象外
},
]
見落としやすいのは、時刻のような明らかな可変値だけではありません。辞書やセットを json.dumps でそのまま埋め込むと、キーの順序が実行ごとに揺れてプレフィックスが変わることがあります。埋め込むときは sort_keys=True を付けて、順序を固定してから境界の内側に置く。これで「同じ内容なのにミスする」の相当数が消えました。
② TTL 失効 — キャッシュは5分で静かに消えます
ephemeral キャッシュの標準的な生存期間は約5分です。最後にアクセスがあってから5分経つと、そのプレフィックスは破棄され、次のリクエストは書き込み(=ミス)からやり直します。呼び出しがまばらなバッチだと、ここが効いてきます。
切り分けは計測で付きます。cache_creation が立った回の直前リクエストからの経過秒数 を一緒に記録しておくと、失効ミスは「間隔が長い回」に固まって現れます。
def classify_miss (gap_seconds: float , read: int , write: int ) -> str :
"""ミスを系統に振り分ける — 対処を選ぶための粗い分類"""
if read == 0 and write == 0 :
return "under_threshold" # ③ しきい値未満
if write > 0 and gap_seconds > 300 :
return "ttl_expired" # ② TTL 失効の疑い
if write > 0 :
return "prefix_changed" # ① 非決定プレフィックスの疑い
return "hit"
失効が主因だと分かったら、選択肢は2つでした。呼び出し間隔を詰めてキャッシュを温め続けるか、1時間の延長キャッシュ(書き込みは約2倍だが生存が長い)へ切り替えるか。私は要約バッチを5分刻みで束ねて投げる形に寄せました。まばらに投げていたのを、束ねて連続させただけです。書き込み率は 61.2% から 18.4% へ落ちました。
③ しきい値未満 — 短いプロンプトは、そもそもキャッシュされません
ブレークポイントまでのトークン数が最小要件に届かないと、キャッシュは一切作られません。read も creation もずっと 0 のままなら、まずこれを疑います。
モデル系統 キャッシュ成立の最小トークン
上位・標準クラス(Opus / Sonnet 系) 約 1,024 トークン
軽量クラス(Haiku 系) 約 2,048 トークン
短い指示文にだけ cache_control を付けていた箇所が、まさにこれでした。境界の内側にナレッジベースを寄せて、プレフィックスが最小トークンを確実に超えるよう組み直す。ここは削るのではなく、安定した内容を境界の内側へ集める作業になります。
cache_creation が消えないときは、境界を1つずつ後ろへ動かします
3系統を潰しても書き込みがちらつく場合、プレフィックスのどこかにまだ揺れが残っています。私は二分探索のように境界を動かして、揺れの発生源を追い込みました。
手順はこうです。ブレークポイントをいったん最も安定した先頭ブロックだけに絞り、書き込み率を計測する。落ち着いたら、次のブロックを境界の内側に取り込んで再計測する。書き込み率が跳ね上がったブロックが、揺れの発生源です。
# 境界を段階的に広げて、揺れを持ち込むブロックを特定する
def build_system (blocks: list[ str ], cache_upto: int ) -> list[ dict ]:
"""先頭から cache_upto 個のブロックまでを1つの安定プレフィックスとして束ねる"""
out = []
for i, text in enumerate (blocks):
part = { "type" : "text" , "text" : text}
if i == cache_upto - 1 : # 束ねた最後のブロックに境界を1つ置く
part[ "cache_control" ] = { "type" : "ephemeral" }
out.append(part)
return out
# cache_upto を 1 → 2 → 3 と増やしながら rewrite_rate を観察し、
# 急に悪化した手前で止めるのが、安定プレフィックスの端になる
ここで大事なのは、ブレークポイントを増やせば増やすほど良い、ではないという点です。境界は最大4つですが、私の実測では、安定した先頭を1〜2個に束ねる方が、細かく刻むより書き込み率が低く出ました。刻むほど「その境界までが完全一致しているか」の条件が増え、どこか1つがずれると後続がまとめて崩れるからです。並べる順序は、変更頻度の低いものから先頭へ。system(安定した指示)→ tools(ツール定義)→ messages(古い履歴)→ 最新メッセージ、の順に落ち着きました。
ヒット率をダッシュボードに残しておきます
一度直しても、キャッシュはまた静かにずれます。だから計測を単発で終わらせず、書き込み率とヒット率を監視ツールへ流し続けることにしました。数値の系列があると、劣化を請求書より先に捉えられます。
def export_prometheus (probe) -> str :
"""CacheProbe の状態を Prometheus 形式で吐き出す"""
return (
"# TYPE claude_cache_hit_rate gauge \n "
f "claude_cache_hit_rate { probe.hit_rate :.2f }\n "
"# TYPE claude_cache_rewrite_rate gauge \n "
f "claude_cache_rewrite_rate { probe.rewrite_rate :.2f }\n "
"# TYPE claude_cache_read_tokens counter \n "
f "claude_cache_read_tokens { probe.read_tokens }\n "
)
監視で1つだけアラートを置くなら、私は書き込み率にしきい値を張ります。ヒット率が下がる前に、書き込み率の方が先にざわつくからです。要約バッチでは 15% を超えたら通知する設定にして、プロンプトへ手を入れた翌日にちょうど拾えました。
どこまで下がったか、そして線引き
要約バッチでの最終的な数字は、ヒット率 34.8% → 88.6%、書き込み率 61.2% → 6.1%、入力トークンの実効単価は約 41% まで下がりました。「最大9割引き」という見出しの数字には届きません。届かないのが普通だと思います。全リクエストが完全にキャッシュヒットする運用は、現実にはあまりないからです。
線引きとして、私はこう考えるようになりました。キャッシュは「入れる/入れない」の設定ではなく、ヒット率という継続監視する運用指標 です。モデルの既定が入れ替わるとき、プロンプトを1行足すとき、呼び出し間隔を変えるとき——そのたびにプレフィックスは静かにずれ得ます。だから最適化の主役は賢い配置ではなく、cache_read を毎回見ておくという退屈な習慣の方でした。
書き込み率がじわじわ上がってきたら、どこかでプレフィックスが揺れ始めた合図です。そのときにこのハーネスがあれば、請求書を待たずに気づけます。私自身、次に既定モデルを入れ替えるときは、まずこの数字を数日ぶん眺めてから本番に寄せるつもりです。
計測して、名指しして、境界を1つ動かす。この往復が、キャッシュを「付けたつもり」から「効いている」へ運んでくれました。どこかで同じ請求書を眺めている方の手が、少し早く止まれば嬉しいです。お読みいただきありがとうございました。