A propos Compétences Expérience Services Blog Contact

Mets le LLM en dernier : j'ai remplacé un modèle 7B par un petit classifieur Go Put the LLM last: I replaced a 7B model with a tiny Go classifier

TL;DR : la plupart des tâches « IA » en production ne sont pas des tâches pour un LLM. Pour trier mes mails, j'ai remplacé un modèle de 7 milliards de paramètres par un petit classifieur en Go. La règle tient en une phrase. Les règles d'abord, un petit modèle ensuite, le LLM en dernier recours seulement. Résultat : plus de GPU, une inférence sous la milliseconde, un appel au cloud devenu rare. Voici comment, avec les vrais chiffres.

Cet article s'adresse aux devs qui mettent un LLM en production et qui paient la facture. Pas une démo.

La plupart des tâches « IA » ne sont pas des tâches pour un LLM

En 2026, le réflexe par défaut, c'est de brancher un gros modèle sur tout. Une question arrive, on appelle le LLM. Mais beaucoup de tâches n'ont pas besoin de lui. Ranger un mail dans « travail » ou « newsletter », c'est de la classification. Un problème résolu depuis vingt ans, bien avant les LLM.

Classer, c'est choisir une étiquette dans une courte liste stable. Générer du texte, c'est autre chose. Le premier travail se fait avec un petit modèle. Le second mérite un gros modèle. La règle que je défends tient en une phrase. Mets le LLM en dernier.

Le contexte

J'ai construit un agent qui trie ma boîte mail. C'est un daemon. Il lit les nouveaux messages et range chacun dans une catégorie : travail, notification, newsletter, promo, et quelques autres. Rien de secret, juste ma vraie boîte, avec des années de courrier.

La première version confiait chaque mail à un LLM local. Un modèle de 7 milliards de paramètres, Qwen 2.5 7B, servi par Ollama sur un GPU. Ollama, c'est un outil qui fait tourner un LLM sur ta machine. Ça marchait. Mais le prix était lourd. Un GPU allumé en permanence. Un conteneur de plus à surveiller. Et une lenteur absurde pour la question posée.

Un jour je me suis demandé : décider « est-ce une newsletter ? » réclame-t-il vraiment 7 milliards de paramètres ? Non. La réponse tient dans deux ou trois mots de l'expéditeur et du sujet. J'ai donc tout repensé.

Trois couches, de la moins chère à la plus chère

Chaque mail traverse trois couches, dans l'ordre. Il s'arrête à la première qui sait répondre.

  • Des règles déterministes. Instantanées, exactes, sans coût.
  • Un petit modèle. Sous la milliseconde, sur CPU.
  • Le LLM. Seulement si le petit modèle doute.

Le code de routage tient en quelques lignes. Il dit toute l'histoire.

func (c *Classifier) decide(m Message) Decision {
	// 1. Regles deterministes : expediteurs evidents, tranches d'entree.
	if d := preClassify(m); d != nil {
		return *d
	}
	// 2. Petit modele ML : sous la milliseconde, sur CPU.
	pred := c.ml.Predict(m)
	if pred.Confidence >= c.threshold {
		return decisionFrom(pred)
	}
	// 3. LLM en dernier : seule la queue incertaine part vers le cloud.
	return c.classifyWithLLM(m)
}

La logique est simple. Chaque couche coûte plus cher que la précédente. Donc chaque couche ne traite que ce que les autres n'ont pas su trancher.

Les règles écrèment le facile

Une bonne partie de mon courrier est évidente rien qu'à l'expéditeur. Une adresse de newsletter connue est toujours une newsletter. Une alerte de plateforme est toujours une notification. Aucune intelligence requise.

Une courte liste de règles décide ces cas d'un coup. Elle regarde le domaine de l'expéditeur et pose l'étiquette. Zéro devinette, zéro appel modèle, zéro coût. Ces mails n'atteignent jamais le petit modèle, encore moins le LLM.

