はじめに

GPT-4o、Claude 3.5 Sonnet、Gemini 2.0 Flash… 2026年現在、優秀なLLMが乱立しています。しかし「とりあえず最強モデルを使えばいい」という考えは、プロダクション運用では通用しません。

LLMルーティング(LLM Routing)とは、クエリの内容・難易度・コスト制約に応じて、最適なモデルへ動的に振り分ける技術です。うまく設計すれば、品質をほぼ維持したままコストを50〜70%削減できることが実測で確認されています。

この記事では、ルーティングの基本原理から実装パターン、本番導入のコツまでコード付きで解説します。

この記事で学べること

  • LLMルーティングが必要な理由と効果の定量的な見積もり方
  • 5つのルーティング戦略と使い分け
  • RouteLLM / LiteLLM を使った実装
  • カスケードパターンの設計と落とし穴
  • 本番環境でのモニタリングと継続的改善

なぜLLMルーティングが必要か

コストの非対称性

現在(2026年Q1)の主要モデルのコスト(input/output per 1M tokens)を比べると、モデル間で大きな差があります:

モデル Input Output 強み
GPT-4o $2.50 $10.00 バランス・マルチモーダル
Claude 3.5 Haiku $0.80 $4.00 高速・低コスト
Gemini 2.0 Flash $0.10 $0.40 超低コスト・長コンテキスト
GPT-4o mini $0.15 $0.60 軽量タスク向け
Llama 3.3 70B (self-host) ~$0.05 ~$0.05 データ主権・固定コスト

※価格は参考値。最新情報は各社の公式ページを確認してください。

簡単な質問を全てGPT-4oで処理するのは、宅配便を全てヘリコプターで送るようなものです。

難易度分布の現実

実際のプロダクションログを分析すると、多くのユースケースで次のような分布が見られます:

クエリ難易度の分布(一般的なチャットボットの場合)
──────────────────────────────────
簡単(事実確認・定型応答)  ████████████████  ~45%
中程度(推論・要約・変換)  ████████████      ~35%
難しい(複雑推論・創造)    ████████          ~20%
──────────────────────────────────

つまり、全クエリの45%は安価なモデルで十分なのです。


5つのルーティング戦略

戦略1: ルールベース(キーワード・メタデータ)

最もシンプルで予測可能な方法。クエリの特徴から直接ルールで振り分けます。

from dataclasses import dataclass
from enum import Enum

class ModelTier(Enum):
    FAST = "gemini-2.0-flash"      # 最安・高速
    BALANCED = "claude-3-5-haiku"  # バランス型
    POWERFUL = "gpt-4o"            # 最高品質

@dataclass
class RouterConfig:
    fast_keywords: list[str]
    powerful_keywords: list[str]
    max_tokens_for_fast: int = 500

class RuleBasedRouter:
    def __init__(self, config: RouterConfig):
        self.config = config

    def route(self, query: str, context: dict = None) -> ModelTier:
        query_lower = query.lower()
        token_count = context.get("token_count", 0) if context else 0

        # 明らかに簡単なタスクは最速モデルへ
        if any(kw in query_lower for kw in self.config.fast_keywords):
            if token_count < self.config.max_tokens_for_fast:
                return ModelTier.FAST

        # 複雑なタスクは最強モデルへ
        if any(kw in query_lower for kw in self.config.powerful_keywords):
            return ModelTier.POWERFUL

        # デフォルトはバランス型
        return ModelTier.BALANCED

# 使用例
config = RouterConfig(
    fast_keywords=["こんにちは", "ありがとう", "何時", "天気", "翻訳して"],
    powerful_keywords=["コードを書いて", "設計して", "分析して", "比較して", "論文"]
)

router = RuleBasedRouter(config)
model = router.route("こんにちは、今日の天気は?")
print(model)  # ModelTier.FAST

メリット: レイテンシゼロ、完全予測可能、デバッグ容易 デメリット: メンテナンスコスト、カバレッジの穴


