AI APIのプロンプトキャッシュが効かない原因|キャッシュヒット率の確認方法

AI APIのプロンプトキャッシュが効かない原因|キャッシュヒット率の確認方法 AI開発

OpenAI APIやClaude API、Gemini APIには、同じプロンプトを繰り返し送信したときの料金と応答時間を抑えるプロンプトキャッシュ機能があります。

しかし、プロンプトキャッシュを利用しているつもりでも、レスポンスの使用量を見るとキャッシュ済みトークンが0のままになることがあります。

よくある原因は、キャッシュ機能を有効にしていないことではありません。

キャッシュ対象となる入力が短すぎる、固定部分より前に日時やユーザーIDを入れている、ツール定義の順番が変わっている、TTLが切れているなど、リクエスト間で同じプロンプト接頭辞を再利用できていないことが主な原因です。

また、OpenAI、Claude、Geminiでは、キャッシュを有効にする方法や確認する使用量フィールドが異なります。

この記事では、AI APIのプロンプトキャッシュが効かない原因を整理したうえで、TypeScriptからキャッシュヒット率を測定する方法を解説します。OpenAI APIの基本は【TypeScript】OpenAI API入門、Claude APIの基本は【TypeScript】Claude API入門もあわせてご覧ください。

スポンサーリンク
  1. プロンプトキャッシュは回答を保存する機能ではない
  2. キャッシュはプロンプトの先頭から照合される
  3. キャッシュが効かない最も多い原因は入力が短すぎること
  4. 文字数ではなくトークン数を確認する
  5. 動的な情報を先頭へ入れるとキャッシュが壊れる
  6. JSONのキー順や空白の違いでも接頭辞が変わる
  7. ツール定義を毎回並べ替えている
  8. モデルを変更すると同じキャッシュを使えない
  9. キャッシュの有効期限が切れている
  10. 最初のリクエストは基本的にキャッシュミスになる
  11. OpenAI APIでキャッシュ使用量を確認する
  12. GPT-5.6以降でcached_tokensが0になる新しい原因
  13. prompt_cache_keyを細かく分けすぎない
  14. OpenAIのトークンキャッシュ率を計算する
  15. Claude APIでプロンプトキャッシュを有効にする
  16. Claudeではtools、system、messagesの順番が重要
  17. Claudeの明示的ブレークポイントを使う
  18. Claudeのキャッシュヒット率を計算する
  19. Claudeの長い会話で突然ヒットしなくなる原因
  20. Gemini APIで暗黙的キャッシュを確認する
  21. Geminiの明示的キャッシュを使う
  22. Geminiのキャッシュヒット率を計算する
  23. キャッシュヒット率はリクエスト単位だけでは判断できない
  24. 本番環境で共通形式へ変換する
  25. フレームワーク経由では使用量が見えない場合がある
  26. キャッシュ確認用のテストを作る
  27. プロンプトのハッシュをログへ残す
  28. キャッシュヒット率が急落したときの確認順
  29. キャッシュのために古い情報を固定しすぎない
  30. AI APIのプロンプトキャッシュに関するよくある質問
  31. まとめ

プロンプトキャッシュは回答を保存する機能ではない

最初に理解しておきたいのは、プロンプトキャッシュと一般的なレスポンスキャッシュの違いです。

Webアプリケーションで使われるレスポンスキャッシュは、同じリクエストに対して以前の回答をそのまま返す仕組みです。

一方、AI APIのプロンプトキャッシュは、入力の先頭部分をモデルが処理したときに作られる中間状態を再利用する仕組みです。

キャッシュへヒットしても、モデルは毎回新しい回答を生成します。そのため、同じプロンプトを送った場合でも、温度設定やモデルの非決定性によって回答内容が変わる可能性があります。OpenAIも、プロンプトキャッシュは出力トークンの生成方法を変えず、同じ入力から同じ出力を返すことを保証する機能ではないと説明しています。

プロンプトキャッシュが再利用するのは、主に次のような長くて変化しにくい入力です。

キャッシュ対象になりやすい入力
長いシステム指示
共通の開発ルール
大量のFew-shot例
製品マニュアル
ソースコード
ツール定義
JSON Schema
会話履歴

毎回変わるユーザーの質問まで保存し、回答そのものを再利用する機能ではありません。

キャッシュはプロンプトの先頭から照合される

多くのAI APIでは、プロンプト全体の中から似ている文章を探すのではなく、先頭から連続して一致する接頭辞をキャッシュします。

たとえば、次のリクエストでは固定のシステム指示が先頭にあり、ユーザーの質問だけが最後で変わります。

キャッシュしやすい構成
固定のシステム指示
固定の商品データ
固定の出力ルール
今回のユーザー質問

この構成であれば、固定部分をキャッシュしながら、最後の質問だけを毎回新しく処理できます。

一方、次の構成では先頭付近に現在時刻があります。

キャッシュが壊れやすい構成
現在時刻: 2026-08-04T23:30:00+09:00
固定のシステム指示
固定の商品データ
固定の出力ルール
今回のユーザー質問

次のリクエストでは現在時刻が変わるため、その位置から後ろの接頭辞が一致しません。

OpenAIはキャッシュヒットには正確な接頭辞の一致が必要であり、固定の指示や例を先頭へ、ユーザー固有の情報などの可変部分を末尾へ置くよう案内しています。画像やツール定義もリクエスト間で同一である必要があります。

キャッシュが効かない最も多い原因は入力が短すぎること

プロンプトキャッシュには、キャッシュ対象となる最低トークン数があります。

OpenAIでは、GPT-5.6以降のモデルで1,024トークン以上の接頭辞が必要です。GPT-5.5以前のモデルでは、モデルによって最低1,024~2,048トークンとなり、1,024トークンをわずかに超える程度では安定してキャッシュされない場合があります。最低トークン数に届かないリクエストでも使用量にはcached_tokensが表示されますが、値は0になります。

