0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

8 プロバイダ統合 LLM ゲートウェイを Cloudflare Workers + D1 + KV だけで作った話 — 4 軸アーキテクチャの設計判断

0
Posted at

この記事の概要

  • 日本企業向けの LLM API ゲートウェイを、Cloudflare Workers + D1 (SQLite) + KV だけ で実装した。本記事はその設計判断を 4 つの軸に分けて解説する。
  • 4 軸とは: (1) 横断統合 API キー / (2) Zero-PII 設計 / (3) Multi-Dimensional Budget Governance / (4) 為替平準化 + 多様な決済。これに加えてオプションの Smart Routing (コスト最適化 + BCP + 速度優先 + 大文脈対応) を持つ。
  • 「8 プロバイダ統合 × Zero-PII × 為替平準化 × Smart Routing」を 1 つの設計で両立させるために、どのテーブル設計・どのフロー設計・どの妥協を選んだかを共有する。
  • 「箱だけ豪華な集約 API ゲートウェイ」ではなく、日本企業の経理・情シス・経営の三者がそれぞれ抱える課題を、設計レベルで同時に解決する ことを目指した。

対象読者: LLM API のマルチプロバイダ統合を真剣に考えているエンジニア・SaaS 創業者・情シス・経理担当の方。第一弾「LLM API ゲートウェイで予算上限を本気で守る設計」の続編ですが、本記事だけでも独立して読めます。


この記事はZennにも投稿しています(より多くの方に読んでいただくためにQiitaにも掲載しています)。
https://zenn.dev/beki/articles/9c44b781a7874d


1. なぜ「4 軸」なのか

LLM API のゲートウェイ製品は、世界では既に Portkey / OpenRouter / Vercel AI Gateway / LiteLLM など多数存在します。それらは大変よくできた製品で、私自身も学ぶことが多いプロダクトです。

ただ、日本の法人組織 が共通して抱える特有の商習慣やガバナンス課題(経理・情シス・予算管理)に対しては、既存製品では設計上の埋まらない隙間が残っていることに気づきました。

課題 既存製品の対応 日本企業の現実
8 プロバイダの API 経費を、月末に 1 枚の日本円 JCT 適格請求書 に集約したい 多くは USD 請求書 / 海外送金 JCT 番号付き / 銀振 / インボイス制度対応が必須
社員 PII (氏名・メール・所属) を 海外 SaaS に預けたくない 多くは Auth0 等で PII 取得が前提 情シス稟議で「PII 海外移転」は致命的論点
部署 × プロジェクト × 環境 × 個人 の 多軸予算 を、組織図に沿って組みたい 多くは「組織 / チーム / メンバー」の 3 階層 「営業部 × A 社案件 × 検証環境 × 田中さん」のような M:N が現場の実態
月末請求が USD 為替変動で月ごとに 5-15% ブレる 多くは USD のまま転嫁 日本企業の予算管理は円建て、変動は経営判断の障害

※ 既存製品の対応は、各社の公式ドキュメント (2026 年 6 月時点で公開、筆者が読んだ範囲) を要約したもので、断定的な比較ではありません。仕様は時点もので、変更され得ます。最新は各社公式でご確認ください: Portkey docs / OpenRouter docs / Vercel AI Gateway docs / LiteLLM docs。また、各製品は本記事の関心外の領域で優れた機能を多数持っており、本表はあくまで「日本企業の上記 4 課題」という限定軸での観察です。

これら 4 つを 「個別の機能追加」ではなく、データモデル・認証・課金の設計レベルで同時に解決する ために、設計を 4 軸に整理しました。


2. 全体アーキテクチャ

