Gemini 2.5 Pro を個人プロジェクトで使い始めて、最初の請求書を見たとき少し驚きました。ドキュメントに「implicit caching で最大75%削減」と書いてあったので期待していたのですが、そのメリットをほとんど享受できていなかったのです。
調べてみると、原因はシンプルでした。キャッシュが機能するための前提条件を、いくつか満たしていなかったのです。同じ経験をしている方は多いと思いますので、実際の対処法を整理してお伝えします。
implicit caching とは(短く振り返り)
Gemini 2.5 Pro と 2.5 Flash では、同じプロンプトの先頭部分が繰り返し送信される場合に自動でキャッシュされ、入力トークンの料金が割引になる仕組みが導入されています。明示的に CachedContent を作成する explicit caching とは異なり、API 側が自動で処理するため「implicit(暗黙の)caching」と呼ばれます。
問題は、この自動化の裏に いくつかの前提条件 があり、それを満たさないとキャッシュが全くヒットしない点です。
よくある問題1: キャッシュ対応トークン数に達していない
最も多い原因はこれです。Gemini 2.5 Pro の implicit caching は、入力トークンが一定数(2026年5月時点で約1,024トークン)を超えた場合のみ有効になります。
import google.genai as genai
client = genai.Client()
# このような短いプロンプトはキャッシュされません
response = client.models.generate_content(
model="gemini-2.5-pro",
contents="Pythonでフィボナッチ数列を実装してください"
)
# usage_metadata でキャッシュヒットを確認
meta = response.usage_metadata
print(f"入力トークン: {meta.prompt_token_count}")
print(f"キャッシュヒット: {meta.cached_content_token_count}")
# cached_content_token_count: 0
# 長いシステム指示 + 繰り返し呼び出しでキャッシュが効きます
SYSTEM_PROMPT = """
あなたはシニアPythonエンジニアです。以下のコーディング規約に従ってください:
- PEP 8 準拠
- 型ヒントを必ず付ける
- docstring は Google スタイル
- エラーハンドリングは明示的に
(数千トークンの詳細な指示...)
"""
response = client.models.generate_content(
model="gemini-2.5-pro",
contents=[
{"role": "user", "parts": [{"text": SYSTEM_PROMPT + "\n\nPythonでフィボナッチ数列を実装してください"}]}
]
)
meta = response.usage_metadata
print(f"キャッシュヒット: {meta.cached_content_token_count}")
# 2回目以降: cached_content_token_count が正の値になります
usage_metadata.prompt_token_count を確認して、キャッシュ対象になるほどの長さかチェックしてください。短いプロンプトの繰り返しには implicit caching は効果がありません。
よくある問題2: キャッシュ可能な部分が先頭にない
Implicit caching はプロンプトの先頭から連続した一致部分をキャッシュします。途中から変わる部分(ユーザーの質問など)より後ろに配置された固定コンテンツはキャッシュされません。
# 問題のある構成: システム指示を後ろに置いている
contents_bad = [
{"role": "user", "parts": [{"text": user_question}]}, # 毎回変わる
{"role": "system", "parts": [{"text": LARGE_SYSTEM_PROMPT}]} # 固定だが後ろ
]
# 正しい構成: 固定コンテンツを先頭に置く
contents_good = [
{"role": "system", "parts": [{"text": LARGE_SYSTEM_PROMPT}]}, # 先頭・固定
{"role": "user", "parts": [{"text": user_question}]} # 後ろ・可変
]
マルチターン会話でも同様の原則が適用されます。
def build_contents(system_prompt: str, history: list, new_message: str) -> list:
"""会話履歴を正しく構成する"""
contents = [
{"role": "system", "parts": [{"text": system_prompt}]}, # 先頭固定
]
for turn in history:
contents.append(turn)
contents.append({"role": "user", "parts": [{"text": new_message}]})
return contents
よくある問題3: モデルバージョンが一致していない
gemini-2.5-pro と gemini-2.5-pro-latest は別のエンドポイントとして扱われ、キャッシュは共有されません。同一のシステム指示を使っていても、呼び出しごとにモデルバージョンが変わると、キャッシュが積み上がりません。
# 問題: バージョンがリクエストごとにブレている
if condition_a:
model = "gemini-2.5-pro-preview-05-06"
else:
model = "gemini-2.5-pro" # 別のキャッシュ領域になります
# 対策: モデルを定数化して統一する
GEMINI_MODEL = "gemini-2.5-pro" # 一箇所で管理
response = client.models.generate_content(
model=GEMINI_MODEL,
contents=contents
)
特に環境変数やフィーチャーフラグでモデルを切り替えている場合は注意が必要です。本番とステージングで別モデルを使っていると、それぞれでキャッシュが別々に作られます。
よくある問題4: キャッシュヒット率の確認方法がわからない
キャッシュが効いているかどうかは usage_metadata を見れば確認できます。この確認を怠ると、改善されているのかどうかが把握できません。
response = client.models.generate_content(
model="gemini-2.5-pro",
contents=contents
)
meta = response.usage_metadata
total_input = meta.prompt_token_count
cached = meta.cached_content_token_count
non_cached = total_input - cached
print(f"総入力トークン: {total_input:,}")
print(f"キャッシュヒット: {cached:,} ({cached/total_input*100:.1f}%)")
print(f"キャッシュミス: {non_cached:,}")
# コスト差を概算(2026年5月時点の料金)
# Gemini 2.5 Pro: 通常 $1.25/1M tokens、キャッシュ $0.31/1M tokens
NORMAL_RATE = 1.25 / 1_000_000
CACHE_RATE = 0.31 / 1_000_000
cost_with_cache = (non_cached * NORMAL_RATE) + (cached * CACHE_RATE)
cost_without_cache = total_input * NORMAL_RATE
print(f"推定コスト(キャッシュあり): ${cost_with_cache:.6f}")
print(f"推定コスト(キャッシュなし): ${cost_without_cache:.6f}")
この数値をログに記録するだけで、プロンプト設計の改善効果を定量的に把握できます。
よくある問題5: TTL(有効期限)が切れている
Implicit caching のキャッシュは一定時間(デフォルト1時間程度)で無効になります。バッチ処理で大量のリクエストを送る場合に、途中でキャッシュが切れてコストが跳ね上がることがあります。
def process_batch_with_cache_monitoring(
items: list[str],
system_prompt: str,
model: str = "gemini-2.5-pro"
) -> list[str]:
"""バッチ処理でキャッシュ状況を監視する"""
results = []
consecutive_cache_miss = 0
for i, item in enumerate(items):
contents = [
{"role": "system", "parts": [{"text": system_prompt}]},
{"role": "user", "parts": [{"text": item}]}
]
response = client.models.generate_content(model=model, contents=contents)
meta = response.usage_metadata
if meta.cached_content_token_count == 0 and i > 0:
consecutive_cache_miss += 1
if consecutive_cache_miss >= 3:
# 3回連続でキャッシュミスならTTL切れの可能性が高い
print(f"⚠️ Item {i}: キャッシュが切れた可能性があります(TTL超過)")
consecutive_cache_miss = 0
else:
consecutive_cache_miss = 0
results.append(response.text)
return results
長時間のバッチ処理では、TTL 切れ後の最初のリクエストだけ通常料金になります。その後は再キャッシュされますので、コスト増のインパクトは限定的です。
よくある問題6: マルチモーダルコンテンツがキャッシュされない
画像や PDF を含むプロンプトでも implicit caching は機能しますが、ファイルの参照方法によって結果が変わります。
from google.genai import types
# Files API でアップロードしたファイルの URI 参照を使うと安定する
file = client.files.upload(path="reference_document.pdf")
# file.uri が固定 URL として返されます
contents = [
{"role": "system", "parts": [
types.Part.from_uri(file_uri=file.uri, mime_type="application/pdf"),
{"text": "上記のPDFに関する質問に答えてください。"}
]},
{"role": "user", "parts": [{"text": user_question}]}
]
response = client.models.generate_content(model="gemini-2.5-pro", contents=contents)
print(f"キャッシュヒット: {response.usage_metadata.cached_content_token_count}")
inline_data(バイト直接渡し)でも動作しますが、毎回同一バイト列を渡す必要があります。ファイルを Files API 経由でアップロードしておくと、URI が固定されてキャッシュが安定してヒットします。
本番でキャッシュヒット率を継続監視する
単発の usage_metadata 確認は最初の一歩としては十分ですが、実運用では「先週まで効いていたキャッシュが、いつの間にか効かなくなっている」という静かな劣化が一番こわい問題です。私自身、個人開発で複数のサイトに同じ生成パイプラインを使い回しているため、どこか一箇所のプロンプトを直した拍子に、別サイトのヒット率だけが落ちていた、という経験があります。
そこで、リクエストごとの結果を集計し、直近のヒット率と「キャッシュなし換算でいくら損したか」をモデル別に出す小さな監視を挟んでいます。
from collections import defaultdict, deque
from dataclasses import dataclass, field
NORMAL_RATE = 1.25 / 1_000_000 # Gemini 2.5 Pro 通常単価(2026年6月時点)
CACHE_RATE = 0.31 / 1_000_000 # キャッシュ単価
@dataclass
class CacheMonitor:
window: int = 200
# モデル別に直近 window 件のヒット率を保持する
_recent: dict = field(default_factory=lambda: defaultdict(lambda: deque(maxlen=200)))
def record(self, model: str, meta) -> dict:
total = meta.prompt_token_count or 0
cached = meta.cached_content_token_count or 0
ratio = cached / total if total else 0.0
self._recent[model].append(ratio)
# キャッシュが効いていれば浮いたはずの差額
saved = cached * (NORMAL_RATE - CACHE_RATE)
return {"model": model, "ratio": ratio, "saved_usd": saved}
def hit_rate(self, model: str) -> float:
win = self._recent[model]
return sum(win) / len(win) if win else 0.0
def regressed(self, model: str, floor: float = 0.4) -> bool:
# 直近ウィンドウが十分たまってから判定する(初動の誤検知を避ける)
win = self._recent[model]
return len(win) >= self.window and self.hit_rate(model) < floor
呼び出し側ではこう使います。
monitor = CacheMonitor()
for item in items:
response = client.models.generate_content(model=GEMINI_MODEL, contents=build_contents(item))
stat = monitor.record(GEMINI_MODEL, response.usage_metadata)
if monitor.regressed(GEMINI_MODEL):
# ログに残し、必要なら通知へ回す(ここで握りつぶさない)
print(f"⚠️ {GEMINI_MODEL} の暗黙キャッシュ ヒット率が低下: {monitor.hit_rate(GEMINI_MODEL):.1%}")
ヒット率を「率」で持っておくと、回帰の検知がぐっと楽になります。たとえば 2026年6月に Gemini 3.5 Flash が一般提供となり既定モデルが更新される局面では、モデル名を固定していないコードのヒット率だけが静かに落ちます。金額ではなく率を監視していると、こうした既定値の変化を早い段階で異常として拾えます。
「固定に見えて固定でない」プレフィックスを潰す
問題2でプレフィックスを先頭に置く話をしましたが、実際にハマるのはもう一歩奥です。先頭に置いたつもりの固定部分が、毎回ほんの少しだけ違っているケースが、ヒット率を地味に削ります。暗黙キャッシュは先頭からの連続一致で判定されるため、プレフィックスの中に一文字でも可変要素が混ざると、そこから後ろは丸ごとキャッシュ対象外になります。
私が実際にやってしまった典型が、システム指示の中に現在時刻を埋め込んでいた例です。
from datetime import datetime
import json
# Before: 固定のつもりが、毎回変わってしまうプレフィックス
def build_system_prompt_bad(rules: dict) -> str:
return (
f"現在時刻: {datetime.now().isoformat()}\n" # 毎回変わる → 以降すべてキャッシュ外
f"設定: {json.dumps(rules, ensure_ascii=False)}\n" # dict の順序揺れでも一致が崩れる
"あなたはシニアPythonエンジニアです。以下の規約に従ってください。"
)
このプレフィックスは、見た目は固定でも実際には毎回バイト列が変わるため、暗黙キャッシュはまずヒットしません。対策は「プレフィックスを決定的(deterministic)にする」ことです。
# After: 可変要素を後ろへ追い出し、固定部分を決定的にする
def build_system_prompt_good(rules: dict) -> str:
# キーをソートして JSON の順序揺れをなくす
settings = json.dumps(rules, ensure_ascii=False, sort_keys=True)
return (
"あなたはシニアPythonエンジニアです。以下の規約に従ってください。\n"
f"設定: {settings}" # ソート済みなので毎回同一
)
def build_contents(system_prompt: str, user_question: str, now_iso: str) -> list:
# 時刻のような可変情報は user 側(後ろ)へ。先頭の system は不変に保つ
return [
{"role": "system", "parts": [{"text": system_prompt}]},
{"role": "user", "parts": [{"text": f"参照時刻: {now_iso}\n{user_question}"}]},
]
プレフィックスを壊しがちな可変要素は、だいたい次の3つに集約されます。
| 混入しやすい可変要素 | 症状 | 対策 |
| タイムスタンプ・乱数・UUID | 毎回バイト列が変わり全くヒットしない | 可変情報は user パート(後ろ)へ移す |
| dict / set のシリアライズ順 | 内容は同じなのに順序揺れで一致が崩れる | json.dumps(..., sort_keys=True) で決定的に |
| ロケール依存の数値・日付整形 | 環境差でプレフィックスが微妙に変わる | 整形はプレフィックス外で行い、固定文字列のみ前置 |
ヒット率が「ゼロではないが妙に低い」ときは、まずプレフィックスを実際にバイト列として2回ぶん書き出し、diff を取ってみてください。目では同じに見えても、差分が出ることがあります。
implicit から explicit caching へ切り替える判断
暗黙キャッシュは設定不要で手軽ですが、「確実に効かせたい」「巨大な固定コーパスを何度も参照する」用途では、明示的な CachedContent(explicit caching)のほうが向いています。暗黙キャッシュはあくまでベストエフォートで、ヒットするかどうかを保証しないためです。
判断の目安を表にまとめます。
| 状況 | 向いている方式 | 理由 |
| 短〜中程度のプロンプトを散発的に呼ぶ | implicit | 設定不要。TTL 管理も不要で運用が軽い |
| 数万トークンの固定資料を高頻度で参照 | explicit | 確実にヒットし、TTL を自分で延長できる |
| 夜間バッチで同一コンテキストを連続使用 | explicit | 暗黙キャッシュの TTL 切れに左右されない |
| プレフィックスが頻繁に変わる対話用途 | implicit | 固定化できないので明示キャッシュの利点が薄い |
explicit へ切り替える場合は、固定コーパスを一度だけキャッシュ化し、TTL を処理時間に合わせて設定します。
from google.genai import types
# 固定の参照資料を明示的にキャッシュする
cache = client.caches.create(
model="gemini-2.5-pro",
config=types.CreateCachedContentConfig(
system_instruction="あなたは社内ドキュメントの回答アシスタントです。",
contents=[large_reference_corpus], # 数万トークンの固定資料
ttl="3600s", # バッチ全体が終わるまで十分な長さに
),
)
# 以降のリクエストはキャッシュ名を指すだけで確実にヒットする
response = client.models.generate_content(
model="gemini-2.5-pro",
contents=[user_question],
config=types.GenerateContentConfig(cached_content=cache.name),
)
print(f"キャッシュヒット: {response.usage_metadata.cached_content_token_count}")
explicit caching はキャッシュ保持自体にもストレージ料金がかかります。「散発的な呼び出しなら implicit、高頻度・大容量・確実性が要るなら explicit」という線引きで考えると、無駄なく使い分けられます。個人開発での私の運用でも、対話系は implicit に任せ、夜間の一括分類のように同じ資料を何百回も参照する処理だけ explicit に寄せています。
全体を振り返って: 原因チェックリスト
キャッシュが効いていないと感じたら、以下を順番に確認してみてください。
- 入力トークン数が1,024以上か? → 短すぎるとキャッシュ対象外です
- 固定コンテンツがプロンプト先頭にあるか? → 後ろに置くとヒットしません
- モデル名が全リクエストで統一されているか? → バージョン違いはキャッシュを分断します
usage_metadata.cached_content_token_count を確認しているか? → 数値を見ないと改善できません
- バッチ処理で1時間以上経過していないか? → TTL 切れでキャッシュが失われます
- マルチモーダルは Files API 経由の URI か? → inline_data より URI 参照が安定します
usage_metadata.cached_content_token_count を定期的にログに記録して、実際のキャッシュヒット率を把握するところから始めてみてください。数値が見えると、どこを改善すればよいかが自然と分かってきます。