Claudeはモデルごとに最低トークン数が異なります。2026年8月時点では512トークンから4,096トークンまで差があります。最低トークン数に届かない状態でcache_controlを指定してもAPIエラーにはならず、キャッシュされないまま通常の入力として処理されます。

GeminiのInteractions APIでは、2026年7月30日時点でGemini 3.5 FlashとGemini 3.1 Pro Previewが4,096トークン、Gemini 2.5 FlashとGemini 2.5 Proが2,048トークンを暗黙的キャッシュの最低入力数としています。

短いシステムプロンプトを10回送ったのにキャッシュされない場合、実装ミスではなく、そもそもキャッシュ対象となる長さへ達していない可能性があります。

文字数ではなくトークン数を確認する

最低条件は文字数ではなくトークン数で判定されます。

日本語、英語、コード、JSONでは、同じ文字数でもトークン数が異なります。そのため、「5,000文字あるから1,024トークンを超えているはず」と推測するのではなく、各APIの使用量やトークン計算機能で確認します。

Gemini APIでは、送信前にcountTokensを呼び出して入力トークン数を確認できます。Googleの現行JavaScript SDKでは、次のように利用できます。

src/count-tokens.ts
import { GoogleGenAI } from "@google/genai";

const ai = new GoogleGenAI({
  apiKey: process.env.GEMINI_API_KEY,
});

const count = await ai.models.countTokens({
  model: process.env.GEMINI_MODEL,
  contents: `
ここにキャッシュ対象の
システム指示や共通ドキュメントを入れます。
  `.trim(),
});

console.log({
  totalTokens: count.totalTokens,
});

最低トークン数へ届いているかを確認するときは、ユーザーの質問を含むリクエスト全体ではなく、リクエスト間で同一になるキャッシュ対象の接頭辞が何トークンあるかを見る必要があります。

全体で5,000トークンあっても、最初の500トークンでユーザー固有の値が変わっていれば、長い共通部分を再利用できない可能性があります。

動的な情報を先頭へ入れるとキャッシュが壊れる

キャッシュ対象の接頭辞には、リクエストごとに変化する情報を入れないようにします。

特に、現在日時、リクエストID、ランダムなUUID、ユーザー名、ユーザーID、セッションID、アクセス元IP、毎回更新される利用回数などは注意が必要です。

次のシステムプロンプトは、呼び出すたびに内容が変わります。

壊れる例
const systemPrompt = `
現在日時は${new Date().toISOString()}です。
あなたはカスタマーサポート担当です。
以下の製品マニュアルを基に回答してください。

${manualText}
`.trim();

固定のマニュアルが非常に長くても、その前に現在日時があるため、日時以降の接頭辞は前回と一致しません。

次のように、固定部分と動的部分を分離します。

分離した例
const staticPrompt = `
あなたはカスタマーサポート担当です。
以下の製品マニュアルを基に回答してください。

${manualText}
`.trim();

const dynamicPrompt = `
現在日時は${new Date().toISOString()}です。
ユーザーの質問は次のとおりです。

${userQuestion}
`.trim();

APIへ送る順番も、固定部分を先、動的部分を後にします。

JSONのキー順や空白の違いでも接頭辞が変わる

ツール定義、JSON Schema、商品データなどをプロンプトへ含める場合、意味が同じでも生成された文字列が異なればキャッシュが外れる可能性があります。

次の二つのJSONは、アプリケーションから見ると同じデータです。

例1
{
  "query": "OpenAI",
  "limit": 10
}
例2
{
  "limit": 10,
  "query": "OpenAI"
}

しかし、トークン列は同一とは限りません。

オブジェクトのキー順が実行ごとに変わる言語や処理系を使用している場合、安定した順序へ正規化してから送信します。

src/stable-stringify.ts
function stableStringify(value: unknown): string {
  if (
    value === null ||
    typeof value !== "object"
  ) {
    return JSON.stringify(value);
  }

  if (Array.isArray(value)) {
    return `[${value
      .map((item) => stableStringify(item))
      .join(",")}]`;
  }

  const object =
    value as Record<string, unknown>;

  const entries = Object.keys(object)
    .sort()
    .map((key) => {
      return `${JSON.stringify(
        key,
      )}:${stableStringify(object[key])}`;
    });

  return `{${entries.join(",")}}`;
}

Claudeの公式ドキュメントでも、tool_useブロック内のJSONキー順が安定していないとキャッシュが壊れる場合があると案内されています。

文章の意味が同じでも、空白、改行、句読点、並び順を変更すれば、キャッシュ対象の接頭辞は別のものとして扱われる可能性があります。

ツール定義を毎回並べ替えている

Function Callingを使うアプリでは、ツール定義もキャッシュ対象に含まれる場合があります。

OpenAIではメッセージだけでなく、利用可能なツールの一覧もキャッシュ対象となり、最低キャッシュトークン数へ加算されます。Structured Outputsのスキーマもシステムメッセージの接頭辞としてキャッシュできます。

そのため、ツールの並び順、説明文、JSON Schemaが変わると、後続のプロンプトキャッシュにも影響します。

次の処理では、権限や画面状態によってツールの順序が変わる可能性があります。

並べ替えていない例
const tools = availableTools
  .filter((tool) => {
    return userPermissions.includes(
      tool.permission,
    );
  })
  .map((tool) => tool.definition);

ツールの組み合わせが同じ場合は、名前などで並べ替えてから送ります。

並べ替えた例
const tools = availableTools
  .filter((tool) => {
    return userPermissions.includes(
      tool.permission,
    );
  })
  .sort((a, b) => {
    return a.definition.name.localeCompare(
      b.definition.name,
    );
  })
  .map((tool) => tool.definition);

ユーザーごとに利用可能なツールが異なる場合は、すべてのユーザーで一つのキャッシュを共有しようとせず、ツールセットや権限グループ単位でキャッシュキーを分ける設計が必要です。

モデルを変更すると同じキャッシュを使えない

プロンプトの文字列が同じでも、異なるモデル間で同じプロンプトキャッシュを共有できるとは限りません。

モデルによってトークン化や内部状態が異なるためです。

