Les architectures d’agents IA, expliquées simplement

Workflows, agents, multi-agents et briques transverses : les architectures d’agents IA, quand chacune s’applique le mieux, et quand un agent n’est pas la bonne réponse.

Agents IAPar 8 min de lecture

Beaucoup de projets d’« agent IA » n’ont pas besoin d’un agent. Ils ont besoin d’un workflow bien découpé, avec un appel au modèle au bon endroit.

Le mot agent désigne aujourd’hui à peu près tout, du chatbot branché sur une FAQ au système qui enchaîne des dizaines d’appels d’outils sans supervision. Cette confusion pousse souvent à choisir l’architecture la plus impressionnante plutôt que la plus adaptée, et le prix se paie en production : coûts imprévisibles, comportements difficiles à reproduire, bugs que les tests rattrapent mal.

Du déterministe à l’autonome

Dans un workflow, votre code décide du chemin : les étapes sont écrites à l’avance, le modèle intervient à certaines d’entre elles. Dans un agent, c’est le modèle qui décide : il choisit l’outil suivant, juge s’il a fini, recommence s’il le faut. Les architectures se rangent sur cet axe :

  1. Les workflows : le flux est codé, le modèle exécute des étapes.
  2. Les agents : un modèle pilote la boucle et choisit ses actions.
  3. Les multi-agents : plusieurs agents spécialisés coopèrent.

S’y ajoutent des briques transverses, valables pour toutes : RAG, validation humaine, mémoire, exécution durable, garde-fous. Chaque pas vers l’autonomie achète de la flexibilité et la paie en prévisibilité, en coût et en testabilité.

1. Workflows : le code décide du chemin

Un workflow est un pipeline classique dont certaines étapes appellent le modèle. L’ordre, les conditions, les reprises sur erreur restent dans votre code. Cinq formes reviennent presque toujours :

  • Chaînage : la sortie d’une étape alimente la suivante, avec des contrôles entre les deux (extraire, valider, rédiger). Chaque appel reçoit une tâche plus simple : un peu de latence contre de la précision.
  • Routage : un classifieur oriente la demande vers le bon traitement, par exemple un ticket vers la facturation, la technique ou le commercial.
  • Parallélisation : des sous-tâches indépendantes traitées en même temps, ou la même question posée plusieurs fois pour faire voter les réponses.
  • Orchestrateur-workers : un modèle découpe la tâche à la volée et confie chaque morceau à un worker, un exécutant spécialisé. Le découpage est dynamique, la structure reste fixe.
  • Évaluateur-optimiseur : un appel produit, un autre évalue selon des critères explicites, jusqu’à ce que le résultat passe, dans une boucle bornée. Utile quand les critères sont clairs (traduction, code soumis à des règles de sécurité, ton d’un courrier), inutile quand la première réponse suffit.

Premier exemple : un routeur par coût. Le plus petit modèle évalue la complexité de la tâche et choisit le modèle qui la traitera ; si la confiance déclarée revient trop basse, le code escalade d’un niveau, tant que le budget le permet. Chaque appel est consigné dans un journal des coûts.

Workflow : confier chaque tâche au plus petit modèle capable01-cost-aware-router.ts
tierconfiant, ou budget atteintconfiance < 0,85tier + 1STARTtriagesolveescalateEND
  • Appel au modèle
  • Code
  • Transition conditionnelle
import { StateGraph, StateSchema, ReducedValue, START, END } from "@langchain/langgraph";
import { ChatAnthropic } from "@langchain/anthropic";
import type { AIMessage } from "@langchain/core/messages";
import { z } from "zod";

const TIERS = ["small", "medium", "large"] as const;
type Tier = (typeof TIERS)[number];

const models: Record<Tier, ChatAnthropic> = {
  small: new ChatAnthropic({ model: "claude-haiku-5-5" }),
  medium: new ChatAnthropic({ model: "claude-sonnet-5-5" }),
  large: new ChatAnthropic({ model: "claude-opus-5-5" }),
};
// $ par million de tokens, à renseigner depuis la grille tarifaire en vigueur
declare const PRICING: Record<Tier, { input: number; output: number }>;
declare function estimateUsd(tier: Tier, task: string): number;

