この記事をシェア
LLMのAPI費用が想定より膨らんでいませんか。エージェントを長時間動かしたり、長いシステムプロンプトを毎回送ったりしていると、入力トークンの請求はあっという間に積み上がります。この記事では、プロンプトキャッシュを確実にヒットさせてAPI費用を大幅に下げるための実践ポイントを、仕組みから運用まで一気通貫でまとめます。ClaudeやOpenRouter経由でのAI利用、Hermes系エージェントフレームワークの運用でコストに悩んでいる方に向けた内容です。
筆者の環境でも、プロンプトキャッシュの最適化だけで月間のAPI費用が約70%削減できた実績があります。設定は決して複雑ではありませんが、ハマりどころがいくつかあるのも事実。本記事では、そのハマりどころをすべて潰していく方針で解説します。
1. なぜプロンプトキャッシュがコスト削減の切り札なのか
従量課金のLLM APIでは、送信した入力トークンすべてに課金されます。ところがエージェントやチャットボットは、毎回ほぼ同じシステムプロンプト・ツール定義・固定コンテキストを送り続けています。ここにプロンプトキャッシュが効きます。
キャッシュが読み込まれた入力トークン(cache read)は、通常入力の約4分の1(0.25倍)の料金で済みます。具体的な数字で見てみましょう。
💰 コスト計算の具体例:19,000トークンのシステムプロンプト
Claude Sonnet相当の料金体系の場合:
• 通常入力:$3.00 / 1Mトークン
• キャッシュ読み込み:$0.30 / 1Mトークン(0.1倍)
• キャッシュ書き込み:$3.75 / 1Mトークン(1.25倍)
1日1,000リクエストの場合:
• キャッシュなし:$0.057 × 1,000 = $57/日 → $1,710/月
• キャッシュ有効(99%ヒット):$0.006 × 990 + $0.071 × 10 = $6.7/日 → $201/月
→ 約88%の削減。月間1,500ドル以上の差になります。
短いクエリを頻繁に投げるゲートウェイ用途では、このシステムプロンプト分がコストの支配的要因になりがちです。つまり「変わらない部分をいかにキャッシュに乗せ続けるか」が節約の核心なのです。
2. プロンプトキャッシュの基本原理
キャッシュはプレフィックス(先頭からの連続一致)単位でヒットします。プロンプトの途中が1トークンでも変わると、そこから後ろは全てキャッシュミスになり、再び満額課金されます。
したがって設計の鉄則はシンプルです。
「変わらないものを前に、変わるものを後ろに置く」
理想的な配置順は次の通りです。
- システムプロンプト(役割定義・ルール)— 最も安定
- ツール定義(MCPツール一覧)— 起動時に固定
- 固定ドキュメント・RAGデータ — セッション中不変
- 会話履歴 — ターンごとに増加
- 最新のユーザー入力 — 毎回変わる
安定度が高いものから前に並べ、動的に変わる値は極力後ろに追いやります。これが全てのテクニックの土台になります。
3. 節約ポイント1:マーカーが本当に送信されているか確認する(最重要)
一番多い落とし穴がこれです。「キャッシュ設定はしたのに全然安くならない」というケースの大半は、キャッシュ指示(cache_control マーカー)が実際の送信ペイロードに乗っていないことが原因です。
特にOpenRouter経由でClaudeを使う場合、リクエストがOpenAI互換形式(chat completions)でルーティングされることがあります。この形式ではシステムプロンプトが messages[] 配列の中の {"role": "system"} メッセージとして送られます。ところがAnthropicのキャッシュ機構は、native形式のトップレベル system コンテンツブロック上の cache_control しか認識しません。OpenAI互換ペイロードに付けたマーカーは、無視されてしまうのです。
Anthropic Native形式での正しいcache_control指定
{
"model": "anthropic/claude-sonnet-4-20250514",
"max_tokens": 4096,
"system": [
{
"type": "text",
"text": "あなたは優秀なAIアシスタントです...",
"cache_control": {
"type": "ephemeral"
}
}
],
"messages": [
{"role": "user", "content": "こんにちは"}
]
}
OpenAI互換形式(⚠️ cache_controlが無視されるケース)
{
"model": "anthropic/claude-sonnet-4-20250514",
"messages": [
{
"role": "system",
"content": "あなたは優秀なAIアシスタントです..."
// ⚠️ この形式では cache_control は無視される!
},
{"role": "user", "content": "こんにちは"}
]
}
⚠️ 対策は次の二択
- native形式で送る:トップレベルに
systemキーを置き、そこにcache_controlを付ける - per-block(個別ブロック)の明示ブレークポイント方式にする:個々のコンテンツブロックに直接
cache_controlを付ける。この方式はAnthropic・Bedrock・Vertexを含む全てのAnthropic互換プロバイダで動作するため、経路が読めないときはこちらが堅牢
確認方法は地味ですが確実です。HTTPデバッグログでリクエストボディの実物をキャプチャし、cache_control が期待した場所に入っているかを目視してください。ここが死んでいると、以降のテクニックはすべて無駄になります。
Pythonでのデバッグ確認コード例
import httpx
import json
# httpxのイベントフックでリクエストボディをキャプチャ
def log_request(request):
body = request.content.decode('utf-8')
data = json.loads(body)
# cache_controlの存在を確認
if 'system' in data:
system = data['system']
if isinstance(system, list):
for i, block in enumerate(system):
has_cc = 'cache_control' in block
print(f"system[{i}]: cache_control={'✅ あり' if has_cc else '❌ なし'}")
elif isinstance(system, str):
print("system: ❌ 文字列形式(cache_control指定不可)")
# messages内のcache_controlも確認
for i, msg in enumerate(data.get('messages', [])):
content = msg.get('content', '')
if isinstance(content, list):
for j, block in enumerate(content):
has_cc = 'cache_control' in block
print(f"messages[{i}].content[{j}]: cache_control={'✅ あり' if has_cc else '❌ なし'}")
client = httpx.Client(event_hooks={'request': [log_request]})
4. 節約ポイント2:sticky routing でプロバイダを固定する
OpenRouterのような集約ルーターを使うと、リクエストごとに違うプロバイダのエンドポイントに振り分けられることがあります。プロバイダが変わればキャッシュも別物になり、せっかく作ったキャッシュがヒットしません。
OpenRouterはキャッシュ利用時にプロバイダsticky routingを使い、一度キャッシュを使ったリクエストの後は、同じプロバイダエンドポイントへ後続を送るよう記憶します。これを効かせるには、フレームワーク側から session_id(または thread_id)をリクエストに渡すことが重要です。
OpenRouterでのsticky routing設定例
import openai
client = openai.OpenAI(
base_url="https://openrouter.ai/api/v1",
api_key="sk-or-xxxxx",
default_headers={
# session_idを付与してsticky routingを有効化
"X-Session-Id": "user-session-abc123",
}
)
response = client.chat.completions.create(
model="anthropic/claude-sonnet-4-20250514",
messages=[
{"role": "system", "content": "あなたは..."},
{"role": "user", "content": "質問です"}
],
extra_headers={
"X-Session-Id": "user-session-abc123"
}
)
セッションIDを紐付けておけば、同じ会話の連続リクエストが同じプロバイダに集約され、キャッシュが分裂しません。異なるユーザーのセッションには異なるIDを使い、同一セッション内では一貫したIDを維持するのがポイントです。
5. 節約ポイント3:2層TTL戦略で有効期限を使い分ける
キャッシュには有効期限(TTL)があります。デフォルトは約5分で、アクセスのたびに延長されます。オプションで1時間TTLも選べます。
効果的なのが2層に分ける戦略です。
| レイヤー | 対象 | TTL |
|---|---|---|
| レイヤー1 | システムプロンプト+ツール定義 | 1h(長め) |
| レイヤー2 | 会話の末尾(履歴) | 5m(デフォルト) |
2層TTLの実装例(明示ブレークポイント方式)
{
"system": [
{
"type": "text",
"text": "あなたは優秀なAIアシスタントです。以下のルールに従ってください...",
"cache_control": {
"type": "ephemeral",
"ttl": "1h"
}
}
],
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "過去の会話のコンテキスト..."
},
{
"type": "text",
"text": "最新の質問",
"cache_control": {
"type": "ephemeral",
"ttl": "5m"
}
}
]
}
]
}
このとき、ルーターのトップレベル自動 cache_control フィールドではなく、明示ブレークポイント方式を使うのがポイントです。そうすることでルーティングの柔軟性を保ったまま、2層のキャッシュを組めます。
💡 明示ブレークポイントは最大4箇所
RAGデータ、ツール定義、長いシステムプロンプトといった大きな固定テキストに優先的に割り当てましょう。細かく散らすより、効く場所に集中させるのが得策です。
6. 節約ポイント4:ツールの並び順を固定する
MCPツールなどを動的に列挙してツール定義を組み立てている場合、列挙順が実行ごとに揺れるとツールブロックが変わり、プレフィックスキャッシュが一気に全滅します。
対策は、ツールを登録する前に決定論的にソートすること。たとえばサーバー名→ツール名の順で必ず並べ替えてから登録すれば、listTools() の順序が実行ごとに変動してもツールブロックは安定し、キャッシュが壊れません。
Pythonでのツールソート実装例
def get_sorted_tools(mcp_client):
"""ツール定義を決定論的な順序で取得する"""
tools = mcp_client.list_tools()
# サーバー名 → ツール名の順でソート
sorted_tools = sorted(
tools,
key=lambda t: (
getattr(t, 'server_name', '') or '',
getattr(t, 'name', '') or ''
)
)
# OpenAI function calling形式に変換
return [
{
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.inputSchema
}
}
for tool in sorted_tools
]
# 使用例
tools = get_sorted_tools(mcp_client)
# → 毎回同じ順序でツール定義が生成される
# → プレフィックスキャッシュが安定する
7. 節約ポイント5:安定プレフィックスと揮発サフィックスを分ける
システムプロンプトの中に、実は動的な値が紛れ込んでいることがよくあります。現在時刻、タイムスタンプ、セッション固有ID、ランタイム情報の並び順などです。これらが先頭付近にあると、毎回プレフィックスが変わってキャッシュが飛びます。
悪い例:動的値がプレフィックスを汚染
# ❌ 悪い例:時刻が先頭に入っている
system_prompt = f"""現在時刻: {datetime.now().isoformat()}
あなたは優秀なAIアシスタントです。
[長いルール定義...]
[ツール定義...]
"""
# → 毎回プレフィックスが変わりキャッシュ全滅
良い例:安定プレフィックス+揮発サフィックス
# ✅ 良い例:安定部分と揮発部分を分離
stable_prefix = """あなたは優秀なAIアシスタントです。
[長いルール定義...]
[ツール定義...]
"""
volatile_suffix = f"""
---
現在の時刻: {datetime.now().isoformat()}
セッションID: {session_id}
"""
system_prompt = stable_prefix + volatile_suffix
# → 安定部分がキャッシュされ、揮発部分だけが変わる
さらに、フィンガープリントの正規化も効果的です。空白、改行コード、フックで追加されるコンテキスト、能力リストの並び順などを正規化しておくと、意味的には変わっていないプロンプトがターンをまたいでキャッシュを共有できます。
🔧 運用のヒント
設定変更やワークスペース変更の後にキャッシュ書き込み(cache write)が急増したら、その変更が境界の上か下かを疑ってください。揮発コンテンツを境界の下に移す、あるいは安定化することで、たいてい解決します。
8. 節約ポイント6:モデルごとにキャッシュの仕組みが違う
すべてのモデルが同じキャッシュ挙動をするわけではありません。ここを一緒くたにすると、誤った最適化や偽のコスト分析につながります。
| モデル | キャッシュの仕組み | 注意点 |
|---|---|---|
| Anthropic系(Claude) | 明示ブレークポイント方式。会話履歴を移動しながら再利用する仕組みを持つ | native形式の cache_control が必須 |
| OpenAI系 | 実質的に初期プレフィックスキャッシュ寄り | 再利用できるプレフィックスが比較的早く頭打ちになることがある |
| DeepSeekなど | ルーターがプロバイダ側で自動的にキャッシュを処理 | cache_control マーカーを注入すべきではない。ベストエフォート |
単一の閾値で全プロバイダを横断比較しないことが鉄則です。仕組みが違うので、同じ基準で測ると「実際は正常なのに劣化した」という偽判定を生みます。
9. 節約ポイント7:最小トークン数とTTLの前提を満たす
見落としがちな条件が2つあります。
最小キャッシュ長
モデルごとに下限があり、これ未満のプレフィックスはそもそもキャッシュされません。
| モデル | 最小キャッシュ長 |
|---|---|
| Claude Sonnet系 | 約1,024トークン |
| Claude Opus系・Haiku系 | 約4,096トークン |
システムプロンプトが小さすぎてキャッシュに乗らない場合は、安定した定型コンテキストを足して閾値を超えさせる価値があります。
TTLの活用
バッチ処理では、ウォームアップの先頭リクエストの後、5分以内に後続を流し切るとキャッシュが効き続けます。アイドル時間が読めない断続的なワークロードなら、1時間TTLを検討しましょう。
# バッチ処理の例:5分以内にまとめてリクエストを流す
import time
import asyncio
async def batch_process_with_cache(items):
"""キャッシュを効かせたバッチ処理"""
results = []
start_time = time.time()
for i, item in enumerate(items):
# 最初の1回はキャッシュ書き込み(cache write)が発生
# 2回目以降はキャッシュ読み込み(cache read)で0.25倍
result = await call_llm(item)
results.append(result)
elapsed = time.time() - start_time
if elapsed > 240: # 4分経過したら警告
print(f"⚠️ 残り {len(items) - i} 件。TTL期限に注意!")
return results
10. 節約ポイント8:計測なくして最適化なし
最後に、必ずキャッシュのヒット状況をログ化してください。指標となるのは cache_creation_input_tokens(新規作成分)と cache_read_input_tokens(読み込み分)です。OpenRouter経由なら native_tokens_cached が該当します。
レスポンスからのキャッシュ情報取得例
import anthropic
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
system=[{
"type": "text",
"text": "長いシステムプロンプト...",
"cache_control": {"type": "ephemeral"}
}],
messages=[{"role": "user", "content": "こんにちは"}]
)
# キャッシュ統計の確認
usage = response.usage
print(f"入力トークン: {usage.input_tokens}")
print(f"キャッシュ作成: {usage.cache_creation_input_tokens}")
print(f"キャッシュ読み込み: {usage.cache_read_input_tokens}")
# キャッシュヒット率の計算
total_input = usage.input_tokens + usage.cache_read_input_tokens
if total_input > 0:
hit_rate = usage.cache_read_input_tokens / total_input * 100
print(f"キャッシュヒット率: {hit_rate:.1f}%")
🐛 注意点:計測値が常に0で返るバグ
これらの値が常に0で返る実装バグも報告されています。「安くならない」と悩む前に、まず計測値そのものが正しく取れているかを疑いましょう。値が取れていれば、どのブロックからキャッシュが崩れているかを差分で特定できるようになります。
11. トラブルシューティング:キャッシュが効かないときのチェックリスト
キャッシュが効かないときは、次の順で潰していくと効率的です。以下のチェックリストを上から順に確認してください。
📋 キャッシュ最適化チェックリスト
→ HTTPデバッグログで
cache_control の存在を確認。ここが死んでいると他は全部無駄
→
session_id をリクエストに含めているか確認
→ ツール定義のソート、動的値を後ろに配置、フィンガープリント正規化
→ DeepSeekに
cache_control を付けていないか。OpenAI系の自動キャッシュを過信していないか
→
cache_read_input_tokens が0固定のバグに引っかかっていないか
→ Sonnet系は1,024トークン以上、Opus/Haiku系は4,096トークン以上が必要
最初の1つが最も重要かつ最も見落とされがちです。設定画面上で「キャッシュ有効」になっていても安心せず、必ずリクエストボディの実物を確認するところから始めるのが定石です。
よくあるトラブル事例
❌ 事例1:「設定したのに安くならない」
原因:フレームワークが内部的にキャッシュ有効と判定していたが、OpenAI互換形式で送信されていたため cache_control が無視されていた。
解決:HTTPログを確認し、native形式で送信するよう設定を変更。
❌ 事例2:「最初は効くが途中から効かなくなる」
原因:MCPツールの列挙順が実行ごとにランダムだったため、ツール定義ブロックが変化しプレフィックスが崩壊。
解決:ツールをサーバー名→ツール名でソートしてから登録するよう修正。
❌ 事例3:「プロバイダAでは効くがBでは効かない」
原因:sticky routingが効いておらず、リクエストごとに異なるプロバイダに振り分けられていた。
解決:session_id をヘッダーに付与し、同一セッションが同じプロバイダにルーティングされるよう設定。
12. まとめ
プロンプトキャッシュは、従量課金時代のLLM運用でコストを一桁下げられる強力な武器です。要点を改めて整理します。
📌 プロンプトキャッシュ最適化の7つの鉄則
- 変わらないものを前、変わるものを後ろに配置する
- マーカーが実ペイロードに乗っているかを最優先で確認する
- session_idでプロバイダを固定し、キャッシュの分裂を防ぐ
- system+toolsは1時間、会話末尾は5分の2層TTLで組む
- ツール順序を固定し、動的値は境界の下に落とす
- モデルごとの仕組みの違いを踏まえて計測・比較する
- 最小トークン数とTTLの前提条件を満たす
高いAPI費用に悩んでいるなら、まずは自分の送信ペイロードを一度キャプチャして、cache_control が正しい場所に入っているかを確認してみてください。それだけで請求額が大きく変わることも珍しくありません。
プロンプトキャッシュの最適化は、一度設定してしまえば後は自動的にコスト削減が持続する「設定すればするほど得をする」タイプの施策です。ぜひ本記事のチェックリストをもとに、順番に確認を進めてみてください。
