Un robot de veille, un prompt de 8 900 caractères, et trois règles qui n'arrivaient jamais au modèle

L'item est en tête du flux. « Dernier jour de cotation pour le premier ETF Bitcoin spot américain à fermer. » Étiqueté fait vérifié. Trois sources, toutes datées du 3 août.

On est le 17.

Quatorze jours entre la dernière source et l'événement qu'elle annonce. Une fermeture, ça se repousse. Un vote, ça s'ajourne. Et j'avais écrit une règle pour exactement ce cas une semaine plus tôt, une règle qui dit noir sur blanc de ne pas recopier une annonce de deux semaines comme si elle était encore vraie.

Le décor, pour ceux qui débarquent

Sur ce site tourne une veille automatisée. Toutes les 24 heures, une tâche planifiée réveille un script Node qui appelle un modèle de langage ayant accès à la recherche web, lui demande de balayer l'actualité crypto et financière des dernières heures, et exige en retour du JSON strict. Ce JSON alimente une page publique : une liste d'items, chacun avec un titre, un résumé, ses sources et leurs dates de publication.

Le modèle ne décide pas seul de ce qu'il ramène. Il reçoit un prompt, c'est-à-dire le texte d'instructions envoyé à chaque appel, ici 8 900 caractères qui jouent le rôle de cahier des charges : les catégories à couvrir, les sources à privilégier, le format de sortie attendu, et des règles de fraîcheur qui définissent ce qui mérite d'entrer dans le flux. Le prompt impose aussi d'étiqueter chaque item avec un niveau de certitude, de FAIT_VERIFIE quand deux sources primaires indépendantes concordent, jusqu'à SPECULATIF pour une hypothèse.

Le point qui compte pour la suite : ce prompt n'est pas un texte figé, c'est un gabarit. Il contient des marqueurs entre doubles accolades que le script remplit juste avant l'appel. {{DATE}} devient l'horodatage du moment, {{FREQUENCY_HOURS}} devient le nombre d'heures du cycle, {{PRICES}} devient un bloc de prix récupérés depuis une API. Le texte que le modèle reçoit n'est donc jamais exactement celui que je lis dans mon éditeur.

C'est dans cet écart que toute l'histoire se joue.

La chaîne de la veille : tâche planifiée, remplissage des marqueurs du gabarit, appel au modèle avec recherche web, JSON strict, page publique. Le défaut se situe à l'étape de remplissage. Tâche planifiée, toutes les 24 h Gabarit : les marqueurs sont remplis le défaut est ici Modèle + recherche web JSON strict, un objet par news Page publique de la veille
Le modèle ne voit jamais le gabarit, seulement le texte produit à l'étape de remplissage. Tout ce qui rate là ne se voit nulle part ailleurs.

La règle existait, et elle ne servait à rien

Voilà le passage concerné, tel qu'il est stocké dans la configuration :

Si tu retiens un item dont l'événement tombe dans ce cycle mais dont TOUTES
les sources sont plus anciennes que {{FREQUENCY_HOURS}}h, tu ne peux PAS le
publier tel quel. Vérifie qu'elle tient toujours :
- cherche une source RÉCENTE confirmant que l'événement a bien lieu ;
- si tu la trouves, ajoute-la aux sources et garde FAIT_VERIFIE ;
- sinon, rétrograde en PROBABLE, et dis dans le summary que l'échéance
  n'a pas été reconfirmée.

La règle prévoit deux issues, et la sortie n'en respecte aucune. Pas de source récente ajoutée, pas de rétrogradation, pas de réserve dans le résumé. L'item sort en fait vérifié, avec l'aplomb de quelque chose de confirmé le matin même.

Le premier réflexe, à ce stade, est toujours le même : le modèle a désobéi. Il faudrait insister davantage, mettre plus de majuscules, répéter la consigne à deux endroits.

C'est le réflexe qui m'a coûté le plus de temps.

Le réflexe cher : accuser la rédaction

J'ai fait ce que fait tout le monde. J'ai relu le prompt et je l'ai trouvé mal fichu, ce qu'il était d'ailleurs : quatre blocs différents s'y déclarent prioritaires, la règle de fraîcheur et la règle de reconfirmation se contredisent sur les cas limites, et sur 8 900 caractères il n'y a qu'un seul exemple concret.

Alors je l'ai réécrit. Balises de section à la place des titres en capitales, un bloc de priorités qui ordonne explicitement les familles de règles, le test de fraîcheur transformé en procédure numérotée à quatre branches, deux exemples travaillés dont un item volontairement rétrogradé. De 8 900 à 11 800 caractères, et une lecture nettement plus claire.

Puis vient la seule question qui compte : meilleur, mais mesuré comment ?

