Aller au contenu
/

Apprivoiser l'imprévisible

· Updated
Les LLM sont probabilistes par nature. Construire de la prédictibilité autour d'eux, c'est un problème d'ingénierie, pas de prompting.

L’ours blanc

Pendant trois mois, j’ai développé au quotidien avec la méthode Ralph Wiggum. L’idée : faire tourner un agent en boucle, chaque itération repartant avec un contexte frais, le progrès étant persisté dans les fichiers et l’historique git, la complétion détectée par un token : quand l’agent écrit <promise>COMPLETE</promise>, l’itération s’arrête et la boucle passe à la suivante.

while :; do cat PROMPT.md | claude-code ; done

Trois fois, la boucle a échoué en silence. Le LLM, confronté à un échec, a écrit quelque chose comme « la tâche a échoué, donc je ne dois pas écrire <promise>COMPLETE</promise> ». Le token était dans la sortie. Le string match est passé. La boucle s’est arrêtée. Tout était vert. Rien ne marchait.

Le LLM n’a pas désobéi. Il a raisonné, et son raisonnement contenait exactement le token qu’il essayait de ne pas émettre. La théorie des processus ironiques de Wegner : « ne pense pas à un ours blanc » garantit qu’on y pense.

Si la détection avait reposé sur un script déterministe (vérifier que les tests passent, que le build compile), ce bug n’existerait pas.

Les instructions ne scalent pas

Les éditeurs d’outils IA ont tous convergé vers la même idée : donner aux développeurs des moyens d’infléchir le comportement de l’agent. Des fichiers de contexte (AGENTS.md, CLAUDE.md), des rules (.cursorrules), des skills, des commandes custom. Documenter les règles, documenter le contexte du projet, et l’IA se comporte bien.

Sauf que tout ça repose sur des instructions, et le budget est plus serré qu’on ne le croit. Le nombre d’instructions qu’un LLM peut suivre avec cohérence est limité, et le prompt système du harness en consomme déjà une bonne part. Ces instructions brûlent du budget en permanence (tokens et argent), même quand elles ne sont pas pertinentes : « utilise pnpm » occupe du contexte y compris quand l’agent édite du CSS. Et l’IA n’a aucune conscience de ses propres lacunes. Elle produit du code qui compile mais qui passe à côté de conventions qu’on lui a pourtant expliquées.

Les AGENTS.md générés par LLM dégradent les résultats tout en augmentant les coûts. Vercel passe toutes ses evals avec un AGENTS.md ultra-condensé, mais leur propre conclusion : « small wording tweaks produce large behavioral swings. » Plus d’instructions ne veut pas dire meilleurs résultats. Ça veut dire plus de bruit.

Des scripts, pas des instructions

L’approche qui marche pour moi, c’est l’inverse de l’instinct : moins d’instructions, pas plus. Pour tout ce qui doit être garanti, utiliser des mécanismes déterministes. L’instruction « utilise pnpm » dépend de la bonne volonté du modèle. Un hook qui rejette npm install est un mur.

Sur ce projet, chaque cas d’usage qui était une instruction est devenu un script :

Instruction CLAUDE.mdÉquivalent déterministe
« Formate ton code avec oxfmt »Hook PostToolUse qui reformate chaque fichier modifié
« Utilise pnpm, pas npm »Hook PreToolUse qui rejette les commandes npm
« Ne merge jamais une PR toi-même »Hook PreToolUse qui bloque gh pr merge
« Mets à jour modifiedAt quand tu modifies un article »Script pre-commit qui met à jour la date automatiquement
« Ne mets pas de co-authoring dans les commits »

Claude Code expose des hooks de lifecycle des scripts shell qui s’exécutent automatiquement quand l’agent fait certaines actions, avant (PreToolUse) ou après (PostToolUse).

« Formate ton code avec oxfmt »

Hook PostToolUse : se déclenche après chaque modification de fichier. Le matcher filtre les outils concernés, le exit 0 garantit que le hook ne bloque jamais l’agent même si le formatage échoue. L’IA n’a pas besoin d’y penser.

