LLM API ゲートウェイで予算上限を本気で守る設計 — Cloudflare D1 アトミック UPDATE の予約方式
この記事の概要
- LLM API の予算管理は、並行リクエスト下の Race Condition という古典的な問題に直面する。残予算 100ドル の口座に並行 50 リクエストが同時着弾し、各リクエストが 1 件 5ドル 規模のコスト (Claude Opus 長文処理など) を持つ場合、最悪 200ドル 規模のオーバー消費が起こりうる。エージェントの繰り返し呼び出しや暴走スクリプトが絡むと、より大きな額に膨らみうる。
- 解法は古くから知られていて、ACID と条件付き UPDATE による「予約方式」。リクエスト着弾と同時に「最大コスト分」を予約 → 上流応答後に確定値で差し替える。
- Cloudflare D1 (SQLite ベース) のアトミック条件付き
UPDATEで、追加インフラなしに実装できる。本記事は SQL と TypeScript の擬似コードで設計を解説する。 - 多段予算 (組織 × チーム × メンバー) も、各層への逐次 Reserve + 失敗時ロールバックで自然に拡張できる。
対象読者: LLM API のコスト管理を厳格にしたい方全般。自前の API ゲートウェイ構築・マルチテナント AI SaaS・社内 AI Gateway・既存サービス経由で利用、いずれの場合でも「予算超過を構造的に防ぎたい」という課題感があれば、設計原理だけでも参考になります。エンタープライズ・チーム開発向けの設計のため、個人の小規模スクリプトには過剰かもしれません。
この記事はZennにも投稿しています(より多くの方に読んでいただくためにQiitaにも掲載しています)。
https://zenn.dev/beki/articles/0170717cc81f3e
1. 「予算アラートが届いた時には、もう想定をオーバーしていた」
LLM API のコストが事業の固定費に組み込まれ始めた 2025-2026 年、運用現場で語られるリスクシナリオに、こんなものがあります。
「月次予算アラートが届いた頃には、想定の数十%オーバーしていた」
「個人検証用のキーが暴走 (無限ループ / 長文プロンプトの誤繰り返し等) して、気づいた時には数百〜数千ドルが消えていた」
これらは怠慢でも事故でもなく、多くの予算管理機構が抱える設計上の特性から構造的に起こり得るパターンです。本記事ではその「特性」を技術的に分解し、追加インフラなしで根治できる設計を提示します。
2. なぜ並行リクエスト下で予算管理は難しいのか
予算管理の仕組みは、ざっくり 2 つの設計パターンに分類できます。
パターン A: バッチ集計型 (事後検知)
[Request] → 上流 API → 利用ログ蓄積
↓ (バッチ集計、数分〜数十時間遅延)
予算データ更新
↓
閾値超過 → アラート / 以降ブロック
請求基盤・データウェアハウスとの整合性を取りやすい反面、「予算判定」と「リクエスト処理」の間に遅延があるため、その間に着弾したリクエストはすべて通ります。クラウドの請求ダッシュボードや BI 連携型の予算アラートに多いパターンです。
パターン B: リアルタイム差引型 (事前判定)
[Request] → 予算判定 (即時) → 上流 API
↓ 不足
429 で即拒否
リクエスト着弾の瞬間に予算をチェックして拒否できますが、ここでも油断すると Race Condition が顔を出します。次の節で数字で見ましょう。
3. Race Condition の数値例 — 残予算 100ドル から 10,100ドル まで膨らむ
前提:
月予算上限 = 10,000ドル
月初から 9,900ドル 消費済み (残予算 100ドル)
並行リクエスト 50 件が「ほぼ同時」に着弾
各リクエストの最大コスト見積もり = 5ドル
(Claude Opus の長文処理を想定: input 100k × 15ドル/Mtok
+ output 8k × 75ドル/Mtok ≈ 2.1ドル、安全マージン込みで 5ドル に設定する運用が多い)
ナイーブな実装の挙動:
T=0 : 50 リクエストが「残予算は今 100ドル」と読み取る
T=1ms : 50 件すべてが「5ドル ≤ 100ドル、予算 OK」と判定 ← ここが穴
T=1ms : 50 件すべてが上流に流れる
T=30s : 各リクエストが応答を受け取り、実コスト 平均4ドル を加算
実消費 : 9,900ドル + (4ドル × 50) ≈ 10,100ドル
(予算 10,000ドル を 100ドル オーバー)
これは「並行 50 件 × 平均 4ドル」という、エージェントの並列実行やバッチ処理を含む利用での最悪値です。同じ Race Condition でも、実運用では桁が変わるケースもあります:
- max_tokens を緩く設定した長文応答 / コード生成エージェントの繰り返し呼び出しで 1 件 20-50ドル になると、1,000-2,500ドル オーバーに膨らむ
- 無限ループバグや暴走スクリプトでは、1 時間で数千ドル単位の超過も起こり得る (実コストが見積もりを上回るのではなく、リクエスト数が想定の数百倍に膨らむケース)
これは SQL を知っていれば誰でも「ロックや原子的更新を入れれば防げる」と気づける、典型的な Read-Modify-Write の競合です。にもかかわらず実装で残りがちなのは、
- 「アラート」と「強制停止」を同一視している UX (経営層から見ると違いが分かりにくい)
- 利用ログ集計の遅延と組み合わさり、本番運用してから初めて顕在化する
- 普段は予算に余裕があり、月末ギリギリにしか起きない
といった理由が大きいです。設計時に気づくか、本番で気づくかの違いでしかありません。
4. 解法の方向性
予算 Race Condition の解法を整理します。
| 方向性 | 仕組み | メリット | デメリット |
|---|---|---|---|
| A. Mutex / 楽観ロック | 予算チェック区間を排他制御 | 実装シンプル | 並行性が死ぬ (RPS 低下) |
| B. メッセージキュー直列化 | 全リクエストを 1 本のキューに | 一貫性◎ | レイテンシ増 |
| C. ACID + 条件付き UPDATE で「予約」 | 「使う前に押さえる」 | 並行性 + 一貫性両立 | SQL の理解が必要 |
| D. 分散合意 (Raft / Paxos) | 厳密 | 完璧 | オーバースペック |
LLM API ゲートウェイのように 同一テナント内の並行 RPS がせいぜい数百 であれば、C の「ACID + 条件付き UPDATE による予約方式」が最もコスト効率が良いと考えています。本記事はこれを取ります。
5. 予約方式の核心 — リクエストの寿命を 3 段階に分ける
設計の本質は、リクエストの寿命を以下の 3 段階に分解することです。
Request 受付
│
├─ [1] Reserve : 最大コスト見積もりを「予約」として記録
│
├─ [2] Upstream: LLM プロバイダへ転送
│
├─ 成功 → [3a] Commit : 予約を解除 + 実コストを記録
└─ 失敗 → [3b] Release: 予約を解除 (実コストは記録しない)
予算の状態は次の不等式で管理します:
予約済 (reserved) + 確定消費 (spent) ≤ 上限 (cap)
「予約」という中間状態を持たせることで、「上流の応答を待つ間も、その分の枠は他のリクエストから使えない」ことが保証されます。
6. Cloudflare D1 (SQLite) で実装する
6.1 テーブル設計 (抽象例)
説明用の簡略スキーマです。実プロダクションではテナント階層や期間 (月次 / 日次 / 週次) で分かれます。
CREATE TABLE budget_state (
tenant_id TEXT NOT NULL,
period TEXT NOT NULL, -- '202605' など
cap_usd_micro INTEGER NOT NULL, -- 上限 (USD × 10^6 で整数化)
reserved_usd_micro INTEGER NOT NULL DEFAULT 0,
spent_usd_micro INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (tenant_id, period)
);
なぜ整数 (micro-USD) ? 浮動小数の累積誤差は予算管理では致命的です。USD を 10^6 倍 (1,000,000 倍) した整数で扱うのが定石です。
行の初期化を忘れずに。後述の Reserve は UPDATE 1 発で行うため、対象行がないと changes() が 0 を返し、「予算不足」と「行未作成」を区別できなくなります。テナント新規作成時や期間切替時 (月初など) に、以下のような INSERT ... ON CONFLICT DO NOTHING で行を先に作っておくのが安全です:
INSERT INTO budget_state (tenant_id, period, cap_usd_micro, reserved_usd_micro, spent_usd_micro, updated_at)
VALUES (?, ?, ?, 0, 0, ?)
ON CONFLICT (tenant_id, period) DO NOTHING;
6.2 ステップ [1] Reserve — 条件付き UPDATE 1 発で原子化する
ここが本設計の心臓部です。次の UPDATE 1 文で、「予算が足りるなら予約、足りなければ何もしない」を原子的に実現します。
UPDATE budget_state
SET reserved_usd_micro = reserved_usd_micro + ?max_cost,
updated_at = ?now
WHERE tenant_id = ?tenant
AND period = ?period
AND (reserved_usd_micro + spent_usd_micro + ?max_cost) <= cap_usd_micro;
?max_cost などのプレースホルダ表記は、後述の TypeScript (.bind()) と対応させるため、疑似コードとして表記しています。D1 SDK の実際の prepare 文では ?1, ?2 または ? を使用します。
ポイントは 3 つ:
-
WHERE句に予算チェックを含める ことで、UPDATE 自体が「条件成立時のみ更新」になる -
changes()が 0 を返したら予算不足 として即 429 拒否 - SQLite は書き込みを内部でシリアライズするため 、並行 UPDATE もエンジン内部で順序化される (参考: SQLite アトミックコミットの実装)
TypeScript (Workers SDK) からの呼び出し例:
async function reserveBudget(
db: D1Database,
tenantId: string,
period: string,
maxCostUsdMicro: number,
): Promise<{ ok: boolean }> {
const now = Date.now();
const result = await db
.prepare(`
UPDATE budget_state
SET reserved_usd_micro = reserved_usd_micro + ?,
updated_at = ?
WHERE tenant_id = ?
AND period = ?
AND (reserved_usd_micro + spent_usd_micro + ?) <= cap_usd_micro
`)
.bind(maxCostUsdMicro, now, tenantId, period, maxCostUsdMicro)
.run();
return { ok: (result.meta.changes ?? 0) > 0 };
}
result.meta.changes が 0 なら予算不足。この瞬間に予算判定と予約が原子的に成立しています。
6.3 ステップ [2] 上流 API コール
予約成功後、LLM プロバイダへリクエストを転送します。この区間で予約済の枠は他のリクエストから「使えない」状態になっているため、二重消費は起きません。
6.4 ステップ [3a] Commit — 成功時
応答が返り、実トークン数からコストが確定したら:
UPDATE budget_state
SET reserved_usd_micro = reserved_usd_micro - ?max_cost,
spent_usd_micro = spent_usd_micro + ?actual_cost,
updated_at = ?now
WHERE tenant_id = ?tenant
AND period = ?period;
実コストが見積もりより少なかった分は、自動的に枠へ戻ります。
冪等性の注意: 上の UPDATE をそのまま使うと、Workers のリトライや上流タイムアウト後の再送で Commit が二重実行された場合、reserved が二重に減って spent が二重に増えます。本番運用では予約 1 件を独立した行として budget_reservations テーブルに request_id (idempotency_key) 付きで持ち、UPDATE ... WHERE status = 'pending' のアトミック遷移で「二度目以降の Commit は何もしない」設計に拡張するのが定石です。詳細は §9.1 で触れます。
6.5 ステップ [3b] Release — 失敗時
上流タイムアウト / 5xx / ネットワーク障害などで失敗した場合は、予約を全額解放するだけ:
UPDATE budget_state
SET reserved_usd_micro = reserved_usd_micro - ?max_cost,
updated_at = ?now
WHERE tenant_id = ?tenant
AND period = ?period;
実コスト 0 で枠が戻るので、ユーザーは安心して再試行できます。
7. 多段予算 — 組織 × チーム × メンバー
実運用では「組織全体で月 1,000,000円、チーム A で 200,000円、メンバー X で 30,000円」のような階層が必要になります。
アプローチはシンプルです。リクエストが属するすべての階層に対して順番に Reserve し、1 つでも失敗したら直前までの予約を Release してロールバックするだけ。
// `releaseBudget` は §6.5 の UPDATE を関数化したもの (実装省略)
async function reserveAcrossHierarchy(
db: D1Database,
hierarchyIds: string[], // [orgId, teamId, memberId, ...] 上位 → 下位の固定順
period: string,
maxCost: number,
): Promise<{ ok: boolean }> {
const reserved: string[] = [];
for (const id of hierarchyIds) {
const r = await reserveBudget(db, id, period, maxCost);
if (!r.ok) {
// ロールバック
for (const done of reserved) {
await releaseBudget(db, done, period, maxCost);
}
return { ok: false };
}
reserved.push(id);
}
return { ok: true };
}
5 階層あっても、各段の UPDATE は数 ms 程度なので、合計 p99 は数十 ms に収まります。LLM API の応答が秒オーダーであることを考えれば、十分実用的です。
並列 reserve は避ける。Promise.all で並列化するとロールバック時の一貫性が崩れます。順次 reserve が安全です。
順序を固定する。複数リクエストが異なる順序で reserve すると、ロールバック中の他リクエストから「解放されかけている枠」が一瞬見えて、無駄に弾かれることがあります。SQLite の書き込みシリアル化のおかげでデッドロックにはなりませんが、効率は落ちます。常に上位 (組織) → 下位 (メンバー) の固定順で reserve することを推奨します。
8. なぜ Cloudflare D1 (SQLite) で十分なのか
「分散 DB を使うべきでは?」という疑問が湧くかもしれません。私の結論は マルチテナント SaaS の予算管理ユースケースでは D1 で十分 です。
8.1 単一プライマリの強み
D1 の各データベースはプライマリで書き込みが直列化されます (Cloudflare D1 公式ドキュメント)。プライマリでの書き込み直列化により、追加のロック機構なしにアトミック UPDATE が成立します。
8.2 レイテンシの目安
(あくまで目安、実数値は環境依存)
- 単段
UPDATE: p50 数 ms、p99 数十 ms (同リージョン) - 5 段の逐次 reserve : 合計でも p99 数百 ms 以内
- Workers コールドスタート: 数-十数ms 程度
LLM API の応答自体が秒オーダーなので、予算チェックの追加コストはユーザー体験に影響しません。
8.3 D1 が向かないケース
- グローバル並列書き込み (秒間 10 万書き込みなど) → Durable Objects + リージョン分散カウンタを検討
- 厳密な分散合意 (金融取引等) → Cloudflare の範囲を超える
- ストアドプロシージャ / トリガが必要 → PostgreSQL / MySQL
9. プロダクションで考慮すべき追加要件
ここまでが設計の核です。実プロダクションには以下の追加対応が必要です。本記事の主題ではないので深堀はしませんが、読者が自分の運用に落とすときに必ず引っかかる 3 点だけは方針を示しておきます。
9.1 Commit/Release の冪等性
§6.4 の UPDATE をそのまま使うと、Workers のリトライ、上流タイムアウト後の SDK 再送、クライアントの retry-after など、現実に発生する経路で Commit/Release が二重実行され、reserved が二重に減って spent が二重に増えます。
対応の定石は、予約 1 件を独立した行として持つことです:
CREATE TABLE budget_reservations (
request_id TEXT PRIMARY KEY, -- idempotency_key
tenant_id TEXT NOT NULL,
period TEXT NOT NULL,
max_cost_usd_micro INTEGER NOT NULL,
status TEXT NOT NULL, -- 'pending' | 'committed' | 'released'
actual_cost_usd_micro INTEGER,
apply_nonce TEXT, -- 適用識別用の nonce (UUID 文字列、時刻ではない)
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
この設計を導入すると、§6 の Reserve / Commit / Release はすべて 「予約テーブルへの状態遷移」+「budget_state への適用」の 2 文ペア に変わります。2 文を D1 の db.batch([stmt1, stmt2]) で 1 トランザクションとして実行することで、Worker のクラッシュ等で「片方だけ適用されて永続不整合」になる事態を防ぎます。
db.batch のロールバック条件を誤解しない。db.batch は内部で BEGIN ... COMMIT を発行しますが、ロールバックされるのは SQL exception (CHECK / UNIQUE / NOT NULL 違反、trigger ABORT 等) が発生した時のみです。SQLite では UPDATE が 0 行マッチで no-op になるのは exception ではなく成功扱いなので、「2 文目の changes=0 で 1 文目もロールバック」とはなりません。したがって本設計では、両 statement が同じ条件で「両方適用」か「両方 no-op」に揃うように SQL を組む か、apply_nonce のような適用マーカーで冪等化する必要があります。
Reserve (新フロー: 予算条件 + apply_nonce マーカーで両側冪等化)
Reserve も Commit/Release と同じ apply_nonce マーカーパターンで揃えます。これにより、(1) 予算不足時は両方 no-op (ゴミレコード残らず)、(2) 同じ request_id でリトライされても reserved が二重加算されない、の両方が保証されます。
const applyNonce = crypto.randomUUID();
const result = await db.batch([
// 1. 予算条件を満たし、かつ request_id が新規のときだけ予約レコードを作成し、
// 今回の applyNonce を apply_nonce 列に記録する
db.prepare(`
INSERT INTO budget_reservations
(request_id, tenant_id, period, max_cost_usd_micro, status,
apply_nonce, created_at, updated_at)
SELECT ?, ?, ?, ?, 'pending', ?, ?, ?
WHERE EXISTS (
SELECT 1 FROM budget_state
WHERE tenant_id = ? AND period = ?
AND (reserved_usd_micro + spent_usd_micro + ?) <= cap_usd_micro
)
ON CONFLICT (request_id) DO NOTHING
`).bind(
requestId, tenantId, period, maxCost, applyNonce, now, now, // SELECT 部
tenantId, period, maxCost, // WHERE EXISTS 部
),
// 2. 「予算条件を満たし、かつ予約行の apply_nonce が今回の値と一致」する時だけ
// budget_state を更新
db.prepare(`
UPDATE budget_state
SET reserved_usd_micro = reserved_usd_micro + ?, updated_at = ?
WHERE tenant_id = ? AND period = ?
AND (reserved_usd_micro + spent_usd_micro + ?) <= cap_usd_micro
AND EXISTS (
SELECT 1 FROM budget_reservations
WHERE request_id = ? AND apply_nonce = ?
)
`).bind(maxCost, now, tenantId, period, maxCost, requestId, applyNonce),
]);
const reserveOk = (result[1].meta.changes ?? 0) > 0;
動作の整理:
| シナリオ | Statement 1 の挙動 | Statement 2 の挙動 |
|---|---|---|
| 1 回目 (新規 request_id、予算あり) | INSERT 成功、apply_nonce = N1 を記録 |
EXISTS 内が一致 (apply_nonce = N1) → 適用 |
| 1 回目 (新規 request_id、予算不足) |
WHERE EXISTS が空で INSERT 自体が no-op |
EXISTS 内に該当行なし → no-op |
| 2 回目 (同じ request_id、リトライ) |
ON CONFLICT で no-op (既存行の apply_nonce = N1 のまま) |
今回の applyNonce = N2 (新規生成) と DB の N1 が不一致 → no-op |
db.batch は同一トランザクション内で BEGIN ... COMMIT を発行するため、両 statement は同じ budget_state / budget_reservations の状態を見ます。予算判定とマーカー判定が常に整合し、Reserve も完全に冪等になります。
Commit (新フロー: apply_nonce マーカーで冪等化)
ここが落とし穴です。「1 文目で状態を 'committed' に遷移、2 文目で budget_state を加減算」のナイーブな書き方だと、リトライ時に 1 文目は no-op (WHERE status = 'pending' で弾かれる) なのに 2 文目は無条件に走る ため、spent が二重に増えます。
そこで、apply_nonce 列に 「今回の適用を識別する nonce」 を書き込み、2 文目はそれと一致するときだけ適用するようにします:
// 各リクエストごとにユニークな nonce を生成 (Workers の Date.now() は I/O 間で固定されるため、
// crypto.randomUUID() ベースが安全)
const applyNonce = crypto.randomUUID();
await db.batch([
// 1. 状態遷移 + 今回の applyNonce を記録 (二度目以降は changes() = 0)
db.prepare(`
UPDATE budget_reservations
SET status = 'committed',
actual_cost_usd_micro = ?,
apply_nonce = ?,
updated_at = ?
WHERE request_id = ? AND status = 'pending'
`).bind(actualCost, applyNonce, now, requestId),
// 2. apply_nonce が「今回の applyNonce と一致」する時だけ budget_state を更新
// リトライ時は 1 文目が no-op = apply_nonce が前回の nonce のままなので、
// 今回渡した applyNonce と一致せず、2 文目も no-op になる
db.prepare(`
UPDATE budget_state
SET reserved_usd_micro = reserved_usd_micro - ?,
spent_usd_micro = spent_usd_micro + ?,
updated_at = ?
WHERE tenant_id = ? AND period = ?
AND EXISTS (
SELECT 1 FROM budget_reservations
WHERE request_id = ? AND apply_nonce = ?
)
`).bind(maxCost, actualCost, now, tenantId, period, requestId, applyNonce),
]);
リトライ時の挙動を整理すると、二重加算が起きないことが分かります:
| 観点 | 1 回目 Commit | 2 回目 Commit (リトライ) |
|---|---|---|
渡される applyNonce
|
N1 |
N2 (新規生成、N1 ≠ N2) |
Statement 1 (UPDATE budget_reservations) |
apply_nonce = N1 に更新成功 |
WHERE status = 'pending' で弾かれ no-op (apply_nonce は N1 のまま) |
Statement 2 (UPDATE budget_state) |
EXISTS 内の apply_nonce = N1 (今回値) と一致 → 適用
|
EXISTS 内が apply_nonce = N2 (今回値) で N1 (前回値) と不一致 → no-op
|
これで二重加算を防げます。
Release (新フロー: 同じパターン)
Release も同じ apply_nonce マーカーパターンで冪等化します:
const applyNonce = crypto.randomUUID();
await db.batch([
db.prepare(`
UPDATE budget_reservations
SET status = 'released',
apply_nonce = ?,
updated_at = ?
WHERE request_id = ? AND status = 'pending'
`).bind(applyNonce, now, requestId),
db.prepare(`
UPDATE budget_state
SET reserved_usd_micro = reserved_usd_micro - ?, updated_at = ?
WHERE tenant_id = ? AND period = ?
AND EXISTS (
SELECT 1 FROM budget_reservations
WHERE request_id = ? AND apply_nonce = ?
)
`).bind(maxCost, now, tenantId, period, requestId, applyNonce),
]);
これで Reserve / Commit / Release のいずれも、Workers のリトライ・クラッシュ・並行実行のどれが起きても一貫性が保たれます。
代替案: CHECK 制約で物理的に予算超過を禁じる
budget_state に CHECK ((reserved_usd_micro + spent_usd_micro) <= cap_usd_micro) を張ると、予算超過の UPDATE が CHECK 違反 exception を投げ、db.batch 全体がロールバックされます。アプリ側で例外を捕まえて 429 に変換する処理が必要になりますが、「予算超過は SQL レイヤで物理的に不可能」という強い保証が得られ、本記事のテーマと相性が良いパターンです。Reserve の SQL もシンプル化できます (両 statement に予算条件を持たせず、CHECK 違反に任せる)。
ただし §9.2 との整合に注意: CHECK 制約は Reserve の予算チェックには綺麗に効きますが、§9.2 で触れる「実コストが max_cost を超えた場合は cap を超えてでも spent に記録する」運用と衝突します (超過分の Commit が CHECK 違反で弾かれて記録不能になる)。両立させたい場合は、Commit パスだけ CHECK を外す (CHECK を reserved のみに限定する等) か、超過記録を別カラム / 別テーブルに切り出すなどの工夫が必要です。「予算ぴったりに揃えたい・超過は許さない」運用なら CHECK が綺麗、「超過は許容して記録だけは正確に」したいなら本文の apply_nonce パターンの方が柔軟、という棲み分けになります。
9.2 実コストが max_cost を超えるケース
「max_tokens を超えるはずはない」と書くと油断を招きます。実コストが見積もりを超える経路は計算式バグ以外にも複数あります:
- マルチモーダル入力 (画像 / PDF / 音声) のトークン数は事前に厳密予測が難しい
- システムプロンプト・ツール定義・ツール結果が動的に膨らむ
- prompt caching の cache hit/miss で実効単価が変わる
- ツール呼び出しを伴うエージェント実行では出力が累積する (1 回の API call で複数 turn)
→ 基本方針: 超過分は cap を超えてでも spent に正直に記録し、「アラート + 次回以降のリクエストブロック」で対処する。max_cost で完全に防ぐのは無理と割り切るのが現実解です。これにより、超過は「警告すべき例外」として可視化されつつ、システムは破綻しません。
9.3 その他の運用論点 (タイトルのみ)
深堀すると別記事になるので、列挙だけ:
- 見積もりコスト (max_cost) の精度向上: プロバイダ・モデルごとに単価が異なる、プロンプト長と max_tokens から事前計算
-
Stuck reservation の回収: Workers が異常終了して Commit/Release されなかった予約 (
budget_reservations.status = 'pending'のまま長時間更新なし) を、cron で定期的に Release する。9.1 の予約テーブル設計と組み合わせる -
アラート閾値設計: 50% / 80% / 90% / 100% の閾値通知 (
last_alert_threshold列で重複抑制) - 期間境界: 月跨ぎリクエストの扱い、UTC か JST か
- プロバイダ価格改定: 月中で単価が変わるとき、改定前後で日割する
- D1 障害時のフェイルセーフ: KV キャッシュ併用、Read-only モード
これらは「設計原理」よりも「運用ノウハウ」の領域です。本記事の Reserve / Commit / Release を理解した上で、自分たちの運用要件に合わせて追加していけば十分です。
10. テスト戦術
「予算オーバーが起きない」ことを検証するテストは、以下のような形が定石です。
10.1 並行リクエストでの境界条件
// 残予算ぴったりに設定し、並行 N リクエストを発射
const remaining = 500_000_000; // $500 (micro USD)
const perRequest = 200_000_000; // $200 max_cost
const N = 50;
const results = await Promise.all(
Array.from({ length: N }, () =>
fetch("/api/llm/proxy", { method: "POST", /* ... */ }),
),
);
const ok = results.filter(r => r.status === 200).length;
const blocked = results.filter(r => r.status === 429).length;
// 残予算 $500、各 $200 → 最大 2 件しか通らないはず
expect(ok).toBeLessThanOrEqual(Math.floor(remaining / perRequest));
expect(ok + blocked).toBe(N);
// DB 上の reserved + spent が cap を超えていないか確認
10.2 タイミング攻撃の擬似試験
- 時刻 0 で残予算ぴったり に設定
- 同時に 2 リクエスト を発射
- 少なくとも 1 つは 429 で返ることを確認
「片方だけ通る」ことが確認できれば、予約方式が機能している証拠です。
10.3 Release のロールバック検証
- 5 階層 reserve のうち 4 段目で意図的に予算不足を起こす
- 1-3 段目の
reserved_usd_microが元に戻っていることを確認
11. まとめ
- LLM API の予算管理は「事後通知」だけでなく「事前予約」を選択肢に持つと、Race Condition を構造的に避けられる
- Cloudflare D1 のアトミック条件付き
UPDATEを 1 発打つだけで、予約方式の核は実装できる - 多段予算も「逐次 reserve + 失敗時 release」で自然に拡張できる
- 本番運用に持ち込むなら、§9.1 の冪等性 (request_id ベースの予約テーブル) と §9.2 の実コスト超過の扱いも合わせて設計しておくと安全
- 予約方式は古典的な手法ですが、1 リクエストの単価が大きく揺れる LLM API のユースケースでは特に効果が大きい設計です
参考
著者について
日本企業向けの AI API ゲートウェイ「Apimane (エピマネ)」を開発しています。Claude / GPT / Gemini を含む 7 プロバイダを 1 枚の日本円建て JCT 適格請求書に集約するサービスで、本記事で紹介した予約方式は中核設計の一部です。
2026 年 6 月 15 日ローンチしました。技術的なフィードバックや、似た設計をしている方の知見共有、大歓迎です。プロフィール からお気軽にどうぞ。