はじめに
RAG(検索拡張生成)にプロンプトキャッシュを導入した際、「APIの cached_tokens(または cache_read_input_tokens)が常に 0 になる」という事象が頻発します。
本記事では、RAG環境でキャッシュヒット率が 0.0% になる3つの原因を解説します。あわせて、Claude・OpenAI・Gemini の3社で2ターン目以降のヒット率を94.8%〜96.0%に引き上げるPython実装コードを紹介します。
本記事の検証コードおよび再現用CLIツール一式は、以下のGitHubリポジトリで公開しています。
- 検証コードリポジトリ: ai-nexus-jp/rag-prompt-caching-benchmark
1. 前提環境と各社最小トークン閾値
プロンプトキャッシュが発動するためには、固定プレフィックス部分が各プロバイダの定める最小トークン数を超えている必要があります。
| プロバイダ | 使用Python SDK | 対象モデル例 | 最小トークン閾値 | キャッシュ読取割引率 |
|---|---|---|---|---|
| Anthropic |
anthropic (>=0.49.0) |
claude-sonnet-5-5 / claude-sonnet-4-6
|
512 〜 4,096 tok | 90% 〜 95% 割引 |
| OpenAI |
openai (>=1.66.0) |
gpt-6.1-sol / gpt-5 / gpt-4.1
|
1,024 tok(128刻み) | 50% 〜 95% 割引 |
| Google Gemini |
google-genai (>=1.5.0) |
gemini-3.8-flash / gemini-3.1-pro
|
1,024 〜 4,096 tok | 90% 割引 |
2. 原因①:プロンプト先頭への「動的要素」の混入(前方不一致)
3社すべてのプロンプトキャッシュは、「プロンプト先頭からのトークン完全一致(Prefix Match)」を条件としています。
そのため、先頭に「現在時刻(datetime.now())」「リクエストUUID」「毎ターン変わるRAG検索結果」を置くと、後ろの固定規定(数千トークン)は毎回100%キャッシュミスになります。
NGな組み立て順序(bad_prefix:ヒット率 0.0%)
# ❌ NG例:先頭に現在時刻や動的RAGチャンクを置くと、後続の固定規定がすべてキャッシュミスになる
def build_bad_prompt(static_manual: str, dynamic_rag_chunk: str, user_question: str) -> str:
return (
f"### 検索コンテキスト (取得時刻: {datetime.now().isoformat()})\n"
f"{dynamic_rag_chunk}\n\n"
f"### 全社共通規定マニュアル (約4,000トークン)\n"
f"{static_manual}\n\n"
f"### 質問\n{user_question}"
)
OKな組み立て順序(good_prefix:2ターン目以降ヒット率 95%超)
不変の共通規定やシステム指示を最前方に固定し、毎ターン変化するRAG検索結果や日時・ユーザー質問は必ず末尾へ配置します。
# ⭕ OK例:不変の固定マニュアルを先頭に固定し、動的要素をすべて後方へ分離する
def build_good_prompt(static_manual: str, dynamic_rag_chunk: str, user_question: str) -> str:
return (
f"### 全社共通規定マニュアル (約4,000トークン)\n"
f"{static_manual}\n\n"
f"### 検索コンテキスト (取得時刻: {datetime.now().isoformat()})\n"
f"{dynamic_rag_chunk}\n\n"
f"### 質問\n{user_question}"
)
3. 原因②:プロバイダ別のAPI呼び出し作法の不一致(3社別実装コード)
プロンプトの順序を good_prefix に揃えた上で、各プロバイダ固有のキャッシュパラメータを正しく指定する必要があります。
(A) Anthropic Claude:明示的 cache_control ブレークポイントの指定
Anthropicの messages.create では、content をブロック配列として渡します。そして先頭に配置した固定コンテキストブロックの末尾に "cache_control": {"type": "ephemeral"} を明示指定します。
import anthropic
client = anthropic.Anthropic()
def ask_claude_with_cache(
static_manual: str,
dynamic_rag_chunk: str,
user_question: str,
model: str = "claude-sonnet-5-5",
):
# 1. 固定規定ブロックを先頭に置き、ephemeral ブレークポイントを付与
content_blocks = [
{
"type": "text",
"text": static_manual,
"cache_control": {"type": "ephemeral"},
},
{
"type": "text",
"text": dynamic_rag_chunk,
},
{
"type": "text",
"text": f"### User Question\n{user_question}",
},
]
response = client.messages.create(
model=model,
max_tokens=256,
messages=[{"role": "user", "content": content_blocks}],
)
usage = response.usage
print(
f"Base Input: {usage.input_tokens}, "
f"Cache Write: {usage.cache_creation_input_tokens}, "
f"Cache Read (HIT): {usage.cache_read_input_tokens}"
)
return response
-
実測値(5ターン検証): Turn 1で
6,518 tokが書込された後、Turn 2〜5では毎回6,518 tok(95.8%)がヒットし、累計コストが -64.29% 削減されました。
(B) OpenAI:Chat Completions の罠と Responses API への移行
OpenAIで陥りやすいのが、従来の chat.completions.create に単一の user メッセージ文字列として全文を渡してしまうケースです。この方法では、先頭が一致していても毎回キャッシュ書込(cache_write_tokens)ばかりが発生し、読取ヒット(cached_tokens)が 0 になる現象が起きます。
確実にキャッシュヒットさせるには、Responses API(client.responses.create) を使用し、以下の3点を指定します。
-
instructionsに固定マニュアルを分離して渡す。 -
inputに動的RAGチャンクとユーザー質問を渡す。 -
prompt_cache_keyに固定のルーティング識別子を指定し、同一キャッシュ保持サーバへリクエストを誘導する。
import openai
client = openai.OpenAI()
def ask_openai_responses_with_cache(
static_manual: str,
dynamic_rag_chunk: str,
user_question: str,
model: str = "gpt-6.1-sol",
):
response = client.responses.create(
model=model,
instructions=static_manual, # 固定コンテキストを instructions に分離
input=f"{dynamic_rag_chunk}\n\n### User Question\n{user_question}",
prompt_cache_key=f"rag_manual_v1_{model}", # 固定キーでサーバアフィニティを維持
prompt_cache_retention="24h",
max_output_tokens=256,
)
usage = response.usage
details = getattr(usage, "input_tokens_details", None)
cached_tokens = getattr(details, "cached_tokens", 0) or 0
cache_write_tokens = getattr(details, "cache_write_tokens", 0) or 0
# OpenAIは input_tokens 総数に cached 分が含まれるため減算して通常入力分を算出
base_input_tokens = max(0, usage.input_tokens - cached_tokens - cache_write_tokens)
print(
f"Base Input: {base_input_tokens}, "
f"Cache Write: {cache_write_tokens}, "
f"Cache Read (HIT): {cached_tokens}"
)
return response
-
実測値(5ターン検証):
chat.completions.create単発結合ではヒット率が 0.0% でした。一方、responses.create+prompt_cache_keyでは2ターン目以降 96.0%(5,313 tok HIT) に改善し、累計コストも -68.43% 削減されました。
(C) Google Gemini:google-genai SDKでの Context Caching 制御
Google Gemini(google-genai SDK)には、自動で効く「暗黙的キャッシュ」と、client.caches.create で作成する「明示的キャッシュ」の2種類があります。
明示的キャッシュでは、時間比例の保管料(gemini-3.8-flash で $0.50 / 1M tok / 時間)が課金されます。そのため、短い ttl を設定した上で、セッション終了時に必ず caches.delete で破棄する実装が推奨されます。
from google import genai
from google.genai import types
client = genai.Client()
def run_gemini_session_with_cache(
static_manual: str,
turns_data: list[tuple[str, str]],
model: str = "gemini-3.8-flash",
):
# 1. 固定マニュアルに対して短期TTL(5分=300s)の明示的キャッシュを作成
cache_obj = client.caches.create(
model=model,
config=types.CreateCachedContentConfig(
display_name="rag_static_manual_cache",
contents=[static_manual],
ttl="300s",
),
)
try:
for dynamic_rag_chunk, user_question in turns_data:
# 2. キャッシュ名を指定し、差分(動的RAG + 質問)のみを送信
response = client.models.generate_content(
model=model,
contents=f"{dynamic_rag_chunk}\n\n### User Question\n{user_question}",
config=types.GenerateContentConfig(
cached_content=cache_obj.name,
max_output_tokens=256,
),
)
meta = response.usage_metadata
prompt_tokens = getattr(meta, "prompt_token_count", 0) or 0
cached_tokens = getattr(meta, "cached_content_token_count", 0) or 0
base_input = max(0, prompt_tokens - cached_tokens)
print(f"Base Input: {base_input}, Cache Read (HIT): {cached_tokens}")
finally:
# 3. 保管料の継続課金を防ぐため、セッション終了時に必ず削除
client.caches.delete(name=cache_obj.name)
4. 原因③:トークン集計時の「二重課金計算バグ」に注意
自前でコスト集計ロガーを実装する際は注意が必要です。プロバイダごとに、レスポンスの総入力トークン数に キャッシュヒット分が含まれるかという内訳仕様 が異なるためです。
| プロバイダ | 総入力フィールド | キャッシュ読取フィールド | 通常課金トークン(Base Input)の正しい算出式 |
|---|---|---|---|
| Anthropic | usage.input_tokens |
usage.cache_read_input_tokens |
usage.input_tokens(最初から分離されているため減算不要) |
| OpenAI |
usage.input_tokens (または prompt_tokens) |
input_tokens_details.cached_tokens |
max(0, input_tokens - cached_tokens - cache_write_tokens) |
| Google Gemini | usage_metadata.prompt_token_count |
usage_metadata.cached_content_token_count |
max(0, prompt_token_count - cached_content_token_count) |
OpenAIやGeminiで総入力数へそのまま通常単価を掛けると、割引されたはずのトークンを二重計上してしまうため注意してください。
5. よくあるトラブル・運用時の注意点チェックリスト
-
アクセス間隔が空く社内ボットでの「書込割増負け」:
- Anthropic(5分TTL:1.25倍、1時間TTL:2.0倍)やOpenAI Responses API(30分TTL:1.25倍、12時間TTL:2.5倍)には書込割増があります。TTL内に 最低2〜3回以上 ヒットしないと割増分が割引額を上回り、キャッシュOFF時より割高になります。
-
Geminiでの小規模プロンプト長期保持:
- 4,000トークン程度の規定をGeminiの明示的キャッシュで週末72時間保持すると、Cold再計算の100倍以上の保管料が発生します。夜間や休日は自然失効させるか、明示的に
deleteしてください。
- 4,000トークン程度の規定をGeminiの明示的キャッシュで週末72時間保持すると、Cold再計算の100倍以上の保管料が発生します。夜間や休日は自然失効させるか、明示的に
-
手元での再現・ベンチマーク測定:
- 自分のプロンプトデータで3社のヒット率と実コストを一括比較したい場合は、公開リポジトリのCLIツールで即座に検証できます。
git clone https://github.com/ai-nexus-jp/rag-prompt-caching-benchmark.git
cd rag-prompt-caching-benchmark
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # APIキーを設定
# 3社一括で bad_prefix vs good_prefix を5ターン実測しレポート生成
python3 -m benchmark.cli --provider all --turns 5
関連記事・お問い合わせ
-
開発背景・アーキテクチャの解説:
本ツールの設計思想や、プロンプトキャッシュの損益分岐点・JITウォームアップ設計などの詳細は Zennの解説記事 にまとめています。 -
業務導入・受託開発のご相談:
AI Nexusでは、業務効率化ツールの受託開発やAI導入コンサルティングを行っています。詳細は AI Nexus 公式サイト をご覧ください。



