ある朝、個人開発で続けている Dolice Labs の4サイトの自動投稿パイプラインの API コストを見直していて手が止まりました。リクエスト本数も投入トークン数もほとんど変わっていないのに、cache_read_input_tokens がほぼゼロに張り付いていたのです。前週まではプレフィックスの大半がキャッシュから読まれていたはずでした。
直前にやった変更は一つだけ、思い当たりました。ツール定義の description を一行だけ手直ししていたのです。日本語の言い回しを整えただけのつもりでした。けれどその一行が、その下にぶら下がっていた system プロンプトも few-shot 例も、丸ごとキャッシュの作り直しに追い込んでいました。プロンプトキャッシュは「ブロック単位」で効くものではなく「先頭からの連続した一致(prefix)」で効くものだ、という当たり前の事実を、コストの数字で思い出させられた朝でした。
この記事は、その「カスケード失効」がなぜ起きるのか、そしてブロックの並び順とブレークポイントの置き場所をどう設計すれば、揮発する部分を直しても安定した部分のキャッシュが生き残るのかを、実装と判断表でまとめたものです。
prefix キャッシュは「ブロック」ではなく「先頭からの一致」で効く
prompt caching を理解するうえで一番誤解しやすいのが、ここだと感じています。cache_control を付けたブロックだけが独立してキャッシュされるわけではありません。実際には、リクエストの先頭から cache_control ブレークポイントまでの連続したプレフィックス全体 が一つのキャッシュエントリになります。
リクエストは決まった順序で連結されます。tools → system → messages の順です。キャッシュの照合は毎回この連結された列の先頭から行われ、「前回と完全に一致する最長のプレフィックス」までがキャッシュから読まれます。途中で一文字でも違えば、その地点から後ろは一致しなくなり、新しく書き直し(cache creation)になります。
私のケースで起きていたのはまさにこれでした。tools は最上流にあります。そのツール定義を一行直したことで、tools の途中で前回との一致が途切れ、その後ろに続く system も messages も「一致しないプレフィックス」になってしまったわけです。下流のブロックの中身は一切変えていないのに、上流が動いた瞬間に全部が巻き添えになる。これがカスケード失効です。
逆に言えば、揮発するものを下流に、安定したものを上流に置く だけで、この巻き添えはほとんど防げます。
usage の4つの数字を正しく読む
並べ替えの効果を判断するには、usage を正しく読めることが前提になります。私が毎回確認しているのは次の4つです。
フィールド 意味 おおまかな課金感
cache_creation_input_tokens 新しくキャッシュに書き込んだトークン 通常入力より割高(5分TTLで約1.25倍、1時間TTLで約2倍)
cache_read_input_tokens キャッシュから読めたトークン 通常入力の約0.1倍と安い
input_tokens キャッシュ対象外の素の入力 通常価格
output_tokens 生成された出力 出力価格
健全な状態では、2回目以降のリクエストで cache_read_input_tokens が大きく、cache_creation_input_tokens が小さい(変わった末尾だけ)はずです。私の障害時は逆で、毎回 cache_creation_input_tokens が膨らみ cache_read_input_tokens がほぼゼロでした。「creation が毎回出続けている」のは、上流のどこかが毎回変わっているサイン だと考えて、まず疑うようにしています。
def cache_health (usage) -> dict :
created = usage.cache_creation_input_tokens or 0
read = usage.cache_read_input_tokens or 0
fresh = usage.input_tokens or 0
cacheable = created + read
# キャッシュ可能だった分のうち、実際に読めた割合
read_ratio = read / cacheable if cacheable else 0.0
return {
"read_ratio" : round (read_ratio, 3 ),
"created" : created,
"read" : read,
"uncached" : fresh,
}
read_ratio が 2回目以降も低いままなら、TTL 切れ(時間の問題)か、上流の揮発(並び順の問題)のどちらかです。本数を増やしても下がらず、間隔を詰めると改善するなら TTL 側。間隔に関係なく下がりっぱなしなら、並び順側を疑います。
安定度でブロックを並べ替える
設計の芯はここに尽きます。変わる頻度が低いものほど上流に置く ことです。私のパイプラインでは、おおむね次の順序に落ち着きました。
位置 ブロック 変わる頻度
1(最上流) tools(ツール定義) ほぼ不変(機能追加時のみ)
2 system(役割・出力規約・恒久ルール) 低い
3 大きめの定数コンテキスト(スタイルガイド・固定の参照表) 低い
4 動的参照データ(その日のニュース・可変の事実) 高い(日次など)
5(最下流) 会話履歴・今回のユーザー入力 毎回
私が最初にやってしまっていたのは、4の「その日の動的参照データ」と、現在時刻のタイムスタンプを、2の system プロンプトの中に埋め込んでいたことでした。system は本来とても安定したブロックなのに、そこに日次で変わる文字列を差し込んでいたため、毎日 system 以降が全滅していたのです。
直し方はシンプルでした。system からは可変の値を全部追い出し、動的参照データは末尾近くの user メッセージとして渡す ようにしました。tools と system は固定文字列だけで構成し、その境目に最初のブレークポイントを置く。これだけで read_ratio は 0 付近から 0.8 台に戻りました。
ブレークポイントは4つしか置けない — どこに spend するか
cache_control は1リクエストにつき最大4つまでです。やみくもに付けるのではなく、「ここまでは前回と一致しているはず」という安定度の段差 に合わせて置くのが効きます。
ブレークポイント 置く場所 守りたいもの
1つ目 tools の末尾 ツール定義(最も再利用される)
2つ目 system の末尾 恒久ルール込みのプレフィックス
3つ目 大きな定数コンテキストの末尾 スタイルガイド等まで含めた長いプレフィックス
4つ目 会話履歴の「確定済み」末尾 マルチターンで伸びていく履歴
複数のブレークポイントを置くと、Claude は「前回と一致する最長のプレフィックス」を自動で選んで読みます。つまり手前のブレークポイントは、奥のキャッシュが失効したときの保険 になります。たとえば3つ目(動的参照データの直前)が今日の更新で失効しても、1つ目・2つ目(tools と system)まではヒットが残ります。全か無かではなく、段階的に守れるわけです。
一つ注意点があります。キャッシュには最小プレフィックス長があり、短すぎるプレフィックスはそもそもキャッシュ対象になりません(モデルによりおおむね 1,024 トークン前後、Haiku 系はより長め)。ツール定義が小さいうちは1つ目のブレークポイントが効かないこともあるので、cache_creation_input_tokens が一度でも計上されているかで「そこが本当にキャッシュ対象になっているか」を確認しておくと安全です。
実装: メッセージを「安定度レイヤ」で組む
設計を素直にコードへ落とすと、こうなります。可変の値を構築関数の引数として一番外側に追い出し、安定したレイヤには触れないのがポイントです。
import anthropic
client = anthropic.Anthropic( api_key = "YOUR_API_KEY" )
# ── 安定レイヤ(毎日変えない)────────────────────────
TOOLS = [
{
"name" : "publish_article" ,
"description" : "記事を所定のリポジトリへ登録します。" , # ここを直すと全失効する
"input_schema" : {
"type" : "object" ,
"properties" : { "slug" : { "type" : "string" }, "locale" : { "type" : "string" }},
"required" : [ "slug" , "locale" ],
},
# tools 末尾にブレークポイント(1つ目)
"cache_control" : { "type" : "ephemeral" },
}
]
SYSTEM = [
{
"type" : "text" ,
"text" : "あなたは技術ブログの編集者です。出力規約と恒久ルールは以下のとおりです……(固定文字列のみ)" ,
# system 末尾にブレークポイント(2つ目)
"cache_control" : { "type" : "ephemeral" , "ttl" : "1h" },
}
]
def build_messages (daily_reference: str , user_input: str ) -> list :
# ── 揮発レイヤ(毎回・毎日変わる)── あえて末尾の user に置く
return [
{
"role" : "user" ,
"content" : [
{ "type" : "text" , "text" : f "本日の参照データ: \n{ daily_reference } " },
{ "type" : "text" , "text" : user_input},
],
}
]
def run (daily_reference: str , user_input: str ):
resp = client.messages.create(
model = "claude-sonnet-4-6" ,
max_tokens = 2048 ,
tools = TOOLS ,
system = SYSTEM ,
messages = build_messages(daily_reference, user_input),
)
return resp
肝心なのは、daily_reference(その日のニュースなど日次で変わる文字列)を SYSTEM の中に入れない ことです。SYSTEM と TOOLS を固定文字列に保ち、可変値は build_messages の引数経由で末尾の user メッセージにだけ流す。こうしておけば、参照データを毎日差し替えても tools と system のキャッシュは生き残ります。
並べ替えても効かないケース
並び順を整えても read_ratio が戻らないことがあります。私が踏んだものを挙げておきます。
第一に、system の中にタイムスタンプや UUID を残していた ケースです。「最終更新: 2026-06-24 19:45」のような一行が混ざっているだけで、system は毎回別物になります。動的に見える文字列は system から全部追い出すのが原則です。
第二に、tool 定義をリクエストごとに辞書から組み立て直していて、キーの順序が安定していなかった ケースです。JSON のシリアライズ順がぶれると、内容は同じでも文字列としては別物になり、一致が外れます。ツール定義はモジュールレベルの定数として一度だけ作るのが安全です。
第三に、モデルを切り替えた ケースです。キャッシュはモデルごとに分かれているため、claude-sonnet-4-6 と Haiku 系を行き来するパイプラインでは、それぞれで独立にキャッシュが温まります。フォールバックでモデルが変わる設計なら、read_ratio が一時的に下がるのは想定内だと割り切っています。
ヒット率の回帰を、気づける形にしておく
並べ替えが効いても、後日の何気ない一行で再びカスケード失効を踏むことがあります。私は無人で回す前提なので、ヒット率が閾値を割ったらログに残す最小の番人を挟んでいます。
def assert_cache_healthy (usage, * , min_read_ratio = 0.5 , warmed = True ):
h = cache_health(usage)
# 2回目以降(warmed)でこの比率を割ったら並び順の回帰を疑う
if warmed and h[ "read_ratio" ] < min_read_ratio:
print (
f "⚠️ cache read_ratio= { h[ 'read_ratio' ] } "
f "(created= { h[ 'created' ] } , read= { h[ 'read' ] } ) — 上流の揮発を確認"
)
return False
return True
数値で見張っておくと、「言い回しを整えただけ」の一行がコストに跳ねた瞬間に気づけます。私自身、最初の障害に気づくまで数日かかってしまったので、この番人を入れてからは安心して system やツール定義に手を入れられるようになりました。
次の一歩として、まずは手元の API 呼び出しのレスポンスから cache_creation_input_tokens と cache_read_input_tokens を一度ログに出してみてください。2回目のリクエストで creation が大きいままなら、上流のどこかに揮発する文字列が紛れています。そこを末尾へ動かすだけで、コストの数字は素直に応えてくれます。