.claude/settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write|NotebookEdit",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/format-on-save.sh",
            "timeout": 30
          }
        ]
      }
    ]
  }
}
.claude/hooks/format-on-save.sh
jq -r '.tool_input.file_path // .tool_input.notebook_path' \
  | xargs oxfmt --write --no-error-on-unmatched-pattern 2>/dev/null
exit 0

« Utilise pnpm, pas npm »

Hook PreToolUse : rejette la commande avant qu’elle s’exécute, exit code 2, message d’erreur explicite. L’agent reçoit le message et s’adapte.

.claude/hooks/block-npm.sh
jq -r '.tool_input.command' \
  | grep -qE '^npm\b|\bnpm install\b|\bnpm i\b' \
  && echo 'Blocked: use pnpm instead of npm.' >&2 \
  && exit 2
exit 0

« Ne merge jamais une PR toi-même »

Même mécanisme. Un merge par erreur, c’est réversible (on rebase), mais c’est le genre de nettoyage qu’on préfère éviter.

.claude/hooks/block-pr-merge.sh
jq -r '.tool_input.command' \
  | grep -q 'gh pr merge' \
  && echo 'Blocked: merging requires human approval.' >&2 \
  && exit 2
exit 0

« Mets à jour modifiedAt quand tu modifies un article »

Le pre-commit Husky n’est pas nouveau : linting, formatting, c’est standard. Ce qui est intéressant, c’est d’aller plus loin avec des scripts métier. Le script ne se déclenche que quand du vrai contenu a changé, pour éviter une boucle infinie avec sa propre modification.

.husky/pre-commit
for file in $(git diff --cached --name-only --diff-filter=M -- 'content/*.mdx'); do
  content_changed=$(git diff --cached -U0 "$file" \
    | grep '^[+-]' | grep -v '^[+-]\{3\}' \
    | grep -v '^[+-]modifiedAt:' | head -1)
  if [ -z "$content_changed" ]; then
    continue
  fi

  # Only check/update within frontmatter (between first two --- markers)
  frontmatter=$(awk '/^---$/{n++; if(n==2) exit} n==1{print}' "$file")
  if echo "$frontmatter" | grep -q '^modifiedAt:'; then
    awk -v today="$TODAY" '
      /^---$/ { n++ }
      n==1 && /^modifiedAt:/ { $0 = "modifiedAt: \"" today "\"" }
      { print }
    ' "$file" > "$file.tmp" && mv "$file.tmp" "$file"
  else
    awk -v today="$TODAY" '
      /^---$/ { n++ }
      { print }
      n==1 && /^publishedAt:/ { print "modifiedAt: \"" today "\"" }
    ' "$file" > "$file.tmp" && mv "$file.tmp" "$file"
  fi
  git add "$file"
done

Et l’IA dans tout ça ? C’est elle qui écrit les scripts sur mesure pour le projet.

« Ne mets pas de co-authoring dans les commits »

Piège : le Co-Authored-By: Claude est injecté par la configuration de Claude Code, pas par le modèle. Lui demander de ne pas le faire dans un CLAUDE.md marchera la plupart du temps, mais pas tout le temps. La solution est dans le settings.json, pas dans les instructions.

.claude/settings.json
{
  "includeCoAuthoredBy": false
}

Filtrer ce que le modèle voit

Il y a une catégorie moins évidente : non pas corriger le code ni bloquer une action, mais filtrer ce que le modèle voit. Les hooks PostToolUse peuvent réécrire la sortie d’un outil avant qu’elle entre dans le contexte. Tronquer un output de build de 20 Ko à 10 Ko, compresser un fichier de 500 lignes en signatures de fonctions, supprimer les blocs <system-reminder> qui s’accumulent au fil de la conversation. Chaque octet qui n’entre pas dans le contexte est du budget libéré pour le raisonnement. claude-warden pousse cette logique jusqu’au bout : gouvernance de tokens, troncature, compression structurelle, budgets par subagent, le tout en Bash et jq.

