はじめに / 対象と前提
Claude API を使ったアプリで、毎回同じシステムプロンプトや長い参照ドキュメントを送っていると、入力トークン代がじわじわ効いてくる。プロンプトキャッシュ(prompt caching)を有効にすると、繰り返し送る部分の読み取りコストが通常入力の約1/10になる。
自分のケースでは、約8,000トークンのシステムプロンプト+ツール定義を毎リクエスト送っていた処理に導入して、入力コストが実測でほぼ9割落ちた。ただし「付けたのに効いていない」パターンを3つ踏んだので、実装手順と合わせてまとめる。
- 想定読者:Anthropic の Claude API(Messages API)を Python から叩いている人
- 環境:Python 3.13 / anthropic SDK 0.7 系 / claude-sonnet-5
- 料金体系は 2026年9月時点の情報
TL;DR
- コンテンツブロックに
"cache_control": {"type": "ephemeral"}を付けるだけで、その位置までのプレフィックスがキャッシュされる - キャッシュ読み取りは通常入力の約0.1倍、書き込みは1.25倍(5分TTL)。2リクエスト目から元が取れる
- ハマりどころは3つ:最小トークン数未満は黙って無視される / プレフィックスが1バイトでも変わると後ろ全滅 / tools の変更は先頭からの無効化
- 効いているかは
usage.cache_read_input_tokensで必ず実測確認する
手順 / 動かし方
システムプロンプトの最後のブロックに cache_control を付ける。これだけ。
import anthropic
client = anthropic.Anthropic()
SYSTEM = [
{
"type": "text",
"text": LONG_SYSTEM_PROMPT, # 長い共通プロンプト(例: 数千トークン)
"cache_control": {"type": "ephemeral"},
}
]
def ask(question: str):
res = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
system=SYSTEM,
messages=[{"role": "user", "content": question}],
)
print("write:", res.usage.cache_creation_input_tokens,
"read:", res.usage.cache_read_input_tokens)
return res
ask("この仕様の要点を教えて") # 1回目
ask("制約条件だけ抜き出して") # 2回目
実行結果(2回目でキャッシュが効いている):
write: 8123 read: 0
write: 0 read: 8123
1回目は cache_creation_input_tokens にキャッシュ書き込み分が計上され、2回目以降は同じプレフィックスが cache_read_input_tokens として約0.1倍の料金で読まれる。この2つのフィールドを見ずに「付けたからOK」と思い込むのが一番危ない。
ハマりどころ
1. 最小トークン数未満は黙って無視される
cache_control を付けてもプレフィックスが短いとキャッシュは作られない。エラーは出ない。cache_creation_input_tokens: 0 のまま、通常料金で処理され続ける。
最小キャッシュ長はモデルごとに違う(しかも世代順に単調ではない):
| モデル | 最小トークン数 |
|---|---|
| Claude Opus 5 | 512 |
| Claude Sonnet 5 / Opus 4.8 | 1,024 |
| Opus 4.7 | 2,048 |
| Opus 4.6 / Haiku 4.5 | 4,096 |
自分は最初 3,000 トークン弱のプロンプトを Haiku 4.5 でキャッシュしようとして、いくら待っても read が 0 のままで悩んだ。Sonnet 5 なら通るサイズでも Haiku 4.5(最小4,096)では黙って無視される。モデルを下位に切り替えた瞬間にキャッシュだけ静かに死ぬので、モデル変更時は usage の再確認が必須。
2. プレフィックスが1バイトでも変わると後ろが全滅する
キャッシュは完全なプレフィックス一致で判定される。cache_control の位置までのバイト列が1文字でも違えば、そこから後ろは全部キャッシュミス。
ありがちな「静かな破壊者」はこのあたり:
# NG: 毎リクエスト、プレフィックスの先頭が変わる
system_text = f"現在時刻: {datetime.now()}\n{LONG_SYSTEM_PROMPT}"
# NG: dict の順序が不定 → シリアライズ結果のバイト列が毎回違う
context = json.dumps(user_context) # sort_keys なし
# OK: 動的な情報はキャッシュ境界より後ろ(messages の末尾側)に置く
messages=[{"role": "user", "content": f"現在時刻: {now}\n{question}"}]
原則は「不変なものを前、変わるものを後ろ」。タイムスタンプ・ユーザーID・リクエストIDをシステムプロンプトに埋め込んでいたら、それがキャッシュを毎回破壊している。json.dumps には sort_keys=True を付けて決定的にする。
3. tools を途中で変えるとキャッシュが先頭から無効化される
プロンプトの内部的なレンダリング順は tools → system → messages。つまり tools は常に先頭にいる。ツールを1個追加・削除・並び替えしただけで、system 以降のキャッシュも道連れで全滅する。
自分はリクエスト内容に応じてツールセットを動的に組み替える実装にしていて、キャッシュヒット率が全く上がらなかった。対策は:
- ツールセットは固定し、名前順にソートして決定的にシリアライズする
- 「モード切り替え」をツースセットの差し替えで表現しない(メッセージ内容で伝える)
- モデルの切り替えも同様(キャッシュはモデル単位で別物)
背景・補足 — 料金の考え方
- キャッシュ書き込み:通常入力の 1.25倍(5分TTL)/ 2倍(1時間TTL)
- キャッシュ読み取り:通常入力の 約0.1倍
- 5分TTLなら2リクエストで損益分岐(1.25 + 0.1 = 1.35倍 < 2倍)。1時間TTLは書き込みが2倍なので3リクエスト以上でペイ
- ブレークポイントは1リクエスト 最大4個。TTL延長は
{"type": "ephemeral", "ttl": "1h"}
TTL はアクセスのたびに更新されるので、5分以内に次のリクエストが来続けるワークロードなら5分TTLで十分。バースト間隔が空くバッチ的な用途だけ1時間TTLを検討する。
まとめ
-
cache_control: {"type": "ephemeral"}を付けるだけでプレフィックスがキャッシュされ、読み取りは約0.1倍 - ただし最小トークン数(512〜4,096、モデル依存)未満は黙って無視される
- プレフィックス完全一致なので、タイムスタンプ・非決定的な JSON・動的ツールセットが静かにキャッシュを壊す
- 効果は思い込まず
usage.cache_creation_input_tokens/cache_read_input_tokensで実測する
「付けたのに安くならない」ときは、まず usage の2フィールドを見る。read が 0 なら、この記事の3つのどれかを踏んでいる可能性が高い。