┌─────────────────────────────────────────────────────────────────────┐
│  顧客側 (情シスが調達した固定 IP)                                     │
│                                                                     │
│  ┌────────────────────────────┐      ┌────────────────────────────┐ │
│  │ 顧客アプリ (Anthropic SDK,  │     │ クライアント SPA (Next.js)  │ │
│  │ OpenAI SDK, Google SDK)   │      │  - IndexedDB に PII 保管   │ │
│  │ server_url = <ゲートウェイ> │      │  - サーバーに送らない       │ │
│  └────────────┬───────────────┘      └───────────┬────────────────┘ │
│               │ Authorization: Bearer sk-...     │ HTTPS (API)      │
└───────────────┼──────────────────────────────────┼──────────────────┘
                │                                  │
                ▼                                  ▼
        ┌──────────────────────────────────────────────────┐
        │  Cloudflare Workers (Gateway)                    │
        │  - 認証 (D1 api_keys_v2 + KV cached lookup)      │
        │  - グループルックアップ (D1 org_groups M:N)        │
        │  - 多軸予算 reserve (D1 atomic UPDATE)            │
        │  - プロバイダ振り分け (Smart Routing optional)     │
        │  - 為替適用 (D1 exchange_rates / Frankfurter rate)│
        └──────────────────┬───────────────────────────────┘
                           │
        ┌──────────────────┼──────────────────────────────┐
        │                  │                              │
        ▼                  ▼                              ▼
   Anthropic API      OpenAI API                  Google / Mistral / Grok /
                                                  Groq (Llama 等) / DeepL /
                                                  Google Translate API
                           │
                           ▼
        ┌──────────────────────────────────────────────────┐
        │  Cloudflare D1 + KV (使用量集計、リアルタイム集計)  │
        │  - usage_logs_v2 (Zero-PII: organization_id +    │
        │    numeric values のみ、IP/UA 等を持たない)        │
        │  - admin_access_logs (メタ監査ログ)               │
        │  - provider_pricing_history (時刻ベース価格管理)  │
        └──────────────────────────────────────────────────┘

主要な技術スタック:

レイヤ 選定 主な理由
エッジコンピューティング Cloudflare Workers リクエスト毎に Cold Start なし、APAC リージョン経由で日本顧客に低レイテンシ
DB Cloudflare D1 (SQLite) 単一ファイル SQLite だが Cloudflare による分散管理、月 25M req までほぼ無料、Workers から直接呼べる
キャッシュ Cloudflare KV API キー → org のルックアップ、為替レート、ヘルスチェック結果のキャッシュに使用
フロントエンド Next.js (Vercel) LP + ダッシュボード (クライアント SPA)、IndexedDB ベース認証で完全クライアント完結
決済 Stripe 日本円建てサブスク、JCT 適格請求書発行は別経路

3. 軸 1: 横断統合された API キー

3.1 何ができるか

顧客は ゲートウェイ発行の 1 本の API キー だけを持ちます。SDK の server_url (または base_url) をゲートウェイのエンドポイントに向けるだけで、Anthropic / OpenAI / Google / Mistral / Grok、そして Groq がホストする Meta Llama / OpenAI gpt-oss 等の主要なオープンソースモデルが呼べます。さらに DeepL / Google Translate も同じキーで使えます。

# Anthropic SDK の場合
from anthropic import Anthropic
client = Anthropic(
    api_key="sk-...",                          # ゲートウェイ発行キー (各社 API キー不要)
    base_url="https://your-gateway.example/v1" # ゲートウェイのエンドポイント
)

response = client.messages.create(
    model="claude-sonnet-4-6",            # Anthropic モデル
    messages=[{"role": "user", "content": "Hello"}]
)
# 同じキーで OpenAI モデルを呼べる (Anthropic SDK 経由でも内部ルーティング)
response = client.messages.create(
    model="gpt-5.5",                       # ← 別プロバイダの別モデル
    messages=[...]
)

3.2 内部設計

Worker が model パラメータを見て どのプロバイダに振り分けるか を決定します。各プロバイダの SDK の差異は Worker 内部で吸収し、顧客には統一されたインターフェース (/v1/messages の Anthropic 形式 / /v1/chat/completions の OpenAI 形式の両方) を提供します。

// 擬似コード: model → provider のルーティング
async function routeRequest(req: Request, env: Env): Promise<Response> {
  const body = await req.json();
  const provider = inferProviderFromModel(body.model);
  //   anthropic: claude-* / openai: gpt-* / google: gemini-* / mistral: mistral-* / xai: grok-*

  switch (provider) {
    case "anthropic":  return callAnthropic(body, env.ANTHROPIC_API_KEY);
    case "openai":     return callOpenAI(body, env.OPENAI_API_KEY);
    // ...
  }
}

各プロバイダの API キーは ゲートウェイ運営側が保有 し、顧客には開示しません。これにより:

  • 顧客は プロバイダ各社のクレジットカード登録 が不要 (情シス的に大きい)
  • 顧客は プロバイダの T&C への個別同意 が不要 (法務的に大きい)
  • ゲートウェイ側で 異常検知 / レート制御 を一元化可能

3.3 何が難しいか

実は、この「単一エンドポイントで複数 SDK を吸収する」設計は、各 SDK が想定するレスポンス形式の 微妙な差異 をどう扱うかが課題です。例えば Anthropic SDK は content_block_delta でストリーミングしますが、OpenAI SDK は choices[].delta.content です。私たちの実装では 「呼び出し元 SDK のフォーマットに合わせて変換」 することで、SDK の変更を最小化しています。詳細は別記事「マルチプロバイダ SDK 互換層」で扱う予定です。


