Accueil / Articles

Un pipeline LangGraph qui maintient la mémoire du codebase pour assurer l’exactitude des agents IA

Créez un pipeline LangGraph qui génère des fichiers de connaissances par service, les contrôle à l’aide de Ragas et DeepEval, traque les exécutions dans Langfuse et permet également leur exécution localement sur Ollama.

Éditeur : REACTAPP.TOP11 min de lecture2531 mots

Sources: Codebase Memory for AI Agents: A LangGraph Pipeline That Actually Stays Accurate

Tout ceux qui ont utilisé des agents de codage sur un grand dépôt se posent une question : comment un agent peut-il comprendre la base de code sans devoir relire chaque fichier pour chaque tâche ? Ce qui suit constitue la troisième tentative de résoudre ce problème. Les premières deux tentatives utilisaient un RAG basé sur l’embedding des docstrings. Toutes deux ont échoué en moins d’un mois, car les documents ne suivaient pas les modifications du code. Cette troisième version a permis à une équipe de faire trente ou quarante commits par jour.

Il ne s’agit pas d’une analyse du format de fichiers d’autrui, mais bien du pipeline lui-même : un graphe d’état LangGraph qui surveille le dépôt, rédige un fichier de connaissances structuré pour chaque service, évalue la précision de ses propres résultats avant que tout agent ne les lise, et traque chaque exécution afin que le coût soit visible. Les échecs de la première phase y sont également pris en compte.

Ce que couvre le texte : pourquoi les problèmes de budget de tokens dans les environnements de codage multi-agents relèvent en réalité d’un problème de désuétude, la conception complète de LangGraph (schéma d’état, nœuds, arêtes conditionnelles, sauvegardes via DynamoDB), une utilisation de Amazon Bedrock avec Claude Haiku 4.5 pour la citation, un système d’évaluation basé sur Ragas et DeepEval, des outils d’observabilité grâce à Langfuse auto-hébergé, une version entièrement locale d’Ollama destinée aux équipes ne pouvant pas envoyer du code à un modèle cloud, le hook git qui relie tout cela, ainsi qu’une analyse des modes de défaillance.

Le problème

Chaque fois qu’un agent de codage commence une tâche dans un dépôt, il ignore que ses deux options sont médiocres. Il peut lire tout le contenu, ce qui consomme des tokens pour des fichiers sans rapport avec la tâche. Ou il peut utiliser une recherche basée sur des embeddings, qui fournit des extraits sémantiquement similaires mais ne donne aucune réponse explicite sur qui appelle quoi ou à qui appartient un module.

La solution viable consiste en un petit nombre de fichiers structurés et soigneusement sélectionnés, un par service, indiquant ce qu’il fait et de quoi il dépend, générés une fois puis mis à jour automatiquement. Un agent d’orchestration consulte un index, sélectionne les deux ou trois fichiers pertinents pour la tâche, puis n’en envoie que ceux-ci à un sous-agent. Il s’agit autant de dépenser judicieusement les tokens que de représenter les connaissances.

Le format des fichiers n’est pas la source de la difficulté. Markdown associé à du frontmatter YAML n’est pas vraiment innovant. La difficulté réside dans le mécanisme qui empêche ces fichiers de devenir obsolètes au fur et à mesure que le code qui les sous-tend évolue.

L’architecture

                       git push / commit
                               |
                               v
                  +-------------------------+
                  |   scan_diff (node)      |
                  |   git diff --name-only  |
                  |   scope to changed pkgs |
                  +-------------------------+
                               |
                               v
                  +-------------------------+
                  |   draft_concept (node)  |
                  |   Bedrock Claude Haiku  |
                  |   one file per service  |
                  +-------------------------+
                               |
                               v
                  +-------------------------+
                  |   add_citations (node)  |
                  |   link to runbooks/PRs  |
                  +-------------------------+
                               |
                               v
                  +-------------------------+
                  |   eval_gate (node)      |
                  |   Ragas faithfulness    |
                  |   DeepEval assert_test  |
                  +-------------------------+
                          pass |  | fail
                               |  +----> re-draft (max 2 retries) --> human review queue
                               v
                  +-------------------------+
                  |   relink (node)         |
                  |   fix cross-references  |
                  +-------------------------+
                               |
                               v
                  +-------------------------+
                  |   publish (node)        |
                  |   commit bundle to repo |
                  +-------------------------+

        every node emits a Langfuse trace
        every super-step checkpoints to DynamoDB