戦略2: 分類器ルーティング(Classifier-based Routing)

小さな分類モデルを使ってクエリの難易度・タイプを判定します。RouteLLM はこのアプローチの代表実装です。

# pip install routellm
from routellm.controller import Controller

# RouteLLMの設定
client = Controller(
    routers=["mf"],  # matrix-factorization based router
    strong_model="gpt-4o",
    weak_model="gpt-4o-mini",
)

# 通常のOpenAI APIと同じインターフェース
response = client.chat.completions.create(
    # threshold: 0に近いほど強力モデルを使う頻度が増える
    # 0.11367 でGPT-4oへのルーティングが40%程度になる(公式ベンチマーク)
    model="router-mf-0.11367",
    messages=[{"role": "user", "content": "Pythonでクイックソートを実装して"}]
)

RouteLLMのベンチマーク結果(公式発表):

  • GPT-4oへのルーティングを40%に抑えつつ、GPT-4o単独と同等の品質を維持
  • コスト削減率: 約50〜60%

自前の分類器を作る場合

from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.feature_extraction.text import TfidfVectorizer
import numpy as np

class DifficultyClassifier:
    """クエリの難易度を0.0〜1.0でスコアリング"""

    def __init__(self):
        self.pipeline = Pipeline([
            ("tfidf", TfidfVectorizer(max_features=5000, ngram_range=(1, 2))),
            ("clf", LogisticRegression(max_iter=1000))
        ])
        self._is_trained = False

    def train(self, queries: list[str], difficulty_labels: list[float]):
        """
        queries: クエリ文字列のリスト
        difficulty_labels: 各クエリの難易度ラベル (0=簡単, 1=難しい)
        """
        # 二値分類に変換
        binary_labels = [1 if d > 0.5 else 0 for d in difficulty_labels]
        self.pipeline.fit(queries, binary_labels)
        self._is_trained = True

    def score(self, query: str) -> float:
        """難易度スコアを返す(0=簡単, 1=難しい)"""
        if not self._is_trained:
            raise RuntimeError("モデルが未学習です")
        proba = self.pipeline.predict_proba([query])[0]
        return float(proba[1])  # 難しいクラスの確率

    def route(self, query: str, threshold: float = 0.5) -> ModelTier:
        score = self.score(query)
        if score < 0.3:
            return ModelTier.FAST
        elif score < threshold:
            return ModelTier.BALANCED
        else:
            return ModelTier.POWERFUL

実践Tips: 分類器の学習データは、本番ログ + 人間のラベリング(または強力モデルによる自動ラベリング)で収集します。最初は小さなモデルで始めて、精度が不十分なら徐々に複雑化します。


戦略3: LLMによる自己ルーティング

ルーター自体に軽量LLMを使い、クエリの分析結果に基づいて振り分けます。

import json
from openai import OpenAI

client = OpenAI()

ROUTER_SYSTEM_PROMPT = """あなたはクエリの難易度を評価するアシスタントです。
ユーザーのクエリを分析し、以下のJSON形式で回答してください:

{
    "difficulty": "easy" | "medium" | "hard",
    "reasoning": "判断理由(1文)",
    "requires_code": true | false,
    "requires_math": true | false
}

判断基準:
- easy: 事実確認・挨拶・単純な変換・翻訳(1ステップで完結)
- medium: 要約・説明・比較・簡単な推論(複数ステップだが直感的)
- hard: 複雑なコーディング・数学的証明・多段推論・創造的タスク
"""

