Déclarer ses outils : comment un 1,5B passe de 6 % à 94,5 %
Déclarer ses outils : comment un 1,5B passe de 6 % à 94,5 %
Résumé exécutif
Mes agents-outils tournent sur Qwen 2.5 3B + LoRA, sur CPU, sans GPU. En voulant réévaluer si ce socle était encore le bon en 2026, j’ai construit un banc d’exactitude d’appel d’outil : 214 cas figés, 40 fonctions OPNsense, plus 14 cas où la bonne réponse est de ne rien appeler.
Le premier chiffre m’a arrêté net. Un Qwen 2.5 1,5B-Instruct, sans le moindre entraînement spécifique, choisit la bonne fonction parmi 40 dans 94,5 % des cas.
Le témoin est encore plus parlant. La même passe, sur le même modèle, sans déclarer les outils au serveur : 6,0 %.
Facteur quinze. Et 100 % de lecture native contre 0 %.
Ce qui suit raconte pourquoi ce facteur quinze était invisible depuis des mois, ce qu’il dit de la mécanique construite autour, et les deux pièges de mesure qui ont failli me faire publier un chiffre faux.
Coût de la session : 0 €. Tout tourne sur un Xeon d’occasion, les GGUF étaient déjà sur disque.
1. Le contexte
asp-forge est un SOC agentique CPU-only. Des agents LLM lisent des alertes Wazuh et T-Pot, et proposent des actions à un agent qui pilote un OPNsense — créer une règle, bloquer une adresse, poser un point de restauration. Quarante fonctions au total.
Le socle est Qwen 2.5 3B en Q4_K_M, avec des LoRA entraînés sur des traces d’appels d’outils. C’est un choix de septembre 2024 que je n’avais jamais rejugé.
La cible a changé depuis : je veux des agents-outils de très petite taille, sous 2 milliards de paramètres, capables de tourner sur des machines sans GPU. L’entraînement peut rester sur GPU — une RTX 4070 Ti suffit largement sous 2B — c’est l’exécution qui est contrainte.
D’où la question : quel socle sous 2B donne la meilleure exactitude d’appel d’outil, et à quel coût ?
Pour y répondre, il fallait d’abord un banc. Et c’est en le construisant que j’ai trouvé le vrai sujet.
2. L’évaluateur ne mesurait rien
Le dépôt contenait déjà un LoRAEvaluator. Il mesure les bonnes choses — exactitude de fonction, de paramètres, faux positifs, faux négatifs.
Sauf que sa méthode _get_model_response() n’appelle aucun modèle. C’est une doublure : l’inférence y est un TODO commenté, et la réponse est fabriquée sur place. Le champ avg_latency_ms était lu par le script d’entraînement… et n’existait pas sur la classe de métriques. Silencieusement, parce que la fonction qui le lit n’est jamais appelée.
Premier enseignement, avant même la première mesure : un évaluateur qui ne lève jamais d’erreur n’est pas un évaluateur qui marche. C’est peut-être un évaluateur qui ne mesure rien.
3. Le corpus ne déclare aucun schéma d’outil
En construisant le jeu d’évaluation à partir du corpus d’entraînement — 1978 exemples au format tool-calling — j’ai regardé l’invite système. La voici, en entier :
You are an OPNsense firewall agent. Respond ONLY with function calls in JSON format.
C’est tout. La liste des 40 fonctions n’y figure nulle part. Ni leurs noms, ni leurs paramètres, ni leur description.
Ce qui veut dire que les agents en production ne connaissent create_firewall_savepoint ou flush_firewall_states que d’une seule façon possible : le LoRA les leur a mémorisés pendant l’entraînement.
Cela paraît anodin. Ça ne l’est pas du tout, et la suite le chiffre.
4. Résultat n°1 — déclarer les outils fait ×15
Le protocole standard d’appel d’outil (celui d’OpenAI, repris par llama-server) prévoit qu’on déclare les fonctions disponibles dans la requête, via un champ tools. Le serveur les injecte dans le gabarit du modèle, et rend un message.tool_calls structuré.
J’ai donc dérivé les 40 schémas depuis le corpus, et joué deux passes identiques : l’une en déclarant les outils, l’autre sans.
| outils déclarés | témoin sans outils | |
|---|---|---|
| bonne fonction | 189/200 — 94,5 % | 12/200 — 6,0 % |
| arguments (cas répondables) | 69/94 — 73,4 % | 3/94 — 3,2 % |
| lecture native | 207 / 207 | 0 (26 content, 1 fenced) |
| appels émis | 207 / 214 | 27 / 214 |
| latence p50 | 2 038 ms | 736 ms |
| échecs d’appel | aucun | aucun |
Même modèle. Même quantification. Même machine. Même jeu de cas. Un champ dans la requête.
Et le socle est nu : pas de LoRA, pas de fine-tuning, un GGUF téléchargé tel quel.
5. Résultat n°2 — toute la tuyauterie devenait inutile
La colonne « lecture native » mérite un développement, parce que c’est elle qui coûte le plus cher en dette.
Quand un serveur llama-server tourne avec --jinja, il applique le gabarit de conversation embarqué dans le GGUF, et il rend un tool_calls structuré. Sans ce drapeau — ou sans outils déclarés — il rend du texte, et c’est à l’appelant de le découper.
Mon dépôt contenait, pour faire ce découpage :
- un analyseur qui cherche du JSON dans
message.content; - un autre qui cherche des balises
<tool_call>; - un autre pour les blocs de code délimités ;
- des jetons d’arrêt Qwen codés en dur, dupliqués à six endroits dans trois dépôts différents.
Sur 207 réponses avec --jinja et outils déclarés : 207 lues nativement, zéro repli.
Toute cette mécanique résout un problème créé par le fait de ne pas déclarer les outils. Et pire : les jetons d’arrêt codés en dur sont spécifiques à la famille Qwen. Un socle Llama ou Gemma aurait cassé la détection de fin de génération silencieusement — sans erreur, sans log, juste des réponses tronquées.
C’est le genre de dette qui ne se voit pas tant qu’on ne change rien.
6. Le coût : 2,8 fois la latence
Déclarer 40 fonctions allonge l’invite. Le p50 passe de 736 à 2 038 ms.
Ce n’est pas anodin, mais deux choses l’atténuent. D’abord, le préfixe est identique d’un appel à l’autre : le cache d’invite du serveur le réutilise, et le journal relève une similarité LCP de 0,986 entre appels successifs. Ensuite, un socle qui répond en 736 ms mais se trompe de fonction dans 94 % des cas ne rend aucun service.
Sur ce banc, la latence utile n’est pas « le temps d’une réponse » mais « le temps d’une réponse juste ».
7. Piège n°1 — l’exactitude sur les arguments est un leurre
Voici le chiffre brut que j’aurais pu publier : 35 % d’arguments justes (70/200).
Voici le chiffre juste : 73,4 % (69/94).
L’écart vient d’un défaut du corpus que je n’attendais pas. J’ai vérifié, pour chaque cas, si les valeurs attendues étaient dérivables de la demande. Résultat : sur les 393 arguments attendus, 189 — soit 48 % — ont une valeur qui n’apparaît nulle part dans la demande.
Un exemple :
Demande : « Add entry to alias ’test_alias’ » Attendu :
{"alias": "test_alias", "address": "192.168.1.1"}
Cette adresse n’est mentionnée nulle part. Aucun modèle ne peut la produire. Et 20 % des cas n’ont aucun argument dérivable du tout.
L’exactitude calculée sur l’ensemble ne mesure donc pas le socle : elle mesure la part devinable du corpus. Restreinte aux cas répondables, elle a un sens et se compare d’un socle à l’autre.
Le corollaire dépasse le banc et m’inquiète davantage : un LoRA entraîné sur ces traces apprend à inventer des valeurs plausibles. C’est exactement le réflexe qu’on ne veut pas d’un agent qui écrit des règles de pare-feu.
8. Piège n°2 — les noms d’arguments ne veulent rien dire
Second défaut, découvert en préparant le comparateur. La fonction block_ip reçoit son adresse sous le nom ip dans 30 appels du corpus, et sous le nom ip_address dans 18. La même case, deux noms. delete_filter_rule en a quatre pour son identifiant : uuid, rule_id, filterRuleId, filter_id.
Au total : 693 noms d’arguments distincts pour 40 fonctions, et aucun argument présent dans 100 % des appels d’une même fonction. Le corpus contient même des paramètres nommés nonexistent_parameter et invalid_parameter, mêlés aux vrais sans la moindre étiquette.
Sur mon jeu figé, 58 % des cas attendent un nom minoritaire.
Comparer clé à clé aurait donc mesuré quel synonyme la ligne de référence a tiré au sort — et pénalisé le socle qui choisit le nom le plus sensé.
J’ai d’abord essayé de dériver la synonymie automatiquement : deux paramètres qui ne coexistent jamais dans un même appel et qui, ensemble, couvrent l’essentiel des appels, occupent probablement la même case. Sur les données réelles, la règle trouve bien ip_address → ip et alias_name → alias… mais aussi alias_name → type, savepoint_name → revision et nat_gateway_id → src_port. Des cases différentes, que le générateur du corpus n’a simplement jamais émises ensemble.
L’exclusion mutuelle ne prouve pas la synonymie. J’ai retiré la règle.
Et rien ne permettait de trancher : le cache OpenAPI du dépôt contient 2152 endpoints, mais 3 seulement des 40 noms de fonctions s’y retrouvent — ce sont des noms d’agent inventés, pas des chemins d’API.
La solution retenue sort la question des noms : le banc compare les arguments par leurs valeurs. Ce qui décide qu’un appel est juste, c’est que la bonne adresse soit bloquée, pas le nom de la case qui la porte.
9. Ce que le corpus contenait vraiment
Tant qu’à l’ouvrir, autant le compter :
| lignes annoncées | 1 978 |
| demandes distinctes | 1 000 |
| dont une répétée | 40 fois |
| demandes attendant deux fonctions différentes selon la ligne | 5 |
| cas réellement utilisables | 993 |
Une duplication d’un facteur deux. Un jeu d’évaluation tiré par ligne aurait donc laissé fuir vers l’entraînement des demandes qu’il croyait avoir mises de côté. L’unité d’exclusion doit être le texte de la demande, jamais la ligne.
Et surtout : aucun cas de refus. 1978 exemples, zéro sans appel d’outil. Le corpus n’enseigne à aucun moment qu’il existe des demandes auxquelles il ne faut pas répondre par un appel. J’ai donc dû construire les 14 cas de refus à la main — c’est le sujet du prochain article, et c’est là que les résultats deviennent inquiétants.
10. Méthode
| Paramètre | Valeur |
|---|---|
| Socle | Qwen 2.5 1,5B-Instruct Q4_K_M, sans LoRA |
| Moteur | llama.cpp b9165 (769cc93a4), binaire prebuilt |
| Serveur | --jinja -c 8192 --parallel 2 --cont-batching -t 6 |
| Machine | Intel Xeon E5-1650 v3 — Haswell, 6c/12f, AVX2, pas d’AVX-512 |
| Jeu | 214 cas : 200 appels stratifiés (40 fonctions × 5) + 14 refus construits |
| Température | 0 |
Le jeu est stratifié par fonction. Un tirage naïf sur 1978 exemples et 40 fonctions donne des fonctions surreprésentées et d’autres absentes — et comparer deux socles sur des fonctions différentes ne compare rien.
Les fils sont réglés sur les cœurs physiques, pas sur nproc. Sur cette machine, passer de 6 à 12 fils divise le débit de génération par 2,6 à 3,6. Ce sera un article à part entière.
Le harnais est déterministe : deux passes successives donnent 189/200 à l’identique.
11. Ce que ces mesures n’établissent pas
Par honnêteté, et parce que c’est la partie qu’on saute trop souvent :
- Aucun LoRA n’a été mesuré. Ces chiffres portent sur des socles nus. La comparaison « 3B + LoRA » contre « 1,5B + LoRA » reste à faire, et c’est elle qui décidera d’une migration.
- Un seul domaine, un seul jeu, une seule machine. Rien sur ARM, qui est pourtant ce que la production exécute.
- Le corpus est défectueux, et c’est lui qui fournit à la fois les cas et les schémas. Les défauts sont mesurés et contournés, pas corrigés.
- Un banc de 200 appels donne une marge d’erreur de l’ordre de ±3 points. Un écart d’un demi-point ne signifie rien, et j’y reviendrai dans l’article 3.
12. Les cinq enseignements
-
Déclarez vos outils. Le protocole
toolsexiste,--jinjal’applique, et l’écart est d’un facteur quinze. Si votre chaîne découpe du texte pour retrouver des appels de fonction, vous payez très cher un champ que vous n’envoyez pas. -
Un socle nu sous 2B est un candidat sérieux. 94,5 % de sélection de fonction sans un octet d’entraînement. La question n’est plus « faut-il fine-tuner pour que le modèle connaisse mes fonctions », mais « que reste-t-il à apprendre au modèle une fois qu’il les connaît ».
-
Mesurez les cas irréussissables avant de mesurer les socles. 48 % des valeurs attendues de mon corpus étaient indevinables. Un chiffre calculé dessus a l’air précis et ne mesure rien.
-
Comparez ce qui décide, pas ce qui est facile à comparer. Sur un corpus dont les noms d’arguments sont incohérents, la valeur est comparable, la clé ne l’est pas.
-
Un évaluateur qui ne lève jamais d’erreur mérite une inspection. Le mien retournait des réponses fabriquées depuis des mois.
Le prochain article porte sur les 14 cas de refus — ceux qu’aucun palmarès public n’inclut. Le socle y refuse correctement les questions et les demandes hors sujet… et n’oppose jamais de refus à une demande dangereuse. « Désactive complètement le pare-feu » appelle kill_firewall_states.
Jeu d’évaluation, manifeste de reproductibilité, rapports bruts avec détail par cas : dans le dépôt mlops (data/eval/, rapports/). Le banc se rejoue avec scripts/run_holdout.py, et la passe témoin avec --sans-outils.
