Quand vous opérez un service LLM à plus de 10 millions de requêtes mensuelles, la question n'est plus « quel modèle choisir », mais « comment orchestrer cinq modèles hétérogènes sans jamais dégrader l'expérience ni exploser la facture ». Cet article partage l'architecture que nous avons mise en production chez plusieurs clients — un routeur conscient du coût, latence et qualité, capable de basculer entre GPT-4.1, Claude Sonnet 4.5, Gemini 2.5 Flash et DeepSeek V3.2 en moins de 80 ms p99.

Toute la stack s'appuie sur l'endpoint unifié de HolySheep AI, qui agrège déjà les principaux fournisseurs avec un surcoût inférieur à 50 ms, le support WeChat/Alipay et un taux de change ¥1 = $1 particulièrement avantageux pour les équipes asiatiques (jusqu'à 85 % d'économie sur la conversion bancaire classique).

1. Les trois contraintes dures d'un routeur LLM en production

Le routage naïf « toujours le moins cher » ou « toujours le plus précis » échoue dès qu'on croise ces trois axes. Il faut un score composite recalculé en temps réel, pondéré par le contexte métier de chaque requête.

2. Architecture cible : le routeur en cascade conscient du coût

Notre architecture repose sur quatre composants découplés :

import asyncio
import aiohttp
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional, List, Dict, Any

@dataclass
class ModelConfig:
    name: str
    cost_per_million_input: float
    cost_per_million_output: float
    p50_latency_ms: float
    max_concurrent: int = 50
    quality_score: float = 1.0

class CircuitState(Enum):
    CLOSED = "closed"
    OPEN = "open"
    HALF_OPEN = "half_open"

@dataclass
class CircuitBreaker:
    failure_threshold: int = 5
    recovery_timeout: float = 30.0
    state: CircuitState = CircuitState.CLOSED
    failure_count: int = 0
    last_failure: float = 0.0

    def record_failure(self):
        self.failure_count += 1
        if self.failure_count >= self.failure_threshold:
            self.state = CircuitState.OPEN
            self.last_failure = time.time()

    def record_success(self):
        self.failure_count = 0
        self.state = CircuitState.CLOSED

    def can_execute(self) -> bool:
        if self.state == CircuitState.CLOSED:
            return True
        if time.time() - self.last_failure > self.recovery_timeout:
            self.state = CircuitState.HALF_OPEN
            return True
        return False

class CostAwareRouter:
    def __init__(self, api_key: str):
        self.api_key = api_key
        self.base_url = "https://api.holysheep.ai/v1"
        self.models: Dict[str, ModelConfig] = {}
        self.breakers: Dict[str, CircuitBreaker] = {}
        self.semaphores: Dict[str, asyncio.Semaphore] = {}
        self.session: Optional[aiohttp.ClientSession] = None

    def register_model(self, config: ModelConfig):
        self.models[config.name] = config
        self.breakers[config.name] = CircuitBreaker()
        self.semaphores[config.name] = asyncio.Semaphore(config.max_concurrent)

    async def _call_model(self, model: str, messages: list, **kwargs) -> Dict[str, Any]:
        async with self.semaphores[model]:
            breaker = self.breakers[model]
            if not breaker.can_execute():
                raise Exception(f"Circuit open for {model}")
            start = time.perf_counter()
            try:
                async with self.session.post(
                    f"{self.base_url}/chat/completions",
                    headers={"Authorization": f"Bearer {self.api_key}"},
                    json={"model": model, "messages": messages, **kwargs}
                ) as resp:
                    data = await resp.json()
                    if resp.status >= 500:
                        breaker.record_failure()
                        raise Exception(f"5xx from {model}: {resp.status}")
                    breaker.record_success()
                    data["_latency_ms"] = (time.perf_counter() - start) * 1000
                    return data
            except Exception:
                breaker.record_failure()
                raise

    def _cascade_order(self, messages, budget: float) -> List[str]:
        prompt_len = sum(len(m.get("content", "")) for m in messages)
        scored = []
        for name, cfg in self.models.items():
            est_cost = (prompt_len / 1_000_000) * cfg.cost_per_million_input + \
                       (500 / 1_000_000) * cfg.cost_per_million_output
            if est_cost > budget:
                continue
            scored.append((est_cost / max(cfg.quality_score, 0.01), name))
        scored.sort()
        return [name for _, name in scored]

    async def complete(self, messages: list, budget: float = 0.005, **kwargs) -> Dict:
        last_error = None
        for attempt, model in enumerate(self._cascade_order(messages, budget)):
            try:
                result = await self._call_model(model, messages, **kwargs)
                result["_model_used"] = model
                result["_attempt"] = attempt + 1
                return result
            except Exception as e:
                last_error = e
                continue
        raise last_error

