Mathieu Bernard Réserver 30 min

Coût d’un agent IA : la facture ne se lit pas dans le prix du modèle

Le prix unitaire ne dit rien sans le volume envoyé à chaque étape ni le nombre d’étapes. Moins d’outils donnés d’office, des descriptions raccourcies et des boucles bornées ont fait passer la plateforme de 197 000 à 85 000 caractères d’outils par étape.

Quand une direction veut maîtriser le coût d’un agent IA, elle regarde presque toujours la même ligne : le prix du modèle, au million de jetons. On compare les fournisseurs, on descend en gamme, on négocie. C’est un réflexe d’acheteur, et il ne suffit pas : le prix unitaire ne dit rien tant qu’on ne sait pas combien de jetons partent à chaque étape, ni combien d’étapes l’agent fait pour un travail. Ces deux volumes dépendent de choix de conception qui n’apparaissent pas sur la facture, mais qu’une DSI peut demander à voir.

Sur le projet étudié ici, une plateforme d’employés IA, 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 : l’essentiel venait des définitions d’outils relues en boucle, et d’un travail capable de faire trente étapes pour un seul mail.

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.

C’est la première question à poser à l’équipe ou au prestataire qui exploite un agent : combien de jetons partent à chaque étape avant même la demande, et de quoi sont‑ils faits ? Si personne ne sait répondre, le coût d’exploitation n’est pas piloté, il est subi.

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.

Sur une facture mensuelle, un écart de cet ordre se voit vite : il porte sur chaque étape de chaque travail de chaque employé. Et il n’a demandé ni changement de fournisseur, ni renégociation, ni modèle moins capable.

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.

La méthode compte autant que le résultat, et c’est ce qu’il faut exiger quand une optimisation de coût est proposée : contre quelle référence a-t‑elle été vérifiée ? Une économie qui dégrade la fiabilité des appels se paie ailleurs, en étapes supplémentaires et en erreurs chez les services.

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.

Ce cas résume le risque budgétaire d’un agent : ce n’est pas le coût moyen qui surprend, ce sont les cas extrêmes. Un travail ordinaire coûte peu, un travail qui boucle coûte bien davantage, et sans plafond rien ne l’arrête. Une moyenne mensuelle ne le montre pas ; le coût par travail, si.

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.

Ce retour a une portée qui dépasse la technique : il rend la dépense visible à celui qui la provoque. Une DSI qui suit le coût de ses agents a intérêt à demander ce type d’indicateur par mission, et pas seulement un total mensuel par fournisseur.

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 : c’est l’écart entre un agent cadré et un agent laissé libre de tout parcourir.

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.

C’est un réglage qui ne coûte rien à changer et qui joue à la fois sur le délai et sur la facture. La question à poser à l’équipe est simple : quelles tâches ont réellement besoin que le modèle raisonne, et lesquelles se contentent de lire et de transcrire ?

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. C’est aussi une question de confiance dans les chiffres : un suivi de coûts qui ignore le cache n’est pas prudent, il est faux, et les décisions prises dessus (plafonds, refacturation, choix de fournisseur) le sont aussi. 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. Ce passage a un autre intérêt, qui n’est pas budgétaire : chaque service branché en direct dépend d’un intermédiaire de moins.

Les leçons, et ce qu’il faut suivre

  • Demandez ce qui part à chaque étape, pas seulement le prix au million. Le prix unitaire 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, et aucune négociation tarifaire ne l’aurait fait apparaître.
  • 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 ; chaque outil donné d’office doit justifier sa place.
  • 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.
  • Exigez une vérification 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. Une optimisation de coût sans ce contrôle déplace le problème.
  • 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 un plafond d’étapes et de jetons par travail, avec 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.
  • Suivez le coût par mission, et dites-le à qui l’a écrite. Une boucle est souvent une consigne à revoir. Un total mensuel la cache ; un avertissement sur le travail, envoyé à la personne qui a écrit la mission, la corrige.
  • Comptez le cache. Sans lui, le coût affiché est faux, les budgets s’appliquent trop tôt, et les comparaisons entre fournisseurs aussi sont faussées.
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. Pour une DSI, piloter ce coût, c’est suivre trois volumes : ce qui part à chaque étape, le nombre d’étapes par travail, et la part servie par le cache.