Pourquoi ne pas laisser le modèle le faire ? Parce qu'une règle que tu lis vaut mieux qu'une prédiction que tu subis, quand la réponse est évidente. Une règle est stable, testable, et gratuite. Tu la gardes pour tout ce qui est certain.

Un petit modèle pour le milieu ambigu

Pour le reste, celui qui n'est pas évident, j'utilise deux vieilles techniques. Le TF-IDF et la régression logistique.

Le TF-IDF transforme un texte en chiffres. Chaque mot reçoit un poids selon sa fréquence, rare ou banal. La régression logistique est un modèle simple. Il apprend à séparer les catégories à partir de ces chiffres. Ensemble, ils font un classifieur de texte solide et léger.

Je l'entraîne en Python, avec scikit-learn, sur près de 5 800 mails étiquetés, répartis en 6 catégories. Puis je l'exporte dans un simple fichier JSON. Ce fichier pèse 2,4 Mo. À comparer au modèle de 7B, plusieurs gigaoctets et un GPU.

Le point clé : l'inférence est 100 % en Go. Pas de Python, pas de GPU, pas de dépendance C. Le code Go lit le JSON et prédit en bien moins d'une milliseconde, sur un simple CPU. Le tout tient dans mon image de production, une image distroless sans shell ni outils système.

Et la précision ? En validation croisée 5-fold, le modèle atteint 81 % de bonnes réponses. La validation croisée découpe les données en 5 paquets. On entraîne sur 4, on teste sur le 5e, à tour de rôle. C'est une mesure honnête, faite sur des mails jamais vus à l'entraînement. Le modèle est bon sur les catégories fréquentes et nettes, autour de 0,88. Il est plus faible sur les rares et floues, autour de 0,62.

Enfin, le modèle sort une confiance, entre 0 et 1. Au-dessus de 0,60, je lui fais confiance. En dessous, le mail passe à la couche suivante.

81 %, et alors ?

Tout seul, 81 % semble médiocre. Ce n'en est pas un, à cause de la cascade. Aucune couche ne doit être parfaite. Chaque couche doit juste faire ce qu'elle sait faire.

Les règles tranchent le facile, sans erreur. Le petit modèle décide le gros du milieu, avec confiance. Le LLM ne voit que la queue, les mails vraiment ambigus. L'appel cher devient rare. C'est tout l'intérêt.

Tu ne cherches pas un modèle parfait. Tu cherches un système où le coût suit la difficulté. Un mail évident coûte zéro. Un mail dur coûte un appel au cloud. Et il y a peu de mails durs.

Le piège de la parité des tokens

Il y a une partie délicate. Le modèle s'entraîne en Python, mais tourne en Go. La façon de découper le texte en tokens doit être identique des deux côtés. Byte pour byte.

Un token est un morceau de texte que le modèle compte, en général un mot. Si le découpage Go diffère du découpage Python, même un peu, le modèle voit des tokens qu'il n'a jamais appris. La précision chute en silence. Aucune erreur, juste des prédictions plus mauvaises.

Ma parade : un tokeniseur écrit à la main, dupliqué à l'identique dans les deux langages. Même expression régulière, même table pour retirer les accents, aucune bibliothèque unicode externe. Une table explicite, la même en Python et en Go.

// La meme table vit dans l'entraineur Python.
// Un seul ecart et le modele voit des tokens jamais appris.
var fold = map[rune]string{'é': "e", 'è': "e", 'ç': "c" /* table complete */}
var wordRE = regexp.MustCompile(`[a-z0-9]{2,}`)

func wordTokens(s string) []string {
	var b strings.Builder
	for _, r := range strings.ToLower(s) {
		if rep, ok := fold[r]; ok {
			b.WriteString(rep)
		} else {
			b.WriteRune(r)
		}
	}
	return wordRE.FindAllString(b.String(), -1)
}

