Dev

プロンプトキャッシュを壊したものを見つける

Cache diagnostics は、リクエストが直前のものと一致しなくなった最初の箇所を教えてくれます。役に立つのは、その判定をキャッシュ読み取り数と並べて読み、diagnostics が検出しないパラメータの変更を自分で押さえたときです。目で探して最も見つけにくいミスは、まさにそこにあります。

プロンプトキャッシュのミスは静かです。プロンプトの先頭が直前のリクエストとバイト単位で一致しなくなっても、API は文句を言いません。普通に応答し、プレフィックスを書き直す分を請求して、先へ進みます。(Opus 5.5 には、これと似ていてもエラーになるケースがひとつあります。thinking ブロックより前の部分を編集してから thinking ブロックを送り返すと、比較的新しいアカウントでは 400 が返ることがあります。普通のミスでは決して起きません。)残る痕跡は usage.cache_read_input_tokens がゼロになっていることだけで、モデルが変わったのか、system のテキストが変わったのか、履歴が変わったのかは何も教えてくれません。

Cache diagnostics は 5 月からパブリックベータで、9 月 23 日に Claude API でベータを外れました。そのどれだったのかを、プロンプトについては教えてくれます。プロンプトの周りのパラメータについては教えてくれません。そして目で探しても見つからないのは、パラメータが原因のミスです。

Cache diagnostics は実際に何を教えてくれるのか

連続する 2 つのリクエストを比べ、最初に食い違った箇所を示します。直前のレスポンスの id を渡すと、API は新しいリクエストのフィンガープリントを取り、保存済みのものと比較して、レスポンスに diagnostics オブジェクトを付けます。示される原因は model_changed、system_changed、tools_changed、messages_changed で、それぞれに cache_missed_input_tokens が付きます。これは食い違いより後ろにあった入力量の推定値です。ドキュメントはこの推定値を請求額ではなく規模の目安と呼んでいます。推定値が input_tokens を上回ることさえあります。

大事なのは「最初の」という言葉です。ドキュメントはプレフィックスを tools、system、messages の順に並べており、ある階層の変更はその階層とそれ以降をすべて無効にします。レスポンスが報告するのは最も早い食い違いだけです。system のテキストにタイムスタンプが入っていて、しかもツールスキーマのシリアライズが不安定なら、まず tools_changed が表示され、それを直してはじめてタイムスタンプに出会います。

比べているのはリクエストであって、キャッシュの結果ではありません。判定がクリーンなら、リクエストは変わっていないということです。キャッシュがヒットしたかどうかは別の数字で、両方が必要です。

どうやって有効にするのか

すべてのリクエストに diagnostics オブジェクトを付けます。最初のターンでは previous_message_id: null を渡します。比較対象はないまま、機能だけが有効になります。以降のターンでは、その直前のレスポンスの id を渡します。ベータヘッダー cache-diagnosis-2026-04-07 はもう不要ですが、Python SDK では diagnostics パラメータは安定版のメソッドではなく client.beta.messages.create にあり、ドキュメントの SDK サンプルはすべてベータの名前空間を通っています。

素の HTTP なら、フィールドがひとつ増えただけの普通の Messages 呼び出しが 2 回です。プロンプトに入れる文書は本物である必要があります。Opus 5.5 は 512 トークン未満のプレフィックスをキャッシュしないので、プレースホルダーでは何も書き込まれず、ミスする対象がそもそもありません。

bash
DOC=$(cat big-document.txt)   # well over 512 tokens
SYSTEM=$(jq -n --arg d "$DOC" '"You are analyzing this document. <document>" + $d + "</document>"')
 
call() {
  curl -sS --fail-with-body https://api.anthropic.com/v1/messages \
    -H "x-api-key: $ANTHROPIC_API_KEY" \
    -H "anthropic-version: 2023-06-01" \
    -H "content-type: application/json" \
    -d "$1"
}
 
# Turn 1: opt in, write the cache
r1=$(call "$(jq -n --argjson s "$SYSTEM" '{
  model: "claude-opus-5-5", max_tokens: 1024,
  cache_control: {type: "ephemeral"}, system: $s,
  messages: [{role: "user", content: "Summarize section 1."}],
  diagnostics: {previous_message_id: null}}')") || exit 1
jq '{id, usage}' <<< "$r1"   # expect cache_creation_input_tokens > 0
 
# Turn 2: same prefix, the assistant turn echoed back verbatim, one new question
r2=$(call "$(jq -n --argjson s "$SYSTEM" --argjson r "$r1" '{
  model: "claude-opus-5-5", max_tokens: 1024,
  cache_control: {type: "ephemeral"}, system: $s,
  messages: [
    {role: "user", content: "Summarize section 1."},
    {role: "assistant", content: $r.content},
    {role: "user", content: "Now section 2."}],
  diagnostics: {previous_message_id: $r.id}}')") || exit 1
