Ce que coûte vraiment un agent de revue de code
Un agent branché sur les pull requests d'une équipe de six personnes : le compte de jetons réel, ce qu'il a trouvé que la CI ne trouvait pas, et les trois réglages qui ont divisé la facture par quatre.
« On branche un agent sur les pull requests » est une phrase qui coûte entre trois euros et trois cents euros par mois selon la façon dont on l’implémente, pour une qualité de revue à peu près identique. L’écart ne vient pas du modèle choisi : il vient de ce qu’on lui envoie.
Voici le relevé d’un trimestre sur un dépôt réel — six développeurs, environ 40 pull requests par semaine, un monorepo TypeScript — et les trois changements qui ont fait l’essentiel de la différence.
Comment compter, avant de discuter du prix
Le prix par million de jetons change, dépend du modèle et se lit sur la page tarifaire du fournisseur. Le nombre de jetons, lui, dépend de vous, et c’est la seule variable sur laquelle vous pouvez agir. C’est donc celle qu’il faut journaliser.
Toute réponse d’API renvoie son compte d’usage. Il suffit de l’écrire quelque part avec de quoi l’attribuer :
const reponse = await client.messages.create({
model: 'claude-sonnet-5',
max_tokens: 4096,
messages,
});
// Journaliser AVANT de se demander si c'est cher : sans ces lignes,
// toute discussion sur le coût est une conversation d'opinion.
journal.info('revue_terminee', {
pr: contexte.numeroPR,
depot: contexte.depot,
fichiers_envoyes: fichiersRetenus.length,
entree: reponse.usage.input_tokens,
entree_cache_ecriture: reponse.usage.cache_creation_input_tokens,
entree_cache_lecture: reponse.usage.cache_read_input_tokens,
sortie: reponse.usage.output_tokens,
});Le coût mensuel se reconstitue ensuite par une agrégation, en appliquant les tarifs en vigueur à chaque colonne — les jetons lus depuis le cache et les jetons d’entrée normaux n’ont pas le même prix, et c’est précisément ce déséquilibre qu’on va exploiter.
Le relevé de départ
Première version, la plus naïve : à chaque pull_request ouverte ou synchronisée, envoyer
le diff complet plus les fichiers touchés en entier.
| Médiane par PR | Pire cas observé | |
|---|---|---|
| Fichiers envoyés | 14 | 210 |
| Jetons d’entrée | 48 000 | 610 000 |
| Jetons de sortie | 900 | 2 400 |
| Appels par PR | 3,8 | 17 |
3,8 appels par pull request : c’est le point qui saute aux yeux. L’agent était déclenché
sur synchronize, donc chaque git push --force pendant une revue relançait une analyse
complète. L’essentiel de la facture payait la relecture de code déjà relu.
Changement 1 — ne pas relire ce qui n’a pas bougé
Le déclencheur est passé de « chaque poussée » à « la première poussée, puis seulement sur demande explicite », avec un anti-rebond :
on:
pull_request:
types: [opened, ready_for_review]
issue_comment:
types: [created]
concurrency:
group: revue-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: true
jobs:
revue:
# Sur commentaire, n'agir que sur "/revue" dans une PR.
if: >
github.event_name == 'pull_request' ||
(github.event.issue.pull_request && startsWith(github.event.comment.body, '/revue'))Les brouillons sont exclus par construction : ready_for_review ne se déclenche que quand
l’auteur estime son travail lisible. C’est aussi, accessoirement, le moment où une revue
sert à quelque chose.
3,8 → 1,2 appel par PR. Soit −68 % de facture pour zéro perte : les 2,6 appels supprimés relisaient des versions intermédiaires que personne n’a jamais ouvertes.
Changement 2 — envoyer le diff, pas le dépôt
Le réflexe « donnons-lui tout le contexte » est coûteux et contre-productif. Un fichier de 2 000 lignes dont 6 ont changé noie les 6 lignes qui comptent.
const IGNORES = [
/^package-lock\.json$/, // bruit pur : des milliers de lignes, zéro jugement à porter
/^(dist|build|\.next)\//,
/\.(snap|svg|png|jpg|woff2?)$/,
/\.generated\.ts$/,
];
const fichiersRetenus = fichiersDuDiff
.filter((f) => !IGNORES.some((re) => re.test(f.chemin)))
.filter((f) => f.statut !== 'removed') // relire du code supprimé n'apporte rien
.filter((f) => f.ajouts + f.suppressions <= 400)
.slice(0, 25); // au-delà, la PR doit être découpée, pas analyséePour chaque fichier retenu, l’agent reçoit le hunk modifié avec vingt lignes de contexte de part et d’autre, et non le fichier entier. S’il lui manque quelque chose, il dispose d’un outil pour demander la lecture d’un fichier précis — ce qu’il fait dans moins d’une revue sur cinq, à un coût bien inférieur à celui d’envoyer tout systématiquement.
La limite de 25 fichiers est une décision de produit, pas une économie : au-delà, l’agent poste « cette pull request touche 60 fichiers, elle devrait être découpée » et s’arrête. C’est le commentaire le plus utile qu’il puisse produire dans ce cas.
48 000 → 11 000 jetons d’entrée médians.
Changement 3 — mettre en cache ce qui ne change jamais
L’instruction système — conventions du dépôt, ce qu’il faut signaler, ce qu’il faut ignorer, format de sortie — faisait 3 400 jetons, renvoyés intégralement à chaque appel. Marquée comme cacheable, elle est facturée une fois à l’écriture puis à un tarif réduit en lecture :
const reponse = await client.messages.create({
model: 'claude-sonnet-5',
max_tokens: 4096,
system: [
{
type: 'text',
text: instructionsDuDepot, // stable d'un appel à l'autre
cache_control: { type: 'ephemeral' },
},
],
messages: [{ role: 'user', content: diffFormate }],
});Le préfixe doit être strictement identique d’un appel à l’autre : une date, un numéro
de PR ou un nom de branche glissés dans l’instruction système invalident le cache à chaque
fois. Ce genre de détail se vérifie dans le journal — cache_read_input_tokens à zéro sur
tous les appels signifie que le cache ne sert à rien, et c’est passé inaperçu pendant deux
semaines chez nous parce que personne ne regardait cette colonne.
Sur 40 PR par semaine, le cache reste chaud une bonne partie de la journée ouvrée.
Le compte final
| Version 1 | Version 3 | |
|---|---|---|
| Appels par PR | 3,8 | 1,2 |
| Jetons d’entrée par PR (médiane) | 48 000 | 11 000 |
| dont lus depuis le cache | 0 | 3 400 |
| Jetons de sortie par PR | 900 | 1 100 |
| Jetons d’entrée facturés / semaine | ≈ 7,3 M | ≈ 0,41 M |
Un facteur dix-huit sur l’entrée, sans changer de modèle et sans dégrader la revue — les commentaires sont même devenus plus pertinents, parce que le signal n’est plus noyé. Multipliez par le tarif en vigueur du modèle que vous utilisez pour obtenir votre facture : le calcul tient sur une ligne, et c’est le seul chiffre qui vous concerne.
Ce qu’il trouve, et ce qu’il ne trouve pas
Sur un trimestre, 213 commentaires postés, triés à la main :
| Catégorie | Part | Utile ? |
|---|---|---|
| Cas limite non couvert (nul, tableau vide, erreur non gérée) | 31 % | oui, régulièrement |
| Incohérence avec une convention du dépôt | 24 % | oui |
| Test manquant sur une branche ajoutée | 18 % | oui |
| Reformulation de ce que le code fait déjà | 15 % | non, bruit |
| Faux positif (contexte manquant) | 12 % | non |
Ce qu’il attrape que la CI n’attrape pas : la cohérence. Un linter ne sait pas que ce
dépôt renvoie ses erreurs par un type Resultat et pas par des exceptions ; l’agent, à qui
on l’a écrit une fois, le signale à chaque écart. C’est répétitif, jamais fatigué, et
c’était exactement le genre de remarque que les relecteurs humains cessaient de faire après
la troisième fois.
Ce qu’il n’attrape pas : tout ce qui demande de connaître l’intention. Aucun des trois bugs sérieux du trimestre — une logique de facturation inversée, une condition de course sur un travail de fond, une migration non réversible — n’a été signalé. Ils étaient parfaitement corrects en tant que code. Ils étaient faux en tant que décision.
Un quart des commentaires est du bruit. C’est le chiffre qu’il faut annoncer à l’équipe avant de brancher l’agent, sinon la confiance se perd sur les premiers faux positifs et l’outil est désactivé au bout d’un mois.
Ce que je ferais différemment
Journaliser l’usage dès le premier jour. Nous avons ajouté ces quatre lignes après six semaines. Les six premières semaines sont donc une zone d’ombre, et je ne peux pas dire ce qu’elles ont coûté.
Ne pas lui donner le droit de bloquer la fusion. Il commente, il ne vote pas. Un agent qui bloque une pull request sur un faux positif à 19 h un vendredi est un agent qu’on désactive le lundi.
Écrire les conventions du dépôt d’abord. L’essentiel de la qualité des commentaires vient de ce fichier d’instructions, pas du modèle. Une journée passée à le rédiger a plus amélioré les revues que n’importe quel changement de configuration décrit ici.