キャッシュ診断でプロンプトキャッシュが外れた原因を特定する

Claude.ai エンジニアリング 7分で読めます

プロンプトキャッシュが効かなくなったとき、これまで手がかりは usage.cache_read_input_tokens が 0 に落ちたという事実だけでした。キャッシュ診断(cache diagnostics)は、直前のレスポンスの id を渡すだけで、2つのリクエストのどこが食い違ったのかを API 側が特定して返すベータ機能です。本記事では有効にする手順、返ってくる6種類の理由、そして usage と組み合わせた読み方を日本語で整理します。非公式の解説です。

キャッシュ診断とは何か

プロンプトキャッシュは、プロンプトの先頭部分が直前のリクエストとバイト単位で完全に一致しているときにだけ効きます。ツールの並び順が変わった、システムプロンプトにタイムスタンプが埋め込まれた、前のメッセージを編集した。これらはどれも黙ってキャッシュを無効にします。

キャッシュ診断は、この「黙って」の部分を埋めます。前回のレスポンスの iddiagnostics.previous_message_id として渡すと、API は2つのリクエストを比較し、最初に食い違った箇所(モデル・システムプロンプト・ツール・メッセージ履歴のいずれか)を diagnostics オブジェクトとして返します。

仕組みとしては、ベータヘッダーが付いているとき、API はリクエストごとに軽量なフィンガープリントをレスポンスの id をキーにして保存します。次のリクエストでその id を渡すと、新しいリクエストのフィンガープリントを組み立て直して突き合わせます。

この比較はリクエストの構造だけを見ます。 実際にキャッシュが当たったかどうかとは独立しているので、usage.cache_read_input_tokens と組み合わせて読む必要があります(後述)。

保存されるフィンガープリントにプロンプトの生のテキストは含まれません。ハッシュとトークン数の見積もりだけで、組織とワークスペースの単位に閉じており、一定時間で失効します。この機能は ZDR(ゼロデータ保持)の対象として適格とされています。

有効にして使う

必要なのは3点です。ベータヘッダー cache-diagnosis-2026-04-07毎ターン送ること、diagnostics.previous_message_id を渡すこと、そして初回は null を渡して参加表明することです。

client = anthropic.Anthropic()
SYSTEM = "You are an AI assistant analyzing a large document. <document>...</document>"

# 1ターン目: 比較対象が無いので previous_message_id=None で参加する
r1 = client.beta.messages.create(
    model="claude-opus-5",
    max_tokens=1024,
    cache_control={"type": "ephemeral"},
    system=SYSTEM,
    messages=[{"role": "user", "content": "Summarize section 1."}],
    diagnostics={"previous_message_id": None},
    betas=["cache-diagnosis-2026-04-07"],
)

# 2ターン目: 直前のレスポンス id を渡す
r2 = client.beta.messages.create(
    model="claude-opus-5",
    max_tokens=1024,
    cache_control={"type": "ephemeral"},
    system=SYSTEM,
    messages=[
        {"role": "user", "content": "Summarize section 1."},
        {"role": "assistant", "content": r1.content},
        {"role": "user", "content": "Now summarize section 2."},
    ],
    diagnostics={"previous_message_id": r1.id},
    betas=["cache-diagnosis-2026-04-07"],
)

diagnostics = r2.diagnostics
if diagnostics is None:
    print("No divergence detected.")
elif diagnostics.cache_miss_reason is None:
    print("Comparison still pending.")
else:
    print(f"cache_miss_reason: {diagnostics.cache_miss_reason.type}")

複数ターンのループでは、毎回そのターンのレスポンス id を次のターンへ持ち回ります

messages = []
prev_id = None
for i, user_message in enumerate(["Summarize section 1.", "Now section 2.", "Now section 3."]):
    messages.append({"role": "user", "content": user_message})
    r = client.beta.messages.create(
        model="claude-opus-5",
        max_tokens=1024,
        cache_control={"type": "ephemeral"},
        system=SYSTEM,
        messages=messages,
        diagnostics={"previous_message_id": prev_id},
        betas=["cache-diagnosis-2026-04-07"],
    )
    if r.diagnostics is not None and r.diagnostics.cache_miss_reason is not None:
        print(f"Turn {i + 1} cache_miss_reason: {r.diagnostics.cache_miss_reason.type}")
    messages.append({"role": "assistant", "content": r.content})
    prev_id = r.id

ストリーミングでも使えます。その場合 diagnosticsmessage_start イベントに載ります。Python SDK なら stream.get_final_message() から同じように読めます。

cache_miss_reason の6種類

