Ne demandez pas au LLM ce qu'un parseur sait vérifier

RetroDoc lit un dépôt Git et en reconstruit la documentation fonctionnelle : des domaines, des fonctionnalités, des cas d'usage décrits étape par étape. (Le contexte est dans La dette de documentation.)

Un problème s'impose vite : comment savoir si ce que le LLM a écrit est vrai ? Une documentation fausse est pire que pas de documentation, parce qu'on s'y fie.

Chaque étape cite son code

La première décision est de rendre l'affirmation vérifiable. Chaque étape d'un cas d'usage doit citer les lignes de code qui la justifient :

Étape 2 — Le système crée la facture
  action      : appelle create_invoice
  source_refs : a.rs, lignes 1 à 1

Cette étape fait deux promesses : le fichier a.rs a une ligne 1, et on y trouve create_invoice. Reste à les contrôler.

Première réponse : demander au LLM

C'est ce que j'ai fait d'abord. Une passe dédiée demande à un modèle, pour chaque étape, un verdict (soutenue, partielle, non soutenue) au vu du code cité. Ça fonctionne, et ça a un défaut de coût : cette passe représente environ un appel sur cinq d'une exécution (8 sur 28 sur un petit dépôt, 72 sur 320 sur un dépôt plus gros).

Surtout, on paie un modèle pour un travail qui n'en demande pas : les deux promesses ci-dessus se vérifient avec une simple recherche de texte.

Ce qu'un programme peut établir