Ce qu’il reste dans les instructions

Tout n’est pas automatisable. Ma commande /commit reste un prompt (conventional commits, logique de batching, détection du scope) parce que c’est du jugement, pas de la vérification. L’IA doit décider si un changement est un feat ou un fix, si deux fichiers vont dans le même commit ou non, et aucun script ne peut faire ça.

La règle que j’applique :

  • Répétable et vérifiable → script (hook, linter, pre-commit)
  • Contextuel et nécessite du jugement → instruction (commande, skill)
  • Dans le doute → script

Et surtout : pas de fichier de 200 lignes d’instructions que le modèle va survoler et qui deviendra avarié en quelques semaines. Le contenu du projet (le code, les types, la structure des fichiers) est déjà discoverable. L’IA sait lire un package.json, un tsconfig.json, une arborescence. L’expliciter dans un fichier de règles, c’est ajouter du bruit. Si j’ai un CLAUDE.md, c’est quelques lignes, aussi peu que possible, et uniquement pour des erreurs répétées que je n’ai pas réussi à extraire en script.

On a un biais naturel à vouloir tout documenter pour l’IA, comme on le ferait pour un nouveau développeur. Mais un nouveau développeur lit la doc une fois et s’en souvient. L’IA la relit à chaque session, et chaque token de contexte occupé par nos règles est un token de moins pour le raisonnement.

Quand les instructions deviennent des portes

Il y a un cas où les instructions gardent un avantage : les tâches créatives en plusieurs étapes, où chaque phase nécessite du jugement, mais où la transition entre les phases peut être verrouillée.

J’ai construit un skill pour automatiser la création de composants React depuis Figma. Le workflow est découpé en étapes, avec des vérifications déterministes entre chacune :

Studytokens, spacing,typographyArchitecturecomponents, props,variantsImplementationfirst pass, deliberatelylooseRefinementcompare, fix,re-compareValidationtests, a11y,design systemstep-1.shstep-2.shstep-3.shstep-4.sh

Chaque étape a des conditions à remplir pour passer à la suivante. Ce sont des portes, pas des suggestions. L’étape d’implémentation est volontairement lâche. Plutôt que de demander à l’IA d’être parfaite du premier coup, j’accepte l’imperfection et je la corrige dans une boucle de refinement dédiée.

Aujourd’hui, c’est un skill unique, un long prompt avec des portes entre les étapes. Chaque étape a des scripts de vérification, mais c’est au modèle de les lancer. « Après avoir complété cette étape, vérifie le résultat en lançant le script correspondant. » Et l’agent porte l’intégralité du contexte à travers toutes les étapes. La phase « étude » charge les design tokens, les specs Figma, les règles de typographie, et tout ça est encore en contexte quand l’agent arrive au « refinement », où il n’a besoin que du screenshot et du code généré.

La cible, c’est une équipe d’agents (swarm) : un par étape, chacun avec un contexte minimal limité à sa tâche, et un script shell comme orchestrateur. L’orchestrateur lance chaque agent, vérifie la sortie de façon déterministe, et ne transmet que les artefacts pertinents au suivant. Les portes deviennent de vrais scripts entre des processus, pas des instructions à l’intérieur d’un seul.

Le skill avec ses portes, c’est un compromis pragmatique. L’équipe d’agents avec des vérifications déterministes, c’est la cible. Entre les deux, il y a le temps qu’on a, et jusqu’où les leviers qu’on nous donne peuvent nous porter.

Conclusion

On en est encore au début, et l’outillage se construit toujours. La documentation officielle recommande /init pour générer un CLAUDE.md, alors que la recherche montre que les fichiers générés par LLM dégradent la performance. Quand on est en bout de chaîne, la seule chose qu’on contrôle, c’est ce qui se passe autour du modèle. Mais une chose est sûre : la prédictibilité se construit avec du code, pas avec des mots.

Pour ma vision plus large, voir mon manifeste IA.