請求書だけが「効いていない」と言っていた
Context Caching を有効化したのは、システムプロンプトが 8,000 トークンまで膨らんで、入力課金が無視できなくなったからでした。個人開発のマイクロ SaaS で、原価の一円が粗利率に直結する規模です。ドキュメントどおりに明示キャッシュを作り、cached_content を渡す実装に切り替えて、二週間。月初の請求書を開いて、指が止まりました。
入力トークンの課金が、想定していたほど下がっていません。体感では 6 割減るはずが、実際は 1 割そこそこ。コードは正しく動いているように見えます。エラーも出ていません。それでも、数字だけが静かに「効いていない」と言っている。
こういうとき、私はまずコードを疑うのをやめます。疑うのは「効いているはず」という自分の思い込みのほうです。キャッシュが効いたかどうかは、感覚ではなく応答のメタデータに必ず出ます。まずそこを読む計器を立てるところから、立て直しは始まりました。
「効いているはず」を応答から確かめる
Gemini API の応答には usage_metadata が付きます。ここに cached_content_token_count というフィールドがあり、その回のプロンプトのうち何トークンがキャッシュから供給されたかを教えてくれます。ここが 0 なら、実装が正しく見えてもキャッシュは一切効いていません。
まず最小の確認コードで、1 リクエストの内訳を目視します。
from google import genai
from google.genai import types
client = genai.Client( api_key = "YOUR_API_KEY" )
# 全ユーザー共通の固定部分「だけ」を明示キャッシュにする
cache = client.caches.create(
model = "gemini-2.5-flash" ,
config = types.CreateCachedContentConfig(
system_instruction = SYSTEM_PROMPT , # 8,000トークンの共通指示
ttl = "3600s" ,
),
)
resp = client.models.generate_content(
model = "gemini-2.5-flash" ,
contents = user_message, # ユーザーごとに変わる部分だけ
config = types.GenerateContentConfig( cached_content = cache.name),
)
u = resp.usage_metadata
print ( "prompt:" , u.prompt_token_count)
print ( "cached:" , u.cached_content_token_count) # ← ここが本体
print ( "output:" , u.candidates_token_count)
私の環境では、この cached が期待どおり 8,000 前後を返すリクエストと、なぜか 0 を返すリクエストが混在していました。「たいてい効いているが、たまに効いていない」。この「たまに」が積み重なって、請求書の 1 割減という結果になっていたわけです。平均だけを見ていると、この揺らぎは見えません。
命中率の計器を一本立てる
一度の目視では「たまに」の正体はつかめません。全リクエストの内訳を残し、命中率として集計する計器を通信路に挟みます。重いダッシュボードは要りません。1 行の構造化ログで十分です。
def log_cache_stats (resp, sink):
u = resp.usage_metadata
cached = u.cached_content_token_count or 0
prompt = u.prompt_token_count or 0
sink.emit({
"cached_tokens" : cached,
"prompt_tokens" : prompt,
# トークン加重の命中率(この1回で入力の何割がキャッシュ経由か)
"cache_ratio" : (cached / prompt) if prompt else 0.0 ,
"hit" : 1 if cached > 0 else 0 ,
"output_tokens" : u.candidates_token_count or 0 ,
})
集計で見るべき指標は二つです。一つはヒット率 (hit の平均=キャッシュが一度でも効いた割合)。もう一つはトークン加重の実効命中率 (cached_tokens の合計 ÷ prompt_tokens の合計)。前者は「効いた/効かない」の頻度、後者は「原価にどれだけ効いたか」を表します。
私の場合、ヒット率は 78% ありました。それなりに効いている数字です。ところがトークン加重の実効命中率は 34% しかありませんでした。この乖離が謎解きの鍵でした。多くのリクエストで一応キャッシュは効いているのに、原価には効いていない。書き込みが頻発していて、削った分を書き込みコストが食い戻している構図です。
減らない三つの沈黙する原因
計器の数字が出そろうと、原因は三つに絞れました。どれもエラーを出さないので、計測しない限り沈黙したまま進みます。本番運用で最も厄介なのは、この「エラーにならない損失」です。
一つ目は TTL チャーンです。 キャッシュには有効期限があり、期限が切れれば次の呼び出しで再書き込みが走ります。書き込み単価は通常入力とほぼ同等で、読み出し単価(通常入力の 4 分の 1 前後)よりずっと高い。アクセスが疎なのに TTL を短く設定すると、「作る→ほとんど読まれず期限切れ→また作る」を繰り返し、書き込みコストだけが積み上がります。私の TTL は 3,600 秒でしたが、夜間はリクエスト間隔がそれを超えていて、毎回作り直していました。この対処は後述します。
二つ目はユーザー単位の断片化です。 初期実装は、ユーザーごとにキャッシュエントリを作っていました。共通のシステムプロンプトは全ユーザーで同一なのに、エントリが人数分できる。書き込み回数がユーザー数に比例して膨らみ、一人あたりの読み出し回数は少ないため、回収前に期限切れになります。共通部分はただ一つのエントリを全員で共有 するのが正解でした。
三つ目は暗黙キャッシュへの寄りかかりです。 Gemini 2.5 系はプレフィックスが一致すると暗黙キャッシュが自動で効きますが、最小トークン閾値(モデルにより概ね 1,000〜2,000 トークン程度)を下回るプロンプトや、リクエストごとに先頭が変わる構成では発火しません。「暗黙で効くはず」と明示キャッシュを外していた一部の経路が、実は素通しで課金されていました。
書き込みコストの回収点を数字で置く
TTL チャーンが効くかどうかは、感覚ではなく損益分岐で判断できます。要は一つの TTL 窓の中で何回ヒットすれば、書き込みコストを回収できるか です。
単価は相対比で置きます。通常入力を 1 として、キャッシュ読み出しを 0.25、書き込みを 1.0(一度きり)とします。システムプロンプト 8,000 トークンなら、1 回のヒットで浮くのは 8,000 × (1 − 0.25) = 6,000 入力単位。書き込みは 8,000 入力単位かかります。したがって回収に必要なヒット回数は 8,000 ÷ 6,000 ≒ 1.34 回。TTL の窓の中で平均 1.4 回以上ヒットしなければ、キャッシュを入れたことで逆に高くつきます。
窓あたりのヒット回数ごとに、キャッシュなしを 100 とした相対原価を並べると、判断が一目で済みます。
TTL窓あたりのヒット回数 相対入力原価(なし=100) 判定
1回(書き込み+読み出し) 約125 逆効果(赤字)
2回 約88 ほぼ均衡〜微益
5回 約50 黒字
20回 約29 設計どおり
この表を出した瞬間に、夜間の赤字リクエストが見えました。窓あたり 1 回しかヒットしない時間帯が、削減分を食い潰していたのです。対処は「TTL を延ばす」ではなく、「呼び出し密度に TTL を合わせる」でした。密度が高い日中は短め、疎な夜間はキャッシュそのものを張らず素の入力で通す、という切り替えです。
平均原価と粗利率を日次で追う
命中率が直ったかどうかは、最終的に一リクエストあたりの平均原価(ACR: Average Cost per Request)に表れます。私はキャッシュ読み出し・書き込み・出力を単価換算して 1 リクエストの実効原価を出し、日次で追っています。命中率のグラフだけ見て安心すると、書き込みコストの膨張を見落とすためです。
サービス全体では、ユーザーごとの月間原価 ÷ 月額料金で粗利率を出します。ここで効くのは、平均ではなく上位 5% のヘビーユーザーの原価比率 です。この層が全体原価の 3 割以上を占め始めたら、命中率うんぬんより先に、その層向けの上位プランか利用上限を設計する局面に入っています。原価最適化と料金設計は、どこかで必ず地続きになります。
段階的に締めていく手順
一気に作り替えると、どの変更が効いたのか分からなくなります。私は次の順で一手ずつ入れ、そのつど ACR の推移を確認しました。
まず、システムプロンプトの固定部分と動的部分を分離し、固定部分だけを単一の共有キャッシュ に寄せます。ユーザー個別情報は contents 側に付け足すだけにして、エントリ数を 1 に固定します。これだけで書き込み回数がユーザー数分の 1 に落ち、実効命中率が跳ね上がりました。
次に、TTL を呼び出し密度に合わせます。固定の 3,600 秒をやめ、直近の平均リクエスト間隔から窓あたり期待ヒット回数を推定し、1.4 回を割りそうな時間帯はキャッシュを張らない分岐を入れます。
最後に、暗黙キャッシュ頼みだった経路を洗い出し、cached_content_token_count が 0 のまま流れているものを明示キャッシュへ寄せます。閾値割れで発火しない短いプロンプトは、そもそもキャッシュ対象から外し、Flash へのモデル差し替えなど別の手を当てます。この場合は、キャッシュより出力側の最適化のほうが素直に効きます。
キャッシュを張らない判断も持っておく
計測して分かったのは、Context Caching が万能ではないことでした。相性が悪いのは、システムプロンプトが 1,000 トークンに満たないサービス、リクエストごとに前提が丸ごと変わるパーソナライズ型、月に数回しか使われない単発ツールです。いずれも書き込みコストを回収する前に窓が閉じます。
こうしたケースでは、キャッシュを無理に入れず、出力フォーマットの厳密化による出力削減や、構造化出力によるリトライ二重課金の抑止、Flash モデルの併用といった、別の蛇口を開けたほうが素直に効きます。キャッシュは「張れば得」ではなく、「密度が回収点を超えるときに張る」道具でした。
次の一手
もし Context Caching を入れたのに請求が思ったほど減っていないなら、ダッシュボードを増やす前に、まず cached_content_token_count を 1 リクエスト分だけ出力してみてください。そこが 0 なら暗黙キャッシュ頼みの素通し、期待より小さいなら断片化か閾値割れ、ヒット率は高いのに原価が減らないなら TTL チャーンです。原因は三つしかなく、切り分けはメタデータ 1 フィールドから始められます。
私自身もまだ密度に応じた TTL の当て方を調整し続けている最中です。同じところでつまずいている方の、切り分けの起点になれば幸いです。お読みいただきありがとうございました。