import { Callout } from '@/components/ui/callout';
プロンプトキャッシュのヒット率を上げる話はよく見かけますが、私がしばらく見落としていたのは「キャッシュの寿命と、ジョブが走る間隔がかみ合っていない」という、もっと手前の問題でした。Dolice Labs の4サイトに毎日記事を生成するパイプラインは、サイトごとに数時間ずつずらして走ります。前段に共通の参照データと方針プロンプトをまとめて1万トークン以上載せているので、ここをキャッシュできれば確実に効くはずでした。ところが請求の内訳を見ると、cache_read_input_tokens がほとんど立っていません。毎回まるごと書き直していたのです。
原因は単純で、実行と実行のあいだが1時間より長く空いていたからでした。1時間TTLを指定していても、6時間おきに走るジョブには届きません。同じプレフィックスを使っていても、TTLが切れたあとの実行は新規書き込みとして課金されます。ここから先で残すのは、その時間ギャップを埋める「ウォーミング」をどの間隔で打てばよいか、そしてそれが本当に割に合うのかを、私自身の手元の数値で式に落とした過程です。個人開発で複数のジョブを抱えていると、この手の「寿命と間隔のズレ」は地味に効いてきます。
ℹ️ 前提として、`cache_control` の単価は通常入力を1.0倍とすると、5分TTLの書き込みが約1.25倍、1時間TTLの書き込みが約2.0倍、キャッシュ読み取りが約0.1倍です。本稿の試算はこの倍率を使います。実際の金額はモデルとリージョンで変わるため、必ず自分の請求で検算してください。
キャッシュの寿命と「実行間隔」がかみ合っていなかった
プロンプトキャッシュは、リクエストが既存のキャッシュにヒットするたびにTTLが延びます。逆に言えば、TTLの時間内に次のアクセスが来なければ、キャッシュは静かに消えます。ここが、対話型アプリと定期実行で性質が大きく違うところでした。
対話型のチャットは、ユーザーが連続して話しかけるので、数分おきにアクセスが来ます。5分TTLでも自然に延命され続けます。一方、私のパイプラインのような定期実行は、実行間隔そのものがTTLより長いと、毎回が初回扱いになります。整理すると、次のような対応関係になります。
実行パターン 典型的な実行間隔 5分TTL 1時間TTL
対話型チャット 数秒〜数分 ほぼ常にヒット 常にヒット
頻発バッチ(数分おき) 1〜3分 多くがヒット 常にヒット
時報的バッチ(毎時) 60分 ほぼミス 境界次第で半々
分散スケジュール 数時間 常にミス 常にミス
私が踏んだのは表の最下段です。1時間TTLにすれば安心だと思い込んでいましたが、6時間おきのジョブには1時間の寿命では届きません。まずはこの「実行間隔 > TTL」という関係に気づくことが出発点でした。
まず実測する — 書き込みと読み取りの比を実行ログに残す
推測で対策を打つ前に、いま自分のジョブがどれだけ書き直しているかを数値で押さえます。usage には書き込みと読み取りのトークン数が分かれて入るので、毎回これをログに出すだけで実態が見えます。
import json
import time
def log_cache_usage (resp, job_name: str ) -> dict :
u = resp.usage
created = getattr (u, "cache_creation_input_tokens" , 0 ) or 0
read = getattr (u, "cache_read_input_tokens" , 0 ) or 0
fresh = getattr (u, "input_tokens" , 0 ) or 0
total_prefix = created + read
hit_rate = (read / total_prefix) if total_prefix else 0.0
record = {
"ts" : time.time(),
"job" : job_name,
"cache_creation" : created, # 書き込み(高い)
"cache_read" : read, # 読み取り(安い)
"uncached_input" : fresh,
"prefix_hit_rate" : round (hit_rate, 3 ),
}
print (json.dumps(record, ensure_ascii = False ))
return record
prefix_hit_rate が連日ゼロに近いなら、キャッシュは一度も延命できていません。私の場合、4サイトの生成ログを1週間集計したところ、ヒット率は平均0.04でした。つまり載せていた1万トークンの前段は、ほぼ毎回まるごと書き込み課金されていたわけです。ここで初めて「これは寿命の問題だ」と腹落ちしました。
ウォーミングとは何か — 中身を変えずにTTLだけ延命する
ウォーミングは、同じキャッシュプレフィックスを使う「中身のない最小リクエスト」を、TTLが切れる前に挟む手法です。キャッシュは読み取られるたびにTTLがリセットされるので、安い読み取り1回でタイマーを巻き戻せます。出力は不要なので max_tokens を最小にし、本文のメッセージも極小にします。
def warm_cache (client, model: str , cached_prefix: list , breakpoint_idx: int ):
"""cached_prefix は本番と完全に同一のブロック列。
末尾ブロックに cache_control を付けたものをそのまま渡す。"""
return client.messages.create(
model = model,
max_tokens = 1 , # 出力は捨てる
system = cached_prefix, # 本番と1バイトも違わないこと
messages = [{ "role" : "user" , "content" : "ok" }],
)
肝心なのは、ウォーミングで渡すプレフィックスが本番のリクエストと完全一致 していることです。1文字でも違えば別キャッシュになり、延命どころか新しい書き込みが増えるだけです。私は本番コードとウォーミングコードで同じ build_prefix() 関数を共有し、差分が入り込む余地を消しました。
ウォーミング間隔をTTLから逆算する
延命に必要な間隔は、TTLから安全マージンを引いた値です。私は次の手順で決めています。
キャッシュのTTLを確認する(1時間TTLなら60分)。
スケジューラのゆらぎ・APIのレイテンシ・タイムゾーンのズレを見込み、安全係数0.8を掛ける(60 × 0.8 = 48分)。
この48分以内に必ず1回はアクセスが来るよう、ウォーミングを打つ間隔を48分以下に設定する。
本番ジョブ自身もアクセスなので、本番が48分以内に走る時間帯はウォーミングを省く。
def warming_interval_minutes (ttl_minutes: int , safety: float = 0.8 ) -> int :
return max ( 1 , int (ttl_minutes * safety))
# 1時間TTL → 48分ごとに延命すれば、6時間の空白も埋められる
print (warming_interval_minutes( 60 )) # -> 48
この逆算で、6時間の空白を埋めるには48分間隔でおよそ7回の延命が要ると分かります。5分TTLで同じ空白を埋めようとすると4分間隔で約90回必要になり、現実的ではありません。ウォーミングが選択肢になるのは1時間TTLのときだけ 、というのがこの計算からの最初の結論です。
損益分岐 — ウォーミングが割に合うかを式で出す
延命にも読み取りコストがかかります。割に合うかどうかは、「ウォーミングで支払う読み取り」と「コールドミスで支払う書き込みの上乗せ分」を比べれば決まります。プレフィックスを P トークンとすると、1回のコールドミスで余計に払うのは、書き込み(1時間で2.0倍)と読み取り(0.1倍)の差、すなわち約 1.9 × P です。一方、延命1回の読み取りは 0.1 × P です。
def warming_economics (
prefix_tokens: int ,
runs_per_day: int ,
gap_minutes: int ,
ttl_minutes: int = 60 ,
write_mult: float = 2.0 , # 1h 書き込み
read_mult: float = 0.1 , # 読み取り
):
# 何もしない場合: 実行間隔がTTLを超えるなら毎回コールド書き込み
cold_per_run = write_mult if gap_minutes > ttl_minutes else read_mult
cost_cold = runs_per_day * cold_per_run * prefix_tokens
# ウォームする場合: 初回のみ書き込み、以降の本番と延命は読み取り
interval = max ( 1 , int (ttl_minutes * 0.8 ))
warm_touches = max ( 0 , ( 24 * 60 ) // interval - runs_per_day)
cost_warm = (
write_mult * prefix_tokens # 初回書き込み 1 回
+ read_mult * prefix_tokens * (runs_per_day - 1 ) # 本番のヒット
+ read_mult * prefix_tokens * warm_touches # 延命のヒット
)
saving = cost_cold - cost_warm
return {
"cost_cold_units" : round (cost_cold),
"cost_warm_units" : round (cost_warm),
"warm_touches_per_day" : warm_touches,
"should_warm" : saving > 0 ,
"saving_ratio" : round (saving / cost_cold, 3 ) if cost_cold else 0 ,
}
print (warming_economics( prefix_tokens = 12000 , runs_per_day = 4 , gap_minutes = 360 ))
このパラメータ(1万2千トークンの前段・1日4回・6時間間隔)で走らせると、コールドのままが約96,000単位、ウォームすると約57,000単位で、おおよそ4割の削減になります。延命の回数が増えても、読み取りは書き込みの20分の1の単価なので、損益分岐はかなり余裕があります。式の上では、延命回数が「節約できる書き込み上乗せ ÷ 読み取り単価」、つまり 1.9 ÷ 0.1 = 19回を本番1回あたりで超えない限りは黒字です。
私の4サイト運用での結論と、あえてウォームしない判断
数値は黒字でしたが、私が最終的に選んだのはウォーミングではなく実行をまとめる ことでした。6時間ごとにバラけていた4サイトの生成を、TTLの内側に収まるよう近い時間帯に寄せたのです。こうすると2本目以降は本番アクセス自体が延命になり、別途の延命リクエストがほぼ要らなくなります。ウォーミングは「どうしてもスケジュールをずらせないとき」の保険、という位置づけに落ち着きました。
判断の優先順位は、私の場合こうなっています。
まず実行をTTL内に寄せられないか を検討する(追加コストゼロで延命される)。
寄せられない事情(負荷分散・外部依存のタイミング)があるなら、ウォーミングの損益分岐を上の関数で確認する。
黒字かつプレフィックスが安定しているなら、48分間隔の延命を入れる。
プレフィックスが頻繁に変わるなら、ウォーミングはやめてキャッシュ対象を静的な層だけに絞る。
個人開発で4サイトを一人で回していると、ジョブを少し寄せるだけで済む話に、わざわざ常時稼働の延命プロセスを足したくはありません。安く済む順に試すのが結局いちばん壊れにくい、というのが正直な実感です。
ウォーミングが逆に高くつく3つの落とし穴
最後に、本番で踏んで学んだ注意点を残します。いずれも「延命しているつもりで書き込みを増やしていた」パターンです。
ひとつめは、プレフィックスのゆらぎ です。前段に日付や実行IDのような可変値を1つでも混ぜると、毎回別キャッシュになります。延命リクエストが新規書き込みを量産し、コストが跳ね上がります。可変値はキャッシュブレークポイントより後ろに置くのが鉄則です。
ふたつめは、安全係数の取り違え です。スケジューラのゆらぎを甘く見て間隔をTTLぎりぎりに設定すると、たまの遅延でTTLを跨ぎ、その回がまるごと書き込みになります。延命は「切れる前に確実に届く」間隔で打たないと意味がありません。
みっつめは、ブレークポイントの上限超過 です。キャッシュブレークポイントは1リクエストにつき最大4つまでです。延命対象を増やそうとブロックを刻みすぎると上限に当たり、想定したブロックが書き込みされない、あるいは余計に書き込まれることがあります。延命するのは本当に効く静的な層に絞るのが安全です。
まずは log_cache_usage() を1日分のジョブに仕込んで、prefix_hit_rate が連日ゼロに近いかを確かめてみてください。そこが埋まっているかどうかで、寄せるべきか・ウォームすべきかの判断が、推測ではなく数値から始められます。