Mathieu Bernard Réserver 30 min

La facture cachée d’un agent IA : les outils qu’il relit à chaque étape

En passant de 24 à 10 outils par service, en raccourcissant les descriptions et en bornant les boucles, la plateforme est passée de 197 000 à 85 000 caractères d’outils par étape.

Quand on parle du coût d’un agent IA, on regarde presque toujours la même chose : le prix du modèle, au million de jetons. On compare les fournisseurs, on descend en gamme, on négocie. C’est le réflexe courant. Sur le projet étudié ici, c’est en regardant ce que l’agent envoyait vraiment au modèle, à chaque étape, que la question a changé. La facture n’était pas là où on la cherchait.

1. Mesurer ce qu’on envoie vraiment au modèle

Le contexte : une plateforme d’employés IA. Chaque employé (un assistant commercial, une secrétaire, un chef de projet…) est relié aux services de l’entreprise : Gmail, Agenda, Drive, Sheets, Outlook, et des dizaines d’autres. Pour la plupart, les outils de ces services viennent de Composio, un catalogue qui expose chaque service sous forme de fonctions appelables par un modèle.

Un agent, c’est une boucle : le modèle reçoit la conversation et la liste de ses outils, il choisit un appel, l’application l’exécute, ajoute le résultat à la conversation, et on recommence. Ce qu’on oublie facilement, c’est que la liste des outils est renvoyée en entier à chaque étape. Un employé qui fait dix étapes pour traiter un mail relit dix fois la définition de tous ses outils.

La mesure a donc porté sur la taille des définitions que la classe Toolbox envoie au modèle pour un employé relié à ses services courants. Résultat : 197 000 caractères par étape. Avant même la première ligne de la demande, avant le moindre mail à lire.

D’où venait ce volume ? De deux choses. D’abord le nombre : 24 outils par service, donnés d’office. Ensuite la verbosité : les descriptions des catalogues sont écrites pour couvrir tous les cas. Un outil Gmail décrit chacune de ses options avancées, des objets imbriqués sur plusieurs niveaux, des exemples, des titres qui répètent le nom du paramètre, des champs de métadonnées (file_uploadable, human_parameter_name…) qui ne servent à rien au modèle. Un commentaire laissé dans le code donne l’ordre de grandeur : 24 outils coûtaient 50 000 jetons.

Chaque outil donné à un agent est relu à chaque étape. Ce n’est pas un coût d’installation, c’est un loyer.

2. 24 outils par service, puis 10 et un outil pour chercher les autres

Première question : de quoi un employé a-t‑il besoin au quotidien ? Pour Gmail, lire ses mails, ouvrir un message ou un fil, répondre, envoyer, faire un brouillon, ranger avec un libellé, récupérer une pièce jointe, transférer. Pas modifier les règles de filtrage, ni gérer les délégations, ni configurer S/MIME. Ces outils-là existent, mais ils servent une fois par an.

L’équipe a donc écrit, pour les cinq services les plus courants, la liste explicite de leurs essentiels, dans l’ordre :

private const int PER_TOOLKIT = 10;
private const array ESSENTIALS = [
    'gmail' => ['GMAIL_FETCH_EMAILS', 'GMAIL_FETCH_MESSAGE_BY_MESSAGE_ID',
        'GMAIL_FETCH_MESSAGE_BY_THREAD_ID', 'GMAIL_REPLY_TO_THREAD', 'GMAIL_SEND_EMAIL',
        'GMAIL_CREATE_EMAIL_DRAFT', 'GMAIL_ADD_LABEL_TO_EMAIL', 'GMAIL_LIST_LABELS',
        'GMAIL_GET_ATTACHMENT', 'GMAIL_FORWARD_MESSAGE'],
    'googlecalendar' => ['GOOGLECALENDAR_EVENTS_LIST', 'GOOGLECALENDAR_FIND_EVENT',
        'GOOGLECALENDAR_FIND_FREE_SLOTS', 'GOOGLECALENDAR_CREATE_EVENT', 'GOOGLECALENDAR_PATCH_EVENT', …],
    // googledrive, googlesheets, outlook…
];