Le graphe est délibérément petit, avec cinq nœuds réels et une arête de tentative. LangGraph encourage à créer quelque chose de plus complexe que ce que le problème exige. Une première tentative avec onze nœuds s’est avérée impossible à expliquer, car personne ne pouvait dire pourquoi un fichier donné existait.

La pile et la raison d’être de chacun de ses éléments

+------------------+---------------------------+--------------------------------------+
| Layer            | Tool                      | Why this one                          |
+------------------+---------------------------+--------------------------------------+
| Orchestration    | LangGraph                 | Explicit state, conditional retries,  |
|                  |                           | resumable from checkpoint             |
| Draft model      | AWS Bedrock, Claude       | Cheap enough to run on every commit,  |
|                  | Haiku 4.5                 | fast enough not to block CI            |
| Checkpoint store | DynamoDB                  | Single-digit ms reads, TTL for expiry, |
|                  | (langgraph-checkpoint-aws)| survives a crashed worker mid-run      |
| Eval, faithfulness| Ragas                    | Reference-free scoring of a generated  |
|                  |                           | file against the actual code diff     |
| Eval, CI gate    | DeepEval                  | pytest-native, hard-fails a bad draft  |
|                  |                           | instead of silently publishing it     |
| Observability    | Langfuse (self-hosted)   | Per-node token/cost/latency traces,    |
|                  |                           | MIT licensed, own your own data        |
| Local alternative| Ollama (llama3.1:8b,     | Same pipeline, zero cloud calls, for   |
|                  | nomic-embed-text)         | air-gapped or cost-sensitive teams     |
+------------------+---------------------------+--------------------------------------+

Étape 1 : le schéma d’état

Tout dans LangGraph commence par la forme de l’état transmis entre les nœuds. Si cela est mal fait, chaque nœud suivant doit deviner ce que le nœud précédent a produit.

from typing import TypedDict, Literal
from langgraph.graph import StateGraph, END
class ConceptDraft(TypedDict):
    path: str              # e.g. "services/billing-service.md"
    concept_type: str      # "Service", "Runbook", "API Endpoint", ...
    title: str
    body: str              # the drafted markdown body
    citations: list[str]
    faithfulness_score: float
    retry_count: int
class PipelineState(TypedDict):
    repo_root: str
    commit_sha: str
    changed_files: list[str]
    drafts: list[ConceptDraft]
    failed_drafts: list[ConceptDraft]
    status: Literal["scanning", "drafting", "evaluating", "publishing", "done", "needs_review"]

Rien de compliqué là-dedans. C’est le champ retry_count qui est important, car il empêche un brouillon de mauvaise qualité de tenter indéfiniment de passer le contrôle d’évaluation.

Étape 2 : limiter la vérification au diff

Vérifier l’ensemble du répertoire à chaque commit est ce qui rend de tels pipelines trop coûteux pour être exécutés en continu. En limitant la vérification au diff, on les rend suffisamment économiques pour être utilisés à chaque commit et non seulement lors d’une exécution nocturne.

import subprocess
def scan_diff(state: PipelineState) -> PipelineState:
    result = subprocess.run(
        ["git", "diff", "--name-only", f"{state['commit_sha']}~1", state["commit_sha"]],
        cwd=state["repo_root"], capture_output=True, text=True, check=True
    )
    changed = [f for f in result.stdout.splitlines() if f.endswith((".py", ".go", ".ts"))]
    # map file paths to the service/package that owns them
    packages = sorted({f.split("/")[1] for f in changed if "/" in f})
    state["changed_files"] = packages
    state["status"] = "drafting"
    return state

La correspondance entre les fichiers et les paquets est l’élément véritablement spécifique à chaque répertoire. Un monorepo disposant d’une structure ordonnée services// nécessite environ trois lignes. Un répertoire hérité complexe exige une carte des responsabilités conservée séparément, de préférence en analysant un fichier CODEOWNERS plutôt que par déduction à partir des chemins.

Étape 3 : rédaction avec Bedrock

