Des articles approfondis sur les technologies qui façonnent l'avenir.

Décodage contraint : des LLM en modèles de décision rapides

Le décodage contraint transforme un LLM en classifieur rapide. Masquage des logits, calibration et température : comment les rendre fiables.

Un faisceau de lumière se divisant en cinq rayons colorés à travers des portes, illustrant une sortie de tokens contrainte.
Masquer le vocabulaire force toute la distribution de sortie du modèle à passer par quelques portes autorisées.

Voici une astuce peu coûteuse et discrètement utile : si seul le premier token qu'un LLM émettrait vous intéresse, vous pouvez lui poser une question à choix multiples et obtenir la réponse en une seule passe avant. Le décodage contraint — qui consiste à masquer chaque token du vocabulaire sauf quelques-uns que vous acceptez — transforme un modèle génératif en quelque chose qui ressemble beaucoup à un classifieur. Pas de parsing JSON, pas de boucles de nouvelle tentative, pas de onze étapes autorégressives pour produire onze caractères. Une passe, un softmax, une réponse avec une probabilité attachée à chaque option.

Cette idée circule depuis un moment sous des noms comme « modèles de décision » ou inférence de « système un », et elle a récemment fait sensation sur Hacker News avec un tutoriel montrant comment en construire un à partir d'un modèle Qwen de 1,7 milliard de paramètres en une quarantaine de lignes de Python. Les vétérans du machine learning dans les commentaires ont, comme prévu, hurlé : c'est un classifieur, on en fait depuis le perceptron. Ils ont raison, et ils passent un peu à côté du sujet. Ce qui est nouveau n'est pas le concept, c'est qu'on obtient un classifieur zero-shot à partir d'un modèle de langage généraliste sans rien entraîner. La vraie question d'ingénierie est de savoir quand cette approche contrainte bat le fait de laisser simplement le modèle parler, et quand elle vous ment discrètement avec des probabilités trop sûres d'elles.

Deux façons d'obtenir une réponse d'un LLM

Le mode par défaut pour parler à un modèle de langage est la génération. Vous posez une question, le modèle émet des tokens un par un, et quelque part en aval vous analysez ce qui est sorti. Si vous avez besoin d'une sortie structurée, vous ajoutez un schéma : mode JSON, échantillonnage contraint par grammaire, machines à états finis façon Outlines. Ça fonctionne, mais le modèle parcourt quand même token par token toute la réponse. Une réponse à choix multiple triviale peut demander onze étapes de décodage, et chaque étape coûte une passe avant complète sur des milliards de paramètres.

L'alternative consiste à ne jamais le laisser parcourir quoi que ce soit. Une fois le prompt traité, vous regardez les logits sur le vocabulaire à la dernière position, vous jetez tout sauf les identifiants des tokens correspondant à vos options — disons « A », « B », « C », « D », « E » — et vous appliquez un softmax uniquement sur ceux-là. L'argmax est votre prédiction ; les valeurs du softmax sont des scores. Coût total : une passe avant, identique au prefill que vous payiez déjà. Voici le cœur du procédé, adapté de l'approche basée sur Qwen qui circule beaucoup :

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
model_name = "Qwen/Qwen3-1.7B"
options = ["A", "B", "C", "D", "E"]
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(
model_name, torch_dtype="auto", device_map="auto"
)
# First token the model would emit for each option
option_token_ids = [
tokenizer.encode(opt, add_special_tokens=False)[0] for opt in options
]
prompt = (
"What color is the sky?\n"
"A. Red\nB. Blue\nC. Green\nD. Purple\nE. I don't know\nAnswer:"
)
messages = [{"role": "user", "content": prompt}]
text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True,
enable_thinking=False
)
inputs = tokenizer(text, return_tensors="pt").to(model.device)
with torch.no_grad():
logits = model(**inputs).logits[0, -1]
# Constrained decoding: softmax over only the option tokens
probs = torch.softmax(logits[option_token_ids].float(), dim=-1)
print(options[probs.argmax().item()])  # -> B

C'est tout le moteur. Le vocabulaire compte plus de 150 000 tokens et nous avons réduit la décision à cinq nombres. Pas de jeux de température à la génération, pas de parseur qui peut échouer, pas de moyen pour le modèle d'halluciner une option absente de la liste. L'espace de sortie est fermé par construction.

C'est un classifieur, et c'est très bien