本番環境でモデルをA/Bテストしている場合や、障害時に別モデルへフォールバックしている場合は、モデルごとにキャッシュ指標を集計します。

次のようにモデル名を含むキャッシュキーにすると、異なるモデルやプロンプトのバージョンを混同しにくくなります。

src/create-prompt-cache-key.ts
function createPromptCacheKey(
  tenantId: string,
  model: string,
  promptVersion: string,
): string {
  return [
    "support",
    tenantId,
    model,
    promptVersion,
  ].join(":");
}

プロンプトを更新した場合も、v1からv2へバージョンを変更します。

以前のプロンプトと新しいプロンプトに同じ名前を付けたままにすると、ログを見たときに、どのバージョンでキャッシュヒット率が低下したのか分かりにくくなります。

キャッシュの有効期限が切れている

一度キャッシュへ書き込まれても、永久に残るわけではありません。

OpenAIのGPT-5.6以降では、キャッシュされた接頭辞は少なくとも30分間再利用可能で、prompt_cache_options.ttlの現行値は30mです。OpenAIがそれより長く保持する場合もあります。GPT-5.5以前のインメモリキャッシュは、一般的に5~10分の非アクティブ状態で削除対象となり、最大で約1時間保持されます。一部の旧モデルでは最大24時間の延長保持も利用できます。

Claudeのキャッシュは標準で5分です。キャッシュが読み込まれると、有効期限は追加料金なしで更新されます。必要に応じて1時間のTTLも指定できますが、キャッシュ書き込み料金は標準入力料金の2倍になります。

1時間に1回しか実行されない処理で5分キャッシュを利用しても、毎回キャッシュミスになる可能性があります。

プロンプトキャッシュは、同じ大きな入力を短い間隔で繰り返し使う処理に向いています。

毎日1回だけ実行するバッチ処理では、キャッシュ書き込み料金が増えるだけで、読み込みによる節約が発生しない可能性もあります。

最初のリクエストは基本的にキャッシュミスになる

キャッシュが存在しない状態では、最初のリクエストでキャッシュへの書き込みが行われます。

そのため、同じプロンプトを1回だけ送信してcached_tokensが0だったとしても、キャッシュが壊れているとは判断できません。

同一の固定接頭辞を持つ2回目以降のリクエストで、キャッシュ読み込みトークンを確認します。

Claudeでは、最初のレスポンスが開始するまでキャッシュエントリを利用できません。複数のリクエストを完全に同時に送信すると、どのリクエストもキャッシュ作成前に処理を開始し、すべてキャッシュミスになる可能性があります。並列リクエストでキャッシュへヒットさせたい場合は、最初のレスポンスが始まってから後続を送る必要があります。

キャッシュの動作確認では、最初に1件だけ送信し、その完了後に同じ固定接頭辞を持つ2件目を送る方法が確実です。

OpenAI APIでキャッシュ使用量を確認する

OpenAIのResponses APIでは、キャッシュから読み込まれたトークン数がusage.input_tokens_details.cached_tokensに入ります。

GPT-5.6以降では、キャッシュへ新しく書き込まれたトークン数がcache_write_tokensに入ります。GPT-5.6以降のキャッシュ書き込みは、通常の未キャッシュ入力料金の1.25倍で課金されるため、読み込み数だけでなく書き込み数も監視する必要があります。

src/openai-cache-usage.ts
import OpenAI from "openai";

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

const model = process.env.OPENAI_MODEL;

if (!model) {
  throw new Error(
    "OPENAI_MODELが設定されていません。",
  );
}

const staticContext = `
あなたは製品サポート担当です。
次のマニュアルだけを根拠に回答してください。

${process.env.PRODUCT_MANUAL ?? ""}
`.trim();

const userQuestion =
  "製品を初期化する方法を教えてください。";

const response =
  await openai.responses.create({
    model,
    prompt_cache_key:
      "support:product-a:manual-v1",
    prompt_cache_options: {
      mode: "explicit",
    },
    input: [
      {
        role: "user",
        content: [
          {
            type: "input_text",
            text: staticContext,
            prompt_cache_breakpoint: {
              mode: "explicit",
            },
          },
          {
            type: "input_text",
            text: userQuestion,
          },
        ],
      },
    ],
  });

const usage = response.usage;

console.log({
  inputTokens:
    usage?.input_tokens ?? 0,
  cachedTokens:
    usage?.input_tokens_details
      ?.cached_tokens ?? 0,
  cacheWriteTokens:
    usage?.input_tokens_details
      ?.cache_write_tokens ?? 0,
  outputTokens:
    usage?.output_tokens ?? 0,
});

このコードでは、固定のマニュアルの直後へ明示的なキャッシュブレークポイントを設定しています。

ユーザーの質問はブレークポイントより後ろにあるため、質問が変わっても固定のマニュアル部分を再利用できます。

GPT-5.6以降でcached_tokensが0になる新しい原因

GPT-5.6以降では、従来のOpenAIモデルとキャッシュの挙動に違いがあります。

標準の暗黙的キャッシュでは、最新のユーザーメッセージまたはツールメッセージ付近へブレークポイントが置かれます。そのブレークポイントまでの接頭辞に日時やユーザー入力などの可変情報が含まれていると、数千トークンの固定部分があっても、接頭辞全体が一致せずcached_tokensが0になる可能性があります。

この場合は、固定部分の最後にprompt_cache_breakpointを設定します。

さらに、prompt_cache_options.modeexplicitにすると、OpenAIが設定する暗黙的なブレークポイントを無効化し、指定したブレークポイントだけをキャッシュ対象にできます。

src/gpt56-explicit-breakpoint.ts
const response =
  await openai.responses.create({
    model: "gpt-5.6",
    prompt_cache_key:
      "tenant:acme:rules:v3",
    prompt_cache_options: {
      mode: "explicit",
    },
    input: [
      {
        role: "user",
        content: [
          {
            type: "input_text",
            text: staticRules,
            prompt_cache_breakpoint: {
              mode: "explicit",
            },
          },
          {
            type: "input_text",
            text: `
現在日時: ${new Date().toISOString()}
質問: ${userQuestion}
            `.trim(),
          },
        ],
      },
    ],
  });