Cette étape coûte de l’argent réel, elle mérite donc une gestion minutieuse. Claude Haiku 4.5 sur Bedrock est suffisamment rapide et peu coûteux pour être exécuté à chaque commit, tandis qu’un modèle plus puissant n’est utilisé que pour les cas nécessitant une révision humaine, jamais pour la phase de rédaction habituelle.

import boto3
import json
bedrock = boto3.client("bedrock-runtime", region_name="us-east-1")
MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0"  # cross-region inference profile
DRAFT_PROMPT = """You are documenting a software service for an AI coding agent's memory.
Given the file contents below, write a concept file with:
- A one-paragraph Responsibilities section
- A Dependencies section listing what this service calls and what calls it
- Nothing else. Do not invent dependencies you cannot see in the code.
Files:
{file_contents}
"""
def draft_concept(state: PipelineState) -> PipelineState:
    drafts = []
    for package in state["changed_files"]:
        contents = read_package_source(state["repo_root"], package)
        response = bedrock.invoke_model(
            modelId=MODEL_ID,
            body=json.dumps({
                "anthropic_version": "bedrock-2023-05-31",
                "max_tokens": 800,
                "messages": [{"role": "user", "content": DRAFT_PROMPT.format(file_contents=contents)}]
            })
        )
        body = json.loads(response["body"].read())
        text = body["content"][0]["text"]
        drafts.append(ConceptDraft(
            path=f"services/{package}.md",
            concept_type="Service",
            title=package,
            body=text,
            citations=[],
            faithfulness_score=0.0,
            retry_count=0
        ))
    state["drafts"] = drafts
    state["status"] = "evaluating"
    return state

Une seule instruction dans cet encouragement est vraiment efficace : ne inventez pas de dépendances que vous ne pouvez pas voir dans le code. Laisser Haiku agir librement le pousse à décrire avec assurance une dépendance envers un service qui n’existe pas. Selon certaines sources, cette seule phrase a réduit le taux de dépendances hallucinées de près d’un fichier sur six à presque zéro lors des tests. Ne considérez pas cela comme une règle universelle, car cela dépend de la quantité de code fournie dans l’encouragement.

Étape 4 : le filtre d’évaluation que la plupart des gens ignorent

Un fichier Markdown disposant d’un champ de type frontmatter ne peut pas savoir s’il dit la vérité. Rien dans ce format n’empêche un LLM d’écrire une description convaincante, bien formatée mais fausse d’un service. En sautant cette étape, le système peut corrompre silencieusement la compréhension d’un agent par rapport à son propre codebase, tout en paraissant tout aussi fiable qu’une description correcte.

Ragas calcule le score de fidélité, c’est-à-dire dans quelle mesure le brouillon est basé sur la source originale, sans nécessiter de réponse de référence écrite à la main. DeepEval transforme ensuite ce chiffre en une valeur que les systèmes d’intégration continue peuvent utiliser pour prendre des décisions.

from ragas.metrics import faithfulness
from ragas import evaluate
from datasets import Dataset
def score_faithfulness(draft: ConceptDraft, source_contents: str) -> float:
    eval_dataset = Dataset.from_dict({
        "question": ["Describe this service's responsibilities and dependencies."],
        "answer": [draft["body"]],
        "contexts": [[source_contents]],
    })
    result = evaluate(eval_dataset, metrics=[faithfulness])
    return result["faithfulness"][0]
from deepeval import assert_test
from deepeval.test_case import LLMTestCase
from deepeval.metrics import FaithfulnessMetric
def eval_gate(state: PipelineState) -> PipelineState:
    passed, failed = [], []
    for draft in state["drafts"]:
        source = read_package_source(state["repo_root"], draft["title"])
        score = score_faithfulness(draft, source)
        draft["faithfulness_score"] = score
        test_case = LLMTestCase(
            input="Describe this service.",
            actual_output=draft["body"],
            retrieval_context=[source]
        )
        metric = FaithfulnessMetric(threshold=0.75)
        try:
            assert_test(test_case, [metric])
            passed.append(draft)
        except AssertionError:
            draft["retry_count"] += 1
            failed.append(draft)
    state["drafts"] = passed
    state["failed_drafts"] = failed
    state["status"] = "publishing" if not failed else "needs_review"
    return state