Donnons aux sceptiques ce qui leur est dû. Ce que nous avons construit est un classifieur discriminant sur un ensemble d'étiquettes fixe — un descendant d'idées remontant au perceptron de Rosenblatt dans les années 1950, passant par la régression logistique, puis par chaque réseau de neurones à tête softmax de l'ère du deep learning. Si l'on plisse les yeux, le décodage contraint n'est qu'une lecture linéaire de l'état caché final, ce qu'une tête de classification a toujours été. Le MLE qui supplie son équipe depuis des années d'entraîner simplement un classifieur a parfaitement le droit de s'agacer en voyant ce concept rebaptisé « modèle de décision ».

Mais l'histoire montre aussi pourquoi la nouvelle version compte. Les anciens classifieurs étaient étroits : vous collectiez des données étiquetées, vous entraîniez, et vous obteniez un modèle qui connaissait une tâche et rien d'autre. Si les systèmes basés sur des LLM continuent de manger des tâches qui « devraient » utiliser un vrai classifieur, c'est à cause de la propriété zero-shot : le modèle de base a déjà absorbé assez du monde pour que le prompt tienne lieu de données d'entraînement. Quand j'ai testé une configuration de ce type sur un jeu de test de CommonsenseQA, le modèle de 1,7 milliard de paramètres a atteint environ 59 % de précision sans aucun fine-tuning, remontant à environ 62 % après un rapide ajustement sur le split d'entraînement. Ce n'est pas l'état de l'art, mais cela a coûté un après-midi et aucune donnée étiquetée de ma part. Un classifieur sur mesure ferait probablement mieux ; il aurait aussi exigé un pipeline, un jeu de données et une stratégie de réentraînement à chaque changement d'étiquettes.

Il y a ici un cadrage utile qui reprend l'ancien débat génératif contre discriminant. Les classifieurs génératifs modélisent toute la distribution, sont coûteux mais flexibles ; les discriminants modélisent la frontière de décision, sont efficaces mais rigides. Le décodage contraint est un hybride curieux : un modèle génératif mis au service du discriminant au moment de l'inférence. On obtient l'efficacité de la lecture discriminante avec l'ampleur du pré-entraînement génératif. Cette combinaison n'était vraiment pas disponible auparavant, même si les pièces sont anciennes.

Là où le décodage contraint gagne

  • Latence. Une seule passe avant au lieu de N étapes autorégressives. Sur les petits modèles, c'est la différence entre « assez rapide pour un chemin de requête » et « nécessite une file d'attente ». Des praticiens faisant tourner des modèles de décision purs dans le navigateur rapportent des réponses sous les 200 ms — essayez ça avec du JSON génératif.
  • Justesse structurelle. Le modèle ne peut tout simplement pas produire de sortie hors de l'ensemble autorisé. Pas de JSON mal formé, pas de digression du type « La réponse est probablement B parce que... », pas besoin d'une couche de garde-fous pour attraper les violations de format.
  • Débit. Comme chaque requête est une passe unique de forme identique, le batching est trivial et prévisible. Générer des réponses de longueur variable détruit votre efficacité de batching.
  • Un score par option. Vous obtenez la distribution complète, pas seulement le gagnant. Cela ouvre la porte à une logique d'abstention : si la probabilité maximale est sous un seuil, on confie la décision à un humain ou à un modèle plus gros.

Le point sur l'abstention mérite d'être souligné. Une réponse générative est un artefact unique qu'on fait confiance ou non. Une distribution de probabilités sur les options permet de construire du routage : les cas à forte confiance passent automatiquement, ceux à faible confiance remontent. C'est le schéma derrière beaucoup de systèmes de triage en production — routage de tickets support, pré-filtrage de modération de contenu, détection d'intention — et c'est là que cette technique rentabilise sa place. Si votre tâche se décompose naturellement en « choisis l'un de K labels, et dis-moi à quel point tu es sûr », le décodage contraint est presque à coup sûr le bon outil.

Là où la sortie générative gagne