Pour tous les autres services, il existait déjà un classement automatique : un point pour un outil de lecture, un de plus s’il liste ou cherche, des points pour « qui suis-je », pour les pièces jointes et les téléchargements. Une pénalité forte y a été ajoutée pour l’administration du compte :

// l'administration du compte (droits, révisions, réglages, filtres,
// abonnements aux changements) : rarement utile
if (1 === preg_match('/_ACL_|PERMISSION|REVISION|_SETTINGS|_FILTER|_WATCH|CHANNELS_STOP|_CSE_|SMIME|APPROVAL|_LABEL_DEFINITION|_DRIVE$|SHARED_DRIVES|_PROPERTY/', $slug)) {
    $score -= 4.0;
}

Le tri final met d’abord les essentiels, dans l’ordre de la liste, puis le classement pour le reste. On garde les 10 premiers.

Et les autres ? Ils ne disparaissent pas. Quand un service a plus d’outils que ceux donnés d’office, l’employé reçoit un outil de plus, chercher_un_outil :

self::fn(self::SEARCH, 'Trouver dans tes services l\'outil qu\'il te faut quand il n\'est pas
dans ta liste (ex. « lister mes dépôts », « déploiements du site »). Les outils trouvés
deviennent utilisables tout de suite.', [
    'besoin' => ['type' => 'string', 'description' => 'Ce que tu veux faire, en quelques mots
        (en anglais de préférence : les outils sont décrits en anglais)'],
], ['besoin']);

La recherche elle-même est volontairement bête : pas d’embeddings, pas de second modèle. Les mots du besoin sont normalisés (minuscules, sans accents), réduits à une racine, et comparés au nom de l’outil (3 points) puis à sa description (1 point), avec un petit bonus pour les outils de lecture et les outils marqués importants. Les 8 meilleurs sont renvoyés au modèle avec une description de 240 caractères, et ajoutés à sa boîte à outils pour la suite du travail. À la reprise d’un travail interrompu, il retrouve d’ailleurs les outils qu’il avait trouvés ou utilisés.

C’est le même principe que dans l’article sur le petit modèle bien guidé, appliqué aux outils plutôt qu’aux données : l’application sait déjà trier et chercher, inutile de faire porter ce travail au modèle à chaque étape.

3. Raccourcir les descriptions sans toucher aux paramètres requis

Réduire le nombre d’outils ne suffisait pas : chacun restait très bavard. Mais là, il y avait un vrai risque. Un modèle qui appelle un outil a besoin du schéma de ses paramètres pour construire un appel valide. Si on coupe trop, il invente des champs, oublie un paramètre obligatoire, ou se trompe de type, et l’appel échoue chez le service. Un appel raté, c’est une étape de plus, donc toute la liste des outils relue une fois de plus.

D’où une règle simple : les paramètres obligatoires gardent toute leur structure. Seules trois choses sont allégées :

  • la description de l’outil, coupée à 400 caractères (au lieu de 1 000), à la fin d’une phrase quand c’est possible ;
  • la description de chaque paramètre, coupée à 240 caractères, avec la même règle ;
  • les options facultatives très détaillées : un paramètre facultatif de type objet ou tableau dont le schéma dépasse 500 caractères (typiquement les réglages avancés imbriqués) est résumé en une ligne, son type et une description courte.

La coupe à la fin d’une phrase compte plus qu’il n’y paraît. Une description tranchée au milieu d’un mot laisse le modèle face à une consigne incomplète ; une description qui s’arrête au dernier point garde un sens :

private static function shorten(string $text, int $max): string
{
    $text = trim((string) preg_replace('/\s+/u', ' ', $text));
    if (mb_strlen($text) <= $max) {
        return $text;
    }
    $cut = mb_substr($text, 0, $max);
    $end = max((int) mb_strrpos($cut, '. '), (int) mb_strrpos($cut, '; '));

    return $end > $max / 2 ? mb_substr($cut, 0, $end + 1) : rtrim($cut).'…';
}