jq '{usage, diagnostics}' <<< "$r2"

エージェントのループでは、この機能は次のターンへ引き継ぐ変数ひとつで済みます。判定の状態は 3 つではなく 4 つあるので、区別してログに残してください。

python
import anthropic
 
client = anthropic.Anthropic()
SYSTEM = "You are analyzing this document. <document>" + open("big-document.txt").read() + "</document>"
 
def verdict(r, prev_id):
    d = r.diagnostics
    if prev_id is None:
        return "first-turn"
    if d is None:
        return "no-divergence"
    if d.cache_miss_reason is None:
        return "pending"
    return d.cache_miss_reason.type
 
messages, prev_id = [], None
for prompt in ["Summarize section 1.", "Now section 2.", "Now section 3."]:
    messages.append({"role": "user", "content": prompt})
    r = client.beta.messages.create(
        model="claude-opus-5-5",
        max_tokens=1024,
        cache_control={"type": "ephemeral"},
        system=SYSTEM,
        messages=messages,
        diagnostics={"previous_message_id": prev_id},
    )
    u = r.usage
    print(
        f"read={u.cache_read_input_tokens} write={u.cache_creation_input_tokens} "
        f"uncached={u.input_tokens} verdict={verdict(r, prev_id)}"
    )
    messages.append({"role": "assistant", "content": r.content})  # verbatim, never rebuilt
    prev_id = r.id

ストリーミングのレスポンスでは、同じオブジェクトが message_start イベントで届くので、最初のトークンが来る前にログに残せます。

ミスを示す数字をログに残しているか

おそらく全部は残していません。usage ブロックは入力を 3 つに分けます。キャッシュから読み取ったトークン、キャッシュに書き込んだトークン、そして最後のブレークポイントより後ろだけを数える input_tokens です。入力の合計はこの 3 つの和です。input_tokens と output_tokens しか残さないロガーは、ヒットでもミスでも、キャッシュされたプレフィックスをまるごと見失います。プレフィックスと質問が同じなら、プレフィックスが 20 分の 1 の価格で読み取られた場合も、通常価格以上で書き込まれた場合も、その数字はまったく同じに見えます。

自作の agent-recall パッケージがまさにこの作りです。その Anthropic 呼び出しは input_tokens と output_tokens を返すだけで、ほかには何も返しません。cache_control を一度も設定していないので、今はその 2 つの数字ですべてが分かります。ブレークポイントを足した日からはそうでなくなり、パッケージ自身の集計はそれを何も知らせてくれません。

この穴を塞ぐべき理由は価格です。Opus 5.5 ではキャッシュ読み取りは基本入力単価の 0.05 倍で、ほとんどのモデルの 0.1 倍より安くなっています。ミスすると代わりに書き込み単価を払います。デフォルトの 5 分 TTL なら基本の 1.25 倍、1 時間 TTL なら 2 倍です。判定をひとつでも読む前に、3 つのフィールドすべてをログに残してください。

結果をどう読むのか

まず判定を読み、次にそれを cache_read_input_tokens と照らし合わせます。diagnostics フィールドが null になるのは、有効にしていないとき、最初のターン、そして比較が行われて何も見つからなかったときです。{"cache_miss_reason": null} になるのは、レスポンスが送られた時点で比較がまだ走っていたときで、これは判断材料になりません。次のターンを見てください。それ以外の場合は、ドキュメントが示す形で原因が中に入っています。

json
"diagnostics": {
  "cache_miss_reason": {
    "type": "system_changed",
    "cache_missed_input_tokens": 41850
  }
}

previous_message_not_found と unavailable も同じ入れ物で届きますが、比較が行われなかったという意味です。比較が行われたターンについて、ドキュメントは 4 マスの表を示しています。覚えておく価値があるのはここです。

Diagnosticsキャッシュ読み取り意味
null多い正常。プレフィックスは安定していて、キャッシュはヒットした。
null少ない、またはゼロリクエストは変わっていないが、使えるエントリがなかった。たいていは TTL。ターンの間隔を縮めるか、1 時間キャッシュを使う。
*_changed 系少ない、またはゼロ自分のバグ。種類が示す箇所を直す。
*_changed 系多い後ろのほうで変更があったが、手前のブレークポイントはヒットした。直す価値はあるが影響は小さい。