class LLMRouter:
    def __init__(self, router_model: str = "gpt-4o-mini"):
        self.router_model = router_model
        self._cache: dict[str, dict] = {}

    def analyze(self, query: str) -> dict:
        # キャッシュで同一クエリの再分析を防ぐ
        cache_key = query[:100]  # 先頭100文字をキーに
        if cache_key in self._cache:
            return self._cache[cache_key]

        response = client.chat.completions.create(
            model=self.router_model,
            messages=[
                {"role": "system", "content": ROUTER_SYSTEM_PROMPT},
                {"role": "user", "content": f"クエリ: {query}"}
            ],
            response_format={"type": "json_object"},
            max_tokens=150,
            temperature=0,
        )

        result = json.loads(response.choices[0].message.content)
        self._cache[cache_key] = result
        return result

    def route(self, query: str) -> ModelTier:
        analysis = self.analyze(query)
        difficulty = analysis.get("difficulty", "medium")

        if difficulty == "easy":
            return ModelTier.FAST
        elif difficulty == "hard":
            return ModelTier.POWERFUL
        else:
            return ModelTier.BALANCED

注意: ルーター自体にLLMを使うと、ルーティングのレイテンシが増加します。高速な小モデル(GPT-4o mini等)を使い、結果をキャッシュすることが重要です。


戦略4: カスケードルーティング(品質ゲート型)

まず安価なモデルで試し、品質が不十分であれば上位モデルにエスカレーションします。

from typing import Optional
import asyncio

class CascadeRouter:
    """
    安価なモデルから順に試し、品質ゲートを通過したら返す。
    通過しなければ上位モデルにエスカレーション。
    """

    def __init__(self, model_cascade: list[str], quality_evaluator):
        self.cascade = model_cascade
        self.evaluator = quality_evaluator

    async def generate(
        self,
        query: str,
        min_quality_score: float = 0.8,
        timeout_ms: int = 3000
    ) -> tuple[str, str]:
        """
        Returns: (response, model_used)
        """
        for i, model in enumerate(self.cascade):
            is_last = i == len(self.cascade) - 1

            try:
                response = await self._call_model(model, query, timeout_ms)

                # 最後のモデルは評価不要(常に返す)
                if is_last:
                    return response, model

                # 品質評価
                score = await self.evaluator.score(query, response)
                if score >= min_quality_score:
                    return response, model

                # 品質不足 → 次のモデルへ
                print(f"[Cascade] {model} の品質スコア {score:.2f} < {min_quality_score}、エスカレーション")

            except TimeoutError:
                print(f"[Cascade] {model} タイムアウト、エスカレーション")
                continue

        # ここには到達しないはずだが安全のため
        raise RuntimeError("全モデルで失敗")

    async def _call_model(self, model: str, query: str, timeout_ms: int) -> str:
        # 実際の実装ではLiteLLMなどを使用
        raise NotImplementedError


class LLMQualityEvaluator:
    """軽量モデルで応答品質を評価"""

    async def score(self, query: str, response: str) -> float:
        eval_prompt = f"""以下の質問と回答のペアを評価してください。
評価基準:正確性・完全性・明確さ
0.0(最低)から1.0(最高)のスコアのみ返してください。数字だけ。

質問: {query}
回答: {response}"""

        # 評価自体は安価なモデルで行う
        result = await self._quick_call("gpt-4o-mini", eval_prompt)
        try:
            return float(result.strip())
        except ValueError:
            return 0.5  # パース失敗時はデフォルト

    async def _quick_call(self, model: str, prompt: str) -> str:
        raise NotImplementedError

カスケードのフローチャート:

flowchart TD
    A[クエリ受信] --> B[Gemini 2.0 Flash で生成]
    B --> C{品質スコア >= 0.8?}
    C -->|Yes| D[レスポンス返却\n コスト: 低]
    C -->|No| E[Claude 3.5 Haiku で再生成]
    E --> F{品質スコア >= 0.8?}
    F -->|Yes| G[レスポンス返却\n コスト: 中]
    F -->|No| H[GPT-4o で最終生成]
    H --> I[レスポンス返却\n コスト: 高]

カスケードの注意点:

  • 品質評価自体にコスト・レイテンシが発生する
  • レイテンシ要件が厳しいユースケースでは向かない場合がある
  • 品質評価の精度が低いと逆効果になる

戦略5: コスト予算ルーティング

ユーザーのプランや利用状況に応じて動的にモデルを切り替えます。

from datetime import datetime, timedelta
import redis

