はじめに
OpenAI APIには、特定のモデルで利用できる「Prompt Caching」という仕組みがあります。
上手く使えば、プロンプトの一部をキャッシュして、処理時間とコストを抑えられる便利な機能です。
ただ、実際に実装してみると「キャッシュ率 0%」「毎回フル料金」という状態になることがあります。
自分もまさにこれで、最初はまったくキャッシュされず、挙動を追っているうちにプロンプトの組み立て方が原因だったことに気付きました。
この記事では、OpenAI APIをこれから使う方に向けて
- なぜ Prompt Caching がまったく効かなかったのか
- どう設計すればキャッシュされるようになるのか
を具体例を交えて解説します。
同じところでつまずきそうな方の参考になれば幸いです。
Prompt Cachingの仕組み
Prompt Caching は、前回と同じプロンプトを送った場合に、その共通部分をキャッシュとして扱う仕組みです。
キャッシュされた部分は、入力トークンの料金が通常の半額になります!
キャッシュが有効になる条件は次の3つです
- 1024 トークン以上の連続したプロンプト部分があること
- その部分が前回と完全一致していること
messagesの順番が前回と同じであること(先頭から比較される)
この条件を満たした範囲だけがキャッシュとして扱われます。
// リクエスト例
const completion = await openai.chat.completions.create({
model: 'gpt-4o-mini-2024-07-18',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage },
],
})
// キャッシュされたトークン数
console.log(completion.usage.prompt_tokens_details?.cached_tokens)
messages配列は先頭から順に比較されます。
前回とまったく同じメッセージが続くあいだは、その部分のプロンプトがキャッシュとして扱われます。どこかで内容が異なるメッセージが出てきたところから先は、すべて通常どおりの新規処理になります。
初回: [A, B, C] → すべて新規
2回目: [A, B, D] → AとBはキャッシュ、Dは新規
3回目: [B, A, E] → AとBの内容が一緒でも順番が異なるため、キャッシュされない
なぜキャッシュされなかったか
自分が最初にやっていたパターンは、こんな形でした
const systemPrompt = `
あなたは${userName}さんのアシスタントです。
現在の日時は${currentTime}です。
以下のルールに従ってください:
- 丁寧な言葉遣いを使う
- 簡潔に回答する
`
messages = [
{ role: 'system', content: systemPrompt },
...
]
ユーザー名や日時のように毎回変わる値を、システムメッセージの中に一緒に入れてしまっていたため、この systemPrompt 全体が毎回 別のプロンプトとして扱われていました。
その結果、キャッシュが一度も発動せず、ずっとキャッシュ率 0% のままになっていました。
ログを見直してみると、「丁寧な言葉遣いを使う」「簡潔に回答する」といったルール部分は毎回同じなのに、そういった静的な内容と動的な値を 1 つのメッセージにまとめて送っていたことが原因だと分かりました。
解決策: 静的・動的に分ける
結局のところ、やるべきことはシンプルでした。
「毎回同じもの」と「毎回変わるもの」をきっちり分けるだけです。
当たり前といえば当たり前なんですが、実装していると意外と全部を1つのメッセージに突っ込んでしまいがちなんですよね。自分もそうでした。
では何を静的と見なして、どこが動的なのか。ざっくり整理するとこんな感じです。
静的(全リクエストで共通)
- システムのルールや指示
- 出力フォーマット
- ツールや関数の説明
動的(リクエストごとに変わる)
- ユーザー名やID
- 検索結果やコンテキスト
- 日時やセッション情報
ここがごちゃまぜになっていると、一生キャッシュは効きません。
だから、プロンプトを「静的」「動的」の2つのメッセージに分けて送るようにしました。
type PromptParts = {
static: string // 静的部分
dynamic: string // 動的部分
}
function buildPrompt(
staticRules: string[],
dynamicContext: Record<string, unknown>
): PromptParts {
const staticPrompt = staticRules.join('\n\n')
const dynamicPrompt = Object.entries(dynamicContext)
.map(([key, value]) => `${key}: ${value}`)
.join('\n')
return { static: staticPrompt, dynamic: dynamicPrompt }
}
静的部分には、どのリクエストでも変わらないルールセットを入れます。
const STATIC_RULES = [
'あなたは優秀なアシスタントです。',
'以下のルールに従ってください:\n- 丁寧な言葉遣いを使う\n- 簡潔に回答する',
'出力はJSON形式で返してください。',
]
動的部分には、その都度書き換わる値を
const context = {
userName: '田中',
currentTime: '2025-11-17 10:00',
}
API呼び出し時は、静的部分を先頭に、動的部分を次に配置します
async function chat(
staticRules: string[],
context: Record<string, unknown>,
userMessage: string
) {
const prompt = buildPrompt(staticRules, context)
const completion = await openai.chat.completions.create({
model: 'gpt-4o-mini-2024-07-18',
messages: [
{ role: 'system', content: prompt.static }, // 静的部分(キャッシュされる)
{ role: 'system', content: prompt.dynamic }, // 動的部分
{ role: 'user', content: userMessage },
],
})
return {
content: completion.choices[0].message.content,
cachedTokens: completion.usage.prompt_tokens_details?.cached_tokens ?? 0,
}
}
実際に動かすと、こんな感じで差が出ます
const RULES = [
'あなたは優秀なアシスタントです。',
'出力はJSON形式で返してください。',
]
// 1回目
const result1 = await chat(
RULES,
{ userName: '田中', currentTime: '2025-11-17 10:00' },
'おはようございます'
)
// cachedTokens: 0
// 2回目(静的部分は同じ)
const result2 = await chat(
RULES,
{ userName: '鈴木', currentTime: '2025-11-17 10:05' },
'こんにちは'
)
// cachedTokens: 1000(静的部分がキャッシュから取得された)
結果
検証してみると、はっきり差が出ました
初回: promptTokens: 1200, cachedTokens: 0
2回目以降: promptTokens: 1200, cachedTokens: 1000(約83%がキャッシュ)
静的部分(約1000トークン)が丸ごと半額になるので、コストが一気に落ちます!
レスポンスも軽くなるので、普通に嬉しい。
まとめ
キャッシュ率 0%だった原因は、動的な値を含むプロンプトをそのまま1メッセージに押し込んでいたことでした。
静的な部分と動的な部分を適切に分けて、静的な方を messages の先頭に置いてあげるだけでキャッシュがちゃんと働くようになります。
ポイントはこの2つ。
- 静的な部分(共通のルール)と動的な部分(都度変わる情報)を分ける
- 静的部分を
messagesの先頭に置く
同じところでつまずきそうな人の助けになれば嬉しいです。