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コスト | 売上(全員課金時) | 粗利 |
| 100人 | ¥6,500 | ¥58,000 | ¥51,500 |
| 1,000人 | ¥65,000 | ¥580,000 | ¥515,000 |
| 10,000人 | ¥650,000 | ¥5,800,000 | ¥5,150,000 |
| 50,000人 | ¥3,250,000 | ¥29,000,000 | ¥25,750,000 |
小さな規模では問題なく見えます。けれど、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に変えるだけで基本的な導入は完了です。
ただし、それだけではキャッシュは有効になりません。キャッシュを有効にするにはリクエストヘッダーに追加の設定が必要です。
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}`
try {
const response = await fetch(`${gatewayBase}/openai/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${c.env.OPENAI_API_KEY}`,
// キャッシュ設定: 同一プロンプトを24時間キャッシュ
'cf-aig-cache-ttl': cache ? '86400' : '0',
// キャッシュスキップ条件(ユーザー固有のプロンプトはキャッシュしない)
'cf-aig-skip-cache': userId.startsWith('personal:') ? 'true' : 'false',
},
body: JSON.stringify({
model,
messages: [{ role: 'user', content: prompt }],
max_tokens: 1000,
}),
})
if (!response.ok) {
// OpenAI エラー時は Gemini にフォールバック
console.warn(`OpenAI error: ${response.status}, falling back to Gemini`)
return await callGeminiFallback(c, prompt, gatewayBase)
}
const data = await response.json()
return c.json(data)
} catch (error) {
console.error('OpenAI request failed:', error)
// ネットワークエラー時も Gemini にフォールバック
return await callGeminiFallback(c, prompt, gatewayBase)
}
})
// Gemini フォールバック関数
async function callGeminiFallback(c: any, prompt: string, gatewayBase: string) {
try {
const response = await fetch(
`${gatewayBase}/google-ai-studio/v1beta/models/gemini-2.0-flash:generateContent`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': c.env.GEMINI_API_KEY,
'cf-aig-cache-ttl': '86400',
},
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
}),
}
)
if (!response.ok) {
throw new Error(`Gemini also failed: ${response.status}`)
}
const data: any = await response.json()
// OpenAI 互換フォーマットに変換して返す
return c.json({
choices: [{
message: {
content: data.candidates[0].content.parts[0].text
}
}],
_provider: 'gemini-fallback', // デバッグ用
})
} 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-ttl ヘッダーで24時間キャッシュを指定
- フォールバック: OpenAI が503/429/ネットワークエラーの場合、自動的に Gemini に切り替える
- OpenAI 互換レスポンス: Gemini を使った場合も OpenAI と同じ形式でレスポンスを返す(Rork アプリ側の変更不要)
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()
.replace(/\s+/g, ' ') // 複数スペースを単一スペースに
.replace(/。\s*\n/g, '。') // 句読点後の改行を除去
.toLowerCase() // 大文字小文字を統一
}
キャッシュキー正規化の効果
実装前後の比較です。
正規化なし(ヒット率: 約12%):
"以下のテキストを分類してください:\nAIについて学ぶ"
"以下のテキストを分類してください: AIについて学ぶ"
"以下のテキストを分類してください:AIについて学ぶ"
正規化あり(ヒット率: 約67%):
"以下のテキストを分類してください: aiについて学ぶ" // ← 全パターンが同じキーに
全角半角・句読点・スペースの違いでキャッシュミスが起きていたのが、正規化によって大幅に改善されました。
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
provider: string
}
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:${btoa(prompt).slice(0, 50)}`
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
const provider = data._provider || 'openai'
const result: AIResponse = { content, fromCache: false, provider }
// ローカルキャッシュに保存
if (cache) {
const cacheKey = `ai_cache:${btoa(prompt).slice(0, 50)}`
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): デバイス上にキャッシュ。ネットワーク通信ゼロで高速
- Cloudflare キャッシュ: サーバー側キャッシュ。複数ユーザー間で共有される
この2段階設計により、API コストの削減に加えてレスポンスタイムも大幅に改善されます。
よくある間違いと落とし穴 — 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 エラー(不正なリクエスト形式)でフォールバックしても意味がありません。エラー種別を見て、フォールバックが有効なケースとそうでないケースを区別してください。
Cloudflare ダッシュボードで確認するコスト最適化メトリクス
実装後、以下の指標を定期的にモニタリングします。
AI Gateway のアナリティクス画面で確認できる指標:
- Cache Hit Rate: 目標は50%以上。30%を下回っている場合はプロンプト正規化を見直す
- Total Requests vs Cached Requests: キャッシュ済みリクエスト分は OpenAI に課金されない
- Provider Distribution: フォールバックが頻発していないか(Gemini の比率が高すぎる場合は OpenAI の設定を確認)
- Error Rate by Provider: 各プロバイダーのエラー率推移
コスト計算の実例:
実装前: 月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 ダッシュボードの数字です。
| 指標 | 値 |
| 総リクエスト数 | 47,800 |
| キャッシュヒット | 35,100(73.4%) |
| 実際のAPI呼び出し | 12,700 |
| Gemini フォールバック発動 | 340(全体の0.71%) |
| 平均レスポンス(キャッシュヒット時) | 87ms |
| 平均レスポンス(API呼び出し時) | 1,240ms |
| OpenAI APIコスト | 約 ¥2,700 |
| 前月(キャッシュなし) | 約 ¥18,700 |
想定していなかった副産物が、レスポンスタイムの改善でした。キャッシュヒットが100ミリ秒未満で返るのに対し、ライブのAPI呼び出しは1〜2秒かかります。この差が体感速度に効いて、導入した週にユーザーのセッション時間が約18%伸びました。
コスト削減のつもりで打った施策が、結果としてアプリの使い心地まで良くしていた。私自身、キャッシュ設計が体験の改善にまで効くとは想像していませんでした。数字を眺めながら、静かに手応えを感じた瞬間でした。
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日は強気に見えるかもしれません。けれど、静的な記事や商品説明から構造化データを取り出す処理では、元のコンテンツ自体が変わりません。大きなコンテンツ資産を持つアプリでは、この長期キャッシュが大きなコスト削減につながります。
パターン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,
}),
})
)
)
}
これをコンテンツ取り込みパイプラインの一部として走らせます。ユーザーが新しいコンテンツを開くころには、キャッシュはすでに温まっています。最初の読者たちにも、レスポンスの遅延がありません。
パターン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回連続で投げる負荷テストを行い、数分以内にダッシュボードでキャッシュヒット率が跳ね上がることを確認
ゲートウェイ自体は、ただのインフラです。差を生むのは「この応答はキャッシュしてよい」「この応答は必ず新鮮でなければならない」という境界を、どれだけ丁寧に設計するかです。その判断をアプリ全体で一貫して積み重ねられるかどうかが、成長しても採算の取れるアプリと、大きくなるほど支えきれなくなるアプリを分けます。
全体を振り返って — 実装を始める最初の一歩
今日やることは一つです。Cloudflare ダッシュボードで AI Gateway を作成し、既存のOpenAI呼び出しURLをゲートウェイURL経由に変更してみてください。
この変更だけで、アナリティクスダッシュボードにリクエストが流れ始め、自分のアプリがどのようなプロンプトパターンを持っているかが可視化されます。
キャッシュ戦略の設計はその後でも遅くありません。まず「現状把握」から始めることが、コスト最適化の第一歩です。
個人開発者にとって、AIコストはスケールへの障壁になりがちです。私はこの障壁を、Cloudflare AI Gateway と丁寧なキャッシュ設計で下げられると実感しました。数少ない実践的なツールの一つだと感じています。ぜひ活用してみてください。