はじめに
こんにちは、RevComm AI Div. の id:tmotegi です。
RevComm では Copilot・要約・コーチングなど、LLM を活用した機能が急速に増えています。当初は1〜2つのサービスが LLM を直接呼び出すだけでしたが、新機能のリリースと並行して利用するプロバイダーも広がり、気づけば各サービスが個別に Claude(Bedrock)、OpenAI(Azure)を呼び出す構成になっていました。
しばらくはそれでも回っていましたが、LLM を利用するチームが増え、利用するモデルやプロバイダーも広がるにつれ、いくつかの問題が表面化してきました。
- クォータ・レートリミット対応がサービスごとに分散 ─ 以前はクォータの上限が低く、サービスごとに複数アカウントを作成してレートリミットを回避していました。レートリミットに引っかからないよう、各チームが個別にリトライや切り替え処理を書いており、同じエラーハンドリングコードが各所に散在していました。
- 新モデル対応のコストが高い ─ 新しいモデルが出るたびに、複数サービスでクライアントコードやモデル名を個別に更新する必要がありました。
- コストの内訳が見えない ─ 「どの機能が、どのテナントで、どのモデルをどれくらい使っているか」がプロバイダーをまたいで把握できず、プロバイダーの請求書を見ても機能別・テナント別の内訳がわかりませんでした。コスト増の原因を特定するだけで時間がかかっていました。
- 国内処理の適用漏れリスク ─ 顧客要件でデータを国外に出せない機能では、利用できるモデルが制限されます。その制約を各サービスで個別に管理していたため、適用漏れが起きる潜在的なリスクがありました。
これらを解決するために、社内の LLM 呼び出しを一本化する LLM Gateway を開発しました。
目的は大きく2つです。
- マルチプロバイダーの統一 ─ 単一インターフェイスで Bedrock / Azure OpenAI / Vertex AI を透過的に扱い、クライアント側のコードを変えずにモデルを切り替えられるようにすること。
- コストの可視化 ─ リクエストごとの利用状況を記録し、テナント・機能・モデル別にコストを集計できるようにすること。
最初から自分たちで実装すると決めていたわけではありません。まずは既製の LiteLLM Proxy Server をそのまま使うことを検討し、最終的に litellm をライブラリとして使って自分たちで実装する形に落ち着きました。
なぜ LiteLLM Proxy Server を最初の選択肢にしたのか?
LLM Gateway の構築を決めたとき、まず検討したのは LiteLLM Proxy Server です。100 以上のプロバイダーを OpenAI API 互換の単一エンドポイントで束ねる OSS で、フォールバック制御・コスト追跡・Observability 連携が標準で揃っています。はてなや弁護士ドットコムなど、国内企業の技術ブログでも導入事例が多く公開されており、LLM Gateway が必要になった企業が最初に検討する選択肢です。
LiteLLM Proxy Server が標準で持つ機能は非常に充実しています。
- OpenAI API 互換エンドポイント(既存の OpenAI クライアントを向き先だけ変えてそのまま使える)
- 複数プロバイダーへのルーティングとロードバランシング
- リクエストボディで
fallbacksフィールドを指定するフォールバック制御 - コスト追跡・予算管理(モデルごとのトークン単価を設定しコストを自動計算)
- Langfuse、Datadog などの Observability 連携
特に「リクエストにフォールバック順序を書ける」機能は、私たちが必要としていた要件と重なっていました。
{ "model": "claude-sonnet-4", "messages": [...], "fallbacks": ["gpt-4o", "gemini-2.0-flash"] }
クライアントがフォールバック順序をリクエストに含めて送るだけで、LiteLLM Proxy Server 側がエラー時に次のモデルへ切り替えてくれます。導入するだけで多くの機能が手に入り、運用面の作り込みも不要になる魅力的な選択肢でした。
なぜ自分たちで実装したのか
「国内処理の制約」と「フォールバック戦略」を連動させる複雑なルーティングロジックを、LiteLLM Proxy Server の async_pre_call_hook などのフック処理内に無理に詰め込みたくなかった、というのが大きな理由です。
LiteLLM Proxy Server には async_pre_call_hook でリクエストを受け取り、data["model"] を書き換えることで動的なルーティングを実現するカスタムハンドラーがあります。私たちの要件もこれで実装できます。ただし私たちのルーティングには、単純なモデル切り替えにとどまらない要件が重なっていました。
- リクエスト単位で国内処理を強制できる ─ 特定のリクエストは日本国内リージョンで動作するモデルのみ使用する
- フォールバック先も国内リージョンのモデルに限定する ─ 国内処理が必要なリクエストがフォールバックしても、海外リージョンには流れない
- クライアントがフォールバック順序を指定できる ─ 「まず Claude、だめなら OpenAI」という優先順位をリクエストに書ける
要件1だけなら async_pre_call_hook でモデルを国内リージョンのものに差し替えれば済みます。要件3だけなら LiteLLM Proxy Server 標準の fallbacks フィールドをそのまま使えます。問題は1・2・3の組み合わせです。「国内処理を要求しつつ、かつクライアント指定の優先順位でフォールバックし、かつフォールバック先も国内に限定する」という連動した制御は、カスタムハンドラーとフォールバック機構をまたいだロジックになります。
async_pre_call_hook はシンプルなモデル切り替えやロギングには向いていますが、複数の制約を連動させるルーティングには向きません。そこで、この部分は専用のロジックとして自前で持つことにしました。
なお、LiteLLM Proxy Server は litellm という Python ライブラリをベースに作られており、litellm は単体でもライブラリとして利用できます。そこで Proxy Server は使わず、litellm をプロバイダー抽象化のライブラリとして使い、ビジネスロジックは自分たちで設計・実装する方が長期的な見通しが良いと判断し、自分たちで実装することを選びました。
litellm をどのようにライブラリとして組み込んだか?