この表がある理由は 2 行目です。判定が null で読み取りがゼロなのは健康の証明ではなく、プロンプトをいくら探しても説明はつきません。TTL のせいにする前に、1 ターン目が実際に何かを書き込んだか確認してください。そこでも cache_creation_input_tokens がゼロなら、プレフィックスは最初からキャッシュできない状態でした。短すぎたか、ブレークポイントがなかったかです。

どの原因にどの修正が対応するのか

どの種類も、プレフィックスのひとつの階層を指しています。修正はたいてい地味で、それは良い知らせです。

system_changed。 リクエストごとに変わる何かが system フィールドに差し込まれています。タイムスタンプ、リクエスト ID、今日の日付などです。system のテキストを定数にし、変わる部分はキャッシュブレークポイントより後ろの最初のユーザーメッセージに移してください。

tools_changed。 ツールの一覧に追加があったか、並び順が変わったか、シリアライズの仕方が変わりました。プラグインのスキャンや順序のない集合から組み立てるレジストリは、誰もツールに触れていなくてもこれを起こします。毎ターン同じツールを同じ順序で送り、スキーマは決定的にシリアライズしてください。

python
import json
 
def stable_tools(tools):
    ordered = sorted(tools, key=lambda t: t["name"])
    # round-trip through sorted JSON so key order never depends on how the dict was built
    return json.loads(json.dumps(ordered, sort_keys=True))

これは各スキーマ内の properties も並べ替えるので、モデルが読むフィールドの順序が一度だけ変わります。以降は安定するので、セッションの途中からではなく最初のリクエストから適用してください。セッションの途中でどうしてもツールを追加する必要があるなら、トップレベルの tools 配列には触れず、会話の中で届くようにします。deferred tool loading がやっているのはこれです。同じ主張を deferred loading とキャッシュされたプレフィックス についても書きました。プロンプトの先頭にあるものこそキャッシュが守る部分で、だからこそ触らないようにする部分です。

messages_changed。 モデル、system、ツールは一致していますが、以前のメッセージが追記ではなく編集、並べ替え、削除されています。履歴の切り詰めがこれを起こしますし、content をそのまま送り返さずに自前のデータモデルからアシスタントのターンを組み立て直すのも同じです。ドキュメントは、オブジェクトを JSON に変換するときにキーの順序をランダムにする言語があり、送り返す tool_use ブロック経由でキャッシュが壊れるとも警告しています。履歴は追記のみとして扱ってください。

model_changed。 ルーター、A/B 分割、フォールバックのいずれかが会話の途中で別のモデルを選んでいます。キャッシュはモデルごとに分かれているため、フォールバックしたターンはプレフィックス全体を払い直し、元のエントリがそれまでに期限切れになっていれば、戻ってきたターンも同じです。フォールバックするなら、その会話の最後までそのままにしてください。これは ワーカーとレビュアーを別々のモデルで動かすこと への反論ではありません。役割が違えば会話も別で、最初からキャッシュを共有していないからです。

previous_message_not_found。 何かが変わった証拠ではありません。直前のリクエストが diagnostics オブジェクトを省いたか、別のワークスペースで実行されたか、古すぎるかです。フィンガープリントはごく短期間しか保存されません。ベータを早くから使っていたなら、最初のターンを確認してください。9 月 9 日以降、diagnostics オブジェクトなしでベータヘッダーだけを送るリクエストはフィンガープリントを保存しないため、その次のターンは毎回この種類を報告します。

Cache diagnostics が見えなくなるのはどこか

3 か所あります。プロンプトの外にあるパラメータ、非常に長い会話、そして Claude API 以外のすべてのプラットフォームです。やっかいなのは 1 つ目です。

プロンプトの外にあるパラメータ。 model、system、tools は一致しているのに、tool_choice、thinking、context_management、output_config、output_format、あるいは anthropic-beta ヘッダーの組み合わせが違う場合、判定は unavailable になります。ここでキャッシュのページにある無効化の表を読んでください。tool_choice の変更ではツールと system のキャッシュは有効なままで、メッセージのブロックが無効になります。thinking の設定やトップレベルの output_config.effort の変更は、常にメッセージのブロックを無効にし、モデルによってはそれ以上です。つまり目で見て最も気づきにくいケース(ツールも system も無傷で、メッセージの読み取りだけが崩れる)で、diagnostics は原因を示してくれません。

effort はつまずきやすいところです。モデルのデフォルト値を明示的に設定するのは省略するのと同じですが、難しい 1 ステップのためにトップレベルの effort を上げるエージェントは、そのターンのメッセージキャッシュで代償を払います。メッセージ単位の effort に対応したモデルなら、その変更を messages 内の role: "system" メッセージに載せて、キャッシュされたプレフィックスを無傷のまま残せます。デフォルト自体は Opus 5.5 で変わりました。これは Opus 5.5 への移行メモ で書いたとおりです。