Le seuil de 0,75 n’est qu’un point de départ et non une loi. Il a été relevé de 0,6 après deux versions initiales au cours desquelles des affirmations de dépendance légèrement erronées ont été acceptées. Une équipe qui adopte cette méthode devrait s’attendre à passer une semaine à observer ses taux de faux positifs et de faux négatifs avant de pouvoir faire confiance à ce chiffre.

Étape 5 : connexion du graphique aux points de contrôle DynamoDB

from langgraph_checkpoint_aws import DynamoDBSaver
checkpointer = DynamoDBSaver(
    table_name="codebase-memory-checkpoints",
    ttl_seconds=60 * 60 * 24 * 7  # expire checkpoints after a week
)
graph = StateGraph(PipelineState)
graph.add_node("scan_diff", scan_diff)
graph.add_node("draft_concept", draft_concept)
graph.add_node("eval_gate", eval_gate)
graph.add_node("relink", relink)
graph.add_node("publish", publish)
graph.set_entry_point("scan_diff")
graph.add_edge("scan_diff", "draft_concept")
graph.add_edge("draft_concept", "eval_gate")
graph.add_conditional_edges(
    "eval_gate",
    lambda s: "relink" if s["status"] == "publishing" else END,
    {"relink": "relink", END: END}
)
graph.add_edge("relink", "publish")
graph.add_edge("publish", END)
pipeline = graph.compile(checkpointer=checkpointer)

Le choix de DynamoDB n’est pas arbitraire. Le pipeline s’exécute dans un environnement CI, où les processus sont souvent arrêtés en cours d’exécution plus fréquemment que ce que l’on admet. Un graphe marqué par des points de contrôle reprend à partir du dernier super-étape terminé, plutôt que de réécrire cinq fichiers simplement parce que le sixième a expiré.

Étape 6 : connaître le coût

On ne faisait pas confiance au pipeline tant que l’utilisation des tokens et la durée de chaque nœud n’étaient pas visibles. Le décorateur @observe() de Langfuse a permis d’ajouter cette fonctionnalité en cinq minutes, sans avoir à redessiner l’ensemble du système.

from langfuse.decorators import observe
@observe()
def draft_concept(state: PipelineState) -> PipelineState:
    # same body as above, now traced automatically
    ...

L’hébergement auto-géré ne nécessite qu’un fichier docker-compose, ce qui est indispensable si votre code, y compris les différences affichées dans les prompts, doit rester à l’intérieur de votre réseau :

git clone https://github.com/langfuse/langfuse.git
cd langfuse
docker compose up -d

Après que ClickHouse ait acquis le projet, Langfuse auto-hébergé conserve des traces tant dans Postgres que dans ClickHouse, et l’auto-hébergement semble tout aussi fiable qu’auparavant. Il convient de le vérifier avant d’intégrer des outils d’observabilité en production, car il est tout à fait légitime de se demander si un outil sera abandonné ou soumis à un mur payant.

Le tableau de bord affiche trois éléments : le coût du token par commit, la distribution de la fiabilité au fil du temps et le taux de tentatives répétées. Une baisse lente de la fiabilité indique généralement un problème dans la logique de lecture des sources, et non une détérioration du modèle. Un taux de tentatives répétées supérieur à environ 10 % est toujours dû aux prompts, jamais au modèle lui-même.

Une version locale sans cloud

Certaines équipes ne peuvent même pas envoyer des différences de code à un modèle hébergé. Il suffit de remplacer le nœud de draft par Ollama pour que le même graphe fonctionne sans aucun autre changement dans l’architecture.

import ollama
def draft_concept_local(state: PipelineState) -> PipelineState:
    drafts = []
    for package in state["changed_files"]:
        contents = read_package_source(state["repo_root"], package)
        response = ollama.chat(
            model="llama3.1:8b",
            messages=[{"role": "user", "content": DRAFT_PROMPT.format(file_contents=contents)}]
        )
        drafts.append(build_draft(package, response["message"]["content"]))
    state["drafts"] = drafts
    return state

Pour une recherche basée sur l’incorporation de données au sein du bundle publié, utile lorsque l’on dispose de plus d’une centaine de fichiers conceptuels et que index.md seul ne suffit pas, nomic-embed-text via Ollama est le modèle que la plupart des configurations RAG locales utilisent déjà, ce qui évite de devoir justifier l’ajout d’une nouvelle dépendance.