Un test de parité compare les deux tokeniseurs sur du vrai texte. S'ils divergent, le build casse. Mieux vaut un build rouge qu'une précision qui fond sans prévenir.

La régression silencieuse qui m'a appris un garde-fou

Le modèle s'améliore avec une boucle simple. Quand l'agent range un mail au mauvais endroit, je le déplace dans le bon dossier. Ce déplacement devient une nouvelle étiquette. Je réentraîne, et le modèle apprend de ma correction.

Un jour, cette boucle m'a mordu. J'avais retrié mes mails à la main. Une catégorie est tombée sous le minimum d'exemples nécessaire pour l'apprendre. Le réentraînement l'a supprimée en silence. Le nouveau modèle ne savait plus la prédire. Toujours aucune erreur affichée.

La correction : le script d'entraînement refuse maintenant de laisser tomber une catégorie dont je dépends. Il s'arrête et me dit quel dossier s'est vidé.

# Le reentrainement refuse de perdre une categorie en silence.
python train.py --in labeled.jsonl --out model.json \
    --expect-labels "travail,notification,newsletter,promo,maison"
# ABORT: expected classes would be DROPPED from the model: [personnel]

La leçon dépasse le mail. Une régression silencieuse est pire qu'un crash. Un crash, tu le vois. Une précision qui baisse en douce, tu la découvres trop tard. Alors tu la rends bruyante, exprès.

Quand le LLM mérite sa place

Je n'ai pas supprimé le LLM. Je l'ai déplacé là où il gagne son coût. L'écriture.

Classer un mail est un problème fermé, avec peu de réponses. Écrire une réponse humaine est un problème ouvert. Et le langage libre, c'est exactement ce qu'un gros modèle fait mieux que tout. Alors quand l'agent doit rédiger un vrai message, un modèle cloud s'en charge.

Petit modèle pour la tâche fermée. Gros modèle pour la tâche ouverte. Le bon outil à chaque étage. Voilà la vraie leçon, pas « les LLM, c'est nul ». Les LLM sont excellents. Juste pas pour tout.

La checklist avant de coller un LLM sur une tâche

Avant d'appeler un gros modèle par réflexe, passe la tâche à ces questions.

  • La tâche a-t-elle un petit nombre de réponses stables ? Alors c'est de la classification, pas un LLM
  • Peux-tu écrire des règles pour les cas évidents ? Fais-les d'abord, elles sont gratuites
  • As-tu des exemples étiquetés ? Un petit modèle suffit sans doute
  • Le modèle a-t-il besoin de comprendre du langage libre, ou juste de choisir une étiquette ?
  • Peux-tu mesurer une précision honnête, en validation croisée ?
  • Le petit modèle tourne-t-il sans GPU, sur CPU, dans ton image de prod ?
  • Ton tokeniseur est-il identique à l'entraînement et à l'inférence, byte pour byte ?
  • Un garde-fou empêche-t-il une régression silencieuse au réentraînement ?
  • Gardes-tu le LLM pour ce qu'il fait vraiment mieux, générer du langage ?

Ce qu'il faut retenir

Le réflexe de 2026, c'est le gros modèle pour tout. Souvent, la tâche n'en a pas besoin. Une cascade coûte bien moins cher et tourne bien plus vite. Des règles pour l'évident. Un petit modèle pour le milieu. Le LLM pour la seule vraie difficulté.

Ce n'est pas un rejet de l'IA. C'est de l'ingénierie. Tu fais correspondre le coût à la difficulté, couche par couche. Tu construis un système IA et la facture grimpe ? Tu veux savoir où un petit modèle remplacerait ton LLM ? C'est exactement ce que je fais. Écris-moi. Garde le gros modèle pour ce qui le mérite.

TL;DR: most production AI tasks are not LLM tasks. To triage my email, I replaced a 7-billion-parameter model with a tiny classifier in Go. The rule fits in one sentence. Rules first, a small model next, the LLM only as a last resort. The result: no GPU, sub-millisecond inference, and a cloud call that became rare. Here is how, with the real numbers.

