アーティスト・クリエイターの廣川政樹(@dolice)です。2014 年から個人で iPhone / Android アプリを作り、累計 5,000 万 DL の壁紙・癒し・引き寄せ系アプリ群を運営しています。広告収益は主に AdMob で、月単位の API 費用は売上から直接引かれる経費です。だから「Gemini API の月額費用」というテーマは、私にとっては書類上の最適化ではなく、夕食の予算を決めるのと同じ重さで向き合うものになっています。
きっかけはささやかなものでした。壁紙アプリに「画像の雰囲気から短い詩を生成する」機能を入れた最初の月、Cloud Billing のメールに $412 と書かれていて、AdMob の月次レポートを開きながら少し息を止めました。広告収益は黒字でしたが、Gemini に支払う API 費用が想定の 3 倍になっていて、原因の大半はシステムプロンプトとガイドラインを毎リクエストで送っていたことでした。Context Caching と Implicit Caching を真剣に学び始めたのはそこからです。
ここでは、その後 6 ヶ月の本番運用で身についた「キャッシュをコスト削減装置として動かす」設計を、動くコードと実測値で整理します。1997 年にインターネットで独学を始めたころから「コードは静かに費用と時間を節約してくれる道具だ」と思ってきましたが、Gemini API のキャッシュもまさにその系譜にある仕組みだと感じています。
Gemini API の料金構造とキャッシュが効く理由
まず、なぜキャッシュがこれほど重要なのかを理解するために、Gemini API の料金構造を整理します。
料金は大きく「入力トークン」「出力トークン」「キャッシュ保存料」の3種類に分かれています。注目すべきは、入力トークンは毎回のリクエストで課金されるという点です。
たとえば、10,000 トークンのシステムプロンプトを持つカスタマーサポートボットが 1 日 1,000 回のリクエストを受ける場合、システムプロンプトだけで 1 日 1,000 万トークンが課金対象になります。1 ヶ月では 3 億トークン。これが gemini-2.5-pro の料金(〜200K トークンで $1.25 / 100 万)で計算すると、システムプロンプト分だけで月 $375 ほどかかる計算になります。
ここにキャッシュを適用すると話が変わります。
- Context Caching: 大きなコンテンツを事前にキャッシュし、後続リクエストでそのキャッシュを参照します。キャッシュされたトークンはフル料金の約 75% 割引で請求されます
- Implicit Caching: 同じプロンプトプレフィックスが繰り返されると、自動的に割引料金が適用されます(gemini-2.0-flash 以降対応)
この 2 つを使い分けること、そして組み合わせることが、API 費用を根本から削減するカギです。
キャッシュが特に効果的なコンテンツ
どんなコンテンツでもキャッシュが効くわけではありません。効果が大きいのは「繰り返し使われる固定コンテキスト」です:
- 長いシステムプロンプト(5,000 トークン超の仕様書・指示書)
- 大きなドキュメント(製品マニュアル、API 仕様書、法律文書)
- 画像・動画ファイル(マルチモーダルな参照コンテキスト)
- コードベース全体(コードレビューや質問応答に使う参照コード)
逆に、毎回内容が変わるユーザーメッセージ部分はキャッシュの対象になりません。この「固定部分」と「変動部分」を意識してプロンプトを設計することが、キャッシュ効率を最大化する第一歩です。
私の壁紙アプリの場合は、「アプリの世界観・トーン・禁則語リスト・出力フォーマット指示」を合わせて約 12,400 トークンの固定コンテキストにまとめてあります。これがキャッシュ側、ユーザーメッセージとして送るのは「画像の特徴量と数行の指示」だけ。この設計に切り替えてから、後述の通り月額費用が $281 → $96 まで下がりました。
詳細なコスト最適化の考え方については、Gemini API コスト最適化完全ガイドも参考になります。
Context Caching の仕組みと実装
Context Caching は、コンテンツを明示的にキャッシュする API です。一度キャッシュを作成して ID を取得し、後続のリクエストでその ID を参照することで割引料金が適用されます。
Context Caching が使える条件(まずここを確認)
Context Caching にはいくつかの制限があります。実装前に必ず確認してください:
- 最小トークン数: キャッシュするコンテンツが一定数以上のトークンを持つこと(
gemini-2.5-pro の場合は 2,048 トークン以上)
- 対応モデル: すべてのモデルが対応しているわけではありません。
gemini-2.0-flash、gemini-2.5-pro、gemini-2.5-flash などが対応
- キャッシュの有効期限: デフォルトは 1 時間。最大 24 時間まで延長可能
「最小トークン数」の条件は見落としがちで、これを知らないまま短いプロンプトをキャッシュしようとすると BadRequestError が返ります。
Context Cache の作成(基本実装)
以下は、長いシステムプロンプトをキャッシュする基本的な実装です。
import google.generativeai as genai
from google.generativeai import caching
import datetime
import time
# APIキーの設定(実際は Environment Variable から取得)
genai.configure(api_key="YOUR_GEMINI_API_KEY")
# キャッシュするコンテンツ(長いシステムプロンプトの例)
SYSTEM_DOCUMENT = """
あなたは高度なカスタマーサポート AI です。以下のルールと知識ベースに基づいて回答してください。
[製品ドキュメント: 10,000 文字以上の詳細な仕様書]
--- 製品仕様 ---
製品名: ExampleApp Pro
バージョン: 3.2.1
... (長い内容が続く) ...
""" # 実際には 2,048 トークン以上になるコンテンツを使用
def create_cache() -> caching.CachedContent:
"""Context Cache を作成し、キャッシュオブジェクトを返す"""
try:
cached_content = caching.CachedContent.create(
model="models/gemini-2.5-pro",
display_name="customer-support-system-prompt", # 後で検索しやすい名前を付ける
system_instruction=SYSTEM_DOCUMENT,
ttl=datetime.timedelta(hours=12), # 12 時間キャッシュを保持
)
print(f"✅ キャッシュ作成成功: {cached_content.name}")
print(f" 有効期限: {cached_content.expire_time}")
print(f" 使用トークン数: {cached_content.usage_metadata.total_token_count}")
return cached_content
except Exception as e:
print(f"❌ キャッシュ作成失敗: {e}")
raise
def chat_with_cache(cached_content: caching.CachedContent, user_message: str) -> str:
"""キャッシュを参照しながらチャットリクエストを送信する"""
try:
# キャッシュからモデルを作成(これが Context Caching を有効にするポイント)
model = genai.GenerativeModel.from_cached_content(cached_content)
response = model.generate_content(user_message)
# 使用トークン数を確認(キャッシュが効いているかここで検証)
usage = response.usage_metadata
print(f" キャッシュトークン: {usage.cached_content_token_count}")
print(f" 入力トークン(合計): {usage.prompt_token_count}")
print(f" 出力トークン: {usage.candidates_token_count}")
return response.text
except Exception as e:
print(f"❌ リクエスト失敗: {e}")
raise
# 使用例
if __name__ == "__main__":
# キャッシュを 1 回だけ作成
cache = create_cache()
# 同じキャッシュを複数のリクエストで再利用(ここでコスト削減)
questions = [
"返品ポリシーを教えてください",
"製品の保証期間はどれくらいですか?",
"サポートの営業時間を教えてください",
]
for question in questions:
print(f"\n❓ {question}")
answer = chat_with_cache(cache, question)
print(f"💬 {answer[:100]}...")
time.sleep(1) # レート制限対策
このコードで特に注目してほしいのは、response.usage_metadata.cached_content_token_count を確認している部分です。この値が 0 の場合はキャッシュが参照されていないため、設定に問題がある可能性があります。本番環境では必ずこの値をモニタリングしてください。
Context Cache の管理(一覧・更新・削除)
本番環境ではキャッシュのライフサイクルを適切に管理する必要があります。以下のマネージャークラスを使うと、キャッシュの検索・更新・削除を安全に扱えます。
import google.generativeai as genai
from google.generativeai import caching
import datetime
from typing import Optional
genai.configure(api_key="YOUR_GEMINI_API_KEY")
class CacheManager:
"""Context Cache の CRUD 操作をまとめたマネージャークラス"""
@staticmethod
def list_caches() -> list:
"""現在アクティブなキャッシュ一覧を取得"""
caches = list(caching.CachedContent.list())
print(f"アクティブなキャッシュ数: {len(caches)}")
for c in caches:
now = datetime.datetime.now(datetime.timezone.utc)
remaining = (c.expire_time - now).total_seconds()
print(f" - {c.display_name}: 残り{remaining/3600:.1f}時間")
return caches
@staticmethod
def get_cache_by_name(display_name: str) -> Optional[caching.CachedContent]:
"""名前でキャッシュを検索して取得(プロセス再起動後の再利用に重要)"""
for c in caching.CachedContent.list():
if c.display_name == display_name:
now = datetime.datetime.now(datetime.timezone.utc)
remaining_hours = (c.expire_time - now).total_seconds() / 3600
if remaining_hours > 0.5: # 30 分以上残っている場合のみ使用
return c
return None
@staticmethod
def extend_cache_ttl(cache: caching.CachedContent, hours: int = 24):
"""キャッシュの有効期限を延長する"""
try:
cache.update(ttl=datetime.timedelta(hours=hours))
print(f"✅ TTL 更新: {cache.display_name} → +{hours}時間")
except Exception as e:
print(f"❌ TTL 更新失敗: {e}")
@staticmethod
def delete_cache(cache: caching.CachedContent):
"""キャッシュを削除する(保存コストを止めるため、不要になったら削除)"""
try:
display_name = cache.display_name
cache.delete()
print(f"🗑️ キャッシュ削除: {display_name}")
except Exception as e:
print(f"❌ 削除失敗: {e}")
get_cache_by_name が特に重要です。サーバーを再起動するたびに新しいキャッシュを作ってしまうと、古いキャッシュの保存コストが積み重なります。名前で既存キャッシュを検索して再利用することで、この問題を防げます。
Context Caching のコスト計算(実例)
具体的な数値で節約効果を見てみましょう。
条件: gemini-2.5-pro、システムプロンプト 10,000 トークン、1 日 1,000 リクエスト
キャッシュなし(月額):
- 入力トークン: 10,000 × 1,000 × 30 = 3 億トークン
- 推定コスト: $375 / 月
Context Caching あり(月額):
- キャッシュ作成・保存料(12 時間 TTL、日 1 回更新): 約 $24 / 月
- キャッシュ参照料(フル料金の約 75% 割引): 約 $95 / 月
- 合計: 約 $119 / 月(節約率: 約 68%)
この試算は、実際にシステムプロンプトが毎リクエストで送信されている場合の効果です。料金の正確な数値は Google AI料金ページ で確認してください。
Implicit Caching の動作原理
Implicit Caching は Context Caching よりも新しい仕組みで、ユーザーが何も設定しなくても自動的に適用されるキャッシュです。gemini-2.0-flash や gemini-2.5-flash などのモデルで有効です。
Implicit Caching がヒットする条件
Implicit Caching が適用されるのは、以下の条件が揃ったときです:
- 同一プロンプトプレフィックス: リクエストの先頭部分が過去のリクエストと一致する
- 十分なトークン数: キャッシュ対象のプレフィックスが一定のトークン数以上ある
- インフラによる判断: Google のサーバー側でキャッシュが残っている場合のみ
重要なのは、Implicit Caching はキャッシュヒットを保証しないという点です。ヒットすれば割引料金になりますが、ヒットしなければ通常料金です。確実なコスト削減が必要な場合は Context Caching を使い、Implicit Caching はあくまで「ボーナス」として捉えると設計がシンプルになります。
Gemini API Implicit Caching の詳細では、このキャッシュ単体の挙動についてより詳しく解説しています。
Implicit Caching のヒット率を上げるプロンプト設計
設定は不要ですが、プロンプトの構造を工夫することでヒット率を大きく変えられます。
import google.generativeai as genai
genai.configure(api_key="YOUR_GEMINI_API_KEY")
SYSTEM_DOC = "ここに長い固定コンテンツが入ります..." # 実際には数千トークン以上
# ❌ 悪い例: 変動する内容が先頭にある
def bad_prompt_structure(user_message: str) -> str:
"""ユーザーメッセージが先頭にあるため、Implicit Caching が効かない"""
prompt = f"{user_message}\n\n以下のドキュメントを参照してください:\n{SYSTEM_DOC}"
model = genai.GenerativeModel("gemini-2.0-flash")
response = model.generate_content(prompt)
return response.text
# ✅ 良い例: 固定コンテンツを先頭に、変動コンテンツを末尾に
def good_prompt_structure(user_message: str) -> str:
"""固定部分が先頭に来るため、Implicit Caching のヒット率が上がる"""
prompt = f"""以下のドキュメントを参考に、ユーザーの質問に回答してください。
===ドキュメント開始===
{SYSTEM_DOC}
===ドキュメント終了===
ユーザーの質問: {user_message}"""
model = genai.GenerativeModel("gemini-2.0-flash")
response = model.generate_content(prompt)
# キャッシュヒットを確認
usage = response.usage_metadata
cached = getattr(usage, "cached_content_token_count", 0)
if cached > 0:
print(f"🎯 Implicit Cache ヒット: {cached}トークン節約")
else:
print("💡 キャッシュミス(通常料金)")
return response.text
# ✅ 最も効果的: system_instruction を使う
def use_system_instruction(user_message: str) -> str:
"""system_instruction に固定コンテンツを置くことで最もヒット率が高まる"""
model = genai.GenerativeModel(
"gemini-2.0-flash",
system_instruction=SYSTEM_DOC, # 固定コンテンツは system_instruction に
)
response = model.generate_content(user_message)
usage = response.usage_metadata
cached = getattr(usage, "cached_content_token_count", 0)
print(f"入力: {usage.prompt_token_count} / キャッシュ: {cached}")
return response.text
Gemini API のキャッシュはプレフィックスマッチングで動作します。プロンプトの先頭から一致する部分が長いほどヒット率が上がるため、固定コンテンツを先頭に、変動コンテンツを末尾に配置するのが基本原則です。
Context Caching と Implicit Caching の使い分け
この 2 つをどちらで使うべきか判断する際の基準をまとめます。
Context Caching が向いている場面:
- システムプロンプトが 5,000 トークン以上と長い
- 同じコンテキストを数時間〜1 日継続的に使う
- コスト削減効果を確実に計測したい
- キャッシュのヒットを保証したい(毎リクエストで必ずキャッシュを参照させたい)
Implicit Caching が向いている場面:
- 対応モデル(gemini-2.0-flash 等)を使っている
- キャッシュ管理の手間を省きたい
- ヒットしなくても問題ない(あくまでボーナス扱い)
- 短期間に同じようなプロンプトを大量送信する
組み合わせて使う場面:
最も効果的なのは、Context Caching を明示的に設定しつつ、プロンプトの構造も Implicit Caching が効きやすいように設計することです。この 2 段構えのアプローチを次のセクションで実装します。
統合設計パターン:本番キャッシュアーキテクチャ
以下は、両方のキャッシュを組み合わせた本番グレードの実装です。スレッドセーフな設計、自動再作成、フォールバック機能を備えています。
import google.generativeai as genai
from google.generativeai import caching
import datetime
import threading
from typing import Optional
import logging
genai.configure(api_key="YOUR_GEMINI_API_KEY")
logger = logging.getLogger(__name__)
class ProductionCacheStrategy:
"""
本番環境向けのキャッシュ管理クラス。
Context Caching と Implicit Caching を組み合わせてコストを最小化する。
"""
def __init__(
self,
model_name: str = "models/gemini-2.5-pro",
system_prompt: str = "",
cache_ttl_hours: int = 12,
min_token_threshold: int = 2048, # Context Caching を使う最小トークン数
cache_display_name: str = "prod-cache",
):
self.model_name = model_name
self.system_prompt = system_prompt
self.cache_ttl_hours = cache_ttl_hours
self.min_token_threshold = min_token_threshold
self.cache_display_name = cache_display_name
self._cache: Optional[caching.CachedContent] = None
self._lock = threading.Lock() # 複数スレッドからの同時アクセスを防ぐ
self._request_count = 0
self._cache_hit_count = 0
def _estimate_tokens(self, text: str) -> int:
"""トークン数の概算(概算値。正確には model.count_tokens() を使うこと)"""
return len(text) // 3 # 日英混在の場合の大まかな目安
def _should_use_context_cache(self) -> bool:
"""Context Caching を使うべきか判定"""
estimated = self._estimate_tokens(self.system_prompt)
return estimated >= self.min_token_threshold
def _get_or_create_cache(self) -> Optional[caching.CachedContent]:
"""キャッシュを取得(なければ作成)するスレッドセーフなメソッド"""
with self._lock:
# 既存キャッシュの有効期限を確認
if self._cache is not None:
now = datetime.datetime.now(datetime.timezone.utc)
remaining_sec = (self._cache.expire_time - now).total_seconds()
if remaining_sec > 300: # 5 分以上残っていれば使用
return self._cache
logger.info("キャッシュが期限切れ間近 — 再作成します")
# Context Caching が適切か判定
if not self._should_use_context_cache():
logger.info("システムプロンプトが短いため Context Caching をスキップ(Implicit Caching で代替)")
return None
# まず既存キャッシュを名前で検索(プロセス再起動後の再利用)
for existing in caching.CachedContent.list():
if existing.display_name == self.cache_display_name:
now = datetime.datetime.now(datetime.timezone.utc)
remaining_h = (existing.expire_time - now).total_seconds() / 3600
if remaining_h > 0.5:
logger.info(f"既存キャッシュを再利用: {existing.name}(残り{remaining_h:.1f}h)")
self._cache = existing
return self._cache
# 新規作成
try:
self._cache = caching.CachedContent.create(
model=self.model_name,
display_name=self.cache_display_name,
system_instruction=self.system_prompt,
ttl=datetime.timedelta(hours=self.cache_ttl_hours),
)
logger.info(f"新規キャッシュ作成: {self._cache.name}")
return self._cache
except Exception as e:
logger.error(f"キャッシュ作成失敗(Implicit Caching にフォールバック): {e}")
return None
def generate(self, user_message: str) -> dict:
"""
キャッシュを活用してテキスト生成を実行する。
Returns:
dict: {"text", "cache_hit", "cached_tokens", "input_tokens", "output_tokens"}
"""
self._request_count += 1
cache = self._get_or_create_cache()
try:
if cache is not None:
# Context Cache を明示的に参照するリクエスト
model = genai.GenerativeModel.from_cached_content(cache)
response = model.generate_content(user_message)
else:
# Context Cache なし(Implicit Caching が自動適用される可能性あり)
# system_instruction を使うことで Implicit Caching のヒット率を上げる
model = genai.GenerativeModel(
self.model_name,
system_instruction=self.system_prompt,
)
response = model.generate_content(user_message)
usage = response.usage_metadata
cached_tokens = getattr(usage, "cached_content_token_count", 0)
if cached_tokens > 0:
self._cache_hit_count += 1
return {
"text": response.text,
"cache_hit": cached_tokens > 0,
"cached_tokens": cached_tokens,
"input_tokens": usage.prompt_token_count,
"output_tokens": usage.candidates_token_count,
}
except Exception as e:
logger.error(f"生成失敗: {e}")
raise
def get_stats(self) -> dict:
"""キャッシュのヒット統計を返す"""
hit_rate = (
self._cache_hit_count / self._request_count * 100
if self._request_count > 0
else 0
)
return {
"total_requests": self._request_count,
"cache_hits": self._cache_hit_count,
"hit_rate_percent": round(hit_rate, 1),
}
# 使用例
SYSTEM_PROMPT = """
[ここに 2,048 トークン以上の長いシステムプロンプトを配置する]
製品マニュアル、サポートポリシー、FAQ の標準回答など...
"""
strategy = ProductionCacheStrategy(
model_name="models/gemini-2.5-pro",
system_prompt=SYSTEM_PROMPT,
cache_ttl_hours=12,
cache_display_name="support-bot-v1",
)
result = strategy.generate("返品期限を教えてください")
print(f"回答: {result['text'][:200]}")
print(f"キャッシュヒット: {result['cache_hit']}")
print(f"キャッシュトークン: {result['cached_tokens']}")
print(f"統計: {strategy.get_stats()}")
この設計が特に意識しているのは次の点です:
- スレッドセーフ: Web アプリで複数のリクエストが同時に来ても、キャッシュ作成は 1 回しか行われない
- プロセス再起動に対応:
display_name で既存キャッシュを検索し、不要なキャッシュの積み重ねを防ぐ
- フォールバック: キャッシュ作成に失敗してもサービスは続行し、Implicit Caching で代替する
公式ドキュメントには書かれていない、運用で気づいた 7 つの実装インサイト
ここからが、この記事でいちばん書きたかった部分です。Google AI Studio のドキュメントを読んだだけでは見えてこない、本番運用で 6 ヶ月転んだ末に身についた知見を 7 つ並べます。
インサイト 1: count_tokens() 自体も課金されるので呼びすぎない
model.count_tokens() は SDK 越しに API を叩きます。トークン数を返すだけの呼び出しでも、わずかに課金対象になります。私は当初、キャッシュ作成前に毎回 count_tokens() を呼ぶ実装にしていましたが、起動時間あたり数百回呼ぶジョブで月 $4 ほどの「見えない出費」が発生していました。
対策は、システムプロンプトを更新したタイミングだけ count_tokens() を呼んでローカルに保存し、以降は保存値を参照することです。テキストが固定の間は毎回計算する必要はありません。
import json
import pathlib
import hashlib
import google.generativeai as genai
CACHE_FILE = pathlib.Path("/tmp/.gemini_token_cache.json")
def count_tokens_cached(model_name: str, text: str) -> int:
"""count_tokens の結果をローカルに永続化して、同じテキストでは API を叩かない"""
key = hashlib.sha256(f"{model_name}::{text}".encode("utf-8")).hexdigest()
if CACHE_FILE.exists():
try:
store = json.loads(CACHE_FILE.read_text())
except json.JSONDecodeError:
store = {}
else:
store = {}
if key in store:
return store[key]
model = genai.GenerativeModel(model_name)
count = model.count_tokens(text).total_tokens
store[key] = count
CACHE_FILE.write_text(json.dumps(store))
return count
インサイト 2: 「保存料」が想像より重い
Context Caching の保存料は時間あたりで請求されます。gemini-2.5-pro で 100,000 トークンを 24 時間保持すると、保存だけで月額 $7〜$10 ほどになります。たかが保存料と侮れないのは、不要になったキャッシュを delete() し忘れた時で、私は一度「テスト用に作った 30,000 トークンのキャッシュを 3 つ放置していて、月末に $18 が消えた」事故を起こしました。
対策は次の 2 つです。
- テスト用キャッシュには
display_name を必ず test- プレフィックスで切る
- CI のジョブ末尾で
test-* のキャッシュを一括削除する
def cleanup_test_caches() -> int:
"""CI 終了時に呼んで、テスト用キャッシュをまとめて削除"""
deleted = 0
for c in caching.CachedContent.list():
if c.display_name.startswith("test-"):
try:
c.delete()
deleted += 1
except Exception as e:
print(f"⚠️ {c.display_name} の削除に失敗: {e}")
print(f"🧹 テストキャッシュ {deleted} 件削除")
return deleted
インサイト 3: display_name 衝突は静かに事故になる
Context Caching の display_name はユニーク制約がありません。同じ display_name で別のキャッシュを 2 つ作れてしまいます。私の場合、ローカル開発機と本番サーバーが同じ customer-support-system-prompt を別 API キーで作って、list() で取得した時にどちらを返すか分からない状態が一度発生しました。
対策は、display_name に環境名(prod-、staging-、dev-)と日付プレフィックスを必ず付けることです。
import os
from datetime import date
def stable_display_name(base: str) -> str:
"""環境と日付を含む display_name を生成する"""
env = os.environ.get("APP_ENV", "dev")
# 月単位で固定にすると、毎月 1 回だけ新規作成される
month_key = date.today().strftime("%Y-%m")
return f"{env}-{base}-{month_key}"
インサイト 4: 期限切れ間際の race condition
CachedContent.list() で取得したキャッシュを、いざ from_cached_content() で使おうとした瞬間に期限切れになるパターンがあります。PermissionDenied: 403 がキャッシュ期限切れで返ってくるのは見落としやすい挙動です。
対策は、期限切れ間際(残り 5 分以下)は最初から使わずに新規作成することです。
import datetime
from google.generativeai import caching
def is_safely_usable(cache: caching.CachedContent, min_remaining_sec: int = 300) -> bool:
"""このキャッシュを使うのが安全か判定(race condition 回避)"""
now = datetime.datetime.now(datetime.timezone.utc)
remaining = (cache.expire_time - now).total_seconds()
return remaining > min_remaining_sec
インサイト 5: マルチモーダルは Files API 経由が必須
画像や動画を Context Caching に含める場合、ローカルファイルを直接渡そうとすると InvalidArgument でエラーになります。Files API で先にアップロードして URI を取得し、その URI をキャッシュコンテンツに渡すのが正解です。
私の壁紙アプリでは「ブランドカラーパレット画像」を参照画像として Context Caching に含めていますが、最初これに気付かず 2 日ハマりました。
def cache_with_image(image_path: str, system_text: str) -> caching.CachedContent:
"""画像を含むコンテンツを Files API 経由でキャッシュする"""
print("📤 Files API にアップロード中...")
uploaded_file = genai.upload_file(
path=image_path,
display_name="brand-palette-reference",
)
print(f"✅ アップロード完了: {uploaded_file.uri}")
return caching.CachedContent.create(
model="models/gemini-2.5-pro",
display_name="prod-brand-palette-2026-04",
contents=[
{
"role": "user",
"parts": [
{"text": system_text},
{"file_data": {"file_uri": uploaded_file.uri}},
],
}
],
ttl=datetime.timedelta(hours=6),
)
インサイト 6: TTL は「次の使用予定時刻 + 30 分」が経験則
TTL を「とりあえず 24 時間」と設定すると、夜間に使われない時間帯の保存料が無駄になります。逆に「2 時間」だと、想定外のリクエストが来た時にキャッシュミスが続きます。
私が壁紙アプリで採用しているルールは「次の使用予定時刻 + 30 分」です。バッチが朝 6 時と夜 22 時に動くなら、夜のバッチが終わってから翌朝までの 6 時間はキャッシュを破棄し、朝 6 時のバッチ開始前に再作成します。
def calculate_smart_ttl(hours_until_next_use: float, buffer_minutes: int = 30) -> int:
"""次の使用予定時刻に合わせて TTL を計算する"""
# 0.5 時間刻みで切り上げ、最低 1 時間、最大 24 時間
raw = hours_until_next_use + (buffer_minutes / 60)
return max(1, min(24, int(raw + 0.999)))
# 使用例: バッチが 4 時間後に動く場合
ttl_hours = calculate_smart_ttl(4)
print(f"推奨 TTL: {ttl_hours}時間") # 推奨 TTL: 5時間
インサイト 7: AdMob 収益に対する Gemini API 費用の比率は 10〜15% 目安
これは技術というよりビジネス指標ですが、個人開発でアプリに AI 機能を組み込む時の判断軸として共有します。私の壁紙アプリでは、AdMob 月次収益に対する Gemini API 費用を 10〜15% に抑えることを運用ラインにしています。
理由は単純で、それを超えると AdMob のスパイク(広告 eCPM が一時的に下がる月)に耐えられなくなるからです。Cloud Billing で日次アラートを「月予算 ÷ 30 × 1.3」(つまり日次平均の 130%)で設定し、超えたら Slack に通知する仕組みにしています。
def daily_budget_alert(monthly_budget_usd: float) -> float:
"""日次アラート閾値を計算する(月予算 ÷ 30 × 1.3)"""
return (monthly_budget_usd / 30) * 1.3
# 月予算 $150 のアプリの場合
threshold = daily_budget_alert(150)
print(f"日次アラート閾値: ${threshold:.2f}") # 日次アラート閾値: $6.50
個人開発アプリで実測した、6 ヶ月分のコスト推移
ここからは、私の壁紙アプリで Context Caching を本格導入した 6 ヶ月のコスト推移を共有します。実際の Cloud Billing と AdMob レポートから抜粋した数値です。
対象アプリ: 壁紙提案アプリ(DAU 約 8,200・累計 DL の一部)
Gemini 機能: 画像から短い詩を生成、ブランドガイドラインに沿った推奨タグ付け
システムプロンプト: 約 12,400 トークン
1 日のリクエスト数: 約 1,500 件
導入前後のコスト推移を、月ごとに見ていきます。
- 導入前(2025 年 10 月): $281 / 月 — システムプロンプトを毎回送信、キャッシュ未使用
- 導入 1 週目: $164 / 月相当(試算)— Context Caching 12 時間 TTL に切り替えただけで 42% 削減
- 導入 1 ヶ月後: $112 / 月 — display_name 衝突を直し、TTL を「使用予定時刻 + 30 分」に最適化
- 導入 3 ヶ月後: $96 / 月 — Implicit Caching が効きやすいよう、プロンプト構造を
system_instruction 化
- 導入 6 ヶ月後(現在): $98 / 月 — DAU が 9,400 まで増えても費用はほぼ横ばい
導入前 $281 → 現在 $98 で、約 65% の削減になりました。AdMob 月次収益 $1,150 に対する Gemini API 費用比率は約 8.5% で、運用ライン(10〜15%)の内側に余裕を持って収まっています。
特に効いたのは次の 3 点です。
- display_name の月単位ローテーション(インサイト 3)— 重複キャッシュの保存料が消えた
- TTL の動的計算(インサイト 6)— 夜間 6 時間分の保存料が消えた
- count_tokens の永続キャッシュ(インサイト 1)— 月 $4 の見えない出費が消えた
AdMob 連携アプリで Gemini を扱う時の判断ライン
個人開発で AdMob と Gemini を併用するアプリ向けに、私が実際に使っている判断基準を共有します。
モデル選択ライン
- Flash(gemini-2.0-flash / gemini-2.5-flash): ユーザー操作トリガーの軽量タスク(タグ付け・短文生成・要約)。大半のリクエストはここ
- Pro(gemini-2.5-pro): 夜間バッチで品質が必要なタスク(ブランドガイドラインに沿った長文・複雑な推論)。1〜3% に絞る
- Context Caching: Flash・Pro どちらでも、システムプロンプトが 5,000 トークン超なら必ず使う
- Thinking モード: ユーザー操作トリガーでは原則 OFF。バッチ用途のみ ON
TTL の判断早見表
| 用途 | リクエスト間隔 | 推奨 TTL | display_name の更新頻度 |
| ユーザー操作(壁紙アプリ等) | 数秒〜数分 | 24 時間 | 月 1 回 |
| 夜間バッチ(タグ付け一括) | 1 日 1 回 | 2 時間 | 日次(バッチ前に作成) |
| 週次集計(レポート生成) | 週 1 回 | 1 時間 | 週次 |
| デモ・検証用 | 不定期 | 1 時間 | 即削除 |
コスト管理ライン
- 月予算: AdMob 月次収益の 10〜15% を上限とする
- 日次アラート: 月予算 ÷ 30 × 1.3 を超えたら Slack 通知
- API キー分離: Flash 用 / Pro 用 / 緊急枠の 3 系統に分けると、課金トラブル時の切り分けが楽
- 保存料モニター: 月初に
caching.CachedContent.list() を全件出力し、不要なものを delete()
タスク別キャッシュ設計の早見表
最後に、よく使うタスクごとのキャッシュ設計をまとめた早見表を置きます。コピーして自分のアプリ用に書き換えて使ってください。
| タスク | モデル | システムプロンプト | TTL | Context Cache | Implicit Cache |
| カスタマーサポート Bot | Pro | 約 10,000 トークン | 12 時間 | 必須 | 補助 |
| 壁紙タグ付けバッチ | Flash | 約 4,000 トークン | 2 時間 | 推奨 | 補助 |
| 短い詩生成(ユーザー操作) | Flash | 約 8,000 トークン | 24 時間 | 必須 | 補助 |
| ドキュメント QA | Pro | 約 20,000 トークン | 12 時間 | 必須 | 補助 |
| コードレビュー Bot | Pro | 約 6,000 トークン | 6 時間 | 必須 | 補助 |
| デモ・検証用 | Flash | 任意 | 1 時間 | 任意 | 任意 |
キャッシュヒット率のモニタリング
本番環境では、キャッシュの効果を継続的に計測することが欠かせません。以下は Prometheus メトリクスとして記録するパターンです。
from prometheus_client import Counter, Gauge, Histogram
# メトリクスの定義
cache_hits_total = Counter(
"gemini_cache_hits_total",
"キャッシュヒット数",
["cache_type"], # context または implicit
)
cache_misses_total = Counter("gemini_cache_misses_total", "キャッシュミス数")
token_savings_total = Counter(
"gemini_token_savings_total", "節約されたトークン数(推定)"
)
api_latency = Histogram(
"gemini_api_latency_seconds",
"API レイテンシ",
buckets=[0.5, 1, 2, 5, 10, 30],
)
cache_hit_rate_gauge = Gauge("gemini_cache_hit_rate_percent", "現在のキャッシュヒット率")
class MonitoredCacheStrategy(ProductionCacheStrategy):
"""Prometheus メトリクスを記録するキャッシュ戦略(本番推奨)"""
def generate(self, user_message: str) -> dict:
import time
start = time.time()
result = super().generate(user_message)
elapsed = time.time() - start
# レイテンシを記録
api_latency.observe(elapsed)
if result["cache_hit"]:
cache_hits_total.labels(cache_type="context_or_implicit").inc()
# キャッシュトークンはフル料金より約 75% 割引 → 75% 分を節約として計上
savings = int(result["cached_tokens"] * 0.75)
token_savings_total.inc(savings)
else:
cache_misses_total.inc()
stats = self.get_stats()
cache_hit_rate_gauge.set(stats["hit_rate_percent"])
return result
Grafana ダッシュボードを設定すれば、「今月節約したトークン数」「ヒット率の時系列推移」「キャッシュミスが増えたタイミング」などをリアルタイムで確認できます。本番導入後は最低でも 1 週間分のヒット率を確認してから、TTL やプロンプト設計の調整を行うことをおすすめします。
エラーハンドリングとリトライ戦略については、Gemini API 本番エラーハンドリングとレート制限の実践パターンも合わせてご覧ください。
結びに
Gemini API のキャッシュは、コスト削減のための単なる技術ではなく、個人開発アプリを長く続けるための仕掛けだと感じています。私の壁紙アプリは AdMob 収益で運営している以上、月の API 費用は売上から直接引かれる経費です。$281 を $98 に下げられたことで、Pro モデルを試す余裕も、新機能を載せる余裕も生まれました。
宮大工だった祖父は「丁寧に作っておけば、後から手が入る時に困らない」とよく言っていました。Context Caching の display_name ルールも、TTL の動的計算も、保存料のモニタリングも、最初に少しだけ手間をかけておけば、半年後の自分が同じ作業で困らなくなります。
次の一歩として試してみてほしいのは、今動かしているアプリのシステムプロンプトを count_tokens に通すことです。2,048 トークンを超えていたら、この記事の ProductionCacheStrategy をそのまま使えば 1 日で本番に乗せられます。response.usage_metadata.cached_content_token_count が 0 でない値を返した瞬間、コストが目に見えて静かになります。
長くなりましたが、最後までお付き合いいただきありがとうございました。私自身まだ学びの途中ですが、共に学んでいけたら嬉しいです。