LLM アプリ運用 #3 プロンプトキャッシング実践

読了 6分

第2回で入力と出力を減らし、モデルを分けました。ところが第1回のログを見ると、入力トークンの大きな塊が毎リクエスト同じまま繰り返されています。システムプロンプト、ツール定義、RAG の共通指示のようなものです。プロンプトキャッシング(prompt caching)は、この繰り返し分を API 側に保存して再利用する機能で、キャッシュから読むトークンは基本単価の約10分の1です。きちんと当たれば入力費用の半分以上が消える、運用段階で最も強力な単一の技法です。

大原則 — キャッシングは接頭辞一致です #

キャッシングを扱うのに必要な原理は、実は一文です。キャッシュは、リクエストの前半(接頭辞)がバイト単位で正確に同じときだけヒットします。リクエストはツール定義 → システムプロンプト → メッセージの順にレンダリングされ、その直列化された前半が前のリクエストと一文字でも違えば、その地点以降のキャッシュはすべて無効になります。

この原則から、設計指針がそのまま導かれます。変わらないものを前に、変わるものを後ろに置きます。

キャッシュに優しいリクエスト構造
[ツール定義]           ← 固定(順序まで固定)
[システムプロンプト]    ← 固定
--- cache_control 境界 ---
[会話履歴]             ← セッションごとに異なる
[今回の質問]           ← 毎回異なる

cache_control — 境界に印を付ける #

キャッシュする境界は cache_control で印を付けます。印を付けたブロックまでの接頭辞がキャッシュの対象になります。

prompt_caching.py
response = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=4096,
    tools=tools,                          # ツールはシステムより前にレンダリングされる
    system=[{
        "type": "text",
        "text": SYSTEM_PROMPT,            # 固定のシステムプロンプト
        "cache_control": {"type": "ephemeral"},   # ここまでキャッシュ
    }],
    messages=conversation + [{"role": "user", "content": question}],
)

システムブロックに印を一つ置けば、その前のツール定義まで一緒にキャッシュされます。寿命(TTL)はデフォルトで5分、ヒットするたびに延長されます。リクエストが5分以内に続くサービスなら、最初のリクエストがキャッシュを書き(write)、以降のリクエストが読み続ける(read)構造になります。トラフィックがまばらなら1時間 TTL のオプション("ttl": "1h")もありますが、書き込み単価が高くなるので、より多くのヒットがないと元が取れません。

経済性 — いつ得になるのか #

キャッシングはタダではなく取引です。単価構造が判断基準になります。

区分単価(基本入力比)
キャッシュ書き込み(5分 TTL)1.25倍
キャッシュ書き込み(1時間 TTL)2倍
キャッシュ読み取り約0.1倍

5分 TTL 基準では、2回目のリクエストですでに元が取れます(1.25 + 0.1 < 2)。つまり同じ接頭辞で5分以内に2回以上呼び出すなら必ず得です。逆に、接頭辞がリクエストごとに異なったり(パーソナライズされたシステムプロンプトなど)、短すぎたりすると、キャッシュがそもそも作られないか、書き込み費用だけを払うことになります。モデルごとにキャッシュ可能な最小接頭辞長があり(数千トークン程度)、短いプロンプトは印を付けても静かにキャッシュされない、という点も知っておく価値があります。

検証は、第1回ですでに敷いておいたログで行います。usagecache_read_input_tokens が0でなければヒットしています。

第1回のログから
{"feature": "answer_question", "input_tokens": 412,
 "cache_read": 8120, "cache_write": 0, ...}

入力全体8,532トークンのうち8,120がキャッシュから来たので、このリクエストの入力費用はキャッシング前の15%程度です。

沈黙のキャッシュ無効化 — ヒット率が0のとき #

キャッシングを有効にしたのに cache_read がずっと0なら、十中八九、接頭辞のどこかが毎リクエスト少しずつ違っています。エラーなしに静かに外れるので、監査リストを持って探すのが早道です。

  • システムプロンプトの動的な値 — 「現在時刻: …」「ユーザー名: …」のような挿入が代表的な犯人です。動的な情報はシステムではなくメッセージ側(境界の後ろ)に移します。
  • 非決定的な直列化 — ツール定義を dict から作るときにキーの順序が揺れると、バイトが変わります。並びを固定します。
  • ツールリストの変動 — リクエストごとにツールを出し入れすると、位置0から無効になります。エージェント第2回で作業別のツールリストを勧めましたが、キャッシングの観点では作業ごとに固定されたリストであるべき、という条件が付きます。
  • モデルの変更 — キャッシュはモデル別です。第2回のルーティングでモデルが分かれると、キャッシュもモデルごとに別々に積まれます(これは正常で、各経路の中でヒットすればよいのです)。
  • セッション ID や乱数 — 接頭辞のどこかに UUID が埋め込まれていると、永遠にヒットしません。

要するに、キャッシングの80%は cache_control の印ではなく、接頭辞を固定する規律です。

対話型アプリでは — 履歴までキャッシュする #

チャットボットのように会話が長くなるアプリは、もう一歩進めます。システムプロンプトだけでなく過去の会話履歴までキャッシュ境界に入れるのです。最後のユーザーメッセージの直前のブロックに印を置けば、次のターンで直前のターンまでの全体がキャッシュから読まれます。ターンを重ねるほど入力は長くなるのに、費用は新しいメッセージ分だけ払う構造になります。エージェントシリーズのループのように、同じ会話に呼び出しが連続するワークロードで効果が特に大きいです。

よくつまずくところ #

  • 印だけ付けて検証しない — cache_control を付けたら終わり、ではありません。cache_read 指標が実際に0でないかをログで確認してこそ、沈黙の無効化を捕まえられます。
  • 動的な値をシステムプロンプトに置く — 日付の一行がキャッシュ全体を無効化します。変わるものはすべて境界の後ろに送ります。
  • 短いプロンプトに期待する — 最小長未満は印を付けてもキャッシュされません。キャッシングは長い固定接頭辞を持つ機能から適用します。

まとめ #

今回は、繰り返される入力の価格を10分の1にしました。

  • キャッシングは接頭辞一致です。変わらないものを前に、変わるものを後ろに置く構造が、すべての出発点です。
  • 5分 TTL 基準で2回目のリクエストから得になります。cache_read 指標でヒットを検証します。
  • ヒット率0の原因は、ほとんどが沈黙の無効化(動的な値、直列化の揺れ、ツールの変動)です。監査リストで探します。

ここまではリアルタイムリクエストの費用でした。ところが、すぐに答えが要らない作業もあります。次回「LLM アプリ運用 #4 バッチ処理 — 急がない仕事は半額で」では、Batches API で非リアルタイム作業の費用を半分にします。

X