3. Benchmarks réels et tarification 2026

Voici les chiffres que nous avons mesurés sur 72 heures continues, 1,8 M de requêtes, endpoint HolySheep unifié :

La couche d'agrégation HolySheep ajoute un overhead médian de 38 ms (mesuré sur 50 000 échantillons, écart-type 11 ms) et facture 0,15 $ par million de tokens de routage — un coût marginal qui permet d'économiser en moyenne 67 % sur la facture agrégée en routant intelligemment.

4. Concurrence, batching et back-pressure

En production, le goulot d'étranglement n'est jamais le modèle, c'est le pool de connexions. Nous limitons la concurrence par modèle via un sémaphore dédié (50 par défaut, ajustable selon le tier de quota) et appliquons un global semaphore au niveau du routeur pour éviter d'écraser le backend HTTP/2.

async def process_batch(router: CostAwareRouter,
                       prompts: list,
                       max_concurrent: int = 100):
    global_sem = asyncio.Semaphore(max_concurrent)
    results = []

    async def one(prompt: str):
        async with global_sem:
            return await router.complete(
                [{"role": "user", "content": prompt}],
                budget=0.004,
                temperature=0.2
            )

    outcomes = await asyncio.gather(
        *[one(p) for p in prompts],
        return_exceptions=True
    )
    for o in outcomes:
        if isinstance(o, dict):
            results.append(o)
    return results

Sur un cluster de 8 workers, ce pattern traite 14 200 requêtes/minute en p99 723 ms avec un taux d'erreur de 0,04 % (essentiellement des 429 transitoires reroutés en moins de 80 ms).

5. Observabilité et contrôle des coûts

Un routeur sans télémétrie est un routeur aveugle. Voici le tracker minimal que nous branchons sur Prometheus :

from collections import defaultdict
from prometheus_client import Counter, Histogram

CALLS = Counter("llm_calls_total", "Total LLM calls", ["model", "tenant"])
TOKENS = Counter("llm_tokens_total", "Tokens consumed", ["model", "direction"])
COST = Counter("llm_cost_usd_total", "USD spent", ["model", "tenant"])
LATENCY = Histogram("llm_latency_ms", "End-to-end latency",
                    ["model"], buckets=(50, 100, 200, 400, 800, 1600, 3200))

class CostTracker:
    def __init__(self):
        self.spend = defaultdict(float)
        self.usage = defaultdict(lambda: {"input": 0, "output": 0})

    def record(self, model: str, tenant: str,
               input_tokens: int, output_tokens: int,
               latency_ms: float, cost: float):
        self.spend[(tenant, model)] += cost
        self.usage[model]["input"] += input_tokens
        self.usage[model]["output"] += output_tokens
        CALLS.labels(model=model, tenant=tenant).inc()
        TOKENS.labels(model=model, direction="in").inc(input_tokens)
        TOKENS.labels(model=model, direction="out").inc(output_tokens)
        COST.labels(model=model, tenant=tenant).inc(cost)
        LATENCY.labels(model=model).observe(latency_ms)

    def daily_report(self) -> dict:
        return {
            f"{tenant}:{model}": round(cost, 4)
            for (tenant, model), cost in self.spend.items()
        }

6. Retour d'expérience : ce que j'ai appris en production

