Aller au contenu
gaetancottrez.dev

Remplacer un moteur maison sans régression : pourquoi j'ai parié quatre fois avant d'écrire la première ligne

Publié: at 06:00 | (13 min de lecture)

Table des matières

Ouvrir table des matières

Le refactoring le plus tentant du monde

Vous avez peut-être, quelque part dans un projet, un de ces moteurs maison. Le mien était une boucle d’agent : le code qui orchestre un modèle de langage local, lui présente des outils, interprète ses réponses, gère ses ratés. Elle avait grossi organiquement — un parsing JSON maison, des retries maison, des adaptateurs maison pour chaque famille de modèles. Elle marchait. « À peu près », comme marchent tous les moteurs maison : chaque comportement correct avait été payé par un bug, et personne n’avait envie de la toucher.

Et puis le marché a rattrapé le sujet, comme toujours. Des frameworks d’agents open source solides pour les modèles locaux, des SDK d’agents fournis par les acteurs du cloud. La tentation devient énorme : jeter la boucle artisanale, brancher les moteurs du marché, supprimer mille lignes de code que plus personne n’aime.

Alors, disons-le franchement : c’est le refactoring le plus tentant et le plus dangereux qui soit. Tentant, parce que le gain est évident — moins de code à maintenir, des moteurs testés par des milliers d’utilisateurs. Dangereux, parce que tout marche « à peu près » avant, et que rien — absolument rien — ne garantit qu’après. Les comportements durement acquis de votre moteur maison, ceux qui ont chacun coûté un incident, ne sont écrits nulle part dans la doc du framework qui va le remplacer.

Cet article raconte comment j’ai mené cette migration sans en subir une seule régression. Pas par talent, ni par chance : par méthode. Trois temps — un « lot zéro » de paris mesurés, des invariants verrouillés, une migration par lots — et un chiffre que vous n’oublierez pas : ×122.

Le décor : une boucle maison, deux moteurs du marché

Posons le problème. L’objectif était de remplacer la boucle de décision maison par deux moteurs du marché derrière un protocole commun : un framework d’agents open source pour la voie locale, et le SDK d’agents d’un fournisseur pour la voie cloud. Deux moteurs, parce que je tiens à pouvoir changer de crémerie — vous me connaissez, l’anti-vendor-lock-in est un fil rouge chez moi : le domaine parle à un port, les moteurs sont des adaptateurs, et aucun des deux ne dicte l’architecture.

Mais le vrai sujet n’est pas là. Le vrai sujet, c’est la liste des comportements que la boucle maison avait durement acquis et qu’il était hors de question de perdre :

Chacun de ces comportements avait une histoire, en général douloureuse. Et chacun pouvait silencieusement disparaître dans la migration : les frameworks du marché ont leurs propres opinions sur les erreurs, les timeouts et les sorties.

Lot zéro : transformer chaque incertitude en pari

Le réflexe classique devant une migration pareille, c’est de commencer à migrer « pour voir ». On branche le framework, on bricole jusqu’à ce que ça tourne, et on découvre les problèmes au fil de l’eau — c’est-à-dire au pire moment, quand la moitié du code est déjà convertie et qu’on ne peut plus reculer sans tout jeter.

J’ai fait l’inverse : un lot zéro entièrement dédié à lever les incertitudes, avant toute ligne de migration. Le principe tient en une phrase : chaque « est-ce que le framework sait faire X ? » devient un spike autonome avec un protocole PASS/FAIL explicite et une mesure chiffrée. Pas un essai vague — un pari, au sens propre : formulé avant, tranché par un chiffre, avec une conséquence définie dans les deux sens. Quatre incertitudes, donc quatre paris.

Le lot zéro : quatre paris mesurés avant la première ligne

Pari n°1 : le canal d’erreur métier survit-il ?

Le test : un outil de test qui refuse systématiquement le premier choix de l’agent et propose des alternatives. PASS si l’agent encaisse le refus comme une observation — pas un crash, pas une exception avalée — et retente de lui-même sur une alternative.

Résultat : auto-correction en 2 appels sur toutes les combinaisons moteur × modèle testées. Le refus revient au modèle, le modèle choisit un créneau libre, personne ne s’énerve. Pari gagné.

Pari n°2 : sortie structurée et contrôle du thinking en local ?

Les modèles récents « réfléchissent » avant de répondre — un budget de tokens de raisonnement, réglable. Le pari : peut-on obtenir une sortie structurée fiable tout en contrôlant ce budget, sur les modèles locaux ?

Vérifié sur trois modèles, et voilà le genre de découverte qui justifie tout le lot zéro : la règle change par famille de modèle. L’une accepte qu’on coupe le thinking à zéro sans broncher. L’autre, si on le coupe complètement, casse sa sortie structurée — silencieusement, évidemment : il faut le laisser au minimum, jamais à zéro. Aucune doc ne vous le dira ; seul un spike qui mesure le taux de sorties valides le révèle.

Pari n°3 : une question bloquante traverse-t-elle le moteur ?