Maintenant l'autre côté. Dès que votre tâche ne rentre pas dans un ensemble d'étiquettes fixe, le décodage contraint s'effondre. Si la réponse est une entité libre, un nombre, du code ou quoi que ce soit de compositionnel, il faut de la génération — éventuellement avec des contraintes de sortie structurée, mais tout de même de la génération. Il y a aussi une perte plus subtile : le raisonnement. Quand un modèle génère une chaîne de pensée avant de répondre, il fait souvent nettement mieux sur les questions difficiles. La tête de décision en une passe ne donne aucun brouillon. Vous demandez une pensée de système un, rapide et intuitive, et vous obtenez exactement cela — y compris ses défaillances caractéristiques.

Il y a aussi le problème de la sensibilité à la formulation. Un classifieur contraint sur « A/B/C/D/E » mesure en réalité la préférence du modèle pour le texte de chaque option à sa position. Reformulez légèrement l'option C, réordonnez la liste, ou remplacez « Réponse : » par « La meilleure réponse est » et vous pouvez déplacer les scores. Les réponses génératives avec raisonnement sont en général plus robustes aux perturbations de surface, car le modèle doit s'engager sur le contenu, pas seulement sur un token. Si vous évaluez l'une ou l'autre approche, perturbez la présentation et observez ce qui casse : c'est un test de robustesse peu coûteux et inconfortable.

Deux chemins traversant un paysage, l'un direct et l'autre sinueux, symbolisant l'inférence rapide face à l'inférence délibérative.
Le décodage contraint est le chemin direct ; la génération avec raisonnement est le long sentier qui atteint parfois des hauteurs plus élevées.

Le piège de la calibration : vos scores de confiance mentent

Voici le piège qui attrape tous ceux qui en construisent un. Vous obtenez des probabilités d'un softmax, donc ce sont forcément des probabilités, non ? Ce n'est pas le cas. Ce sont la confiance du modèle qu'un token donné vient ensuite, ce qui est une affirmation sur la langue, pas sur l'exactitude. Demandez au modèle « Où trouveriez-vous le plus probablement une chauve-souris ? » avec les options « Grotte » et « Match de baseball », et il attribuera quelque chose comme 0,998 à « Grotte » — une question ambiguë sans réponse défendable avec certitude, répondue avec une certitude quasi totale.

Classez les prédictions par niveau de confiance sur une vraie évaluation et le tableau empire. Dans un test CommonsenseQA, le seau de confiance 0,9–1,0 n'était correct qu'environ 70 % du temps, et le seau 0,8–0,9 atteignait à peine 40 %. Un modèle bien calibré devrait avoir raison environ 90 % du temps quand il annonce 0,9. Celui-ci est systématiquement trop confiant — ce qui, si l'on y réfléchit, rejoint l'observation ancienne selon laquelle les réseaux profonds modernes sont en général trop sûrs d'eux. Guo et al. ont montré dès 2017 qu'un ResNet classique produit des sorties softmax très mal calibrées, comparé aux réseaux peu profonds des années 1990. Tout ce qui est vieux redevient neuf ; on a simplement réinventé le problème à 1,7 milliard de paramètres.

La solution, heureusement, est elle aussi ancienne et presque ridiculement simple : la mise à l'échelle de la température. On divise les logits par un scalaire unique T appris avant le softmax :

def scaled_probs(logits, option_ids, temperature):
selected = logits[option_ids].float() / temperature
return torch.softmax(selected, dim=-1)
# Fit T on a validation set by minimizing negative log-likelihood
# of the correct option. For the CommonsenseQA run above, T ~= 3.8
temperature = 3.7973
probs = scaled_probs(logits, option_token_ids, temperature)

T supérieur à 1 aplatit la distribution ; T inférieur à 1 la rend plus nette. Ajuster ce seul nombre sur un jeu de validation a transformé ce tableau de calibration déplorable en quelque chose d'honnête : le seau 0,9–1,0 se situe maintenant autour de 95 % de précision, le seau 0,5–0,6 autour de 55 %. La précision ne change pas du tout — l'argmax est invariant par une mise à l'échelle monotone — mais les scores signifient désormais ce que vous pensiez qu'ils signifiaient. Si vous comptez router sur ces probabilités, calibrez d'abord, ou ne prenez même pas la peine de les collecter.

Régler la température sur un benchmark, est-ce tricher ?