This article is for developers who put an LLM in production and pay the bill. Not a demo.

Most AI tasks are not LLM tasks

In 2026, the default reflex is to wire a big model into everything. A question comes in, you call the LLM. But many tasks do not need it. Filing an email under "work" or "newsletter" is classification. A problem solved for twenty years, long before LLMs.

To classify is to pick a label from a short, stable list. To generate text is something else. The first job needs a small model. The second earns a big one. The rule I defend fits in one sentence. Put the LLM last.

The setup

I built an agent that triages my inbox. It is a daemon. It reads new messages and files each one into a category: work, notification, newsletter, promo, and a few more. Nothing secret, just my real mailbox, with years of mail.

The first version handed every email to a local LLM. A 7-billion-parameter model, Qwen 2.5 7B, served by Ollama on a GPU. Ollama is a tool that runs an LLM on your own machine. It worked. But the price was heavy. A GPU on all the time. One more container to watch. And an absurd slowness for the question asked.

One day I asked myself: does deciding "is this a newsletter?" really need 7 billion parameters? No. The answer sits in two or three words from the sender and the subject. So I rethought the whole thing.

Three layers, from cheapest to most expensive

Every email goes through three layers, in order. It stops at the first one that can answer.

  • Deterministic rules. Instant, exact, no cost.
  • A small model. Sub-millisecond, on CPU.
  • The LLM. Only if the small model is unsure.

The routing code fits in a few lines. It tells the whole story.

func (c *Classifier) decide(m Message) Decision {
	// 1. Deterministic rules: obvious senders, decided at the door.
	if d := preClassify(m); d != nil {
		return *d
	}
	// 2. Small ML model: sub-millisecond, on CPU.
	pred := c.ml.Predict(m)
	if pred.Confidence >= c.threshold {
		return decisionFrom(pred)
	}
	// 3. LLM last: only the uncertain tail reaches the cloud.
	return c.classifyWithLLM(m)
}

The logic is simple. Each layer costs more than the one before. So each layer only handles what the others could not decide.

Rules skim off the easy mail

A large share of my mail is obvious from the sender alone. A known newsletter address is always a newsletter. A platform alert is always a notification. No intelligence required.

A short list of rules decides these at once. It looks at the sender domain and sets the label. Zero guessing, zero model call, zero cost. These emails never reach the small model, let alone the LLM.

Why not let the model do it? Because a rule you can read beats a prediction you cannot, when the answer is obvious. A rule is stable, testable, and free. You keep it for everything that is certain.

A small model for the ambiguous middle

For the rest, the part that is not obvious, I use two old techniques. TF-IDF and logistic regression.

TF-IDF turns text into numbers. Each word gets a weight based on how common or rare it is. Logistic regression is a simple model. It learns to separate categories from those numbers. Together they make a solid, light text classifier.

I train it in Python, with scikit-learn, on nearly 5,800 labelled emails across 6 categories. Then I export it to a plain JSON file. That file weighs 2.4 MB. Compare it to the 7B model: several gigabytes and a GPU.

The key point: inference is 100% Go. No Python, no GPU, no C dependency. The Go code reads the JSON and predicts in well under a millisecond, on a plain CPU. It all fits inside my production image, a distroless image with no shell and no system tools.

And the accuracy? In 5-fold cross-validation, the model reaches 81% correct. Cross-validation splits the data into 5 parts. You train on 4 and test on the 5th, in turn. It is an honest measure, taken on mail never seen during training. The model is strong on the frequent, clear categories, around 0.88. It is weaker on the rare, fuzzy ones, around 0.62.

Finally, the model returns a confidence, between 0 and 1. Above 0.60, I trust it. Below, the email moves to the next layer.

81%, so what?

On its own, 81% looks mediocre. It is not a problem, because of the cascade. No layer has to be perfect. Each layer just has to do what it is good at.