4. 軸 2: Zero-PII 設計 + クライアント側 PII マージ

4.1 何が嬉しいか

このゲートウェイは、顧客の 社員 PII (氏名・メール・所属・社員番号) を一切預かりません。usage_logs_v2 テーブルには organization_id と数値のみが入り、誰がどのリクエストを送ったかはサーバー側からは追跡不能です。

これにより:

  • 情シス稟議の最大のハードルが解消 (「PII を海外 SaaS に移転する」承認が不要)
  • GDPR / APPI / SOC2 の管理対象が劇的に縮小
  • データ侵害事故が発生しても、PII 流出にはならない (経済価値のあるトークン分布のみ)

4.2 設計トレードオフ

ただし、Zero-PII を貫くと 「どの社員がいくら使ったか」が分からなくなる という致命的な問題が生まれます。これを解決するのが クライアント側 PII マージ です。

顧客のブラウザ内で完結する Next.js SPA を用意し、IndexedDB に「社員台帳 ↔ API キー ID のマッピング表」を保管します。CSV インポート機能で 100 名規模の組織でも一括取り込み可能。表示時には、Worker から取得した「key_id ベースの集計」と、ブラウザ内の「社員台帳」を ローカルでマージ することで、「マーケ部 田中さん」と表示できます。

┌─────────────────────────────────────────────────────────────┐
│ 顧客ブラウザ (クライアント SPA)                               │
│                                                             │
│  IndexedDB: { "key_xxx": { name: "田中", dept: "営業" }, …}  │
│                                                             │
│  ┌──────────────────────────────────────┐                   │
│  │ Worker からの集計 + ローカル PII マージ │ ← ローカル結合のみ │
│  │ → 画面表示「田中さん ¥2,100」          │                   │
│  └──────────────────────────────────────┘                   │
└─────────────────────────────────────────────────────────────┘
                          ↑                             ↑
                          │ Worker への API リクエストは  │
                          │ key_id (PII 含まず) のみ     │
                          ▼                             │
┌────────────────────────────────────────────────────────────┐
│ Worker (Cloudflare)                                        │
│   usage_logs_v2: organization_id, key_id, cost_jpy_milli   │
│   (氏名・メール・社員番号は持たない)                          │
└────────────────────────────────────────────────────────────┘

4.3 セキュリティ強化

クライアント SPA は 30 分無操作で自動ログアウト + ログイン時に毎回 admin tier の API キーを要求 します。これにより、誤って共用 PC でログインしたままにしても、30 分以内に強制サインアウト。社員 PII は IndexedDB に残りますが、API キーが消えれば集計データは取得不能になります。

将来のロードマップ (Phase 2) では WebAuthn / TOTP MFA + IP allowlist + 異常検知 を追加予定。本記事執筆時点 (2026-06-12) では、これらは ダッシュボード上の disabled UI で「🚧 Phase 2 で対応予定」紫バッジ + 「💡 たとえば」ユースケース表示にとどめています。

4.4 Zero-PII を維持したまま「誰がいくら使ったか」を可視化する

Zero-PII を貫くと、サーバー側は「key_xxx が当月 ¥2,100 使った」までは集計できますが、「key_xxx ≒ 田中さん」のマッピングはサーバー側で持てません。これをダッシュボードでどう解決したか。

設計の核は 「集計はサーバー、結合はクライアント」 です:

  1. Worker は usage_logs_v2 から key_id ベースの集計 を返す (PII を含まない API)
  2. ブラウザ側の SPA は IndexedDB に保管した社員台帳 (key_id → 氏名/部署/...) と、(1) の集計結果を クライアントで JOIN
  3. 「マーケ部 田中さん ¥2,100」という表示は すべてブラウザ内で完結

これにより、サーバーが侵害を受けても流出するのは「key_xxx ¥2,100」レベルの集計データのみで、氏名は流れません。

副次的な設計判断として、グラフ描画は依存ゼロの自前 SVG を採用しました。Chart.js / Recharts 等のサードパーティライブラリに IndexedDB のデータを渡すと、ライブラリ側のテレメトリ実装次第で PII が漏れるリスクが理論上ゼロにならないためです (実害は限定的でも、Zero-PII を 設計レベルで 貫くなら自前描画が筋)。