class BudgetRouter:
    """
    ユーザーのコスト予算に基づいてルーティング。
    - Freeプラン: 常に最安モデル
    - Proプラン: 予算内は高品質モデル、超過したら降格
    """

    PLAN_CONFIG = {
        "free": {
            "daily_budget_usd": 0.10,
            "default_model": ModelTier.FAST,
            "max_model": ModelTier.FAST,
        },
        "pro": {
            "daily_budget_usd": 5.00,
            "default_model": ModelTier.BALANCED,
            "max_model": ModelTier.POWERFUL,
        },
        "enterprise": {
            "daily_budget_usd": 100.0,
            "default_model": ModelTier.POWERFUL,
            "max_model": ModelTier.POWERFUL,
        },
    }

    # モデルのコスト見積もり(1クエリあたりの平均USD)
    MODEL_COST_EST = {
        ModelTier.FAST: 0.0001,
        ModelTier.BALANCED: 0.001,
        ModelTier.POWERFUL: 0.01,
    }

    def __init__(self, redis_client: redis.Redis):
        self.redis = redis_client

    def route(self, user_id: str, plan: str, desired_tier: ModelTier) -> ModelTier:
        config = self.PLAN_CONFIG.get(plan, self.PLAN_CONFIG["free"])
        max_tier = config["max_model"]

        # 希望モデルが上限を超える場合は降格
        if desired_tier.value > max_tier.value:
            desired_tier = max_tier

        # 今日の消費コストを取得
        today_key = f"cost:{user_id}:{datetime.now().strftime('%Y-%m-%d')}"
        spent = float(self.redis.get(today_key) or 0)

        # 予算の80%を超えたら降格
        budget = config["daily_budget_usd"]
        if spent > budget * 0.8 and desired_tier == ModelTier.POWERFUL:
            return ModelTier.BALANCED
        elif spent > budget * 0.95:
            return ModelTier.FAST

        return desired_tier

    def record_cost(self, user_id: str, cost_usd: float):
        today_key = f"cost:{user_id}:{datetime.now().strftime('%Y-%m-%d')}"
        self.redis.incrbyfloat(today_key, cost_usd)
        self.redis.expire(today_key, 86400 * 2)  # 2日でTTL

LiteLLMで統一インターフェース実装

各モデルプロバイダーのAPIは微妙に異なります。LiteLLM を使えば、同一インターフェースで100以上のモデルにアクセスできます。

# pip install litellm
from litellm import acompletion
import asyncio

class UnifiedLLMClient:
    """LiteLLMを使ったモデル統一クライアント"""

    MODEL_MAPPING = {
        ModelTier.FAST: "gemini/gemini-2.0-flash",
        ModelTier.BALANCED: "anthropic/claude-3-5-haiku-20241022",
        ModelTier.POWERFUL: "openai/gpt-4o",
    }

    # フォールバック設定
    FALLBACKS = {
        "openai/gpt-4o": ["anthropic/claude-3-5-sonnet-20241022"],
        "gemini/gemini-2.0-flash": ["openai/gpt-4o-mini"],
    }

    async def complete(
        self,
        tier: ModelTier,
        messages: list[dict],
        **kwargs
    ) -> str:
        model = self.MODEL_MAPPING[tier]

        try:
            response = await acompletion(
                model=model,
                messages=messages,
                fallbacks=self.FALLBACKS.get(model, []),
                # LiteLLMの組み込みリトライ
                num_retries=2,
                timeout=30,
                **kwargs
            )
            return response.choices[0].message.content

        except Exception as e:
            print(f"[LLM Error] {model}: {e}")
            raise


