Pourquoi votre healthcheck LLM ment depuis le premier jour
Tout le monde teste si l'API du LLM répond. Personne ne teste si le modèle est encore intelligent. Et le jour où Claude perd 20 % de qualité après une mise à jour, vous le découvrez dans votre prod, pas dans vos métriques. Voici la solution que j'ai codée dans aiSelector — 30 lignes de C# qui changent la donne.
Cet article décortique le projetaiSelectorLe bug que personne n'a vu venir
Mai 2026. Un client utilise mon outil pour générer des relances commerciales avec Claude. Mes 14 000 requêtes/jour passent sans erreur. Status 200 partout. Latence p99 stable à 380 ms.
Et pourtant.
Le client m'appelle :
"Vos relances sont devenues nulles. Elles font 3 lignes au lieu de 12. Pas de signature, pas de salutation. C'est cassé depuis quand ?"
Je regarde mes métriques :
- HTTP 200 ✓
- Latence p99 388 ms ✓
- Tokens entrants normaux ✓
- Tokens sortants… divisés par 4 depuis 6 jours.
Sans qu'aucune alerte ne se déclenche. Sans qu'aucun status 5xx n'apparaisse. Anthropic avait mis à jour Claude 3.5 Haiku. Le modèle était plus court, plus terse, et — pour mon cas d'usage spécifique — moins bon.
Pourquoi les healthchecks classiques ratent ça
Un healthcheck LLM standard ressemble à ça :
public async Task<HealthStatus> CheckProvider(IAiProvider provider)
{
var response = await provider.Client.GetAsync("/v1/models");
return response.IsSuccessStatusCode
? HealthStatus.Healthy
: HealthStatus.Unhealthy;
}
C'est ce que 99 % des wrappers LLM font. Y compris ceux des frameworks populaires.
Cette vérification dit une seule chose : l'API répond.
Elle ne dit rien sur :
- la qualité des réponses,
- la longueur des réponses,
- la cohérence des réponses,
- la vitesse de décodage des tokens.
Le serveur OpenAI peut renvoyer "0123456789" en boucle à toutes les questions qu'on lui pose. Le healthcheck dira "healthy". Status 200.
La solution dans aiSelector : healthcheck à 2 couches
J'ai codé deux niveaux de vérification. Le code est dans
ProviderHealthService.cs et tient en 60 lignes.
Couche 1 — TCP / HTTP (toutes les 30 s)
Le check classique. On vérifie que le serveur répond, qu'il y a une route
/models ou équivalente, et que le status est 2xx.
public async Task<HealthLayer> CheckTcp(IAiProvider provider, CancellationToken ct)
{
var sw = Stopwatch.StartNew();
try
{
using var resp = await provider.HttpClient.GetAsync(
provider.ModelsEndpoint, ct);
return new HealthLayer {
Layer = "tcp",
Healthy = resp.IsSuccessStatusCode,
LatencyMs = sw.ElapsedMilliseconds,
StatusCode = (int)resp.StatusCode,
};
}
catch (Exception e) {
return HealthLayer.Failed("tcp", sw.ElapsedMilliseconds, e.Message);
}
}
Coût : 50–100 ms par provider, toutes les 30 s.
Couche 2 — Canary inference (toutes les 5 minutes)
C'est la couche intelligente. On envoie une question dont on connaît la réponse. Si le modèle se trompe, c'est qu'il a dérivé.
private static readonly Dictionary<string, (string Prompt, string ExpectedSubstring)> Canaries = new()
{
["math"] = ("What is 2+2? Answer with just the number.", "4"),
["geo"] = ("What is the capital of France? Answer with one word.", "Paris"),
["count"] = ("Count from 1 to 5, comma-separated.", "1, 2, 3, 4, 5"),
["length"] = ("Write a 100-word paragraph about coffee.", "::LEN>=80"),
};
public async Task<HealthLayer> CheckInference(IAiProvider provider, CancellationToken ct)
{
var (prompt, expected) = Canaries[RandomKey()];
var sw = Stopwatch.StartNew();
var response = await provider.SendAsync(
new AiProviderRequest(provider.DefaultModel, prompt, MaxTokens: 200), ct);
var ok = expected.StartsWith("::LEN>=")
? response.Text.Split(' ').Length >= int.Parse(expected[7..])
: response.Text.Contains(expected, StringComparison.OrdinalIgnoreCase);
return new HealthLayer {
Layer = "inference",
Healthy = ok,
LatencyMs = sw.ElapsedMilliseconds,
OutputTokens = response.OutputTokens,
SemanticMatch = ok,
};
}
Coût : 1–3 s par check, toutes les 5 minutes. ~280 requêtes/jour par provider. À $0.05/M tokens (GPT-4o-mini), ça coûte moins de $0.01/jour pour 5 providers.
Les 4 canaries que j'utilise
Pas un seul prompt, mais 4 :
| Type | Question | Test |
|---|---|---|
| Math | "What is 2+2?" | Réponse contient "4" |
| Géo | "Capital of France?" | Réponse contient "Paris" |
| Counting | "Count 1 to 5" | Réponse contient "1, 2, 3, 4, 5" |
| Length | "Write 100 words about coffee" | Réponse ≥ 80 mots |
Pourquoi 4 ? Parce qu'un modèle peut être bon pour les maths et nul pour la longueur. C'est exactement ce qui m'est arrivé avec Claude — il répondait parfaitement aux 3 premiers tests, mais générait des sorties de 25 mots au lieu de 100. Le test "length" l'a attrapé en quelques minutes.
Si je n'avais eu que le test "math", je ne l'aurais jamais détecté.
À chaque appel, on tire un canary au hasard. Sur 24 h, chaque provider est testé ~50 fois sur chaque type. Statistiquement, on détecte une dérive sous 2 heures.
Les alertes : ce qui se déclenche
Si 2 canaries consécutifs échouent sur le même provider, mon dashboard passe en rouge et envoie une notification Discord :
🚨 PROVIDER DRIFT DETECTED
Provider: Anthropic
Model: claude-3-5-haiku-latest
Layer 1 (TCP): ✓ OK (388ms p50)
Layer 2 (Inference): ✗ FAIL (length canary, 28 words instead of ≥80)
Last 24h: 3/47 canaries failed (6.4%)
Action: provider marked SUSPENDED, routing fallback to OpenAI
Le routing du gateway passe automatiquement au provider de fallback. Mes clients ne voient rien. Quand le canary repasse vert pendant 1 h, Anthropic est réactivé.
L'effet de bord intéressant
En implémentant ça, j'ai découvert qu'aucun des 5 providers que j'utilise n'est stable sur 90 jours. Chacun a connu au moins un drift détecté :
| Provider | Drift events sur 90j | Catégorie principale |
|---|---|---|
| OpenAI | 4 | latence (+150 ms quelques heures) |
| Anthropic | 2 | length (réponses raccourcies) |
| Gemini | 7 | format (markdown variable) |
| Mistral | 3 | counting (ordre des items) |
| DeepSeek | 9 | reasoning (réponses erratiques) |
Si je n'avais pas eu de canary, mes clients auraient subi 25 incidents silencieux sur 90 jours.
La leçon
Si tu mets un LLM en production sans canary inference, tu fais voler ton avion en regardant l'altimètre, pas la fenêtre.
L'altimètre dit "tu voles à 10 000 pieds". Vrai. Il ne dit pas "tu fonces sur une montagne". Tu as juste pas regardé.
Les LLMs en prod nécessitent une couche d'observability comportementale, pas juste réseau. C'est 30 lignes de code. C'est 1 centime par jour. C'est ce qui sépare un setup "amateur" d'un setup "j'ose le mettre en prod chez un client qui paye".
Stack & code
ProviderHealthService.csdansaiSelector/Application/Services/- 5 providers : OpenAI, Anthropic, Gemini, Mistral, DeepSeek
- 4 canaries : math, geo, counting, length
- Polly pour les retries et circuit-breaker
- Dashboard dans
aiSelector/Components/Pages/Health.razor - Coût total des canaries : ~$0.04/jour pour 5 providers
- MTBR (mean time before drift) : 8 minutes depuis un changement réel du modèle
Le module entier fait 180 lignes. Et c'est la couche la plus rentable que j'ai jamais codée — elle économise des heures de debug et des appels client furieux à 2 h du matin.
Florian Sola
Lead Technique · Haute performance temps réel · 9 ans d'expérience