TL;DR
- La cause n°1 d'un agent qui meurt en prod : la non-idempotence. Un restart ou un retry refait une action irréversible (mail, facture, commande) deux fois.
- Toute action irréversible a besoin d'une clé d'idempotence, une clé métier déterministe, vérifiée avant d'agir.
- Dry-run avant la première vraie action : on diffe ce qui se passerait, on n'agit pas à l'aveugle.
- Human-in-the-loop pour les cas sensibles ou ambigus, on escalade, on n'auto-envoie jamais du non-validé.
- La livraison réelle est at-least-once. L'idempotence est ce qui rend l'at-least-once sûr.
« Ça marchait une fois dans le notebook »
On vit un moment étrange. Les démos d'agents IA sont partout : un agent qui lit ta boîte mail, un agent qui passe des commandes, un agent qui gère ta trésorerie. C'est impressionnant, et c'est presque toujours faux. Pas faux dans le sens « ça ne marche pas », faux dans le sens « ça marche une fois, dans des conditions parfaites, sous le regard de son créateur ».
La production, c'est l'inverse de la démo. Personne ne regarde. Le process redémarre à 3h du matin parce que le serveur a OOM. Un cron se relance. Un retry réseau rejoue la requête. Un utilisateur recharge la page, ou laisse deux onglets ouverts. Et là, l'agent qui marchait si bien refait son action. Sauf que cette action, elle, n'est pas rejouable sans dégât.
La règle d'or des agents en prod : suppose que chaque action sera tentée au moins deux fois. Conçois pour le redémarrage, pas pour le chemin heureux.
L'idempotence, en une phrase
Une opération est idempotente si l'exécuter dix fois produit exactement le même résultat que l'exécuter une fois. Régler un thermostat à 21° est idempotent. Augmenter la température de 1° ne l'est pas. Envoyer un email, créer une facture, passer une commande : par défaut, aucune de ces actions n'est idempotente. Chaque appel crée un nouvel effet de bord dans le monde réel.
Le piège, c'est que la non-idempotence est invisible en démo. Tu lances l'agent une fois, ça marche, tu passes à autre chose. Le bug ne se révèle qu'au deuxième passage, et le deuxième passage, en production, n'est pas une question de si mais de quand. Un simple restart devient un incident.
Le cas concret : un SaaS automobile, une API bancaire, et de la vraie monnaie
Passons du principe au réel. Sur un SaaS B2B de l'automobile, j'ai construit une intégration de facturation reliée à une API bancaire tierce, la trésorerie de l'entreprise. Pas une simulation : de vrais appels API, de vraies factures, des documents qui ont une valeur légale et comptable. Le genre de système où une erreur ne produit pas un mauvais pixel, mais un double prélèvement.
Le scénario d'horreur est trivial à déclencher. L'utilisateur resoumet un formulaire. La page recharge. Il a deux onglets ouverts. Un retry HTTP rejoue silencieusement la requête. N'importe lequel de ces événements, sans protection, crée une facture en double via l'API bancaire. Une vraie facture, avec un vrai montant, dans la vraie compta. Multiplie par le volume, et tu as un incident financier déclenché par un rechargement de page.
DELETE. C'est un document comptable émis, parfois déjà transmis, parfois déjà payé. Le coût du défaut n'est pas technique, il est légal et financier. C'est exactement le terrain où l'idempotence cesse d'être une option.Première ligne de défense : la guard clause
La protection la plus simple, et la plus importante : avant de créer, on cherche si ça existe déjà. Concrètement, une fonction findActiveInvoiceForDeal qui interroge la base pour toute facture active (non annulée) liée à ce deal / cette inscription. Si elle en trouve une, on bloque la création et on renvoie l'existante. Pas de doublon, point.
C'est l'incarnation de la clé d'idempotence : on ne fait pas confiance à un flag « déjà traité » qui traîne en mémoire, on demande au système de vérité (la base) si l'effet de bord a déjà eu lieu. Le deal lui-même est la clé d'idempotence : à un deal correspond au plus une facture active.
// avant TOUTE création, on interroge la vérité
const existing = await findActiveInvoiceForDeal(dealId);
if (existing) {
// l'effet de bord a déjà eu lieu → on renvoie l'existant
return { invoice: existing, created: false };
}
// sinon seulement, on agit, voir plus bas pour la transaction
On double cette protection par une déduplication sur clé métier : un MD5 / une empreinte déterministe de l'opération logique, pour qu'une même opération « créer la facture du deal X au montant Y » mappe toujours vers un seul enregistrement, même si deux requêtes arrivent en parallèle à la milliseconde.
La file d'attente et le marquage atomique
La guard clause protège contre les doublons synchrones. Mais il reste le cas du crash en plein milieu d'un batch. Imagine un traitement par lot sur une file bank_pending_invoices : on traite 200 deals, le process meurt au 137ᵉ. Que se passe-t-il au restart ? Sans précaution, on recommence à zéro et on refacture les 136 premiers.
La parade : une fonction markPendingInvoicesProcessed() qui marque les deals comme traités atomiquement, et seulement après le succès de la création. Un deal n'est retiré de la file que quand sa facture existe vraiment. Si le crash survient avant ce marquage, le restart reprend le deal, mais la guard clause voit que la facture existe déjà, et passe son chemin. Les deux mécanismes se complètent : la file gère la reprise, la guard gère le doublon.
Les cas qui ne doivent PAS être automatisés
Un agent en production doit aussi savoir ne pas agir. Sur ce SaaS, certains clients sont en facturation « one-time », ils ne doivent surtout pas être refacturés automatiquement par le batch. La tentation naïve, c'est de les inclure dans la boucle « parce que le code est plus simple ». La bonne décision, c'est de les bypasser explicitement et de lever une alerte Sentry à la place.
C'est le principe du human-in-the-loop : pour tout cas sensible ou ambigu, l'agent n'auto-exécute pas, il escalade. Une alerte, un message dans un canal #review, et un humain tranche. Un agent qui sait dire « je ne suis pas sûr, regarde ça » est infiniment plus déployable qu'un agent qui fonce avec confiance dans tous les cas.
La transaction : ton meilleur ami
Toute la création de facture est enveloppée dans une transaction MySQL. Soit tout réussit, l'enregistrement en base, la génération du PDF, l'upload, le marquage, soit tout est annulé. Pas d'état intermédiaire bancal où la facture existe en base mais pas côté API bancaire, ou l'inverse. La transaction, c'est ce qui rend un effet de bord composite atomique.
Détail gagné dans la douleur : j'ai dû monter l'innodb_lock_wait_timeout à 120s. Par défaut il est à 50s, et la génération + upload du PDF est lente. Sous concurrence, deux traitements se bloquaient mutuellement et l'un mourait sur un deadlock avant la fin de l'upload. Allonger le timeout a laissé le temps aux verrous de se libérer proprement. Le genre de réglage qu'on ne voit jamais en démo, et qui décide si le système tient ou pas en prod.
// l'action idempotente complète, en pseudo-code
async function createInvoiceIdempotent(deal) {
const key = businessKey(deal); // clé déterministe (deal + montant)
// 1. la clé a-t-elle déjà été vue ?
if (await findActiveInvoiceForDeal(deal.id)) {
return noop("already-invoiced"); // → no-op, zéro effet de bord
}
if (isOneTimeClient(deal)) {
return escalateToHuman(deal); // → Slack #review + Sentry
}
// 2. agir, dans une transaction atomique
await tx(async (t) => { // innodb_lock_wait_timeout = 120s
const invoice = await bank.createInvoice(deal, t);
const pdf = await renderAndUploadPdf(invoice, t);
await markPendingInvoicesProcessed([deal.id], t); // 3. enregistrer la clé
}); // commit = tout, ou rollback = rien
}
La doctrine, généralisée à tout agent IA
Ce cas n'est qu'un exemple. Le même squelette s'applique à n'importe quel agent IA en production qui touche au monde réel, c'est exactement la doctrine qu'on applique chez Gérer.ai. Cinq règles, non négociables :
- Clé d'idempotence sur chaque action irréversible. Une clé métier déterministe, vérifiée avant d'agir. Si la clé existe déjà, on ne refait pas, on renvoie l'existant.
- Simuler avant d'agir. Un mode dry-run qui diffe ce qui se passerait, avant la première vraie action. On valide le plan, puis on l'exécute.
- Human-in-the-loop sur le sensible et l'ambigu. On escalade vers un humain (un
#reviewSlack) plutôt que d'auto-envoyer du non-validé. L'agent a le droit de dire « je ne sais pas ». - Observabilité : tracer chaque étape. Un log par décision, un trace par run. Un rejeu doit être auditable : qui a fait quoi, quand, et pourquoi l'agent a tranché ainsi.
- At-least-once est la réalité ; l'idempotence est ce qui la rend sûre. On n'essaie pas d'avoir un « exactly-once » magique. On rend chaque traitement rejouable sans dégât.
Ce que je retiens
- « Ça marchait une fois dans un notebook » n'est pas de la production. La démo prouve que le chemin heureux existe. La prod, c'est tous les autres chemins.
- L'idempotence n'est pas un nice-to-have. C'est le prix d'entrée pour faire tourner quoi que ce soit sans surveillance. Pas d'idempotence, pas d'agent autonome.
- La transaction base de données est ton amie. Elle transforme un effet de bord composite et fragile en une opération atomique : tout, ou rien. Et parfois il faut juste régler le bon timeout.
- Conçois pour le restart, pas pour le happy path. Pose-toi une seule question avant de déployer un agent : « que se passe-t-il si ce process meurt maintenant et redémarre ? » Si la réponse t'inquiète, tu n'es pas prêt.
Un agent IA fiable, ce n'est pas un agent plus intelligent, c'est un agent qui survit au redémarrage, escalade ce qu'il ne maîtrise pas, et ne fait jamais deux fois ce qui ne doit arriver qu'une seule fois. Ce n'est pas glamour. C'est ce qui sépare une démo d'un système en production.
Des agents IA qui tiennent vraiment en production ?
C'est le cœur de Gérer.ai : des agents idempotents, avec human-in-the-loop, déployés sur votre infra et souverains. On ne fait pas des démos qui marchent une fois, on construit des systèmes qui tiennent quand personne ne regarde.
Découvrir Gérer.ai ↗