機能 実装
リアルタイム集計 Worker → usage_logs_v2 SQL 集計、key_id ベース
社員台帳マージ ブラウザ内 IndexedDB → JS で JOIN
グラフ描画 依存ゼロの自前 SVG (Chart.js 等を使わない)
CSV エクスポート ブラウザ側で生成、Worker は raw 数値のみ提供
メタ監査ログ admin_access_logs (誰がいつ閲覧したかを別テーブルで記録、GDPR/APPI 監査対応)

これにより、月末の経理処理を待たずに リアルタイムで利用状況を可視化 でき、Zero-PII の制約を 設計の制約から、設計の強みに変換 できます。


5. 軸 3: Multi-Dimensional Budget Governance

5.1 既存製品の 3 階層では足りない

多くの LLM ゲートウェイは「組織 → チーム → メンバー」の 3 階層で予算管理します。しかし日本企業の現実は、もっと多次元です。

「田中さんは 営業部 に所属しているが、A 社案件 にも兼任で参加していて、検証環境 での API 利用は 全社 R&D 予算 から、本番環境 での利用は A 社案件予算 から払いたい」

このように、1 人の社員 (= 1 個の API キー) が 4-6 個の論理グループに M:N で同時所属 することがあります。各グループには月予算があり、リクエスト 1 件は 所属する全グループの予算を同時に消費 します。

5.2 6 軸のグループタイプ

このゲートウェイでは、以下の 6 種類のグループタイプを定義しています:

グループタイプ 典型用途
all 全社共通グループ (組織全体予算) 月次の全社予算上限
member 個人グループ (1 人 1 つ) 個人の暴走防止
department 部署グループ (営業部 / マーケ部) 部署別の予算統制
project プロジェクトグループ (A 社案件) 案件別の原価管理
environment 環境グループ (検証 / 本番) 検証時の予算限定
custom カスタムグループ (R&D 予算 / 新人研修等) 柔軟な特例運用

1 つの API キーは、これら 6 種類のグループに M:N で同時所属 します (例: 田中さん = all + member-田中 + dept-営業 + project-A社案件 + env-本番)。

5.3 リクエスト 1 件が「同時 reserve」する仕組み

リクエスト着弾時、Worker は 所属する全グループの予算を 1 トランザクションで同時 reserve します:

-- 擬似 SQL: 1 リクエストで 5 グループの予算を同時 reserve
BEGIN;
  UPDATE org_groups
  SET reserved_jpy_milli = reserved_jpy_milli + 50000  -- 想定 cost
  WHERE id IN ('grp_all', 'grp_member_tanaka', 'grp_dept_sales',
               'grp_project_a', 'grp_env_prod')
    AND (limit_jpy_milli - used_jpy_milli - reserved_jpy_milli) >= 50000;
  -- 5 グループ全てで条件を満たさなければ rowcount < 5、ROLLBACK
COMMIT;

これにより、並行リクエスト下でも予算超過が構造的に発生しない。第一弾の予算予約方式を多次元に拡張した形です。これを我々は Multi-Dimensional Budget Governance と呼んでいます。

5.4 「事前拒否そのもの」ではなく「同時 reserve 設計」が新しさの根拠

業界には「予算上限を厳格に守る」事前判定型の製品は既にいくつかあります (OpenAI の予算 API / Anthropic の Spend Limit 等)。この設計の新規性は「事前拒否そのもの」ではなく、「M:N グループ階層を 1 トランザクションで同時 reserve する設計」 にあります。組織図が複雑な企業ほど、この設計が有効に機能します。


6. 軸 4: 為替平準化 + 多様な決済

6.1 為替平準化エンジン

LLM API の単価は USD 建てです。Anthropic Sonnet が $3 / Mtok のとき、月によって 1 USD = 145 円 / 158 円 / 152 円 と揺れると、月末請求が同じ利用量でも 10% 近く変動 します。これは経理にとって致命的な課題です。

私たちの実装では、毎日 UTC 0:00 (JST 9:00) に Frankfurter API (ECB 集約の仲値レート) から USD/JPY を取得し、D1 の exchange_rates テーブルに (date, rate, source) で保存します。当日の全リクエストは その日のレート で確定し、月末請求は 当月日次レートの平均 で平準化します。


[Cron: 毎日 UTC 0:00 (JST 9:00)]
  ↓
Frankfurter API (api.frankfurter.dev) から USD/JPY を取得
  ↓
D1 exchange_rates テーブルに upsert (date PK, rate, source="frankfurter")
  ↓