Le test : un outil qui bloque 30 secondes en attendant une réponse humaine. PASS si le moteur ne timeout pas, ne court-circuite pas l’outil, et reprend le flux avec la réponse. Les deux moteurs passent. Bien entendu, j’aurais pu le supposer — c’est précisément ce que « supposer » veut dire : découvrir en production que non.

Pari n°4 : le SDK cloud, à quel coût de préambule ?

Et voici la vedette de l’article. Les SDK d’agents des fournisseurs cloud arrivent avec des piles incluses : des outils par défaut, un préambule système, tout un environnement préchargé. Confortable. Mais combien ça coûte, avant même votre premier mot ?

La mesure : le même appel, dans les mêmes conditions, consomme 163 tokens avec la liste d’outils vidée… contre environ 19 900 avec les outils par défaut du SDK. Faites la division : ×122. Cent-vingt-deux fois plus cher, par appel, pour des outils dont mon agent n’avait pas l’usage.

Autant dire que ce chiffre a changé de statut dans la minute : ce qui aurait été un « détail de configuration » enfoui dans un constructeur est devenu un contrat non négociable du projet — la liste d’outils du SDK est vidée, toujours, et un test le verrouille. Un réglage d’usine qui multiplie votre facture par deux ordres de grandeur n’est pas un réglage, c’est un piège à loup.

Le verdict

Quatre paris, quatre GO, tous chiffrés. Mais comprenez bien la vraie valeur du dispositif : un seul FAIL aurait arrêté la migration avant qu’elle ne coûte quoi que ce soit. C’est ça, un pari honnête — il peut se perdre, et perdre au lot zéro est une victoire : une matinée de spike contre des semaines de migration à jeter.

On veut du code !

À quoi ressemble un spike-pari, concrètement ? À presque rien — et c’est le but. Voici la forme générique du pari n°1, reconstruite pour l’exemple :

// Spike GO/NO-GO : le refus métier revient-il au modèle comme observation ?
// PASS = l'agent propose une alternative valide en ≤ N appels, sans crash.

const bookingTool = defineTool({
  name: "book_slot",
  description: "Réserve un créneau",
  handler: async ({ slot }) => {
    if (slot === "09:00") {
      // Le REFUS est une donnée, pas une exception :
      return {
        booked: false,
        reason: "slot 09:00 already taken",
        alternatives: ["10:00", "11:00", "14:00"],
      };
    }
    return { booked: true, slot };
  },
});

const run = await engine.runAgent({
  prompt: "Réserve-moi un créneau demain matin, commence par 09:00.",
  tools: [bookingTool],
  maxTurns: 4,
});

// Le verdict est un chiffre, pas une impression :
const attempts = run.toolCalls.filter(c => c.name === "book_slot").length;
const finalSlot = run.toolCalls.at(-1)?.args.slot;
console.log(
  attempts <= 2 && finalSlot !== "09:00"
    ? `PASS (${attempts} appels, choisi ${finalSlot})`
    : `FAIL (${attempts} appels)`
);

Ce petit bout de code ne fait qu’une chose, mais il la fait en vrai : il oblige le moteur candidat à traverser exactement le scénario qui vous fait peur, et il imprime PASS ou FAIL. Pas « ça a l’air de marcher » — un protocole, une mesure, un verdict. Si vous ne pouvez pas écrire la condition de FAIL, votre spike n’est pas un spike : c’est une démo.

Et le pari n°4 se verrouille de la même façon, en trois lignes de garde :

// Le contrat né du ×122 : la liste d'outils par défaut du SDK reste vide.
// Ce test-porte casse si quelqu'un « profite » un jour des outils intégrés.
const session = createCloudAgentSession(config);
expect(session.builtinTools).toHaveLength(0);

Les comportements durement acquis sont des invariants, pas du folklore

Deuxième temps de la méthode, entre le lot zéro et la migration : l’inventaire. Tous les comportements précieux de l’ancien moteur ont été recensés en une liste d’invariants numérotés — le canal d’erreur métier, la question bloquante, la sortie structurée, la règle de thinking par famille de modèle, le contrat des outils vides… Chacun adossé à la mesure du lot zéro qui le justifie.

Cette liste vit dans une note de référence, et j’y ai attaché une règle d’hygiène directement héritée de ma discipline de source de vérité : toute session de travail ultérieure doit charger cette note avant de toucher la couche moteur. Parce que le danger des invariants, ce n’est pas le jour de la migration — c’est six mois plus tard, quand quelqu’un (vous, moi, ou un agent IA à qui on a confié un refactoring) « simplifie » un réglage dont il ignore l’histoire.

En parallèle, chaque invariant a son test-porte : un test exécutable contre les vrais modèles, hors suite unitaire, qu’on peut relancer à tout moment pour re-vérifier que le comportement tient toujours. Pourquoi hors suite unitaire ? Parce que ces tests-là appellent de vrais modèles — lents, coûteux, non déterministes — et qu’une suite unitaire doit rester rapide et fiable. Ce sont deux filets différents, et si le sujet vous intéresse, j’ai raconté ailleurs ce que les tests classiques ne voient pas : les tests-portes jouent exactement ce rôle de filet supplémentaire, celui qui regarde la réalité en face au lieu de la mocker.

