LLM ゲートウェイを自作した理由|Gemini 無料枠の「超過ゼロ」をコードで保証する設計
LLM ゲートウェイを自作する価値は、互換の口ではなく裏で守る不変条件にある。llm-gateway では、無料枠を超えない(予約→確定)、機密をクラウドへ出さない(未列挙の先はクラウド扱い)、失敗の種類で行き先を決める(5分類と when)の3つに絞った。
llm-gateway は、自作プロジェクトの LLM 呼び出しを1本に集約するための OpenAI 互換ゲートウェイである。LAN 内の GPU 機 EdgeXpert GB10 の Ollama を優先し、Gemini API の無料枠を予備に使う。FastAPI + httpx + SQLite 製で Synology NAS の Docker で動き、クライアントは base_url を替えるだけでよい。
# README.md から抜粋
# client_example.py
from openai import OpenAI
client = OpenAI(base_url="http://gateway.local:8787/v1", api_key="dummy")
res = client.chat.completions.create(
model="auto", # config の routes に定義した alias
messages=[{"role": "user", "content": "..."}],
)
# …(embeddings の例は省略)
集約は目的で実績ではなく、経由して動くプロジェクトは執筆時点でまだ無い。仕様は openapi.yaml・config-schema.yaml・quota-db-schema.sql の3つの契約ファイルに置いた(契約ファースト)。
1. 既製品を使わず薄く自作した理由
設計時(2026年7月)に前提にした制約はこうだった。Gemini API の無料枠は per-project の RPM・RPD・TPM で、RPD は太平洋時間0時(夏時間あり)にリセットされ、値は予告なく変わる。入出力が学習に使われ得るので機密は送れず、billing を有効にすると同一プロジェクトの無料枠が消える。複数キーでの水増しは規約違反なので考えない。いずれも設計文書(docs/design.md §2、2026-10-22 確認)に記録した当時の理解で、現行の規約と値は公式ドキュメントで確かめる。
LiteLLM Proxy をフックで拡張する案は捨てた。日次枠や予約→確定はフックで自作になり、枠管理がフレームワーク内部で動くので、「枠超過ゼロ」の保証主体がコードの外に出て自分のテストで保証できない。フレームワーク内部への理解負債が本体の実装より重くなり得て、依存も太い。
各プロジェクトが SDK を直接叩く案も捨てた。RPD は per-project の共有資源で、分散管理は二重消費と 429 の押し付け合いになり、2本を超えた時点で破綻すると判断した。
採ったのは FastAPI + httpx の薄い自前実装で、OpenAI 互換 IF により選定を可逆にし、LiteLLM へ移す退路も残した。
2. 枠を「予約→確定」で管理する
枠は先に押さえる。見積(入力の概算+max_tokens)で reserve、実測 usage で confirm、失敗なら release する。同時のリクエストが同じ残量を見て両方通る二重消費を防ぐためで、「RPD を1件も超過しない」は同時 N 件の reserve を投げる原子性テストで保証する。rpm・rpd・tpm は1トランザクションで検査・加算する。
# llm_gateway/router/quota/manager.py から抜粋(_reserve_sync)
# …(deltas = 各窓の加算量、limits = 実効上限、_METRIC_ORDER = rpm/rpd/tpm の定義は省略)
conn.execute("BEGIN IMMEDIATE")
committed = False
try:
# …(3つの窓を読む)
# all-or-nothing: どれか1つでも不足していれば、他が足りていても一切加算しない。
for metric in _METRIC_ORDER:
used, reserved = used_reserved[metric]
if used + reserved + deltas[metric] > limits[metric]:
raise QuotaExhausted(
# …
)
for metric in _METRIC_ORDER:
conn.execute(
"UPDATE quota_window SET reserved = reserved + ? "
# …
)
conn.execute("COMMIT")
committed = True
# …
finally:
# …
if not committed and conn.in_transaction:
conn.execute("ROLLBACK")
上限は limit × (1 − reserve_ratio) で、reserve_ratio 分は緊急用に温存する。limit は config を正として DB に持たず、枠の変動は差し替えで追う。太平洋時間の変換は zoneinfo のみ(自前のオフセット計算は DST の境界で誤りやすい)で、tz データの無い Windows や最小構成の Docker 向けに tzdata を足した。
当日スキップのフラグは rpd 不足時だけ立てる(rpm・tpm だと翌分に戻る枠を1日捨てる)。当日スキップは「試行」と数える(でないと [gemini, ollama(when: quota_exhausted)] が2回目以降働かない)。単独で上限を超える request_too_large は、その候補では恒久に通らないので即次候補へ進む。
3. 機密をクラウドへ出さない二重の遮断
遮断は起動時の静的検証と実行時フィルタの二重で、sensitive の route にクラウド候補がある config は起動しない。実行時に踏むと失敗分類が backend_error などに化けて原因が隠れるからだ。判定はローカルだけを列挙する allowlist で行う。
# llm_gateway/router/privacy_guard/constants.py から抜粋
LOCAL_BACKENDS: frozenset[str] = frozenset({"ollama"})
def is_cloud_backend(backend: str) -> bool:
# …
return backend not in LOCAL_BACKENDS
当初の denylist(CLOUD_BACKENDS)は、新しいクラウド backend の追記漏れで両検査を素通しする fail-open だと Phase 2 のゲートで gate-reviewer が指摘し、運営者の承認で 2026-07-20 に反転した。今は追記漏れが起動失敗という安全側に倒れる。
4. 失敗を5つに分類して when で次候補へ
失敗は5つに分類し、既定の経路を決めた。
| 分類 | 検知 | 既定経路 |
|---|---|---|
| rate_limited | 429 のうち分単位 quota / Retry-After が短い | backoff + jitter で同一候補を上限 2 回再試行 → 次候補 |
| quota_exhausted | reserve 失敗、または 429 の日次 quota 表示 | 当日中は当該モデルをスキップ(フラグ化)→ 次候補 |
| backend_error | 5xx / timeout / 接続不可 | 再試行 1 回 → 次候補 |
| schema_invalid | 応答パース不能・契約違反 | 次候補へ(修正指示付き再試行は呼び出し側の責務。gateway は透過に徹する) |
| request_too_large | reserve 見積が単独で window 実効上限を超過 | 再試行・当日スキップとも行わず即次候補へ(この候補では恒久に不能)。全候補不能なら 400 / code: request_too_large |
出典: docs/design.md(2026-10-22 確認)
次へ進む条件は when に書く。以下はリポジトリの設定例(HEAD c63dd42、2026-10-22 確認)で、gemini-3.5-flash はリポジトリの記録では 2026-10-11 時点で公式の廃止予定に入っており、モデル名は変わる。
# config.example.yaml から抜粋
routes:
# 既定: ローカル優先、失敗時のみ Gemini へエスカレーション
auto:
privacy: normal
chain:
- { backend: ollama, model: "qwen3:32b" } # 要置換: 手元の Ollama モデル名
- { backend: gemini, model: "gemini-3.5-flash", when: [backend_error, schema_invalid] }
# 機密系: クラウド候補を書くと起動時エラーになる(privacy guard の静的検証)
local-only:
privacy: sensitive
chain:
- { backend: ollama, model: "qwen3:32b" } # 要置換
when は最後に実際に試行して失敗した分類で評価する。不一致で飛ばした候補は試行ではないので分類を更新せず、chain A→B→C で A が rate_limited、B が不一致なら C は A の分類で評価される。
全滅時は枠系が 429(Retry-After 付き)、障害系が 502、request_too_large が 400 を返す。枠が尽きればローカルだけの劣化運転になる。
5. キャッシュと stream の境界で起きたこと
キャッシュキーは実体モデル名を含めない alias ベースにした。chain 解決前に引け、劣化運転中もヒットすればクラウド品質の応答を返せる。旧モデルの応答が残る点は TTL で上限を張った。
stream は stream_options.include_usage を強制付与し、最終 chunk の usage で confirm する(欠落時は見積のまま)。最初の chunk で確立した後は、応答を混ぜないためフォールバックしない。tool_calls を含む stream 応答はキャッシュしない。delta の tool_calls を結合する実装は Phase 3 の必要性に対して複雑さが見合わないと判断して捨て、非 stream だけがキャッシュされる非対称を意図した挙動として受け入れた。
欠陥はこの境界で出た。Phase 3 のゲートの should(クライアント切断の API レベル回帰テスト)に対応する中で、切断時の予約リークが見つかった。anyio の cancel scope はキャンセルを繰り返し配送するため、中断処理内の会計の await が完走しない。会計を独立タスクに切り出し、asyncio.shield で完走を待ち切った(当時の名は _finish_accounting。Phase 5 で改名)。
# llm_gateway/router/resolver.py から抜粋(finish_accounting)
async def finish_accounting(awaitable: Awaitable[_T]) -> _T:
# …(docstring は省略)
task = asyncio.ensure_future(awaitable)
while True:
try:
return await asyncio.shield(task)
except asyncio.CancelledError:
if task.done():
return task.result()
# 外側(stream タスク)への再キャンセル配送のみ吸収し、会計タスクの完走を待つ。
運営者が指示した計画外の横断レビュー2回でも must が2件出た。1件は stream リクエストが tool_calls 応答のキャッシュにヒットして tool_calls が欠落する欠陥で stream とキャッシュの境界に、もう1件は backend が GatewayError 以外の例外を出すと reserved がリークする欠陥で quota 会計の例外処理(非 stream も含む)にあり、どちらも機能どうしが接する所(stream とキャッシュ、quota 会計と backend の例外)だった。Phase 3 のゲートでも quota 会計は敵対的に追跡していたが、見つからなかった。
まとめ
自作の価値は、予約→確定・allowlist・5分類で守る不変条件にあると考えている。欠陥は会計・stream・キャッシュが互いに接する所に集まった。ローカル LLM の作業分担は別稿で扱った。配備と運用は別稿の予定である。開発は implementer(sonnet)と gate-reviewer(opus)を分けたフェーズゲート制で進め(モデルは開発当時の指定)、承認後にコミットした。
--- 参照: llm-gateway v0.2.0(HEAD c63dd42、2026-10-22 確認)の README.md・config.example.yaml・docs/design.md と llm_gateway/router/ の quota/manager.py・privacy_guard/constants.py・resolver.py。Gemini API のレート制限: https://ai.google.dev/gemini-api/docs/rate-limits / モデル一覧: https://ai.google.dev/gemini-api/docs/models (いずれも 2026-10-23 確認)。