失敗時のフォールバック (3 段階):
  1. 7 日以内の最新 rate を D1 から読み戻して継続 (source="fallback_previous")
  2. それも無ければ環境変数の固定値で稼働 (source="fallback_env")
  3. 3 日連続で frankfurter source が記録されなかった場合、scheduled handler が Discord にアラート
  
// 擬似コード: リクエスト処理時の為替適用
const today = await env.DB.prepare(
  "SELECT rate FROM exchange_rates WHERE date = ? LIMIT 1",
).bind(todayUTC).first<{ rate: number }>();
const cost_jpy_milli = cost_usd_micro * today.rate / 1_000_000;
// 当日のレートで JPY を確定、後でレートが変動しても遡及しない

補足: 当初は KV (24h TTL) も検討しましたが、(a) 月末請求の透明性のため過去レートを「いつでも参照可能」にしたい、(b) D1 の方が (date, rate, source) を全件保持して監査ログとして使えて好都合、という理由で D1 一本に統一しました。KV は API キー → org のルックアップなど「揮発しても再構築できる」用途で使い分けています。

6.2 「クレジット / 残高 / ポイント」用語の落とし穴

LLM API の課金で前払い(デポジット)方式を採用すると、つい「クレジット」「残高」「ポイント」と呼びたくなりますが、これらの用語は 資金決済法上の前払式支払手段 と解釈される可能性があり、注意が必要です(当社独自の見解。実装にあたっては弁護士等の専門家にご相談ください)。

実装では用語ガバナンスとして:

  • ✅ 使用可能: 「デポジット」「前払金」「お支払い」
  • ❌ 使用禁止: 「クレジット」「残高」「ポイント」「ストアドバリュー」

を明確に分けています。この種の言葉選びは、機能を実装するより先に決めておくと運用が楽です。


7. Smart Routing — 自動化と顧客制御のバランスをどう取るか

7.1 設計の問い: どこまで自動化するか

LLM API のゲートウェイで「モデルの自動選定」を提供する設計は、ユーザビリティと信頼性のトレードオフを抱えます。安価なモデルへ自動で振り分ければコスト削減になりますが、「勝手にモデルが変わった」「監査要件で特定モデルしか使えない」というケースには逆効果になります。

このゲートウェイでは Smart Routing を 顧客側のオプトイン機能 として設計しました。初期設定はオフで、リクエスト単位で明示的に有効化したときだけ作動します (X-Apimane-Smart-Routing: on ヘッダ または model: "apimane-auto")。モードは X-Apimane-Mode: cheapest | balanced | fastest で切替え、未指定・不正値は cheapest として処理します。

「自動化を顧客に強制しない」設計を貫いた結果、規制業界・監査要件で「特定モデル指名」が契約上必須な顧客でも、オフで導入できる形に落とせました。オン時でもリクエストで明示モデルを指定すれば、その指定は常に優先されます。例外は「障害時フェイルオーバー」と「Context Window 超過時の自動切替」の 2 つだけで、どちらも応答ヘッダー (X-Apimane-Fallback / X-Apimane-Model-Used) で開示されます。意図しない・検知できないモデル切替は発生しません

そのうえで、自動化が活きるシーンは 4 つあると整理しました: ①コスト削減 (cheapest)、②品質を保ちつつコストを下げる (balanced)、③低遅延 (fastest)、④プロバイダ障害時の業務継続 (Failover)。さらに付録として⑤Context Window 超過時の自動切替を持たせています。以下、それぞれを「なぜそう作ったか」の角度で書きます。

7.2 cheapest (最安自動選定) — 最初に組んだ機能と運用で見えたこと

MVP の最初のバージョンでは cheapest の単機能で出しました。理由は明確で、私たちが日本企業から聞いたユースケースの中で、最も即効性のあった話が「上位モデルと廉価モデルで入力トークン単価に数十倍の差があり、軽いタスクを廉価モデルに寄せるだけで月次コストが大きく下がる」だったからです。

実装の中核は、リクエストの prompt トークン数と max_tokens から各候補モデルの推定コストを算出し、全候補の中で最小のモデルを選ぶ、というシンプルな argmin です。

// 擬似コード: cheapest ルーティングの判定軸
const cost_estimate = unitPrice.input_usd_per_mtok * promptTokens
                    + unitPrice.output_usd_per_mtok * maxTokens;
// → 全候補で算出 → argmin で1モデルを選び、以降の処理は選択モデルで動く

注意した点が 2 つあります。1 つ目は「削減率を一律に保証しない」という方針です。上位モデル偏重の構成からの移行ほど削減効果は大きく、元々廉価モデル中心の構成では効果が薄い、という極端な依存があるため、「○○% 下がります」と打ち出すと誤導になりかねません。記事中・LP 上でも「タスク構成によって大きく変わるため、一律の削減率は保証しない」表記で統一しました。