レスポンスの diagnostics フィールドは4つの状態を取ります。「フィールドが無い」と「null」と「cache_miss_reason: null」はすべて意味が違います。

意味
フィールドが無いリクエストに diagnostics を入れていないか、ベータヘッダーが無い
nullprevious_message_idnull だった(初回)か、比較した結果食い違いが無かった
{"cache_miss_reason": null}レスポンスを組み立てる時点で比較がまだ走っていた。判定不能として扱い、次のターンで確認する
{"cache_miss_reason": {...}}理由が付いた。*_changed 系なら最初の食い違い箇所を指す

cache_miss_reasontype による判別共用体で、6種類あります。報告されるのは最も早い食い違いだけなので、まずそれを直します。後ろの食い違いはその陰に隠れている可能性があります。

type何が起きたかどう直すか
model_changedmodel が前回と違う。ルーター・A/Bテスト・フォールバックが別のモデルを選んだ場合など。キャッシュはモデルごとに別キャッシュした会話の中ではモデルを固定する
system_changedsystem が違う。多くはタイムスタンプやリクエストIDがシステムプロンプトに埋め込まれているシステムプロンプトをバイト単位で不変の定数にし、動的な値はキャッシュ区切りより後ろの最初の user メッセージへ移す
tools_changedtools 配列が違う。追加・削除・並べ替えのほか、input_schema の JSON が非決定的に直列化された場合も含む毎ターン同じツールを同じ順序で送り、スキーマはキーをソートするなどして決定論的に直列化する
messages_changedモデル・システム・ツールは一致しているが、messages の前のほうの要素が書き換えられた・並べ替えられた・削除された。履歴の切り詰めや、アシスタントのターンや tool_result を再送時に別の形で直列化した場合など履歴は追記のみとして扱い、アシスタントの content とツール結果はそのまま返す
previous_message_not_found渡した previous_message_id のフィンガープリントが無い。リクエストが変わった証拠ではない。前回ベータヘッダーを付けていない、別のワークスペースだった、時間が空きすぎた、など毎ターンベータヘッダーを送り、連続するターンの間隔を空けすぎない
unavailable診断情報が得られなかった。modelsystemtools は一致しているが tool_choice / thinking / context_management / output_config / output_format / 有効な anthropic-beta ヘッダーの集合が違う場合と、比較範囲を超える非常に長い会話が該当する。リクエスト自体は通常どおり処理されるキャッシュした会話の間、プロンプトに影響するパラメータをすべて固定する

*_changed の4種類には cache_missed_input_tokens という整数も付きます。食い違いより後ろに何トークンぶんあったかの見積もりで、失われたキャッシュ可能な前置きの規模が分かります。ただしトークン化前のバイト長から導いた値なので、課金の数字としては使えませんusage.input_tokens と一致しないことがあり、まれに上回ることもあります。

usage と組み合わせて読む

diagnostics は「リクエストが変わったか」に答え、usage.cache_read_input_tokens は「キャッシュが当たったか」に答えます。別の問いなので、2つ揃えて初めてどこを見るべきかが決まります。

診断の結果キャッシュ読み込みトークン解釈
null多い期待どおり。前置きは安定していてキャッシュも当たっている
null少ない、または0リクエストは一致しているのにキャッシュのほうが消えていた。ターンの間隔を詰めるか、1時間の TTL を検討する
*_changed少ない、または0こちらのバグtype が示す原因を直す
*_changed多いまれ。プロンプトの後ろのほうで変化が起きたが、手前の cache_control 区切りが当たっている。直す価値はあるが影響は小さい

この表が使えるのは実際の previous_message_id を渡したターンだけです。初回(previous_message_id: null)は diagnostics が必ず null になり、キャッシュは読むのではなく書いている段階なので cache_read_input_tokens が 0 なのは正常です。切り分けは不要です。

また cache_miss_reasonnull(比較が保留)のときと、previous_message_not_found / unavailable のときも、この表の対象外です。比較そのものが行われていないためです。

制限と注意点

この機能はベータです。実装前に必ず公式ドキュメントで最新の仕様を確認してください。

運用上の要点を1つだけ挙げるなら、ベータヘッダーを毎ターン送ることです。前回のターンにヘッダーが無いとフィンガープリントが保存されず、次のターンは previous_message_not_found になります。これは「リクエストが変わった」ではなく「比較できなかった」なので、原因の切り分けが1周遅れます。

本記事は Anthropic の公式ドキュメント Cache diagnostics(2026-09-07 確認)に基づく非公式の日本語解説です。ベータ機能のため、フラグ名や仕様は変更される可能性があります。実装前に公式ドキュメントで最新の内容をご確認ください。