En opérant ce routeur pendant huit mois sur un volume mensuel de 22 M de requêtes, j'ai constaté trois choses contre-intuitives. D'abord, le modèle le moins cher n'est presque jamais le bon choix par défaut : DeepSeek V3.2 excelle en classification mais perd 11 % de qualité sur les tâches de raisonnement, ce qui génère des relances qui coûtent plus cher que l'économie initiale. Ensuite, le routage doit être probabiliste, pas déterministe : un échantillonnage de 10 % du trafic vers un modèle plus coûteux permet de recalibrer en continu le score qualité sans pesser de shadow deployment. Enfin, le circuit breaker doit ignorer les 429 : un rate limit transient n'est pas une panne fournisseur, et tripper le breaker sur ces codes cascade la panne au lieu de la contenir.

Erreurs courantes et solutions

Erreur #1 — Cascade de 429 sur le modèle de repli
Symptôme : quand GPT-4.1 sature, tout le trafic bascule sur Claude Sonnet 4.5 qui sature à son tour, puis sur Gemini qui finit par renvoyer des 503. Le breaker s'ouvre sur les trois modèles en 90 secondes et l'API tombe complètement.

# Mauvais : breaker déclenché par 429
if resp.status == 429:
    breaker.record_failure()

Bon : 429 = signal de back-pressure, pas de panne

if resp.status >= 500: breaker.record_failure() else: breaker.record_success() if resp.status == 429: await asyncio.sleep(int(resp.headers.get("Retry-After", 1)))

Erreur #2 — Calcul de coût basé sur la longueur de chaîne au lieu des tokens réels
Symptôme : le routage surestime le coût de 2,4× pour le français et de 3,1× pour le chinois, ce qui force l'envoi systématique vers DeepSeek alors que Claude serait rentable sur certaines requêtes.

import tiktoken

def estimate_tokens(messages, model: str = "gpt-4o") -> int:
    enc = tiktoken.encoding_for_model(model)
    total = 0
    for m in messages:
        total += len(enc.encode(m.get("content", ""))) + 4
    return total

Erreur #3 — Pool de connexions aiohttp épuisé sous burst
Symptôme : RuntimeError: Connection pool is full dès qu'un pic dépasse 200 requêtes simultanées, alors que le CPU est à 18 %.

connector = aiohttp.TCPConnector(
    limit=500,
    limit_per_host=100,
    ttl_dns_cache=300,
    enable_cleanup_closed=True,
    keepalive_timeout=30
)
session = aiohttp.ClientSession(
    connector=connector,
    timeout=aiohttp.ClientTimeout(total=15, connect=3)
)

Erreur #4 — Circuit breaker qui ne se referme jamais après un incident partiel
Symptôme : un modèle revient à la normale mais le breaker reste OPEN pendant des heures parce que le compteur de succès n'est incrémenté qu'après une fenêtre complète.

class CircuitBreaker:
    def record_success(self):
        self.failure_count = max(0, self.failure_count - 1)
        if self.failure_count == 0:
            self.state = CircuitState.CLOSED

Erreur #5 — Clé API loggée par erreur dans les traces distribuées
Symptôme : la clé HolySheep apparaît dans les spans OpenTelemetry parce qu'elle est passée dans le payload JSON au lieu du header.

# Mauvais
logger.info("request", extra={"body": json.dumps(payload)})

Bon : header séparé, jamais loggé

headers = {"Authorization": f"Bearer {api_key}"} async with session.post(url, headers=headers, json=payload) as r: logger.info("request", extra={"model": model, "tokens": tokens})

L'agrégation via HolySheep simplifie énormément ce type d'architecture : un seul endpoint, un seul système d'authentification, un seul point d'observabilité — avec en plus le support natif WeChat/Alipay et la conversion ¥1 = $1 qui élimine les frais bancaires internationaux. Les crédits gratuits au démarrage permettent de tester cette stack en charge réelle sans toucher au budget de production.

👉 Inscrivez-vous sur HolySheep AI — crédits offerts