Une question légitime soulevée dans la discussion : ajuster T pour que le modèle paraisse calibré sur un benchmark n'est-il pas un peu du p-hacking ? Ça le serait si vous l'ajustiez sur le jeu de test. Bien fait — ajuster T sur un split de validation, rapporter la calibration sur un split de test tenu à l'écart — c'est simplement une régression à un paramètre, et c'est la pratique standard dans la littérature pour cette raison précise. La discipline qui compte est la même qu'ailleurs en ML : gardez vos splits honnêtes, et méfiez-vous de toute affirmation de calibration mesurée sur des données qui ont touché à l'ajustement. Si le domaine de déploiement s'éloigne de votre domaine d'évaluation, votre T dérive aussi, donc la recalibration doit faire partie de votre boucle de maintenance, au même titre que le reste.

C'est en réalité une instance d'un mal plus général : l'écart entre ce qu'un système est censé signifier et ce qu'il calcule réellement, un écart où, j'ai déjà soutenu, vivent les bugs. La « confiance » est spécifiée comme une probabilité d'exactitude ; l'implémentation vous donne une plausibilité du token suivant. La mise à l'échelle de la température est un correctif posé sur cet écart, pas une résolution. Gardez cette distinction en tête chaque fois que vous êtes tenté de brancher des sorties softmax sur un seuil d'alerte.

Une procédure de décision pratique

Quand je choisis aujourd'hui entre les deux modes, je parcours une courte liste de contrôle :

  1. La sortie est-elle un ensemble fixe d'étiquettes ? Si oui, le décodage contraint est sur la table. Sinon, on génère.
  2. Ai-je besoin de raisonnement pour obtenir une précision acceptable ? Prototypez les deux. Si la tête en une passe est à la traîne par rapport à la chaîne de pensée plus que ce que vous pouvez tolérer, la génération l'emporte malgré la latence.
  3. Ai-je besoin de scores par option pour le routage ou l'abstention ? Si oui, le décodage contraint associé à la mise à l'échelle de la température est presque gratuit, alors que la génération ne donne rien de comparable.
  4. Quelle est la taille de l'ensemble d'étiquettes ? Cinq options, c'est trivial ; cinq mille relèvent de la recherche. Avec de grands espaces d'étiquettes, vectorisez vos étiquettes, préselectionnez par recherche vectorielle, et seulement ensuite utilisez le modèle pour choisir parmi les finalistes — l'astuce de passage à l'échelle des classifieurs, standard depuis l'ère des systèmes de recommandation.
  5. Les étiquettes changeront-elles souvent ? La propriété zero-shot est tout l'intérêt. Si les étiquettes changent chaque semaine, un classifieur réentraîné est un fardeau de maintenance ; une modification de prompt, non.

Le softmax du modèle est une affirmation sur le token qui vient ensuite, pas sur ce qui est vrai. Calibrez-le, ou ne lui faites pas confiance.

Une considération de plus : la taille du modèle interagit avec ce choix. La tête en une passe d'un petit modèle est assez peu coûteuse pour tourner sur chaque requête, même côté client ; des gens livrent déjà des modèles de décision qui tournent dans un navigateur avec des réponses sous 200 ms. Une conception en cascade — petit modèle contraint pour les 80 % faciles, grand modèle génératif pour la queue difficile — bat souvent les deux extrêmes en coût comme en précision. C'est le même réflexe que le décodage spéculatif, appliqué au niveau du système plutôt qu'au niveau du token.

La recommandation

Ma position : si votre tâche est réellement un choix fixe — routage, triage, intention, évaluation à choix multiples — construisez la tête de décision contrainte et ne regardez pas en arrière. Le gain en latence seul le justifie, les garanties structurelles éliminent toute une classe d'erreurs de parsing, et les scores par option vous donnent une logique de routage que la génération ne peut pas égaler. Mais traitez le softmax brut comme un instrument non calibré. Affinez sur votre tâche réelle si vous pouvez rassembler même un jeu de données modeste, ajustez une température sur un split de validation propre, et vérifiez la calibration sur des données que l'ajustement n'a jamais vues.

Réservez la sortie générative — avec des contraintes de sortie structurée là où vous en avez besoin — aux tâches réellement compositionnelles ou qui bénéficient d'un raisonnement visible. Et ne laissez personne vous vendre le « modèle de décision » comme une catégorie nouvelle : c'est un classifieur portant le manteau d'un LLM, issu de soixante ans de modélisation discriminante, et il est le plus utile précisément quand on respecte assez cette lignée pour faire le travail de calibration que les anciens ont toujours exigé. L'outillage est neuf. La rigueur, elle, ne l'est pas.