Créer un agent de documentation auto-corrigé à l’aide de LangGraph et d’Ollama.
Un pipeline de réflexion locale où un modèle rédige les documents API en Python, un deuxième les critique et un troisième concilie le résultat, connecté en boucle LangGraph sur Ollama.
Sources: Build a Self-Correcting AI Agent with LangGraph and Ollama
Cette approche s’appelle la réflexion. Son principe repose sur le fait que les LLM détectent bien mieux les erreurs dans un texte qu’ils ne parviennent à en écrire un sans défaut d’un seul coup, c’est pourquoi le travail est divisé en différentes tâches. Un détail est particulièrement important : chaque tâche utilise un modèle local distinct. Un modèle qui s’occupe à la fois de l’écriture et de l’évaluation a tendance à accepter tel quel son propre output, tandis qu’un critique indépendant évite ce biais.
Auparavant, des agents créés de zéro sans framework affirmaient que des outils comme LangGraph se révélaient utiles une fois qu’on avait construit manuellement la version simplifiée. Cette fois, LangGraph est un choix délibéré, car un cycle de révision avec des sorties conditionnelles correspond exactement au flux de travail cyclique pour lequel il a été conçu. Ollama gère les modèles. Par défaut, on utilise qwen3.5:9b comme générateur, deepseek-coder:6.7b comme critique et llama3:8b comme juge, mais un petit fichier JSON assigne les rôles, ce qui permet à n’importe quel modèle téléchargé localement de remplir cette fonction.
Le résultat est une seule commande de console, reflexion. Elle lit un fichier source, exécute la boucle, affiche un tableau de scores pour chaque manche et enregistre le Markdown final sur disque. Tout le processus tient dans un seul fichier Python d’environ 360 lignes.
La structure comprend sept éléments : l’état du flux de travail et le schéma d’évaluation du critique, le nœud Generateur, le nœud Critique, le routeur qui décide entre boucler ou sortir, le nœud Adjudicateur, les connexions entre les nœuds, et enfin l’environnement de exécution avec la configuration et le nettoyage des résultats.
Mise en place
Le code se trouve dans un répertoire nommé local-reflexion-agent, sous forme d’un seul paquet, src/reflexion/main.py, exposé en tant que script de console via pyproject.toml. Clonez-le et installez-le en mode éditable :
git clone https://github.com/jfjensen/local-reflexion-agent.git
cd local-reflexion-agent
pip install -e .
Cela intègre langgraph, langchain-core, langchain-community, langchain-ollama et pydantic, et enregistre un script de console nommé reflexion. Ensuite, vérifiez que Ollama est en cours d’exécution localement et téléchargez les trois modèles mentionnés dans config.json:
ollama pull qwen3.5:9b
ollama pull deepseek-coder:6.7b
ollama pull llama3:8b
Differentes versions sont acceptables. Les rôles sont définis dans le fichier config.json situé à la racine du répertoire, comme expliqué à nouveau dans la section relative au fonctionnement ci-dessous. Le répertoire contient trois petits fichiers d’exemple dans le dossier input/, ce qui permet de lancer la chaîne de traitement immédiatement :
reflexion --input input/source_function_a.py --output output/result.md --config config.json
Partie 1 : L’État et le schéma du critique
Deux structures de données sont nécessaires en premier lieu. L’une définit ce que le critique doit retourner, et l’autre définit la mémoire qui circule à travers le graphe.
La sortie du critique est celle qui présente le plus d’intérêt. Si l’on demande à un modèle local de 6,7 milliards de paramètres d’évaluer quelque chose, il répondra volontiers par un paragraphe en prose. Ce qui est requis, cependant, ce sont des chiffres ainsi qu’une correction concrète ; c’est pourquoi un schéma Pydantic oblige le modèle à remplir des champs prédéfinis :
class DocEvaluationSchema(BaseModel):
"""Pydantic schema used to force a structured JSON schema constraint out of our local critic model.
Deliberately contains only judgment fields: the overall average and the pass/fail decision are computed in code."""
technical_accuracy: int = Field(description="Score from 0-100 on accuracy.")
completeness: int = Field(description="Score from 0-100 covering parameter definitions.")
single_critical_fix: str = Field(description="The single absolute highest priority correction required.")
Quelques points concernant le schéma :
- Le champ
single_critical_fixconstitue le cœur du boucle. Au lieu d’une liste de tous les problèmes, le critique indique la seule correction à haut niveau de priorité. Un petit générateur gère beaucoup mieux une instruction du type « corrigez ceci » qu’une multitude de retours mélangés. - Ce qui est omis est tout aussi important que ce qui y figure. Une version antérieure demandait également au modèle un pourcentage global ainsi qu’un indicateur de réussite/échec, ce qui s’est avéré être une erreur, comme expliqué dans la section consacrée au critique. En bref : ne demandez jamais à un modèle d’effectuer des calculs ou des comparaisons de seuils que Python peut gérer en une seule ligne. Le schéma ne contient que les jugements vraiment nécessaires pour le modèle, tandis que le nœud du critique effectue la moyennisation et prend lui-même la décision de réussite/échec, en code simple.
Field comme de simples éléments décoratifs. Elles sont transmises au modèle au sein de la contrainte du schéma JSON et fonctionnent comme de minuscules indications.Viennent ensuite les états. LangGraph transmet un objet d’état de nœud en nœud, et chaque nœud y répond par une mise à jour partielle. Un TypedDict définit sa structure :
def append_scores(old: List[int], new: List[int]) -> List[int]:
return old + new
class AgentWorkflowState(TypedDict):
"""Memory tracking state for the heterogeneous local graph layout."""
source_code: str
generated_markdown: str
current_score: int
score_history: Annotated[List[int], append_scores]
required_fix: Optional[str]
iteration_count: int
is_approved: bool
final_adjudicated_markdown: Optional[str]
La seule ligne qui n’est pas évidente est score_history. Le type Annotated[List[int], append_scores] indique à LangGraph de fusionner les mises à jour de ce champ avec le réducteur append_scores au lieu de les écraser. Chaque score retourné par le critique est donc ajouté à l’historique. À la fin d’une exécution, on dispose de la courbe complète des scores pour toutes les étapes de révision, ce qui en fait la ligne la plus informative affichée par le pipeline.
Partie 2 : Le nœud Générateur
Un nœud LangGraph n’est rien d’autre qu’une fonction : les données d’état y entrent et un dictionnaire des mises à jour en sort. Le Générateur dispose de deux modes. La première passe rédige directement la documentation à partir du code. Chaque passe suivante modifie le brouillon précédent en fonction des corrections apportées par le critique.
def content_generator_node(state: AgentWorkflowState) -> dict:
"""The Generator node: Focuses entirely on drafting/editing documentation text layout."""
current_iteration = state.get("iteration_count", 0)
target_model = MODEL_REGISTRY["GENERATOR"]
# ... console logging trimmed, see the repo for the full version
llm = ChatOllama(model=target_model, temperature=0.7)
if not state.get("generated_markdown"):
system_prompt = "You are an expert technical writer. Convert raw code snippets into clean, comprehensive API markdown documentation."
user_prompt = f"Generate Markdown documentation for the following source code:\n\n{state['source_code']}"
else:
system_prompt = "You are a precise editor rewriting technical documentation to fix a structural flaw."
user_prompt = (
f"Your previous documentation draft was scored {state['current_score']}/100.\n"
f"You must completely rewrite the documentation to fix this specific issue: {state['required_fix']}\n\n"
f"Original Code Context:\n{state['source_code']}\n\n"
f"Previous Draft:\n{state['generated_markdown']}"
)
messages = [SystemMessage(content=system_prompt), HumanMessage(content=user_prompt)]
response = llm.invoke(messages)
return {
"generated_markdown": response.content,
"iteration_count": current_iteration + 1
}
Ce que fait le code :
Sélection du mode. Si generated_markdown est vide, cela signifie la première étape ; le modèle agit alors comme un rédacteur technique en partant du texte source brut. S’il existe déjà un brouillon, le modèle est sollicité en tant qu’éditeur et reçoit la note précédente, la seule correction apportée par le critique, le texte source original ainsi que son propre brouillon antérieur.
L’instruction de révision. Fournir au modèle sa note précédente est un petit truc efficace. Une indication concrète du type « vous avez obtenu 78/100 » le pousse à modifier véritablement le texte plutôt que de se contenter d’une reformulation légère. L’instruction demande une réécriture complète pour corriger un problème spécifique, et de telles directives précises conviennent bien aux petits modèles locaux.
Température. Le générateur fonctionne avec temperature=0.7, ce qui apporte une certaine variété aux brouillons entre les différentes étapes. Le critique, comme on le verra ensuite, fonctionne avec une température nulle.
Valeur de retour. Seuls les champs modifiés sont renvoyés. LangGraph les intègre à l’état, et iteration_count augmente d’un par cycle de génération.
Partie 3 : Le nœud Critic
Il s’agit d’un modèle différent, deepseek-coder:6.7b par défaut, et il a fallu plus d’essais pour formuler correctement sa requête que pour tout autre modèle de ce ensemble.
def content_critic_node(state: AgentWorkflowState) -> dict:
"""The Critic node: Cross-checks generator output using a different model with strict JSON formatting."""
target_model = MODEL_REGISTRY.get("CRITIC")
llm = ChatOllama(model=target_model, temperature=0.0)
structured_llm = llm.with_structured_output(DocEvaluationSchema)
system_prompt = (
f"You are a meticulous QA inspector evaluating API documentation against its source code. "
f"You are grading the DOCUMENTATION, not the source code. Never deduct points because the source code "
f"itself could be designed better, and never ask for the source code to be changed. "
f"Score by deduction: start from 100 and subtract points only for concrete errors you can verify by "
f"pointing at specific documentation text that contradicts the source code. "
f"Check that every 'raise' statement in the source is documented with its exact exception type and its "
f"exact quoted error message: a missing, denied, or misquoted exception is a major deduction. "
f"Recompute every numeric value shown in the documentation's examples against what the source code would "
f"actually return: a wrong number is a major deduction. "
f"If you find no verifiable errors, score 100. "
f"The single_critical_fix must point at the documentation text to change, not at the source code."
)
user_prompt = (
f"Evaluate the following markdown documentation relative to its raw source code.\n\n"
f"Raw Source Code:\n{state['source_code']}\n\n"
f"Generated Markdown Document:\n{state['generated_markdown']}"
)
evaluation: DocEvaluationSchema = structured_llm.invoke([
SystemMessage(content=system_prompt),
HumanMessage(content=user_prompt)
])
# The model provides the judgment scores; the arithmetic and the pass/fail decision are done here in code.
overall_score = round((evaluation.technical_accuracy + evaluation.completeness) / 2)
is_approved = overall_score >= QUALITY_SCORE_BAR
required_fix = sanitize_critical_fix(evaluation.single_critical_fix)
return {
"current_score": overall_score,
"score_history": [overall_score],
"required_fix": required_fix,
"is_approved": is_approved
}
Sortie structurée. C’est la ligne llm.with_structured_output(DocEvaluationSchema) qui est déterminante. Elle contraint le modèle afin qu’un objet DocEvaluationSchema validé soit retourné plutôt que du texte libre. Cela signifie pas de parsing JSON, pas d’expressions régulières et pas de logique de tentative supplémentaire pour les sorties mal formatées. Le critère d’évaluation s’exécute avec temperature=0,0, car un évaluateur doit être déterministe ou le plus proche possible de cela pour un modèle local.
Évolution du prompt. Il a connu trois versions, et son historique constitue probablement la leçon la plus utile à retenir ici.
- La première version demandait poliment au modèle d’analyser la documentation et de lui attribuer une note. Les résultats étaient des scores courtois dans les 80 et 90 pour les documents contenant des messages d’erreur inventés, ce qui a fait que la boucle s’est arrêtée prématurément.
Évitez d’inclure des calculs arithmétiques dans le modèle. Le premier schéma faisait en sorte que le modèle génère lui-même un pourcentage global ainsi qu’un booléen indiquant succès ou échec, et les résultats étaient étranges. Une fois, le juge a attribué 100 points à l’exactitude technique et 100 points à la complétude, puis a indiqué un « moyenne nette » de 95, ce qui n’était en réalité que le seuil de réussite mentionné dans sa consigne, et non une moyenne réelle. Une autre fois, il a donné 97 points à un projet sur quatre tours consécutifs, alors que le seuil était de 95, mais a tout de même marqué chaque tentative comme échouée. Les quatre tentatives ont été gaspillées sur des projets qui qualifiaient déjà, car le routeur croyait au booléen, et le journal indiquait calmement que 97 était inférieur à 95.
Aujourd’hui, le modèle ne fournit que les deux sous-notations. Trois lignes de Python s’occupent du reste : elles calculent la moyenne, la comparent à QUALITY_SCORE_BAR et définissent le flag que le routeur suit. Comparer un nombre à une valeur seuil n’est pas une question de jugement, et tout ce qui ne relève pas du jugement ne devrait pas être confié à un modèle.
La protection sanitize_critical_fix. Lors de cette même séquence de quatre tours, les retours du critique au cours d’un seul tour se sont transformés en plusieurs centaines de tokens ../ répétés, qui ont été collés mot pour mot dans la prochaine instruction du générateur en tant que problème à corriger. Les petits modèles fonctionnant sous des contraintes de sortie structurée se comportent parfois de cette manière. Cette protection vérifie si la correction est vide ou dégénérée (un seul token répété, ou un texte composé de seulement quelques caractères distincts) et, le cas échéant, remplace cela par une demande générique afin de revoir la documentation par rapport à la source. Ce n’est pas très sophistiqué, mais cela empêche des données inutiles d’influencer un tour entier. Toute la fonction est courte :
def sanitize_critical_fix(fix: Optional[str]) -> str:
"""Guards against degenerate critic feedback (empty strings or runaway token repetition)."""
stripped = (fix or "").strip()
words = stripped.split()
is_degenerate = (
not stripped
or len(set(stripped)) <= 4
or (len(words) > 5 and len(set(words)) <= 2)
)
if is_degenerate:
return "Re-verify the documentation line by line against the source code and correct the weakest section."
return stripped
À chaque tour, le score est renvoyé sous forme de liste contenant un seul élément, et le réducteur d’état défini précédemment l’ajoute à l’historique. C’est ainsi que la courbe s’accumule. Le nœud affiche également un tableau de scores par tour indiquant la précision technique, la complétude, le score net par rapport à la barre de référence ainsi que les retours du critique. Ces affichages ne figurent pas dans l’extrait mais se trouvent dans le répertoire, et suivre l’évolution des scores d’un tour à l’autre constitue une partie du plaisir.
Partie 4 : Le routage
Le générateur et le critique étant prêts, il faut maintenant quelque chose pour choisir ce qui suit chaque critique. Dans LangGraph, cette tâche revient à une fonction de routage, qui lit l’état et renvoie le nom du nœud suivant.
def execution_router(state: AgentWorkflowState) -> Literal["content_generator", "content_adjudicator"]:
"""Monitors workflow states and determines whether to repeat, loop, or step out to adjudication."""
if state["is_approved"]:
return "content_adjudicator"
if state["iteration_count"] >= MAX_ATTEMPTS:
return "content_adjudicator"
return "content_generator"
Notes sur le routeur :
- Le boucle ne comporte que deux issues : soit le critique approuve le brouillon, soit la limite d’essais est atteinte. Dans un cas comme dans l’autre, le brouillon est transmis à l’Adjudicateur ; dans tous les autres cas, il revient au Générateur.
- Cette limite mérite plus de respect qu’il n’y paraît. Avec une barre à 95 points et un critique strict, certains brouillons ne seront jamais acceptés, et en l’absence de
MAX_ATTEMPTS, la boucle continuerait d’appeler Ollama indéfiniment. Quatre essais constituent un chiffre arbitraire mais fonctionnel, et il peut être modifié. - Le type de retour
Literalva au-delà d’une simple documentation, car il correspond à la carte de routage définie lors du câblage du graphe. - Tout comme pour les autres nœuds, les affichages de la console sont supprimés dans l’extrait. Dans le répertoire, le routeur enregistre le nombre d’essais et l’historique des scores avant chaque décision.
Partie 5 : Le nœud Adjudicateur
Tout ce qui quitte la boucle, que ce soit après avoir réussi ou épuisé ses tentatives, est soumis une dernière fois à un troisième modèle. La tâche de l’Adjudicateur n’est pas d’ajouter du contenu ; il résout les contradictions apparues au cours des différentes phases de révision et élimine tout élément de nature conversationnelle.
def content_adjudicator_node(state: AgentWorkflowState) -> dict:
"""The Adjudicator node: Runs outside the loop to resolve structural contradictions and finalize text."""
target_model = MODEL_REGISTRY["ADJUDICATOR"]
llm = ChatOllama(model=target_model, temperature=0.2)
system_prompt = (
"You are a senior technical documentation editor giving the final word on an API document "
"that has been through multiple revision rounds. Your job is to read the raw source code, "
"look at the latest draft, eliminate any conflicting statements, clean up semantic prose, and output the polished final markdown document. "
"Do not include any preamble, comments, conversational pleasantries, or explanations-return ONLY the markdown documentation."
)
user_prompt = (
f"Review and finalize this documentation template.\n\n"
f"Raw Source Reference Code:\n{state['source_code']}\n\n"
f"Latest Revision Draft:\n{state['generated_markdown']}\n\n"
f"Last Remaining Critic Concern:\n\"{state['required_fix']}\""
)
response = llm.invoke([SystemMessage(content=system_prompt), HumanMessage(content=user_prompt)])
return {"final_adjudicated_markdown": response.content}
Raison d’existence du nœud. Après plusieurs tours de « réécriture complète pour corriger X », un brouillon peut contenir des contradictions internes : un tour ajoute une affirmation et une réécriture ultérieure la contredit sans la supprimer. L’arbitre reçoit le texte original, le dernier brouillon ainsi que la dernière préoccupation non résolue du critique, avec pour instruction d’éliminer les affirmations contradictoires. Il sert également de voie de sortie pour les brouillons qui n’ont jamais satisfait aux critères. Au lieu d’envoyer la quatrième tentative brute, le processus fournit au moins une version harmonisée.
Temperature. Elle est réglée à temperature=0.2. Cette valeur est faible car l’objectif principal est l’édition, mais elle n’est pas nulle car il faut encore réécrire le texte.
Seulement Markdown. Les modèles locaux aiment commencer par "Bien sûr ! Voici votre documentation :" et terminer par "J’espère que cela vous sera utile." La requête interdit ces deux formules. Seule la formulation de la requête ne suffit pas, comme le montre la section relative au temps d’exécution ; c’est pourquoi une étape de post-traitement est nécessaire pour éliminer tout ce qui pourrait passer inaperçu.
Partie 6 : Mise en réseau du graphe
C’est maintenant que les éléments sont assemblés. C’est ici que LangGraph se révèle utile, car toute la topologie, y compris les cycles, peut être représentée en quelques lignes seulement :
builder = StateGraph(AgentWorkflowState)
builder.add_node("content_generator", content_generator_node)
builder.add_node("content_critic", content_critic_node)
builder.add_node("content_adjudicator", content_adjudicator_node)
builder.set_entry_point("content_generator")
builder.add_edge("content_generator", "content_critic")
builder.add_conditional_edges(
"content_critic",
execution_router,
{
"content_generator": "content_generator",
"content_adjudicator": "content_adjudicator"
}
)
builder.add_edge("content_adjudicator", END)
agent_pipeline = builder.compile()
Nœuds et point d’entrée. Les trois fonctions de nœud sont enregistrées sous des noms, le Générateur servant de point d’entrée. Le Générateur passe toujours la parole au Critique, et ce lien est inconditionnel.
Le lien conditionnel. La fonction add_conditional_edges relie le routeur au Critique. Une fois une critique terminée, le routeur s’exécute et sa réponse est recherchée dans la table de correspondance afin de trouver le nœud suivant. Cette table de correspondance crée un cycle, car content_critic peut renvoyer à content_generator, qui alimente à nouveau content_critic.
Compilation. L’appel à builder.compile() génère un pipeline pouvant être exécuté, de sorte que tout le cycle devient une seule appelation à agent_pipeline.invoke(initial_state).
Tout ceux qui ont écrit manuellement cette boucle, avec une instruction while, un gérant d’état manuel et un compteur d’essais, remarqueront à quel point les tâches de suivi ont disparu. La déclaration du graphique se documente également d’une manière que une boucle écrite manuellement ne peut jamais faire.
Partie 7 : Temps d’exécution, configuration et nettoyage des résultats
Enfin, il y a la fonction run() qui se trouve derrière le script de console reflexion. Elle analyse les arguments, charge la configuration, exécute le pipeline et nettoie les résultats. Une grande partie de son fonctionnement lié à l’analyse des arguments est expliquée de manière la plus claire dans le répertoire source, mais deux aspects méritent une attention particulière.
La configuration vient en premier. Le fichier config.json fournit les rôles du modèle ainsi que les paramètres de la boucle :
{
"model_registry": {
"GENERATOR": "qwen3.5:9b",
"CRITIC": "deepseek-coder:6.7b",
"ADJUDICATOR": "llama3:8b"
},
"quality_score_bar": 95,
"max_attempts": 4
}
Si le fichier est absent ou illisible, le chargeur utilise ces mêmes valeurs par défaut, de sorte que le processus démarre malgré tout. Les emplacements par défaut pour les données d’entrée, les données de sortie et la configuration sont déterminés par rapport au paquet installé ; dans le cas d’une installation modifiable, il s’agit de la racine du répertoire de dépôt. L’exécution de reflexion depuis n’importe quel répertoire permet donc de trouver le fichier config.json et le dossier input/ du répertoire de dépôt, tandis que les options --input, --output et --config permettent de les modifier.
Deuxièmement, le nettoyage des données de sortie, devenu nécessaire lors du travail avec des modèles locaux :
raw_final_text = final_output.get("final_adjudicated_markdown", "")
clean_final_markdown = raw_final_text
# 1. Clean Preamble (Conversational text at the top)
if "#" in raw_final_text:
parts = raw_final_text.split("#", 1)
if len(parts[0].strip()) > 0 and len(parts[0].strip()) < 200:
clean_final_markdown = "#" + parts[1]
# 2. Clean Postscript (Conversational and meta review text at the bottom)
meta_prefixes = (
"note:", "note that", "i hope", "this concludes", "in conclusion",
"no formatting errors", "no contradictions", "no errors",
"let me know", "feel free", "please let me know"
)
meta_keywords = ("errors or contradictions", "contradictions were found", "errors were found")
lines = clean_final_markdown.rstrip().split("\n")
while lines:
last_line = lines[-1].strip()
if not last_line:
lines.pop()
continue
lowered = last_line.lower()
# Real content usually carries Markdown structure; meta chatter does not.
is_structural = lowered.startswith(("#", "|", "-", "*", ">", "`")) or lowered[0].isdigit()
is_meta = (not is_structural) and (
lowered.startswith(meta_prefixes) or any(k in lowered for k in meta_keywords)
)
if not is_meta:
break
lines.pop()
clean_final_markdown = "\n".join(lines).rstrip() + "\n"
Comportement :
- Vérification du préambule. Si un texte précède le premier en-tête
#et compte moins de 200 caractères, il s’agit presque certainement d’une introduction du type « Voici le document finalisé » et il est supprimé. S’il dépasse 200 caractères, il reste, car il pourrait s’agir de contenu réel. - Vérification des post-scriptums. En partant de la dernière ligne vers le haut, le code examine chaque ligne une par une. Une ligne est supprimée si elle ressemble à du texte de modèle plutôt qu’à du contenu : un jugement de révision restant, ou une conclusion commençant par « Note : » ou « J’espère », comme « Aucune erreur de mise en forme ni contradiction n’a été trouvée », laissée à la fin d’un document globalement correct lors d’une première vérification. Comme il s’agit d’une boucle et non d’une seule vérification, plusieurs lignes de conclusion superposées disparaissent en une seule passe.
Lorsque le texte a été nettoyé, le Markdown final est enregistré dans le chemin de sortie. La console affiche ensuite un résumé du processus, y compris l’historique complet des scores et le nombre d’itérations effectuées.
Essayer
Trois exemples d’entrées sont fournis avec le répertoire : une fonction de filtrage des métriques, une vérification de signature API et un calculateur d’intérêts composés. La documentation du premier exemple se présente comme suit :
reflexion --input input/source_function_a.py --output output/result.md --config config.json
À partir de là, la boucle devient visible pendant son exécution. À chaque tour, le Générateur se présente, le Critique affiche un tableau de scores et le Routeur indique s’il y a boucle ou sortie.
Les résultats des exécutions sur les trois entrées se trouvent dans le dossier output/, ce qui permet d’examiner les produits du pipeline sans avoir à rien exécuter : des tableaux de paramètres bien organisés, des descriptions des valeurs retournées ainsi que des listes des exceptions levées avec leurs messages exacts entre guillemets. Deux des trois fichiers contiennent une petite erreur du type décrit ci-dessous, soit un résultat annoncé qui diffère de ce que le code renvoie, soit un exemple où une exception est levée alors qu’un retour était attendu. Elles ont été laissées intentionnellement, afin que leur découverte constitue un bon exercice.
Lorsque quelque chose ne va pas
Cela peut mal se passer, et la liste ci-dessous provient des dysfonctionnements observés lors des essais. Dans un cas, la documentation indiquait le mauvais type d’exception ainsi qu’un message d’erreur inventé, alors que le code génère en réalité un TypeError avec une chaîne de caractères spécifique. Dans un autre cas, elle affirmait qu’aucune exception n’était explicitement levée. Un tableau d’historique des versions présentait une ligne fictive « 0.1 initial release » datée de « TBD », lesquelles n’apparaissent nulle part dans le code source. Un exemple affichait un rapport de rendement proche de 0,86 alors que la réponse correcte est 0,667. Enfin, un document plutôt bien rédigé se terminait par le jugement du critique lui-même : « Aucune erreur de mise en forme ni contradiction n’a été trouvée. »
Puis il y a eu cette série où le score atteignait 97 face à 95 pendant quatre tours consécutifs, alors que l’on affirmait encore que le brouillon n’était pas prêt, un tour de retour d’information se transformant en des centaines de tokens ../. Et une fois que l’instruction destinée au critique a été poussée à l’extrême en termes de sévérité, il a attribué 0 sur 100 à un brouillon globalement correct pendant quatre tours d’affilée, justifiant ce score par une affirmation clairement fausse concernant la source. Le critique n’est ni un juge fixe ni une calculatrice ; il amplifie simplement l’humeur que lui transmet l’instruction.
Heureusement, les défaillances se regroupent en clusters, et chaque cluster dispose d’une mesure corrective ciblée dans le code. Les erreurs de contenu, telles que des exceptions incorrectes ou des calculs erronés dans les documents, sont gérées par les vérifications nommées intégrées aux prompts des critiques. Les défaillances de calibration (des validations trop laxistes d’un côté, des zéros excessifs de l’autre) sont traitées par la procédure de notation basée sur des déductions, ainsi que par le transfert du calcul de la moyenne et du seuil hors du modèle pour être effectué en Python au niveau du nœud critique. Les erreurs de sortie, les validations finales et les verdicts d’évaluation restants sont gérés par le balayage post-scriptum, tandis que les retours dégradés sont traités par la fonction de protection sanitize.
Rien de tout cela ne rend la boucle infaillible, car une instruction de prompt n’est qu’une demande et non une preuve. Lorsque les scores continuent de varier malgré toutes ces mesures, l’élément le plus influent est le modèle plutôt que le prompt. Le critère a la tâche la plus difficile dans ce système, et remplacer l’entrée CRITIC dans config.json par un modèle polyvalent plus performant ne nécessite qu’un changement en une seule ligne, sans aucun code supplémentaire.
La boucle produit de manière fiable un document bien meilleur que celui obtenu avec un prompt unique adressé au même générateur, et l’historique des scores offre un compte rendu honnête de la manière dont cela a été réalisé. Si les tentatives continuent de dépasser la limite autorisée, deux paramètres méritent d’être ajustés : quality_score_bar, dont la valeur peut descendre en dessous de 95, et max_attempts, dont la valeur peut monter au-dessus de 4.
Assemblage final
Le résultat est un pipeline en un seul fichier d’environ 360 lignes qui :
- Un état partagé, ainsi qu’un schéma d’évaluation structuré pour le critique, la moyenne et la décision de validation étant calculées en Python et non par le modèle.
- Un brouillon rédigé par un modèle local et critiqué par un autre, de sorte qu’aucun modèle ne note son propre travail.
- Un routeur qui renvoie des brouillons pour des révisions ciblées jusqu’à ce que la qualité requise soit atteinte ou que les tentatives soient épuisées.
- Lancement d’un troisième modèle en tant qu’arbitre final pour résoudre les contradictions laissées par les tours de révision.
- Suppression des introductions et des post-scriptum conversationnels du Markdown final avant de l’écrire sur disque.
- Les paramètres sont entièrement stockés dans un petit fichier
config.json, de sorte que changer de modèle ou assouplir le processus ne nécessite aucune modification de code.
Il y a de la marge pour l’élargir. Le critique pourrait évaluer plusieurs dimensions du critère, le routage choisissant la plus faible à corriger, au lieu d’une seule correction critique. Un quatrième nœud pourrait exécuter les exemples de code documentés et renvoyer des erreurs réelles dans la boucle, permettant de détecter les affirmations erronées que le critique manque. Ou bien la même structure de Générateur, Critique et Adjudicateur pourrait être utilisée pour une tâche complètement différente, comme la traduction ou la génération de tests, puisque rien dans le graphe n’est spécifique à la documentation.
Ici, l’argument en faveur des frameworks est valable : ils deviennent utiles lorsque le flux de travail dispose de cycles réels, de branches et d’un état partagé, et ce pipeline correspond à cette description ; LangGraph s’est chargé des parties que personne ne souhaite implémenter manuellement, à savoir la fusion des états, le routage conditionnel et le cycle lui-même. Construire d’abord des versions simplifiées permet de voir clairement quelles parties du framework effectuent réellement le travail, et cet ordre mérite d’être recommandé.