GPT-5.6以降では、より安定したキャッシュ照合を使うためにprompt_cache_keyの設定が必要です。同じ長い接頭辞を共有するリクエストには、同じキーを設定します。

prompt_cache_keyを細かく分けすぎない

prompt_cache_keyを毎回ランダムに生成すると、同じ接頭辞でも別のキャッシュへ振り分けられ、ヒット率が低下します。

次の実装は避けます。

避ける例
const promptCacheKey =
  crypto.randomUUID();

ユーザーや会話ごとに同じ接頭辞を共有したい場合は、安定した識別子を使用します。

安定した識別子を使う例
const promptCacheKey = [
  "coding-assistant",
  projectId,
  repositoryVersion,
].join(":");

一方、すべてのユーザーを一つのキーへ集約する設計も適切とは限りません。

OpenAIは、一つのprompt_cache_keyに対するすべての接頭辞のトラフィックを、おおむね1分あたり15リクエスト程度に保つよう案内しています。それを超えると負荷分散のため別の処理先へ振り分けられ、一部のリクエストがキャッシュミスになる可能性があります。高トラフィックでは、安定した規則で複数のキーへ分割します。

次のように、ユーザーIDのハッシュを複数のバケットへ分ける方法があります。

src/get-cache-bucket.ts
import {
  createHash,
} from "node:crypto";

function getCacheBucket(
  userId: string,
  bucketCount: number,
): number {
  const hash = createHash("sha256")
    .update(userId)
    .digest();

  return hash.readUInt32BE(0) %
    bucketCount;
}

const bucket = getCacheBucket(
  userId,
  32,
);

const promptCacheKey =
  `support:v2:bucket-${bucket}`;

ただし、同じキーへ異なる接頭辞を大量に混ぜると分析が難しくなります。

キャッシュキーには、用途、プロンプトバージョン、テナントやプロジェクトなど、固定接頭辞を共有する単位を反映させます。

OpenAIのトークンキャッシュ率を計算する

キャッシュヒット率には、リクエスト単位とトークン単位の二つの考え方があります。

リクエスト単位のヒット率は、キャッシュ読み込みが1トークン以上あったリクエストの割合です。

トークン単位のヒット率は、全入力トークンのうち、キャッシュから読み込まれたトークンの割合です。

長いプロンプトの一部だけがキャッシュされた場合、リクエスト単位ではヒットしていても、トークン単位では低い値になります。

src/calculate-openai-cache-metrics.ts
type OpenAICacheUsage = {
  inputTokens: number;
  cachedTokens: number;
  cacheWriteTokens: number;
};

function calculateOpenAICacheMetrics(
  records: readonly OpenAICacheUsage[],
) {
  const totals = records.reduce(
    (current, record) => {
      current.inputTokens +=
        record.inputTokens;

      current.cachedTokens +=
        record.cachedTokens;

      current.cacheWriteTokens +=
        record.cacheWriteTokens;

      current.hitRequests +=
        record.cachedTokens > 0
          ? 1
          : 0;

      return current;
    },
    {
      inputTokens: 0,
      cachedTokens: 0,
      cacheWriteTokens: 0,
      hitRequests: 0,
    },
  );

  const requestHitRate =
    records.length > 0
      ? totals.hitRequests /
        records.length
      : 0;

  const tokenHitRate =
    totals.inputTokens > 0
      ? totals.cachedTokens /
        totals.inputTokens
      : 0;

  const readWriteRatio =
    totals.cacheWriteTokens > 0
      ? totals.cachedTokens /
        totals.cacheWriteTokens
      : totals.cachedTokens > 0
        ? Number.POSITIVE_INFINITY
        : 0;

  return {
    ...totals,
    requestHitRate,
    tokenHitRate,
    readWriteRatio,
  };
}

結果をパーセント表示します。

結果表示
const metrics =
  calculateOpenAICacheMetrics(records);

console.log({
  requestHitRate:
    `${(
      metrics.requestHitRate * 100
    ).toFixed(1)}%`,
  tokenHitRate:
    `${(
      metrics.tokenHitRate * 100
    ).toFixed(1)}%`,
  readWriteRatio:
    Number.isFinite(
      metrics.readWriteRatio,
    )
      ? metrics.readWriteRatio.toFixed(2)
      : "Infinity",
});

GPT-5.6以降では、キャッシュ読み込みだけでなく、cache_write_tokensとの比率を見ることが重要です。

キャッシュヒット率が高く見えても、新しい接頭辞を大量に書き込み続けていれば、書き込み料金によって期待したほどコストが下がらない場合があります。

Claude APIでプロンプトキャッシュを有効にする

Claude APIでは、リクエストのトップレベルへcache_controlを追加する自動キャッシュと、個別のコンテンツブロックへ設定する明示的なキャッシュブレークポイントを利用できます。

自動キャッシュは、最後にキャッシュできるブロックへ自動的にブレークポイントを移動させるため、会話履歴が伸びるチャットに向いています。Claude APIの基本的な使い方は【TypeScript】Claude API入門で解説しています。

src/claude-cache-basic.ts
import Anthropic from "@anthropic-ai/sdk";

const anthropic = new Anthropic({
  apiKey:
    process.env.ANTHROPIC_API_KEY,
});

const model =
  process.env.ANTHROPIC_MODEL;

if (!model) {
  throw new Error(
    "ANTHROPIC_MODELが設定されていません。",
  );
}

const message =
  await anthropic.messages.create({
    model,
    max_tokens: 1_024,
    cache_control: {
      type: "ephemeral",
    },
    system: `
あなたは製品サポート担当です。
次のマニュアルを基に回答してください。

${process.env.PRODUCT_MANUAL ?? ""}
    `.trim(),
    messages: [
      {
        role: "user",
        content:
          "初期化方法を教えてください。",
      },
    ],
  });

