« Fais-moi une synthèse sur l’état de l’art des bases vectorielles en 2026. » Une seule question, mais qui en cache dix : quels acteurs, quelles performances, quels compromis, quelles tendances ? Un agent monolithique s’y noie. La bonne réponse est architecturale : décomposer, investiguer en parallèle, puis synthétiser.

Ce cas construit cet assistant de bout en bout. On y mobilise les patterns du deep-dive orchestration — superviseur, fan-out par Send, human-in-the-loop — et on trace tout avec LangFuse.

L’architecture

Trois rôles, un graphe. Un planificateur (le superviseur) découpe la question. Un essaim de chercheurs (le même agent ReAct, instancié N fois) traite chaque sous-question en parallèle. Un rédacteur assemble les trouvailles en rapport.

Fig.01 · Rôles
délègue ↩ retour superviseur route recherche agent + outils rédaction agent code agent + REPL
Le planificateur découpe, les chercheurs investiguent en parallèle, le rédacteur synthétise. Chaque chercheur est un agent ReAct outillé indépendant.

Le cœur du flux est un map-reduce : on disperse une branche par sous-question (map), chaque chercheur accumule ses trouvailles dans l’état partagé via un reducer, puis le rédacteur agrège (reduce).

Fig.02 · Map-reduce de recherche
Send() disperse worker A worker B worker C
L'API Send instancie un chercheur par sous-question ; le reducer du champ « findings » fusionne leurs résultats avant la synthèse.

L’état partagé

On commence par modéliser ce qui circule. Le champ findings est accumulé en parallèle : il lui faut un reducer.

from typing import Annotated
from typing_extensions import TypedDict
import operator

class Finding(TypedDict):
    question: str
    resume: str
    sources: list[str]

class Etat(TypedDict):
    sujet: str                                   # la question de recherche initiale
    sous_questions: list[str]                    # produites par le planificateur
    findings: Annotated[list[Finding], operator.add]   # accumulées EN PARALLÈLE
    rapport: str                                 # la synthèse finale

Construire l’assistant, étape par étape

Outiller le chercheur

Chaque chercheur a besoin d’un outil de recherche web. On le déclare proprement (la docstring guide le modèle) et on simule l’API ; en production, branchez Tavily, Brave ou votre moteur interne.

from langchain_core.tools import tool

@tool
def recherche_web(requete: str) -> str:
    """Recherche sur le web. À utiliser pour toute question factuelle nécessitant
    des informations à jour ou des sources externes."""
    resultats = moteur.search(requete, max_results=5)
    return "\n\n".join(f"[{r.url}]\n{r.extrait}" for r in resultats)

outils = [recherche_web]

Le planificateur : décomposer la question

Le superviseur ne « discute » pas : il produit une sortie structurée — la liste des sous-questions. with_structured_output garantit un objet exploitable.

from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model

modele = init_chat_model("openai:gpt-4o")

class Plan(BaseModel):
    sous_questions: list[str] = Field(
        description="3 à 6 sous-questions ciblées et complémentaires couvrant le sujet"
    )

def planifier(etat: Etat) -> dict:
    plan = modele.with_structured_output(Plan).invoke(
        f"Décompose cette question de recherche en sous-questions précises, "
        f"non redondantes et couvrant l'ensemble du sujet :\n{etat['sujet']}"
    )
    return {"sous_questions": plan.sous_questions}

Disperser : une branche de recherche par sous-question

C’est le map. Depuis une arête conditionnelle, on renvoie une liste de Send — LangGraph instancie un nœud chercheur par sous-question, tous en parallèle.

from langgraph.types import Send

def disperser(etat: Etat) -> list[Send]:
    return [Send("chercheur", {"question": q}) for q in etat["sous_questions"]]

Le chercheur : un agent ReAct par sous-question

Chaque chercheur est un agent ReAct complet (boucle outil + raisonnement). On le construit avec create_react_agent, on l’invoque sur sa sous-question, et on renvoie une trouvaille structurée — qui s’accumulera via le reducer.

from langgraph.prebuilt import create_react_agent

chercheur_agent = create_react_agent(modele, outils)

def chercheur(payload: dict) -> dict:
    question = payload["question"]
    res = chercheur_agent.invoke({
        "messages": [{"role": "user", "content":
            f"Recherche et synthétise une réponse sourcée à : {question}"}]
    })
    texte = res["messages"][-1].content
    sources = extraire_urls(res["messages"])         # collecte les URLs citées par les outils
    return {"findings": [{"question": question, "resume": texte, "sources": sources}]}

Le rédacteur : synthétiser le rapport

Le reduce. Une fois toutes les branches terminées, findings contient toutes les trouvailles. Le rédacteur les assemble en un rapport structuré et sourcé.

def rediger(etat: Etat) -> dict:
    blocs = "\n\n".join(
        f"### {f['question']}\n{f['resume']}\nSources : {', '.join(f['sources'])}"
        for f in etat["findings"]
    )
    rapport = modele.invoke(
        f"Rédige un rapport de synthèse clair et structuré sur « {etat['sujet']} », "
        f"à partir de ces recherches. Cite les sources.\n\n{blocs}"
    )
    return {"rapport": rapport.content}

Câbler le graphe