FastAPI ベースの Python サービスとして実装し、DDD のレイヤー構成を採用しています。litellm はインフラストラクチャ層の LLMClient クラス内だけで使い、ドメイン層はどのプロバイダーを使っているかを知りません。Bedrock / Azure OpenAI / Vertex AI の 3 プロバイダーに対応しながらも、上位レイヤーのビジネスロジックはプロバイダーの違いを意識せずに済みます。
class LLMClient:
async def create_completion(
self, deployment: ModelDeployment, request: ChatCompletionRequest
) -> ChatCompletion:
response = await litellm.acompletion(model=model_string, ...)
return ChatCompletion.model_validate(response.model_dump())
litellm が担うのは「Bedrock / Azure / Vertex AI の差異を吸収する HTTP クライアント」という役割だけです。各プロバイダーは認証方式と API の形式が異なりますが、litellm がその差異を吸収します。
フォールバックの判断やモデル選択といったルーティングロジックは、すべて自分たちのドメイン層に置いています。レイヤーを分けることでプロバイダーへの依存がコードベース全体に広がるのを防ぎ、ルーティングロジックの単体テストも書きやすくなりました。
設計で工夫したこと
① フォールバック戦略をクライアントが選べるようにする
フォールバック戦略は auto / specified / none の 3 種類から、リクエストごとに revcomm_options.fallback_strategy で指定します。処理の特性に合わせて可用性・品質・エラー伝播の挙動を選べるようにした設計です。
| 戦略 | 動作 | 使いどころ |
|---|---|---|
auto |
同一モデルの別リージョン・別アカウントに自動でフォールバック | とにかく止めたくない処理 |
specified |
クライアントが指定した順番でフォールバック | モデルの品質差が許容できない処理 |
none |
フォールバックしない(エラーをそのまま返す) | ─ |
auto は可用性を優先するモードです。あるアカウントの Bedrock がレートリミットに引っかかった場合、同一モデルを提供する別アカウント・別リージョンのデプロイメントへ自動で切り替えます。国内処理を要求しているリクエストでは、切り替え先も国内処理に対応したデプロイメントに限定されます。
specified はクライアントが「まず Claude、だめなら OpenAI」という形で優先順位を明示するモードです。出力品質に敏感な処理では、意図しない品質の異なるモデルへの切り替えを避けたいためこちらを使います。
none はエラーをそのまま返します。
なお、フォールバックが発動するのは、レートリミットやタイムアウト・サーバーエラーなどの一時的なエラーに限られています。リクエスト内容の不備(不正なパラメータ、コンテキストウィンドウ超過など)ではフォールバックせず、そのままエラーを返します。意図しないモデルへの切り替えが起きるリスクを抑えるためです。
国内処理を要求するリクエストでは、フォールバック先も国内リージョンのモデルに絞られます。国内制約とフォールバック順序が連動して動く部分です。
② データが国内で処理されることを保証する
顧客との契約でデータの国内処理が義務付けられているケースでは、一つでも適用漏れがあるとビジネスリスクになります。LLM Gateway ではモデル設定に国内・グローバルのフラグを持たせ、ゲートウェイがリクエスト単位で国内処理を強制します。この判断をアプリケーション側に委ねず、ゲートウェイで一元管理する設計です。
モデルごとのリージョン・プロバイダー設定は YAML で管理しています。
gateway_region: ap-northeast-1
deployments:
- model_name: claude-sonnet-4
provider: bedrock
region_scope: regional # 国内リージョン
region: ap-northeast-1
- model_name: gpt-4o
provider: azure
region_scope: global # グローバルリージョン
region: japaneast
region_scope: regional のモデルが「国内処理」として扱われます。リクエストで国内処理を要求した場合、ゲートウェイが該当するモデルを選択します。
この設計の利点は、国内処理の判定ロジックがゲートウェイ内に閉じていることです。新しいモデルを追加するときも、YAML に region_scope を適切に設定するだけで国内処理の制約が適用されます。環境ごとの設定差分は別ファイルで管理しているため、呼び出し元のサービスは環境を意識せず同じリクエストを送るだけで済みます。
③ モデルを切り替えても呼び出し元のコードを変えずに済むようにする
ゲートウェイは OpenAI Chat Completions API 互換のインターフェースを提供しています。既存の OpenAI クライアントはエンドポイントの向き先を変えるだけで移行でき、モデルを切り替えるときも model フィールドの値を変えるだけです。RevComm 固有の制御は revcomm_options フィールドに集約しています。OpenAI クライアントからは extra_body={"revcomm_options": {...}} として渡すため、標準 API との後方互換性を維持しています。
{ "model": "claude-sonnet-4", "messages": [...], "revcomm_options": { "require_domestic_processing": true, "prefer_prompt_caching": true, "fallback_strategy": "specified", "fallback_models": ["gpt-4o", "gemini-2.0-flash"], "labels": { "tenant_id": "tenant-123", "service": "transcription", "team": "asr" } } }
オプションを省略すれば通常の OpenAI API リクエストとほぼ同じ形式です。
レスポンスには revcomm_metadata を付与し、実際に使ったプロバイダーやフォールバックの発生有無を呼び出し元に返します。
{ "revcomm_metadata": { "actual_provider": "bedrock", "actual_model": "claude-sonnet-4", "fallback_used": true, "fallback_reason": "RateLimitError: ...", "gateway_latency_ms": 42, "domestic_processing": true } }
フォールバックが透過的に起きると、呼び出し元からは「なぜこのモデルが使われたのか」が見えなくなります。メタデータをレスポンスに含めることで、デバッグ時にフォールバックの発生をすぐに確認でき、コスト分析にも活用できます。フォールバックが頻発している場合は、特定のプロバイダーや時間帯に問題がある兆候として検知できます。
④ 誰がどのモデルをどれだけ使っているかを把握できるようにする
LLM Gateway のもうひとつの柱がコストの可視化です。LiteLLM Proxy Server には Cost Tracking 機能が標準で備わっていますが、litellm をライブラリとして使う場合はリクエストごとのコスト計算値は得られるものの、保存・集計・可視化は自前で用意する必要があります。リクエストごとのトークン数・プロバイダー・モデル・レイテンシを PostgreSQL に記録し、Redash でテナント別・機能別に可視化しています。ロギングはレスポンスを返した後にバックグラウンドで行うため、ゲートウェイのレイテンシには影響しません。
labels は JSONB カラムで、リクエスト時にオプションとして渡したキーバリューをそのまま保存します。JSONB にしているのは、ラベルのキーをあらかじめ固定せず、各サービスが必要なディメンションを自由に付与できるようにするためです。
SELECT labels->>'tenant_id' AS tenant_id, actual_model, SUM(total_tokens) AS total_tokens FROM llm_usage_logs WHERE created_at >= now() - INTERVAL '30 days' GROUP BY labels->>'tenant_id', actual_model ORDER BY total_tokens DESC;
テナント別・サービス別・チーム別など、集計軸をスキーマ変更なしに追加できます。「新しいチームが使い始めたのでチーム別集計を出したい」という要望があれば、そのチームが team ラベルをリクエストに含めるだけで対応できます。テーブル定義の変更もマイグレーションも不要です。
各サービスは推論リクエスト時に tenant_id や service をラベルとして渡すだけで、コスト集計の基盤が整います。プロバイダーの請求書を見ていてもわからなかった「どのテナントの、どの機能が、どのモデルをどれくらい使っているか」が一本のクエリで出せるようになりました。