Le résumé des options facultatives ne touche jamais à ce que le schéma déclare requis :

$long = \strlen((string) json_encode($property)) > self::OPTIONAL_DETAIL;
if ($long && !\in_array($name, $required, true)
    && \in_array($property['type'] ?? null, ['object', 'array'], true)) {
    // résumé en une ligne : le type et une description courte
}

Dernier nettoyage : les title redondants, qui répètent en général le nom du paramètre. Avec un piège qu’il a fallu traiter explicitement : sous properties, les clés sont des noms de paramètres. Un service peut très bien avoir un paramètre qui s’appelle title (le titre d’un événement d’agenda, par exemple). Le nettoyage récursif reçoit donc un drapeau qui dit « ici, les clés sont des noms, ne retire rien ». Sans ça, le nettoyage aurait silencieusement supprimé le titre des rendez-vous.

Résultat sur les mêmes services : de 197 000 à 85 000 caractères de définitions par étape. Le message du commit note environ 8 fois moins de jetons par message.

4. Vérifier que le modèle sait encore appeler ses outils

Un schéma allégé n’a de valeur que si le modèle produit toujours des appels corrects. Le vérifier contre les schémas raccourcis eux-mêmes n’aurait rien prouvé : ce serait juger la copie avec le corrigé qu’on vient de réécrire.

La vérification s’est faite avec les vrais outils Composio et le vrai modèle (DeepSeek, en direct) : dix demandes concrètes, le modèle reçoit les définitions allégées, et chaque appel qu’il produit est comparé au schéma complet d’origine, celui du service. Résultat : 10 demandes sur 10 remplies conformément au schéma complet.

C’est un échantillon court, et il faut le lire comme tel : il ne prouve pas qu’aucun appel ne ratera jamais. Il prouve que la règle choisie (couper le texte, résumer le facultatif, ne pas toucher au requis) ne casse pas les cas du quotidien. Pour le reste, un appel en échec n’est pas une catastrophe : le service renvoie son erreur, le modèle la lit et corrige.

5. Les boucles : quand l’agent fait trop d’étapes

Alléger chaque étape, c’est la moitié du problème. L’autre moitié, c’est le nombre d’étapes. Et là, c’est la production qui a fourni le cas d’école.

Une mission classique : « À chaque nouveau mail, … ». Une surveillance repère le mail, lance un travail pour le traiter. Sauf que le travail, au lieu de traiter ce mail, a remonté toute la boîte de réception, page après page : 30 étapes, 2 millions de jetons, 0,60 $ pour un seul mail reçu. Rien d’absurde dans chaque étape prise isolément. Mais chaque étape renvoyait toute la conversation, qui grossissait à chaque page de mails lue.

La cause était double. D’un côté, le travail ne recevait qu’un aperçu du mail (celui de la liste), et allait donc chercher le reste. De l’autre, il avait sous la main les outils pour parcourir la boîte, et rien ne l’en empêchait.

Plusieurs garde-fous ont été ajoutés, chacun pour une raison précise.

Le mail reçu ouvert en entier avant le travail. La surveillance ouvre elle-même le message complet (corps coupé à 8 000 caractères, sans le message brut ni les identifiants de pièces jointes) et le donne au travail. Le modèle n’a plus de raison d’aller le chercher.

Les outils de parcours retirés pendant ce travail. Un travail lancé pour un mail traite ce mail, lui seul. Les outils qui listent la boîte lui sont retirés, y compris des résultats de chercher_un_outil : sinon, il les retrouverait par la recherche.

12 étapes au plus pour une mission déclenchée, au lieu de 30 pour un travail ordinaire, et un budget de 600 000 jetons envoyés par travail :

/** Une mission lancée par un élément (un mail, une commande) le traite, lui seul : moins d'échanges. */
private const int MAX_TURNS_TRIGGERED = 12;
/** Jetons envoyés au modèle pour un travail ; au-delà, il fait le point. */
private const int MAX_INPUT_TOKENS = 600000;
/** Le même outil appelé à tant d'échanges d'affilée : refusé, il conclut. */
private const int REPEAT_LIMIT = 5;

