Le modèle qui perd des chiffres : choisir l'architecture d'un LLM par maillon agentique
Il y a quelques jours, j'ai cliqué sur un lien fabriqué par mon assistant local. 404. C'était une URL de tweet : https://x.com/karpathy/status/204990382105354523. Le tweet d'origine, lui, était bien là sous une URL voisine : https://x.com/karpathy/status/2049903821095354523. La différence : dix-huit chiffres dans la première, dix-neuf dans la seconde. Un chiffre avait disparu au milieu de l'identifiant.
L'assistant, c'est mAIstrow, le client desktop que j'utilise au quotidien et qui tourne sur mon propre moteur d'inférence (herbert-rs) avec Granite-4.1-30B en Q4 comme backbone. L'outil web_fetch a reçu l'URL tronquée et il a fait ce qu'il devait faire : il a appelé l'URL et il a renvoyé un 404.
Première réaction : c'est mon code. Je suis en train d'écrire un moteur d'inférence à partir de zéro, j'ai du sampling, du tokenizer et de la gestion de KV cache à debugger en permanence. Une perte d'un chiffre au milieu d'une chaîne, ça ressemble à une erreur de pointeur ou à un sampler mal câblé. J'ai ouvert le bug bundle, j'ai regardé la trace : la réponse du modèle était déjà arrivée tronquée du côté de mAIstrow. Le sampler n'avait rien à se reprocher. Le modèle, en sortie de génération, avait écrit littéralement dix-huit chiffres au lieu de dix-neuf.
Avant le bench, un mot sur le titre. Quand je parle de « système d'agents IA », je parle d'un système qui enchaîne plusieurs appels au modèle pour une même requête utilisateur (extraire un argument, appeler un outil, synthétiser un résultat, suivre un format), pas d'une orchestration de plusieurs agents qui collaborent. L'article défend une seule idée : l'archi du modèle qui domine n'est pas la même d'un maillon à l'autre. Ce qui a cassé sur mAIstrow, c'est exactement le maillon de la copie verbatim.
Reproduction minimale
Pour sortir herbert-rs de l'équation, j'ai relancé exactement le même prompt sous llama-server (llama.cpp build b8680), avec le même quant Q4_K_M de Granite-4.1-30B et temperature=0. Même réponse. Un chiffre en moins, au même endroit.
Donc ce n'est pas mon code. Ce n'est pas non plus du sampling stochastique : à temperature=0, le modèle est déterministe sur ce prompt. Si je relance, j'ai la même mauvaise sortie. Toujours.
Le prompt, lui, est explicite à un niveau presque insultant pour le modèle :
Extract the numeric status ID from this URL and return it as JSON in the form
{"id": "<digits>"}. Copy the digits EXACTLY: do not abbreviate, round, or omit any digit. Respond with the JSON only, no prose.
L'URL est dans le contexte. Trois tokens plus haut. Le modèle a juste à pointer dessus. Il rate.
Ce n'est pas une hallucination de connaissance : c'est une faute de copie sur une chaîne présente dans le contexte. Cette catégorie d'erreur a un nom et elle a une littérature.
Le bench
J'ai écrit un bench minimal en cinquante lignes de Python. Pour cinq longueurs (8, 12, 16, 19, 22 chiffres), vingt identifiants aléatoires par longueur, le même prompt, un seul tour utilisateur, temperature=0. Comparaison caractère par caractère. Et pour ne pas rester sur du synthétique, j'ai aussi pris vingt-et-une vraies URLs de tweets publics (@tenstorrent, @elonmusk, @ggerganov, @karpathy) que n'importe qui peut cliquer pour vérifier l'identifiant attendu.
Voilà ce que ça donne sur sept modèles, tous via le même backend llama-server :
| Modèle | Architecture | Quant | Erreur (vraies URLs) |
|---|---|---|---|
| granite-4.1-30B | Mamba2-hybride (36/4) | Q4_K_M | 24% (5/21) |
| granite-4.1-8B | Mamba2-hybride | BF16 | 88% |
| granite-4.0-1B | Transformer dense | BF16 | 0% |
| Qwen3-VL-2B | Transformer dense | Q4_K_M | 0% |
| Qwen3.6-27B | Hybride DeltaNet 3:1 | Q4_K_M | 0% |
| LFM2-2.6B | Hybride shortconv (~33% attn) | Q4_K_M | 0% |
| gpt-oss-20b | Transformer dense (MoE) | F16 | 0% |
| gemma-4-26B-A4B | Transformer dense (MoE) | Q4_K_M | 0% |
| SmolLM3-3B | Transformer dense | Q4_K_M | 0% |
Le pattern saute aux yeux. Tout ce qui est Granite-4-H (les hybrides Mamba2 vanilla) rate. Tout le reste passe, indépendamment de la taille (de 2B à 27B), du quant (Q4 ou BF16) ou de la famille.
La ligne qui isole vraiment la variable, c'est Granite-4.0-1B : le plus petit modèle de la même famille, en BF16, en Transformer dense classique (40 couches d'attention, zéro Mamba). Cent pour cent juste sur le bench. Même tokenizer, même pipeline d'entraînement, même équipe. Seule différence : l'architecture.
Sur les longueurs synthétiques, le profil de Granite-4.1-30B suit une courbe régulière : 10% d'erreur à 8 chiffres, 50% à 16 chiffres, 55% à 19 chiffres, 30% à 22 chiffres. Granite-4.1-8B est encore pire, jusqu'à 100% sur certaines longueurs. Le modèle plus petit en pleine précision se trompe plus que le grand modèle quantifié. La quantification n'est pas le problème.
Quatre hypothèses falsifiées
Avant d'arriver à "c'est l'architecture", j'ai traversé quatre hypothèses. Aucune n'a tenu plus de dix minutes de bench, mais chacune était plausible quand je l'ai posée.
Le tokenizer. Granite utilise un BPE numérique en chunks de 1 à 3 chiffres (regex \p{N}{1,3}), comme GPT-3.5 et GPT-4. Llama-3, Qwen3 et Gemma-4 sont sur du single-digit (\p{N}). On sait depuis Singh & Strouse 2024 (arXiv:2402.14903) que ce choix dégrade l'arithmétique. Hypothèse : il dégrade aussi la copie. Falsification : gpt-oss-20b utilise exactement la même regex (vérifié dans son tokenizer.json) et copie 100/100. Le tokenizer seul n'explique pas un taux d'erreur de 36%.
La quantification. Q4_K_M est agressif. Peut-être que les poids déquantifiés perdent trop de précision pour porter une chaîne de dix-neuf chiffres exacte. Falsification : Granite-4.1-8B en BF16, donc sans aucune quantification, fait pire (88%) que Granite-4.1-30B en Q4 (36%). Le modèle plus petit en pleine précision rate plus que le grand modèle quantifié.
La taille. Peut-être que Granite est juste un modèle de moyenne gamme un peu faible sur cette tâche et qu'à 30B on n'a pas assez de capacité. Falsification : Granite-4.0-1B, le plus petit de la famille, fait 0%. Et Qwen3-VL-2B, deux fois plus petit que Granite-4.0-1B, fait aussi 0%. La taille n'est pas la variable.
L'entraînement. Peut-être que la famille Granite a été optimisée pour des tâches d'instruction et de RAG, pas pour de la copie verbatim. Falsification partielle : Granite-4.0-1B, avec exactement le même pipeline d'entraînement, copie parfaitement. Donc la famille sait copier. Ce sont les variantes hybrides qui n'y arrivent pas.
Reste une variable que je n'avais pas isolée : l'architecture. Granite-4.0-1B est un Transformer dense pur. Granite-4.1-8B et 30B sont en Mamba2-hybride avec un ratio de 36 couches Mamba2 pour 4 couches d'attention.
Le théorème qui prédit le bug
À ce stade je me suis dit qu'il devait y avoir de la littérature. Il y en a. Trois papiers convergent.
Jelassi, Brandfonbrener, Kakade, Malach (Harvard / Kempner Institute, ICML 2024). Repeat After Me: Transformers are Better than State Space Models at Copying (arXiv:2402.01032). Le résultat principal est formel et fort :
"We prove two results. First, we show that a small Transformer can be used to copy extremely long sequences. Second, we show that any language model with fixed-size memory (i.e., any SSM) fails to copy long random strings."
Et leur tâche d'évaluation s'appelle phonebook retrieval : on donne un annuaire au modèle, on lui demande le numéro d'une personne. C'est strictement la même tâche que la mienne (extraire un identifiant numérique long depuis un contexte). Le 88% d'erreur de Granite-4.1-8B est la réalisation empirique de leur théorème.
L'intuition est information-théorique, pas implémentaire.
L'attention d'un Transformer alloue une mémoire de taille O(n × d) : chaque token du contexte a son propre slot. Copier un identifiant trois tokens plus haut, c'est juste pointer le bon slot.
Un SSM type Mamba a un état latent de taille fixe (d_state=256 pour Granite-4). Compresser une chaîne aléatoire de dix-neuf chiffres dans cet état puis la ressortir bit-perfect est borné par le principe des tiroirs. Le modèle doit apprendre à allouer son état précisément ; cet apprentissage ne généralise pas au-delà de la distribution d'entraînement. On ne corrige pas ça avec du meilleur C++. C'est une propriété de l'architecture.
Yang, Kautz, Hatamizadeh (déc 2024). Gated Delta Networks: Improving Mamba2 with Delta Rule (arXiv:2412.06464). Ce papier précise une nuance importante : Mamba2 vanilla est connu pour être faible en retrieval. Il existe une variante (Gated DeltaNet) qui répare ce trou en combinant un mécanisme de gating et une règle de mise à jour delta. Qwen3-Next, Qwen3.5 et Qwen3.6 utilisent Gated DeltaNet à un ratio 3:1 (75% DeltaNet, 25% attention). Mon bench le confirme : Qwen3.6-27B copie sans erreur.
Et LFM2 de LiquidAI prend une troisième voie : il intercale ses couches d'attention avec des convolutions causales courtes (shortconv, l_cache=3, donc chaque conv ne voit que les trois derniers tokens). Ces conv-layers ne cherchent pas à faire du retrieval, elles sont trop courtes pour ça. Ce sont les couches d'attention (33% du modèle) qui s'en chargent. Résultat : 0% d'erreur sur le même bench.
La lecture qui colle aux quatre points de données :
| Architecture | Composant non-attention | Tente du retrieval ? | Bench |
|---|---|---|---|
| Transformer dense | (aucun) | (sans objet) | ✅ |
| LFM2 (LiquidAI) | shortconv l_cache=3 | Non, trop court | ✅ |
| Qwen3-Next / 3.5 / 3.6 | Gated DeltaNet | Oui, conçu pour | ✅ |
| Granite-4-H | Mamba2 vanilla | Oui, mais ne sait pas | ❌ |
"Hybride = mauvais" est trop grossier. Le bon résumé : si le composant non-attention tente d'absorber le retrieval, il a intérêt à être conçu pour (DeltaNet) ; sinon, on le garde superficiel (LFM2) et on laisse l'attention faire son travail. Granite-4-H tombe dans le trou : Mamba2 vanilla (le SSM le plus faible en recall) plus le ratio d'attention le plus bas (10%). C'est le double pire choix.
Et IBM le savait. Dans la doc officielle Granite (ibm.com/granite/docs/models/granite), table des variantes :
Granite-4.0-Micro / 1B / 350M (Traditional, Dense): "Alternative option for users when Mamba2 support is not yet optimized (e.g. llama.cpp, PEFT, etc)"
IBM publie exprès des variantes denses dans la même famille parce qu'ils savent que les hybrides cassent dans les stacks d'inférence actuels. Le phrasing parle d'optimisation d'implémentation, mais le résultat de Harvard de 2024 prédisait déjà le problème indépendamment de l'implémentation. J'avais Granite-4-H comme backbone principal de mAIstrow depuis des semaines. J'avais raté la note dans la doc.
Un système d'agents IA est une chaîne de maillons
Un système d'agents IA, ce n'est pas un appel à un LLM. C'est une chaîne d'appels spécialisés qui se passent le résultat l'un à l'autre. Quand l'utilisateur dit "résume-moi ce thread Twitter et range-le dans mon dossier veille", il y a typiquement :
- Une compréhension d'intention (le modèle décide quel outil appeler).
- Une extraction d'arguments (l'URL du thread, le nom du dossier).
- Un appel à un outil externe (
web_fetchqui va chercher le thread). - Une compression / synthèse (le modèle résume ce qui revient).
- Un suivi de format (sortir une note structurée selon un template).
- Un dernier appel d'outil pour écrire le fichier.
Chacun de ces maillons mobilise une compétence différente. L'extraction d'arguments demande de la copie verbatim. La synthèse demande du raisonnement et de la compression. Le suivi de format demande de la discipline syntaxique. Le tool calling demande une connaissance précise des signatures et des types.
Ces compétences ne sont pas alignées sur les benchmarks génériques. Un modèle qui domine MMLU ou GPQA peut très bien échouer en copie verbatim. Granite-4.1-30B fait des scores honnêtes en raisonnement et 24 à 88% d'erreur en copie. Inversement, un modèle 2B en Q4 qui ferait pâle figure sur GPQA est parfaitement adéquat pour extraire un identifiant numérique d'un contexte de mille tokens.
L'erreur que j'ai faite et qui est très facile à faire quand on commence à industrialiser, c'est de prendre un modèle et de le coller à toutes les étapes. Granite-4.1-30B est un bon modèle généraliste. Il est compétitif en raisonnement, en code et en multilingue. Il s'écroule sur la copie. Si l'extraction d'arguments est sur le chemin critique de mon système, je n'ai pas besoin d'un meilleur prompt : j'ai besoin d'un autre modèle pour ce maillon-là.
Quand cette discipline en vaut la peine
Sectionner un système par maillon a un coût : maintenir plusieurs modèles, les charger, les router, garder les benchmarks à jour. Quand est-ce que ça en vaut la peine ?
Pour moi, le critère est simple : est-ce que le système fait plusieurs appels de la même structure, plusieurs fois par jour, sur des entrées qui se ressemblent ? Si oui, on est dans la zone où la discipline rapporte plus qu'elle ne coûte. Si non, "un seul gros modèle pour tout" est plus que raisonnable.
Concrètement :
- Prototype, POC, test de marché. Pas la peine. Prendre Claude Sonnet ou un Gemini Pro et avancer. Si le système ne survit pas trois semaines, on n'aura pas amorti l'effort de le sectionner.
- Assistant personnel utilisé tous les jours, qui enchaîne des chaînes d'outils. On est dans la zone. Identifier les deux ou trois maillons les plus fréquents, les bencher sur la tâche réelle, choisir le modèle par maillon. Le gain compose : moins d'erreurs, moins de retries, moins de latence sur les bouts triviaux.
- Produit qui fait des milliers d'appels par jour pour un usage industriel. Non négociable. À ce volume, le coût d'un modèle 30B sur des extractions d'IDs est un gaspillage de calcul ; le coût d'un modèle qui se trompe une fois sur quatre est un gaspillage de retries et de fiabilité perçue.
La méthode de bench, elle, est triviale : on prend la tâche réelle (pas une approximation), on la fait tourner sur trois ou quatre modèles candidats, on mesure ce qu'on veut mesurer (taux de réussite, latence, coût). Cinquante lignes de Python suffisent. Le piège n'est jamais technique : il est cognitif, c'est de croire qu'on sait avant d'avoir mesuré. Sur cette histoire de copie, j'aurais juré jusqu'à dix minutes avant le bench que la quantification ou le tokenizer étaient en cause. Les deux étaient faux.
Pour les autres maillons
Pour les autres maillons d'un système d'agents IA, voilà les archis qui dominent les benchmarks publics, à titre de pointeurs (pas de démonstration ici) :
- Raisonnement et math : modèles avec mode "think" (GPT-OSS, Gemma 4, Nemotron Nano v2). Repère : GPQA Diamond, AIME.
- Tool calling pur : modèles spécialisés type xLAM-8B ou Hammer-7B, qui battent souvent des généralistes plus gros sur la signature et les types. Repère : Berkeley Function Calling Leaderboard (BFCL).
- Long contexte au-delà de 256K : hybrides Mamba/MoE comme Nemotron 3 Nano (RULER 86.3% à 1M, le meilleur public à ce jour). Repère : RULER, pas la valeur de
max_position_embeddings. - GUI agents : UI-TARS-7B, qui bat Claude sur ScreenSpot à 7B et sous Apache 2.0.
J'entretiens une liste plus complète des modèles open-weight exploitables (licence commerciale, moins de 200B paramètres, sortis après avril 2024) sur github.com/xigh/open-weight-models. Le bench complet sur la copie verbatim, avec les sources et la méthodo détaillée, est sur github.com/xigh/llm-copy-bench. herbert-rs travaille sur l'autre bout de la chaîne : faire tourner ces modèles localement, en Rust et en assembleur, pour ne pas dépendre d'une API.
Le bug que j'ai vu en cliquant sur un lien 404 est un théorème de 2024 que j'aurais pu connaître avant. Une raison de plus pour qu'un assistant agentique sérieux ne repose pas sur un seul modèle pour tous ses maillons.