+------------------+------------------------+---------------------------+
| Component        | Cloud version          | Local version             |
+------------------+------------------------+---------------------------+
| Draft model      | Bedrock Claude Haiku   | Ollama llama3.1:8b        |
| Embeddings       | Bedrock Titan Embed    | Ollama nomic-embed-text   |
| Checkpoint store | DynamoDB               | SQLite (LangGraph built-in)|
| Observability    | Langfuse (self-hosted) | Langfuse (self-hosted)    |
+------------------+------------------------+---------------------------+

Lors des tests sur les mêmes fichiers sources, la fidélité de llama3.1:8b était d’environ 10 à 15 points inférieure à celle de Haiku 4.5, principalement parce qu’il rend plus difficile la description des dépendances. C’est cette différence qui justifie l’existence du mécanisme d’évaluation, quel que soit le modèle utilisé. On ne veut pas découvrir une différence de qualité via un agent confus trois semaines plus tard.

L’hook git

#!/usr/bin/env bash
# .git/hooks/post-commit
set -euo pipefail

COMMIT_SHA=$(git rev-parse HEAD)
python -m codebase_memory.pipeline --commit "$COMMIT_SHA" --repo-root "$(git rev-parse --show-toplevel)"

Fonctionnez-le des deux manières : en tant que hook post-commit sur l’ordinateur du développeur et comme tâche CI obligatoire sur la branche partagée. Localement, il détecte les écarts avant même qu’une PR ne soit créée. En CI, c’est le point de contrôle, car certains contributeurs n’installent jamais ce hook, et un hook qui ne peut pas être imposé n’est qu’un conseil.

Vérification de la réalité en production

Voici ce qu’il convient de savoir avant de le mettre en place.

  • Le coût est réel mais modeste. Pour un dépôt avec environ 40 commits par jour touchant deux ou trois packages chacun, le test de draft avec Haiku coûte une dizaine de dollars par mois environ, tandis que le test d’évaluation double à peu près ce montant, car chaque draft est évalué par rapport à sa version source. C’est un prix raisonnable pour éviter de fournir une documentation erronée à un agent.
  • La latence est le compromis dont personne ne parle. Un cycle complet de scan, de rédaction, d’évaluation et de publication pour une modification contenant un seul paquet prend entre 15 et 25 secondes selon les mesures effectuées sur une configuration donnée. Cela convient pour un job CI asynchrone, mais pas comme bloc synchronique lors de la soumission d’une modification ; une erreur commise dans la première version est ainsi conservée avant qu’elle ne soit traitée en arrière-plan.
  • Le système de contrôle peut parfois bloquer une version provisoire correcte. L’évaluation de la fidélité n’est pas parfaite, et une version provisoire précise mais concise peut se retrouver en deçà du seuil requis. Tout ce qui échoue deux fois est envoyé dans une file d’attente pour révision humaine et n’est pas simplement éliminé, ce qui correspond au statut needs_review dans la machine à états.
  • Cela ne vaut pas la peine d’être développé sans plusieurs agents sur votre propre base de code. Toute la valeur provient des agents qui traitent le paquet. Une version basée uniquement sur l’incorporation a été créée en premier mais est restée inutilisée pendant deux mois, car rien ne la lisait.
  • La correspondance entre les paquets et les fichiers ne se fera pas de manière propre. Tout le reste est presque du copier-coller. C’est cette fonction-là que vous devrez réécrire pour votre propre mise en page, et elle mérite un développement complet plutôt qu’une solution rapide.
  • Que construire ensuite

    Actuellement, le système d’évaluation ne prend en compte la fidélité qu’une fois un brouillon écrit, sans jamais reconsidérer un fichier déjà publié. La prochaine étape logique consiste à réévaluer périodiquement l’ensemble du paquet en fonction du code source actuel, afin de détecter les fichiers conceptuels qui étaient exacts au moment initial mais qui ont mal fonctionné après trois modifications sans que de nouveau brouillon ne soit généré. Cela n’a pas encore été mis en place.

    Les seuils d’évaluation sont des paramètres que chacun ajuste différemment et que presque personne ne rend publics ; donc, si vous développez quelque chose de similaire, suivez où se situe votre seuil et vérifiez s’il reste valable après quelques semaines.