Version : 4.15.0 Société : AISIA — Structure juridique en cours de création URL publique : https://aisia.fr/
---
1. Vue d'ensemble du routage 2. UCB1 : Upper Confidence Bound 3. Thompson Sampling 4. Cost-aware routing 5. Latency-aware routing 6. Domain routing 7. Circuit breaker 8. Feature extraction contextuelle 9. Pipeline de routage complet 10. État persistant Redis 11. Configuration et tuning 12. Analyse et interprétation
---
Le système de routage AISIA orchestre la sélection du meilleur provider pour chaque requête utilisateur. Il combine plusieurs algorithmes :
Requête utilisateur
│
▼
┌──────────────────┐
│ Feature Extractor │ ← Analyse le prompt (domaine, longueur, code, etc.)
└──────────────────┘
│
▼
┌──────────────────┐
│ Domain Router │ ← Filtre les providers spécialisés (15 domaines)
└──────────────────┘
│
▼
┌──────────────────┐
│ Circuit Breaker │ ← Exclut les providers en panne (OPEN)
└──────────────────┘
│
▼
┌──────────────────┐
│ Bandit Router │ ← Sélectionne le meilleur (UCB1 ou Thompson)
│ + Cost-aware │
│ + Latency-aware │
└──────────────────┘
│
▼
Provider sélectionné
---
UCB1 est un algorithme de bandit multi-bras qui équilibre exploration (tester des providers peu connus) et exploitation (utiliser le meilleur provider connu).
score_i = mean_reward_i + C * sqrt(ln(N) / n_i) + cost_bonus_i + latency_bonus_i
Où :
mean_reward_i : récompense moyenne du provider itotal_reward_i / n_i
- Plage : [0.0, 1.0]
C : bonus d'exploration (défaut : sqrt(2) = 1.4142)BANDIT_EXPLORATION_BONUS
- Plus grand C = plus d'exploration
N : nombre total de requêtes (tous providers confondus)N = sum(n_i) pour tous les providers
n_i : nombre de requêtes envoyées au provider ipulls dans le code
cost_bonus_i : bonus économique (providers moins chers favorisés)latency_bonus_i : bonus de vitesse (providers plus rapides favorisés)
Le terme C * sqrt(ln(N) / n_i) est le bonus d'exploration.
| Situation | Valeur du terme | Comportement |
|---|---|---|
| Provider peu testé (n_i petit) | Élevé | Sera sélectionné pour exploration |
| Provider très testé (n_i grand) | Faible | Sélectionné uniquement si mean_reward élevé |
| Provider jamais testé (n_i = 0) | Infini | Toujours sélectionné en premier |
# Code dans bandit.py
for p in candidates:
if states[p][0] == 0: # pulls == 0
return p # Sélectionné immédiatement
Tout provider avec 0 pulls est automatiquement sélectionné avant d'appliquer la formule UCB1. Cela garantit que chaque provider est testé au moins une fois.
Prenons 3 providers après 100 requêtes :
| Provider | pulls | mean_reward | UCB1 term | Cost bonus | Latency bonus | Score final |
|---|---|---|---|---|---|---|
| Cerebras | 50 | 0.85 | 0.43 | 0.098 | 0.145 | 1.523 |
| OpenAI | 30 | 0.90 | 0.52 | 0.050 | 0.100 | 1.570 |
| Groq | 20 | 0.80 | 0.60 | 0.097 | 0.140 | 1.637 |
---
Thompson Sampling utilise un échantillonnage bayésien. Chaque provider est modélisé par une distribution Beta(alpha, beta) qui représente notre croyance sur sa probabilité de succès.
sample_i ~ Beta(alpha_i, beta_i)
À chaque requête, un échantillon aléatoire est tiré de la distribution de chaque provider. Le provider avec l'échantillon le plus élevé est sélectionné.
| Paramètre | Initialisation | Mise à jour |
|---|---|---|
alpha | 1.0 | +1.0 si reward >= 0.5 (succès) |
beta | 1.0 | +1.0 si reward < 0.5 (échec) |
| alpha | beta | Interprétation |
|---|---|---|
| 1 | 1 | Inconnu (distribution uniforme) |
| 10 | 2 | Bon provider (80% succès) |
| 5 | 5 | Provider moyen (50% succès) |
| 2 | 10 | Mauvais provider (17% succès) |
| 50 | 5 | Excellent provider, haute confiance |
| Situation | Recommandation |
|---|---|
| Peu de providers (< 10) | UCB1 (plus stable) |
| Beaucoup de providers (> 20) | Thompson (convergence plus rapide) |
| Performances stables | UCB1 |
| Performances variables | Thompson |
| Besoin d'explicabilité | UCB1 (formule déterministe) |
Le routage cost-aware favorise les providers économiques en ajoutant un bonus au score des providers moins chers.
cost_bonus = max(0, 1.0 - cost / 10.0) * cost_weight
Où :
cost : coût en USD par million de tokenscost_weight : poids du bonus coût (défaut : 0.1)| Provider | Coût $/1M tokens | Cost bonus (weight=0.1) |
|---|---|---|
| Cerebras | 0.20 | 0.098 |
| Groq | 0.30 | 0.097 |
| DeepSeek | 0.50 | 0.095 |
| DeepInfra | 0.60 | 0.094 |
| Together | 0.80 | 0.092 |
| Fireworks | 0.90 | 0.091 |
| Cohere | 1.00 | 0.090 |
| Perplexity | 1.00 | 0.090 |
| Gemini | 1.25 | 0.088 |
| AI21 | 1.50 | 0.085 |
| Mistral | 2.00 | 0.080 |
| OpenRouter | 3.00 | 0.070 |
| OpenAI | 5.00 | 0.050 |
| Anthropic | 8.00 | 0.020 |
| HuggingFace | 0.00 | 0.100 |
| cost_weight | Effet |
|---|---|
| 0.0 | Aucun impact du coût sur le routage |
| 0.05 | Léger avantage pour les providers économiques |
| 0.10 (défaut) | Avantage modéré |
| 0.20 | Fort avantage pour les providers économiques |
| 0.50 | Le coût domine la décision |
Le coût est résolu par le préfixe du provider_id :
cost = self.COST_PER_1M.get(p.split("-")[0], 2.0)
Par exemple, openai-gpt4 utilise le coût d'openai. Si le préfixe
n'est pas dans la table, un coût par défaut de 2.0 est utilisé.
---
Le routage latency-aware favorise les providers rapides en ajoutant un bonus basé sur leur latence moyenne historique.
latency_bonus = max(0, 1.0 - avg_latency / 30.0) * latency_weight
Où :
avg_latency : latence moyenne en secondes (EMA)latency_weight : poids du bonus latence (défaut : 0.15)La latence moyenne est calculée avec un EMA qui donne plus de poids aux mesures récentes :
# Pour un provider avec des mesures existantes (pulls > 0)
new_avg_lat = avg_lat 0.8 + last_latency_s 0.2
Pour un nouveau provider (pulls == 0)
new_avg_lat = last_latency_s
Le facteur 0.8/0.2 signifie que chaque nouvelle mesure représente 20% du poids, et l'historique 80%.
| Provider | avg_latency (s) | Latency bonus (weight=0.15) |
|---|---|---|
| Cerebras | 0.4 | 0.148 |
| Groq | 0.6 | 0.147 |
| Ollama local | 2.0 | 0.140 |
| OpenAI | 3.0 | 0.135 |
| Anthropic | 4.0 | 0.130 |
| Provider lent | 15.0 | 0.075 |
| Provider très lent | 30.0 | 0.000 |
La latence moyenne est initialisée à 5.0 secondes pour tous les providers. Cela permet une estimation conservatrice avant les premiers appels.
---
Le DomainRouter analyse le contenu de la requête pour détecter un domaine de spécialisation et filtrer les providers disposant de modèles spécialisés.
| Domaine | Détection | Patterns clés |
|---|---|---|
| math | Regex multi-langue | calcul, équation, dérivée, matrice, probability, sqrt, \sum |
| coding | Regex + détection syntaxe | code, function, class, def, python, API, debug, pytest |
| reasoning | Regex analyse complexe | raisonne, explique pourquoi, step by step, chain of thought |
| image | Regex génération | génère image, dessine, illustre, stable diffusion, dall-e |
| voice | Regex TTS | text to speech, synthèse vocale, voix, narration |
| transcription | Regex STT | transcrire, speech to text, whisper, dictée |
| translation | Regex multilingue | traduis, traduction, en anglais, en français, translate to |
| vision | Regex analyse image | cette image, décris image, OCR, lire document |
| embedding | Regex vectorisation | embed, vectorise, similarity, semantic search, cosine |
| medical | Regex clinique | médical, diagnostic, symptôme, patient, maladie, IRM |
| finance | Regex financier | financ, bourse, stock, trading, ROI, EBITDA |
| science | Regex scientifique | physique, chimie, biologie, molécule, quantum |
| classification | Regex classif | classifie, catégorise, label, spam, toxic, detect |
| sentiment | Regex opinion | sentiment, opinion, positif négatif, satisfaction, avis |
| legal | Regex juridique | juridique, contrat, réglementation, RGPD, droit, tribunal |
def detect_domain(prompt: str) -> str | None:
scores = {}
for domain, pattern in _DOMAIN_PATTERNS.items():
matches = pattern.findall(prompt)
if matches:
scores[domain] = len(matches)
if not scores:
return None
best = max(scores, key=scores.get)
return best if scores[best] >= 1 else None
Le domaine avec le plus de matches est sélectionné. En cas d'égalité,
max() retourne le premier trouvé (ordre du dictionnaire).
Quand un domaine est détecté :
1. Les providers sont filtrés par le champ specialized dans leurs modèles
(providers.yaml)
2. Si des spécialistes existent pour ce domaine, seuls ces providers sont
proposés au BanditRouter
3. Si aucun spécialiste n'existe, tous les providers généralistes sont utilisés
(fallback)
def filter_specialized_providers(providers, domain):
specialized = []
for p in providers:
has_specialist = any(
m.get("specialized") == domain for m in p.get("models", [])
)
if has_specialist:
specialized.append(p)
return specialized
Si un provider spécialisé est sélectionné, AISIA utilise le modèle
ayant l'annotation specialized correspondante plutôt que le modèle
par défaut du provider.
def get_specialized_model(provider, domain):
for m in provider.get("models", []):
if m.get("specialized") == domain:
return m["name"]
return None
---
Le circuit breaker protège le système contre les appels répétés à un provider défaillant. Il suit le pattern classique à trois états.
succès (décrémente failures)
┌─────┐
│ │
▼ │
┌─────────┐ ┌──────────┐
│ CLOSED │─── failures >= 5 ──▶│ OPEN │
│ (normal)│ │ (bloqué) │
└─────────┘ └──────────┘
▲ │
│ │ timeout_s expiré
│ ▼
│ ┌───────────┐
└── 3 succès ────────────│ HALF_OPEN │
│ (test) │
└───────────┘
│
│ 1 échec
▼
┌──────────┐
│ OPEN │
└──────────┘
| Paramètre | Variable | Défaut | Description |
|---|---|---|---|
| Seuil d'échecs | CB_FAILURE_THRESHOLD | 5 | Échecs consécutifs avant ouverture |
| Timeout | CB_TIMEOUT_S | 60 | Durée en OPEN avant test (secondes) |
| Max appels half-open | CB_HALF_OPEN_MAX_CALLS | 3 | Succès requis pour fermer |
#### CLOSED -> OPEN
Quand le nombre d'échecs atteint failure_threshold (5 par défaut),
le circuit s'ouvre. Toutes les requêtes suivantes sont bloquées.
if new_failures >= self.failure_threshold:
await self._save_state(CBState.OPEN, new_failures, now, 0)
#### OPEN -> HALF_OPEN
Après timeout_s secondes (60 par défaut), le circuit passe en HALF_OPEN.
Un nombre limité de requêtes de test sont autorisées.
elapsed = time.time() - last_failure_time
if elapsed > self.timeout_s:
await self._save_state(CBState.HALF_OPEN, failures, last_failure_time, 0)
return False # autoriser la requête
#### HALF_OPEN -> CLOSED
Si half_open_max_calls succès consécutifs (3 par défaut) sont enregistrés,
le circuit se ferme et le fonctionnement normal reprend.
if new_success >= self.half_open_max_calls:
await self._save_state(CBState.CLOSED, 0, 0.0, 0)
#### HALF_OPEN -> OPEN
Si un seul échec survient en HALF_OPEN, le circuit se rouvre immédiatement.
if s["state"] == CBState.HALF_OPEN:
await self._save_state(CBState.OPEN, new_failures, now, 0)
#### CLOSED : auto-guérison
En état CLOSED, chaque succès décrémente le compteur d'échecs :
if s["state"] == CBState.CLOSED:
new_failures = max(0, s["failures"] - 1)
L'état du circuit breaker est persisté dans Redis (hash) :
cb:{provider_id} → {state, failures, last_failure_time, success_count}
TTL : 3600 secondes (1 heure).
Si Redis est indisponible, l'état est maintenu en mémoire locale (non partagé entre replicas).
ai_circuit_breaker_state{provider="xxx"} = 0|1|2
0 = CLOSED
1 = HALF_OPEN
2 = OPEN
---
Le ContextualFeatureExtractor analyse le prompt de l'utilisateur pour
extraire des caractéristiques qui influencent le routage :
| Feature | Type | Détection | Impact |
|---|---|---|---|
task_type | string | Regex par domaine | Sélection du domaine |
has_code | bool | Détection syntaxe code | Bonus modèles code |
is_creative | bool | Détection contenu créatif | Bonus modèles créatifs |
is_realtime | bool | Détection besoin temps réel | Bonus modèles rapides |
length_bucket | string | Longueur du prompt | Bonus modèles grand contexte |
modality | string | Type de contenu | Filtrage par modalité |
requested_modalities | list | Analyse multi-modale | Filtrage strict |
---
1. Réception de la requête POST /v1/invoke
│
2. Extraction des features (ContextualFeatureExtractor)
│
3. Chargement des guardrails (check_input)
│ ├── Mots-clés interdits ? → Refus
│ ├── Rate limit dépassé ? → Refus
│ └── OK → Continue
│
4. Mode local-first activé ?
│ ├── OUI → AutonomousLocalRouter.answer(prompt, features)
│ │ ├── Confiance >= 0.75 ? → Retourne réponse locale
│ │ └── Confiance < 0.75 → Continue vers cloud
│ └── NON → Continue vers cloud
│
5. Domain routing
│ ├── Domaine détecté + spécialistes ? → Filtre providers
│ └── Pas de domaine ou pas de spécialiste → Tous les providers
│
6. Circuit breaker filtering
│ └── Exclut les providers en état OPEN
│
7. Bandit router selection
│ ├── UCB1 : score = mean_reward + exploration + cost + latency
│ └── Thompson : sample ~ Beta(alpha, beta)
│
8. Appel au provider sélectionné
│
9. Post-traitement
│ ├── Scoring : calcul du reward
│ ├── Bandit update : mise à jour alpha/beta/pulls/reward
│ ├── Circuit breaker : record_success() ou record_failure()
│ ├── Métriques Prometheus : record_request()
│ ├── RAG : append_to_conversation()
│ ├── Guardrails : check_output()
│ └── Billing : record_usage()
│
10. Retour de la réponse au client
---
#### Bandit Router
bandit:{provider_id} → Hash {
pulls: int,
total_reward: float,
alpha: float,
beta: float,
avg_latency: float
}
TTL: 86400 (24h)
#### Circuit Breaker
cb:{provider_id} → Hash {
state: "CLOSED"|"HALF_OPEN"|"OPEN",
failures: int,
last_failure_time: float,
success_count: int
}
TTL: 3600 (1h)
Si Redis est indisponible, chaque replica maintient son propre état en mémoire :
self._pulls: Dict[str, int] # Compteur par provider
self._rewards: Dict[str, float] # Récompense totale par provider
self._alpha: Dict[str, float] # Paramètres Thompson
self._beta: Dict[str, float]
self._avg_latency: Dict[str, float] # Latence EMA
Conséquence : sans Redis, chaque replica a une vue différente de l'état des providers. Le routage reste fonctionnel mais non coordonné.
---
| Variable | Défaut | Description | Impact |
|---|---|---|---|
BANDIT_STRATEGY | ucb1 | Algorithme de sélection | ucb1 ou thompson |
BANDIT_EXPLORATION_BONUS | 1.4142 | Bonus d'exploration UCB1 | Plus grand = plus d'exploration |
| Valeur | Comportement |
|---|---|
| 0.5 | Très exploiteur — choisit rapidement le meilleur connu |
| 1.0 | Équilibré — bon compromis exploration/exploitation |
| 1.4142 (défaut) | Standard UCB1 — exploration modérée |
| 2.0 | Très explorateur — teste régulièrement tous les providers |
| 3.0 | Fortement explorateur — pour phases de découverte |
Ces paramètres sont définis dans le constructeur de BanditRouter :
cost_weight: float = 0.1 # Poids du bonus coût
latency_weight: float = 0.15 # Poids du bonus latence
Pour modifier ces valeurs, il faut actuellement modifier le code. Un futur ajout via variables d'environnement est prévu.
| Objectif | Configuration |
|---|---|
| Minimiser les coûts | cost_weight=0.3, latency_weight=0.05 |
| Minimiser la latence | cost_weight=0.05, latency_weight=0.3 |
| Maximiser la qualité | cost_weight=0.0, latency_weight=0.0 |
| Équilibre (défaut) | cost_weight=0.1, latency_weight=0.15 |
GET /admin/bandit/stats
{
"openai": {"pulls": 150, "mean_reward": 0.92, "alpha": 140.0, "beta": 12.0},
"cerebras": {"pulls": 300, "mean_reward": 0.88, "alpha": 265.0, "beta": 36.0},
"groq": {"pulls": 200, "mean_reward": 0.85, "alpha": 172.0, "beta": 29.0}
}
ai_bandit_choice_total{provider="xxx", strategy="ucb1"}
Permet de visualiser dans Grafana la répartition des choix du bandit et de détecter si un provider est sur-exploité ou sous-exploré.
---
Document AISIA v4.21.0