2 つ目は、廉価モデルの実例を「時点もの」で開示する ことです。本記事の執筆時点 (2026-06-12) では、Groq がホストする llama-3.1-8b-instant (入力 $0.05 / 出力 $0.08 per MTok) が候補中の最安級で、cheapest を有効にした軽量タスクはここへ流れることが多くなっています。フラッグシップモデルの入力単価と比べると 2 桁違うので、「分類・抽出・短い要約はこのクラスで十分」というワークロードを持つ組織ほど効きます。ただし単価・選定結果は時点もので、プロバイダの価格改定で変わるため、その断りを記事側で明示しています。

7.3 balanced (タスク種別 × 入力長) — 自動推定の精度限界をどう扱うか

cheapest だけで運用すると、「品質が必要なタスクでも安いモデルが選ばれる」場面が出てきました。これに対し、タスク種別 (要約 / コード生成 / 翻訳 / 一般会話 等) × 入力長クラス (短 / 中 / 長) で品質要件を満たす範囲を先に絞り、その中で cheapest と同じく argmin で最安を選ぶ balanced モードを後から追加しました。

ここで悩んだのが タスク種別の自動推定の精度 です。プロンプトのキーワードと長さから推定する仕組みは「コード」「翻訳」「分析」などの明示的なキーワードが含まれる場合は高精度ですが、それ以外の汎用な質問では推定精度が落ちる。「精度 100% の自動分類」はキーワードベースでは原理的に無理、というのは早期に分かりました。

ここで「軽量 LLM に 1 回分類させてから本番モデルへ振り分ける」という定石も検討しましたが、採りませんでした。理由は 2 つです。1 つはレイテンシ — リクエストごとに判定用の LLM 呼び出しを 1 回挟むぶん、応答が遅くなります。もう 1 つが決定的で、プロンプト本文を判定用 LLM にもう一度送ることになり、§4 で貫いた Zero-PII の思想に正面から反します。タスクを分類するためだけに、PII を含みうるプロンプトを余計な経路へ流すのは本末転倒です。だからこそ「Worker メモリ内で完結し、外へ一切出さない正規表現」を選び、本気で運用したい顧客にはヘッダ明示 (X-Apimane-Task) を本線として推奨する設計に割り切りました。正規表現の判定精度には限界がありますが、その限界は次の 2 つの仕掛けで吸収しています。

設計判断として、2 つの仕掛けで対処しました:

  1. キーワード判定は Worker メモリ上で瞬時に正規表現マッチして即破棄する。プロンプト本文・LLM 応答は当社サーバーに一切保存・収集しません (§4 Zero-PII 設計と整合)。判定が「失敗」してもプロンプトはサーバー側に残らないため、誤判定の精度コストはあっても情報漏洩リスクは生じない設計にしました
  2. X-Apimane-Task ヘッダで顧客が明示指定する運用を「正しい運用」として推奨する。本気で運用したい顧客には明示指定を推奨し、自動推定は「明示指定が無いリクエスト向けのフォールバック」と位置づけました

結果として、balanced は「自動推定の精度に依存しない設計」になっています。明示指定がある顧客は高精度で運用でき、明示指定が無い顧客はおおまかな振り分けの恩恵を受けつつ、推定が外れても致命的な品質低下にはならない (cheapest との品質帯マージンを取った最安選定なので)、というバランスです。

7.4 fastest (速度優先) — 断定を避けたルールベース

fastest モードを追加するにあたり、「実測で最速」を断定する設計にはしませんでした。プロバイダの速度はリージョン・時間帯・モデル更新で常に変動するため、「いつでも誰でも最速」を保証することは構造的に不可能だからです。

代わりに採用したのが 公式が「高速 / 低遅延」と位置づけているモデルクラスを優先するルールベース です。Claude Haiku / GPT-mini / Gemini Flash 等の各プロバイダ公式の速度系クラスを優先選定し、同クラス内では安価な順に選びます。

この設計の副次効果として、運用容易性も得られました。プロバイダ側の更新で速度が変わるたびにベンチマークを更新する保守コストが発生せず、「公式が高速としているクラス」をルールとして固定できるからです。「実測ベンチマーク主義」を採らないことで、運用負荷とトレードオフを両立させた形です。