console.log({
  inputTokens:
    message.usage.input_tokens,
  cacheCreationInputTokens:
    message.usage
      .cache_creation_input_tokens,
  cacheReadInputTokens:
    message.usage
      .cache_read_input_tokens,
  outputTokens:
    message.usage.output_tokens,
});

最初のリクエストでは、cache_creation_input_tokensが増え、cache_read_input_tokensは0になるのが基本です。

同じ接頭辞を持つ後続のリクエストでは、cache_read_input_tokensが増えます。

cache_creation_input_tokenscache_read_input_tokensが両方0の場合は、最低トークン数へ届いていないか、キャッシュ対象として認識されていない可能性があります。

Claudeではtools、system、messagesの順番が重要

Claudeのキャッシュは、toolssystemmessagesの順番で接頭辞を構成します。

ツール定義を変更するとツール以降のキャッシュへ影響し、システムプロンプトを変更するとシステム以降のキャッシュへ影響します。

次のように、頻繁に変わる値をシステムプロンプトへ入れると、会話履歴のキャッシュも外れやすくなります。

壊れやすい例
const system = `
現在日時: ${new Date().toISOString()}
あなたは製品サポート担当です。
${manualText}
`.trim();

日時などの可変情報は、最後のユーザーメッセージ側へ移動します。

分離した例
const system = `
あなたは製品サポート担当です。
${manualText}
`.trim();

const userMessage = `
現在日時: ${new Date().toISOString()}
質問: ${userQuestion}
`.trim();

ツール定義が頻繁に変わる場合は、ツールセットごとにキャッシュ指標を分けて監視します。

Claudeの明示的ブレークポイントを使う

自動キャッシュでは、最後のキャッシュ可能なブロックが毎回変化する内容の場合、意図しない位置へブレークポイントが置かれることがあります。

Claudeの公式ドキュメントでも、可変のタイムスタンプやユーザーメッセージを含むブロックへブレークポイントを置くと、毎回新しいキャッシュが書き込まれ、読み込みへヒットしない例が説明されています。ブレークポイントは、リクエスト間で同一になる最後のブロックへ設定します。

src/claude-explicit-breakpoint.ts
const message =
  await anthropic.messages.create({
    model,
    max_tokens: 1_024,
    system: [
      {
        type: "text",
        text: `
あなたは製品サポート担当です。
次のマニュアルを基に回答してください。

${manualText}
        `.trim(),
        cache_control: {
          type: "ephemeral",
        },
      },
    ],
    messages: [
      {
        role: "user",
        content: `
現在日時: ${new Date().toISOString()}
質問: ${userQuestion}
        `.trim(),
      },
    ],
  });

標準のTTLは5分です。

1時間キャッシュを使う場合は、ttl: "1h"を追加します。

1時間TTLの例
cache_control: {
  type: "ephemeral",
  ttl: "1h",
}

Claudeでは5分キャッシュへの書き込みが通常入力料金の1.25倍、1時間キャッシュへの書き込みが2倍、キャッシュ読み込みが通常入力料金の0.1倍です。

利用回数が少ない処理では、1時間キャッシュを使っても書き込み料金を回収できない可能性があります。

Claudeのキャッシュヒット率を計算する

Claudeでは、入力トークンがinput_tokenscache_creation_input_tokenscache_read_input_tokensへ分かれて記録されます。

トークン単位のヒット率を計算するときは、これらを合計した値を分母にします。Claudeのコンテキストウィンドウでも、三つの入力トークンがすべてカウントされます。

src/calculate-claude-cache-metrics.ts
type ClaudeCacheUsage = {
  inputTokens: number;
  cacheCreationInputTokens: number;
  cacheReadInputTokens: number;
};

function calculateClaudeCacheMetrics(
  records: readonly ClaudeCacheUsage[],
) {
  const totals = records.reduce(
    (current, record) => {
      current.regularInputTokens +=
        record.inputTokens;

      current.creationTokens +=
        record.cacheCreationInputTokens;

      current.readTokens +=
        record.cacheReadInputTokens;

      current.hitRequests +=
        record.cacheReadInputTokens > 0
          ? 1
          : 0;

      return current;
    },
    {
      regularInputTokens: 0,
      creationTokens: 0,
      readTokens: 0,
      hitRequests: 0,
    },
  );

  const totalEffectiveInput =
    totals.regularInputTokens +
    totals.creationTokens +
    totals.readTokens;

  const requestHitRate =
    records.length > 0
      ? totals.hitRequests /
        records.length
      : 0;

  const tokenHitRate =
    totalEffectiveInput > 0
      ? totals.readTokens /
        totalEffectiveInput
      : 0;

  const readWriteRatio =
    totals.creationTokens > 0
      ? totals.readTokens /
        totals.creationTokens
      : totals.readTokens > 0
        ? Number.POSITIVE_INFINITY
        : 0;

  return {
    ...totals,
    totalEffectiveInput,
    requestHitRate,
    tokenHitRate,
    readWriteRatio,
  };
}

cache_read_input_tokensが増えていても、cache_creation_input_tokensも毎回同程度増えている場合は、固定部分の後ろに可変情報を含めている可能性があります。

読み込みトークンが作成トークンを十分に上回っているかを継続的に確認します。

Claudeの長い会話で突然ヒットしなくなる原因

Claudeの明示的キャッシュでは、ブレークポイントから後ろへ最大20ブロックまでさかのぼって、以前に書き込まれた一致する接頭辞を探します。

会話が一度に20ブロック以上伸びると、以前のキャッシュエントリが検索範囲の外へ出て、突然キャッシュミスになることがあります。この場合は、会話の途中にも追加のブレークポイントを配置します。Claudeでは最大4個のキャッシュブレークポイントを利用できます。

通常のユーザーメッセージとアシスタント回答だけなら、1ターンで増えるブロック数は限定的です。

しかし、Function Callingで複数のtool_usetool_resultが追加されるエージェントでは、1回の処理で大量のブロックが増えることがあります。