The rules decide the easy mail, without error. The small model handles the bulk of the middle, with confidence. The LLM only sees the tail, the truly ambiguous mail. The expensive call becomes rare. That is the whole point.

You are not chasing a perfect model. You are chasing a system where cost follows difficulty. An obvious email costs nothing. A hard email costs one cloud call. And there are few hard emails.

The token parity trap

There is one tricky part. The model trains in Python but runs in Go. The way you cut text into tokens must be identical on both sides. Byte for byte.

A token is a piece of text the model counts, usually a word. If the Go split differs from the Python split, even slightly, the model sees tokens it never learned. Accuracy drops in silence. No error, just worse predictions.

My fix: a hand-rolled tokenizer, duplicated exactly in both languages. Same regular expression, same table to strip accents, no external unicode library. One explicit table, the same in Python and in Go.

// The same table lives in the Python trainer.
// One divergence and the model sees tokens it never learned.
var fold = map[rune]string{'é': "e", 'è': "e", 'ç': "c" /* full table */}
var wordRE = regexp.MustCompile(`[a-z0-9]{2,}`)

func wordTokens(s string) []string {
	var b strings.Builder
	for _, r := range strings.ToLower(s) {
		if rep, ok := fold[r]; ok {
			b.WriteString(rep)
		} else {
			b.WriteRune(r)
		}
	}
	return wordRE.FindAllString(b.String(), -1)
}

A parity test compares the two tokenizers on real text. If they diverge, the build breaks. A red build beats an accuracy that melts without warning.

The silent regression that taught me a guard-rail

The model improves through a simple loop. When the agent files an email in the wrong place, I move it to the right folder. That move becomes a new label. I retrain, and the model learns from my correction.

One day, this loop bit me. I had re-sorted my mail by hand. One category fell below the minimum number of examples needed to learn it. The retrain dropped it in silence. The new model could no longer predict it. Still no error shown.

The fix: the training script now refuses to drop a category I rely on. It aborts and tells me which folder ran dry.

# Retraining refuses to lose a category in silence.
python train.py --in labeled.jsonl --out model.json \
    --expect-labels "work,notification,newsletter,promo,home"
# ABORT: expected classes would be DROPPED from the model: [personal]

The lesson goes beyond email. A silent regression is worse than a crash. A crash, you see. An accuracy that quietly drops, you find out too late. So you make it loud, on purpose.

When the LLM earns its place

I did not delete the LLM. I moved it to where it earns its cost. Writing.

Classifying an email is a closed problem, with few answers. Writing a human reply is an open one. And free language is exactly what a big model does better than anything. So when the agent has to draft a real message, a cloud model handles it.

Small model for the closed task. Big model for the open task. The right tool at each stage. That is the real lesson, not "LLMs are bad". LLMs are excellent. Just not for everything.

The checklist before you reach for an LLM

Before you call a big model on reflex, run the task through these questions.

  • Does the task have a small set of stable answers? Then it is classification, not an LLM
  • Can you write rules for the obvious cases? Do them first, they are free
  • Do you have labelled examples? A small model is probably enough
  • Does the model need to understand free language, or just pick a label?
  • Can you measure honest accuracy, with cross-validation?
  • Does the small model run without a GPU, on CPU, inside your production image?
  • Is your tokenizer identical at training and inference, byte for byte?
  • Does a guard-rail prevent a silent regression on retrain?
  • Do you keep the LLM for what it truly does better, generating language?

What to remember

The 2026 reflex is the big model for everything. Often, the task does not need it. A cascade costs far less and runs far faster. Rules for the obvious. A small model for the middle. The LLM for the only real difficulty.

This is not a rejection of AI. It is engineering. You match cost to difficulty, layer by layer. Building an AI system and watching the bill climb? Want to know where a small model would replace your LLM? That is exactly what I do. Write to me. Keep the big model for what deserves it.