L’outil répété refusé. Quand le même outil d’un service est appelé seul cinq étapes d’affilée, il n’est plus exécuté. Le modèle reçoit à la place un résultat en erreur qui lui dit quoi faire :

'Tu as appelé cet outil '.$streak.' fois d\'affilée : arrête de chercher, fais avec ce
que tu as déjà, puis appelle terminer avec ta réponse.'

Ses propres outils (terminer, rendre compte, retenir une note, chercher un outil) ne comptent pas dans la série : les répéter ne coûte rien chez un service.

L’appel identique non refait. Si le modèle demande exactement le même appel (même outil, mêmes arguments, dans un ordre normalisé) qu’un appel déjà réussi dans ce travail, il ne repart pas :

'Tu as déjà fait exactement cet appel dans ce travail : son résultat est plus haut.
Sers-t\'en, ou change ta demande.'

Ce garde-fou a un double intérêt. Il économise une étape, bien sûr. Mais surtout, il évite d’envoyer deux fois le même mail ou de créer deux fois le même rendez-vous. Un appel en échec, lui, peut être retenté : un service passagèrement indisponible ne doit pas bloquer le travail.

Le choix de répondre par une erreur explicite plutôt que de couper net la boucle est délibéré. Le modèle lit le refus comme n’importe quel résultat d’outil, et il conclut avec ce qu’il a. Le travail se termine avec une réponse, pas avec une exception.

Enfin, chaque travail terminé est relu par une classe Behaviour, qui repère ce qui a coûté sans servir : le même appel refait au moins deux fois de trop, le même outil appelé à cinq étapes ou plus (dix pour la recherche web, où plusieurs recherches différentes sont normales pour une veille), la limite d’étapes atteinte, plus de 300 000 jetons envoyés. Les avertissements vont sur le travail, dans le suivi d’administration, et pour une mission, une fois par jour au plus, dans le journal du manager : « Cette mission a travaillé plus que nécessaire. Revoyez sa consigne : elle doit traiter ce qui la déclenche, pas tout reparcourir. » Une boucle n’est pas toujours un défaut du code ; c’est parfois une consigne mal écrite, et c’est à la personne qui l’a écrite de le savoir.

Pour valider l’ensemble, un test lance chaque mission du catalogue avec le vrai modèle : 95 missions pour 1,59 $, soit environ 0,017 $ par mission. À comparer aux 0,60 $ d’un seul mail traité par la mission qui bouclait.

6. Couper la réflexion de la vision

Les employés lisent aussi des images : une facture photographiée, une capture d’écran, un document scanné. Cette lecture passait par un modèle qui voit, chez un autre fournisseur, avec sa propre clé et son propre réglage. En regroupant toute l’IA de la plateforme chez un seul fournisseur (une adresse, une clé, un modèle : LLM_URL, LLM_API_KEY, LLM_MODEL), la vision a pris le même modèle que les employés, et surtout le même réglage de réflexion.

Le travail de la vision, c’est de transcrire fidèlement le texte d’une image et de dire ce qu’elle montre. Il n’y a rien à déduire. La consigne le dit d’ailleurs dès sa première phrase : « Réponds directement, sans phrase d’introduction ni bloc de code. Transcris fidèlement tout le texte de ce document… ». Le réglage suit :

/** comme l'IA des employés : sans réflexion (none), une transcription est plus rapide et jamais coupée */
#[Autowire(env: 'LLM_REASONING')] private string $reasoning,
…
} elseif ('' !== $this->reasoning) {
    $body['reasoning_effort'] = $this->reasoning;
}

Effet mesuré : 2,5 secondes au lieu de 17 à 27 secondes par image, et plus de transcription vide. La réflexion consommait le budget de la réponse avant que la transcription n’arrive. Ce point est déjà développé dans l’article sur le petit modèle bien guidé : quand la réponse est dans les données, la réflexion est du temps perdu.

7. Le cache et le prix

