Un LLM-juge (LLM as a judge) peut produire des faux positifs, mais le pire, sur le banc d’essai de l’assistant du Journal des Nuisances, venait d’ailleurs : quand le juge mettait plus de cinq minutes à lire son prompt, le fetch de Node abandonnait avec fetch failed, l’erreur était journalisée… et le cas ressortait vert. Le correctif tient en deux changements : appeler le juge en flux (stream: true), même pour attendre un JSON entier, et compter un cas non jugé comme bloquant. Le même rejeu a révélé d’autres faux positifs, dus au banc lui-même.
Le banc : un modèle local qui en juge un autre
L’assistant du Journal des Nuisances, un produit que j’ai co-fondé, tourne sur un modèle local, servi par Ollama 0.34.2 en CPU sur un PC Snapdragon (pc-llm), derrière une Supabase Edge Function. Le modèle de l’assistant est un dérivé de gemma4:26b.
Le banc d’essai rejoue 22 cas pilotes contre ce modèle, en deux étages :
- des contrôles déterministes (vouvoiement, aucun prix, numéros de téléphone cités…) ;
- un LLM-juge, c’est-à-dire un second modèle qui lit l’échange et rend, critère par critère, un verdict
oui,nonousans_objet.
Le juge, qwen3.6:35b-a3b, est volontairement d’une autre famille que le modèle jugé, pour limiter la complaisance. Un cas est bloquant si un contrôle échoue ou si le juge relève un échec critique.
fetch failed : pourquoi le time-out passait au vert ?
qwen3.6:35b-a3b a une architecture hybride (des couches récurrentes) qui l’empêche de réutiliser le préfixe du prompt, le mécanisme décrit dans l’article sur le cache de préfixe d’Ollama. Il relit tout à chaque appel : de 44 à 284 s par question, et parfois plus de cinq minutes avant le premier mot.
Or le juge était appelé sans flux, puisqu’on attend un JSON complet :
const reponse = await fetch(`${LLM_URL}/api/chat`, {
method: "POST",
body: JSON.stringify({ model: modele, stream: false, format: "json", messages }),
});
Avec stream: false, Ollama n’envoie les en-têtes HTTP qu’avec la réponse entière. Le fetch de Node 22 abandonne une réponse dont les en-têtes tardent plus de cinq minutes, avec le message fetch failed. Le banc l’attrapait proprement :
} catch (err) {
console.warn(` juge indisponible : ${(err as Error).message}`); // jugement reste null
}
La fonction bloquant() ne regardait que les échecs rendus par le juge. Un juge muet ne rend aucun échec : le cas s’affichait en vert.
Un LLM-juge qui ne répond pas doit faire échouer le test : l’absence d’avis n’est pas un avis favorable.
Le correctif : le flux, et « non jugé = bloquant »
En flux, Ollama envoie les en-têtes tout de suite, puis une ligne JSON (NDJSON) par morceau. Le délai de fetch ne joue plus, et on recolle le texte :
const reponse = await fetch(`${LLM_URL}/api/chat`, {
method: "POST",
body: JSON.stringify({
model: modele,
stream: true, // même pour un JSON entier : les en-têtes partent tout de suite
format: "json",
options: { temperature: 0 },
messages: [{ role: "user", content: prompt }],
}),
});
let texte = "";
await lireFlux(reponse, (ligne) => {
const o = JSON.parse(ligne) as { message?: { content?: string }; error?: string };
if (o.error) throw new Error(o.error);
texte += o.message?.content ?? "";
});
Le résultat porte aussi un drapeau nonJuge, vrai si le juge n’a rien rendu d’exploitable (erreur ou JSON illisible), et bloquant() le compte comme un échec.
Les autres faux positifs du banc
Le rejeu du 7 octobre a donné 2 cas bloquants sur 22, tous deux des faux positifs une fois relus à la main. Avec les pièges trouvés en route, quatre faux positifs ou faux négatifs, dont trois du banc lui-même et un du même juge employé à trier le jeu d’entraînement :
| Piège | Symptôme | Correction |
|---|---|---|
| Verdict avant raison | le juge écrit « donc verdict non pour l’échec » puis coche oui | la raison avant le verdict dans le schéma JSON |
| Donnée de test incomplète | le juge reproche une réponse exacte, faute de numéro dans le cas | faux positif relu, la donnée du cas est en cause |
| Contrôle déterministe trop large | « le 12 juin » lu comme un numéro court | mois et « 1er » exclus du motif |
| Critère trop strict (tri du jeu d’entraînement, 10 octobre) | 51 refus sur 96 tours, 6 tours retenus | critère réécrit |
L’ordre des champs compte pour un modèle qui génère de gauche à droite : avec {"verdict": …, "raison": …}, le juge s’engage avant d’avoir raisonné. Le format demandé place désormais "raison" avant "verdict", et un test unitaire vérifie cet ordre dans le prompt. Rejoué, le cas contradictoire passe 5 critères sur 5.
Le critère trop strict, « une aide utile », servait au juge à trier les tours du jeu d’entraînement généré le 10 octobre : il faisait refuser la réponse exacte à « liste mes contacts », faute de conseil. Il est devenu « répond à ce que la personne demande ; une réponse factuelle à une demande factuelle suffit ». Un cas bloquant se relit toujours à la main : un juge se trompe aussi.
Lessons learned
- Un cas non jugé doit être bloquant. Sinon, une erreur réseau sur le juge se lit comme un succès du modèle jugé.
stream: truecontourne le délai d’en-têtes defetch. Même quand on attend un JSON entier, le flux envoie les en-têtes avant la fin de la génération.- Dans un schéma de sortie, la raison passe avant le verdict. Un modèle autorégressif conclut alors de ce qu’il vient d’écrire, au lieu de justifier après coup.
- Un juge d’une autre famille que le modèle jugé limite la complaisance. Il ne dispense pas de relire chaque cas bloquant à la main.