振り返って
LiteLLM Proxy Server のカスタムハンドラーでも、技術的には実現できたはずです。ルーティングロジックとラベル集計を自社のドメインロジックとして持ったことで、要件の変化にコードで素直に対応できています。
フォールバックロジックとモデル選択ロジックを外部依存のない純粋な Python クラスで実装したため、単体テストも書きやすくなりました。「国内処理を要求した場合に region_scope: global のモデルは候補から除外される」「フォールバック戦略が none のときはエラーがそのまま伝播する」といったロジックを、LiteLLM Proxy Server を起動せずにテストできます。
一方で、コスト管理 UI や API キーのセルフサービス発行など、LiteLLM Proxy Server が標準で持つ運用機能は自前で用意する必要がありました。現時点では、コスト集計は Redash のダッシュボードで対応していますが、API キーのセルフサービス発行など長期的には整備が必要な領域もあります。
カスタム性が単純なうちは LiteLLM Proxy Server で十分です。
まとめ
- 各サービスに分散していた LLM 呼び出しを一本化するため、マルチプロバイダー統一とコスト可視化を目的に社内 LLM Gateway を開発しました
- LiteLLM Proxy Server を検討しましたが、国内処理制約 × フォールバック戦略の連動が設計想定外になるため、自分たちで実装することにしました
- litellm はプロバイダー抽象化のライブラリとして使い、ルーティングロジックは自分たちのドメインとして設計しました
- リクエストに付与する labels(テナント ID・サービス名など)を PostgreSQL の JSONB カラムに記録することで、スキーマを変えずに集計軸を追加できるようにしました