Dernier étage : compter juste. Le suivi des coûts de la plateforme sert à appliquer les budgets IA de chaque compte. Il calculait le coût d’un échange avec deux prix, l’entrée et la sortie. Or une bonne partie de ce qu’on envoie à chaque étape est identique d’une étape à l’autre : la consigne de l’employé, ses outils, le début de la conversation. Le fournisseur le garde en cache, et le facture beaucoup moins cher.

Les prix réglés pour le modèle utilisé, en dollars par million de jetons, au tarif des heures pleines (les heures creuses coûtent moitié moins) :

JetonsPrix par million
Entrée0,30 $
Entrée déjà en cache0,006 $
Sortie1,20 $

Un facteur 50 entre l’entrée normale et l’entrée en cache. Ignorer le cache, c’était surestimer le coût, et donc risquer d’arrêter trop tôt un employé qui n’avait pas dépassé son budget. Le calcul tient en une ligne :

$cached = min($cachedInputTokens, $inputTokens);

return (int) round(($inputTokens - $cached) * $this->inputPrice
    + $cached * $this->cachedPrice + $outputTokens * $this->outputPrice);

Le nombre de jetons en cache est lu dans la réponse du fournisseur, sous deux noms possibles selon l’API : prompt_cache_hit_tokens chez DeepSeek, prompt_tokens_details.cached_tokens au format OpenAI.

Le cache ne rend pas pour autant le poids des outils indolore. Il le rend moins cher, pas plus léger : le modèle relit toujours tout, ce qui coûte du temps de traitement, et la fenêtre de contexte reste occupée. Et le cache ne joue que sur ce qui ne change pas. Ce que l’agent produit à chaque étape (ses appels, les résultats des services) est neuf, et facturé au prix plein.

Et la suite logique est déjà engagée : quand un service a un serveur MCP officiel ou une API simple, la plateforme y va en direct plutôt que par le catalogue. Le fichier qui décrit les services en recense aujourd’hui 79 : 21 par leur serveur MCP officiel, 26 par une API avec clé, 32 encore par Composio. Pour la voie par clé, la description de chaque action est écrite à la main, en français et au plus court : une trentaine de caractères en moyenne. Aucun chiffre de gain sur les jetons n’est encore avancé pour ce passage en direct : à mon sens, il reste à le mesurer sur un vrai compte.

Les leçons

  • Mesurez ce que vous envoyez, pas seulement ce que vous payez. Le prix au million de jetons ne dit rien si on ne sait pas combien de jetons partent à chaque étape. Ici, l’essentiel était dans les définitions d’outils, pas dans la demande.
  • Un outil est un loyer, pas un achat. Il est relu à chaque étape de chaque travail. Donnez d’office ce qui sert tous les jours, et un moyen de trouver le reste.
  • Allégez le texte, pas le contrat. Les descriptions peuvent maigrir, les paramètres obligatoires non. C’est cette frontière qui permet de couper sans casser.
  • Vérifiez contre la référence d’origine. Les appels produits avec les schémas allégés ont été validés contre le schéma complet du service, pas contre la version raccourcie.
  • Le nombre d’étapes multiplie tout le reste. Donnez à l’agent ce qu’il doit traiter, retirez-lui ce qui l’invite à tout parcourir, et posez des limites qui répondent par une consigne plutôt que par une coupure.
  • Un appel identique non refait, c’est aussi une protection. Il économise une étape, et il évite le mail envoyé deux fois.
  • Une boucle est souvent une consigne à revoir. Relire les travaux terminés et le dire à la personne qui a écrit la mission vaut mieux qu’une limite silencieuse.
  • Comptez le cache. Sans lui, le coût affiché est faux, et les budgets s’appliquent trop tôt.
En résumé : la facture d’un agent ne se joue pas seulement sur le prix du modèle, mais sur ce qu’on lui fait relire à chaque étape et sur le nombre d’étapes. Moins d’outils donnés d’office, des descriptions courtes qui gardent intacts les paramètres requis, des garde-fous contre les boucles et pas de réflexion là où il n’y a rien à déduire : 197 000 caractères d’outils sont devenus 85 000, et la lecture d’une image est passée de 17 à 27 secondes à 2,5 secondes.