Le routage de modèles : petit d'abord, grand si besoin
L'idée de la cascade
La plupart des questions de support sont simples ; un petit modèle y répond bien, plus vite et moins cher. Une cascade (popularisée par FrugalGPT, voir les références) essaie d'abord le petit modèle et n'escalade vers le grand que si un signal indique que la réponse est douteuse.
flowchart TD
Q["Question"] --> S["flash-lite : réponse + confiance (JSON)"]
S --> C{"confiance >= CONF_MIN ?"}
C -->|"oui"| R1["Réponse du petit (≈ 0,43 $ / 1 000)"]
C -->|"non"| B{"budget de la requête OK ?"}
B -->|"oui"| G["flash : nouvelle réponse (≈ 0,98 $ / 1 000)"]
B -->|"non"| R1
G --> R2["Réponse du grand"]
| Signal pour escalader | Coût | Fiabilité |
|---|---|---|
| Confiance auto-déclarée par le modèle (notre lab) | nul (même appel) | faible, voir les mesures |
| Règles sur la question (calcul, date, plusieurs sujets) | nul | bonne sur les cas prévus |
| Validateur : un 2ᵉ appel juge la réponse | un appel | moyenne à bonne |
| Classifieur entraîné sur vos données | entraînement | la meilleure, une fois calibrée |
| Vérification déterministe (JSON valide, montant recalculé) | nul | excellente, mais spécifique |
Le code
Le petit modèle répond en JSON structuré (schéma imposé par response_schema) avec sa confiance :
SCHEMA = {
"type": "object",
"properties": {"answer": {"type": "string"}, "confidence": {"type": "number"}},
"required": ["answer", "confidence"],
}
small = await ask(self.client, SMALL_MODEL, question)
res = Result(small.answer, "small", [small], ttft=small.ttft)
big_estimate = cost_usd(BIG_MODEL, small.prompt_tokens, small.cached_tokens, 2 * small.output_tokens)
if small.confidence < self.conf_min and small.cost + big_estimate <= self.max_cost:
big = await ask(self.client, BIG_MODEL, question)
res = Result(big.answer, "escalated", [small, big], ttft=small.latency + big.ttft)Une sortie illisible (JSON cassé) reçoit une confiance de 0 : elle escalade automatiquement.
Ce que disent les mesures
Sur les 16 questions, le petit modèle a déclaré une confiance de 1,0 sur 15 questions, et de 0,9 à 0,95 sur la seule question qu'il a ratée (q12 : « Je commande en express un vendredi à 15 h, quand est-ce que je reçois mon colis ? » ; il répond « le prochain jour ouvré » sans conclure « mardi »).
Seuil CONF_MIN | q12 (confiance 0,9) | Escalades | Qualité | Coût du lot |
|---|---|---|---|---|
| 0,8 | reste au petit, fausse | 0 | 94 % | 0,00612 $ |
| 0,95 | escalade, juste | 1 | 100 % | 0,00716 $ |
Avec CONF_MIN = 0,95, le routeur a atteint 100 % de qualité pour un coût proche du petit modèle seul. Que faut-il en conclure ?
Un seul cas a fait la différence. C'est un indice encourageant, pas une preuve. Le grand modèle a justement corrigé ce cas : il reste utile pour la traîne difficile.
Améliorer le signal sans appel supplémentaire
Les questions ratées ont un profil : calcul ou raisonnement sur une date. Une règle simple, combinée à la confiance, attrape ce profil sans aucun appel :
import re
RISKY = re.compile(r"\b(combien je paie|total|remise|quand|quel jour|si je commande)\b", re.I)
def must_escalate(question: str, confidence: float, conf_min: float = 0.8) -> bool:
return confidence < conf_min or bool(RISKY.search(question))Dans une cascade, quel est le coût caché d'une escalade pour l'utilisateur ?
Les deux appels sont séquentiels. C'est pourquoi le p95 du routeur au seuil 0,95 (1,76 s) dépasse celui du petit modèle seul (1,35 s). Pour un chat, on peut afficher la réponse du petit tout en vérifiant en arrière-plan, au prix d'une complexité accrue.
Exercice
Intégrez must_escalate dans Router.answer et écrivez un test sans clé qui vérifie que q10 (calcul de remise) est escaladée même avec une confiance de 1,0.
Voir la solution
Dans router.py :
if must_escalate(question, small.confidence, self.conf_min) and small.cost + big_estimate <= self.max_cost:Dans test_router.py, en réutilisant le faux client :
def test_calcul_toujours_escalade():
cl = client({R.SMALL_MODEL: 1.0, R.BIG_MODEL: 0.9})
q = "Je suis pro : 2 suspensions à 80 € avec la remise, combien je paie au total ?"
assert run(R.Router(cl).answer(q)).route == "escalated"Attention à l'effet sur le coût : si 20 % de votre trafic contient « quand », vous escaladez 20 % des requêtes. Mesurez la part réelle sur vos journaux.
Résultat obtenu
Deux exécutions réelles du routeur le 2026-10-15 (16 questions, 4 en parallèle), extrait de bench.py --strategies router :
# CONF_MIN=0.8
router TTFT p50 0.88 p95 1.16 lat p50 0.97 p95 1.32 qualité 94% coût 0.00612 $ appels 14 cache 2 escal. 0
q12 small [0.9] False 1.32 La livraison express est en 24 h ouvrées pour toute commande passée avant 13 h, du lundi a…
# CONF_MIN=0.95
router TTFT p50 0.85 p95 1.56 lat p50 0.95 p95 1.76 qualité 100% coût 0.00716 $ appels 15 cache 2 escal. 1
q12 escalated [0.9, 0.95] 0.731 True 2.7 3.19 La livraison express en 24 h ouvrées s'applique uniquement aux commandes passées avant 13 h…Je sais faire
- Construire une cascade petit → grand modèle avec un signal d'escalade
- Expliquer pourquoi la confiance auto-déclarée est mal calibrée
- Choisir un seuil à partir des confiances observées sur les réussites et les échecs
- Combiner confiance et règles déterministes pour escalader