注: 「実測で最速」を断定しない代わりに、応答ヘッダー X-Apimane-Model-Used で実際に選ばれたモデルを毎回開示しています。顧客側でレイテンシ計測が必要な場合は、このヘッダを基準にすればクライアント計測と突き合わせ可能です。

7.5 Failover (BCP) — 「予算 reserve との整合」と SSE の境界

Smart Routing の中で最も実装が複雑だったのが Failover (マルチプロバイダ・フェイルオーバー) です。リクエスト処理パス上で対象プロバイダが下記のいずれかに該当した場合、同等品質帯の別プロバイダへ自動切替します。

[Anthropic 障害発生]
   ↓ Worker が検知 (5xx 連続 3 回 / 30 秒タイムアウト / 429)
   ↓
[Discord/Slack 即時通知 + X-Apimane-Fallback ヘッダ付与]
   ↓
[自動的に OpenAI または Google にフェイルオーバー]
   ↓
[毎分 cron で復旧プローブ (KV ダウンマップを参照)]
   ↓
[復旧確認後、元のプロバイダに自動戻し]

設計の核は 予算 reserve との整合 でした。Failover が動くケースでは、最初に reserve した額 (= 最初のプロバイダの推定コスト) と切替先の推定コストが異なる可能性があり、これを並行リクエスト下でどう整合させるかが悩みどころでした。

最終的に採用したのが 「1 候補 1 予約」直列処理 です:

  1. 候補モデルごとに reserve
  2. プロバイダ呼び出し失敗時は release
  3. 次候補で再 reserve

直列処理にすることで、途中失敗時に reserved が孤児になる事態を構造的に避けられます (§5 で書いた D1 atomic UPDATE 設計、第一弾の予算予約方式と同じ思想)。並行 reserve も検討しましたが、整合性管理が複雑になりすぎる割に、Failover の発火頻度はそもそも稀 (プロバイダ障害時のみ) という観点で、直列処理のシンプルさを優先しました。

「候補ごとに D1 へ往復するとレイテンシが増えるのでは」という懸念はもっともですが、ここは桁で考えると割り切れます。Failover が発火するのは前段で障害検知のためのタイムアウト (秒オーダー) を待った後で、それに比べれば 1 候補ごとの reserve / release は軽量な UPDATE 1 本 (ミリ秒オーダー) です。秒に対してミリ秒の往復が数回増えても体感は変わりません。発火頻度が稀なこともあわせ、ここは往復の最適化より「reserved を孤児にしない安全性」を優先する判断にしました。

ひとつ設計上の境界を正直に書いておきます。SSE ストリーミングでのフェイルオーバーは「接続確立前」のエラーに対してのみ 働きます。配信開始後の切断は対象外です — 既にクライアントへ流れたデータを巻き戻せないためで、「中途半端に二重配信するより、切断をそのまま伝える」方を選びました。切替が動いた応答には X-Apimane-Fallback: trueX-Apimane-Fallback-From (切替元モデル) が付き、利用側で検知できます。

「設計レベルで割り切る」決断は他にもいくつかありますが、SSE の境界は特に「巻き戻せないものは巻き戻さない」というシンプルな原則を貫いた結果です。緊急停止スイッチとして環境変数 FEATURE_SMART_FAILOVER_ENABLED=false を持たせ、想定外の挙動が起きた場合は即座に単発呼び出しに復帰できる経路も用意しました。

7.6 Context Window 自動切替 — 「明示指定優先」の唯一の例外

最後に Context Window 自動切替について。これは Smart Routing 全体の中でも特殊な位置づけで、**「明示モデル指定を顧客が出していても、入力サイズが context window を超える場合は自動で対応可能なモデルへ切替える」**という、明示指定優先の唯一の例外です。

なぜこの例外を入れたか。明示指定優先の原則を貫くと、「context window 超過時はエラーで止める」のが筋ですが、実際の業務利用では「指定モデルでは無理ですよ」と返されるとそのリクエストが業務として止まります。長文要約・コード解析などの利用シーンで context window 超過は珍しくないため、エラー停止を原則にすると顧客側で「事前にトークン数を見積もってモデルを切替える」コードを書く必要があり、ゲートウェイの存在価値 (「複数モデルを 1 本のキーで吸収する」) を一部損ねます。

設計判断として、context window 超過は「明示指定優先」の例外として 自動切替を許容しつつ、切替えた事実は応答ヘッダー X-Apimane-Model-Used で必ず開示する という形に落としました。「自動的にやるが、必ず開示する」は Smart Routing 全体の通底テーマで、CW 切替もその思想に沿っています。