Le contrôle (confidence/checks.rs, un fichier d'environ 100 lignes) range chaque étape dans l'une de quatre catégories (cinq cas ci-dessous) :

ÉtapeVerdictPourquoiAppel au LLM
appelle create_invoice, cite a.rs:1validele nom est bien à la ligne citéenon
appelle create_invoice, cite a.rs:2douteusele nom existe dans le fichier, mais pas à la ligne citéeoui
« envoie la commande en cuisine », cite a.rs:1douteuserien de vérifiable dans le texteoui
cite a.rs:40, le fichier a 3 lignescasséela citation est fausse, c'est un faitnon
ne cite aucun codesans appuiplafonnée à 0,25non

Les « noms » que l'étape promet sont extraits du texte par une heuristique volontairement simple : ce qui est entre accents graves, les snake_case, les CamelCase, les appels de la forme foo(. Les mots ordinaires, même en majuscules, sont de la prose. Les entités métier, elles, viennent du glossaire déjà construit.

Le cœur du contrôle, simplifié :

// l'étape nomme des identifiants : sont-ils aux lignes citées ?
if let Some(missing) = named.iter().find(|id| !cited.contains(&id.to_lowercase())) {
    return StepCheck::Doubtful(format!("`{missing}` is not at the cited lines"));
}

Seules les étapes douteuses coûtent un appel : les lignes 2 et 3 du tableau. La deuxième est la plus intéressante. Le nom est dans le fichier, donc le code ne peut pas dire si l'étape est fausse ou si le modèle s'est simplement trompé de ligne. Là, il faut du jugement, et c'est précisément ce qu'on veut acheter au LLM. La troisième est douteuse pour une autre raison, que le paragraphe suivant détaille.

Le piège : un contrôle qui ne sait pas dire « je ne sais pas »

Ma première version du contrôle donnait un résultat superbe : 93 % des étapes valides, 69 % des cas d'usage réglés sans modèle, 3 appels au lieu de 8. C'était trop beau, et en relisant le code j'ai trouvé pourquoi : la troisième ligne du tableau était comptée comme valide. L'étape « envoie la commande en cuisine » ne nomme aucun identifiant, mais les lignes qu'elle cite existent, donc le contrôle ne trouvait rien à lui reprocher.

Mais l'absence de contradiction n'est pas une preuve. Une étape qui ne dit rien de vérifiable est invérifiable, donc douteuse. Une fois corrigé, le résultat est tombé à 80 % d'étapes valides et 46 % de cas d'usage réglés, et la passe fait 4 appels sur 8 possibles au lieu des 3 annoncés. C'est moins flatteur, mais c'est la vraie valeur.

Même mésaventure ailleurs dans le projet, avec un contrôle encore plus facile à tromper.

RetroDoc doit découper les gros fichiers pour les faire lire par morceaux : envoyé d'un bloc, un contrôleur de 34 Ko et 65 actions ne donnait que 9 points d'entrée, le modèle tronquait ou dépassait sa limite de sortie. Pour que chaque morceau s'arrête sur une frontière naturelle, il faut savoir où commence une fonction ou une classe, et cela dépend du langage. Je n'avais pas envie d'écrire et de maintenir un découpeur par langage, avec le risque de tomber sur un langage que je n'avais pas prévu.

J'ai donc délégué ce savoir au LLM. Il reconnaît le langage d'un projet sans difficulté : on lui demande une expression régulière qui reconnaît le début d'une définition dans ce langage, et le découpeur coupe avant la dernière ligne qui correspond. Un seul appel remplace n'importe quel nombre de découpeurs écrits à la main.

Une regex produite par un modèle reste une hypothèse. Je l'ai donc mesurée avant de l'utiliser : elle est appliquée aux 20 plus gros fichiers du même type, et la mesure est la part des lignes de définition qu'elle reconnaît. Sous 90 %, le modèle reçoit jusqu'à trois demandes de correction, avec les erreurs de compilation de la regex. Sous 50 %, on abandonne et on découpe sur les lignes vides.

Le piège est venu d'une regex JavaScript terminée par un |. En syntaxe de regex, c'est une alternative vide, qui correspond à toutes les lignes. Elle reconnaissait donc forcément toutes les lignes de définition, et ma mesure affichait 100 %. Le contrôle ne pouvait pas échouer. J'ai ajouté une borne dans l'autre sens : une expression qui correspond à plus de 40 % des lignes d'un fichier est rejetée comme trop large.

Sur le contrôleur de test, le découpage fait passer de 9 à 37 points d'entrée. Depuis, tree-sitter, un vrai parseur, a remplacé ce mécanisme pour cinq langages (Ruby, Rust, JavaScript, TypeScript, Python). La regex proposée par le LLM reste en service pour tous les autres, avec ses contrôles.

Ce que ça donne, et ce que ça ne prouve pas

Sur une petite bibliothèque Ruby (13 cas d'usage, 75 étapes, modèle local), après correction :

  • 80 % des étapes passent seules, 20 % partent au modèle, aucune n'est cassée ;
  • 6 cas d'usage sur 13 sont réglés sans aucun appel, du même côté du seuil d'alerte de 50 % que l'avis complet du modèle, dans les 6 cas ;
  • la passe fait 4 appels au lieu de 8.

Sur linkding, une application Django d'environ 180 fichiers source, j'ai rejoué les contrôles sur trois exécutions déjà sauvegardées (sans aucun appel au modèle, ce sont de simples lectures de fichiers). Le résultat est plus modeste :

ExécutionCas d'usageÉtapes validesÉtapes douteusesÉtapes cassées
134548 %51 %0 %
216155 %45 %0 %
310754 %46 %0 %

Les contrôles ne remplacent donc qu'environ la moitié des verdicts, pas les quatre cinquièmes. Je l'attribue, sans l'avoir établi, à du code Python plus dynamique et à des étapes qui nomment moins d'identifiants vérifiables, mais je n'ai pas décomposé les causes. Ce qui ne bouge pas, c'est l'absence d'étapes cassées : les citations sont bien formées, le modèle de départ ne les invente pas.

Ce que ces mesures ne disent pas encore : les exécutions sur linkding ont été lancées sans passe de confiance, donc je n'ai pas d'avis du modèle pour comparer, et je ne sais pas combien de cas d'usage se règlent sans appel. Sur la petite bibliothèque, quand on demande quand même son avis au modèle, il est systématiquement plus sévère (écart moyen de 0,25) : il note « partielle » là où le code ne montre pas un détail. Les scores obtenus mécaniquement sont donc un peu optimistes. J'ai gardé une option qui fait relire un échantillon de cas d'usage validés par le modèle, uniquement pour mesurer cet écart.

Le principe

Le LLM propose, le code dispose. Plus précisément :

  • Si un programme peut établir la réponse, il la donne. Le modèle ne traite que ce qui reste réellement ambigu.
  • Un contrôle doit pouvoir répondre « je ne peux pas conclure », sinon il valide par défaut.
  • Un contrôle doit pouvoir échouer, et il faut l'avoir vu échouer.

C'est le même réflexe que dans mon environnement de développement avec un agent : il ne peut pas s'arrêter tant que cargo clippy -- -D warnings n'est pas propre. Ce n'est pas lui qui juge que son code est correct, c'est un outil qui ne se fatigue pas. Ici, le modèle écrit la documentation et c'est un programme qui vérifie les citations.

Les choix et les mesures de RetroDoc sont consignés dans des ADR publics, notamment les numéros 0007 et 0026 pour ce qui précède.