ツール実行の多いエージェントでは、単に会話の最後だけをキャッシュするのではなく、固定のシステム指示やツール定義にも明示的なブレークポイントを置く設計が有効です。

Gemini APIで暗黙的キャッシュを確認する

GeminiのInteractions APIでは、Gemini 2.5以降のモデルで暗黙的キャッシュが標準で有効です。

同じ長い接頭辞を短い間隔で送信すると、自動的にキャッシュへヒットする可能性があります。共通の大きなコンテンツをプロンプトの先頭へ置くことが推奨されています。

src/gemini-implicit-cache.ts
import {
  GoogleGenAI,
} from "@google/genai";

const ai = new GoogleGenAI({
  apiKey: process.env.GEMINI_API_KEY,
});

const model =
  process.env.GEMINI_MODEL;

if (!model) {
  throw new Error(
    "GEMINI_MODELが設定されていません。",
  );
}

const interaction =
  await ai.interactions.create({
    model,
    system_instruction: `
あなたは製品サポート担当です。
次のマニュアルを基に回答してください。

${process.env.PRODUCT_MANUAL ?? ""}
    `.trim(),
    input:
      "製品の初期化方法を教えてください。",
  });

console.log({
  totalInputTokens:
    interaction.usage
      ?.total_input_tokens ?? 0,
  totalCachedTokens:
    interaction.usage
      ?.total_cached_tokens ?? 0,
  totalOutputTokens:
    interaction.usage
      ?.total_output_tokens ?? 0,
});

Interactions APIでは、キャッシュから使われたトークン数をusage.total_cached_tokensで確認できます。

キャッシュ対象の最低トークン数を超えていても、同じ接頭辞を短い間隔で送らなければ、暗黙的キャッシュへ安定してヒットしない場合があります。

Geminiの明示的キャッシュを使う

Geminiでは、generateContent APIを使用すると、キャッシュリソースを明示的に作成できます。

Interactions APIは暗黙的キャッシュだけに対応し、明示的なキャッシュオブジェクトを利用したい場合はgenerateContent APIを使用します。

src/gemini-explicit-cache.ts
import {
  GoogleGenAI,
} from "@google/genai";

const ai = new GoogleGenAI({
  apiKey: process.env.GEMINI_API_KEY,
});

const model =
  process.env.GEMINI_MODEL;

const cache =
  await ai.caches.create({
    model,
    config: {
      contents: [
        {
          role: "user",
          parts: [
            {
              text: process.env
                .PRODUCT_MANUAL ?? "",
            },
          ],
        },
      ],
      systemInstruction:
        "あなたは製品サポート担当です。",
      ttl: "3600s",
      displayName:
        "product-manual-v1",
    },
  });

if (!cache.name) {
  throw new Error(
    "キャッシュ名を取得できませんでした。",
  );
}

const response =
  await ai.models.generateContent({
    model,
    contents:
      "製品の初期化方法を教えてください。",
    config: {
      cachedContent: cache.name,
    },
  });

console.log(response.text);

console.log({
  promptTokenCount:
    response.usageMetadata
      ?.promptTokenCount ?? 0,
  cachedContentTokenCount:
    response.usageMetadata
      ?.cachedContentTokenCount ?? 0,
});

GeminiのpromptTokenCountには、キャッシュされたコンテンツも含む有効な入力全体が入ります。cachedContentTokenCountには、そのうちキャッシュから利用されたトークン数が入ります。

明示的キャッシュでは、作成時に指定したモデルと、生成時に使用するモデルを一致させます。

また、キャッシュ名をプロセス内の変数だけに保存すると、サーバーレス関数の再起動後に参照できなくなります。データベースやRedisなどへ、キャッシュ名、有効期限、モデル名、プロンプトバージョンを保存する設計が必要です。

Geminiのキャッシュヒット率を計算する

Interactions APIでは、total_input_tokensを分母、total_cached_tokensを分子としてトークンキャッシュ率を計算できます。

generateContent APIでは、promptTokenCountcachedContentTokenCountを使用します。

src/calculate-gemini-cache-metrics.ts
type GeminiCacheUsage = {
  promptTokens: number;
  cachedTokens: number;
};

function calculateGeminiCacheMetrics(
  records: readonly GeminiCacheUsage[],
) {
  const totals = records.reduce(
    (current, record) => {
      current.promptTokens +=
        record.promptTokens;

      current.cachedTokens +=
        record.cachedTokens;

      current.hitRequests +=
        record.cachedTokens > 0
          ? 1
          : 0;

      return current;
    },
    {
      promptTokens: 0,
      cachedTokens: 0,
      hitRequests: 0,
    },
  );

  return {
    ...totals,
    requestHitRate:
      records.length > 0
        ? totals.hitRequests /
          records.length
        : 0,
    tokenHitRate:
      totals.promptTokens > 0
        ? totals.cachedTokens /
          totals.promptTokens
        : 0,
  };
}

リクエストごとにモデル、キャッシュ名、プロンプトバージョンも記録しておくと、キャッシュ作成の失敗と、期限切れによるミスを区別しやすくなります。

キャッシュヒット率はリクエスト単位だけでは判断できない

100件中90件でcached_tokensが1以上なら、リクエスト単位のヒット率は90%です。

しかし、各リクエストが10,000トークンあり、キャッシュされたのが1,000トークンだけなら、トークン単位のヒット率は10%程度です。

料金へ直接影響するのは、主にキャッシュから読み込まれたトークン量です。

そのため、本番環境ではリクエストヒット率だけでなく、トークンヒット率、キャッシュ書き込み量、通常入力トークン量を記録します。

OpenAIとClaudeではキャッシュ書き込みに追加料金が発生するモデルやTTLがあるため、読み込みトークンと書き込みトークンの比率も重要です。

一度書き込んだ10,000トークンを20回読み込めれば高い効果が期待できます。

反対に、10,000トークンを書き込み、1回も再利用されない状態では、キャッシュを使用しない場合より料金が高くなる可能性があります。

本番環境で共通形式へ変換する

