ゲートウェイを入れた翌週、コストの内訳が読めなくなった
Cloudflare AI Gateway を Claude API の前段に入れる動機は、たいてい四つに集約されます。リクエストを可視化したい、スパイクでレート上限に当たる前に自分で絞りたい、重複呼び出しをキャッシュで削りたい、モデル障害時に別モデルへ逃がしたい。どれも正当な要求で、ゲートウェイは確かにそれらを一枚のマネージドレイヤーで引き受けてくれます。
ところが、個人開発者として Dolice Labs の4サイトのコンテンツ生成パイプラインの前段にこれを入れた翌週、私自身が直面したのは「入れる前より内訳が読めなくなった」という逆説でした。総コストとレイテンシのグラフは綺麗に出る。けれど「どの機能が、どのバッチが、どれだけ使ったのか」がダッシュボードからは落ちていたのです。ゲートウェイは通信を仲介するだけなので、こちら側の文脈を渡さなければ、すべてのリクエストは区別のつかない一塊として記録されます。
この記事は、ゲートウェイ導入の手順書ではありません。導入したあとに「思っていたほど見えない・効かない」と気づく四つの箇所——コスト按分・キャッシュの誤ヒット・フォールバックの静かな品質低下・予算の実効化——について、実際に手を入れたコードと判断を残しておきます。
まず、計装の文脈をリクエストに括り付ける
ゲートウェイのログを後から絞り込めるかどうかは、リクエストを投げる瞬間にメタデータを括り付けたかどうかで決まります。cf-aig-metadata ヘッダに渡した値はそのままログに残るので、ここに「あとで按分したい軸」を全部入れておきます。私の場合は「どのサイトの」「どの生成種別の」「どのバッチ実行の」という三軸でした。
// src/lib/claude-gateway.ts
import Anthropic from "@anthropic-ai/sdk" ;
const GATEWAY_BASE_URL = process.env. CLOUDFLARE_GATEWAY_URL ;
const ANTHROPIC_API_KEY = process.env. ANTHROPIC_API_KEY ;
if ( ! GATEWAY_BASE_URL || ! ANTHROPIC_API_KEY ) {
throw new Error ( "CLOUDFLARE_GATEWAY_URL と ANTHROPIC_API_KEY の両方が必要です" );
}
// baseURL をゲートウェイに差し替えるだけで、既存の SDK 呼び出しはそのまま通る
const client = new Anthropic ({
apiKey: ANTHROPIC_API_KEY ,
baseURL: GATEWAY_BASE_URL ,
});
export interface CallContext {
site : string ; // 例: "claudelab"
workload : string ; // 例: "recovery" / "brushup" / "daily"
runId : string ; // バッチ単位の識別子
}
export async function gatewayChat (
params : Anthropic . MessageCreateParamsNonStreaming ,
ctx : CallContext
) {
return client.messages. create (params, {
headers: {
// ここに入れた値が AI Gateway のログに残り、後から軸で絞れる
"cf-aig-metadata" : JSON . stringify ({
site: ctx.site,
workload: ctx.workload,
runId: ctx.runId,
ts: new Date (). toISOString (),
}),
},
});
}
なぜ baseURL の差し替えだけで済むかというと、Anthropic の TypeScript SDK は HTTP の宛先を baseURL で受け取り、認証ヘッダやリトライ処理はそのまま使い回すからです。ゲートウェイは Anthropic 互換のパスを通すので、アプリ側のロジックは一行も変えずに済みます。逆に言えば、メタデータを渡し忘れたリクエストは「匿名の一塊」としてしか記録されません。導入直後の私のログがまさにこれでした。
按分の集計は、Logs API を叩いてメタデータでグループ化します。ここで初めて「どのワークロードが効いているか」が数字になります。
// src/lib/gateway-attribution.ts
interface CostByWorkload {
[ workload : string ] : { requests : number ; tokens : number ; usd : number };
}
// モデル別の概算単価(USD / 1M tokens, 2026-06 時点・入力基準の概算)
const INPUT_PRICE : Record < string , number > = {
"claude-haiku-4-5" : 1 ,
"claude-sonnet-4-6" : 3 ,
"claude-opus-4-8" : 15 ,
};
export async function attributeCost (
accountId : string ,
gatewayId : string ,
apiToken : string ,
since : Date
) : Promise < CostByWorkload > {
const url =
`https://api.cloudflare.com/client/v4/accounts/${ accountId }` +
`/ai-gateway/gateways/${ gatewayId }/logs?since=${ since . toISOString () }` ;
const res = await fetch (url, {
headers: { Authorization: `Bearer ${ apiToken }` },
});
const { result = [] } = await res. json ();
const acc : CostByWorkload = {};
for ( const log of result) {
const meta = safeParse (log.metadata);
const key = meta?.workload ?? "unattributed" ;
const price = INPUT_PRICE [log.model] ?? 3 ;
const usd = (log.tokens_in ?? 0 ) * (price / 1_000_000 );
acc[key] ??= { requests: 0 , tokens: 0 , usd: 0 };
acc[key].requests += 1 ;
acc[key].tokens += log.tokens_in ?? 0 ;
acc[key].usd += usd;
}
return acc;
}
function safeParse ( s : unknown ) {
try { return typeof s === "string" ? JSON . parse (s) : s; } catch { return null ; }
}
"unattributed" のバケツが膨らんでいたら、それはメタデータを渡し損ねている呼び出しが残っているサインです。私はこの数字をゼロに寄せることを、計装が完成したかどうかの目安にしています。
セマンティックキャッシュは「似ているが違う」で事故る
セマンティックキャッシュは、リクエストの意味的な近さで過去のレスポンスを再利用します。FAQ のように問いと答えが安定している領域では劇的に効きます。問題は、意味が近いのに答えが違うべき質問——たとえば「返品ポリシーを教えて」と「海外発送の返品ポリシーを教えて」——を、埋め込みの距離だけでは取り違えうることです。後者に前者のキャッシュが返ると、嘘ではないが不正確な答えが静かに配信されます。
私の手当ては三段構えです。整理すると次のようになります。
キャッシュを共有してよい範囲を、キャッシュキーの名前空間で区切る
ユーザー固有・最新性が要る呼び出しは、明示的にキャッシュを外す
誤ヒットの兆候を、ヒット率とは別の指標で取る
共有してよい範囲を名前空間で区切る
// 共有してよい FAQ 系: 名前空間を切って取り違えを防ぐ
const faq = await gatewayChat (
{
model: "claude-haiku-4-5" ,
max_tokens: 512 ,
system: "カスタマーサポート担当として簡潔に答えてください。" ,
messages: [{ role: "user" , content: "国内配送の返品ポリシーを教えてください" }],
},
ctx
);
// ↑ 呼び出し側ヘッダで以下を付与する(gatewayChat を拡張して渡す)
// "cf-aig-cache-ttl": "3600"
// "cf-aig-cache-key": "faq:returns:domestic" ← トピックを名前空間で固定
// 個別性が高い・最新性が要る呼び出しはキャッシュを外す
const summary = await gatewayChat (
{
model: "claude-sonnet-4-6" ,
max_tokens: 1024 ,
messages: [{ role: "user" , content: `注文 ${ orderId } の状況を要約して` }],
},
ctx
);
// "cf-aig-skip-cache": "true"
最新性が要る呼び出しは外す
ここで効くのは、キャッシュキーを埋め込み距離任せにせず、こちらが意味のまとまり(トピック)で明示的に固定することです。faq:returns:domestic と faq:returns:international を別の名前空間にしておけば、近い文面でも別物として扱われ、取り違えが起きません。埋め込みの賢さに全部預けるのではなく、人間が知っている境界を一本引いておく——これが運用で一番効いた判断でした。
誤ヒットの兆候を別指標で見る
ヒット率は高ければよいというものでもありません。私の環境では FAQ 系のヒット率は30%前後で、ここが下手に上がるときほど警戒します。誤ヒットの兆候としては「キャッシュ済み応答に対する低評価率」を別に取り、ヒット率と並べて見ています。ヒット率だけ追うと、誤ヒットでコストが下がったように見えて品質が落ちる、という最悪の最適化に進みかねないからです。
呼び出しの性質 キャッシュ方針 キー設計
定型 FAQ・ヘルプ応答 有効(TTL 長め) トピック名前空間で固定
ドキュメント要約(共有資料) 有効(TTL 短め) 資料ハッシュをキーに含める
ユーザー固有の照会 スキップ —
生成系・創作系 スキップ —
フォールバックは「落ちない」が「静かに品質を下げる」
フォールバックは、プライマリのモデルがエラーを返したときに別モデルへ自動で逃がす仕組みです。可用性は確かに上がります。けれど見落としがちなのは、逃がした先が必ずしも同等の品質ではない、という当たり前の事実です。Opus 4.8 を期待していた処理が、障害時に Haiku 4.5 へ落ちて「成功扱い」で返ってくると、システムは健全に見えるのに出力の質だけが静かに痩せます。
だから私は、フォールバックを「起きたかどうか」を必ず記録し、どのモデルが実際に応答したかをアプリ側でも保持するようにしました。レスポンスヘッダに実応答モデルが返るので、それを按分メタデータと同じログ系に流します。
// フォールバックが起きたかを呼び出し側で検知して記録する
export async function chatWithFallbackTrace (
params : Anthropic . MessageCreateParamsNonStreaming ,
ctx : CallContext ,
expectedModel : string
) {
const res = await client.messages. create (params, {
headers: {
"cf-aig-metadata" : JSON . stringify ({ ... ctx, expectedModel }),
},
});
// 実際に応答したモデルは戻り値の model に入る
const served = (res as { model ?: string }).model ?? "unknown" ;
if (served !== expectedModel) {
// 静かな品質低下を「イベント」として残す(後で頻度を集計する)
console. warn (
JSON . stringify ({
kind: "fallback_served" ,
runId: ctx.runId,
expectedModel,
served,
ts: new Date (). toISOString (),
})
);
}
return { res, served, degraded: served !== expectedModel };
}
運用上の判断としては、フォールバック先を「とりあえず一番安いモデル」にしないことです。私は重要度の高いワークロードでは、プライマリの一段下の品質帯までしか落とさない設定にしています。可用性のために品質を底まで捨てるのは、Stripe の課金読者に届ける記事の前段では割に合いません。品質の劣化は課金率に直接効くので、重要ワークロードでは一段下までに留めることをお勧めします。落ちないことよりも、落ちたと分かることのほうが大事だと考えています。
予算は「ログ」では止まらない。実効ゲートを別に置く
ゲートウェイのレート制限やバジェット表示は、観測としては優秀ですが、「上限に達したら本当にリクエストを止める」という実効性は、こちら側のゲートで担保したほうが確実でした。表示が予算超過を教えてくれても、その間にもバッチは走り続けるからです。私は呼び出しの直前に、軽い予算チェックを一枚噛ませています。
// src/lib/budget-gate.ts — 呼び出し前に当日コストを確認して止める
interface BudgetState { date : string ; spentUsd : number ; }
const DAILY_CAP_USD = 5 ; // ワークロード単位の上限
export async function withinBudget (
read : () => Promise < BudgetState >,
write : ( s : BudgetState ) => Promise < void >,
estimateUsd : number
) : Promise < boolean > {
const today = new Date (). toISOString (). slice ( 0 , 10 );
const state = await read ();
const base = state.date === today ? state : { date: today, spentUsd: 0 };
if (base.spentUsd + estimateUsd > DAILY_CAP_USD ) {
return false ; // 上限超過 → 呼び出さない
}
await write ({ date: today, spentUsd: base.spentUsd + estimateUsd });
return true ;
}
// 使い方(Cloudflare Workers の KV を読み書きに使う想定)
// const ok = await withinBudget(readKV, writeKV, estimateForRequest(params));
// if (!ok) { return new Response("budget exceeded", { status: 429 }); }
ここで estimateUsd は、入力トークンの概算とモデル単価(1M トークンあたり数ドル)から事前に見積もります。私はワークロード単位で1日あたり5ドルの上限を引いています。完璧な精度は要りません。要るのは「走り続けるバッチを上限で確実に止める」という一点です。表示は事後、ゲートは事前——この役割分担を分けてから、予期しない請求の跳ねは起きなくなりました。
Cloudflare Workers に寄せると、全部が一つの境界の内側に入る
私のパイプラインは Cloudflare Workers の上で動いているので、ゲートウェイ・予算ゲート・按分ログがすべて同じエッジの内側に収まりました。クライアントは Worker だけを知っていればよく、Claude の API キーは Worker の環境変数に閉じ、ゲートウェイの設定もコストの集計も一箇所で完結します。
// worker.ts — 入口で予算→生成→按分を一筆書きにする
export default {
async fetch ( req : Request , env : Env ) : Promise < Response > {
const ctx = { site: "claudelab" , workload: "daily" , runId: crypto. randomUUID () };
const ok = await withinBudget (
() => readBudget (env. KV , ctx.workload),
( s ) => writeBudget (env. KV , ctx.workload, s),
0.02 // この呼び出しの概算コスト
);
if ( ! ok) return new Response ( "budget exceeded" , { status: 429 });
const { res , degraded } = await chatWithFallbackTrace (
{
model: "claude-sonnet-4-6" ,
max_tokens: 1024 ,
messages: [{ role: "user" , content: await req. text () }],
},
ctx,
"claude-sonnet-4-6"
);
return Response. json ({ degraded, content: res.content });
} ,
} ;
この形にすると、障害時の品質低下も、予算による拒否も、コストの内訳も、すべて同じ runId で串刺しにできます。あとから「あの日のあのバッチで何が起きたか」を一本の線でたどれることが、個人で複数サイトを回すうえでは何より効きました。一人で運用していると、観測の手間をかけられる総量は限られています。だからこそ、入口で文脈を一度だけ括り付けて、あとはその文脈に全部を紐づける設計が、結局いちばん長持ちすると感じています。
次の一手
もしこれからゲートウェイを入れるなら、最初の作業を「メタデータ設計」にしてください。コスト按分・キャッシュキー・フォールバック検知・予算ゲートは、どれも入口で括り付けた一つの文脈に乗ります。可視化や節約の機能を有効化するのはそのあとで十分です。最初にメタデータ設計へ時間を割くことを強く推奨します。文脈を後付けしようとすると、私のように「入れた翌週に内訳が読めない」一週間を過ごすことになります。
お読みいただきありがとうございました。同じようにエッジの内側で AI を回している方の、運用の足場になれば嬉しいです。