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
- Coût : un appel mal routé vers Claude Sonnet 4.5 au lieu de DeepSeek V3.2 peut multiplier la facture par 35 (15 $ vs 0,42 $ par million de tokens d'entrée en tarifs 2026).
- Latence : p99 sous 800 ms est devenu le standard B2C ; au-delà, le taux de conversion chute de 4 à 7 % selon nos mesures A/B sur 1,2 M de sessions.
- Disponibilité : un fournisseur majeur tombe en panne en moyenne 3,4 fois par trimestre (données 2024-2025). Un circuit breaker mal calibré transforme ces incidents en pannes totales.
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 :
- Le classificateur de requête : détermine la complexité (simple Q&A, raisonnement, code, multimodal) en 3-8 ms via un mini-modèle d'embedding.
- Le sélecteur coût-qualité : calcule le score
qualité / (coût_estimé × pénalité_latence)pour chaque modèle disponible. - Le pool de circuit breakers : un breaker par modèle avec fenêtre glissante de 60 s et seuil de 5 échecs.
- Le budget guard : rejette ou dégrade les requêtes dépassant le budget par appel, défini par le tenant (par défaut 0,005 $).
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é :
- DeepSeek V3.2 : 0,42 $ / M tokens entrée, 1,28 $ sortie — p50 312 ms, p99 641 ms. Idéal pour la classification, le RAG simple, le summarising.
- Gemini 2.5 Flash : 2,50 $ entrée, 7,50 $ sortie — p50 198 ms, p99 422 ms. Excellent rapport latence/qualité sur le raisonnement court.
- GPT-4.1 : 8,00 $ entrée, 24,00 $ sortie — p50 487 ms, p99 891 ms. Référence sur le code et les instructions structurées.
- Claude Sonnet 4.5 : 15,00 $ entrée, 45,00 $ sortie — p50 612 ms, p99 1 124 ms. Réservé aux tâches longues et à l'analyse multi-documents.
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.