OpenAI、Claude、Geminiを切り替えて利用する場合は、プロバイダー固有の使用量を共通形式へ変換すると集計しやすくなります。

src/normalized-cache-usage.ts
type NormalizedCacheUsage = {
  provider:
    | "openai"
    | "anthropic"
    | "gemini";
  model: string;
  promptVersion: string;
  requestId: string | null;
  totalInputTokens: number;
  cachedReadTokens: number;
  cacheWriteTokens: number;
  outputTokens: number;
  cacheHit: boolean;
};

OpenAIのレスポンスを変換します。

src/normalize-openai-usage.ts
function normalizeOpenAIUsage(
  response: OpenAI.Responses.Response,
  model: string,
  promptVersion: string,
): NormalizedCacheUsage {
  const usage = response.usage;

  const cachedReadTokens =
    usage?.input_tokens_details
      ?.cached_tokens ?? 0;

  return {
    provider: "openai",
    model,
    promptVersion,
    requestId:
      response._request_id ?? null,
    totalInputTokens:
      usage?.input_tokens ?? 0,
    cachedReadTokens,
    cacheWriteTokens:
      usage?.input_tokens_details
        ?.cache_write_tokens ?? 0,
    outputTokens:
      usage?.output_tokens ?? 0,
    cacheHit:
      cachedReadTokens > 0,
  };
}

Claudeのレスポンスを変換します。

src/normalize-claude-usage.ts
function normalizeClaudeUsage(
  message: Anthropic.Message,
  model: string,
  promptVersion: string,
): NormalizedCacheUsage {
  const usage = message.usage;

  const cacheWriteTokens =
    usage.cache_creation_input_tokens;

  const cachedReadTokens =
    usage.cache_read_input_tokens;

  return {
    provider: "anthropic",
    model,
    promptVersion,
    requestId:
      message._request_id ?? null,
    totalInputTokens:
      usage.input_tokens +
      cacheWriteTokens +
      cachedReadTokens,
    cachedReadTokens,
    cacheWriteTokens,
    outputTokens:
      usage.output_tokens,
    cacheHit:
      cachedReadTokens > 0,
  };
}

GeminiのInteractions APIを変換します。

src/normalize-gemini-usage.ts
function normalizeGeminiUsage(
  interaction: {
    id?: string;
    usage?: {
      total_input_tokens?: number;
      total_cached_tokens?: number;
      total_output_tokens?: number;
    };
  },
  model: string,
  promptVersion: string,
): NormalizedCacheUsage {
  const cachedReadTokens =
    interaction.usage
      ?.total_cached_tokens ?? 0;

  return {
    provider: "gemini",
    model,
    promptVersion,
    requestId:
      interaction.id ?? null,
    totalInputTokens:
      interaction.usage
        ?.total_input_tokens ?? 0,
    cachedReadTokens,
    cacheWriteTokens: 0,
    outputTokens:
      interaction.usage
        ?.total_output_tokens ?? 0,
    cacheHit:
      cachedReadTokens > 0,
  };
}

Geminiの明示的キャッシュには、キャッシュリソースの作成や保存に別の料金体系が適用される場合があります。

そのため、プロバイダー横断の比較では、トークン数だけでなく、実際の請求金額も別途記録する必要があります。

フレームワーク経由では使用量が見えない場合がある

LangChain、Vercel AI SDK、各種エージェントフレームワーク、プロキシAPIを利用している場合、プロバイダーのレスポンスに含まれるキャッシュ使用量が共通形式へ変換されないことがあります。

画面上の総トークン数だけを見ていると、キャッシュが効いているのか判断できません。

調査時は、フレームワークの抽象化された使用量だけでなく、プロバイダーから返された生のレスポンスやメタデータを確認します。

ストリーミングでは、使用量が最後のイベントや最終チャンクにだけ含まれるAPIもあります。途中の差分だけを処理し、最終イベントを保存していない場合、キャッシュ使用量を取得できません。

キャッシュの検証用に、ストリーミングを使わない小さなテストスクリプトを用意すると原因を切り分けやすくなります。

キャッシュ確認用のテストを作る

プロンプトキャッシュの動作確認では、アプリケーション全体を通す前に、同じ固定接頭辞を連続して送るテストを実行します。

src/test-prompt-cache.ts
async function testPromptCache(): Promise<void> {
  const questions = [
    "この製品の初期化方法は?",
    "保証期間は何年ですか?",
    "対応OSを教えてください。",
  ];

  for (
    let index = 0;
    index < questions.length;
    index += 1
  ) {
    const startedAt =
      performance.now();

    const response =
      await createResponse(
        questions[index],
      );

    const elapsed =
      performance.now() -
      startedAt;

    console.log({
      requestNumber:
        index + 1,
      elapsedMilliseconds:
        Math.round(elapsed),
      inputTokens:
        response.usage
          ?.input_tokens ?? 0,
      cachedTokens:
        response.usage
          ?.input_tokens_details
          ?.cached_tokens ?? 0,
      cacheWriteTokens:
        response.usage
          ?.input_tokens_details
          ?.cache_write_tokens ?? 0,
    });
  }
}

1回目でキャッシュ書き込みが発生し、2回目以降で読み込みトークンが増えるかを確認します。

質問文だけを変更し、固定のシステム指示、ドキュメント、ツール定義、モデル、キャッシュキーは変更しません。

この最小テストでヒットするのに本番ではヒットしない場合、本番コード内で日時、ユーザー情報、ツール順、会話履歴などが変化しています。

プロンプトのハッシュをログへ残す

キャッシュが突然効かなくなったときは、固定接頭辞の内容が本当に同じかを確認します。

ただし、システムプロンプトやドキュメント全文をログへ保存すると、機密情報や個人情報が残る危険があります。

固定接頭辞をSHA-256などでハッシュ化し、ハッシュだけを記録します。

src/hash-prompt-prefix.ts
import {
  createHash,
} from "node:crypto";