context window の値は実装上 providers/model-meta.ts に保守値として持ち、各モデルの公称値に対して安全マージンを取った値を採用しています。実プロバイダの context window 計算がトークナイザ依存で変動する余地を吸収するためで、「公称値ギリギリで切替判定する」と境界トラブルが起きやすいという経験則からの選択です。


8. なぜ Cloudflare スタックを選んだか

このゲートウェイの主要スタック (Workers + D1 + KV) は、以下の理由で選定しました:

8.1 レイテンシ

Cloudflare の APAC リージョン (NRT / KIX / TPE 等) は日本に近く、Workers のコールドスタートはほぼゼロ。Anthropic API への中継として、自前で AWS EC2 + RDS + ALB を構築するより 顧客から見たレイテンシが 50-100ms 程度短縮 される傾向があります (当社環境・本記事執筆時点での目安。リージョン・時間帯・対象プロバイダにより実測値は変動します)。

8.2 運用コスト

8 プロバイダ統合の MVP を 1 名で運用するには、運用工数ゼロ が必須でした。Workers + D1 + KV は、サーバープロビジョニング・パッチ適用・SSL 更新・スケーリング設定がすべて不要。これだけで創業者 1 名の工数が週数時間、節約できます。

8.3 課金モデル

Workers は月 100K リクエストまで無料、それ以降は $5/月から。D1 は月 5M リクエストまで無料。KV は月 100K reads まで無料。MVP 初期の 6 ヶ月は ほぼ無料枠で運用可能 で、収益化前の固定費がほぼゼロに抑えられます。1 人で立ち上げる場合、ここの差は大きく効きました。

8.4 妥協点

ただし、Cloudflare スタックには制約もあります:

  • D1 は SQLite ベースで、PostgreSQL の RETURNING / WINDOW / CTE 等の一部機能が限定的
  • Workers の CPU 時間はデフォルト 30 秒 (有料プランは最大 5 分まで設定可)。ただし LLM プロキシの実処理は大半が fetch() の I/O 待機で CPU 時間としてはカウントされないため、CPU 時間制限がボトルネックになる場面はほぼない。長い生成タスクで SSE ストリーミングを使うのは、CPU 時間制限の回避ではなく クライアント側のレスポンス体験 (生成途中で文字が流れる UX) のため
  • D1 の DROP COLUMN 不可 (マイグレーションが片道切符)
  • KV は eventual consistency (read-after-write は数秒の遅延あり)

これらは MVP 規模では問題になりませんが、将来のロードマップ(Phase 2 以降)の規模化で再評価する必要があるかもしれません。


9. まとめ

設計の 4 軸:

  1. 横断統合された API キー — 8 プロバイダを 1 本のキーで、SDK の base_url 切替のみ
  2. Zero-PII 設計 — 社員 PII は顧客ブラウザに留め、サーバー側は預からない (情シス稟議のハードル解消)
  3. Multi-Dimensional Budget Governance — 6 軸 M:N グループの同時 reserve で並行リクエスト下も構造的に予算超過なし
  4. 為替平準化 + 多様な決済 — 日次 TTM × 月次平均で月次変動を吸収

加えて Smart Routing (コスト最適化 + BCP + 速度優先 + 大文脈対応) で、コスト削減・ベンダー障害耐性・低遅延ユースケース・大入力対応を 1 つのオプションで同時に提供する設計です。

これらを Cloudflare Workers + D1 + KV のみ で実装し、1 名で運用可能な MVP として組みました。

機能を「並べる」のではなく、データモデル・認証・課金の 設計レベルで「同時に解く」 ことを優先しました。経理・情シス・経営の三者が抱える課題は、表面的な機能の足し算では噛み合わないので、設計判断のレイヤーで対処した形です。

各軸の実装で「ここはもっとこう書けたはず」というご意見・反論・別案があれば、ぜひコメントや X (@kohei_hosoo) でお聞かせください。次回 (6/22 公開予定) は本記事の続編で、Zero-PII Architecture の実装の現実 — 個人情報を持たない LLM ゲートウェイで「誰がいくら使ったか」をどう答えるか を掘り下げます。


本記事のアーキテクチャを実装したサービス: Apimane (エピマネ) — 日本企業向け AI ガバナンスプラットフォーム。


前回記事: LLM API ゲートウェイで予算上限を本気で守る設計 — Cloudflare D1 アトミック UPDATE の予約方式

次回予告 (6/24 公開予定): Zero-PII Architecture の実装の現実 — 個人情報を持たない LLM ゲートウェイで「誰がいくら使ったか」をどう答えるか


0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?