type CostLine = { step: string; tier: Tier; inTok: number; outTok: number; usd: number };
const costLine = (step: string, tier: Tier, raw: AIMessage): CostLine => {
  const u = raw.usage_metadata ?? { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
  const p = PRICING[tier];
  const usd = (u.input_tokens * p.input + u.output_tokens * p.output) / 1e6;
  return { step, tier, inTok: u.input_tokens, outTok: u.output_tokens, usd };
};

const State = new StateSchema({
  task: z.string(),
  budgetUsd: z.number(),
  tier: z.enum(TIERS).default("small"),
  answer: z.string().default(""),
  confidence: z.number().default(0),
  // Journal du coût par décision : chaque appel ajoute sa ligne.
  ledger: new ReducedValue(z.array(z.custom<CostLine>()).default(() => []), {
    reducer: (all, added) => all.concat(added),
  }),
});
type S = typeof State.State;
const spent = (s: S) => s.ledger.reduce((t, l) => t + l.usd, 0);

// Routage par complexité : le plus petit modèle fait le tri.
async function triage(s: S) {
  const { raw, parsed } = await models.small
    .withStructuredOutput(z.object({ complexity: z.enum(["simple", "moderate", "hard"]) }), { method: "jsonSchema", includeRaw: true })
    .invoke(`Évalue la complexité de cette tâche : ${s.task}`);
  const tier = ({ simple: "small", moderate: "medium", hard: "large" } as const)[parsed.complexity];
  return { tier, ledger: [costLine("triage", "small", raw as AIMessage)] };
}

async function solve(s: S) {
  const { raw, parsed } = await models[s.tier]
    .withStructuredOutput(z.object({ answer: z.string(), confidence: z.number().min(0).max(1) }), { method: "jsonSchema", includeRaw: true })
    .invoke(s.task);
  return { ...parsed, ledger: [costLine("solve", s.tier, raw as AIMessage)] };
}

const escalate = (s: S) => ({ tier: TIERS[TIERS.indexOf(s.tier) + 1] });

// Sortie anticipée sur confiance, escalade tant que le budget le permet.
function next(s: S) {
  if (s.confidence >= 0.85) return END;
  const up = TIERS[TIERS.indexOf(s.tier) + 1];
  if (!up) return END;
  if (spent(s) + estimateUsd(up, s.task) > s.budgetUsd) return END;
  return "escalate";
}

export const graph = new StateGraph(State)
  .addNode("triage", triage)
  .addNode("solve", solve)
  .addNode("escalate", escalate)
  .addEdge(START, "triage")
  .addEdge("triage", "solve")
  .addConditionalEdges("solve", next, ["escalate", END])
  .addEdge("escalate", "solve")
  .compile();

// Le ledger part dans votre base : c'est lui qui dit ce que le routage fait gagner.
// const out = await graph.invoke({ task, budgetUsd: 0.05 });
// await analytics.insert(out.ledger.map((l) => ({ ...l, taskId })));

Second exemple : un orchestrateur-workers, dont les workers sont ici de simples appels d’outils, qui synthétise un compte client depuis plusieurs systèmes. Le planificateur (planner) ne voit que les outils autorisés pour l’appelant, et son plan est refiltré par le code, parce qu’un modèle peut inventer un outil. Les appels partent en parallèle avec Send, et la synthèse reçoit une règle de priorité explicite quand deux sources se contredisent.

Orchestrateur-workers : des appels en parallèle, sous permissions04-multi-tool-orchestrator.ts
Send × Nplan videresultsSTARTplannerexecute ×NresolveEND
  • Appel au modèle
  • Outils
  • Transition conditionnelle
import { StateGraph, StateSchema, ReducedValue, Send, START, END } from "@langchain/langgraph";
import type { StructuredToolInterface } from "@langchain/core/tools";
import { z } from "zod";
import { llm } from "./models";

type Capability = "crm.read" | "crm.write" | "billing.read" | "email.send";

// Catalogue : chaque outil déclare les capacités qu'il exige.
declare const catalog: { tool: StructuredToolInterface; requires: Capability[] }[];

// Permission scoping : seuls les outils couverts par les scopes de l'appelant.
const allowedFor = (scopes: Capability[]) =>
  catalog.filter((e) => e.requires.every((c) => scopes.includes(c))).map((e) => e.tool);

const Step = z.object({
  tool: z.string(),
  args: z.record(z.string(), z.unknown()),
  reason: z.string(),
});
type Step = z.infer<typeof Step>;
type ToolResult = { tool: string; ok: boolean; output: string };

const State = new StateSchema({
  task: z.string(),
  scopes: z.array(z.custom<Capability>()),
  plan: z.array(Step).default(() => []),
  // Les résultats parallèles s'accumulent : sans reducer, le dernier écraserait les autres.
  results: new ReducedValue(z.array(z.custom<ToolResult>()).default(() => []), {
    reducer: (all, added) => all.concat(added),
  }),
  final: z.string().default(""),
});
type S = typeof State.State;

// Capability-based routing : le planner ne voit que les outils autorisés.
async function planner(s: S) {
  const allowed = allowedFor(s.scopes);
  const catalog = allowed.map((t) => `- ${t.name} : ${t.description}`).join("\n");
  const { steps } = await llm.withStructuredOutput(z.object({ steps: z.array(Step) }), { method: "jsonSchema" }).invoke(
    `Outils disponibles :\n${catalog}\n\nTâche : ${s.task}\n` +
      "Liste les appels indépendants nécessaires (ils seront exécutés en parallèle).",
  );
  // Défense en profondeur : on refiltre, le modèle peut inventer un outil.
  const names = new Set(allowed.map((t) => t.name));
  return { plan: steps.filter((st) => names.has(st.tool)) };
}

// Exécution parallèle : un Send par étape, tous dans le même super-step.
const fanOut = (s: S) =>
  s.plan.length ? s.plan.map((step) => new Send("execute", { step })) : "resolve";

async function execute({ step }: { step: Step }) {
  const t = catalog.find((e) => e.tool.name === step.tool)!.tool;
  try {
    const output = await t.invoke(step.args);
    return { results: [{ tool: step.tool, ok: true, output: String(output) }] };
  } catch (e) {
    return { results: [{ tool: step.tool, ok: false, output: (e as Error).message }] };
  }
}

// Conflict resolution : une règle de priorité explicite entre sources de vérité.
async function resolve(s: S) {
  const res = await llm.invoke(
    `Tâche : ${s.task}\nRésultats d'outils :\n${JSON.stringify(s.results, null, 2)}\n\n` +
      "Si deux sources se contredisent, la facturation fait foi sur le CRM. Signale explicitement chaque écart.",
  );
  return { final: res.text };
}

export const graph = new StateGraph(State)
  .addNode("planner", planner)
  .addNode("execute", execute)
  .addNode("resolve", resolve)
  .addEdge(START, "planner")
  .addConditionalEdges("planner", fanOut, ["execute", "resolve"])
  .addEdge("execute", "resolve")
  .addEdge("resolve", END)
  .compile();

// await graph.invoke({ task: "Résume le compte ACME", scopes: ["crm.read", "billing.read"] });

S’applique le mieux

  • Les étapes sont connues à l’avance : extraire, qualifier, trier, résumer, traduire, classer.
  • Le coût doit rester prévisible et chaque étape testable isolément.

À éviter

  • Une règle métier, une requête SQL ou une expression régulière suffit.
  • Les branches pour cas imprévus se multiplient : il est temps de passer à un agent.

2. Agents : le modèle pilote la boucle

Le modèle raisonne, choisit une action (appeler un outil, lire un fichier), observe le résultat, et recommence jusqu’à estimer qu’il a fini. C’est la boucle ReAct (Reason + Act), à la base de la plupart des agents actuels, de Claude Code à Codex.

L’exemple est un agent qui cherche dans une documentation interne. toolsCondition lit la décision du modèle ; au retour des outils, la politique du système reprend la main : toutes les trois itérations, un modèle rapide vérifie que l’agent progresse ; à la huitième, l’agent doit conclure sans outils. Le recursionLimit de LangGraph reste le filet de dernier recours.

ReAct : une boucle d’agent bornée, avec auto-critique03-react-planning.ts
tool_callscontinuerréponsetoutes les 38 itérationsSTARTagenttoolsreflectdegradeEND
  • Appel au modèle
  • Outils
  • Transition conditionnelle
import { StateGraph, StateSchema, MessagesValue, START, END, GraphRecursionError } from "@langchain/langgraph";
import { ToolNode, toolsCondition } from "@langchain/langgraph/prebuilt";
import { tool } from "@langchain/core/tools";
import { AIMessage, HumanMessage, SystemMessage } from "@langchain/core/messages";
import { z } from "zod";
import { llm, fast } from "./models";

declare function searchInternalDocs(query: string): Promise<string>;

const searchDocs = tool(({ query }) => searchInternalDocs(query), {
  name: "search_docs",
  description: "Recherche dans la doc interne",
  schema: z.object({ query: z.string() }),
});
const tools = [searchDocs];
const model = llm.bindTools(tools);
// L'historique contient des tool_use : l'API Anthropic exige que les outils restent déclarés.
// tool_choice "none" les déclare sans permettre de les appeler.
const critic = fast.bindTools(tools, { tool_choice: "none" });
const finalizer = llm.bindTools(tools, { tool_choice: "none" });

const MAX_ITERATIONS = 8;
const REFLECT_EVERY = 3;

const State = new StateSchema({
  messages: MessagesValue,
  iterations: z.number().default(0),
});
type S = typeof State.State;

// Reason + Act : le modèle raisonne puis appelle (ou non) un outil.
async function agent(s: S) {
  const res = await model.invoke([
    new SystemMessage("Raisonne étape par étape, utilise les outils si nécessaire, puis réponds."),
    ...s.messages,
  ]);
  return { messages: [res], iterations: s.iterations + 1 };
}

// Reflect : auto-critique périodique pour casser les boucles improductives.
async function reflect(s: S) {
  const critique = await critic.invoke([
    ...s.messages,
    new HumanMessage("En 2 phrases : progresses-tu vers la réponse ou tournes-tu en rond ? Que changer ?"),
  ]);
  return { messages: [new HumanMessage(`[auto-critique] ${critique.text}`)] };
}

// Graceful degradation : une réponse partielle plutôt qu'une erreur.
async function degrade(s: S) {
  const res = await finalizer.invoke([
    ...s.messages,
    new HumanMessage("Limite d'itérations atteinte. Donne la meilleure réponse partielle et liste ce qui manque."),
  ]);
  return { messages: [res] };
}

// Observe : après les outils, la politique du système décide de la suite.
const afterTools = (s: S) => {
  if (s.iterations >= MAX_ITERATIONS) return "degrade";
  if (s.iterations % REFLECT_EVERY === 0) return "reflect";
  return "agent";
};

export const graph = new StateGraph(State)
  .addNode("agent", agent)
  .addNode("tools", new ToolNode(tools))
  .addNode("reflect", reflect)
  .addNode("degrade", degrade)
  .addEdge(START, "agent")
  .addConditionalEdges("agent", toolsCondition, ["tools", END])
  .addConditionalEdges("tools", afterTools, ["agent", "reflect", "degrade"])
  .addEdge("reflect", "agent")
  .addEdge("degrade", END)
  .compile();

// Filet de sécurité technique : recursionLimit, en super-steps.
export async function run(question: string) {
  try {
    return await graph.invoke({ messages: [new HumanMessage(question)] }, { recursionLimit: 40 });
  } catch (e) {
    if (e instanceof GraphRecursionError) return { messages: [new AIMessage("Je n'ai pas pu conclure.")] };
    throw e;
  }
}

Cette liberté se paie : le nombre d’étapes, donc le coût et la latence, varie d’une exécution à l’autre, et une erreur au troisième tour contamine la suite. D’où les deux limites de l’exemple, auxquelles s’ajoutent en production des outils aux permissions minimales, des traces et un jeu de cas d’évaluation. Avec createAgent, ces garde-fous deviennent des middlewares, comme modelCallLimitMiddleware.

La boucle a plusieurs variantes :

  • Plan-and-execute : un plan complet, exécuté pas à pas, replanifié si besoin. Moins d’appels au gros modèle, plus de contrôle sur les tâches longues.
  • ReWOO : le plan référence d’avance les résultats d’outils, exécutés sans repasser par le modèle, qui ne revient que pour rédiger la réponse. Beaucoup moins de tokens, moins de réactivité à l’imprévu.
  • Reflexion : après un échec, l’agent analyse sa trajectoire et garde la leçon en mémoire pour la tentative suivante. Le nœud reflect de l’exemple en reprend l’idée, en cours d’exécution.
  • Recherche arborescente (Tree of Thoughts, LATS) : plusieurs branches explorées, évaluées, abandonnées. Coûteuse, réservée aux problèmes où l’exploration paie.
  • CodeAct : l’agent écrit et exécute du code dans un bac à sable au lieu d’appeler des outils un par un ; une boucle ou un filtre tient alors en une seule action.

S’applique le mieux

  • Le nombre d’étapes est inconnu et les outils variés : enquêter sur un bug, croiser des sources dispersées.
  • Une tâche longue où le coût compte : en plan-and-execute.

À éviter

  • Vous pouvez dessiner le schéma de la tâche : écrivez le workflow.
  • Le coût et la latence doivent être garantis à l’avance.

3. Multi-agents : plusieurs agents qui coopèrent

Le travail est réparti entre agents spécialisés, chacun avec son contexte, ses outils et sa consigne. L’intérêt n’est pas de faire dialoguer des « experts » : c’est d’isoler les contextes, pour un vrai parallélisme et un coordinateur qui ne lit que des synthèses. Plusieurs topologies existent :

  • Superviseur : un agent délègue à des sous-agents (appelés comme des outils, ou choisis à chaque tour comme dans l’exemple) et agrège leurs résultats. La plus courante et la plus contrôlable.
  • Passage de main (handoffs) : les agents se transmettent la conversation, de l’accueil vers le spécialiste, sans coordinateur central.
  • Pipeline d’agents : recherche, rédaction, relecture en chaîne ; un workflow dont les étapes sont des agents.
  • Débat : des agents argumentent, un juge tranche. Utile pour vérifier, de préférence avec des modèles différents.
  • Réseau et tableau partagé : chacun sollicite les autres ou écrit dans un état commun. Flexible, mais difficile à déboguer et à borner : à réserver à l’exploration.

L’exemple prépare un rendez-vous client. Un superviseur choisit à chaque tour qui intervient : un enquêteur (recherche web), un analyste (CRM, facturation) ou, en dernier, un rédacteur. Les deux premiers sont des agents ReAct qui démarrent avec un contexte vierge, interviennent ici l’un après l’autre et ne renvoient qu’une synthèse ; le rédacteur est un simple appel au modèle. Le nombre de tours est borné par le code, qui arrête aussi la boucle dès que la fiche est rédigée. LangChain propose aussi une forme plus courte, où chaque spécialiste devient un outil d’un agent principal créé avec createAgent ; le graphe ci-dessous rend explicite ce que cette forme cache.

Multi-agents : un superviseur et ses spécialistes06-supervisor.ts
consignesynthèsefiche rédigéeSTARTsupervisorresearcheranalystwriterEND
  • Appel au modèle
  • Transition conditionnelle
import { StateGraph, StateSchema, ReducedValue, Command, GraphRecursionError, START, END } from "@langchain/langgraph";
import { createAgent, type ReactAgent } from "langchain";
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { llm, fast } from "./models";

// Ports : recherche web, CRM et facturation.
declare function webSearch(query: string): Promise<string>;
declare function crmAccount(company: string): Promise<string>;
declare function listInvoices(company: string): Promise<string>;

const webSearchTool = tool(({ query }) => webSearch(query), {
  name: "web_search",
  description: "Recherche web",
  schema: z.object({ query: z.string() }),
});
const crmTool = tool(({ company }) => crmAccount(company), {
  name: "crm_account",
  description: "Fiche du compte dans le CRM",
  schema: z.object({ company: z.string() }),
});
const invoicesTool = tool(({ company }) => listInvoices(company), {
  name: "list_invoices",
  description: "Factures et règlements du compte",
  schema: z.object({ company: z.string() }),
});

// Chaque spécialiste est un agent ReAct complet (createAgent) : son prompt, ses outils, son contexte.
const researcherAgent = createAgent({
  model: llm,
  tools: [webSearchTool],
  systemPrompt: "Tu enquêtes sur une entreprise : actualités, marchés publics, recrutements. Rends une synthèse sourcée.",
});
const analystAgent = createAgent({
  model: llm,
  tools: [crmTool, invoicesTool],
  systemPrompt: "Tu analyses la relation commerciale : contrats, factures, impayés. Rends une synthèse chiffrée.",
});

type Report = { agent: string; summary: string };
const MAX_ROUNDS = 6;

const State = new StateSchema({
  brief: z.string(),
  task: z.string().default(""),
  rounds: z.number().default(0),
  // Chaque spécialiste ajoute son compte rendu : le superviseur ne voit que ces synthèses.
  reports: new ReducedValue(z.array(z.custom<Report>()).default(() => []), {
    reducer: (all, added) => all.concat(added),
  }),
  final: z.string().default(""),
});
type S = typeof State.State;

const Route = z.object({
  next: z.enum(["researcher", "analyst", "writer", "FINISH"]),
  task: z.string().describe("Consigne courte pour l'agent choisi"),
});

// Le superviseur choisit qui intervient et avec quelle consigne ; le code borne la boucle.
async function supervisor(s: S) {
  if (s.final || s.rounds >= MAX_ROUNDS) return new Command({ goto: END });
  const done = s.reports.map((r) => `## ${r.agent}\n${r.summary}`).join("\n\n") || "(rien pour l'instant)";
  const route = await fast.withStructuredOutput(Route, { method: "jsonSchema" }).invoke(
    `Tu coordonnes la préparation d'un rendez-vous client.\nDemande : ${s.brief}\n\nDéjà fait :\n${done}\n\n` +
      "Qui doit intervenir maintenant, et avec quelle consigne ? Le rédacteur passe en dernier.",
  );
  if (route.next === "FINISH") return new Command({ goto: END });
  return new Command({ goto: route.next, update: { task: route.task, rounds: s.rounds + 1 } });
}

// Chaque spécialiste démarre avec un contexte vierge : la consigne du superviseur, rien d'autre.
// Un spécialiste qui boucle rend un compte rendu partiel au lieu de faire échouer tout le graphe.
const delegate = (name: string, agent: ReactAgent) => async (s: S) => {
  try {
    const out = await agent.invoke({ messages: [{ role: "user", content: s.task }] }, { recursionLimit: 20 });
    return { reports: [{ agent: name, summary: out.messages.at(-1)?.text ?? "" }] };
  } catch (e) {
    if (!(e instanceof GraphRecursionError)) throw e;
    return { reports: [{ agent: name, summary: "Enquête interrompue : limite d'étapes atteinte." }] };
  }
};

const researcher = delegate("researcher", researcherAgent);
const analyst = delegate("analyst", analystAgent);

// Le rédacteur n'a pas besoin d'outils : un simple appel au modèle suffit.
async function writer(s: S) {
  const notes = s.reports.map((r) => `## ${r.agent}\n${r.summary}`).join("\n\n");
  const res = await llm.invoke(`Rédige une fiche de préparation d'une page à partir de ces notes.\n\n${notes}`);
  return { final: res.text };
}

export const graph = new StateGraph(State)
  .addNode("supervisor", supervisor, { ends: ["researcher", "analyst", "writer", END] })
  .addNode("researcher", researcher)
  .addNode("analyst", analyst)
  .addNode("writer", writer)
  .addEdge(START, "supervisor")
  .addEdge("researcher", "supervisor")
  .addEdge("analyst", "supervisor")
  .addEdge("writer", "supervisor")
  .compile();

Le gain peut être net : dans le retour d’expérience d’Anthropic(nouvel onglet), un système multi-agents fait 90 % mieux qu’un agent seul sur son évaluation interne de recherche. Mais il consomme environ quinze fois plus de tokens qu’une conversation (quatre fois pour un agent seul) : le gain vient en bonne partie de ces tokens, et la coordination ajoute ses propres erreurs.

S’applique le mieux

  • Une recherche large qui se découpe en pistes indépendantes, ou un contexte qui sature pour un seul agent.
  • Plusieurs domaines d’expertise qui se gênent dans un même contexte.

À éviter

  • Des sous-tâches couplées, comme écrire du code cohérent sur plusieurs fichiers.
  • Un problème de contexte qu’un seul agent mieux outillé réglerait.

4. Les briques transverses

Ce ne sont pas des architectures : elles s’ajoutent à n’importe laquelle des trois familles.

Le RAG : donner au modèle les bonnes pages

Le RAG (retrieval-augmented generation) cherche dans vos documents les passages utiles et les place dans le contexte avant de répondre : découper, indexer (recherche vectorielle et plein texte), retrouver, reclasser, répondre en citant ses sources. Confié à un agent qui décide quand et quoi chercher, il devient un RAG agentique, plus souple et plus coûteux.

L’exemple cite ses passages, puis le code vérifie que chaque citation renvoie à un passage récupéré : une seule citation introuvable, et la confiance tombe à zéro. Sous 0,6, une recherche web est tentée une fois ; si la réponse reste fragile, elle est signalée.

RAG : répondre en citant des sources vérifiées02-rag-citations.ts
confiance ≥ 0,6trop faibledéjà cherchéSTARTretrievegeneratewebSearchflagEND
  • Appel au modèle
  • Code
  • Outils
  • Transition conditionnelle
import { StateGraph, StateSchema, START, END } from "@langchain/langgraph";
import type { DocumentInterface } from "@langchain/core/documents";
import { z } from "zod";
import { llm } from "./models";

// Ports : vector store (pgvector, Qdrant…) et recherche web (Tavily, Exa…).
interface Retriever { search(q: string, k: number): Promise<DocumentInterface[]> }
interface WebSearch { search(q: string): Promise<DocumentInterface[]> }
declare const retriever: Retriever;
declare const web: WebSearch;

const Answer = z.object({
  text: z.string(),
  citations: z.array(z.string()).describe("ids des passages utilisés, ex. doc-12#3"),
  confidence: z.number().min(0).max(1),
});

const State = new StateSchema({
  question: z.string(),
  docs: z.array(z.custom<DocumentInterface>()).default(() => []),
  answer: Answer.extend({ flagged: z.boolean().optional() }).optional(),
  usedWeb: z.boolean().default(false),
});
type S = typeof State.State;

const retrieve = async (s: S) => ({ docs: await retriever.search(s.question, 6) });
const webSearch = async (s: S) => ({ docs: await web.search(s.question), usedWeb: true });

async function generate(s: S) {
  const context = s.docs.map((d) => `[${d.metadata.id}] ${d.pageContent}`).join("\n\n");
  const res = await llm.withStructuredOutput(Answer, { method: "jsonSchema" }).invoke([
    ["system",
      "Réponds UNIQUEMENT à partir du contexte. Chaque affirmation cite un id entre crochets. " +
      "Si le contexte ne suffit pas, dis-le et mets confidence < 0.5."],
    ["user", `Contexte :\n${context}\n\nQuestion : ${s.question}`],
  ]);

  // Grounding déterministe : on ne croit pas le modèle sur parole.
  const known = new Set(s.docs.map((d) => String(d.metadata.id)));
  const grounded = res.citations.length > 0 && res.citations.every((c) => known.has(c));
  return { answer: { ...res, confidence: grounded ? res.confidence : 0 } };
}

const flagLowConfidence = (s: S) => ({ answer: { ...s.answer!, flagged: true } });

function route(s: S) {
  if (s.answer!.confidence >= 0.6) return END;
  return s.usedWeb ? "flag" : "webSearch";
}

export const graph = new StateGraph(State)
  .addNode("retrieve", retrieve)
  .addNode("generate", generate)
  .addNode("webSearch", webSearch)
  .addNode("flag", flagLowConfidence)
  .addEdge(START, "retrieve")
  .addEdge("retrieve", "generate")
  .addConditionalEdges("generate", route, ["webSearch", "flag", END])
  .addEdge("webSearch", "generate")
  .addEdge("flag", END)
  .compile();

S’applique le mieux

  • Un corpus de textes volumineux et vivant : procédures, réglementation, documentation.
  • La réponse existe dans des documents, mais personne ne sait où.

À éviter

  • Des données structurées : une requête SQL exposée comme outil sera exacte.
  • Un corpus qui tient dans le contexte : donnez-le en entier, avec un cache de prompt.

La validation humaine

Le système s’arrête avant une action à effet de bord, présente ce qu’il s’apprête à faire et attend : approuver, corriger, rejeter. Dans LangGraph, interrupt() met le graphe en pause, le checkpointer conserve son état, et l’exécution reprend plus tard, quand la personne a répondu.

L’exemple traite des remboursements. Le code décide quand solliciter un humain : confiance sous 0,8 ou remboursement au-dessus de 200 €. Dans ces deux cas, le gestionnaire valide, corrige ou rejette ; sinon, la politique approuve seule. S’y ajoutent une clé d’idempotence et une piste d’audit, et chaque correction humaine peut servir d’exemple pour évaluer le système.

Validation humaine : un remboursement relu avant exécution05-human-in-the-loop.ts
incertain ou > 200 €sûrapprouver, modifierrejeterSTARTproposereviewautoApproveexecuteEND
  • Appel au modèle
  • Code
  • Humain
  • Transition conditionnelle
import { StateGraph, StateSchema, ReducedValue, Command, MemorySaver, START, END, interrupt } from "@langchain/langgraph";
import { z } from "zod";
import { llm } from "./models";

type Ticket = { id: string; customer: string; body: string; orderTotal: number };
interface Refunds { apply(ticketId: string, d: Decision, idempotencyKey: string): Promise<void> }
declare const refunds: Refunds;

const Decision = z.object({
  action: z.enum(["refund", "reject", "escalate"]),
  amount: z.number().nonnegative().optional(),
  rationale: z.string(),
  confidence: z.number().min(0).max(1),
});
type Decision = z.infer<typeof Decision>;

type AuditEntry = { at: string; actor: string; event: string; data?: unknown };
const audit = (actor: string, event: string, data?: unknown): AuditEntry => ({
  at: new Date().toISOString(), actor, event, data,
});

type HumanReview = {
  decision: "approve" | "edit" | "reject";
  reviewer: string;
  edited?: Decision;
  comment?: string;
};

const State = new StateSchema({
  ticket: z.custom<Ticket>(),
  proposal: Decision.optional(),
  approved: Decision.nullable().default(null),
  // Piste d'audit : chaque événement s'ajoute, rien ne s'efface.
  audit: new ReducedValue(z.array(z.custom<AuditEntry>()).default(() => []), {
    reducer: (all, added) => all.concat(added),
  }),
});
type S = typeof State.State;

async function propose(s: S) {
  const proposal = await llm.withStructuredOutput(Decision, { method: "jsonSchema" }).invoke(
    `Ticket SAV (commande de ${s.ticket.orderTotal} €) :\n${s.ticket.body}\nPropose une action et ta confiance.`,
  );
  return { proposal, audit: [audit("agent", "proposed", proposal)] };
}

// Seul un petit remboursement sûr passe seul : tout le reste (confiance basse, gros montant,
// montant absent, rejet ou escalade) va à un humain.
const needsHuman = (s: S) => {
  const p = s.proposal!;
  return p.action === "refund" && p.amount !== undefined && p.amount <= 200 && p.confidence >= 0.8
    ? "autoApprove"
    : "review";
};

const autoApprove = (s: S) => ({ approved: s.proposal!, audit: [audit("policy", "auto_approved")] });

function review(s: S) {
  // À la reprise, le nœud est rejoué depuis le début : aucun effet de bord avant interrupt().
  const human = interrupt<{ ticketId: string; proposal: Decision }, HumanReview>({
    ticketId: s.ticket.id,
    proposal: s.proposal!,
  });
  const entry = audit(human.reviewer, `human_${human.decision}`, { comment: human.comment, edited: human.edited });

  if (human.decision === "reject") {
    return new Command({ goto: END, update: { approved: null, audit: [entry] } });
  }
  const approved = human.decision === "edit" && human.edited ? human.edited : s.proposal!;
  return new Command({ goto: "execute", update: { approved, audit: [entry] } });
}

async function execute(s: S) {
  await refunds.apply(s.ticket.id, s.approved!, `refund:${s.ticket.id}`);
  return { audit: [audit("system", "executed", s.approved)] };
}

export const graph = new StateGraph(State)
  .addNode("propose", propose)
  .addNode("autoApprove", autoApprove)
  .addNode("review", review, { ends: ["execute", END] })
  .addNode("execute", execute)
  .addEdge(START, "propose")
  .addConditionalEdges("propose", needsHuman, ["review", "autoApprove"])
  .addEdge("autoApprove", "execute")
  .addEdge("execute", END)
  .compile({ checkpointer: new MemorySaver() }); // en production : PostgresSaver

// Un fil d'exécution par ticket : c'est lui qui permet de reprendre plus tard.
const thread = (ticketId: string) => ({ configurable: { thread_id: `ticket:${ticketId}` } });

// En cas de pause, le résultat contient __interrupt__ : la proposition à montrer au gestionnaire.
export const handleTicket = (ticket: Ticket) => graph.invoke({ ticket }, thread(ticket.id));

// Plus tard, depuis le back-office (une autre requête HTTP) :
export const resume = (ticketId: string, review: HumanReview) =>
  graph.invoke(new Command({ resume: review }), thread(ticketId));

S’applique le mieux

  • Les actions irréversibles, externes ou réglementées : écrire à un client, rembourser, supprimer, publier.
  • Le début d’un projet, avant que les données prouvent la fiabilité du système.

À éviter

  • Des centaines de validations par jour : elles finissent approuvées sans être lues.
  • Des actions réversibles et sans enjeu.

Les autres briques

  • Mémoire : le contexte de la conversation, à compacter quand il grossit, et ce qui doit survivre d’une session à l’autre, souvent dans un index vectoriel. RGPD oblige, les données personnelles doivent pouvoir s’effacer.
  • Exécution durable : un point de sauvegarde après chaque étape pour reprendre sans tout recommencer (checkpointer de LangGraph, Temporal, Inngest). Dans LangGraph, c’est aussi ce qui rend interrupt() possible.
  • Garde-fous : entrées et sorties validées, schémas typés, budgets de tokens et d’itérations, dans le code. Le format d’une sortie structurée peut être garanti par l’API (sorties structurées natives, mode strict) ; les règles métier, c’est à votre code de les vérifier.
  • Outils bornés : pagination, filtres et troncature par défaut, sinon un seul appel sature le contexte. Le protocole MCP (Model Context Protocol) plutôt qu’un connecteur écrit à la main pour chaque agent.
  • Observabilité : traces, coûts, latences, alertes sur les boucles, pour savoir pourquoi un agent a pris tel chemin.
  • Le bon modèle à chaque étape : un modèle capable pour raisonner, un modèle rapide pour trier ou extraire. Prompts, outils et modèles restent des briques remplaçables.

Combiner et faire évoluer

Les architectures se combinent : un tri qui envoie les cas simples à un agent léger et les cas complexes à une équipe d’agents, un superviseur dont les agents travaillent en parallèle, un agent unique qui ne passe la main à un système plus lourd que sur les cas limites. En pratique, beaucoup de systèmes en production sont des workflows avec un ou deux nœuds agentiques, placés là où le chemin ne se prévoit pas.

La trajectoire saine ajoute une brique à la fois, chacune justifiée par une mesure :

  1. un workflow ou un agent unique, pour démontrer l’intérêt ;
  2. un routage, quand les demandes se distinguent nettement ;
  3. des spécialistes, quand un domaine le mérite ;
  4. des évaluateurs, quand le volume justifie un contrôle qualité automatique.

Choisir son architecture

  1. Quel niveau de contrôle ? S’il faut justifier chaque décision devant un auditeur, restez sur un workflow ou un agent unique, avec une validation humaine aux points sensibles.
  2. Le chemin est-il prévisible ? Oui : un workflow. Non : un agent, en plan-and-execute si la tâche est longue. Des pistes indépendantes : un superviseur. Un parcours conversationnel par domaine : des agents qui se passent la main.
  3. Quelles contraintes ? Budget serré ou délai court : un workflow ou un agent unique, à faire évoluer ensuite.
SituationArchitectureQui décide du cheminCoût
Étapes connues à l’avance(trier des tickets de support, extraire les données d’une facture)Workflow : chaînage, routageVotre codePrévisible
Qualité mesurable et améliorable(traduire un contrat, générer du code soumis à des règles de sécurité)Workflow : évaluateur-optimiseurVotre code, en boucle bornéePrévisible, plusieurs passes
Nombre d’étapes inconnu, outils variés(diagnostiquer un incident en production, répondre à une question sur une doc interne)Agent : boucle ReActLe modèleVariable
Tâche longue, coût et contrôle à maîtriser(migrer un module de code, monter un dossier de réponse à un appel d’offres)Agent : plan-and-executeLe modèle, puis le planPlus prévisible qu’en ReAct
Recherche large parallélisable, contexte saturé(veille concurrentielle, préparer un rendez-vous client à partir de plusieurs sources)Multi-agents : superviseurUn modèle, qui délègueÉlevé
Parcours conversationnel par domaine(un assistant client qui oriente vers la facturation, la livraison ou le support technique)Multi-agents : passage de mainLes agents, tour à tourVariable

À retenir

  • Dans un workflow, le code décide du chemin ; dans un agent, c’est le modèle.
  • On n’ajoute de l’autonomie que lorsqu’un besoin mesuré l’impose.
  • RAG, validation humaine, mémoire et garde-fous s’ajoutent à toutes les architectures.
  • Les garde-fous s’écrivent dans le code, pas dans le prompt.

Commencez simple, mesurez, et laissez les vrais problèmes vous dire quand aller plus loin.

Un projet d’agent IA à voir ensemble ?

Quelques lignes sur votre contexte suffisent : je vous réponds sous 24 h pour caler un premier échange de 30 minutes.

Écrire un message

Questions fréquentes

Quelle est la différence entre un workflow et un agent IA ?

Dans un workflow, c’est le code qui décide du chemin : les étapes sont écrites à l’avance et le modèle intervient à certaines d’entre elles. Dans un agent, c’est le modèle qui décide : il choisit l’outil suivant, juge s’il a fini et recommence s’il le faut. Le workflow est plus prévisible et moins cher, l’agent plus flexible.

Quand faut-il un agent IA plutôt qu’un workflow ?

Quand le chemin n’est pas connu à l’avance et dépend de ce qu’on découvre en route : enquêter sur un bug, croiser des informations dispersées, répondre à une question qui demande un nombre variable de recherches. Si l’on peut dessiner le schéma de la tâche, un workflow suffit et coûtera moins cher.

Quand utiliser un RAG, et quand s’en passer ?

Un RAG convient à un corpus de textes volumineux et vivant : base de connaissances, procédures, documentation. Il est inutile quand la réponse se trouve dans une base structurée, où une requête SQL exposée comme outil sera exacte, ou quand le corpus tient dans la fenêtre de contexte du modèle, où il suffit de le lui donner en entier.

Comment limiter les hallucinations d’un RAG ?

En vérifiant dans le code que chaque citation renvoie à un passage réellement récupéré, plutôt qu’en se fiant à la confiance déclarée par le modèle : ce contrôle écarte les sources inventées, même s’il ne prouve pas que le passage dit ce qu’on lui prête. Une réponse sans source vérifiable est signalée ou refusée. La qualité dépend surtout de la recherche : un RAG qui répond mal est le plus souvent un RAG qui cherche mal.

Comment empêcher un agent ReAct de boucler à l’infini ?

Avec deux limites. Une limite métier, écrite dans le graphe, qui force l’agent à rendre sa meilleure réponse partielle après un nombre d’itérations donné, outils interdits. Et une limite technique, comme le recursionLimit de LangGraph, en dernier recours. Une auto-critique périodique aide aussi à repérer l’agent qui tourne en rond.

Un système multi-agents coûte-t-il plus cher ?

Oui. Selon Anthropic, un agent consomme environ quatre fois plus de tokens qu’une conversation, et un système multi-agents environ quinze fois plus. Le multi-agents se justifie quand la tâche se découpe en parties indépendantes ; quand les sous-tâches sont couplées, un seul agent fait mieux.

Où placer une validation humaine (human-in-the-loop) ?

Devant les actions irréversibles, visibles à l’extérieur ou réglementées : écrire à un client, rembourser, supprimer, publier. Le code décide quand solliciter un humain, par exemple sous un seuil de confiance ou au-dessus d’un montant. Trop de validations finissent approuvées sans être lues.

Quelle différence entre ReAct et plan-and-execute ?

ReAct décide de l’action suivante à chaque tour, au vu du dernier résultat : souple, mais chaque étape repasse par le modèle. Le plan-and-execute écrit d’abord un plan complet, puis l’exécute étape par étape en ne replanifiant qu’en cas de besoin : moins d’appels au modèle le plus coûteux et plus de contrôle, ce qui convient aux tâches longues.

Faut-il passer au multi-agents quand un agent ne suffit plus ?

Pas forcément. Avant de multiplier les agents, on peut renforcer l’agent existant : de meilleurs outils, des réponses d’outils bornées, des compétences métier chargées à la demande. Le multi-agents se justifie quand la tâche se découpe en pistes indépendantes, ou quand elle mêle plusieurs domaines d’expertise qui se gênent dans un même contexte.

Peut-on combiner plusieurs architectures ?

Oui, et c’est le cas de la plupart des systèmes en production : un tri en tête de workflow qui envoie les cas complexes vers un agent, un orchestrateur dont les workers travaillent en parallèle, un agent unique qui passe la main à un système plus lourd sur les cas limites. On part du plus simple et on ajoute une brique quand une mesure le justifie.

Ces architectures sont-elles propres à LangGraph ?

Non. Workflows, agents, multi-agents et briques comme le RAG ou la validation humaine sont des modèles indépendants du framework. LangGraph les rend explicites (graphe, état partagé, interruption et reprise), mais on les retrouve avec le Vercel AI SDK, Mastra, ou dans du code écrit à la main.