Un prompt qui rend du JSON a cette propriété agréable qu'on peut le noter sans le lire. La règle ci-dessus se traduit en test exécutable, presque mot pour mot :

const times  = item.sources.map(s => Date.parse(s.published_date)).filter(Number.isFinite);
const newest = Math.max(...times);
const stale  = newest < now - WINDOW_H * 3_600_000;
const hedged = /reconfirm|aucune source r(é|e)cente/i.test(item.summary);

// La règle : sources toutes hors fenêtre => PROBABLE + réserve explicite.
const violation = stale && (item.certainty === 'FAIT_VERIFIE' || !hedged);

Six lignes, et l'avis de n'importe qui sur la beauté du prompt ne pèse plus rien. Un run, c'est désormais un nombre : combien d'items violent la règle. Il ne reste qu'à faire tourner l'ancien prompt et le nouveau, et à comparer.

Quarante minutes pour zéro item

L'ancien prompt part en premier. Dix minutes plus tard, le sous-processus se fait tuer sur timeout. Relance automatique, dix minutes de plus, tué à nouveau. Le nouveau prompt prend le relais et fait exactement la même chose. Quatre appels, quarante minutes, zéro item comparé.

À ce moment-là il y a deux façons de continuer. Hausser les épaules en disant que l'API rame aujourd'hui, ou aller voir. « L'environnement » n'est pas un diagnostic, c'est le nom qu'on donne à ce qu'on n'a pas mesuré.

La bonne nouvelle, c'est que ce genre d'appel se laisse observer de près. Le mode flux de la CLI émet un événement JSON par étape, et il suffit d'horodater chaque ligne à sa sortie pour savoir où passe le temps :

claude --print --output-format stream-json --verbose \
       --tools 'WebSearch,WebFetch' -p "$PROMPT" \
| while IFS= read -r line; do printf '%s\t%s\n' "$(date +%s.%N)" "$line"; done > trace.jsonl

Un petit script agrège ensuite les écarts entre événements. Verdict :

durée totale : 618.9s sur 492 événements
   458.8s  74%  génération du modèle entre les outils
    81.0s  13%  résultats d'outils
    70.7s  11%  rédaction de la réponse finale
appels outils : 20 WebSearch, 6 WebFetch
plus longue attente hors réponse finale : 7.6s

Aucun trou. Pas de pause de soixante secondes qui trahirait un backoff, et les événements de quota disent tous que l'appel est autorisé. L'événement de clôture donne le reste : 343 secondes d'API sur 619 secondes de mur, le solde étant la latence des recherches web, soit une dizaine de secondes par appel sur vingt-six appels.

Le travail est simplement long. Le plafond était à dix minutes, les dix cycles précédents tenaient entre 296 et 445 secondes, et la marge paraissait confortable jusqu'à ce qu'une journée chargée la mange. Le pire, c'est le design de la relance : elle repart de zéro. Un run coupé à 599 secondes ne redémarre pas à 599, il redémarre à 0 pour se faire tuer une seconde fois. Payer deux fois pour échouer deux fois.

Plafond porté à quinze minutes. Le cycle suivant est passé en 836 secondes, soit précisément la zone où l'ancien réglage garantissait un flux vide.

Le bug tenait dans un mot

Reste la question de départ, la règle ignorée. Et là, un réflexe qui vaut de l'or : ne pas relire le prompt dans son éditeur, mais regarder ce qui est réellement parti. Le processus tourne, ses arguments sont lisibles.

ps -eo pid,etimes,args | grep 'claude --print'

Et dans le texte qui défile, ceci :

Une news = un ÉVÉNEMENT récent dans les dernières {{FREQUENCY_HOURS}} heures.

Le placeholder est encore là. Pas remplacé. Le modèle a reçu, mot pour mot, une règle avec un trou à la place de son seuil.

La cause tient dans la ligne suivante, qui a l'air parfaitement innocente :

prompt = rawPrompt
    .replace('{{DATE}}', new Date().toISOString())
    .replace('{{FREQUENCY_HOURS}}', String(frequencyHours))
    .replace('{{DEDUP_HINT}}', dedupHint);

En JavaScript, String.prototype.replace appelé avec une chaîne comme motif ne remplace que la première occurrence. Il faut une expression régulière avec le drapeau global, ou replaceAll, pour toucher les suivantes. C'est écrit dans la spécification depuis toujours, ça ne provoque aucune erreur, aucun avertissement de linter, et ça passe inaperçu tant qu'un gabarit ne contient qu'une occurrence de chaque marqueur.

Le prompt en contenait quatre pour ce marqueur-là. Une substituée, trois intactes.