Migrer par lots, supprimer en dernier

Troisième temps : la migration elle-même. Et après tout ce qui précède, elle devient — je vous le promets — presque ennuyeuse. Des lots courts, commitables un par un, dans un ordre qui va du sol au toit :

  1. le protocole commun — le port que les deux moteurs devront servir ;
  2. le registre d’outils — chaque outil déclaré une fois, adapté par moteur ;
  3. l’unification des définitions d’agents ;
  4. le routage des sous-agents vers les nouveaux moteurs ;
  5. puis l’agent principal ;
  6. et en dernier — en tout dernier — la suppression du legacy.

À chaque lot, la suite complète repasse. Et le point sur lequel j’insiste : l’ancienne boucle, ses adaptateurs, son pipeline d’origine ne sont supprimés qu’au dernier lot, quand plus rien ne pointe dessus. Pas « au fil de l’eau », pas « tant qu’on y est ». La suppression du legacy est un lot dédié, et c’est précisément ce qui la rend indolore : quand elle arrive, c’est un non-événement — on retire un code que plus personne n’appelle, la suite est verte avant, la suite est verte après.

Le réflexe inverse — supprimer l’ancien code au fur et à mesure qu’on le remplace — a l’air plus propre, et c’est un piège : il vous prive du filet de comparaison pendant toute la migration, et il transforme chaque rollback en archéologie.

Ce que le lot zéro a évité, chiffres en main

Faisons les comptes, parce que c’est là que la méthode se paie. Sur les quatre paris, deux auraient été des découvertes tardives coûteuses en migration directe :

Découverts en spike : une matinée. Découverts en production : des jours de diagnostic, plus la facture. Le lot zéro n’est pas une précaution de frileux, c’est l’achat le plus rentable de toute la migration.

Ce que j’en retiens

Un spike n’est utile que s’il peut échouer. Formulez chaque incertitude en pari GO/NO-GO, avec un protocole PASS/FAIL écrit avant de lancer, et une mesure chiffrée comme verdict. Un spike sans condition d’échec est une démo qui vous conforte, pas une expérience qui vous renseigne.

Mesurez le coût par défaut de vos SDK. Les réglages d’usine d’un SDK d’agents peuvent multiplier votre consommation par deux ordres de grandeur — ×122 mesuré ici, sur un seul réglage. La configuration qui neutralise ça mérite le rang de contrat, verrouillé par un test qui casse si quelqu’un y retouche.

Les comportements durement acquis sont des invariants, pas du folklore. Recensez-les, numérotez-les, adossez-les à des mesures et à des tests-portes exécutables. Sinon la migration les re-perdra un par un — et vous re-paierez chacun au prix fort, c’est-à-dire au prix d’un bug en production. C’est le même mécanisme que dans mon autopsie d’un échec silencieux : ce qui n’est pas vérifié par construction finit par se perdre sans bruit.

Le legacy se supprime en dernier, en un lot dédié. Des lots courts, la suite verte à chaque étape, et la suppression finale comme non-événement. Si supprimer l’ancien code vous fait transpirer, c’est que la migration n’est pas finie.

Épilogue

Récapitulons le voyage : une boucle d’agent maison qu’on rêvait de jeter, deux moteurs du marché pour la remplacer, et la conviction que « tout marche à peu près avant » est la pire des situations de départ. La réponse : un lot zéro qui transforme chaque incertitude en pari mesuré — dont un ×122 qui a dicté un contrat non négociable —, des invariants numérotés gardés par des tests-portes, une migration par lots avec le legacy supprimé en dernier. Zéro régression, et surtout : zéro suspense. Chaque risque avait été acheté au prix d’un spike avant de pouvoir coûter le prix d’un incident.

Rien de bien sorcier, une fois de plus. Juste l’ordre des opérations : mesurer, verrouiller, migrer, supprimer — dans ce sens-là et pas un autre.

Et vous, avez-vous déjà remplacé un moteur maison — boucle d’agent, ORM artisanal, bus d’événements bricolé — par du standard du marché ? Qu’est-ce que la migration vous a fait perdre que vous n’aviez pas vu venir ? Racontez-moi ça en commentaire, je me ferai une joie de vous répondre — surtout si vous avez un ×122 encore plus salé que le mien.

Vous pourriez aussi aimer

Trois couches disent « succès », le rapport n'est jamais arrivé : autopsie d'un échec silencieux parfait

Trois couches disent « succès », le rapport n'est jamais arrivé : autopsie d'un échec silencieux parfait

L'IA code plus vite que vous. Mais qui vérifie ce qu'elle raconte ?

L'IA code plus vite que vous. Mais qui vérifie ce qu'elle raconte ?

Article précédent
Trois couches disent « succès », le rapport n'est jamais arrivé : autopsie d'un échec silencieux parfait