← Retour au blog

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

VarianteTemps
Génération JSON, Qwen3 4B, M-series6–7 s
Logits, 5 questions987 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èleRisqueFaux vertRouge manquéSurévaluéCatégorie
Qwen3 4B95%00298%
Gemma 3 4B84%04393%
Qwen2.5 3B84%00784%
Phi-4-mini80%10891%
Llama 3.2 3B59%09948%
Qwen3 1.7B59%010868%
Qwen3 0.6B41%002648%

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.

Post-scriptum : et Jev ?

Section ajoutée après publication. Une fois cet article écrit, TypeSafe AI a sorti Jev, un modèle qui ne génère pas de texte mais renvoie des décisions typées : un choix dans une liste, une note sur une échelle, un oui/non avec une probabilité. C'est en substance la même primitive que mon decide(), vendue comme une API hébergée. Ne pas se comparer à la réponse du marché serait étrange, alors voici la comparaison.

Il n'y a pas de secret dans Jev. L'argent se prend sur trois choses.

Entraîné pour décider. Les probabilités sont annoncées comme calibrées : TypeSafe décrit une méthode d'entraînement dédiée où « 0,8 » est censé vouloir dire « juste environ 80 % du temps sur ce genre de réponse ». Moi, j'ai les logits d'un modèle génératif, et je dois les calibrer moi-même sur mes propres données.

Vitesse et prix. Latence annoncée de 70 à 500 ms de bout en bout, les questions sur un même état sont évaluées en parallèle. L'entrée coûte 0,042 $ par million de tokens, la sortie n'est pas facturée. Pas besoin de GPU à soi.

Plomberie prête à l'emploi. Évaluation parallèle de plusieurs questions, enchaînement de classifications en workflow, SDK. Chez moi, c'est du code à écrire et à maintenir.

Le marché l'a adopté vite : selon Vercel, en vingt-quatre heures sur AI Gateway, près de 13 % des équipes payantes avaient branché Jev, plus que n'importe quel lancement de modèle avant lui. Pour moi, c'est un argument pour l'approche, pas pour le produit : l'industrie est arrivée à la même conclusion, à savoir qu'une classification est une question fermée, pas une génération.

Ce que montrent les vérifications indépendantes, encore peu nombreuses :

VérificationCe qui a été mesuréRésultat
jev-benchmark (60 cas, risque de tool-call, 4 classes)précision et calibration face à Claude Sonnet 555/60 sur tous les backends ; Jev 3,25–3,62× plus rapide ; chaque erreur venait avec une confiance prudente
jev-ood-calibration (900 tickets synthétiques + 3 benchmarks publics)ECE sur une tâche inconnue0,107 pour un plancher de bruit de 0,024 ; 0,024–0,032 sur les benchmarks publics
does-jev-confidence-mean-anything (8 000 jugements, civil_comments)ECE et décalage des probabilitésdécalage vers « oui » ; ECE 0,209 avant et 0,006–0,023 après un réajustement à deux paramètres ; AUC 0,91
Banking77 et une tâche de routage à 77 classes similaireprécision face aux modèles frontierenviron 78 % pour Jev contre 84–86 % pour les modèles frontier

La conclusion de ce tableau compte plus pour moi que n'importe quel chiffre marketing : la calibration de Jev tient sur des tâches proches de ses données d'entraînement et se dilue là où l'étiquette dépend de la politique de l'entreprise et non du texte. Le risque d'un ticket dans un circuit fermé est exactement ce genre de tâche. Une recalibration sur ses propres données est nécessaire pour Jev comme pour moi ; la différence, c'est que Jev part d'un meilleur point.

Là où la voie locale gagne :

  • Le périmètre. Jev n'existe que comme API hébergée (TypeSafe, Vercel AI Gateway, OpenRouter, Cloudflare) ; les poids ne sont pas publiés et il n'y a pas d'option on-premise. Pour un circuit fermé, cela l'exclut d'emblée, avant toute discussion sur la qualité.
  • La compréhension du contexte. Sur les tâches multi-classes difficiles, les modèles frontier battent Jev de 6 à 8 points. Un modèle local de 4B n'est pas frontier, mais il lit tout le prompt opérateur avec ses définitions de risque, pas un schéma figé.
  • L'opérateur édite les règles. Les définitions des niveaux de risque vivent dans le prompt et changent sans réentraînement.
  • Le matériel est déjà payé. Qwen3 4B sur M-series met 987 ms pour cinq questions ; plus lent que Jev, mais le coût par requête est nul.

Le bilan pour qui décide : Jev vend la commodité et la calibration à ceux qui n'ont pas de GPU à eux et pas d'interdiction d'API externe. Dans un circuit fermé, les deux conditions sont inversées, donc la voie locale est la bonne ici, et Jev est l'étalon auquel la mesurer.

Les alternatives quand Jev est exclu

Quatre façons d'obtenir une décision avec une probabilité à l'intérieur du périmètre. Je n'ai aucune mesure pour les trois dernières ; c'est un plan, pas un rapport.

OptionDonnées dans le périmètreCalibrationNouvelle catégorieNécessaire pour démarrer
Logits d'un modèle génératif (voie actuelle)ouinon, temperature scaling nécessairemodifier le promptun modèle 4B et des décisions d'opérateur étiquetées
Fine-tuning de Qwen (LoRA) ou petit classificateur encoderouivient de l'entraînement, mais dériveréentraînementdes centaines d'exemples par classe, une chaîne de réentraînement
RAG : tickets similaires avec verdicts d'opérateur comme exemples en contexteouinon, le RAG améliore la précision, pas l'honnêteté des probabilitésajouter des exemples à la baseun index sur l'historique des tickets
Jevnonannoncée, à vérifier sur ses propres tâchesmodifier le schéma de la questionune API externe et l'autorisation de l'utiliser

Fine-tuner Qwen. En prenant les décisions des opérateurs dans risk_assessments comme étiquettes, un LoRA sur Qwen3 4B ou même un simple classificateur encoder donnerait ce que Jev vend prêt à l'emploi : des probabilités apprises pour la tâche et une latence de quelques dizaines de millisecondes. Le prix : des centaines d'exemples par classe plutôt que 44 tickets en tout ; chaque nouvelle catégorie ou changement de politique impose un réentraînement ; le prompt opérateur cesse d'être la source de vérité. L'ordre des opérations en découle : les logits d'abord, parce qu'ils marchent dès le premier jour et accumulent des étiquettes, puis le fine-tuning sur ce qui s'est accumulé.

Le RAG depuis la base de connaissances interne. Pour la classification, ce n'est pas de la recherche de documents mais la récupération de tickets similaires dont le verdict est déjà rendu, insérés dans le contexte comme exemples. Le modèle reçoit « voici cinq demandes similaires et ce que l'opérateur en a fait » et répond à la même question fermée. C'est une adaptation au domaine sans entraînement, les règles restent éditables, et les exemples apparaissent d'eux-mêmes au fil de l'exploitation. Réserves : les exemples allongent le préfixe (il est calculé une fois par ticket, donc pour cinq questions c'est tolérable) ; le texte récupéré est un canal de plus pour la prompt injection ; sur les petits modèles, les exemples amplifient le biais de position. Et surtout : le RAG améliore la précision, mais les probabilités n'en deviennent pas plus honnêtes. La calibration reste une étape à part dans chacune des options.

Sources du post-scriptum

Cet article a été créé dans un format hybride humain + IA. J'ai fixé la direction et les thèses, l'IA a aidé pour le texte, j'ai édité et vérifié. La responsabilité du contenu m'incombe.

← Retour au blog