# フルスタックの使用例
async def smart_complete(
    query: str,
    user_id: str,
    plan: str = "pro"
) -> str:
    router = LLMRouter()          # LLMによる難易度判定
    budget_router = BudgetRouter(redis_client)  # 予算管理
    client = UnifiedLLMClient()

    # Step 1: 難易度に基づく初期ルーティング
    desired_tier = router.route(query)

    # Step 2: 予算制約の適用
    final_tier = budget_router.route(user_id, plan, desired_tier)

    # Step 3: 実行
    response = await client.complete(
        tier=final_tier,
        messages=[{"role": "user", "content": query}]
    )

    # Step 4: コスト記録
    estimated_cost = BudgetRouter.MODEL_COST_EST[final_tier]
    budget_router.record_cost(user_id, estimated_cost)

    return response

ルーティングのモニタリング

ルーティングシステムは継続的な監視と改善が不可欠です。

from dataclasses import dataclass, field
from collections import defaultdict
import time

@dataclass
class RoutingEvent:
    timestamp: float
    query_hash: str  # プライバシーのためハッシュ化
    routed_to: str
    latency_ms: float
    cost_usd: float
    quality_score: float | None = None
    user_feedback: int | None = None  # -1, 0, 1

class RoutingMetrics:
    """ルーティングの効果を追跡するメトリクス"""

    def __init__(self):
        self.events: list[RoutingEvent] = []
        self.model_counts: dict[str, int] = defaultdict(int)

    def record(self, event: RoutingEvent):
        self.events.append(event)
        self.model_counts[event.routed_to] += 1

    def summary(self, last_n: int = 1000) -> dict:
        recent = self.events[-last_n:]
        if not recent:
            return {}

        total_cost = sum(e.cost_usd for e in recent)
        avg_latency = sum(e.latency_ms for e in recent) / len(recent)

        # ベースライン(全クエリをGPT-4oで処理した場合の推定コスト)
        baseline_cost = len(recent) * BudgetRouter.MODEL_COST_EST[ModelTier.POWERFUL]
        cost_reduction = (baseline_cost - total_cost) / baseline_cost * 100

        return {
            "total_queries": len(recent),
            "model_distribution": dict(self.model_counts),
            "total_cost_usd": round(total_cost, 4),
            "estimated_cost_reduction_pct": round(cost_reduction, 1),
            "avg_latency_ms": round(avg_latency, 1),
            "avg_quality_score": (
                sum(e.quality_score for e in recent if e.quality_score is not None)
                / sum(1 for e in recent if e.quality_score is not None)
                if any(e.quality_score is not None for e in recent)
                else None
            ),
        }

Langfuseでルーティングを可視化

from langfuse import Langfuse

langfuse = Langfuse()

async def traced_complete(query: str, user_id: str) -> str:
    trace = langfuse.trace(name="llm-routing", user_id=user_id)

    # ルーティング判定をスパンとして記録
    with trace.span(name="routing-decision") as span:
        tier = router.route(query)
        span.update(output={"tier": tier.value, "model": tier.name})

    # LLM呼び出しを記録
    with trace.generation(
        name="llm-call",
        model=UnifiedLLMClient.MODEL_MAPPING[tier],
        input=query
    ) as gen:
        response = await client.complete(tier, [{"role": "user", "content": query}])
        gen.update(output=response)

    return response

よくある落とし穴と対策

落とし穴1: ルーティングの遅延が目立つ

問題: ルーター自体の処理時間がユーザー体験を損なう

対策:

import asyncio

async def parallel_route_and_prefetch(query: str):
    """ルーティング判定と並行して、デフォルトモデルで先行処理を開始"""

    # ルーティング判定と安価モデルの実行を並行スタート
    routing_task = asyncio.create_task(router.analyze_async(query))
    fast_task = asyncio.create_task(
        client.complete(ModelTier.FAST, [{"role": "user", "content": query}])
    )

    routing_result = await routing_task

    if routing_result["difficulty"] == "easy":
        # 安価モデルの結果をそのまま使用
        return await fast_task
    else:
        # 安価モデルをキャンセルして高品質モデルを使用
        fast_task.cancel()
        return await client.complete(
            ModelTier.POWERFUL,
            [{"role": "user", "content": query}]
        )

落とし穴2: ルーティングの一貫性がない

