Un classificateur de risque L2 qui lit les logits au lieu de générer
L'évaluation des risques dans Open Cradle a deux couches. L1, ce sont des règles déterministes, exécutées en moins de 10 ms. L2, c'est un classificateur LLM. Le niveau final vaut finalLevel = max(L1, L2) : quoi que dise le modèle, un jaune venu des règles ne redescend jamais.
Le problème était L2. Le modèle écrivait du JSON sous grammar : catégorie, risque, confidence, reasoning. Sur Qwen3 4B et une machine M-series, cela prenait 6 à 7 secondes par ticket, et le champ confidence était ce que le modèle avait envie d'écrire. Il y avait un nombre dans la réponse ; il n'y avait pas de sens dedans.
Hypothèse
Une classification est une question fermée. Pour une question fermée, le modèle n'a rien à écrire : un seul forward pass et la distribution du token suivant sur les réponses admises suffisent. Lire les logits est une technique connue, je n'ai rien inventé ici. Ce qui m'intéressait, c'était ce qui allait casser en la transposant dans un produit qui tourne sur de petits modèles locaux.
La primitive decide()
Chaque option de réponse reçoit un alias d'un seul token : les lettres A, B, C… pour les questions à choix et booléennes, des chiffres pour une échelle. Le runner enveloppe le prompt dans le chat template du modèle, laisse le tour de l'assistant ouvert et renvoie la masse de probabilité sur chaque candidat.
Trois grandeurs par réponse :
probabilities— la masse, renormalisée sur les options ;coverage— la somme de la masse sur les options avant renormalisation ;confidence = p(choisi) × coverage.
Le texte du ticket vient en premier dans le prompt. Son préfixe déjà calculé est réutilisé entre les questions d'une même requête, donc la deuxième question et les suivantes coûtent peu.
import { decide } from '@cradle/core/decision'
const res = await decide(runner, {
// The ticket goes first: its evaluated prefix is shared by every question.
state: ticketText,
// System turn: definitions of risks and categories. No "return JSON" lines.
context: logitsContext(operatorPrompt),
questions: {
risk: {
type: 'choice',
instructions: 'What is the risk level of this message?',
choices: {
green: 'safe to answer automatically',
yellow: 'an operator must review the answer',
red: 'always needs approval'
}
},
commitment: {
type: 'boolean',
instructions: 'Does the message ask us to take on a financial or legal commitment?'
},
urgency: {
type: 'score',
instructions: 'How urgent is this message?',
min: 0,
max: 5
}
}
}, { debug: true })
res.method // 'logits' | 'generated'
res.answers.risk.probabilities // { green, yellow, red } — sums to 1
res.answers.risk.coverage // mass on A/B/C before renormalisation
res.answers.risk.confidence // p(chosen) × coverage
res.answers.commitment.probability // P(yes)
res.answers.urgency.value // expected value over 0..5
res.answers.urgency.mode // most probable grade
res.calibrated // false — raw model output
Si le runner ne peut pas exposer les logits (une API externe), decide() bascule sur le chemin generated : une réponse sous schéma enum, probabilities: null.
Le temps obtenu
| Variante | Temps |
|---|---|
| Génération JSON, Qwen3 4B, M-series | 6–7 s |
| Logits, 5 questions | 987 ms |
| Logits, 6 questions (avec red flags) | 1674 ms |
| + la question « qui traite » sur le préfixe partagé | +410 ms |
Ce qui a cassé
1. Le prompt demandait du JSON. Le prompt opérateur de l'ancien chemin finissait par « renvoie du JSON ». Sur le chemin des logits, le modèle ouvrait docilement sa réponse par {, et le coverage restait entre 0,00 et 0,45. Les lignes sur le format de sortie ont été retirées du contexte ; les définitions des risques et des catégories sont restées.
2. Les modèles de raisonnement commencent par <think>. Si c'est le token suivant le plus probable, le runner ajoute un bloc de réflexion vide et lit la distribution après lui. Sur Qwen3, cela se déclenche à chaque question.
3. Un coverage de 1,0 ne veut pas dire juste. Un contexte par défaut « réponds par une seule lettre » a poussé le coverage à 1,0, mais les modèles 0.6B et 1.7B se sont mis à choisir presque toujours la dernière option. Annulé. Le coverage mesure le respect du format, pas la justesse.
4. Une seule question à trois niveaux ratait le rouge. Sur « signez l'avenant au contrat », Qwen3 4B répondait jaune avec p = 1,00. La solution : trois red flags booléens séparés : un engagement financier ou juridique ; suppression, droits d'accès, production ; données personnelles de tiers. Tout « oui » avec P ≥ 0,5 rend le verdict rouge. Un modèle qui se trompe de niveau reconnaît en général le fait quand on le lui demande directement.
5. Le risque n'est pas choisi par argmax. Un faux vert (une réponse automatique non relue) coûte bien plus qu'un jaune de trop (un opérateur jette un œil). D'où des seuils :
function pickRisk(p: Record<'green' | 'yellow' | 'red', number>) {
if (p.red >= 0.3) return 'red' // red as soon as P(red) reaches 0.3
if (p.green >= 0.8) return 'green' // green only when P(green) reaches 0.8
return 'yellow' // everything in between
}
6. Le fallback ne peut pas renvoyer vert. Si le coverage sur le risque ou la catégorie passe sous 0,5, le classificateur retombe sur l'ancien chemin génératif. Mais un verdict issu du fallback ne peut jamais être vert : une prompt injection sur Phi-4-mini a produit exactement ce faux vert. Qu'un modèle refuse une question fermée est en soi le signe que le message est inhabituel.
Mesures
44 tickets étiquetés à la main, quantification Q4_K_M. Colonnes : précision sur le risque, faux verts, rouges manqués (jaune au lieu de rouge, le ticket arrive quand même à un opérateur), niveaux surévalués, précision sur la catégorie.
| Modèle | Risque | Faux vert | Rouge manqué | Surévalué | Catégorie |
|---|---|---|---|---|---|
| Qwen3 4B | 95% | 0 | 0 | 2 | 98% |
| Gemma 3 4B | 84% | 0 | 4 | 3 | 93% |
| Qwen2.5 3B | 84% | 0 | 0 | 7 | 84% |
| Phi-4-mini | 80% | 1 | 0 | 8 | 91% |
| Llama 3.2 3B | 59% | 0 | 9 | 9 | 48% |
| Qwen3 1.7B | 59% | 0 | 10 | 8 | 68% |
| Qwen3 0.6B | 41% | 0 | 0 | 26 | 48% |
La réserve est obligatoire : l'étiquetage est le mien, le jeu est petit. Les chiffres servent à comparer les modèles entre eux, pas à mesurer une qualité absolue. Le seul faux vert de toute la table est cette prompt injection sur Phi-4-mini, d'où est née la règle numéro six.
Limites
- Les probabilités ne sont pas calibrées. Un modèle sûr de lui se trompe avec p = 1,0, et aucun seuil n'attrape cela. La réponse le dit elle-même :
calibrated: false. - Le biais de position des petits modèles n'est pas compensé du tout.
- Les red flags sont réglés pour un seul modèle. Sur un autre, la formulation comme le seuil doivent être revérifiés sur le jeu.
La suite : temperature scaling par question, avec des étiquettes tirées des décisions des opérateurs ; la métrique ECE ; une moyenne sur les permutations des options contre le biais de position. Ce n'est qu'après que les seuils pourront être choisis honnêtement, et non à l'œil.