Un classifieur, c'est surtout de la plomberie
Sur cette page
Chez Flowstate, on mâche un problème qui a l’air trivial jusqu’à ce qu’on s’y essaie : router chaque requête entrante vers le modèle le moins cher capable de la traiter pour de bon. Pour router un prompt, il faut d’abord comprendre ce qu’il veut, et les prompts arrivent dans un pur chaos humain. Des fautes de frappe. Un pavé de code de 400 lignes avec la vraie demande enterrée sur la dernière. « salut tu peux regarder ça ». Il faut en tirer une intention propre, et le faire en bien moins d’une milliseconde, dans le process, sur un CPU, gratuitement. Un jugement sémantique dans une empreinte mémoire plus petite qu’un JPEG, alors que tous les articles sur le sujet jurent qu’il te faut une baie de H100.
Il existe un playbook standard de l’industrie pour ça. Tu constitues un jeu de référence de 200 à 500 prompts étiquetés à la main. Tu valides un LLM professeur contre lui et tu ne continues pas sous un F1 de 0,90. Tu distilles le professeur dans un petit modèle élève : SetFit, une variante de BERT, quelque chose avec des embeddings. Tu traces des matrices de confusion. Tu mesures la latence p99 au micro-benchmark. Tu fais tourner un déploiement fantôme de 48 heures avant de le laisser toucher une seule vraie requête. C’est un joli playbook, le genre écrit par des chercheurs avec du calcul infini et sans astreinte en production. C’est aussi une excellente façon de te sur-ingénierer dans un mur.
Le vrai boulot était étroit : étiqueter chaque prompt entrant avec un type de tâche (parent → child, comme engineering → fix_bug ou data → spreadsheet_edit) pour que le proxy le route quelque part de sensé au lieu de tout envoyer au modèle le plus cher du menu. CPU seulement, pas de GPU dans le chemin critique, beaucoup de requêtes. Ce qui rend l’entraînement de son propre classifieur sensé plutôt que fou, c’est que Flowstate dort déjà sur une montagne de vraies requêtes. Elles ne sont simplement pas étiquetées. C’est le seul bon usage d’un LLM professeur pas cher. Pas comme un portier qui prélève sa part sur chaque requête. Tu le braques une fois sur le tas, tu le laisses tout étiqueter, et tu le jettes. Tu utilises la chose chère exactement une fois.
Le paradoxe du péage
L’objection évidente, celle qui m’a fait fixer le plafond : sauter complètement le classifieur local. Appeler un modèle rapide et bon marché (Flash, Haiku, ce qui traîne en bas de la grille tarifaire), le laisser étiqueter le prompt, et avoir fini avant le déjeuner. Il serait même plus précis que tout ce que je pourrais entraîner.
Mais c’est exactement le piège dont toute l’architecture cherche à s’échapper. Étiqueter un prompt avant qu’il touche un modèle sert à dépenser moins : envoyer le facile quelque part de bon marché, ne payer le prix de la frontière que lorsqu’on y est obligé. Si le routeur fait lui-même un appel d’API payant sur chaque requête, tu as construit un péage devant ton péage. Tu paies un ticket de bus juste pour demander au chauffeur si c’est le bon bus. Tu as brûlé les économies avant de les avoir gagnées, greffé un aller-retour réseau sur chaque requête, et posté une copie de chaque prompt dans les logs de quelqu’un d’autre. Un classifieur qui tourne dans le process sur un CPU que tu possèdes déjà est gratuit par requête. Pas bon marché, gratuit, pour toujours après l’entraînement. Un modèle maladroit à 92 % qui ne coûte rien peut donc valoir plus qu’un modèle à 99 % qui te facture au token. Ici, il te faut un videur rapide et un peu bête, pas un philosophe lent et cher.
Le hic, c’est le mot « entraîné ». Un modèle comme celui-là est affamé. Il lui faut bien plus d’exemples étiquetés que je n’en étiquetterais jamais à la main pour le plaisir. Le professeur gagne donc sa place exactement une fois : étiqueter le tas hors ligne, et le classifieur tourne gratuitement ensuite.
Avant de discuter de tout ça, j’ai fait ce que je fais d’habitude : monter un petit banc d’essai et mesurer.
D’abord, un aveu
Je n’ai pas encore braqué le professeur sur le vrai tas, alors pour cette première passe j’ai généré un substitut. Quelques milliers de prompts synthétiques répartis sur dix-huit types de tâches, avec de l’ambiguïté volontaire pour que le classifieur ait de quoi se tromper. Des prompts comme « regarde l’endpoint cassé », qui est honnêtement fix_bug ou debug et impossible à trancher d’après le texte. Environ 2 800 pour l’entraînement, 600 pour le test.
Ça compte, alors je le dis fort : des données synthétiques étiquetées par ce qui les a générées, c’est une partie truquée. Ça te dit si le pipeline fonctionne et comment les approches se classent entre elles. Ça ne te dit pas la précision en conditions réelles. Et ça flatte discrètement les modèles simples, parce qu’un texte généré sur gabarit laisse des empreintes lexicales qu’un sac de mots aspire sans effort. Garde ça sous le coude. J’y reviens.
Avec ça cloué à la porte, passons aux chiffres.
Le modèle est la décision ennuyeuse
Cinq approches, de la moins chère à la plus sophistiquée. Un simple sac de mots TF-IDF dans une régression logistique. La même chose avec des n-grammes de caractères et un SVM. Un vectoriseur par hachage dans un classifieur SGD. Et celle que le playbook veut vraiment que tu construises : des embeddings de phrases (un transformer BGE quantifié) dans une tête linéaire.
| Approche | Préc. feuille | Préc. parent | p95 (1 req) | p95 (sous charge) | Taille |
|---|---|---|---|---|---|
| TF-IDF + logreg | 0,923 | 1,000 | 0,18 ms | 0,17 ms | 336 Ko |
| TF-IDF + char + SVM | 0,926 | 1,000 | 1,4 ms | 29,6 ms | 3,2 Mo |
| Hashing (2²⁰) + SGD | 0,926 | 1,000 | 20,7 ms | 42,4 ms | 147 Mo |
| Hashing (2¹⁸) + SGD | 0,924 | 1,000 | 4,1 ms | 6,8 ms | 37 Mo |
| Embeddings + logreg | 0,918 | 0,997 | 6,0 ms | 20,8 ms | 131 Mo |
La colonne de précision est la terne. Toutes les approches tombent à un point de pourcentage les unes des autres, groupées autour de 92 %. Le transformer de 131 Mo, celui pour lequel tu écrirais un design doc de justification, est arrivé dernier, et c’était le seul à ne pas réussir à trouver parfaitement l’étiquette parent grossière. Le sac de mots de 336 Ko, une idée plus vieille que la plupart des frameworks de ton package.json, l’a égalé et a répondu en un tiers de milliseconde.
La latence est la colonne qui n’est pas plate. Deux ordres de grandeur entre le haut et le bas. Le seul axe sur lequel ces approches diffèrent vraiment est l’opérationnel, et là, l’option la plus bête gagne haut la main.
La colonne parent cache un résultat plus discret : un 1,000 constant pour presque tout le monde. Toute la confusion vit à l’intérieur d’un domaine : viz pris pour data_analysis, market_research pour web_research. Rien ne confond jamais un tableur avec un rapport de bug. Si le proxy n’a besoin que du domaine grossier, la chose la moins chère de la liste l’avait déjà résolu et je pouvais rentrer chez moi.
Alors si le modèle compte à peine, qu’est-ce qui compte ?
La plomberie
Trois choses ont absorbé bien plus de mon attention que le modèle, et aucune ne figure dans aucun guide.
Piège numéro un : le vectoriseur par hachage avale 147 mégaoctets sans rien dire
Le modèle à hachage avec 2²⁰ features a affiché 20,7 ms et 147 Mo de mémoire résidente, pour une tâche à dix-huit classes. C’est un quart de seconde de latence et un petit fichier vidéo de RAM pour décider si quelqu’un a tapé « répare ça » ou « pourquoi ça casse ».
La cause est ennuyeuse et entièrement auto-infligée : un espace de 2²⁰ features multiplié par dix-huit classes donne une matrice de coefficients dense de la taille d’un album photo de vacances, et chaque prédiction y passe la main sur toute la surface. Descends le hachage à 2¹⁸ et c’est cinq fois plus rapide et quatre fois plus petit pour la même précision. La valeur par défaut est le piège, et personne ne te prévient qu’il est armé.
Piège numéro deux : le mirage du « ça marche sur ma machine »
Le SVM à n-grammes de caractères était superbe isolé : 1,4 ms par requête, la meilleure précision du tableau d’un cheveu. J’ai failli le commiter et aller prendre un café. Puis j’ai braqué huit workers concurrents dessus et son p95 s’est effondré à 29,6 millisecondes. Une chute d’un facteur vingt à la seconde où il a eu de la compagnie.
Deux raisons, toutes deux invisibles dans un benchmark à requête unique. Pour sortir des probabilités d’un SVM, on le calibre, ce qui entraîne et exécute discrètement trois sous-modèles par prédiction. Et tout ce travail CPU en plus par requête s’empile droit dans le verrou global de Python, si bien que les requêtes font poliment la queue dans l’immeuble en flammes au lieu de filer. Le sac de mots, qui ne fait presque rien par requête, n’a même pas remarqué la charge. 0,17 ms sous huit workers, comme quand il tourne seul. Gagner la médiane, c’est agréable. Garder la tête froide quand tout brûle, c’est ce qui t’intéresse à 3 h du matin.
La colle : la troncature fait la différence entre 17 % et 83 %
Celle-là m’a fait me sentir idiot. J’ai lancé un test de résistance sur le modèle gagnant : un pavé Java de 400 lignes avec une instruction cruciale collée sur la dernière ligne, « maintenant corrige le bug qui rend le total faux ». La vraie forme d’un prompt dans un outil de code.
Il a fait 16,7 %. Il a perdu la tête et a étiqueté presque tout refactor, parce que pour un sac de mots un mur de code est un refactor, et l’unique instruction humaine tout en bas s’est noyée dans le bruit.
La solution n’était ni un modèle plus sophistiqué ni une matrice d’embeddings. C’étaient quatre lignes qui jettent tout sauf les 200 derniers caractères et classifient ceux-là.
De seize pour cent à quatre-vingt-trois, juste en changeant ce que le modèle avait le droit de voir plutôt que le modèle lui-même. (La fin seule gagne quand l’instruction est à la fin. Début plus fin est le pari général le plus sûr, au cas où la demande serait en haut.) Le modèle allait bien depuis le début. La plomberie, non.
L’autre colle : lui apprendre à dire « je ne sais pas »
Un étiqueteur qui se trompe avec aplomb est pire qu’un qui s’abstient. Bonne nouvelle, le modèle savait déjà quand il devinait. Ses mauvaises réponses sur les prompts ambigus d’un mot (« debug », « ? ») arrivaient avec une confiance au plancher. J’ai donc balayé un seuil de confiance : en dessous, on route vers un fourre-tout au lieu de deviner.
C’est un compromis net. Garde le seuil autour de 0,7 et tu réponds encore à 90 % des prompts, la précision sur ceux auxquels il répond grimpe à 95,5 %, et tu attrapes 90 % du vrai bruit hors périmètre. Où tu le places est une décision produit (combien de fois tu acceptes de hausser les épaules contre combien de fois tu acceptes de te tromper) et, encore une fois, ça n’a rien à voir avec le modèle.
Où c’est truqué, et où ça ne l’est pas
Retour à l’aveu. Un lecteur affûté est déjà en train de taper : tes données sont synthétiques et lexicalement propres, bien sûr que le sac de mots a gagné. Les embeddings se rattrapent sur de vrais prompts brouillons, que tu n’as jamais testés. Ce lecteur a raison, et je prendrais son pari. Sur du vrai trafic, avec des fautes de frappe, des demi-pensées, trois langues dans une phrase, la même intention formulée de cent façons, je m’attends tout à fait à ce que les embeddings passent devant. Le match des modèles, précisément, est la partie de tout ça en laquelle tu dois avoir le moins confiance.
Le test honnête est juste là : braquer le professeur sur le tas de Flowstate, l’étiqueter une fois, et relancer le match sur de vrais prompts. C’est là qu’on saurait si les 92 % venaient de données complaisantes ou d’une approche solide. Si j’y arrive, ce sera un billet à part.
La plomberie, en revanche, se moque des données que tu lui donnes. Le vectoriseur par hachage avale 147 Mo quoi qu’il arrive. Le SVM calibré s’effondre sous la concurrence sur n’importe quelle entrée. La troncature décide si le modèle voit seulement l’instruction. Le seuil de confiance est une propriété du déploiement, pas du jeu de données. Ces constats survivent intacts à la réserve, et c’était l’essentiel du travail.
Ce que j’ai vraiment appris
- Mesure avant d’architecturer. J’aurais pu passer une semaine à débattre SetFit contre BERT. Un après-midi avec un script m’a dit que le modèle était la variable la moins intéressante du système.
- Les benchmarks à requête unique mentent. Le SVM avait l’air meilleur seul et pire sous charge. Si ton proxy sert du trafic concurrent, benchmarke du trafic concurrent.
- Les valeurs par défaut sont des pièges. Un espace de hachage de 2²⁰ pour dix-huit classes, c’est 147 Mo de rien. Sache ce que fait le bouton avant de le laisser là où tu l’as trouvé.
- La troncature est un modèle. Choisir ce qu’on montre au classifieur a plus fait bouger la précision que n’importe quel choix d’architecture : de 17 % à 83 % avec quatre lignes.
- Laisse-le s’abstenir. Un seuil de confiance transforme « parfois faux avec aplomb » en « presque toujours juste, parfois honnête ». C’est un cadran qui vaut le coup.
- La base ennuyeuse est la chose à battre, pas celle à sauter. Commence avec 336 Ko et un visage impassible. Sors le transformer de 131 Mo quand tu as de vraies données qui prouvent que tu en as besoin, et pas un commit avant.
Le playbook sophistiqué n’avait pas tort, exactement. Il optimisait la seule décision qui ne comptait pas, et restait muet sur les quatre qui comptaient. Toute l’industrie va te vendre un cluster de H100 pour lire un prompt. Ce qui a vraiment fait avancer les choses, ce sont quatre lignes de découpage de chaînes. Un classifieur, c’est surtout de la plomberie, et la plomberie ne se photographie pas bien, ce qui explique probablement pourquoi personne n’en écrit le guide.
Tout le banc d’essai fait environ 600 lignes de Python. C’est du code du boulot, donc je ne le publie pas, mais tout ce qui compte est dans ce billet : les cinq approches, le test de concurrence, la correction par troncature, le balayage de seuil. Vu la réserve, les trous sont exactement ce que je cherche, alors vise la méthode.