diagnostics が見るのはプロンプトまでです。パラメータは自分で見張る必要があり、ターンごとに短いハッシュを判定と並べてログに残せば十分です。

python
import hashlib, json
 
PROMPT_PARAMS = ("tool_choice", "thinking", "context_management", "output_config", "output_format")
 
def param_fingerprint(request: dict, betas: list[str]) -> str:
    snapshot = {k: request.get(k) for k in PROMPT_PARAMS}
    snapshot["betas"] = sorted(betas)
    blob = json.dumps(snapshot, sort_keys=True, default=str)
    return hashlib.sha256(blob.encode()).hexdigest()[:12]

読み取りが崩れた状態で unavailable が来たとき、前のターンから変わったハッシュが階層を示し、2 つのスナップショットの差分がフィールドを示します。

非常に長い会話。 唯一の変更が非常に長いメッセージ列の奥深くにあると、位置ではなく unavailable が返ることがあります。ドキュメントはしきい値を示していません。ミスのコストが最も大きいセッションほど、これに当たりやすくなります。

ほかのプラットフォーム。 diagnostics が動くのは Claude API だけで、Amazon Bedrock、Google Cloud、Microsoft Foundry では動きません。会話を Claude API に対して再生すれば、ターン間でリクエストが変わっているかどうかは分かりますが、ほかのプラットフォームのキャッシュが何をしたかは分かりません。そちらで頼れるのは usage のフィールドだけです。

本番環境で有効にしたままにすべきか

Claude API 上のエージェントループなら、有効にしたままにすべきだと考えます。決定的なミスは必要なときにデバッグできます。null で diagnostics を有効にし、1 ターン待って判定を読めば済みます。常時有効にする理由になるのは断続的なミスです。ときどきツールの順序を入れ替えるプラグインのスキャンや、負荷がかかったときだけ発動するフォールバックなどです。有効にした時点では、ミスしたターンには比較するフィンガープリントがなく、見ている間に再発するとも限りません。一方で、この機能がリクエストを止めたり失敗させたりすることはなく、保存されるのはプロンプトのテキストではなくハッシュとトークン数の推定値で、範囲は自分の組織とワークスペースに限られます。

ただし、判定でアラートを出すのはやめましょう。アラートは読み取り数で出します。2 ターン目以降で読み取りがほぼゼロなら、diagnostics が何と言おうと確認する価値があります。判定は、どこを見ればいいかを教えてくれます。*_changed 系ならプロンプト、null なら TTL かキャッシュできないプレフィックス、unavailable ならパラメータで、フィンガープリントはそのためにあります。

キャッシュ読み取りがゼロに落ちたら、まず何を確認するか

次の順番です。まず usage の 3 つのフィールドをすべてログに残しているか確かめ、ミスと、一度もキャッシュされなかったプレフィックスを区別できるようにします。diagnostics の種類を読み、それが示す階層を直します。最初の食い違いから順に直し、もう一度見ます。判定が null なら、1 ターン目がキャッシュを書き込んだかを確認し、次にターンの間隔を見ます。unavailable なら、パラメータのフィンガープリントを比べます。そして次のターンを待つ間に、system のテキストを見てください。そこにタイムスタンプがないかを確かめるのに、コストはかかりません。

ディスカッション

コメント欄はありません。議論は X で行っています。

Max Nardit

Max Nardit

@mnardit

ほかの記事

クローンしたリポジトリの設定ファイルが、まだ実行できること

Claude Code は、リポジトリがテレメトリのエクスポートを有効にすることを禁止しました。この狭い修正の裏には、もっと広い事実があります。コミットされた設定ファイルは、あなたの権限で動くコードです。ヘッドレス実行では、事前に誰も確認しません。対話モードでは確認のダイアログが表示されますが、筆者はそれを読まずに承認しています。

Opus 5.5 は安くなった。400 エラーは簡単なほうです

400 を返すリクエストは、何を直せばいいかをすでに教えてくれています。今回のアップグレードで実際にコストになる変更は 200 で返ってきます。テキストで始まらなくなったレスポンス、一度も設定しなかったせいで一段下がった effort、そしてデプロイなしで新しいモデルに切り替わったエイリアスです。

計算できない価格

請求額は年 $96 上がりました。金額としては大したことではありません。その下で変わったのは、金額をもう導き出せなくなったことです。プランに含まれる利用枠のサイズは公開されておらず、無料クレジットの量も公開されておらず、年間クレジットは月間クレジットより割高です。公開された数字から計算できない価格は見積もりにすぎず、見積もりは、価格とは別の種類の依存です。