Anthropic API のプロンプトキャッシュを使い始めて半年ほど経ちました。最初は半信半疑だったのですが、長文のシステムプロンプトを毎リクエスト送る本番ワークロードでは、請求額が体感で半分以下になります。一方で、設計を間違えるとほぼ何の効果もない、という落差が大きい機能でもあります。
この記事は、私が個人運営している複数のアプリケーションで実際にプロンプトキャッシュを導入して、月額コストの推移を追いながら設計を直していった記録です。公式ドキュメントには載っていない「現場で気づいた挙動」も含めて整理します。
なぜ私はプロンプトキャッシュを真剣に検討したのか
きっかけは恥ずかしい話ですが、API の請求額が想定の3倍に膨れていたことでした。チャット系のサービスでは、システムプロンプト(前提情報・ペルソナ・出力フォーマット指定)が長くなりがちで、私のケースでは6,000〜10,000トークンほど。これを毎ターン送り直していました。
20往復の会話で、システムプロンプトだけで12万トークン以上を消費している計算です。リクエスト本体のコンテキスト処理に埋もれて見えていませんでしたが、内訳を見て愕然としました。
プロンプトキャッシュは、この「同じ前提情報を毎回送る」コストを大きく下げてくれます。具体的には、キャッシュヒット時の入力トークン単価が標準の10分の1になります(書き込み時は1.25倍ですが、これは初回のみ)。
ただし、効果が出るかどうかは「プロンプトのどこに静的な部分があるか」「アクセスパターンがどうか」に強く依存します。
プロンプトキャッシュが効くプロンプト構造とは
公式の cache_control パラメータを使うと、プロンプトの特定の位置までを「キャッシュ対象」として指定できます。Python SDK だと次のようになります。
from anthropic import Anthropic
client = Anthropic()
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
system=[
{
"type": "text",
"text": LONG_STATIC_SYSTEM_PROMPT, # 数千トークン
"cache_control": {"type": "ephemeral"}
}
],
messages=[
{"role": "user", "content": user_input}
]
)cache_control を付けた要素までがキャッシュされます。重要なのは、キャッシュは「プロンプトの先頭からその位置まで」が完全一致した場合にのみヒットすることです。つまり、キャッシュ対象の前にユーザーごとに変わる情報が混ざっていると、ヒットしません。
私が最初にハマったのはこれでした。ユーザー名やセッション情報をシステムプロンプトの冒頭に書いていたため、キャッシュは存在するのに毎回ミスしていたのです。
正しい配置は、静的な情報を先頭に、動的な情報を後ろに置くことです。
system=[
{
"type": "text",
"text": STATIC_PROMPT, # 動かない部分(ペルソナ・ルール・例示)
"cache_control": {"type": "ephemeral"}
},
{
"type": "text",
"text": f"User context: {user_id}, time: {now}" # 動的部分
}
]キャッシュヒット率を測ることから始める
導入してすぐにやるべきは、ヒット率の計測です。レスポンスの usage には次のようなフィールドが含まれます。
print(response.usage)
# Usage(
# input_tokens=120,
# cache_creation_input_tokens=0,
# cache_read_input_tokens=8500,
# output_tokens=300
# )cache_read_input_tokens がキャッシュヒットしたトークン数、cache_creation_input_tokens が新規にキャッシュ書き込みされたトークン数です。input_tokens は通常通り課金される入力トークン。
私は最低限、次のメトリクスを記録しています。
def log_cache_metrics(response, request_id):
usage = response.usage
total_input = (
usage.input_tokens
+ usage.cache_creation_input_tokens
+ usage.cache_read_input_tokens
)
hit_rate = usage.cache_read_input_tokens / total_input if total_input else 0
logger.info({
"request_id": request_id,
"cache_hit_rate": hit_rate,
"cache_read": usage.cache_read_input_tokens,
"cache_write": usage.cache_creation_input_tokens,
"input_uncached": usage.input_tokens,
"output": usage.output_tokens,
})ヒット率が80%を超えていれば設計は概ね正しいです。50%を切る場合は、キャッシュ境界の前に動的な情報が混ざっている可能性が高いので、プロンプトを再点検します。
TTL(Time To Live)の選び方
ephemeral キャッシュのデフォルト TTL は5分です。最後にアクセスされてから5分経つと自動的に削除されます。チャットのような連続的な対話では十分ですが、たとえば「平日の昼休みだけ集中アクセスがある社内ツール」では、休憩時間の度にキャッシュが消えて再書き込みになります。
長めの TTL(1時間)を使う場合は、cache_control に ttl を指定します。
"cache_control": {"type": "ephemeral", "ttl": "1h"}ただし、長い TTL は書き込みコストの単価が上がります(1時間 TTL は標準の2倍)。ヒット率が高ければ得ですが、低ければ損です。
私の経験則は次の通りです。ユーザーあたり1日10回以上アクセスがある定常ワークロードなら1時間 TTL が有利。深夜だけ動くバッチ処理など、再アクセスが見込めない場合は5分 TTL のままでよい。サービスの利用パターンをログから読んでから決めるのが安全です。
トークン下限を意識する
プロンプトキャッシュには「最小キャッシュ可能トークン数」があります。Claude Sonnet 系では1,024トークン、Haiku 系では2,048トークンです。これより短いプロンプトには cache_control を付けても効きません。
意外と見落としがちなのが、システムプロンプトを「動的に組み立てる」関数です。たとえばユーザー権限によってルールを増減させていると、ある日は2,000トークンでキャッシュが効き、別の日は900トークンで効かない、ということが起きます。
私は本番サービスでは、システムプロンプトを「常に1,024トークン以上になる」ようにテンプレート化しました。例示セクションを充実させて、動的部分(ユーザー固有情報)はキャッシュ境界の外に置く設計です。これで安定してヒット率を稼げます。
4ブロック上限という制約
cache_control を付けられるのは1リクエストにつき最大4ブロックまでです。長大な RAG 文書などをキャッシュしたい場合、無計画にブロックを切ると上限に引っかかります。
おすすめは、論理的に意味のある単位で4ブロック以内に分けることです。
system=[
{"type": "text", "text": COMPANY_RULES, "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": OUTPUT_FORMAT_SPEC, "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": EXAMPLES, "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": USER_RAG_CONTEXT, "cache_control": {"type": "ephemeral"}},
]それぞれのブロックは独立した「キャッシュ階層」を作るので、USER_RAG_CONTEXT だけが変わってもその前の3ブロックはヒットします。これは長期記憶を持つボットを作るときに強力です。
tools を1つ足した翌日、ヒット率が0になった
半年のあいだで一番痛かったのはこれでした。サポートボットに小さなツールを1つ追加しただけの日です。プロンプトには一文字も触れていません。それなのに翌朝のヒット率は0%でした。
キャッシュの前方一致は tools → system → messages の順で判定されます。ツール定義はプロンプトの最も手前に置かれるため、そこが変わると後ろにある system のキャッシュまで巻き添えで無効になります。
client.messages.create(
model="claude-sonnet-4-6",
tools=[search_tool, calc_tool], # ← ここが1つ増えると
system=[
{"type": "text", "text": STATIC_PROMPT,
"cache_control": {"type": "ephemeral"}}, # ← ここも作り直しになる
],
messages=[...],
)対策は素朴です。ツールの追加・変更は週1回のリリースにまとめ、その日はヒット率が落ちるものとして扱う。監視のアラート閾値も、リリース日だけ緩めてあります。
逆に、ツール定義が安定しているサービスなら tools の末尾に cache_control を置いて、ツール定義ごとキャッシュしてしまうほうが得です。JSON Schema の長いツールを10個も抱えていると、それだけで数千トークンになりますので。
会話履歴そのものをキャッシュする
システムプロンプトだけをキャッシュしていた頃、私はまだ半分しか節約できていませんでした。20往復する会話では、ターンが進むほど messages 側が膨らみます。10往復目には履歴だけで数千トークン。ここが毎回まるごと再計算されていました。
cache_control は messages の要素にも置けます。直前のアシスタント応答に印を付けると、そこまでの履歴がキャッシュされます。
def with_history_cache(messages: list[dict]) -> list[dict]:
msgs = [dict(m) for m in messages]
if not msgs:
return msgs
last = msgs[-1]
content = last["content"]
if isinstance(content, str):
content = [{"type": "text", "text": content}]
content = [dict(c) for c in content]
content[-1]["cache_control"] = {"type": "ephemeral"}
last["content"] = content
return msgs次のターンでは、その印より手前(システムプロンプトと過去の履歴)が丸ごとヒットします。新規に課金されるのは直近のやりとりの差分だけ。
気をつけるのは、4ブロックの上限を履歴で食い潰さないことです。私は「システムプロンプトに1つ、FAQ に1つ、履歴の末尾に1つ」の3枠に固定し、残り1枠は予備として空けています。履歴の印は毎ターン末尾へ移すだけで、増やしません。
この設計にしてから、長い会話でのターンあたり入力コストが目に見えて平らになりました。それまでは会話が長引くほど1ターンの単価が上がっていたのですが、いまはほぼ横ばいです。
RAG ワークロードでの実装パターン
実際に私が個人開発のサポートボットで使っている設計を共有します。FAQ データを Claude にロードして応答させるシンプルな RAG です。
def build_messages(user_query: str, retrieved_docs: list[str]) -> dict:
return {
"system": [
{
"type": "text",
"text": SYSTEM_PROMPT_BASE, # ペルソナと出力ルール
"cache_control": {"type": "ephemeral"}
},
{
"type": "text",
"text": FAQ_KNOWLEDGE_BASE, # 約50KB の固定 FAQ
"cache_control": {"type": "ephemeral"}
},
{
"type": "text",
"text": "\n".join(retrieved_docs) # クエリごとに変わる
}
],
"messages": [
{"role": "user", "content": user_query}
]
}このパターンで、FAQ 部分のキャッシュヒット率は95%以上を維持できています。検索結果(retrieved_docs)はキャッシュ外なので毎回新規ですが、これは仕方ありません。
トータルで見ると、月額コストは導入前の38%まで下がりました。当初の半分どころか3分の1近くです。
ヒットしないときに疑うべき5つのポイント
これは現場で何度も書いた「自分用チェックリスト」です。
第一に、cache_control を付けた要素より前に、動的な内容が混ざっていないか。私は最初これでつまずきました。
第二に、最小トークン数を満たしているか。1,024トークン未満では Sonnet ではキャッシュされません。
第三に、TTL 切れではないか。5分以上アクセスが空くと自動的に削除されます。
第四に、モデルが違っていないか。claude-sonnet-4-6 と claude-sonnet-4-5 でキャッシュは別になります。バージョンアップ時はキャッシュが一旦リセットされます。
第五に、空白や改行の差分はないか。文字列が1バイトでも違うとヒットしません。プロンプトを動的生成しているなら、テンプレート関数の挙動を疑います。
月額コスト推移の実例
参考までに、私のサポートボット(月間リクエスト数 約12,000)でのコスト推移を共有します。
| 時期 | 月額 | ヒット率 | そのとき変えたこと |
|---|---|---|---|
| 導入前 | $187(約 ¥28,000) | — | システムプロンプト約8,000トークン × 平均20往復を毎回送信 |
| 1週間後 | $142(約 ¥21,000) | 約30% | キャッシュ境界の置き方を間違えたまま |
| 1ヶ月後 | $79(約 ¥12,000) | 92% | 動的部分を境界の後ろへ移動、TTL を1時間に |
| 3ヶ月後 | $71(約 ¥10,500) | 95% | RAG 文書と会話履歴もキャッシュ対象に追加 |
導入前との比較で約62%の削減です。目を引くのは1週間後の行で、キャッシュは効いているのにヒット率30%だと削減幅は24%止まり。同じ機能を入れていても、境界の置き方ひとつで結果が3倍変わります。
プロンプト長が伸びるほど恩恵も伸びますので、長文プロンプトを毎リクエスト送っているサービスほど、先に手を付ける価値があります。
損益分岐は思っているより手前にある
「書き込みは1.25倍かかる」と聞くと、何十回も読まれないと元が取れない気がします。私もそう思い込んで、アクセスの少ないエンドポイントには入れずにいました。紙の上で計算してみたら、まったく逆でした。
標準入力を1.0とすると、書き込みは1.25、読み出しは0.1。書き込みで余計に払うのは0.25です。一方、1回ヒットするたびに0.9を節約します。
| TTL | 書き込み単価 | 余分に払う分 | 1ヒットの節約 | 回収に必要なヒット |
|---|---|---|---|---|
| 5分 | 1.25× | 0.25× | 0.9× | 1回 |
| 1時間 | 2.0× | 1.0× | 0.9× | 2回 |
5分 TTL なら1回ヒットするだけで書き込み分は回収できます。1時間 TTL でも2回です。
本当のリスクは「書き込みが高いこと」ではなく、「一度もヒットしないまま TTL が切れること」でした。書き込んで0回なら素直に0.25倍(1時間なら1.0倍)の損です。判断の軸は「何回ヒットするか」ではなく「1回でもヒットするか」。そう置き換えてから、迷っていたエンドポイントには全部キャッシュを入れました。
私自身、この計算を先に紙の上でやっていれば、判断を半年ぶんも遅らせずに済んだはずです。単価表を眺めて怯むより、割り算を一度やってしまうほうが早いという当たり前のことを、遠回りして学びました。
次にやること
この記事を読んで「うちでも効きそうだ」と思った方は、まず1日分のリクエストログを取って、システムプロンプトの長さとリクエスト頻度を見てみてください。最小トークン数(1,024)を超えていて、同じ前提情報を繰り返し送っているなら、ほぼ確実に恩恵があります。
実装は「system を配列にして cache_control を付ける」だけなので、半日もあれば組めます。難しいのは設計の方で、特に「どこを境界にするか」と「どう計測するか」が肝心です。この記事のチェックリストを手元に置いて、ヒット率を見ながら境界を動かしてみてください。
最初の数日でヒット率を80%まで持っていけたら、コスト削減はほぼ確実です。