「cache_control を設定したのに、月末の請求書がぜんぜん安くなっていない」— Claude API のプロンプトキャッシュを導入した方から、この相談を一番よく受けます。私自身も最初にキャッシュを入れた時、3日間まったくヒットしていないことに気づかず、「やっぱり効果ないのかな」と諦めかけました。
実は、プロンプトキャッシュが効かない原因は意外と限られています。そして、診断の出発点はリクエストごとに必ず返ってくる usage フィールドにあります。ここでは私が個人運営しているアプリで何度もぶつかった「キャッシュミス」の典型パターンを、診断順に整理します。
まず usage フィールドを読む
Claude API のレスポンスには、必ず usage というオブジェクトが含まれています。プロンプトキャッシュを使っている時は、ここに2つの追加フィールドが現れます。
import anthropic
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
system=[
{
"type": "text",
"text": "あなたは経験豊富な技術ライターです。" * 200, # 約2,000トークン
"cache_control": {"type": "ephemeral"},
}
],
messages=[{"role": "user", "content": "こんにちは"}],
)
print(response.usage)
# Usage(
# input_tokens=10,
# cache_creation_input_tokens=2103,
# cache_read_input_tokens=0,
# output_tokens=42
# )注目するのは次の3つの値です。
input_tokens: キャッシュの対象外で、毎回読み込まれる入力トークン数cache_creation_input_tokens: 今回のリクエストでキャッシュに「書き込まれた」トークン数(5分TTLなら標準単価の1.25倍、1時間TTLなら2倍が課金されます)cache_read_input_tokens: キャッシュから「読み込まれた」トークン数(標準単価の0.1倍)
ヒットしている状態とは、2回目以降のリクエストで cache_read_input_tokens がゼロより大きく、cache_creation_input_tokens がゼロに近づいている状態を指します。これが何度リクエストしても変わらないなら、何かが間違っています。
原因①: 最小トークン数を満たしていない
Claude のプロンプトキャッシュには、キャッシュ対象として認められる「最小トークン数」が設定されています。2026年4月時点での目安は次のとおりです。
- Claude Sonnet 4.6 / Claude Opus 4.6: 1,024 トークン
- Claude Haiku 4.5 系: 2,048 トークン
この閾値を下回るプレフィックスに cache_control を付けても、サーバー側でキャッシュは作られず、cache_creation_input_tokens は常にゼロのまま、input_tokens だけが膨らみ続けます。
短いシステムプロンプト(数百トークン程度)に cache_control を付けて「効かない!」と悩むケースは本当に多いです。私が最初にハマったのもこれでした。診断としては、システムプロンプト全体のトークン数を tokenizer で先に数えてみてください。1,024未満なら、キャッシュよりも先に「プロンプト自体を圧縮しない」方向で設計を見直す方が現実的です。
原因②: cache_control の位置が間違っている
プロンプトキャッシュは「cache_control が付いたブロックの末尾までを1つのキャッシュキーとして登録する」仕組みです。つまり、キャッシュしたい静的部分の 最後 に cache_control を置く必要があります。
# ❌ 間違い: 動的部分の後に cache_control を置いている
system=[
{"type": "text", "text": SYSTEM_INSTRUCTION},
{"type": "text", "text": f"今日は{today}です"}, # 動的
{"type": "text", "text": "出力フォーマットは以下のとおり: ...",
"cache_control": {"type": "ephemeral"}},
]上のコードでは、今日は{today}です が日付ごとに変わるため、キャッシュキー全体が毎日変わってしまい、ヒットしません。動的な値はキャッシュ対象の 後ろ に置くのが鉄則です。
# ✅ 正しい: 静的部分の末尾に cache_control、動的部分はその後
system=[
{"type": "text", "text": SYSTEM_INSTRUCTION},
{"type": "text", "text": "出力フォーマットは以下のとおり: ...",
"cache_control": {"type": "ephemeral"}},
{"type": "text", "text": f"今日は{today}です"}, # キャッシュ後の動的ブロック
]ツール定義についても同様です。tools 配列の最後の要素に cache_control を付けると、その時点までのツール定義群がまとめてキャッシュされます。
原因③: TTL(有効期限)切れ
デフォルトの TTL(Time To Live)は 5分 です。最後にヒットしてから5分以上アクセスが空くと、キャッシュは破棄されます。低頻度のバックグラウンドジョブや、ユーザーの操作間隔が長いチャットボットでは、毎回キャッシュ書き込みが発生してしまい、コスト削減効果がほぼ消えます。
このパターンは、平日昼間だけ使われる業務ツールでよく観測しました。
対策として、2026年に正式提供された 1時間 TTL が有効です。書き込み時のコストは2倍に上がりますが、アクセス頻度の低いワークロードでは全体としては安くなります。
system=[
{
"type": "text",
"text": LARGE_SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral", "ttl": "1h"},
}
]導入前後で、cache_read_input_tokens の比率が一日を通してどう変化したかをログに残し、効果を必ず検証してください。
原因④: 動的な値がキャッシュ対象に紛れ込んでいる
これは私が一番頻繁にやらかすミスです。「全リクエストで同じ」と思い込んでいた静的部分に、実は微妙に変動する値が混ざっている、というパターンです。
よくある例:
- システムプロンプトの先頭に
現在時刻: 2026-04-28T09:00:34Zのようなタイムスタンプを入れている - ユーザーIDをシステムプロンプトに埋め込んでいる
- セッションごとのランダムなトラッキング用UUIDが含まれている
- 改行コード(
\nと\r\n)が環境によって混在している
ハッシュ値で検証するのが確実です。
import hashlib
def cache_key_hash(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
# リクエスト前に、キャッシュ対象部分のハッシュをログ出力
prefix = "".join(b["text"] for b in system_blocks_until_cache_control)
print(f"cache prefix hash: {cache_key_hash(prefix)}")このハッシュが毎回変わっていたら、何かが動的になっています。差分を取って原因を特定してください。私はこれで「サーバーのリクエスト時刻をうっかり混ぜていた」ことに気づき、半日以上の遠回りを避けられました。
原因⑤: ツール定義のリクエスト順が一定でない
ツール(tools 配列)をキャッシュしているのに効かない時は、配列の順序が原因のことがあります。Claude のキャッシュキーは「内容のバイト列」として比較されるため、ツールの並び順が変わると別のキャッシュとして扱われます。
Python の dict から動的にツール一覧を生成している場合、辞書の挿入順が条件によって入れ替わると、毎回別のキャッシュが作られます。生成ロジックを次のように固定するのが安全です。
TOOL_ORDER = ["search_docs", "execute_sql", "send_email"]
def build_tools(enabled: set[str]) -> list[dict]:
# 必ず固定順で並べる
return [TOOL_DEFINITIONS[name] for name in TOOL_ORDER if name in enabled]「ユーザーごとに使えるツール集合が違う」設計の場合は、そもそもキャッシュヒット率が出にくくなります。共通ツール群と差分ツールを別ブロックに分け、共通部分だけキャッシュ対象にする設計が現実的です。
診断スクリプト:3リクエスト連続で usage を観察する
原因を切り分ける時に私が使っている、シンプルな診断スクリプトを置いておきます。同じプロンプトを3回連続で投げ、usage の推移を見るだけのものです。
import anthropic
from anthropic.types import TextBlockParam
client = anthropic.Anthropic()
SYSTEM_PROMPT = open("system_prompt.txt").read() # 1,024 トークン以上を想定
def diagnose():
for i in range(3):
resp = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=64,
system=[
TextBlockParam(
type="text",
text=SYSTEM_PROMPT,
cache_control={"type": "ephemeral"},
)
],
messages=[{"role": "user", "content": f"テスト {i}"}],
)
u = resp.usage
print(
f"req {i}: input={u.input_tokens} "
f"create={u.cache_creation_input_tokens} "
f"read={u.cache_read_input_tokens}"
)
diagnose()期待される出力:
req 0: input=10 create=2103 read=0
req 1: input=10 create=0 read=2103
req 2: input=10 create=0 read=2103
req 1 以降で read がプラスに転じれば、キャッシュは正しく機能しています。req 1 でも read=0 のままなら、上記の原因①〜⑤を順に潰してください。
より深いコスト最適化の設計パターンについては、Claude API のプロンプトキャッシュで月額コストを半分にした実装メモ と Claude API のプロンプトキャッシュと Token-Efficient Tool Use — コスト最大90%削減の本番最適化ガイド を併せてご覧ください。レート制限と組み合わせた本番設計は API レート制限のベストプラクティス も参考になります。
書籍で体系的に
キャッシュが効いている時にダッシュボードで見える兆候
正しく動き始めると、運用ダッシュボードには3つの変化が現れます。
ひとつ目は、キャッシュ対象プレフィックスの 1リクエストあたり入力トークンコスト がおよそ10分の1に落ちることです。TTLが切れるタイミングで一時的に書き込みコストが乗りますが、グラフ全体の形は「ずっと一定の高さ」から「平らで、たまに小さな山が立つ」形に変わります。
ふたつ目は、cache_read_input_tokens と cache_creation_input_tokens の比率です。10:1 以上なら定常状態として健全です。3:1 を下回るようなら、書き込みコストが節約分を食い潰している状態で、原因③(TTL)か原因④(プレフィックスに動的値が混入)を疑います。
みっつ目は、レイテンシの改善です。キャッシュヒットしたプレフィックスでは、Time to First Token が体感で30〜50%短くなります。これはあまり語られない副次効果ですが、ユーザーが応答を待つ画面を持つプロダクトでは、コスト指標と同じくらい重要なUX指標になります。
この3つを既存のエラー率・レート制限ヘッドルームと同じダッシュボードに並べておくと、デグレが起きた週のうちに気づけるようになります。請求書が来てから慌てずに済むのは、本当に大きな違いです。
次の一歩
まずは手元で動かしているプロダクトに上の診断スクリプトを2分で組み込み、cache_read_input_tokens が増えるかどうかを目視で確認してみてください。増えていなければ、原因①「最小トークン数」から順に潰すのが最短ルートです。
キャッシュは「設定すれば効く」機能ではなく、「効いていることを usage で観測しながら設計を直す」機能です。診断ループを一度回せるようになると、本番の請求書は確実に小さくなります。