Le gabarit contient quatre fois le même marqueur. La substitution n'en remplit que le premier : les trois règles suivantes partent au modèle avec un trou à la place de leur seuil. Le gabarit du prompt Ce qui part au modèle Mission du cycle {{FREQUENCY_HOURS}} Fraîcheur d'un item {{FREQUENCY_HOURS}} Absence d'article récent {{FREQUENCY_HOURS}} Reconfirmation d'échéance {{FREQUENCY_HOURS}} remplacé intact intact intact Mission du cycle 24 heures Fraîcheur d'un item {{FREQUENCY_HOURS}} Absence d'article récent {{FREQUENCY_HOURS}} Reconfirmation d'échéance {{FREQUENCY_HOURS}} .replace() avec un motif chaîne : première occurrence seulement
Une seule des quatre règles reçoit son seuil. Les trois autres arrivent avec un trou, et une règle sans seuil ne filtre rien.

Ce détail change tout au diagnostic. Le modèle n'a jamais désobéi. Il a appliqué à la lettre un texte que je n'avais pas lu, parce que je relisais celui de mon éditeur et pas celui qui partait sur le réseau. Insister, mettre des majuscules, répéter la consigne : rien de tout ça n'aurait fonctionné, puisque le problème n'était pas la formulation.

Le même seuil vivait à trois endroits

En corrigeant, je suis tombé sur la cousine du même problème. Le prompt écrivait une fenêtre de fraîcheur de 48 heures en dur. Le code, lui, la calculait :

const windowH = Math.max(frequencyHours * 1.5, 48);

Pour un cycle de 24 heures, les deux tombent d'accord sur 48. Par chance, pas par construction. Change la fréquence de la veille et les deux divergent en silence, sans que rien ne casse : le prompt continue d'annoncer un seuil que le pipeline n'applique plus.

Et il y avait une troisième copie, dans la ligne de log qui recalculait la formule dans son coin pour l'afficher. Résultat, les journaux annonçaient un seuil de 36 heures pendant que le code en appliquait un autre. Quand tu débugues à partir de logs qui mentent, tu perds une soirée.

Le correctif n'a rien d'astucieux, et c'est bien le but. Une seule fonction exportée détient la formule, le code s'en sert, et le prompt la reçoit par un marqueur dédié plutôt que de la recopier à la main. Une constante que le code et le prompt doivent partager a besoin d'un propriétaire unique, exactement comme entre deux modules.

Ce que la mesure a fait à ma réécriture

Une fois la substitution réparée, le comparatif devient possible. Trois runs, le même juge à six lignes, la même journée :

                          items   violations   durée
ancien prompt, tel quel       8        2        703 s
ancien prompt, corrigé        9        0        836 s
ma réécriture                 4        0        680 s

La deuxième ligne suffit. Le correctif d'un mot fait tomber les violations à zéro, sur le prompt que je jugeais mal écrit. Ma réécriture n'améliore rien sur ce critère et sort deux fois moins d'items, sur le même cycle d'actualité.

La raison est dans mon propre texte. J'avais ajouté un bloc de priorités qui classe le volume en dernier, et une phrase qui dit que cinq items solides valent mieux que quinze remplis. Le modèle a obéi. Sur un flux quotidien où neuf items sourcés étaient parfaitement légitimes, il en a jeté cinq pour me faire plaisir.

Mon prompt était meilleur à lire et moins bon à exécuter. J'ai passé une heure à écrire la solution d'un problème qui n'existait pas, et le seul morceau qui valait le coup, ce sont les deux exemples travaillés : c'est grâce à eux que le modèle a produit un item rétrogradé en probable avec la mention explicite que l'échéance n'avait pas été reconfirmée. Le reste part à la poubelle, y compris le bloc dont j'étais le plus content.

Ce que je retiens

Un prompt est du code. Personne ne juge une fonction en la relisant à voix haute, on l'exécute et on regarde ce qu'elle rend. Pourtant, devant un prompt, tout le monde se met à débattre du style et de la formulation comme s'il s'agissait d'une lettre de motivation.

Les trois gestes qui ont réellement produit de la connaissance ici sont d'une banalité totale. Lire ce qui part sur le réseau plutôt que ce qui est dans l'éditeur. Horodater les événements plutôt que supposer que ça rame. Écrire six lignes de juge automatique plutôt que se demander si c'est mieux.

Et une dernière, la plus désagréable : accepter que la mesure invalide ton travail. Ma réécriture était objectivement plus claire, mieux structurée, plus agréable à lire. Elle était aussi moins bonne. Sans le juge, je l'aurais déployée avec la conviction sincère d'avoir amélioré le système.

Quand un modèle ignore une règle, la première question n'est pas « comment mieux la formuler ». C'est « est-ce que la règle est arrivée ».

Commentaires (0)