function hashPromptPrefix(
  value: string,
): string {
  return createHash("sha256")
    .update(value)
    .digest("hex");
}

const prefixHash =
  hashPromptPrefix(staticContext);

console.log({
  model,
  promptVersion:
    "support-v3",
  prefixHash,
});

同じプロンプトバージョンなのにprefixHashがリクエストごとに変わっていれば、固定だと思っていた部分へ動的な値が混ざっています。

JSON Schemaやツール定義も含める場合は、安定したJSON文字列へ正規化してからハッシュ化します。

キャッシュヒット率が急落したときの確認順

最初に、使用モデルとキャッシュ対象の最低トークン数を確認します。

次に、キャッシュ読み込みトークンだけでなく、書き込みトークンが発生しているかを確認します。

読み込みも書き込みも0の場合は、入力が短すぎるか、キャッシュ機能が適用されていない可能性があります。

書き込みだけが毎回発生する場合は、ブレークポイントまでの接頭辞が毎回変わっています。

その後、日時、ID、ユーザー情報、ツール定義、JSON Schema、画像設定、思考設定など、接頭辞に含まれる要素を比較します。

OpenAIではprompt_cache_keyと明示的ブレークポイント、Claudeではcache_controlの位置と20ブロックの検索範囲、Geminiでは最低入力数とキャッシュリソースの有効期限を確認します。

最後に、キャッシュキー単位のリクエスト集中、TTL切れ、並列送信のタイミングを確認します。

キャッシュのために古い情報を固定しすぎない

キャッシュヒット率を上げるために、更新が必要な情報まで長期間固定するのは危険です。

製品価格、在庫、利用規約、権限、現在日時、障害情報などは、古いキャッシュを再利用すると回答の正確性へ影響します。

変更頻度の異なる情報は、キャッシュブレークポイントを分けます。

変更頻度ごとの層
ほとんど変わらない基本ルール
週単位で変わる製品マニュアル
日単位で変わる運用情報
リクエストごとに変わるユーザー質問

プロンプトのバージョンを更新したら、キャッシュキーや明示的キャッシュリソースも更新します。

キャッシュヒット率を最大化することより、正しい情報をモデルへ渡すことを優先します。

AI APIのプロンプトキャッシュに関するよくある質問

Qプロンプトキャッシュを有効にしただけでは効かないのはなぜ?

AOpenAI、Claude、Geminiのいずれも、キャッシュ対象の接頭辞に最低トークン数(数百〜数千トークン)があります。それに届かない短い入力は、機能を有効にしていてもcached_tokensが0のままになります。また、日時やユーザーIDなど毎回変わる値を接頭辞の先頭に置くと、その位置から後ろが一致しなくなりキャッシュが外れます。固定部分を先頭へ、可変部分を末尾へ配置してください。

QOpenAI・Claude・Geminiでキャッシュ確認に使うフィールドは?

AOpenAIのResponses APIはusage.input_tokens_details.cached_tokens(GPT-5.6以降はcache_write_tokensも)、Claudeはusage.cache_read_input_tokensusage.cache_creation_input_tokens、GeminiのInteractions APIはusage.total_cached_tokens、generateContent APIはusageMetadata.cachedContentTokenCountを確認します。プロバイダーごとにフィールド名が異なるため、複数プロバイダーを使う場合は共通形式へ変換して集計すると比較しやすくなります。

Q1回だけリクエストを送ってcached_tokensが0でも故障ではない?

Aはい、故障とは限りません。キャッシュが存在しない状態では、最初のリクエストで書き込みが行われるため、その回のキャッシュ読み込みは基本的に0になります。同一の固定接頭辞を持つ2回目以降のリクエストで読み込みトークンが増えているかを確認してください。並列送信の場合は、最初のレスポンスが始まってから後続を送らないと、全リクエストがキャッシュ作成前に処理されキャッシュミスになることがあります。

Qリクエスト単位のキャッシュヒット率が高いのに料金があまり下がらないのはなぜ?

Aリクエスト単位のヒット率(キャッシュに1トークン以上ヒットしたリクエストの割合)と、トークン単位のヒット率(全入力トークンに占めるキャッシュ読み込みトークンの割合)は別の指標です。長いプロンプトの一部だけがキャッシュされている場合、リクエスト単位では高い値でも、トークン単位では低いことがあります。料金へ直接影響するのはトークン単位の割合と、キャッシュ書き込み・読み込みの比率です。

まとめ

AI APIのプロンプトキャッシュが効かない場合は、最初にキャッシュ対象の接頭辞が最低トークン数へ達しているか確認します。

次に、固定のシステム指示、ドキュメント、例、ツール定義をプロンプトの先頭へ置き、日時、ユーザーID、今回の質問などの可変情報を末尾へ移動します。

OpenAIではusage.input_tokens_details.cached_tokensを確認します。GPT-5.6以降ではcache_write_tokensも記録し、prompt_cache_keyと明示的なprompt_cache_breakpointを利用します。

Claudeではcache_creation_input_tokenscache_read_input_tokensを確認します。書き込みだけが毎回増える場合は、cache_controlを可変ブロックではなく、同一になる固定接頭辞の最後へ移動します。

GeminiのInteractions APIではusage.total_cached_tokens、generateContent APIではusageMetadata.cachedContentTokenCountを確認します。

キャッシュヒット率は、キャッシュへヒットしたリクエスト数だけでなく、全入力トークンに占めるキャッシュ読み込みトークンの割合で計算することが重要です。

さらに、OpenAIとClaudeではキャッシュ書き込みにも料金が発生する場合があります。読み込みトークンと書き込みトークンの比率を記録し、本当に総費用が下がっているかを確認します。

プロンプトキャッシュは、有効にするだけで自動的に高い効果が出る機能ではありません。

固定接頭辞の構造、ブレークポイント、TTL、キャッシュキー、リクエスト頻度を継続的に測定し、用途ごとに調整する必要があります。OpenAI APIの基本は【TypeScript】OpenAI API入門、Claude APIの基本は【TypeScript】Claude API入門もあわせてご覧ください。