問題: 同じクエリが毎回異なるモデルにルーティングされる

対策: ルーティング結果のキャッシュ(同一クエリは同一モデルへ)

import hashlib
from functools import lru_cache

class CachedRouter:
    def __init__(self, base_router, cache_ttl: int = 3600):
        self.base_router = base_router
        self._cache: dict[str, tuple[ModelTier, float]] = {}
        self.cache_ttl = cache_ttl

    def route(self, query: str) -> ModelTier:
        # クエリのハッシュをキーに(プライバシー保護も兼ねる)
        key = hashlib.md5(query.encode()).hexdigest()
        now = time.time()

        if key in self._cache:
            tier, cached_at = self._cache[key]
            if now - cached_at < self.cache_ttl:
                return tier

        tier = self.base_router.route(query)
        self._cache[key] = (tier, now)
        return tier

落とし穴3: 品質劣化の検知が遅れる

問題: 安価モデルへのルーティングが増えすぎて品質が落ちても気づかない

対策: 定期的なゴールデンセットテスト

GOLDEN_SET = [
    {
        "query": "Pythonでデッドロックが発生する状況を説明してください",
        "expected_keywords": ["スレッド", "ロック", "待機", "デッドロック"],
        "min_length": 200,
    },
    # ... 本番で重要なクエリを20〜50件用意
]

async def quality_regression_check(router, client):
    """ゴールデンセットで品質回帰をチェック(毎日実行推奨)"""
    failures = []

    for item in GOLDEN_SET:
        tier = router.route(item["query"])
        response = await client.complete(
            tier, [{"role": "user", "content": item["query"]}]
        )

        # 簡易品質チェック
        checks = [
            len(response) >= item.get("min_length", 0),
            all(kw in response for kw in item.get("expected_keywords", [])),
        ]

        if not all(checks):
            failures.append({
                "query": item["query"],
                "model": tier.value,
                "response_length": len(response),
            })

    if failures:
        # アラート送信(Slackなど)
        print(f"[Quality Alert] {len(failures)}/{len(GOLDEN_SET)} 件で品質劣化を検出")
        return False
    return True

実際のコスト削減効果の計測

以下は実際のプロダクションデプロイで観測される典型的な数値です:

ルーティング導入前後の比較(月間100万クエリの例)
────────────────────────────────────────────────────
                Before              After
────────────────────────────────────────────────────
GPT-4o         100% (100万)        20% (20万)
Claude Haiku      0%               35% (35万)
Gemini Flash      0%               45% (45万)
────────────────────────────────────────────────────
月間コスト       $10,000            $2,800
削減率              -               72%
平均品質スコア     0.91              0.89(-2.2%)
平均レイテンシ    1,200ms            800ms(-33%)
────────────────────────────────────────────────────

品質を2%程度しか下げずに、コストを72%削減できています。


まとめ

LLMルーティングは「コスト削減のハック」ではなく、プロダクションLLMアプリの設計において必須のアーキテクチャパターンです。

導入ロードマップ

フェーズ 内容 期待コスト削減
Phase 1 ルールベースルーティング(2日) 20〜30%
Phase 2 RouteLLM / 分類器導入(1週間) 40〜55%
Phase 3 カスケード + 品質ゲート(2週間) 55〜70%
Phase 4 予算管理 + A/Bテスト(継続) 65〜75%

判断フローチャート

flowchart TD
    A[LLMルーティングを検討] --> B{月間コストは$500以上?}
    B -->|No| C[まだ不要\n単一モデルで問題なし]
    B -->|Yes| D{レイテンシ要件は?}
    D -->|厳格 <500ms| E[ルールベース\nまたは分類器のみ]
    D -->|普通 <2秒| F[分類器 + カスケード]
    D -->|緩い >2秒| G[フルカスケード\n+ 品質評価]

参考リソース


この記事のサンプルコードはPython 3.11+で動作確認しています。各モデルのAPIキーは環境変数で管理し、ソースコードに直接記載しないよう注意してください。