On assemble : planifier → (Send) chercheur → rediger. L’arête conditionnelle après planifier réalise le fan-out ; l’arête chercheur → rediger referme le fan-in.

from langgraph.graph import StateGraph, START, END

g = StateGraph(Etat)
g.add_node("planifier", planifier)
g.add_node("chercheur", chercheur)
g.add_node("rediger", rediger)

g.add_edge(START, "planifier")
g.add_conditional_edges("planifier", disperser, ["chercheur"])
g.add_edge("chercheur", "rediger")
g.add_edge("rediger", END)

assistant = g.compile()

Insérer une validation humaine du plan

Lancer dix recherches web coûte du temps et des tokens. Avant de disperser, on montre le plan à un humain. C’est le interrupt() du deep-dive checkpointing, employé ici comme point de contrôle métier.

from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import InMemorySaver

def valider_plan(etat: Etat) -> Command:
    decision = interrupt({
        "plan": etat["sous_questions"],
        "demande": "Valider ou amender ce plan de recherche ?",
    })
    if isinstance(decision, list):                # l'humain a fourni un plan amendé
        return Command(update={"sous_questions": decision})
    return Command()                              # plan accepté tel quel

g.add_node("valider_plan", valider_plan)
g.add_edge("planifier", "valider_plan")
g.add_conditional_edges("valider_plan", disperser, ["chercheur"])
assistant = g.compile(checkpointer=InMemorySaver())   # le HITL exige un checkpointer

À l’exécution, le graphe se gèle sur l’interruption ; on présente le plan, on reçoit la validation, puis on reprend avec Command(resume=...). La recherche coûteuse ne démarre qu’après le feu vert humain.

Instrumenter et lire la trace

On branche LangFuse — un simple callback, sans toucher la logique — et on regroupe toute l’exécution sous une session.

from langfuse.langchain import CallbackHandler

handler = CallbackHandler()

resultat = assistant.invoke(
    {"sujet": "État de l'art des bases vectorielles en 2026"},
    config={
        "callbacks": [handler],
        "configurable": {"thread_id": "recherche-001"},
        "metadata": {"langfuse_session_id": "recherche-001", "langfuse_tags": ["multi-agent"]},
        "recursion_limit": 40,
    },
)
print(resultat["rapport"])

La trace révèle l’architecture en un coup d’œil : la planification, puis les chercheurs imbriqués et parallèles, puis la rédaction — chacun avec son coût et sa latence.

Fig.03 · Trace du système
TRACE · agent.invoke() 2.4 s · $0.0031 GENERATION · décision gpt-4o-mini · 412 tok SPAN · chercher_doc() 84 ms GENERATION · réponse finale 356 tok
Le planificateur, les chercheurs parallèles (chacun une boucle ReAct imbriquée) et le rédacteur apparaissent dans l'arbre. On repère immédiatement quel chercheur a coûté ou traîné.

Aller plus loin

Cette base se prête à des extensions naturelles, chacune adossée à un deep-dive :

  • Mémoire — mémoriser les rapports déjà produits dans un Store pour ne pas re-chercher l’identique.
  • Qualité — ajouter une boucle évaluateur-optimiseur qui fait critiquer le rapport et le réviser avant livraison.
  • Coût — router les chercheurs vers un modèle plus petit, ne réservant le grand modèle qu’à la synthèse finale.
  • UXstreamer la progression (« 3/6 sous-questions investiguées… ») via des événements custom.

Bonnes pratiques & pièges

Bonnes pratiques
  • Reducer (operator.add) sur le champ accumulé en parallèle : indispensable au fan-in.
  • Planificateur en sortie structurée (with_structured_output) : un plan exploitable, pas du texte libre.
  • Plafonnez le nombre de sous-questions dans le prompt : chaque branche est un agent complet.
  • Validation humaine du plan AVANT la recherche coûteuse (interrupt + checkpointer).
  • Regroupez l'exécution sous une session LangFuse pour lire le système comme un tout.
  • recursion_limit relevé (les sous-agents consomment des tours) mais borné.
Pièges à éviter
  • Oublier le reducer : les chercheurs parallèles s'écrasent, une seule trouvaille survit.
  • Laisser le planificateur générer 15 sous-questions : coût et latence explosent.
  • Lancer la recherche sans validation : on paie un plan parfois absurde.
  • Un seul gros modèle partout : la facture s'envole alors qu'un mini suffit aux chercheurs.
  • Ne pas tracer : impossible de savoir quel chercheur a halluciné ou ralenti le système.

Ce qu’il faut retenir

  • Une question ouverte se traite par décomposition + parallélisme + synthèse, pas par un agent monolithique.
  • Le planificateur produit un plan structuré ; Send réalise le fan-out, un reducer le fan-in.
  • Chaque chercheur est un agent ReAct complet, encapsulé en un nœud — composition par sous-agents.
  • Le human-in-the-loop valide le plan avant la dépense ; le checkpointer le rend possible.
  • La trace LangFuse rend l’architecture — et son coût parallèle — lisible.

Ce système investigue largement. Le prochain cas resserre la focale sur la fiabilité d’une seule réponse : un RAG qui se relit, se corrige et refuse d’halluciner — le RAG agentique correctif.

#cas-pratique#multi-agent#orchestration#send#langfuse