Un petit modèle bien guidé peut‑il suffire ? De 47 % à 97,2 %
Si Whisper a ramené la reconnaissance vocale en local, pourquoi pas la compréhension du langage appliquée aux données métier ? L’expérience montre que le gain vient surtout de ce qu’on retire au modèle, bien plus que de sa taille.

Tout est parti d’une question presque naïve. La voici telle qu’elle s’est posée, avant de donner lieu à une expérience plus complète.
1. La course aux gros modèles, et une intuition venue de Whisper
On vit une époque étrange. Chaque semaine annonce un modèle plus gros, plus puissant, plus gourmand. Et dans le monde professionnel, le réflexe est devenu : dès qu’un besoin d’IA apparaît, on appelle un gros modèle, souvent via une API cloud, et on paie à la consommation de tokens.
Mais il existe une contre-histoire, celle de Whisper.
La reconnaissance vocale, il y a quelques années, c’était du cloud. On envoyait l’audio chez un prestataire, il renvoyait le texte. Puis Whisper est arrivé : un modèle capable de tourner en local, sur une machine de dev, sans datacenter. Et pour une énorme partie des usages, ça suffit amplement.
Pourquoi ça a marché ? Parce que le besoin était bien délimité : comprendre la parole, la transcrire. Pas de connaissance générale, pas de raisonnement complexe. Une compréhension du langage appliquée à une tâche précise, avec les données que la machine a déjà sous la main.
Alors la question s’est imposée : si la reconnaissance vocale peut tourner localement, pourquoi pas la compréhension de langage appliquée aux données métier d’une application ?
Est-ce qu’un petit modèle, à partir du moment où il est bien guidé, ne pourrait pas résoudre déjà 90 % des besoins, sans mobiliser un datacenter, sans consommer des tokens et de l’énergie pour chaque petite question ? Parce qu’au fond, la plupart du temps, on consomme tout ça juste par flemme de formuler, de structurer, de chercher ce qu’on a déjà dans sa base.
2. Le cas d’usage : un chatbot de support client
Pour tester cette idée, l’expérience avait besoin d’un cas concret. Le choix s’est porté sur le classique : le chatbot de support client d’une boutique e‑commerce.
De prime abord, c’est le genre de besoin qui semble exiger le grand jeu : comprendre la discussion, aller chercher les informations sur le client, ses commandes, ses livraisons, croiser tout ça, et donc, logiquement, passer par des modèles intelligents, du tool calling, du RAG, des allers-retours entre le modèle et la base.
Mais l’expérience est repartie de la base du concept, plutôt que de suivre la mode.
Un LLM, ce n’est pas un système de recherche d’information. C’est un moteur de langage. Son métier, c’est comprendre le sens d’un texte, d’un contexte. Et si on lui fournit suffisamment de contexte, les bonnes données bien présentées, il est peut-être capable, sans aller-retour, sans réflexion, sans outil, de comprendre une demande et d’y répondre directement.
Pour répondre à « Où en est ma dernière commande ? », le système doit :
- comprendre qu’il s’agit d’une question sur les commandes ;
- identifier le client ;
- récupérer ses données, ce que l’application sait déjà faire ;
- formuler une réponse.
Le seul besoin en langage, c’est l’étape 1. Tout le reste, c’est de l’accès aux données, le métier même de l’application. Si on donne au modèle un contexte bien construit et la question, il n’a plus qu’à lire et à répondre.
L’app connaît déjà tout. Le LLM ne doit pas chercher l’information, il doit juste la comprendre et la reformuler.
C’est exactement la logique de Whisper : le modèle n’a pas besoin de « connaître » le monde, juste de comprendre un signal et de le transformer.
3. Les premiers choix : Symfony, et une question d’infrastructure
Une fois le cas d’usage posé, il fallait décider où ferait tourner ce modèle. Le terrain de l’expérience, c’est une application PHP / Symfony. Et là, le premier réflexe a été le même que tout le monde : Ollama.
Un container à part, une API HTTP, l’app appelle. C’est la solution « propre » habituelle. Mais quelque chose clochait : si l’idée, c’est d’être autonome, et que Whisper tourne dans l’application métier elle-même, pourquoi le modèle ne pourrait‑il pas faire pareil ?
La question a donc été renversée : est-ce que PHP permet de charger un modèle ?
Réponse : oui, avec ext-infer, une extension PHP qui embarque llama.cpp (le moteur d’inférence C++ open source) directement dans le process. Model::load() charge un modèle GGUF, et on génère une réponse comme on appelle n’importe quelle fonction PHP. Pas de réseau, pas de serveur séparé.
Ça marchait… en théorie. Car le premier test a fait apparaître une évidence : le modèle se rechargeait à chaque appel.
PHP classique a un cycle de vie simple : un process par requête, tué à la fin. Rien ne survit. Un modèle d’environ 1,8 Go rechargé à chaque requête, c’est plusieurs secondes de chargement, une RAM qui sature, une expérience inutilisable.
La solution s’est imposée : FrankenPHP en worker mode. C’est un serveur PHP qui garde les process vivants entre les requêtes. Le worker démarre une fois, charge le modèle une fois, et sert toutes les requêtes qui arrivent. Le log de démarrage le confirme, une seule fois pour toute la vie du process :
[info] Loading LLM model (/models/Qwen3-1.7B-Q8_0.gguf)...
[info] Model loaded in 2450 ms
Et là, la première vraie victoire : ça marche. On parle à un modèle qui vit dans le process PHP, qui reste en mémoire, qui ne redémarre jamais. Un modèle de langage, dans une application Symfony, comme Whisper dans son application métier.
Le flux complet, orchestré par un service ChatService d’une quarantaine de lignes, se déroule ainsi :
customerId + question
↓
CustomerContextBuilder (Doctrine) → JSON métier
↓
ChatPromptBuilder → prompt système + CURRENT_DATE
↓
LocalLlmService → inférence (in-process, zéro appel externe)
↓
answer + metrics
L’hypothèse technique est validée. Il est temps de s’attaquer au vrai sujet : répondre aux questions des clients.
4. Construire le terrain de jeu : les données injectées
Il fallait des données réalistes et, surtout, une « vérité » connue pour pouvoir évaluer les réponses. L’expérience s’appuie sur un modèle de données e‑commerce complet, injecté par fixtures avec Doctrine dans une base SQLite (seed Faker fixe, donc reproductible à l’identique) :
- 500 clients, avec 3 profils connus à identifiant fixe : Alice Martin en gold avec un scénario précis de 6 commandes, un « heavy customer » platinum avec 100 commandes sur 2 ans, et un client bronze sans aucune commande ;
- 500 produits (audio, informatique, mobile, maison, sport) ;
- 2 500 commandes, avec des statuts variés (en attente, payée, en préparation, expédiée, livrée, annulée, remboursée), des articles et des quantités ;
- des livraisons cohérentes avec les statuts (DHL, Chronopost, Colissimo, UPS, FedEx), avec suivi, dates d’expédition, livraison estimée et livraison effective.
Prenons Alice Martin, le scénario de contrôle : 6 commandes étalées sur 3 mois, une en préparation sans colis, une expédiée en transit, trois livrées, une annulée. Ce profil contient tous les pièges qu’un modèle peut rater : une commande récente sans colis, une « dernière commande » qui n’est pas celle avec un colis, des dates relatives, une annulation.
5. Le premier test : le prompt naïf
Vient la question centrale : comment demander au modèle de répondre ?
Le point de départ était simple, comme toujours : un prompt générique, et les données brutes collées dedans. Un JSON avec des dates ISO, des statuts anglais bruts, sans tri documenté, sans position, sans comptage :
{"orders": [
{"number": "ORD-2026-00003", "date": "2026-08-09", "status": "preparing", "total": 34.99},
{"number": "ORD-2026-00001", "date": "2026-08-06", "status": "shipped", "total": 249.90},
{"number": "ORD-2026-00002", "date": "2026-07-21", "status": "delivered", "total": 89.99}
]}
avec un prompt du genre :
Answer the customer question using the JSON data.
Question: What is my latest order?
Le résultat était mauvais, très mauvais. Sur un benchmark de 107 questions générées depuis la vérité en base, le modèle répondait correctement dans 47 % des cas. Moins d’une sur deux.
Les erreurs étaient parlantes. « What is my latest order? » pouvait renvoyer n’importe quelle commande de la liste. « How many orders do I have in total? » répondait 5 au lieu de 6. « Is my latest order delivered? » répondait oui alors que la commande la plus récente était en préparation. Et dans les contextes riches, le modèle inventait des numéros de commande plausibles mais faux.
En regardant les données d’un œil neuf, la cause était évidente. Le modèle devait deviner tout ce que le contexte n’explicitait pas : que la liste était triée, que le statut brut shipped avait un sens métier précis, que l’absence de livraison signifiait « pas de colis », que le total des commandes se comptait.
Un petit modèle ne devine pas. Il comble les vides avec de l’invention.
6. Le déclic : prédigérer les données, structurer le prompt
Le CustomerContextBuilder a été réécrit pour que tout le travail de réflexion soit fait par PHP, avant d’atteindre le modèle. Voici un extrait du JSON que reçoit aujourd’hui le modèle pour Alice Martin, capturé tel quel depuis l’application :
{
"currentDate": "2026-08-11",
"customer": { "id": 1, "firstname": "Alice", "lastname": "Martin", "loyaltyLevel": "gold" },
"ordersCount": 6,
"orders": [
{
"position": 1,
"number": "ORD-2026-00003",
"dateEn": "9 August 2026",
"daysAgo": 2,
"statusEn": "being prepared",
"items": [{"product": "Portable Speaker", "quantity": 1}],
"shipment": null
},
{
"position": 2,
"number": "ORD-2026-00001",
"dateEn": "6 August 2026",
"daysAgo": 5,
"statusEn": "shipped",
"items": [{"product": "Bluetooth Headset", "quantity": 1}],
"shipment": {
"carrier": "DHL",
"statusEn": "in transit",
"estimatedDeliveryEn": "13 August 2026"
}
}
]
}
Chaque champ ambigu a été remplacé par une information que le modèle n’a plus qu’à lire :
- position : chaque commande numérotée, 1 = la plus récente. Le tri devient explicite.
- daysAgo et dateEn : l’ancienneté et la date lisible. Plus de date ISO à interpréter.
- statusEn : le statut lisible (« being prepared » au lieu de
preparing). - ordersCount : le nombre total déjà calculé. Plus besoin de compter.
- shipment: null : explicite quand une commande n’a pas de colis, pour que le modèle ne la rattache jamais au colis d’une autre commande.
- currentDate : la date du jour en tête, pour ancrer les calculs relatifs.
Le prompt système, lui, enseigne la structure au modèle et lui interdit de réfléchir. Voici celui qui tourne en production :
You are an e-commerce customer support assistant. Answer DIRECTLY, without inner thinking,
in one or two short sentences, in English. Use ONLY CUSTOMER_CONTEXT. Never invent anything.
The JSON contains ordersCount and orders sorted newest->oldest (position 1 = latest order).
Each order has: number, dateEn, daysAgo, statusEn, items, shipment (null = no package).
- latest order = position 1
- order state = statusEn
- total orders = ordersCount
- "my package" = the most recent shipment that is NOT null
- shipment null = no package for that order
CURRENT_DATE: 2026-08-11
Le résultat a été immédiat : de 47 % à 85 % de bonnes réponses, avec le même modèle, sur la même machine. Seule la forme des données avait changé.
C’est là que la notion de « bien guidé » a pris tout son sens. Un petit modèle ne raisonne pas : il consomme ce qu’on lui donne. Toute l’intelligence doit être dans les données (trier, traduire, compter, expliciter l’absence) pour ne laisser au modèle que ce qu’il sait faire : comprendre la question et lire la réponse dans un contexte structuré.
7. Le thinking : comprendre, pas réfléchir
À ce stade, un autre problème se profilait : la latence. Qwen3 a un mode de raisonnement intégré. Sur certaines questions, le modèle « réfléchissait » pendant 3 à 5 minutes avant de répondre. Sur CPU, c’est inutilisable.
Dans cette architecture, le modèle n’a rien à déduire. Toutes les informations sont dans le contexte. Il doit comprendre et répondre, pas réfléchir. Le raisonnement, c’est le travail de PHP.
C’est ce que fait la première ligne du prompt : Answer DIRECTLY, without inner thinking. Effet mesuré : une latence divisée par 10 sur les cas pathologiques (de 3 à 5 minutes à environ 22 secondes), et une précision en hausse, car le modèle arrête de « réfléchir à côté ».
Un échange réel, capturé sur l’application en marche :
POST /api/chat
{"customerId": 1, "message": "Where is my latest order?"}
{
"answer": "Your latest order, ORD-2026-00003, was shipped on 9 August 2026 and is currently being prepared.",
"metrics": {
"durationMs": 24893.6,
"promptTokens": 904,
"generatedTokens": 31,
"model": "Qwen3-1.7B-Q8_0.gguf"
}
}
904 tokens de contexte, 31 tokens générés, 25 secondes sur CPU, zéro appel externe.
C’est une belle leçon de proportionnalité : le thinking est un outil précieux pour les problèmes ouverts, mais quand la réponse est déjà dans les données, c’est du temps et de l’énergie gaspillés.
8. Le benchmark : mesurer pour améliorer
Tout ce qui précède n’aurait aucune valeur sans une mesure rigoureuse. L’expérience s’appuie donc sur un benchmark de 107 questions générées depuis la vérité en base, couvrant tous les pièges possibles :
- « What is my latest order? » vérifie le numéro exact de la dernière commande ;
- « Where is my package? » vérifie que le modèle vise le colis le plus récent, pas la commande la plus récente sans colis ;
- « When will my order arrive? » vérifie la date de livraison estimée ;
- « Which order contains a keyboard? » retrouve une commande par son produit ;
- « How many cancelled orders do I have? » teste un comptage filtré.
Pour un client sans aucune commande, « Where is my latest order? » doit répondre qu’il n’y a aucune commande, pas inventer un numéro.
Chaque cas attend des réponses acceptables (le numéro exact, « yes » ou « no », une date au bon format), et l’évaluateur accepte les variantes de formulation pour ne pas sous-estimer le modèle.
9. Pousser plus loin : le LoRA
À 85 %, le prompt seul plafonnait. Les erreurs restantes étaient des raisonnements métier plus fins : comparer des dates relatives, identifier « la dernière commande » quand plusieurs sont livrées.
Deux options : empiler encore des règles dans le prompt, au risque de le rendre illisible, ou apprendre ces patterns au modèle lui-même, avec un fine-tuning LoRA.
Là encore, le mécanisme a d’abord été validé : PHP charge bien un LoRA dans le process, et l’inférence applique l’adaptateur. La chaîne de conversion (MLX vers GGUF) a réservé quelques surprises, mais elle est passée.
Model::load($gguf, ['lora_path' => $lora.gguf])
Reste la question centrale : quelles données pour l’entraîner ?
Le premier réflexe a été le mauvais : générer des paires question → réponse depuis la base du projet. Le LoRA mémorisait les réponses au lieu d’apprendre la logique, et le benchmark, construit sur la même base, mesurait de la mémorisation. Leçon : ne jamais entraîner sur les données qu’on va évaluer.
La solution est venue d’un générateur de scénarios e‑commerce fictifs : 600 clients inventés, 1 à 12 commandes chacun, 22 types de questions avec 4 à 8 formulations, des numéros de commande garantis sans collision avec la vraie base. Au total 43 788 échantillons, au même format de contexte que la production. Le modèle apprend la logique et le format, jamais une réponse mémorisée.
Entraînement sur Apple Silicon avec MLX, export GGUF, chargement dans le process PHP : de 85,0 % à 87,9 %, à latence inchangée.
10. Et le 0,6B qu’on avait abandonné ?
Au début, le Qwen 0,6B avait servi à valider la mécanique, puis il avait été laissé de côté : trop petit, pas assez précis. Mais il n’avait été mesuré qu’avec le prompt naïf et le JSON brut. Le 1,7B avait profité de toutes les optimisations ; le 0,6B, lui, était resté coincé avec les données d’origine.
Avec le même prompt final, il atteignait 71 %. C’est l’examen détaillé de ses échecs qui a révélé le vrai problème :
| Réponse du 0,6B | Pourquoi elle était comptée fausse |
|---|---|
| « The latest package arrives on August 15, 2026. » | L’évaluateur ne connaissait qu’un format de date. La date était correcte. |
| « The most recent order is currently in transit. » | L’évaluateur cherchait le mot « shipped ». « In transit », c’est le même statut. |
| « The order with a keyboard is position 3. » | L’évaluateur cherchait un numéro de commande, mais la position 3 désigne précisément cette commande. |
Ce n’était pas le modèle qui était mauvais. C’était l’évaluateur qui était injuste. Il a été corrigé : plusieurs formats de date, des synonymes de statuts avec une garde contre les négations, et la résolution des positions avec une garde contre les contradictions.
Même benchmark, même modèle, même prompt : le 0,6B passe à 93,5 %. Pour être honnête, 15 réponses sont créditées grâce à la résolution de position ; en texte pur, il est à 79,4 %. Le 1,7B, lui, répond en toutes lettres et atteint 94,4 % avec le même évaluateur. Un point d’écart, pour presque moitié moins de latence (11 secondes contre 18 en médiane).
Et le LoRA ? Sur le 0,6B, il fait régresser le score à 87,9 % : il a appris à répondre par un numéro de commande aux questions de statut. Avec l’évaluateur corrigé, le LoRA du 1,7B ne fait plus gagner non plus (de 94,4 % à 93,5 %) : le gain mesuré avant venait en partie d’artefacts d’évaluation.
L’évaluateur fait partie du système. Il doit être aussi juste que le modèle.
11. La charge : concurrence et workers
La RAM d’abord : environ 87 Mo au repos (le fichier est mappé en mémoire), un pic à 1,93 Go pendant l’inférence, puis retour à environ 130 Mo. Pas de fuite après des dizaines de requêtes.
La latence en séquentiel est stable : 18,5 secondes en médiane, 30 secondes au 95e centile. Lent, mais prévisible : acceptable pour du support, pas pour du temps réel.
Deux requêtes simultanées sur un worker unique : la première répond en 22 secondes, la seconde en 42, après avoir attendu son tour. Aucune requête perdue.
Puis le réflexe d’ingénieur : trois workers devraient tripler le débit. Résultat : timeout généralisé, plus de 120 secondes. Les trois inférences se partageaient les 10 cœurs du CPU et chacune devenait 5 à 6 fois plus lente.
Le goulot d’étranglement n’est pas le nombre de workers, c’est le nombre de cœurs. Pour monter en charge, on ajoute du CPU, pas des workers sur le même CPU.
12. Et si on testait d’autres mini-modèles ?
Si un 0,6B peut presque égaler un 1,7B, qu’en est‑il de modèles encore plus petits, ou d’autres familles ? Cinq ont été testés, tous en Q8_0, sur le même benchmark, avec le même prompt et le même évaluateur. Rien n’a changé sauf le fichier du modèle.
| Modèle | Paramètres | Bonnes réponses | Latence médiane | Taille |
|---|---|---|---|---|
| Gemma-3-270m | 270 M | 97,2 % | 9,9 s | 291 Mo |
| Qwen2.5-0.5B | 0,5 B | 81,3 % | 4,9 s | 676 Mo |
| Llama-3.2-1B | 1 B | 67,3 % | 12,1 s | 1,3 Go |
| SmolLM2-360M | 0,36 B | 51,4 % | 5,3 s | 386 Mo |
Gemma-3-270m, avec 270 millions de paramètres, bat tout ce qu’on avait testé : 97,2 %, plus que le 1,7B, sans aucune aide de l’évaluateur. Ses trois seuls échecs sont des échos du prompt système, jamais une erreur de lecture des données.
Deux enseignements. D’abord, le format prime sur la taille : ce contexte et ce prompt favorisent les modèles entraînés dans cette forme (Qwen, Gemma) et pénalisent ceux qui attendent leur propre gabarit. Ensuite, il y a un plancher : sous 270 millions de paramètres environ, les extractions exactes s’effondrent.
Le cinquième, LFM2.5-230M de Liquid AI, n’a pas pu être testé : son tokenizer n’est arrivé dans llama.cpp qu’après la version embarquée par l’extension PHP. Une piste à retester.
13. La réponse à la question de départ
Alors, un petit modèle bien guidé peut‑il suffire ? Pour la catégorie de besoins testée, comprendre une question et lire la réponse dans des données fournies, la réponse est oui.
| Modèle | Scénario | Bonnes réponses | Latence médiane |
|---|---|---|---|
| Qwen 1,7B | JSON brut et prompt générique | 47 % | 3 à 5 min |
| Qwen 1,7B | Contexte prédigéré et prompt direct | 94,4 % | 18,4 s |
| Qwen 1,7B | Avec LoRA | 93,5 % | 19,2 s |
| Qwen 0,6B | Contexte prédigéré et prompt direct | 93,5 % | 11,3 s |
| Qwen 0,6B | Avec LoRA | 87,9 % | 11 s |
| Gemma-3-270m | Contexte prédigéré et prompt direct | 97,2 % | 9,9 s |
Comme Whisper pour l’audio, la compréhension de langage appliquée à des données connues peut tourner localement. La hiérarchie des modèles existe, mais elle est bien plus plate qu’on ne le croit quand le guidage est bon.
Ce n’est pas un rejet des gros modèles. C’est une question de proportionnalité. Chaque question traitée localement est une requête en moins vers un datacenter. Le local ne remplace pas le cloud : il le soulage pour ce qui n’a pas besoin de lui.
Pour un support d’environ 100 requêtes par jour, l’ordre de grandeur est le suivant :
| Option | Coût estimé |
|---|---|
| Modèle local dans PHP, un worker CPU | 50 à 150 € par mois (serveur), 0 € par requête |
| API externe légère | 10 à 30 € par mois selon les volumes |
| Support humain | 2 000 à 4 000 € par mois (un équivalent temps plein) |
Le local n’est pas forcément moins cher qu’une API à faible volume, et ce n’est probablement pas le bon critère pour décider. Il apporte un contrôle maximal : les données restent dans l’infrastructure, le coût est prévisible, le fonctionnement ne dépend pas d’un service externe. Mais entre « tout local » et « envoyer ses données à n’importe quelle API », il existe beaucoup de nuances : conditions d’utilisation des données, localisation de l’hébergement, politiques de rétention, garanties contractuelles.
Surtout, l’expérience a montré qu’on commence souvent la réflexion trop tard dans la chaîne. On se demande quel modèle utiliser, alors que la première question devrait être :
Quelle quantité d’intelligence faut‑il réellement demander au modèle ?
Si l’application peut trier les commandes, calculer les dates, traduire les statuts et construire un contexte explicite, il n’y a plus besoin de demander tout ce travail au modèle. Et cette optimisation ne profite pas qu’au local : un meilleur contexte permet aussi de descendre fortement en gamme côté API.
L’expérience est passée d’un Qwen 1,7B à 47 % avec un contexte naïf à un Gemma 270M à 97,2 %, sans ajouter d’intelligence autour du modèle. Elle a surtout retiré de l’ambiguïté.
La bonne question n’est donc pas « local ou cloud ? », mais : quel est le plus petit modèle capable de résoudre correctement cette tâche, avec le minimum de données nécessaires ? Selon le cas, ce sera un petit modèle embarqué dans l’application, une API légère, une API avec les garanties d’hébergement nécessaires, ou un gros modèle quand la tâche demande vraiment du raisonnement. Ces solutions peuvent cohabiter dans la même application, et l’humain reste indispensable dès que la situation devient complexe, ambiguë ou sensible.
Les leçons de l’expérience
- Avant de changer de modèle, améliorez ce que vous lui donnez. Le passage de 47 % à 85 % est venu d’un contexte mieux construit, pas d’un modèle plus puissant.
- Ne demandez pas au modèle ce que votre application sait déjà faire. Trier, compter, filtrer ou calculer ne coûte presque rien en code.
- Un bon prompt réduit l’espace de décision. Moins le modèle a de décisions à prendre, plus on peut descendre en taille.
- Petit modèle ne veut pas dire local. La même logique permet d’utiliser des API plus petites, plus rapides et moins chères.
- Cloud ne veut pas dire abandonner ses données. Hébergement, rétention et garanties contractuelles sont des critères d’architecture, au même titre que la latence ou le prix.
- L’évaluateur fait partie du système. Un benchmark mal conçu peut vous faire changer de modèle à tort.
- Le fine-tuning n’est pas automatiquement une amélioration. Il peut aussi encoder de mauvais réflexes.
- Le format peut compter davantage que le nombre de paramètres.
Une première version de cet article a été publiée sur LinkedIn.