正直に書きます。Claude APIのprompt cachingを入れた最初の2時間、私は震えていました。
ダッシュボードに cache_creation_input_tokens だけが増え続けて、cache_read_input_tokens がゼロ。これ、もしかして全部「キャッシュ生成料金」だけ余分に取られて、肝心の節約は1円もしてないのでは、と。
結論から書くと、3時間後にやっと意味がわかって、月末の請求は前月比でだいたい1/3まで落ちました。8月までの請求が日割り換算で1日あたり420円台だったのが、設定後は150円前後で安定しています。
ただ、そこに至るまでに7回くらい設定をいじっていて、効かないパターンを大量に踏みました。3日後の自分のために、効いた構成と、効かなくて泣いた話を全部残します。
地方の自宅でClaudeに副業ツールを書かせている、ただのエンジニアの記録です。
やりたかったのは「同じ前提を毎回送るのをやめたかった」
私の使い方は単純で、業務用のPython自動化ツールから claude-sonnet-4-5 を1日40〜80回叩いています。
リクエストの中身はだいたいこう。
- 役割定義の長いsystem prompt(約2800文字)
- ツールの仕様書を貼ったuser prompt前段(約4200文字)
- そのうえで「今回の依頼」を短く(200〜500文字)
つまり毎回7000文字くらい同じ前提を送って、最後の差分だけ違う。input tokenで言うと、毎回約5500トークン前後を「同じ内容」で投げ続けていたわけです。
請求書を見て震えました。inputだけで月8000円近い。outputは2000円もないのに、inputが4倍。これ、明らかにキャッシュすべきやつでした。
公式ドキュメントを読みに行ったら、ピンとこなかった
Anthropicのprompt cachingの公式ページを最初に読んだとき、正直ピンと来なかったです。
書いてあることは「cache_control というフィールドを足してください、最小1024トークン以上のブロックに付けてください、TTLはデフォルト5分」くらいなんですが、cache_control をどこに置くと何が起こるのか、肌感で全然つかめませんでした。
最初に書いた失敗版がこれです。
import anthropic
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
system="あなたは熟練のPythonエンジニアです。" + LONG_RULES, # 約2800文字
messages=[
{"role": "user", "content": LONG_SPEC + "\n\n今回の依頼: " + task},
],
)
cache_control 一言も書いていない。これでは何もキャッシュされません。当然です。
それなのに私は「max_tokens を小さくしたら安くなるかも」とか、本筋から3kmずれたところで2時間溶かしました。
効いた最小構成
正解はこうでした。systemをリスト形式にして、ブロック単位で cache_control を付ける。
import anthropic
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
system=[
{
"type": "text",
"text": "あなたは熟練のPythonエンジニアです。\n" + LONG_RULES,
"cache_control": {"type": "ephemeral"},
},
],
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": LONG_SPEC,
"cache_control": {"type": "ephemeral"},
},
{
"type": "text",
"text": "今回の依頼: " + task,
},
],
},
],
)
usage = response.usage
print({
"input": usage.input_tokens,
"cache_creation": getattr(usage, "cache_creation_input_tokens", 0),
"cache_read": getattr(usage, "cache_read_input_tokens", 0),
"output": usage.output_tokens,
})
ポイントは3つです。
1. system を文字列ではなく辞書のリストにする 2. キャッシュさせたいブロックの末尾に cache_control: ephemeral を置く 3. 「今回の依頼」のような毎回変わる部分は、cache_control のないブロックに分けて後ろに置く
これで初回だけ cache_creation_input_tokens がドンと出て、2回目以降は cache_read_input_tokens に切り替わります。cache_read は通常inputの1/10の単価です。
私が踏んだ失敗を全部書いておきます
実際にやってみた範囲で、再現性のあった「効かないパターン」を残します。
失敗1: 1024トークン未満のブロックに cache_control を付けた
最初、軽い気持ちで300文字くらいの短いsystemに cache_control を付けて、何回叩いても cache_read がゼロ。
「あれ、効いてない」と1時間悩みました。
ドキュメント原文を見直したら、Cache prefix is too short とコメントされていて、最小トークン数に届いていないとキャッシュ自体が作成されないとのこと。私の環境では概ね1024トークン以上が安全圏でした。短いプロンプトには使えません。
失敗2: messagesの順番がリクエストごとに微妙に変わっていた
これが一番タチが悪かったです。
キャッシュは「先頭から完全一致したprefixの分だけ」効きます。私はuserメッセージの中で、リスト要素の順番をsetでシャッフルしている部分があり、そのせいで毎回先頭の数十バイト先で内容がブレていて、キャッシュが一度も再利用されませんでした。
原因に気づいたのは、cache_creation_input_tokens が毎回4200トークン前後で出続けていて、おかしいなとログを並べたときです。同じ依頼を2回連続で投げているのに、内容が違う。setのせいでした。
修正は1行で、sorted() を挟むだけでした。
# 修正前
items = list({item for item in raw_items})
# 修正後
items = sorted({item for item in raw_items})
このバグで2時間溶かしました。
失敗3: TTLの5分を過ぎてからまとめて叩いた
私の自動化スクリプトは「朝7時に1回、12時に1回、夜21時に1回」みたいな間隔で叩く構成です。
ephemeralキャッシュのデフォルトTTLは5分。当然、12時に叩いた時点でキャッシュは消えていて、毎回 cache_creation から始まります。
ここは1時間TTLの ephemeral 拡張オプション(対応モデルなら)が選べたので、私は1時間版に切り替えました。1時間版は単価がやや高い(input単価の1.25倍程度)ですが、1日内で3〜5回叩くなら確実に元が取れます。短時間に連続で叩くなら5分のまま、間隔があるなら1時間版、と使い分けるのが現実的でした。
"cache_control": {"type": "ephemeral", "ttl": "1h"}
失敗4: モデルを途中で haiku に変えてしまった
これは凡ミスです。コスト削減のつもりで一部処理を claude-haiku-4-5 に振り替えたら、当然キャッシュは別物なので新規作成扱い。
キャッシュはモデル単位です。sonnet で作ったキャッシュは haiku では読めません。当たり前なんですが、最初は気づきませんでした。
数字で見る効果
私の環境では、設定前後の1日あたり請求はこう変わりました。
設定前(4月平均):
input tokens / 日 : 約 420,000
output tokens / 日 : 約 38,000
日次請求(JPY) : 約 420円
設定後(5月、3週目):
input tokens / 日 : 約 410,000
└ うちcache_read : 約 360,000 (88%がキャッシュヒット)
└ うちcache_creation : 約 20,000
└ 通常input : 約 30,000
output tokens / 日 : 約 41,000
日次請求(JPY) : 約 150円
トークン総量はほぼ変わっていないのに、請求は約36%まで落ちました。cache_read の単価が通常inputの約1/10なので、ヒット率88%が効いています。
正直、この数字を最初に出したとき、3回スプレッドシートで計算し直しました。「単位間違ってないよな?」と。
キャッシュ効率を毎日確認するための10行
放っておくと、いつの間にかキャッシュ構造が崩れていてヒット率がゼロに戻ることがあります。私はログに毎回usage情報を吐いて、毎晩集計するようにしました。
def cache_hit_rate(usage) -> float:
read = getattr(usage, "cache_read_input_tokens", 0) or 0
creation = getattr(usage, "cache_creation_input_tokens", 0) or 0
base = getattr(usage, "input_tokens", 0) or 0
total = read + creation + base
if total == 0:
return 0.0
return read / total
この値が0.7を切ったら「どこかでprefixが壊れた」アラート、というシンプルな運用にしてあります。実際これで2回、「いつの間にかsystem prompt末尾に改行が増えていた」事故を発見できました。
prefixは1バイトでも違うとキャッシュミスします。日本語ブログの改行(\n vs \r\n)、末尾のスペース、テンプレートエンジンが勝手に挿入するタブ、全部が原因になります。
振り返ると、結局は「同じものを毎回送るな」だけだった
整理すると、prompt cachingは魔法ではなく、
- 同じ前提を毎回送っているなら、それをキャッシュ可能な単位に切り出す
- キャッシュは先頭からの完全一致でしか効かない
- 1024トークン以上、TTLの管理、モデル別、この3点を守る
これだけです。
私のように「気づいたら毎月8000円inputに払っていた」みたいな状態なら、たぶん3時間で元が取れます。私は実際、設定に半日かけて、その月のうちに費用回収して、翌月から月6000円くらいずつ浮いている計算です。
副業のツールを書かせるのに、その6000円は地味に大きいです。コンビニのおにぎりで言うと月40個分くらい違います。
派手な技術ではないけれど、地味に効くやつでした。同じ条件で毎日叩いているスクリプトを持っている人は、まず system をリスト化して cache_control を1個置くところから試してみると、その日のうちに変化が見えると思います。
次に試したいのは、Anthropic公式のMessage Batches APIとの併用です。あちらも単価が約半額になるとのことなので、組み合わせたらどこまで落とせるか、また書きます。
コメント