Rork でAI機能を搭載したアプリを3本リリースしてから、ある月に請求書を開いて固まりました。OpenAIの月額請求が ¥52,000 になっていたのです。
ユーザー数は約800人。一人あたり月 ¥65 のAIコストは、アプリの収益(月額サブスク ¥580)と比べると無視できない比率です。このままスケールしたら、ユーザーが増えるほど赤字が膨らむ構造になっていました。
調べてみると、問題はシンプルでした。同じプロンプトが何度もAPIに投げられていた のです。たとえばカテゴリ分類の処理では、似たような入力が一日に何百回も送信されていて、すべてに課金されていました。
解決策として導入したのが Cloudflare AI Gateway です。3週間後、同じトラフィックでの月額AIコストは ¥4,800 になっていました。
ここでは私が実装した Cloudflare AI Gateway の設計を、コードレベルで全て公開します。
なぜ個人開発アプリで AI コストが構造的な問題になるのか
実装の話に入る前に、この問題がなぜ「たまたま高かった」で片づけられないのかを整理しておきます。
AI機能を組み込むと、アプリのコスト構造はそれまでの機能とは質的に変わります。プッシュ通知の基盤は、ユーザーが100人でも1万人でも維持費はほぼ一定です。ところがAI APIのコストは、利用量に比例して増えます。設計を誤れば、比例どころか二乗で膨らむこともあります。
下の表は、最適化前のAIコストを1人あたり月 ¥65、サブスクを月額 ¥580 として、ユーザー数ごとに利益を試算したものです。
小さな規模では問題なく見えます。けれど、AIコストが売上に占める比率は約11%のまま、どれだけ成長しても改善しません。しかもこの試算は「全員が課金する」前提です。無料から有料への転換率が現実的な5〜8%だとすると、採算は一気に反転します。
キャッシュはこの構造そのものを変えます。人気のプロンプトが繰り返されるだけのユーザー数が集まると、新規ユーザー1人あたりの限界コストは下がり始めます。キャッシュヒット率70%を実現できれば、5万人規模でのAIコストは ¥3,250,000 ではなく ¥975,000 前後まで落ちます。規模が大きくなるほど採算が良くなる構造へ変わるわけです。
私が請求書を見て固まったのは、この「成長するほど赤字が膨らむ」構造に気づいた瞬間でした。手を打たなければ、ユーザーが増えることを素直に喜べなくなってしまいます。個人開発者にとって、それはいちばん避けたい状況なのです。
Cloudflare AI Gateway とは何か — Rork アプリとの相性
Cloudflare AI Gateway は、あなたのアプリと OpenAI・Anthropic・Google Gemini などのAIプロバイダーの間に置くインテリジェントプロキシ です。
URLを一箇所変えるだけで導入でき、以下の機能が手に入ります。
レスポンスキャッシュ : 同一のリクエストであれば、2回目以降をキャッシュから返します
マルチプロバイダー対応 : OpenAI・Anthropic・Gemini・Groq など主要プロバイダーに対応
フォールバック設定 : プライマリのプロバイダーが落ちたとき、別のプロバイダーへ自動で切り替えます
レート制限 : ユーザー単位・グローバルでリクエストの上限を設定できます
詳細ログとアナリティクス : どのプロンプトが何回使われたかをダッシュボードで確認できます
料金は、無料枠で1日10万リクエスト まで。個人開発アプリであればほぼ無料で運用できます。
Rork アプリとの組み合わせが特に有効な理由
Rork で作るアプリの多くは、ユーザーの操作に応じてAIを呼び出す設計をとっています。たとえば:
画像の説明文の生成(何百枚もの写真に、同じ構造のプロンプト)
テキストのカテゴリ分類(有限のカテゴリに、大量の入力)
おすすめコンテンツの生成(人気のコンテンツには、多くのユーザーが似たリクエストを送ります)
これらのユースケースでは、入力が似ていれば出力も似ている という性質があります。キャッシュが非常に効きやすい構造です。
アーキテクチャ設計 — どこに Gateway を置くか
Rork アプリから直接 OpenAI API を叩く構成から、Cloudflare Workers + AI Gateway を経由する構成に切り替えます。
[Rork アプリ]
↓ HTTPS
[Cloudflare Workers (あなたのプロキシ)]
↓ AI Gateway 経由
[OpenAI / Gemini / Anthropic]
なぜアプリから直接 AI Gateway を叩かないのか 、と思われるかもしれません。理由は2つあります。
APIキーをモバイルアプリに埋め込むのはセキュリティリスク
リクエストの前処理(キャッシュキーの決定・ユーザー認証・レート制限チェック)をサーバー側で行う必要があること
Cloudflare Workers はリクエスト1億件まで無料プランがあるため、中間レイヤーのコストはほぼゼロです。
Step 1: Cloudflare AI Gateway のセットアップ
Cloudflare ダッシュボード → AI Gateway → 「Create Gateway」でゲートウェイを作成します。
ゲートウェイ作成後、エンドポイントURLが発行されます:
https://gateway.ai.cloudflare.com/v1/{ACCOUNT_ID}/{GATEWAY_ID}/openai
https://gateway.ai.cloudflare.com/v1/{ACCOUNT_ID}/{GATEWAY_ID}/google-ai-studio
https://gateway.ai.cloudflare.com/v1/{ACCOUNT_ID}/{GATEWAY_ID}/anthropic
既存のコードでは https://api.openai.com/v1 を使っているはずです。これを上記のゲートウェイURLに変えるだけで、ログとアナリティクスは流れ始めます。
ただし、それだけではキャッシュは効きません。キャッシュは既定で無効 で、ダッシュボードの Settings で「Cache Responses」を有効にするか、リクエスト側でキャッシュ用のヘッダーを付けるまで、1件も保存されないためです。私は最初、URLを差し替えた翌朝にヒット率を見に行って、0% の表示をしばらく眺めておりました。
Step 2: Cloudflare Workers でプロキシを実装する
Rork アプリのバックエンドとして使う Cloudflare Workers の実装です。
// workers/ai-proxy/src/index.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
const app = new Hono < { Bindings: {
OPENAI_API_KEY: string
GEMINI_API_KEY : string
ANTHROPIC_API_KEY : string
AI_GATEWAY_ACCOUNT_ID : string
AI_GATEWAY_ID : string
RATE_LIMIT : KVNamespace
}} > ()
app. use ( '*' , cors ({
origin: [ 'https://your-app-domain.com' ],
allowHeaders: [ 'Content-Type' , 'Authorization' , 'X-User-ID' ],
}))
// ミドルウェア: ユーザーID必須チェック
app. use ( '/ai/*' , async ( c , next ) => {
const userId = c.req. header ( 'X-User-ID' )
if ( ! userId) {
return c. json ({ error: 'Unauthorized' }, 401 )
}
c. set ( 'userId' , userId)
await next ()
})
// テキスト生成エンドポイント
app. post ( '/ai/generate' , async ( c ) => {
const userId = c. get ( 'userId' )
const body = await c.req. json ()
const { prompt , model = 'gpt-4o-mini' , cache = true } = body
// レート制限チェック(ユーザー単位:1時間に50リクエスト)
const rateLimitKey = `ratelimit:${ userId }:${ Math . floor ( Date . now () / 3600000 ) }`
const currentCount = parseInt ( await c.env. RATE_LIMIT . get (rateLimitKey) || '0' )
if (currentCount >= 50 ) {
return c. json ({
error: 'Rate limit exceeded. Please wait before making more requests.' ,
retryAfter: 3600 - (Date. now () % 3600000 ) / 1000
}, 429 )
}
// カウンターをインクリメント(TTL: 1時間)
await c.env. RATE_LIMIT . put (rateLimitKey, String (currentCount + 1 ), {
expirationTtl: 3600
})
// AI Gateway 経由で OpenAI を呼び出す
const gatewayBase = `https://gateway.ai.cloudflare.com/v1/${ c . env . AI_GATEWAY_ACCOUNT_ID }/${ c . env . AI_GATEWAY_ID }`
// キャッシュキーは自分で決める(理由は次章)
const cacheKey = buildCacheKey (prompt, model, 'generate' )
const cacheable = cache && ! userId. startsWith ( 'personal:' ) && isCacheable (prompt)
const headers : Record < string , string > = {
'Content-Type' : 'application/json' ,
'Authorization' : `Bearer ${ c . env . OPENAI_API_KEY }` ,
// 最初の1バイトが返るまでの上限。超えたらエラーになり、下の分岐へ落ちる
'cf-aig-request-timeout' : '8000' ,
// 一時的な失敗はゲートウェイ側で再送(試行は最大5回、遅延は最大5秒)
'cf-aig-max-attempts' : '3' ,
'cf-aig-retry-delay' : '500' ,
'cf-aig-backoff' : 'exponential' ,
}
if (cacheable) {
// この2行がないと、正規化した文字列はキャッシュに届かない
headers[ 'cf-aig-cache-key' ] = cacheKey
headers[ 'cf-aig-cache-ttl' ] = '86400'
} else {
headers[ 'cf-aig-skip-cache' ] = 'true'
}
try {
const response = await fetch ( `${ gatewayBase }/openai/chat/completions` , {
method: 'POST' ,
headers,
body: JSON . stringify ({
model,
messages: [{ role: 'user' , content: prompt }],
temperature: 0 ,
max_tokens: 1000 ,
}),
})
if ( ! response.ok) {
// 429 以外の 4xx は「送った内容そのもの」が原因。
// 別のプロバイダーへ回しても同じ結果になるので、ここで返す
if (response.status >= 400 && response.status < 500 && response.status !== 429 ) {
const detail = await response. text ()
console. warn ( `OpenAI rejected the request: ${ response . status } ${ detail . slice ( 0 , 200 ) }` )
return c. json ({ error: 'Invalid request for the AI provider' }, 400 )
}
console. warn ( `OpenAI error: ${ response . status }, falling back to Gemini` )
return await callGeminiFallback (c, prompt, gatewayBase, cacheable ? cacheKey : null )
}
const data = await response. json ()
// HIT / MISS をそのままクライアントへ渡す(体感速度の説明に使える)
return c. json (data, 200 , {
'x-ai-cache-status' : response.headers. get ( 'cf-aig-cache-status' ) ?? 'UNKNOWN' ,
'x-ai-provider' : 'openai' ,
})
} catch (error) {
console. error ( 'OpenAI request failed:' , error)
// ネットワークエラー・タイムアウトは Gemini にフォールバック
return await callGeminiFallback (c, prompt, gatewayBase, cacheable ? cacheKey : null )
}
})
// Gemini フォールバック関数
async function callGeminiFallback (
c : any ,
prompt : string ,
gatewayBase : string ,
cacheKey : string | null
) {
const headers : Record < string , string > = {
'Content-Type' : 'application/json' ,
'x-goog-api-key' : c.env. GEMINI_API_KEY ,
'cf-aig-request-timeout' : '8000' ,
}
if (cacheKey) {
// プロバイダーが変わるとキーも変わるので、接頭辞で分けておく
headers[ 'cf-aig-cache-key' ] = `gemini:${ cacheKey }`
headers[ 'cf-aig-cache-ttl' ] = '86400'
} else {
headers[ 'cf-aig-skip-cache' ] = 'true'
}
try {
const response = await fetch (
`${ gatewayBase }/google-ai-studio/v1beta/models/gemini-2.0-flash:generateContent` ,
{
method: 'POST' ,
headers,
body: JSON . stringify ({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { temperature: 0 },
}),
}
)
if ( ! response.ok) {
throw new Error ( `Gemini also failed: ${ response . status }` )
}
const data : any = await response. json ()
const text = data?.candidates?.[ 0 ]?.content?.parts?.[ 0 ]?.text
// 安全性フィルタで止まった応答には content が入らない。
// ここを素通りさせると、ステージングでは緑のまま本番でだけ TypeError になる
if ( typeof text !== 'string' ) {
const reason = data?.promptFeedback?.blockReason ?? data?.candidates?.[ 0 ]?.finishReason
console. error ( 'Gemini returned no text. reason:' , reason)
return c. json ({ error: 'AI service temporarily unavailable' }, 503 )
}
// OpenAI 互換フォーマットに変換して返す
return c. json (
{
choices: [{ message: { content: text } }],
_provider: 'gemini-fallback' , // デバッグ用
},
200 ,
{
'x-ai-cache-status' : response.headers. get ( 'cf-aig-cache-status' ) ?? 'UNKNOWN' ,
'x-ai-provider' : 'gemini' ,
}
)
} catch (fallbackError) {
console. error ( 'All providers failed:' , fallbackError)
return c. json ({ error: 'AI service temporarily unavailable' }, 503 )
}
}
export default app
このコードで実現していることを並べます。
ユーザー認証 : X-User-ID ヘッダーがなければ 401 を返します
レート制限 : KV ストアを使い、ユーザー単位で時間あたりの回数を抑えます
キャッシュ : cf-aig-cache-key でキーを自分で決め、cf-aig-cache-ttl で24時間保持します
タイムアウトと再送 : cf-aig-request-timeout と cf-aig-max-attempts を渡し、待ち時間と再試行をゲートウェイ側に任せます
フォールバック : 5xx・429・ネットワークエラーのときだけ Gemini に切り替えます。4xx は切り替えません
OpenAI 互換レスポンス : Gemini を使った場合も同じ形で返すため、アプリ側の変更は要りません
レート制限のカウンターについて、ひとつだけお伝えしておきます。上のコードは KV を読んでから書く形なので、同じ利用者からの同時リクエストでは数え落としが起きます。上限を「課金を守る目安」として使うぶんには十分ですが、厳密に止めたい場面では Durable Object か Workers のレート制限バインディングへ寄せてください。私は前者を目安として使い、費用に直結する箇所だけ後者に置き換えております。
正規化関数を書いても、ヒット率が二週間動かなかった話
キャッシュキーの設計に入る前に、私がいちばん時間を溶かした場所を先にお伝えします。
導入した翌週、私はプロンプトの正規化関数を書いて満足しておりました。全角のコロンを半角へ寄せ、連続する空白をひとつに畳み、小文字へ揃えるだけの短い関数です。ローカルのテストでは、三つの表記ゆれがきれいに同じ文字列へ収束しました。それなのに、ダッシュボードのキャッシュヒット率は12%のまま、二週間ほど動かなかったのです。
原因は正規化のロジックではありませんでした。既定のキャッシュキーは、私の関数の出力ではなく、実際に送ったリクエストそのものから作られていた のです。公式ドキュメントの Caching — How the cache key works には、既定のキーが次の5つを連結して SHA-256 でハッシュしたものだと書かれています。
つまり、正規化した文字列をボディに載せて送るか 、cf-aig-cache-key でキーそのものを上書きするか、どちらかをしない限り、正規化関数はどこにも効きません。私が書いていたのは、定義しただけでリクエストのどこにも渡していない関数でした。ダッシュボードを何度も開き直し、設定を疑い、プランを疑い、最後にコードを読み返して手が止まりました。
キーを決めているのは私の正規化関数ではなく、実際に送り出したリクエストそのものです。 この一行に辿り着くまでに、二週間かかりました。
認証ヘッダーがキーに含まれる点も、覚えておくと後で助かります。APIキーをローテーションした翌日にコストが跳ねたとしても、それは障害ではなく、キャッシュが総入れ替えになっただけかもしれません。原因を外に探しに行かずに済みます。
Step 3: キャッシュキーの設計 — 何をキャッシュして何をキャッシュしないか
キャッシュ設計で最も重要なのは「キャッシュヒット率を上げながら、個人情報や動的なコンテキストをキャッシュしない 」ことです。
// キャッシュ可否の判定ロジック
function shouldCache ( prompt : string , context : Record < string , any >) : {
cache : boolean
reason : string
} {
// 個人名が含まれるプロンプトはキャッシュしない
const personalPatterns = /私の | 私は | 自分の | 名前は | [A-Z][a-z] + さん/
if (personalPatterns. test (prompt)) {
return { cache: false , reason: 'personal_content' }
}
// 時刻・日付参照があるプロンプトはキャッシュしない
const timePatterns = /今日 | 現在 | 今 | 昨日 | 最新 | 今週/
if (timePatterns. test (prompt)) {
return { cache: false , reason: 'time_sensitive' }
}
// プロンプトが短すぎる(50文字以下)はキャッシュしない
// → 同じ短いプロンプトでも文脈が異なる可能性が高い
if (prompt. length < 50 ) {
return { cache: false , reason: 'too_short' }
}
// カテゴリ分類・要約・翻訳は積極的にキャッシュ
const cacheFriendlyPatterns = /以下を分類 | 要約して | 翻訳して | カテゴリは | ジャンルを | タグを付けて/
if (cacheFriendlyPatterns. test (prompt)) {
return { cache: true , reason: 'classification_task' }
}
// デフォルト: キャッシュする
return { cache: true , reason: 'default' }
}
// プロンプトの正規化(キャッシュヒット率向上)
function normalizePrompt ( prompt : string ) : string {
return prompt
. trim ()
. normalize ( 'NFKC' ) // 全角コロン・全角英数を半角へ寄せる
. replace ( / \s + / g , ' ' ) // 複数スペースを単一スペースに
. replace ( /。 \s * \n / g , '。' ) // 句読点後の改行を除去
. toLowerCase () // 大文字小文字を統一
}
// 正規化した文字列を、そのままキャッシュキーへ落とす
// FNV-1a: 依存ゼロで、Workers でも React Native でも同じ値になる
function fnv1a ( input : string ) : string {
let hash = 0x811c9dc5
for ( let i = 0 ; i < input. length ; i ++ ) {
hash ^= input. charCodeAt (i)
hash = Math. imul (hash, 0x01000193 ) >>> 0
}
return hash. toString ( 16 ). padStart ( 8 , '0' )
}
export function buildCacheKey ( prompt : string , model : string , task : string ) : string {
// モデルを含めるのは、差し替えたときに古い答えを引かないため
return `${ task }:${ model }:${ fnv1a ( normalizePrompt ( prompt )) }`
}
export function isCacheable ( prompt : string ) : boolean {
return shouldCache (prompt, {}).cache
}
キャッシュキー正規化の効果
実装前後の比較です。
正規化なし(ヒット率: 約12%):
"以下のテキストを分類してください:\nAIについて学ぶ"
"以下のテキストを分類してください: AIについて学ぶ"
"以下のテキストを分類してください:AIについて学ぶ"
正規化あり(ヒット率: 約67%):
"以下のテキストを分類してください: aiについて学ぶ" // ← 全パターンが同じキーに
全角半角・句読点・スペースの違いでキャッシュミスが起きていたのが、cf-aig-cache-key に正規化後のハッシュを載せたことで揃いました。効いたのは正規化そのものではなく、正規化の結果をキーとして送るようにした一行でした。
cf-aig-cache-key を使うときの注意も、あわせて書き残します。
このヘッダーを付けると、そのリクエストはキャッシュ対象になります 。ゲートウェイ側の既定が無効のままでも保存されます
cf-aig-cache-ttl を付けなければ、ゲートウェイの既定 TTL が使われます。既定も未設定なら 5分 です。24時間のつもりで放置すると、ヒット率が伸びない原因になります
TTL は 最短60秒・最長1か月 の範囲で指定します
キーを自分で決める以上、同じキーに寄せた入力は同じ答えを返します。分類・要約・翻訳のように出力が一意に定まるタスクにだけ使い、生成の自由度が高いタスクには使いません
寄せてよいのは、答えが一つに定まる問いだけです。 この線引きを外すと、コストは下がっても、読み手が受け取る答えが少しずつずれていきます。
Step 4: Rork アプリ側の実装
Rork のフロントエンド(React Native)から Workers を呼び出す実装です。
// hooks/useAI.ts
import { useState, useCallback } from 'react'
import AsyncStorage from '@react-native-async-storage/async-storage'
const AI_WORKER_URL = 'https://your-worker.your-subdomain.workers.dev'
interface AIRequestOptions {
prompt : string
model ?: string
cache ?: boolean
localCacheTTL ?: number // ローカルキャッシュの有効期間(秒)
}
interface AIResponse {
content : string
fromCache : boolean // 端末内キャッシュから返したか
cacheStatus : string // ゲートウェイ側の HIT / MISS
provider : string
}
// サーバーと同じ関数を共有する(値がずれるとキーが割れる)
import { buildCacheKey } from '../shared/cacheKey'
export function useAI () {
const [ loading , setLoading ] = useState ( false )
const [ error , setError ] = useState < string | null >( null )
const generate = useCallback ( async ( options : AIRequestOptions ) : Promise < AIResponse | null > => {
const { prompt , model = 'gpt-4o-mini' , cache = true , localCacheTTL = 3600 } = options
setLoading ( true )
setError ( null )
// ローカルキャッシュ(AsyncStorage)チェック
// Cloudflare キャッシュの前段として、アプリ内でもキャッシュする
if (cache) {
const cacheKey = `ai_cache:${ buildCacheKey ( prompt , model , 'generate' ) }`
try {
const cached = await AsyncStorage. getItem (cacheKey)
if (cached) {
const { data , timestamp } = JSON . parse (cached)
if (Date. now () - timestamp < localCacheTTL * 1000 ) {
setLoading ( false )
return { ... data, fromCache: true }
}
}
} catch (e) {
// ローカルキャッシュエラーは無視して続行
console. warn ( 'Local cache read error:' , e)
}
}
try {
const userId = await getUserId () // デバイスIDまたはユーザーID
const response = await fetch ( `${ AI_WORKER_URL }/ai/generate` , {
method: 'POST' ,
headers: {
'Content-Type' : 'application/json' ,
'X-User-ID' : userId,
},
body: JSON . stringify ({ prompt, model, cache }),
signal: AbortSignal. timeout ( 15000 ), // 15秒タイムアウト
})
if (response.status === 429 ) {
setError ( 'しばらく時間をおいてからお試しください' )
setLoading ( false )
return null
}
if ( ! response.ok) {
throw new Error ( `AI request failed: ${ response . status }` )
}
const data = await response. json ()
const content = data.choices?.[ 0 ]?.message?.content
if ( typeof content !== 'string' ) {
throw new Error ( 'AI response had no content' )
}
const provider = response.headers. get ( 'x-ai-provider' ) || data._provider || 'openai'
// ゲートウェイが HIT を返したかどうかは、ここでしか分からない
const cacheStatus = response.headers. get ( 'x-ai-cache-status' ) || 'UNKNOWN'
const result : AIResponse = { content, fromCache: false , cacheStatus, provider }
// ローカルキャッシュに保存
if (cache) {
const cacheKey = `ai_cache:${ buildCacheKey ( prompt , model , 'generate' ) }`
await AsyncStorage. setItem (cacheKey, JSON . stringify ({
data: result,
timestamp: Date. now (),
})). catch (() => {}) // キャッシュ保存失敗は無視
}
setLoading ( false )
return result
} catch ( err : any ) {
if (err.name === 'TimeoutError' ) {
setError ( 'AI の応答がタイムアウトしました' )
} else {
setError ( 'AI サービスに接続できません' )
}
setLoading ( false )
return null
}
}, [])
return { generate, loading, error }
}
// デバイス固有IDの取得(ユーザー登録前でも使用可能)
async function getUserId () : Promise < string > {
let userId = await AsyncStorage. getItem ( 'user_id' )
if ( ! userId) {
userId = `device:${ Math . random (). toString ( 36 ). slice ( 2 ) }`
await AsyncStorage. setItem ( 'user_id' , userId)
}
return userId
}
2段階キャッシュ を実装している点が要になります。
ローカルキャッシュ(AsyncStorage) : 端末の中だけで完結し、通信が発生しません
ゲートウェイのキャッシュ : サーバー側に置かれ、利用者のあいだで共有されます
この2段階により、API コストの削減に加えてレスポンスタイムも改善されます。
btoa でキーを作ると、日本語のプロンプトで落ちます
ここは私が実際に踏んだところなので、少し丁寧に書き残します。
ローカルキャッシュのキーを btoa(prompt) から作る書き方を、私は最初に採っておりました。英語のプロンプトでは動いてしまうぶん、厄介です。btoa は Latin-1 の範囲を超える文字を渡されると InvalidCharacterError を投げます。日本語のプロンプトを一度でも通した時点で例外になり、キーの計算を try の外に置いていれば、AI 呼び出しごと失敗します。
日本語のテストを後回しにしていたので、気づいたのは英語圏の利用者に配ったあとでした。手元では動き、レビューも通り、日本語の端末でだけ画面が進まない——いちばん見つけにくい形です。
もうひとつ、btoa(prompt).slice(0, 50) は先頭だけを切り取ります。同じ書き出しで終わりが違うプロンプトが、同じキーに落ちます。上の buildCacheKey に置き換えると、文字集合の問題も先頭一致の衝突も同時に消えます。端末とサーバーで同じ関数を使う という一点だけ守ってください。片方だけ直すと、二段のキャッシュが別々のキーで動き始めます。
よくある間違いと落とし穴 — 3つの失敗パターン
落とし穴 1: ストリーミングレスポンスとキャッシュの相性問題
// ❌ これはキャッシュが効かない
const response = await fetch (gatewayUrl, {
body: JSON . stringify ({
stream: true , // ← ストリーミングはキャッシュ不可
...
})
})
// ✅ キャッシュしたいリクエストは stream: false に
// UI でのストリーミング体験が必要な場合は、
// キャッシュヒット時だけ非ストリーミングで返す設計にする
const useStream = ! cacheHit
const response = await fetch (gatewayUrl, {
body: JSON . stringify ({
stream: useStream,
...
})
})
Cloudflare AI Gateway は ストリーミングレスポンス(SSE)をキャッシュしません 。リアルタイム表示が必要な機能と、キャッシュ対象の機能を明確に分けてください。
落とし穴 2: 温度パラメータ(temperature)によるキャッシュ分散
// ❌ temperature が異なるとキャッシュキーが変わる
const responses = await Promise . all ([
fetchWithGateway ({ prompt, temperature: 0.7 }),
fetchWithGateway ({ prompt, temperature: 0.8 }),
// → 同じプロンプトなのに別のキャッシュキー
])
// ✅ カテゴリ分類・要約タスクは temperature: 0 に固定
// キャッシュヒット率が劇的に向上する
const response = await fetchWithGateway ({
prompt,
temperature: 0 , // 決定論的な出力 → キャッシュ効率最大
})
再現性が求められるタスク(分類・要約・翻訳)は temperature: 0 に固定することで、同一プロンプトが常に同じキャッシュキーになります。
落とし穴 3: フォールバック先も同じエラーになるケース
// ❌ OpenAI のレート制限エラー(429)でフォールバックしても、
// Gemini でも同様の問題が起きる場合がある
// ✅ エラー種別に応じてフォールバック戦略を変える
async function callWithFallback ( prompt : string , env : Env ) {
try {
return await callOpenAI (prompt, env)
} catch ( error : any ) {
// 429 (レート制限): フォールバックは有効
if (error.status === 429 ) {
console. warn ( 'OpenAI rate limited, trying Gemini' )
return await callGemini (prompt, env)
}
// 500/503 (サーバーエラー): フォールバック有効
if (error.status >= 500 ) {
return await callGemini (prompt, env)
}
// 400 (不正リクエスト): フォールバックしても意味がない
// プロンプト側の問題なので、エラーをそのまま返す
if (error.status === 400 ) {
throw new Error ( 'Invalid prompt format' )
}
// タイムアウト: フォールバックを試みる
if (error.name === 'TimeoutError' ) {
return await callGemini (prompt, env)
}
throw error
}
}
400 エラー(不正なリクエスト形式)でフォールバックしても意味がありません。エラー種別を見て、フォールバックが有効なケースとそうでないケースを区別してください。Step 2 のプロキシで response.status >= 400 && response.status < 500 && response.status !== 429 を先に返しているのは、この判断を実装に落としたものです。
ここを分けずに全部フォールバックさせていたころは、プロンプトの不備が Gemini 側の失敗として記録され、原因の切り分けに時間がかかりました。切り替えてよいのは、相手の都合で失敗したときだけです。
Cloudflare ダッシュボードで確認するコスト最適化メトリクス
実装後、以下の指標を定期的にモニタリングします。
AI Gateway のアナリティクス画面で確認できる指標:
Cache Hit Rate : 目標は50%以上です。30%を下回っているときは、正規化ではなく「キーを送れているか」から疑ってください
Total Requests vs Cached Requests : キャッシュから返った分はプロバイダーに課金されません
Provider Distribution : フォールバックが頻発していないかを見ます。Gemini の比率が高すぎるときは OpenAI 側の設定を確認します
Error Rate by Provider : 各プロバイダーのエラー率の推移を追います
ダッシュボードは全体の傾向を見る場所で、1件ずつの判定には向きません。個別のリクエストがキャッシュから返ったかどうかは、レスポンスの cf-aig-cache-status が HIT か MISS かで分かります。Step 2 でこれを x-ai-cache-status として素通しにしているのは、アプリ側のログと突き合わせるためです。「遅い」という報告を受けたとき、その1件が MISS だったのかどうかが分かるだけで、調べる範囲がずいぶん狭まります。
コスト計算の実例:
実装前: 月1万リクエスト × 1,000トークン平均 × $0.00015/1Kトークン = 約 $15 / 月
実装後:
ローカルキャッシュヒット: 40%(コストゼロ)
Cloudflare キャッシュヒット: 30%(コストゼロ)
実際のAPI呼び出し: 30%
月コスト: $15 × 0.3 = $4.5 / 月(70%削減)
私のアプリでは、カテゴリ分類と要約タスクのキャッシュヒット率が特に高く(76%)、実質的なコスト削減は90%を超えました。
30日間の実測データ
この設計を、月間アクティブユーザー約1,200人のアプリで30日間動かしたときの、Cloudflare AI Gateway ダッシュボードの数字です。
想定していなかった副産物が、レスポンスタイムの改善でした。キャッシュヒットが100ミリ秒未満で返るのに対し、ライブのAPI呼び出しは1〜2秒かかります。この差が体感速度に効いて、導入した週にユーザーのセッション時間が約18%伸びました。
コスト削減のつもりで打った施策が、結果としてアプリの使い心地まで良くしていたのです。キャッシュ設計が体験の改善にまで効くとは、私自身も想像しておりませんでした。数字を眺めながら、胸のあたりが少しだけ軽くなったのを覚えています。
数字の読み方も添えておきます。ヒット率73.4%は、この30日のプロンプト分布での値です。分類と要約が中心のアプリだから届いた数字であって、自由入力の対話が主役のアプリでは同じようにはいきません。この記事の数字ではなく、ご自分のアプリの Cache Hit Rate を1週間見てから、正規化の粒度を決めていただければと思います。
A/B テストと継続改善のサイクル
AI Gateway のログを活用して、プロンプト設計の改善を継続します。
// workers/ai-proxy/src/analytics.ts
// よく使われるプロンプトパターンを分析してキャッシュ効率を改善する
export async function logRequest (
prompt : string ,
cacheHit : boolean ,
latencyMs : number ,
env : Bindings
) {
// KVにリクエストパターンを記録(集計用)
const hourKey = `stats:${ new Date (). getHours () }`
const stats = JSON . parse ( await env. RATE_LIMIT . get (hourKey) || '{"total":0,"cached":0,"latency":0}' )
stats.total ++
if (cacheHit) stats.cached ++
stats.latency = (stats.latency * (stats.total - 1 ) + latencyMs) / stats.total
await env. RATE_LIMIT . put (hourKey, JSON . stringify (stats), { expirationTtl: 86400 })
}
// 統計を取得するエンドポイント(管理者用)
app. get ( '/admin/stats' , async ( c ) => {
const stats = []
for ( let h = 0 ; h < 24 ; h ++ ) {
const hourKey = `stats:${ h }`
const data = await c.env. RATE_LIMIT . get (hourKey)
if (data) {
const parsed = JSON . parse (data)
stats. push ({
hour: h,
... parsed,
cacheHitRate: parsed.total > 0 ? (parsed.cached / parsed.total * 100 ). toFixed ( 1 ) : 0 ,
})
}
}
return c. json ({ stats })
})
このエンドポイントで時間帯別のキャッシュヒット率を確認し、ヒット率が低い時間帯のリクエストパターンを分析してプロンプト正規化のロジックを改善していきます。
応用キャッシュパターン — Rork アプリでよく出る場面別の設計
基本のキャッシュに慣れたら、Rork で作るアプリに頻出する場面ごとの応用パターンが効いてきます。
パターン1: コンテンツの揮発性に応じた TTL の段階設定
すべてのプロンプトを同じ時間だけキャッシュする必要はありません。「この商品はどのカテゴリか」という分類は数週間有効です。一方で「今この中で話題になっているものは何か」という要約は、1時間で古くなります。
function getCacheTTL ( prompt : string ) : number {
// リアルタイム性の高い問い合わせ: 短時間キャッシュ
if ( /(トレンド | 話題 | 最新 | 今人気 | 現在)/ . test (prompt)) {
return 3600 // 1時間
}
// ユーザー投稿の分類: 中期間キャッシュ
if ( /(分類 | カテゴリ | タグ | ラベル)/ . test (prompt)) {
return 604800 // 1週間
}
// 静的コンテンツからの抽出: 長期間キャッシュ
if ( /(抽出 | 要約 | 翻訳)/ . test (prompt)) {
return 2592000 // 30日
}
return 86400 // デフォルト24時間
}
// fetch のヘッダーで: 'cf-aig-cache-ttl': String(getCacheTTL(normalizedPrompt))
抽出タスクに30日は強気に見えるかもしれません。けれど、静的な記事や商品説明から構造化データを取り出す処理では、元のコンテンツ自体が変わりません。抱えているコンテンツが多いアプリほど、この長期キャッシュが効いてきます。
なお TTL に指定できるのは 60秒から1か月まで です。上のコードの 2592000 秒(30日)は上限に近い値で、ここより長い数字を書いても伸びません。逆に「30秒だけ効かせたい」といった短さも通らないため、リアルタイム性の高い問い合わせは TTL を縮めるのではなく cf-aig-skip-cache で外す、という切り分けにしています。
パターン2: 高トラフィックが予測できるプロンプトのキャッシュウォーミング
ユーザー投稿型のアプリでは、特定のプロンプトが予測どおりに人気化します。ニュース系アプリなら、同じ話題の記事が1時間に何百回も分類されます。最初のユーザーがAPI呼び出しを起こし、その裏で後続の数百人がまだキャッシュされていないレスポンスを踏む——この短い「空白の時間」を、コンテンツ取り込み時に先回りして埋められます。
export async function warmCacheForContent (
contentText : string ,
gatewayBase : string ,
apiKey : string
) : Promise < void > {
const snippet = contentText. slice ( 0 , 500 ). toLowerCase (). trim (). replace ( / \s + / g , ' ' )
const warmupPrompts = [
`以下を次のいずれかに分類してください(テクノロジー・健康・金融・娯楽・教育・その他)。コンテンツ: ${ snippet }` ,
`以下のコンテンツから上位3つのキーワードを抽出してください: ${ snippet }` ,
`以下のコンテンツを一文で要約してください: ${ snippet }` ,
]
// ウォームアップリクエストを並列発火し、実ユーザー到着前にキャッシュを満たす
await Promise . allSettled (
warmupPrompts. map ( prompt =>
fetch ( `${ gatewayBase }/openai/chat/completions` , {
method: 'POST' ,
headers: {
'Authorization' : `Bearer ${ apiKey }` ,
'Content-Type' : 'application/json' ,
'cf-aig-cache-ttl' : '86400' ,
},
body: JSON . stringify ({
model: 'gpt-4o-mini' ,
messages: [{ role: 'user' , content: prompt }],
temperature: 0 ,
max_tokens: 200 ,
}),
})
)
)
}
これをコンテンツ取り込みパイプラインの一部として走らせます。ユーザーが新しいコンテンツを開くころには、キャッシュはすでに温まっています。最初の読者にも、待ち時間がありません。
ひとつだけ、期待しすぎないための補足です。ゲートウェイのキャッシュは揮発性で、公式ドキュメントにも、同一のリクエストが同時に2件届いた場合、1件目の保存が2件目に間に合わないことがあると明記されています。ウォーミングは「同時に殺到する数百件の全部を救う仕組み」ではなく、「先頭集団の待ち時間を削る仕組み」として見積もっておくと、実測とのずれが小さくなります。
パターン3: プレミアム会員向けの選択的キャッシュバイパス
一部のユーザーは、自分の文脈にはわずかに古く感じるキャッシュ応答に気づき、それを気にします。現実的な解決策は、無料ユーザーにはキャッシュを効かせたまま、プレミアム会員にだけキャッシュを回避する道を用意することです。
app. post ( '/ai/generate' , async ( c ) => {
const userId = c. get ( 'userId' )
const body = await c.req. json ()
const { prompt , cache = true , forceRefresh = false } = body
// プレミアム会員は forceRefresh: true で最新結果を要求できる
// サーバー側で判定するため、無料ユーザーはキャッシュを回避できない
const isPremiumUser = userId. startsWith ( 'premium:' )
const bypassCache = forceRefresh && isPremiumUser
const cacheEnabled = cache && ! bypassCache && isCacheable ( normalizePrompt (prompt))
// 以降は本文の実装と同じ
// ヘッダーで: 'cf-aig-cache-ttl': cacheEnabled ? '86400' : '0'
})
このUXの見せ方は、こちらに有利に働きます。「プレミアム会員は常にリアルタイムのAI応答を受け取れます」は正当な差別化になりますが、実際のコストはほとんど増えません。多くのプロンプトは過去に見たものであり、キャッシュはたいてい新鮮なままだからです。
本番投入前のチェックリスト
本番に出す前に、次の項目を確認します。
Workers の環境変数を wrangler secret put で設定(OPENAI_API_KEY・GEMINI_API_KEY・AI_GATEWAY_ACCOUNT_ID・AI_GATEWAY_ID)
KV ネームスペースを作成し wrangler.toml で RATE_LIMIT としてバインド
CORS の許可オリジンをワイルドカードでなく実際のアプリドメインに限定
Cloudflare ダッシュボードで「Cache Responses」を有効化し、妥当なデフォルトTTLを設定
レート制限のしきい値を想定利用量に合わせて調整(本文の1時間50回は保守的な値)
Gemini APIキーを有効化し、Google Cloud Console で課金を有効化
フォールバックテスト(OpenAI キーを一時的に無効な値へ差し替え、_provider: "gemini" を含む応答が正しく返るか確認)
同一リクエストを50回連続で投げる負荷テストを行い、数分以内にダッシュボードでキャッシュヒット率が跳ね上がることを確認
ゲートウェイ自体は、ただのインフラです。差を生むのは「この応答はキャッシュしてよい」「この応答は必ず新鮮でなければならない」という境界を、どれだけ丁寧に設計するかです。その判断をアプリ全体で一貫して積み重ねられるかどうかが、成長しても採算の取れるアプリと、大きくなるほど支えきれなくなるアプリを分けます。
全体を振り返って — 実装を始める最初の一歩
今日の分として一つだけ挙げるなら、ゲートウェイを作って既存の呼び出しURLを差し替え、cf-aig-cache-status をログに落とすところまでを済ませてみてください。キャッシュ戦略はその翌週で間に合います。自分のアプリがどんなプロンプトを、どれだけ繰り返し投げているのか——それが見えてからのほうが、正規化の粒度もTTLも迷わずに決まります。
私はこの二週間で、設定より先に自分のコードを読むようになりました。数字が動かないときに疑うべきは、たいてい遠くではなく手元にあるのだと、いまも感じています。
ここまでお付き合いいただき、ありがとうございました。