Accueil / Articles

LangGraph en pratique : états, nœuds, arêtes et cinq modèles d’agents

Apprenez à définir l’état de LangGraph à l’aide d’annotations et de réducteurs, à relier les nœuds par des arêtes, ainsi qu’à mettre en œuvre les cinq modèles de flux de travail agents fondamentaux en JavaScript.

Éditeur : REACTAPP.TOP10 min de lecture2248 mots

Sources: Agentic AI and LangGraph — Part 2: Building Nodes, Edges, and All 5 Workflow Patterns

Résumé : Où nous en étions

Dans le précédent article, nous avons présenté les fondements conceptuels : un agent d’IA associe un cerveau (le LLM) à un orchestrateur qui coordonne les outils et la mémoire, le tout encadré par une forme de supervision. Nous avons également vu que LangGraph transforme cela en un graph — les nœuds contiennent des unités de travail, les arêtes contrôlent le flux entre elles, et un objet state partagé circule dans tout le système, étant fusionné à chaque étape par des reducers.

Cette fois, nous passons de la théorie à la mise en œuvre. Lorsque vous aurez terminé cette publication, vous serez capable de :

  • Déclarer l’état à l’aide de Annotation, y compris des reducers personnalisés
  • Relier les nœuds entre eux en utilisant des arêtes simples et conditionnelles
  • Mettre en œuvre les cinq modèles de flux de travail décrits dans l’article précédent — chaînage d’instructions, routage, parallélisation, orchestrateur-travailleur et évaluateur-optimiseur — en JavaScript fonctionnel
  • Fournir un diagramme pour chaque modèle montrant précisément comment les éléments sont connectés
  • Tout le code ci-dessous repose sur @langchain/langgraph et du JavaScript pur (les mêmes modèles s’appliquent en TypeScript, il suffit d’ajouter des paramètres de type à Annotation<T>).

    npm install @langchain/langgraph @langchain/core @langchain/openai
    

    1. État et annotations — Le carnet partagé

    Au préalable de l’exécution d’un seul nœud, LangGraph doit comprendre la structure des données qui circulent dans votre graphe. Annotation est le moyen d’exprimer cette structure.

    import { Annotation, StateGraph, START, END } from "@langchain/langgraph";
    
    const WorkflowState = Annotation.Root({
      query: Annotation(),
      searchResults: Annotation(),
      draft: Annotation(),
      finalAnswer: Annotation(),
    });
    

    Chaque clé que vous définites à l’intérieur de Annotation.Root({...}) devient un champ dans l’objet d’état partagé. Tout nœud peut lire ces champs, et lorsqu’il renvoie une valeur, il n’a besoin d’inclure que les champs qu’il met réellement à jour — LangGraph s’occupe de fusionner ce résultat partiel avec l’état complet.

    Reducers : Déterminer comment les mises à jour sont fusionnées

    Sauf indication contraire, la valeur de retour d’un nœud remplace complètement le champ correspondant. Cela fonctionne bien pour des éléments comme draft, où seule la dernière version compte. Mais cela pose problème pour des listes d’éléments cumulatifs, où une écrasement effacerait tout ce qui a été ajouté précédemment. C’est là que le reducer intervient — il s’agit d’une fonction qui définit comment fusionner la valeur existante avec une mise à jour reçue.

    const AgentState = Annotation.Root({
      // Default behavior: last write wins
      draft: Annotation(),
    
      // Custom reducer: append instead of overwrite
      messages: Annotation({
        reducer: (current, update) => [...current, ...update],
        default: () => [],
      }),
    });
    

    Avec cette configuration, si nodeA renvoie { messages: ["hi"] } et nodeB renvoie { messages: ["hello"] }, le réducteur les fusionne en ["hi", "hello"] au lieu de laisser l’un écraser l’autre. Ce mécanisme est essentiel pour exécuter des branches parallèles en toute sécurité (voir le modèle 3 ci-dessous) — deux nœuds peuvent écrire dans la même clé d’état simultanément sans conflit.

    2. Nœuds — Unités de travail

    async function draftNode(state) {
      const response = await llm.invoke(`Write a short draft about: ${state.query}`);
      return { draft: response.content };
    }
    

    Vous le registrez dans le graphe à l’aide de addNode:

    const graph = new StateGraph(WorkflowState)
      .addNode("draft", draftNode);
    

    Notez que le nœud lui-même n’a aucune conscience de la structure du graphe qui l’entoure. Il se contente d’utiliser l’état actuel pour produire une mise à jour. La connexion des nœuds relève entièrement des arêtes.

    3. Arêtes — Le routage du flux

    Les arêtes déterminent l’ordre d’exécution. LangGraph prend en charge trois types :

    Arêtes normales — un chemin fixe et inconditionnel d’un nœud à un autre.

    graph.addEdge(START, "draft");   // entry point: where the graph starts
    graph.addEdge("draft", "polish");
    graph.addEdge("polish", END);    // exit point: where the graph stops
    

    Arêtes conditionnelles — une fonction de routage examine l’état actuel pour décider quel nœud doit s’exécuter en suivant.

    graph.addConditionalEdges("classify", (state) => {
      if (state.category === "billing") return "handleBilling";
      if (state.category === "technical") return "handleTechnical";
      return "handleGeneral";
    });
    

    Arêtes parallèles (fan-out) — lorsque un seul nœud possède plusieurs arêtes normales sortantes, tous leurs destinations s’exécutent simultanément.

    graph.addEdge("orchestrator", "workerA");
    graph.addEdge("orchestrator", "workerB");
    graph.addEdge("orchestrator", "workerC");
    

    Chaque graphe doit inclure au moins une arête partant de START ainsi qu’un parcours qui atteint finalement END ; sinon, LangGraph ne saura pas où l’exécution doit commencer ou se terminer.

    Avec les états, les nœuds et les arêtes maintenant expliqués, vous disposez de tous les éléments nécessaires pour construire les cinq modèles de flux de travail présentés précédemment.

    4. Modèle 1 — Chaînage de demandes

    Concept : diviser une tâche importante en une chaîne linéaire d’appels à un LLM, où chaque étape s’appuie sur celle qui la précède.

    START → outline → draft → polish → END
    
    const ChainState = Annotation.Root({
      topic: Annotation(),
      outline: Annotation(),
      draft: Annotation(),
      final: Annotation(),
    });
    
    async function outlineNode(state) {
      const res = await llm.invoke(`Create a short outline for: ${state.topic}`);
      return { outline: res.content };
    }async function draftNode(state) {
      const res = await llm.invoke(`Write a draft based on this outline:\n${state.outline}`);
      return { draft: res.content };
    }async function polishNode(state) {
      const res = await llm.invoke(`Polish and tighten this draft:\n${state.draft}`);
      return { final: res.content };
    }const chainGraph = new StateGraph(ChainState)
      .addNode("outline", outlineNode)
      .addNode("draft", draftNode)
      .addNode("polish", polishNode)
      .addEdge(START, "outline")
      .addEdge("outline", "draft")
      .addEdge("draft", "polish")
      .addEdge("polish", END)
      .compile();const result = await chainGraph.invoke({ topic: "the history of tea" });
    console.log(result.final);
    

    Chaque nœud ici ne lit que le champ écrit par le nœud qui le précède — il n’y a ni branchement ni boucle, juste un flux continu du début à la fin.

    5. Modèle 2 — Acheminement

    Concept : il faut d’abord classifier l’entrée reçue, puis la diriger vers l’une des plusieurs voies de traitement dédiées.

    ┌──> handleBilling   ──┐
    START → classify ───┼──> handleTechnical ─┼──> END
                        └──> handleGeneral   ──┘
    
    const RouteState = Annotation.Root({
      message: Annotation(),
      category: Annotation(),
      response: Annotation(),
    });
    
    async function classifyNode(state) {
      const res = await llm.invoke(
        `Classify this message as "billing", "technical", or "general". Only reply with one word.\n${state.message}`
      );
      return { category: res.content.trim().toLowerCase() };
    }async function handleBilling(state) {
      return { response: `[Billing team] ${await llm.invoke(state.message).then(r => r.content)}` };
    }
    async function handleTechnical(state) {
      return { response: `[Tech support] ${await llm.invoke(state.message).then(r => r.content)}` };
    }
    async function handleGeneral(state) {
      return { response: `[General desk] ${await llm.invoke(state.message).then(r => r.content)}` };
    }const routeGraph = new StateGraph(RouteState)
      .addNode("classify", classifyNode)
      .addNode("handleBilling", handleBilling)
      .addNode("handleTechnical", handleTechnical)
      .addNode("handleGeneral", handleGeneral)
      .addEdge(START, "classify")
      .addConditionalEdges("classify", (state) => {
        if (state.category === "billing") return "handleBilling";
        if (state.category === "technical") return "handleTechnical";
        return "handleGeneral";
      })
      .addEdge("handleBilling", END)
      .addEdge("handleTechnical", END)
      .addEdge("handleGeneral", END)
      .compile();
    

    La fonction que vous passez à addConditionalEdges doit retourner le nom du prochain nœud à visiter — c’est ce mécanisme qui permet au graphe de choisir dynamiquement un chemin plutôt que de suivre toujours la même route.

    6. Schéma 3 — Parallélisation

    Concept : lancer simultanément plusieurs appels à un LLM sur différentes parties d’une tâche, puis combiner les résultats obtenus.

    ┌──> summarizeDocA ──┐
    START → split ──┼──> summarizeDocB ──┼──> merge → END
                    └──> summarizeDocC ──┘
    
    const ParallelState = Annotation.Root({
      documents: Annotation(),
      summaries: Annotation({
        reducer: (current, update) => [...current, ...update],
        default: () => [],
      }),
      finalSummary: Annotation(),
    });
    
    function splitNode(state) {
      return {}; // no-op entry point, just fans out to the summarizers below
    }async function summarizeDocA(state) {
      const res = await llm.invoke(`Summarize:\n${state.documents[0]}`);
      return { summaries: [res.content] };
    }
    async function summarizeDocB(state) {
      const res = await llm.invoke(`Summarize:\n${state.documents[1]}`);
      return { summaries: [res.content] };
    }
    async function summarizeDocC(state) {
      const res = await llm.invoke(`Summarize:\n${state.documents[2]}`);
      return { summaries: [res.content] };
    }async function mergeNode(state) {
      const res = await llm.invoke(`Combine these summaries into one:\n${state.summaries.join("\n---\n")}`);
      return { finalSummary: res.content };
    }const parallelGraph = new StateGraph(ParallelState)
      .addNode("split", splitNode)
      .addNode("summarizeDocA", summarizeDocA)
      .addNode("summarizeDocB", summarizeDocB)
      .addNode("summarizeDocC", summarizeDocC)
      .addNode("merge", mergeNode)
      .addEdge(START, "split")
      .addEdge("split", "summarizeDocA")
      .addEdge("split", "summarizeDocB")
      .addEdge("split", "summarizeDocC")
      .addEdge("summarizeDocA", "merge")
      .addEdge("summarizeDocB", "merge")
      .addEdge("summarizeDocC", "merge")
      .addEdge("merge", END)
      .compile();
    

    Faites attention au réducteur associé à summaries : sans lui, le dernier branchement parallèle terminé écrasera simplement les résultats précédents au lieu de permettre aux trois sorties d’être accumulées en toute sécurité dans le tableau.

    7. Schéma 4 — Orchestrateur-Worker

    Concept : une seule appel à un LLM divise le travail global en sous-tâches plus petites et les confie chacune à un nœud worker, puis une étape finale assemble tout cela en un seul résultat. Cela ressemble beaucoup à la parallélisation, mais ici c’est l’orchestrateur qui décide en temps réel du nombre de workers à activer et de ce que chacun doit faire, plutôt que cela d’être défini à l’avance.

    START → orchestrator ──> worker(s), one per subtask ──> compile → END
    
    const OrchestratorState = Annotation.Root({
      task: Annotation(),
      subtasks: Annotation(),
      sections: Annotation({
        reducer: (current, update) => [...current, ...update],
        default: () => [],
      }),
      report: Annotation(),
    });
    
    async function orchestratorNode(state) {
      const res = await llm.invoke(
        `Break this task into 3-5 short subtasks, one per line:\n${state.task}`
      );
      const subtasks = res.content.split("\n").filter(Boolean);
      return { subtasks };
    }async function workerNode(state) {
      // In a real app you'd invoke one worker per subtask (e.g. via Send / map-reduce);
      // here's the simple per-subtask logic each worker runs:
      const results = await Promise.all(
        state.subtasks.map((subtask) => llm.invoke(`Write a section about: ${subtask}`))
      );
      return { sections: results.map((r) => r.content) };
    }async function compileNode(state) {
      const res = await llm.invoke(`Compile these sections into one report:\n${state.sections.join("\n\n")}`);
      return { report: res.content };
    }const orchestratorGraph = new StateGraph(OrchestratorState)
      .addNode("orchestrator", orchestratorNode)
      .addNode("worker", workerNode)
      .addNode("compile", compileNode)
      .addEdge(START, "orchestrator")
      .addEdge("orchestrator", "worker")
      .addEdge("worker", "compile")
      .addEdge("compile", END)
      .compile();
    

    Si vous avez besoin d’une véritable dynamique de distribution — c’est-à-dire que le nombre de branches worker change réellement d’une exécution à l’autre — LangGraph.js propose l’API Send, qui vous permet de lancer une invocation worker par sous-tâche au moment de l’exécution. Ce mécanisme mérite un exposé détaillé à part, mais la configuration présentée ici couvre déjà la grande majorité des besoins courants, tels que la rédaction de rapports ou la création d’agents de recherche.

    8. Schéma 5 — Évaluateur-Optimiseur

    Concept : un nœud génère une sortie, un deuxième nœud la vérifie, et un bord conditionnel redirige l’exécution vers le générateur jusqu’à ce que la sortie soit acceptée. C’est dans ce schéma que le support de LangGraph pour les cycles est réellement mis à profit.

    START → generate → evaluate ──(fail)──> generate  (loop)
                           │
                         (pass)
                           ↓
                          END
    
    const LoopState = Annotation.Root({
      prompt: Annotation(),
      code: Annotation(),
      feedback: Annotation(),
      passed: Annotation(),
      attempts: Annotation({
        reducer: (current, update) => current + update,
        default: () => 0,
      }),
    });
    
    async function generateNode(state) {
      const context = state.feedback ? `\nPrevious feedback: ${state.feedback}` : "";
      const res = await llm.invoke(`Write code for: ${state.prompt}${context}`);
      return { code: res.content, attempts: 1 };
    }async function evaluateNode(state) {
      const res = await llm.invoke(
        `Review this code for bugs. Reply "PASS" if it's correct, otherwise explain what's wrong:\n${state.code}`
      );
      const passed = res.content.trim().startsWith("PASS");
      return { passed, feedback: passed ? null : res.content };
    }const loopGraph = new StateGraph(LoopState)
      .addNode("generate", generateNode)
      .addNode("evaluate", evaluateNode)
      .addEdge(START, "generate")
      .addEdge("generate", "evaluate")
      .addConditionalEdges("evaluate", (state) => {
        if (state.passed || state.attempts >= 4) return END; // safety cap on retries
        return "generate";
      })
      .compile();
    

    Remarquez que le champ attempts utilise un réducteur qui ajoute à la somme en cours plutôt que de la remplacer — cela agit également comme une limite. Sans quelque type de plafond pour les tentatives, un évaluateur qui n’approuve jamais la sortie pourrait maintenir la boucle en cours indéfiniment.

    9. Compilation et exécution de l’un d’eux

    Quel que soit le schéma que vous construisez, vous terminez de la même manière :

    const app = graph.compile();
    const finalState = await app.invoke({ /* initial state fields */ });
    

    L’appel de .compile() vérifie que chaque nœud est bien accessible, s’assure que START et END sont correctement connectés, et convertit la description de votre graphe en un objet exécutable. .invoke() exécute ensuite ce graphe du début à la fin et renvoie l’état résultant — il s’agit du même cycle d’exécution décrit conceptuellement précédemment dans cette série.

    Conclusion

    Au cours de cette présentation, les idées théoriques se sont transformées en code exécutable : Annotation.Root pour définir l’état, des fonctions ordinaires en tant que nœuds, addEdge et addConditionalEdges pour contrôler le flux, ainsi que des réducteurs afin d’éviter que les mises à jour concurrentes ou répétées ne s’annulent mutuellement. Avec ces quatre éléments seulement, chacun des cinq modèles de flux abordés — chaînage, routage, parallélisation, orchestrateur-travailleur et évaluateur-optimiseur — s’avère être une combinaison différente des mêmes outils de base.

    La prochaine partie de cette série introduira des outils externes, ajoutera une mémoire permettant au statut de persister entre les sessions, et mettra en place une étape où une personne examine et approuve les actions avant qu’elles ne soient exécutées — les éléments nécessaires pour transformer ces graphes en véritables agents.

    Lectures complémentaires

  • Échelonner les nœuds, pas les tâches : un escalier à six niveaux pour maîtriser les coûts des workflows d’LLM — Pourquoi choisir entre un DAG et un agent par tâche entraîne une augmentation des coûts liés aux LLM, et comment un système d’échelonnement par nœud, accompagné de contrats, de périmètres et de budgets, permet de les limiter.