Un outil peut fonctionner sans incident pendant deux semaines, puis se mettre à échouer à cause d’une modification qui semblait anodine. Vous ajoutez par exemple à votre Agent une commande pour « récupérer les publications publiques d’une plateforme ». Plus tard, vous faites passer la valeur par défaut de limit de 25 à 20 et ajoutez une valeur à l’énumération sort. Les tests sont au vert, la modification est fusionnée.
Trois jours après, quelques erreurs apparaissent en production. Le modèle appelle l’outil avec une valeur d’énumération supprimée la semaine précédente ; la validation à l’exécution la rejette, et la trace pointe vers la couche de dispatch. Vous passez une demi-heure à examiner ce code. Il n’a rien de faux. Le problème se trouve ailleurs : la signature de la fonction a changé, mais pas la description d’outil lue par le modèle. Celui-ci travaille toujours avec l’ancien schéma et produit donc des appels adaptés à une interface qui n’existe plus.
C’est la dérive des descriptions d’outils. C’est à la fois la catégorie de bug la plus courante et la plus pénible à remonter dans l’ingénierie des Agents, pour une raison précise : l’erreur et sa cause ne vivent pas au même endroit. L’exception surgit dans la couche d’exécution ; la cause dort dans un fichier JSON que personne ne pense à ouvrir. L’objectif n’est pas de vous dire de mieux synchroniser les deux. Il faut supprimer structurellement la possibilité qu’ils divergent.
Pourquoi les descriptions finissent par diverger
À la racine, le problème est simple : vous maintenez deux sources de vérité.
La première est le code exécuté : signature de fonction, validation des arguments, valeurs par défaut et contraintes d’énumération. Elle est rigide. Si elle est erronée, elle échoue bruyamment.
La seconde est la description d’outil fournie au modèle : son name, sa description et le schéma JSON parameters. Celle-ci est souple. Une erreur n’explose pas immédiatement : le modèle génère simplement un mauvais appel, qui ne casse qu’ensuite dans la couche d’exécution.
Tant qu’un humain doit maintenir l’alignement entre ces deux éléments, la dérive n’est qu’une question de temps. Vous modifiez un argument dans le code et oubliez la description. Ou l’inverse. Ou vous changez les deux, mais leur sens ne correspond plus vraiment. Rien ne le révèle au moment de la modification. Le défaut attend que le modèle génère un appel qui tombe sur la différence, souvent deux semaines après que vous avez oublié ce que vous aviez modifié. Il n’existe qu’une solution : passer de deux copies à une seule.
Une seule source de vérité : la déclaration définit l’interface
Le changement de perspective est le suivant : vous n’avez pas besoin d’écrire ce JSON de description d’outil.
La déclaration du parser d’une fonction, complétée par sa docstring, contient déjà tous les champs nécessaires. Voici une déclaration argparse classique :
subreddit = commands.add_parser("subreddit", help="Query a public board's feed")
subreddit.add_argument("subreddit")
subreddit.add_argument(
"--sort",
choices=("hot", "new", "top", "rising", "controversial"),
default="hot",
)
subreddit.add_argument("--limit", type=int, default=25)
Le champ help fournit la description courte de la commande. choices définit la contrainte d’énumération. default porte la valeur par défaut. type indique le type du paramètre, tandis que l’argument positionnel est obligatoire. Tout ce dont le modèle a besoin pour appeler l’outil est là : l’action réalisée par la commande, ses paramètres, ceux qui sont requis, les valeurs d’énumération et les défauts. Et comme cette même déclaration sert à l’exécution pour parser et valider les entrées, elle ne peut pas diverger de la logique d’exécution : elle est cette logique.
Il faut donc cesser d’écrire une seconde description d’outil. Considérez qu’elle n’existe pas en tant que document source. Il n’y a que du code ; lorsqu’une description est nécessaire, vous la projetez depuis le code. Cette projection va dans un seul sens, du code vers la description, jamais dans l’autre.
Générer tout le catalogue à partir des déclarations
Une fois la déclaration reconnue comme interface, les descriptions d’outils ne doivent plus être rédigées manuellement. Un générateur doit toutes les produire.
Son travail est mécanique. Il parcourt chaque contexte de plateforme, importe son parser et lit la liste des actions argparse pour construire trois structures immuables : Platform, Command et Parameter. Chaque Parameter porte son nom, son type, son indicateur d’obligation, ses valeurs d’énumération, sa valeur par défaut et son texte d’aide. Vous obtenez ainsi une vue de lecture de l’interface, entièrement dérivée du code.
À partir de cette vue, tous les formats de sortie deviennent des produits dérivés. Un describe --format json émet l’interface complète lisible par machine, à utiliser pour la sélection d’outils par un Agent. Un render_skill() produit un catalogue de capacités lisible par un humain comme par un modèle. Le nombre de commandes de ce catalogue n’est pas une constante saisie à la main : il est calculé à la volée avec sum(len(platform.commands)). À ce jour, cela représente 22 contextes de plateforme et 241 commandes, sans qu’aucune n’ait été ajoutée manuellement au catalogue.
Cette approche apporte une propriété très confortable. Ajouter une plateforme consiste à ajouter son contexte, et le catalogue récupère automatiquement toutes ses commandes. Modifier un paramètre revient à modifier la déclaration du parser, tandis que l’énumération et la valeur par défaut correspondantes se mettent seules à jour dans le catalogue. Vous ne rencontrez plus les cas « j’ai écrit une commande mais oublié de l’enregistrer » ou « le paramètre a changé mais le catalogue est obsolète », car l’enregistrement comme action manuelle n’existe pas. Le catalogue est calculé, pas entretenu.
(Ce réflexe — dériver plutôt que maintenir — est le même que lorsqu’on emploie des opérations ensemblistes pour déterminer ce qui a réellement été migré entre plusieurs langages, sujet abordé dans l’article sur les migrations cross-language.)
La CI transforme la dérive en échec de commit
La génération règle le problème des nouvelles commandes qui arrivent automatiquement dans le catalogue, mais une faille subsiste. Quelqu’un peut modifier une déclaration de parser, oublier de relancer le générateur et ne pas committer le catalogue régénéré. La copie présente dans le dépôt redevient alors obsolète.
Le dernier verrou se place dans la CI, autour d’une seule assertion :
docs-check:
$(PYTHON) -c 'from pathlib import Path; from reverse.catalog import render_skill; \
path = Path("skill/SKILL.md"); \
assert path.read_text(encoding="utf-8") == render_skill(), \
"skill/SKILL.md is out of sync with the code; run make docs"'
Elle compare octet par octet le catalogue committé dans le dépôt avec un catalogue régénéré depuis le code courant. Une seule différence de caractère suffit à faire échouer la CI, avec un message indiquant que le catalogue est désynchronisé et qu’il faut lancer make docs.
L’intérêt de cette ligne est de déplacer le moment où la dérive est détectée. Avant, c’était un fantôme d’exécution : il explosait en production deux semaines plus tard, avec une trace pointant vers le mauvais endroit. Désormais, c’est une croix rouge au moment du commit. La pull request est bloquée, l’erreur indique que le catalogue n’est pas à jour, et sa régénération corrige le problème. La dérive passe du statut de « bug le plus difficile à diagnostiquer » à celui d’erreur de compilation résolue par une commande. C’est le cycle complet de l’interface comme code : la déclaration est la source, le catalogue de capacités est l’artefact de build, et la CI joue le rôle du vérificateur de types. Vous n’écririez pas un artefact de build à la main, ni n’accepteriez qu’il diffère de sa source ; les descriptions d’outils méritent le même traitement.
Ce qui doit être figé dans le code, et ce qui revient au modèle
La génération et la CI garantissent l’exactitude de la description d’interface. Mais une décision doit intervenir plus tôt : faut-il coder une capacité de manière fixe, ou laisser le modèle l’orchestrer selon le contexte ? Une interface exacte ne vous sauvera pas si cette séparation est mauvaise.
Il est utile de distinguer trois niveaux de capacités.
Une primitive bas niveau lit un type de donnée ou réalise une action claire. Son entrée est stable, sa sortie structurée et elle se teste isolément. Cette couche relève entièrement du code et ne consomme aucun raisonnement. La très grande majorité des 241 commandes se trouvent ici.
Un workflow déterministe est un processus fortement ordonné au sein d’une plateforme, avec un état partagé et une condition de réussite claire. Prenez un pipeline créatif, creative-pipeline, qui enchaîne recherche d’opportunités, Top Ads, mise en correspondance avec des créateurs, brief créatif, puis préflight de génération. L’ordre et les dépendances entre les étapes sont fixes. Cette couche doit elle aussi être figée dans le code : si l’ordre est déjà déterminé, demander au modèle de le replanifier à chaque exécution est à la fois plus lent et moins stable. Une ligne suffit à l’indiquer : donnez à la commande set_defaults(_command_level="workflow"). C’est la seule ligne de ce type dans la base de code, et elle permet au catalogue d’afficher workflows et primitives sur deux niveaux distincts.
L’orchestration par l’Agent concerne la recherche inter-plateformes, les arbitrages en direct et les redirections après un échec. C’est cette couche qu’il faut confier au modèle, car la prochaine requête dépend de ce que la précédente a révélé, et ne peut pas être écrite à l’avance.
Le critère est assez net. Si une capacité a besoin d’un statut d’étape stable, d’un contexte partagé ou d’effets de bord de génération, figez-la dans le code. Si elle implique l’expansion de requêtes, la vérification entre plateformes ou un reroutage après échec, laissez-la au modèle. Les deux erreurs coûtent cher. Encoder en dur une hypothèse de recherche dans le client, c’est trop figer : dès que la plateforme change, vous devez modifier le code. Laisser le modèle reconstituer à chaque fois une séquence fixe, c’est ne pas assez figer : vous économisez une décision de modélisation, mais achetez beaucoup d’instabilité.
Permettre au modèle de décider comment se dégrader : six statuts d’étape
Pour que la couche d’orchestration puisse décider, les retours de la couche inférieure doivent être compréhensibles pour le modèle. Un simple booléen opaque de succès ou d’échec ne suffit pas. Donnez-lui success: false, et il ne peut que deviner l’étape suivante.
Chaque étape d’un workflow renvoie donc un statut d’étape plutôt qu’un booléen. Il en existe six : completed, empty, ready, skipped, unavailable et blocked. L’information essentielle réside dans les distinctions entre les statuts qui n’ont pas permis d’avancer :
skippedsignifie que l’opérateur a volontairement désactivé cette étape, par exemple en fixant le plafond d’un chemin de collecte à 0. Ce n’est pas une erreur, et le modèle ne doit pas la relancer.unavailableindique qu’une dépendance de l’étape est temporairement indisponible, par exemple une interface qui renvoie une erreur ou une session absente. Le modèle peut l’ignorer et continuer, ou demander une nouvelle session avant de revenir dessus.blockedsignifie qu’une précondition n’est pas satisfaite, par exemple si les éléments de preuve issus de la recherche sont vides ou si le préflight échoue. Le modèle ne doit pas forcer l’étape suivante : il doit revenir compléter les éléments manquants.
Reprenons ce pipeline créatif. Il évalue séparément « le préflight de la plateforme est prêt » et « les éléments de preuve de recherche sont prêts », puis calcule ready = platform_ready and research_ready. Si l’un des deux échoue, l’étape de génération renvoie blocked avec une liste blockers qui explique ce qui la bloque ; et lorsque tous les résultats de recherche commerciale sont vides, elle ne soumet tout simplement pas le job de génération.
Pourquoi cette conception est-elle pensée pour le modèle ? Un modèle d’orchestration qui lit seedance_generation: blocked et blockers: [research_evidence_empty] sait qu’il doit revenir chercher des éléments de preuve plutôt que de réessayer la soumission. S’il lit organic_discovery: skipped, il comprend qu’il s’agit d’une intention utilisateur et non d’une panne : il n’y touche pas. Face à une étape unavailable, il sait qu’il peut contourner le problème. Dès lors que vous séparez « désactivé volontairement », « temporairement indisponible » et « précondition non satisfaite », le modèle peut choisir le bon chemin de dégradation. Ramenez les trois à false, et même un modèle solide tourne en rond.
Associer le bon modèle à chaque couche
La pile ci-dessus demande des capacités très différentes au modèle selon la couche. (L’article sur le reverse engineering en quatre étapes présente la même grille à quatre niveaux dans un contexte de reverse engineering ; elle s’applique ici à la pile Agent.) En répartissant les modèles par couche, vous évitez de gaspiller de la capacité :
Travail dans la pile Agent | Capacité requise | Choix | model id |
|---|---|---|---|
Charger dans le contexte le JSON | Contexte long, lecture de l’ensemble du catalogue en un passage | Kimi K3 |
|
Orchestration : lire les statuts d’étape et les bloqueurs, décider de dégrader, rerouter ou poursuivre | Raisonnement solide, décision juste à partir du statut | Claude Opus 5 |
|
Générer en masse, depuis les docstrings, un texte de description d’outil adapté aux modèles | Peu coûteux, centaines d’appels avec une forte concurrence | Claude Sonnet 5 |
|
Attribution des erreurs d’appel d’outil : lire l’erreur et la déclaration, décider s’il s’agit d’une dérive ou d’un changement amont | Raisonnement intermédiaire, explication fondée sur des champs précis | GPT-5.6 Sol |
|
La couche d’orchestration mérite qu’on s’y attarde. Lire blocked et skipped pour déterminer l’action suivante est la seule étape de ce flux où changer de modèle modifie visiblement le résultat, car c’est précisément là que l’on mesure sa capacité à prendre la bonne décision à partir d’un statut. Un modèle plus faible traite skipped comme un échec et relance l’étape, ou voit blocked et soumet quand même. Un modèle doté d’un raisonnement fort lit les blockers et reroute avec précision. C’est le même écart que dans l’article sur le fingerprinting, lorsqu’il s’agit de savoir si la section de contre-preuves argumente réellement contre elle-même : tout le monde peut produire un candidat, la difficulté réside dans le jugement.
Inutile de me croire sur parole : testez-le.
Prenez le retour réel de l’un de vos workflows, avec ses
stageset sesblockers, ou fabriquez une réponseblockedavecblockers: [research_evidence_empty].Fournissez cette réponse, votre catalogue de capacités — le JSON
describe— et une instruction demandant de décider l’action suivante, séparément àclaude-opus-5etgpt-5.6-sol.Observez un seul point : l’action suivante proposée distingue-t-elle correctement
blocked(revenir chercher des éléments de preuve),skipped(intention utilisateur, ne rien faire) etunavailable(obtenir une session ou contourner), ou bien le modèle relance-t-ilskippedcomme s’il s’agissait d’un échec ?La proportion de chemins de dégradation corrects devient votre critère de sélection. Elle détermine si votre Agent tourne en rond face à une panne réelle ou s’il la contourne de lui-même.
Le vrai frein, c’est le coût du changement de modèle
Les quatre modèles proviennent de trois fournisseurs, et le coût de bascule est particulièrement élevé pour le function calling. Les formats tools / tool_calls d’OpenAI et tool_use / tool_result d’Anthropic sont différents. Si vous remplacez le modèle d’orchestration par un modèle qui juge mieux, vous devez réécrire toute la chaîne de dispatch des outils et de parsing des erreurs. C’est la vraie raison pour laquelle la plupart des équipes verrouillent un seul modèle dans cette couche, même lorsqu’il interprète mal les statuts d’étape.
AIReiter supprime cette couche de complexité. Une clé, une interface compatible OpenAI, les quatre modèles derrière, et le changement se limite au champ model du corps de la requête.
# Orchestration decision: hand the reasoning tier the catalog plus one blocked workflow response, ask for the next action
curl https://aireiter.com/api/v1/chat/completions \
-H "Authorization: Bearer $AIREITER_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-opus-5",
"messages": [{"role": "user", "content": "<describe json> + <stages/blockers response> + decide the next action"}]
}'
# Generate tool-description text in bulk: change the model field, leave the rest
# "model": "claude-sonnet-5"
# Error attribution:
# "model": "gpt-5.6-sol"
Pour le function calling natif, il suffit d’ajouter un tableau tools ; le protocole d’outils OpenAI traverse cette interface sans modification, donc le changement de modèle reste une édition d’un seul champ. Si vous utilisez déjà le SDK OpenAI, pointez base_url vers https://aireiter.com/api/v1 sans rien changer d’autre. Avec le SDK Anthropic, appelez POST /api/v1/messages avec la même clé.
Côté prix, les modèles Claude bénéficient d’une réduction de 30 % sur le tarif catalogue, les modèles GPT sont proposés à moitié prix, et Kimi K3 est accessible avec la même clé. Pour cette pile, les réductions interviennent là où elles comptent. Chaque avancée de la couche d’orchestration représente un appel supplémentaire au niveau de raisonnement : c’est donc la couche la plus fréquente et la plus coûteuse de tout l’Agent, précisément celle concernée par la réduction Claude. La génération en masse des descriptions d’outils à partir de 241 docstrings est elle aussi un travail Sonnet fortement concurrent, également remisé. Ces deux postes constituent l’essentiel du coût. Les appels GPT-5.6 d’attribution d’erreurs sont beaucoup moins nombreux.
Essayer sans inscription : effectuez quelques essais à la main, donnez la même réponse
blockedaux deux modèles et constatez lequel choisit la bonne dégradation avant de l’intégrer à votre couche d’orchestration.
Pour conclure
La dérive des descriptions d’outils ne se corrige pas en demandant aux équipes de « penser à synchroniser ». Cette approche transforme simplement un défaut structurel en question de discipline individuelle. La vraie solution consiste à supprimer la structure à deux sources : la déclaration du parser et la docstring deviennent l’unique source, le catalogue de capacités est un artefact de build qui en dérive, et une assertion CI fait office de vérification de types. La dérive passe alors d’un fantôme d’exécution à une croix rouge au moment du commit.
La génération garantit seulement que la description est exacte. Elle ne dit rien sur la pertinence du découpage. Les capacités à figer dans le code, celles à laisser au modèle pour l’orchestration, ainsi que les six statuts d’étape qui lui permettent de comprendre s’il doit réessayer ou se dégrader, déterminent si votre Agent peut réellement fonctionner de manière autonome. Dans cette pile, le modèle remplit deux rôles concrets : arbitrer dans la couche d’orchestration et attribuer la cause d’un échec d’appel d’outil. La décision de figer une capacité et le choix d’un chemin de dégradation sont déterminés par les statuts d’étape que vous concevez et la CI que vous écrivez, non par le modèle.
C’est la même posture que dans les articles consacrés à la réconciliation de migrations par ensembles et au fait de ne pas construire un Model de réponse unifié : l’IA compresse le temps d’une étape, tandis que le verdict reste enfermé dans les contraintes que vous codez en dur. Une fois l’ensemble fluide, la seule friction restante est le changement de modèle — un problème d’infrastructure qu’une interface unifiée résout.