import { Callout } from '@/components/ui/callout';
Anthropic の Messages API には cache_control というフィールドがあって、長いプロンプトの先頭部分をサーバー側にキャッシュさせられます。私自身、Claude Lab を含む Dolice Labs の 4 サイトと、2014 年から並行運用している iOS / Android アプリ群(累計 5,000 万ダウンロードを超えました)で、毎日かなりの量の API リクエストを投げています。そのコストを真面目に下げようとした時、いちばん効いたのは「キャッシュを入れるかどうか」ではなく、「TTL を何種類使い分けるか」でした。
cache_control の TTL は、既定の 5m(5 分)と、ttl: "1h" を指定したときの 1 時間の 2 種類が選べます。これを「全部 5m で動かしておけばいい」と考えていたのが、半年前の私です。請求書をきちんと内訳ベースで眺めた結果、静的な前提情報は 1h で、可変な few-shot だけ 5m に分ける ことで、書き込みコストとヒット率のバランスが大きく変わると気づきました。本稿はその設計プロセスと、私の手元で動いている本番ワークロードでの観測値を、なるべくそのままの形で残すための実装メモです。
なぜ「単一 TTL」では効きが悪いのか
プロンプトキャッシュを最初に入れたとき、私は以下のような構造で書いていました。
# 改善前: すべて 5m TTL の単一キャッシュ
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=2048,
system=[
{
"type": "text",
"text": SYSTEM_PROMPT, # 約 4,500 トークン
"cache_control": {"type": "ephemeral"}, # 5m
}
],
tools=TOOLS, # 約 1,200 トークン
messages=[
{"role": "user", "content": user_text},
],
)
これでも一見ちゃんと効いているように見えました。usage.cache_read_input_tokens が動き出すと「あぁ、ヒットしているんだな」と安心してしまいます。ただ、しばらく運用してログを集計すると、2 つの問題が浮かびました。
ひとつは、夜間に 5 分以上アクセスが空くたびに、4,500 トークン分の cache_creation_input_tokens が再課金されていた ことです。書き込み単価は通常入力の 1.25 倍ですから、これが 1 日に何十回も走ると、せっかくのキャッシュ節約がだいぶ食われます。私のアプリの一部はオフピーク帯にユーザーが散在するタイプで、特に響きました。
もうひとつは、可変な few-shot を後ろに足したくなる場面で、「キャッシュブレークポイントは 1 リクエストに最大 4 つまで」という制約のなかで、可変分のせいで静的分の境界が変わって書き直されてしまう ケースが出てきたことです。これは公式ドキュメントの読み方が甘かった私のミスで、後述の二段構成にすると自然に解消しました。
1h TTL が刺さるのは「読まれ続ける静的層」だけ
cache_control に ttl: "1h" を渡すと、キャッシュ期間が 1 時間に延びます。代わりに 書き込みコストが通常の 2 倍(5m 版の約 1.6 倍)になります。ここを取り違えると、1h を入れるほど高くつくという逆転が起きます。
私は次のような単純なルールで判断しています。
書き込みコスト増 < ヒットによる節約
↓
平均アクセス間隔 < 1h、かつ夜間/休日にアクセスがゼロにならない層
↓
1h TTL の出番
逆に、ユーザーごとに変わる few-shot や、リアルタイムに差し込む RAG コンテキストは、そもそも 1h 持たないし、持たせる意味もない ので 5m のままにします。
私の運用の場合、1h を入れる価値があったのは次の 3 つでした。
- 長いシステムプロンプト(ペルソナ・出力フォーマット・安全ガード)
tools の JSON Schema 定義(特に 5 つ以上ツールがある時)
- 全ユーザー共通のドメイン知識(ナレッジベースの「コア部分」)
逆に 5m のままで十分だったのが、ユーザーごとに切り替える few-shot や、その日のキャンペーン情報など、1 セッション内では効くが翌日には別物に差し替わる 系のコンテンツです。
二段キャッシュの設計図
私が現在動かしているのは、おおむね次のような階層です。
| 層 | 内容 | サイズ目安 | TTL | キャッシュブレークポイント |
| L1 | system プロンプト本体 | 4,500 tok | 1h | 1 つ目 |
| L2 | tools の JSON Schema | 1,200 tok | 1h | 2 つ目 |
| L3 | 可変 few-shot / 直近の RAG | 800〜2,000 tok | 5m | 3 つ目 |
| L4 | ユーザー入力 | 100〜800 tok | キャッシュなし | — |
これを実装に落とすと、こうなります。
import anthropic
client = anthropic.Anthropic()
def build_messages_with_two_tier_cache(
system_prompt: str,
tools: list[dict],
fewshot_block: str,
user_text: str,
):
"""二段キャッシュ構成のリクエスト本体を返す。
L1/L2 を 1h、L3 を 5m に分けることで、夜間に
L3 が落ちても L1/L2 は保たれる。
"""
system = [
{
"type": "text",
"text": system_prompt,
"cache_control": {"type": "ephemeral", "ttl": "1h"}, # L1
}
]
# L2: tools 定義の末尾に 1h ブレークポイント
tools_with_cache = [dict(t) for t in tools]
tools_with_cache[-1] = {
**tools_with_cache[-1],
"cache_control": {"type": "ephemeral", "ttl": "1h"},
}
messages = [
{
"role": "user",
"content": [
{
"type": "text",
"text": fewshot_block,
"cache_control": {"type": "ephemeral"}, # L3 (5m)
},
{"type": "text", "text": user_text}, # L4: キャッシュなし
],
}
]
return system, tools_with_cache, messages
system, tools, messages = build_messages_with_two_tier_cache(
system_prompt=SYSTEM_PROMPT,
tools=TOOLS,
fewshot_block=current_fewshot(),
user_text=user_input,
)
resp = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=2048,
system=system,
tools=tools,
messages=messages,
)
ポイントは 3 つあります。1 つ目は L1 と L2 を別ブレークポイントにすること。tools 定義を差し替えても system プロンプトのキャッシュは保てます。2 つ目は L3 のブレークポイントを messages 側の user ロールに置く こと。system 側に置こうとすると、可変分が増えるたびに書き直しが連鎖します。3 つ目は L4 を絶対にキャッシュ対象に入れない こと。ユーザー入力は毎回違うので、キャッシュさせても無駄に書き込みコストだけ膨らみます。
監視メトリクス — TTL 設定の正否は数値で判断する
「ちゃんと効いているか」を肌感覚で語っても仕方ないので、私は次の 3 つの指標を毎日見ています。
def cache_efficiency_metrics(usage: dict) -> dict:
"""1 リクエスト分の usage から効率指標を出す。
本番では集計バックエンドに送って日次ダッシュボードにしている。
"""
cache_read = usage.get("cache_read_input_tokens", 0)
cache_create = usage.get("cache_creation_input_tokens", 0)
plain_input = usage.get("input_tokens", 0)
total_input = cache_read + cache_create + plain_input
if total_input == 0:
return {}
return {
# (1) ヒット率: 入力のうちキャッシュから読めた割合
"hit_ratio": cache_read / total_input,
# (2) 書き込み比: 読みに対して何回書いているか
"write_ratio": cache_create / max(cache_read, 1),
# (3) 通常入力比: キャッシュに乗らなかった生の入力割合
"plain_ratio": plain_input / total_input,
}
それぞれの読み方を私の運用基準で言うと、こうなります。
hit_ratio が 0.6 未満なら、TTL かブレークポイント位置のどちらかが間違っている。私のチャット系ワークロードでは 0.72〜0.85 で安定しています。
write_ratio が 0.3 を超えていたら、書き込み過多。1h を入れたのに write_ratio が下がらない場合、その層は「1h 持たない」アクセスパターンなので 5m に戻すべきです。
plain_ratio が 0.5 を超えていたら、そもそもキャッシュ可能な領域を活かせていない。few-shot を messages 側ではなく system 側に書いてしまっているケースが多いです。
これを 1 日単位でユーザー集計すると、TTL を分けたあと hit_ratio の平均が 0.58 → 0.78 に上がり、月額の input_tokens 関連請求が 約 42% 下がりました。書き込み単価が 2 倍になっても、ヒット側の節約が圧倒的に勝つ、というのが二段化の本質です。
1h TTL を入れて損をする 3 つのパターン
実体験として、1h を入れたのに数字が悪化したことが何度かあります。記憶している限り 3 パターンあって、いずれも「アクセスパターンの誤読」が原因でした。
ひとつ目は、深夜帯にユーザーが完全に消えるサービス。1 時間以内に再アクセスが来ない時間帯が長いと、書き込みコストが回収できません。私の場合、アート系の連携アプリで夜中 3〜5 時にアクセスがほぼゼロになるものがあり、ここだけは 5m のまま運用しています。
ふたつ目は、system プロンプトを A/B テストで頻繁に差し替えるフェーズ。プロンプトレジストリで v17 → v18 のように差し替えると、キャッシュキーが変わって書き込みが走ります。安定運用に入る前は 5m で回し、運用が落ち着いたら 1h に切り替えるのが安全です。
3 つ目は、messages 履歴を全部キャッシュに乗せようとする。過去の会話を全部 1h にすると、ユーザーの発話 1 つで全層が書き直しになります。会話履歴は明示的にブレークポイントを置かず、自動的に切れるに任せる方が、長い目で見るとコストが安定します。
二段化を進める順番
ここまでの設計をいちどに入れるのは怖いので、私は次の順で段階導入しました。
- 計測だけ入れる:
usage の 3 値を集計バックエンドに送る。1 週間眺めて現在の hit_ratio を把握。
- L3(few-shot)の 5m を最初に入れる: 影響範囲が小さい層からテストするとロールバックが楽。
- L1(system)を 1h に切り替え: ここで
cache_creation_input_tokens が一時的に跳ねるが、24 時間後に hit_ratio が上がれば成功。
- L2(tools)を 1h に切り替え: tools の数が 5 つを超えていたら効果が大きい。
- A/B フェーズだけ 5m に戻すスイッチを足す: プロンプトレジストリ側にフラグを持ち、版を上げる時だけ 5m に切替。
各段階で 24〜48 時間の観測を挟むと、どこで効いているかが切り分けやすくなります。私は推奨派ですが、リリースの圧力が強い時は L3 だけ入れて翌週に L1/L2 を入れる、という分割でも問題なく回りました。
ハマりやすい挙動 — ブレークポイントは「最大 4 つ」
公式仕様上、cache_control ブレークポイントは 1 リクエストに最大 4 つまでです。私が最初にやらかしたのは、tools の各定義 1 つずつにブレークポイントを置いてしまい、5 個目の tools を足した瞬間に 全層のキャッシュが無効になっていた ことでした。
# アンチパターン: ツールごとにブレークポイントを置く
for tool in tools:
tool["cache_control"] = {"type": "ephemeral", "ttl": "1h"}
正しくは、最後の tools 定義 1 つだけにブレークポイントを置き、それより前の tools 全体を 1 つのキャッシュ単位として扱います。これは Anthropic の挙動として「ブレークポイントの位置までを 1 つのプレフィックスとして扱う」と決められているためで、ブレークポイントを増やせばキャッシュも分かれる、と勘違いしやすい部分です。
ロールバック手順を先に用意しておく
私の運用では、本番投入する前に必ず「TTL を 5m 単一に戻すスイッチ」を環境変数で持たせるようにしています。
import os
def cache_ttl_for(layer: str) -> dict:
"""環境変数で 1h を 5m に強制ロールバックできる構成。"""
if os.getenv("PROMPT_CACHE_FORCE_5M") == "1":
return {"type": "ephemeral"} # 5m
if layer in ("system", "tools"):
return {"type": "ephemeral", "ttl": "1h"}
return {"type": "ephemeral"} # 5m
これがあると、本番で hit_ratio が想定外に下がった時、コードを変えずに環境変数 1 つで以前の挙動に戻せます。AdMob を 2014 年から運用してきた経験上、「夜中に呼ばれた時に 30 秒で戻せる」設計は、機能追加そのものよりよほど価値があります。深夜の判断ミスを救うのはほとんどこの種のスイッチでした。
二段化のあとに見えたもの
二段キャッシュに切り替えてから 2 ヶ月ほど運用したところで、私の中で考え方が少し変わったことがあります。
ひとつは、「キャッシュは設計の継ぎ目」だという感覚 です。L1 と L3 の境目は、「どこまでが全ユーザー共通か」と「どこからが個別か」を線で引くこととほぼ同義でした。境界を明確にすると、キャッシュ効率が上がるだけでなく、コード上もシステムプロンプトと few-shot を別ファイルで持つことが自然になりました。
もうひとつは、「TTL は運用上のレバー」だということ。1h と 5m を分けて運用するようになってから、運用上のチューニングポイントが「プロンプトをいじる」「モデルを変える」だけではなくなりました。アクセスパターンに合わせて TTL を動かすという、第 3 のレバーが手に入った感覚があります。
個人開発で 12 年やってきて、サーバーコストはいつも気になり続けてきた部分でした。Claude API のキャッシュは、その「気になる」部分を、設計の問題として扱える形に翻訳してくれる仕組みだと感じています。
次に手をつけたい人へ
これから二段キャッシュを入れる方には、まず計測だけ入れて 1 週間眺めることをお勧めします。hit_ratio の現在地が分かれば、1h を入れる順番もブレークポイントを置く層も自然に決まります。逆に、計測なしでいきなり 1h に切り替えると、cache_creation_input_tokens が跳ねた時に「効いているのかいないのか分からない」状態に陥ります。
私自身、最初の 1 週間の計測ログがあったおかげで、夜間に効かない時間帯を発見できました。少し時間を割いてでも、観測を先に入れるのが結果的に近道だと思っています。同じ課題に取り組んでいる方の参考になれば幸いです。