Frameworks d’agents#

Le chapitre précédent a introduit les agents LLM — des systèmes capables de raisonner, planifier et agir de manière autonome en utilisant des outils. Construire un agent depuis zéro est un exercice pédagogique précieux, mais dès que le système dépasse la complexité d’un prototype, la question de l’outillage se pose : gestion de l’état, routage conditionnel, persistance, observabilité, gestion d’erreurs. C’est précisément le rôle des frameworks d’agents, qui fournissent des abstractions pour orchestrer les interactions entre le LLM, les outils, la mémoire et l’environnement extérieur.

L’écosystème des frameworks d’agents évolue à une vitesse remarquable. En l’espace de deux ans (2023–2025), des dizaines de bibliothèques ont vu le jour, chacune proposant sa vision de ce que devrait être l’interface de programmation d’un agent. Certains frameworks privilégient la simplicité et l’accessibilité, d’autres la flexibilité et le contrôle fin. Comprendre leurs forces, leurs limites et leurs compromis est essentiel pour choisir l’outil adapté à un projet donné — ou pour décider de ne pas en utiliser du tout.

Ce chapitre présente les principaux frameworks (LangChain, LangGraph, CrewAI, AutoGen, Semantic Kernel, Haystack), détaille les concepts fondamentaux de LangChain et LangGraph, identifie les patterns architecturaux récurrents dans la conception d’agents, propose une comparaison structurée des frameworks, et termine par une réflexion sur le moment opportun pour adopter — ou éviter — un framework.

Ecosystème des frameworks d’agents#

Le paysage des frameworks d’agents peut être organisé selon deux axes : le niveau d’abstraction (haut niveau / bas niveau) et le paradigme d’orchestration (chaînes linéaires, graphes d’état, multi-agents). Voici les principaux acteurs.

LangChain (Harrison Chase, 2022) est le framework pionnier. Il a popularisé les concepts de chain, prompt template, tool, memory et retriever, offrant une interface unifiée pour composer des applications LLM. Son succès initial repose sur sa large couverture d’intégrations (OpenAI, Anthropic, Hugging Face, bases vectorielles, etc.) et sur sa communauté très active.

LangGraph (LangChain Inc., 2024) est une extension de LangChain qui modélise les workflows d’agents sous forme de graphes d’état. Contrairement aux chaînes linéaires, LangGraph permet des boucles, du routage conditionnel et de la persistance d’état, ce qui le rend adapté aux agents complexes nécessitant un contrôle humain (human-in-the-loop).

CrewAI (2024) se concentre sur les systèmes multi-agents avec des rôles définis. Chaque agent possède un rôle, un objectif et un ensemble d’outils. Les agents collaborent au sein d’un crew selon un processus séquentiel ou hiérarchique. CrewAI privilégie la simplicité d’utilisation et les métaphores organisationnelles.

AutoGen (Microsoft, 2023) propose un paradigme de conversation multi-agents. Les agents communiquent par messages dans un protocole conversationnel. AutoGen se distingue par sa flexibilité dans la définition des interactions et par son support natif de l’exécution de code.

Semantic Kernel (Microsoft, 2023) adopte une approche orientée entreprise, intégrant les LLM dans des applications existantes via des plugins et des planificateurs. Il est conçu pour s’intégrer dans l’écosystème Microsoft (Azure, .NET, Python).

Haystack (deepset, 2020) est initialement un framework de recherche documentaire (NLP) qui a évolué vers l’orchestration d’agents. Sa version 2.x introduit un système de pipelines modulaires et composables, avec un accent sur la robustesse en production.

Remarque 74

L’écosystème des frameworks d’agents est en mutation rapide. Les versions, les API et les paradigmes changent fréquemment. Les exemples de ce chapitre illustrent les concepts fondamentaux et les patterns architecturaux qui, eux, restent stables. Le lecteur est invité à consulter la documentation officielle de chaque framework pour les détails d’implémentation les plus récents.

LangChain : chaines et abstraction#

LangChain est le framework le plus connu et le plus utilisé pour construire des applications autour des LLM. Il repose sur plusieurs abstractions centrales.

Définition 68 (Chaîne (Chain))

Une chaîne (chain) est une séquence composable d’opérations qui transforme une entrée en une sortie. Chaque maillon de la chaîne peut être un appel à un LLM, un traitement de texte, une recherche dans une base de données, un appel à un outil, ou toute autre fonction. Les chaînes se composent de manière déclarative : la sortie d’un maillon est transmise comme entrée au suivant.

Les abstractions principales de LangChain sont :

  • PromptTemplate : un gabarit de prompt paramétrable, avec des variables à remplir dynamiquement.

  • LLM / ChatModel : une interface unifiée vers les modèles de langage (OpenAI, Anthropic, Ollama, etc.).

  • OutputParser : transforme la sortie brute du LLM en un format structuré (JSON, liste, objet Pydantic).

  • Tool : encapsule une fonction externe (recherche web, calculatrice, API) que l’agent peut appeler.

  • Memory : gère l’historique de la conversation et le contexte persistent entre les appels.

  • Retriever : interface vers les systèmes de recherche documentaire (bases vectorielles, BM25, etc.).

LCEL : LangChain Expression Language#

LCEL est le langage déclaratif de LangChain pour composer des chaînes. Il utilise l’opérateur pipe (|) pour connecter les composants.

Exemple 50 (Pipeline RAG avec LangChain (LCEL))

L’exemple suivant montre un pipeline RAG typique construit avec LCEL. Le code est présenté à titre illustratif et n’est pas exécutable dans ce notebook (LangChain n’est pas dans les dépendances).

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings

# Composants
embeddings = OpenAIEmbeddings()
vectorstore = FAISS.load_local("mon_index", embeddings)
retriever = vectorstore.as_retriever(search_kwargs={"k": 4})
llm = ChatOpenAI(model="gpt-4o", temperature=0)

prompt = ChatPromptTemplate.from_template("""
Réponds à la question en utilisant uniquement le contexte suivant.

Contexte : {context}

Question : {question}

Réponse :
""")

# Chaîne LCEL avec l'opérateur pipe
chain = (
    {"context": retriever, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)

# Exécution
reponse = chain.invoke("Qu'est-ce que l'attention multi-têtes ?")
print(reponse)

Dans cette chaîne, le retriever récupère les documents pertinents, le prompt formate la requête, le llm génère la réponse, et le StrOutputParser extrait le texte brut. L’opérateur | connecte chaque étape de manière déclarative.

Forces et critiques de LangChain#

LangChain a eu un impact considérable sur l’écosystème des LLM en démocratisant l’accès aux patterns courants (RAG, agents, chaînes). Ses forces incluent une couverture d’intégrations très large, une communauté active, et une documentation abondante.

Cependant, LangChain fait l’objet de critiques récurrentes. Le niveau d’abstraction peut être trop élevé : les développeurs se retrouvent à déboguer des comportements cachés derrière plusieurs couches d’indirection. L’API a connu de nombreux changements cassants (breaking changes), rendant la maintenance difficile. Enfin, pour des cas d’usage simples, LangChain introduit une complexité disproportionnée par rapport à un appel direct à l’API du LLM.

Remarque 75

Toute abstraction a un coût. Un framework facilite les cas courants au prix d’une moindre transparence sur les cas limites. Avant d’adopter LangChain (ou tout autre framework), il est sage de se poser la question : « Est-ce que je pourrais résoudre ce problème avec un appel API direct et quelques fonctions Python ? » Si la réponse est oui, le framework est probablement superflu.

LangGraph : graphes d’état#

LangGraph représente un changement de paradigme par rapport aux chaînes linéaires de LangChain. Au lieu de composer des opérations en séquence, LangGraph modélise le workflow comme un graphe d’état où les noeuds sont des fonctions et les arêtes sont des transitions.

Définition 69 (Graphe d’état (State Graph))

Un graphe d’état est un graphe orienté \(G = (N, E, s_0, S)\) où :

  • \(N\) est l’ensemble des noeuds, chacun associé à une fonction \(f_i : \mathcal{S} \to \mathcal{S}\) qui transforme l’état ;

  • \(E \subseteq N \times N\) est l’ensemble des arêtes (transitions entre noeuds) ;

  • \(s_0 \in \mathcal{S}\) est l”état initial ;

  • \(S\) est l”espace d’état, un dictionnaire typé contenant toutes les variables du workflow (messages, résultats intermédiaires, compteurs, etc.).

Les arêtes peuvent être inconditionnelles (toujours empruntées) ou conditionnelles (empruntées selon une fonction de routage \(r : \mathcal{S} \to N\) qui inspecte l’état courant).

Définition 70 (Machine à états)

Une machine à états (state machine) est un modèle de calcul défini par un ensemble fini d’états, un ensemble de transitions entre ces états, un état initial et un ensemble d’états finaux. À chaque instant, la machine se trouve dans un unique état. Une transition est déclenchée par un événement ou une condition, et amène la machine dans un nouvel état. Les graphes d’état de LangGraph sont une forme de machine à états enrichie, où l’état est un dictionnaire mutable et les transitions sont déterminées par des fonctions.

Concepts clés de LangGraph#

  • State : un dictionnaire typé (souvent défini avec TypedDict ou Pydantic) qui contient l’ensemble des variables du workflow. L’état est transmis de noeud en noeud et modifié par chaque fonction.

  • Node : une fonction Python qui reçoit l’état courant, effectue un traitement (appel LLM, appel outil, calcul), et retourne un état modifié.

  • Edge : une transition entre deux noeuds. Les arêtes conditionnelles utilisent une fonction de routage qui inspecte l’état pour décider du prochain noeud.

  • Checkpoint : un point de sauvegarde de l’état, permettant de reprendre l’exécution après une interruption ou de mettre en oeuvre un contrôle humain (human-in-the-loop).

Exemple 51 (Agent ReAct avec LangGraph)

L’exemple suivant montre un agent ReAct (Reason + Act) implémenté avec LangGraph. Le code est présenté à titre illustratif et n’est pas exécutable dans ce notebook.

from langgraph.graph import StateGraph, END
from typing import TypedDict, Annotated
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage

# Définition de l'état
class AgentState(TypedDict):
    messages: list
    next_action: str

# Noeuds
def reasoning_node(state: AgentState) -> AgentState:
    """Le LLM raisonne et décide de la prochaine action."""
    llm = ChatOpenAI(model="gpt-4o")
    response = llm.invoke(state["messages"])
    if response.tool_calls:
        return {"messages": state["messages"] + [response],
                "next_action": "tool"}
    return {"messages": state["messages"] + [response],
            "next_action": "end"}

def tool_node(state: AgentState) -> AgentState:
    """Exécute l'outil demandé par le LLM."""
    last_msg = state["messages"][-1]
    result = execute_tool(last_msg.tool_calls[0])
    return {"messages": state["messages"] + [result],
            "next_action": "reason"}

# Construction du graphe
graph = StateGraph(AgentState)
graph.add_node("reason", reasoning_node)
graph.add_node("tool", tool_node)
graph.set_entry_point("reason")
graph.add_conditional_edges("reason", lambda s: s["next_action"],
                            {"tool": "tool", "end": END})
graph.add_edge("tool", "reason")

agent = graph.compile()
result = agent.invoke({"messages": [HumanMessage("Quelle est la météo à Paris ?")],
                       "next_action": "reason"})

Le graphe alterne entre un noeud de raisonnement et un noeud d’exécution d’outils, formant la boucle ReAct caractéristique. L’arête conditionnelle après le noeud reason inspecte l’état pour décider si l’agent doit appeler un outil ou terminer.

Remarque 76

LangGraph est étroitement lié à l’écosystème LangChain. L’utiliser implique souvent d’adopter les abstractions LangChain (messages, modèles, outils). Cette dépendance peut constituer un verrouillage fournisseur (vendor lock-in) qui rend difficile la migration vers un autre framework. Il est important de bien séparer la logique métier des abstractions du framework pour limiter ce risque.

Patterns architecturaux#

Au-delà des frameworks spécifiques, certains patterns architecturaux reviennent systématiquement dans la conception de systèmes à base d’agents. Les comprendre permet de concevoir des workflows robustes indépendamment du framework choisi.

Définition 71 (Workflow)

Un workflow est une séquence organisée d’étapes de traitement qui transforme une entrée en une sortie. Dans le contexte des agents LLM, un workflow orchestre les appels au modèle, les exécutions d’outils, les vérifications et les décisions de routage. Les workflows peuvent être linéaires (chaînes), arborescents (routeurs), cycliques (boucles d’agents) ou parallèles (map-reduce).

Pattern 1 : Chaîne séquentielle#

La chaîne séquentielle est le pattern le plus simple. Les étapes s’exécutent l’une après l’autre, chaque étape recevant la sortie de la précédente. Ce pattern convient aux tâches déterministes : extraction d’information, puis résumé, puis mise en forme.

Pattern 2 : Routeur (branching)#

Le routeur inspecte l’entrée (ou un résultat intermédiaire) et dirige le flux vers l’une de plusieurs branches. Par exemple, un routeur peut classifier une requête utilisateur (question factuelle, demande de code, conversation libre) et la diriger vers un pipeline spécialisé.

Pattern 3 : Boucle d’agent (loop)#

La boucle d’agent est le coeur du pattern ReAct. L’agent alterne entre raisonnement et action jusqu’à ce qu’une condition de terminaison soit satisfaite (réponse trouvée, nombre maximal d’itérations atteint, ou timeout). Ce pattern introduit des cycles dans le graphe, ce qui le rend plus expressif mais aussi plus difficile à déboguer.

Pattern 4 : Map-reduce#

Le map-reduce parallélise le traitement en distribuant des sous-tâches indépendantes à plusieurs instances du LLM (phase map), puis en agrégeant les résultats (phase reduce). Ce pattern est utile pour traiter de grands corpus documentaires : chaque document est résumé individuellement, puis les résumés sont fusionnés.

Pattern 5 : Superviseur#

Le superviseur est un agent de haut niveau qui coordonne plusieurs agents spécialisés. Il reçoit la requête, décide quel agent doit la traiter, collecte les résultats et les synthétise. Ce pattern est au coeur des systèmes multi-agents (chapitre 14).

Hide code cell source

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns
import networkx as nx

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
np.random.seed(42)

Hide code cell source

fig, axes = plt.subplots(1, 3, figsize=(15, 6))

# --- Pattern 1 : Séquentiel ---
ax = axes[0]
ax.set_xlim(-0.5, 4.5)
ax.set_ylim(-0.5, 4.5)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title("Chaîne séquentielle", fontsize=12, fontweight="bold")

seq_labels = ["Entrée", "LLM\n(extraction)", "LLM\n(résumé)", "LLM\n(format)", "Sortie"]
seq_colors = ["#A8D8EA", "#4C72B0", "#4C72B0", "#4C72B0", "#55A868"]
for i, (label, color) in enumerate(zip(seq_labels, seq_colors)):
    rect = mpatches.FancyBboxPatch((1.2, 4 - i * 1.0 - 0.3), 1.6, 0.6,
                                    boxstyle="round,pad=0.1",
                                    facecolor=color, edgecolor="white",
                                    alpha=0.85, linewidth=1.5)
    ax.add_patch(rect)
    fc = "white" if color != "#A8D8EA" else "black"
    ax.text(2.0, 4 - i * 1.0, label, ha="center", va="center",
            fontsize=8, color=fc, fontweight="bold")
    if i < len(seq_labels) - 1:
        ax.annotate("", xy=(2.0, 4 - (i + 1) * 1.0 + 0.3),
                     xytext=(2.0, 4 - i * 1.0 - 0.3),
                     arrowprops=dict(arrowstyle="->", color="gray", lw=1.5))

# --- Pattern 2 : Routeur ---
ax = axes[1]
ax.set_xlim(-0.5, 5.5)
ax.set_ylim(-0.5, 4.5)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title("Routeur (branching)", fontsize=12, fontweight="bold")

rect_in = mpatches.FancyBboxPatch((1.75, 3.7), 1.5, 0.6,
                                   boxstyle="round,pad=0.1",
                                   facecolor="#A8D8EA", edgecolor="white",
                                   alpha=0.85, linewidth=1.5)
ax.add_patch(rect_in)
ax.text(2.5, 4.0, "Entrée", ha="center", va="center", fontsize=8, fontweight="bold")

rect_router = mpatches.FancyBboxPatch((1.75, 2.5), 1.5, 0.6,
                                       boxstyle="round,pad=0.1",
                                       facecolor="#DD8452", edgecolor="white",
                                       alpha=0.85, linewidth=1.5)
ax.add_patch(rect_router)
ax.text(2.5, 2.8, "Routeur", ha="center", va="center",
        fontsize=8, color="white", fontweight="bold")
ax.annotate("", xy=(2.5, 3.1), xytext=(2.5, 3.7),
            arrowprops=dict(arrowstyle="->", color="gray", lw=1.5))

branch_labels = ["Branche A", "Branche B", "Branche C"]
branch_xs = [0.5, 2.5, 4.5]
for bx, label in zip(branch_xs, branch_labels):
    rect_b = mpatches.FancyBboxPatch((bx - 0.65, 1.0), 1.3, 0.6,
                                      boxstyle="round,pad=0.1",
                                      facecolor="#4C72B0", edgecolor="white",
                                      alpha=0.85, linewidth=1.5)
    ax.add_patch(rect_b)
    ax.text(bx, 1.3, label, ha="center", va="center",
            fontsize=7, color="white", fontweight="bold")
    ax.annotate("", xy=(bx, 1.6), xytext=(2.5, 2.5),
                arrowprops=dict(arrowstyle="->", color="gray", lw=1.2))

rect_out = mpatches.FancyBboxPatch((1.75, 0.0), 1.5, 0.6,
                                    boxstyle="round,pad=0.1",
                                    facecolor="#55A868", edgecolor="white",
                                    alpha=0.85, linewidth=1.5)
ax.add_patch(rect_out)
ax.text(2.5, 0.3, "Sortie", ha="center", va="center",
        fontsize=8, color="white", fontweight="bold")
for bx in branch_xs:
    ax.annotate("", xy=(2.5, 0.6), xytext=(bx, 1.0),
                arrowprops=dict(arrowstyle="->", color="gray", lw=1.2))

# --- Pattern 3 : Boucle d'agent ---
ax = axes[2]
ax.set_xlim(-0.5, 4.5)
ax.set_ylim(-0.5, 4.5)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title("Boucle d'agent (loop)", fontsize=12, fontweight="bold")

rect_in2 = mpatches.FancyBboxPatch((1.2, 3.7), 1.6, 0.6,
                                    boxstyle="round,pad=0.1",
                                    facecolor="#A8D8EA", edgecolor="white",
                                    alpha=0.85, linewidth=1.5)
ax.add_patch(rect_in2)
ax.text(2.0, 4.0, "Entrée", ha="center", va="center", fontsize=8, fontweight="bold")

rect_reason = mpatches.FancyBboxPatch((1.2, 2.5), 1.6, 0.6,
                                       boxstyle="round,pad=0.1",
                                       facecolor="#4C72B0", edgecolor="white",
                                       alpha=0.85, linewidth=1.5)
ax.add_patch(rect_reason)
ax.text(2.0, 2.8, "Raisonner", ha="center", va="center",
        fontsize=8, color="white", fontweight="bold")
ax.annotate("", xy=(2.0, 3.1), xytext=(2.0, 3.7),
            arrowprops=dict(arrowstyle="->", color="gray", lw=1.5))

rect_act = mpatches.FancyBboxPatch((1.2, 1.3), 1.6, 0.6,
                                    boxstyle="round,pad=0.1",
                                    facecolor="#DD8452", edgecolor="white",
                                    alpha=0.85, linewidth=1.5)
ax.add_patch(rect_act)
ax.text(2.0, 1.6, "Agir (outil)", ha="center", va="center",
        fontsize=8, color="white", fontweight="bold")
ax.annotate("", xy=(2.0, 1.9), xytext=(2.0, 2.5),
            arrowprops=dict(arrowstyle="->", color="gray", lw=1.5))

# Boucle
ax.annotate("", xy=(2.8, 2.5), xytext=(2.8, 1.9),
            arrowprops=dict(arrowstyle="->", color="#C44E52", lw=2,
                            connectionstyle="arc3,rad=-0.5"))
ax.text(3.6, 2.2, "boucle", ha="center", va="center",
        fontsize=7, color="#C44E52", style="italic")

rect_out2 = mpatches.FancyBboxPatch((1.2, 0.0), 1.6, 0.6,
                                     boxstyle="round,pad=0.1",
                                     facecolor="#55A868", edgecolor="white",
                                     alpha=0.85, linewidth=1.5)
ax.add_patch(rect_out2)
ax.text(2.0, 0.3, "Sortie", ha="center", va="center",
        fontsize=8, color="white", fontweight="bold")
ax.annotate("", xy=(1.2, 0.6), xytext=(1.2, 1.3),
            arrowprops=dict(arrowstyle="->", color="gray", lw=1.5))
ax.text(0.6, 0.95, "terminé", ha="center", va="center",
        fontsize=7, color="gray", style="italic")

plt.show()
_images/82c811ab874fa53fe202e13b6af87ed1b4488a5361d833e1779f37a9e5c60885.png

Propriété 17 (Composabilite des patterns)

Les patterns architecturaux sont composables : un noeud d’un graphe peut lui-même contenir un sous-graphe complet. Par exemple, chaque branche d’un routeur peut contenir une boucle d’agent, et le superviseur d’un système multi-agents peut utiliser un pattern map-reduce pour distribuer les sous-tâches. Cette composabilité permet de construire des systèmes d’une grande complexité à partir de briques simples et bien comprises.

Un framework minimal en Python pur#

Pour démystifier le fonctionnement interne des frameworks d’agents, construisons un micro-framework en Python pur. Ce framework implémente les concepts fondamentaux — état, noeuds, arêtes, routage conditionnel — sans aucune dépendance externe.

Hide code cell source

from dataclasses import dataclass, field
from typing import Any, Callable


@dataclass
class State:
    """Etat mutable transmis de noeud en noeud."""
    data: dict = field(default_factory=dict)

    def __getitem__(self, key: str) -> Any:
        return self.data[key]

    def __setitem__(self, key: str, value: Any) -> None:
        self.data[key] = value

    def get(self, key: str, default: Any = None) -> Any:
        return self.data.get(key, default)

    def __repr__(self) -> str:
        return f"State({self.data})"


@dataclass
class Node:
    """Un noeud du graphe : une fonction qui transforme l'etat."""
    name: str
    func: Callable[[State], State]

    def run(self, state: State) -> State:
        return self.func(state)


@dataclass
class Edge:
    """Arete entre deux noeuds (inconditionnelle)."""
    source: str
    target: str


class StateGraph:
    """Graphe d'etat minimal : noeuds, aretes, routage conditionnel."""

    END = "__END__"

    def __init__(self):
        self.nodes: dict[str, Node] = {}
        self.edges: list[Edge] = []
        self.conditional_edges: dict[str, Callable[[State], str]] = {}
        self.entry_point: str | None = None

    def add_node(self, name: str, func: Callable[[State], State]) -> None:
        self.nodes[name] = Node(name=name, func=func)

    def add_edge(self, source: str, target: str) -> None:
        self.edges.append(Edge(source=source, target=target))

    def add_conditional_edge(self, source: str,
                              router: Callable[[State], str]) -> None:
        self.conditional_edges[source] = router

    def set_entry_point(self, name: str) -> None:
        self.entry_point = name

    def _get_next(self, current: str, state: State) -> str | None:
        if current in self.conditional_edges:
            return self.conditional_edges[current](state)
        for edge in self.edges:
            if edge.source == current:
                return edge.target
        return None

    def run(self, initial_state: State, max_steps: int = 20) -> State:
        """Execute le graphe depuis le point d'entree."""
        if self.entry_point is None:
            raise ValueError("Aucun point d'entree defini.")

        current = self.entry_point
        state = initial_state
        steps = 0

        while current is not None and current != self.END and steps < max_steps:
            node = self.nodes[current]
            state = node.run(state)
            state["_last_node"] = current
            steps += 1
            current = self._get_next(current, state)

        state["_total_steps"] = steps
        return state

    def get_structure(self) -> dict:
        """Retourne la structure du graphe pour la visualisation."""
        return {
            "nodes": list(self.nodes.keys()),
            "edges": [(e.source, e.target) for e in self.edges],
            "conditional_edges": list(self.conditional_edges.keys()),
            "entry_point": self.entry_point,
        }


print("Framework minimal charge.")
print(f"Classes disponibles : State, Node, Edge, StateGraph")
Framework minimal charge.
Classes disponibles : State, Node, Edge, StateGraph

Exemple 52 (Agent de recherche avec le framework minimal)

Utilisons notre micro-framework pour implémenter un agent simplifié qui simule une boucle de recherche. L’agent reçoit une question, « cherche » une information (ici simulée), évalue si la réponse est satisfaisante, et itère si nécessaire.

# --- Fonctions des noeuds ---
def analyze(state):
    """Analyse la question et formule une requête de recherche."""
    question = state["question"]
    state["search_query"] = f"recherche: {question}"
    state["iteration"] = state.get("iteration", 0) + 1
    return state

def search(state):
    """Simule une recherche (dans un vrai système : appel API)."""
    query = state["search_query"]
    # Simulation : la recherche réussit après 2 tentatives
    if state["iteration"] >= 2:
        state["search_result"] = f"Résultat pertinent pour '{query}'"
        state["found"] = True
    else:
        state["search_result"] = "Résultat non pertinent"
        state["found"] = False
    return state

def evaluate(state):
    """Evalue si le résultat est satisfaisant."""
    if state.get("found", False):
        state["answer"] = f"Reponse finale : {state['search_result']}"
        state["status"] = "done"
    else:
        state["status"] = "retry"
    return state

# --- Construction du graphe ---
graph = StateGraph()
graph.add_node("analyze", analyze)
graph.add_node("search", search)
graph.add_node("evaluate", evaluate)
graph.set_entry_point("analyze")
graph.add_edge("analyze", "search")
graph.add_edge("search", "evaluate")
graph.add_conditional_edge("evaluate",
    lambda s: "analyze" if s["status"] == "retry" else StateGraph.END)

# --- Execution ---
result = graph.run(State(data={"question": "Qu'est-ce que LangGraph ?"}))

Le graphe effectue la boucle analyze -> search -> evaluate -> [retry ou fin] jusqu’à ce que le résultat soit satisfaisant. C’est exactement le pattern de boucle d’agent décrit dans la section précédente, implémenté en quelques dizaines de lignes de Python.

Hide code cell source

# Exécution de l'exemple ci-dessus
def analyze(state):
    question = state["question"]
    state["search_query"] = f"recherche: {question}"
    state["iteration"] = state.get("iteration", 0) + 1
    print(f"  [analyze] Itération {state['iteration']}, requête: '{state['search_query']}'")
    return state

def search(state):
    if state["iteration"] >= 2:
        state["search_result"] = f"Résultat pertinent pour '{state['search_query']}'"
        state["found"] = True
    else:
        state["search_result"] = "Résultat non pertinent"
        state["found"] = False
    print(f"  [search]  Résultat: '{state['search_result']}'")
    return state

def evaluate(state):
    if state.get("found", False):
        state["answer"] = f"Réponse finale : {state['search_result']}"
        state["status"] = "done"
    else:
        state["status"] = "retry"
    print(f"  [evaluate] Statut: {state['status']}")
    return state

graph = StateGraph()
graph.add_node("analyze", analyze)
graph.add_node("search", search)
graph.add_node("evaluate", evaluate)
graph.set_entry_point("analyze")
graph.add_edge("analyze", "search")
graph.add_edge("search", "evaluate")
graph.add_conditional_edge("evaluate",
    lambda s: "analyze" if s["status"] == "retry" else StateGraph.END)

print("Exécution de l'agent de recherche :")
print("-" * 50)
result = graph.run(State(data={"question": "Qu'est-ce que LangGraph ?"}))
print("-" * 50)
print(f"Résultat : {result['answer']}")
print(f"Nombre d'étapes : {result['_total_steps']}")
Exécution de l'agent de recherche :
--------------------------------------------------
  [analyze] Itération 1, requête: 'recherche: Qu'est-ce que LangGraph ?'
  [search]  Résultat: 'Résultat non pertinent'
  [evaluate] Statut: retry
  [analyze] Itération 2, requête: 'recherche: Qu'est-ce que LangGraph ?'
  [search]  Résultat: 'Résultat pertinent pour 'recherche: Qu'est-ce que LangGraph ?''
  [evaluate] Statut: done
--------------------------------------------------
Résultat : Réponse finale : Résultat pertinent pour 'recherche: Qu'est-ce que LangGraph ?'
Nombre d'étapes : 6

Hide code cell source

# Visualisation du graphe d'état de l'agent
G = nx.DiGraph()
G.add_node("START", shape="ellipse")
G.add_nodes_from(["analyze", "search", "evaluate"])
G.add_node("END", shape="ellipse")
G.add_edges_from([
    ("START", "analyze"),
    ("analyze", "search"),
    ("search", "evaluate"),
])
G.add_edge("evaluate", "analyze", label="retry")
G.add_edge("evaluate", "END", label="done")

pos = {
    "START": (2, 5),
    "analyze": (2, 4),
    "search": (2, 3),
    "evaluate": (2, 2),
    "END": (2, 1),
}

fig, ax = plt.subplots(figsize=(8, 8))
ax.set_title("Graphe d'état de l'agent de recherche", fontsize=13, fontweight="bold")

node_colors = {"START": "#A8D8EA", "analyze": "#4C72B0",
               "search": "#DD8452", "evaluate": "#C44E52", "END": "#55A868"}
colors = [node_colors[n] for n in G.nodes()]

nx.draw_networkx_nodes(G, pos, ax=ax, node_color=colors, node_size=2500,
                       edgecolors="white", linewidths=2)
nx.draw_networkx_labels(G, pos, ax=ax, font_size=10, font_weight="bold",
                        font_color="white")

# Arêtes normales
normal_edges = [("START", "analyze"), ("analyze", "search"),
                ("search", "evaluate"), ("evaluate", "END")]
nx.draw_networkx_edges(G, pos, edgelist=normal_edges, ax=ax,
                       edge_color="gray", width=2, arrows=True,
                       arrowsize=20, arrowstyle="-|>",
                       connectionstyle="arc3,rad=0.0")

# Arête de boucle (retry)
nx.draw_networkx_edges(G, pos, edgelist=[("evaluate", "analyze")], ax=ax,
                       edge_color="#C44E52", width=2, arrows=True,
                       arrowsize=20, arrowstyle="-|>",
                       connectionstyle="arc3,rad=0.4")

# Labels sur les arêtes conditionnelles
ax.text(3.0, 3.0, "retry", fontsize=9, color="#C44E52", style="italic",
        fontweight="bold")
ax.text(2.15, 1.45, "done", fontsize=9, color="#55A868", style="italic",
        fontweight="bold")

ax.set_xlim(0, 4.5)
ax.set_ylim(0.5, 5.5)
ax.axis("off")
plt.show()
_images/8bc19164ee063e9696ddc241a0919beda77eb4af122e87590d78bdaecf7d3e4c.png

Comparaison des frameworks#

Le choix d’un framework dépend du contexte : complexité du workflow, taille de l’équipe, contraintes de production, et préférences architecturales. Le tableau suivant compare les principaux frameworks selon des critères objectifs.

Hide code cell source

frameworks = ["LangChain", "LangGraph", "CrewAI", "AutoGen", "Sem. Kernel", "Haystack"]
criteria = [
    "Courbe\nd'apprentissage",
    "Flexibilite",
    "Support\nagents",
    "Multi-\nagents",
    "Communaute",
    "Production-\nready",
]

# Scores (1-5, 5 = meilleur) — estimation qualitative à visée pédagogique
scores = np.array([
    # LangChain  LangGraph  CrewAI  AutoGen  Sem.Kernel  Haystack
    [3,          2,         4,      3,       3,          3],   # Courbe d'apprentissage
    [4,          5,         3,      4,       3,          4],   # Flexibilité
    [3,          5,         4,      4,       3,          3],   # Support agents
    [2,          3,         5,      5,       2,          2],   # Multi-agents
    [5,          4,         3,      4,       3,          3],   # Communauté
    [3,          4,         2,      3,       4,          4],   # Production-ready
])

fig, ax = plt.subplots(figsize=(12, 6))
im = ax.imshow(scores, cmap="YlOrRd", aspect="auto", vmin=1, vmax=5)

ax.set_xticks(range(len(frameworks)))
ax.set_xticklabels(frameworks, fontsize=10, fontweight="bold")
ax.set_yticks(range(len(criteria)))
ax.set_yticklabels(criteria, fontsize=9)

for i in range(len(criteria)):
    for j in range(len(frameworks)):
        val = scores[i, j]
        color = "white" if val >= 4 else "black"
        ax.text(j, i, str(val), ha="center", va="center",
                fontsize=13, fontweight="bold", color=color)

ax.set_title("Comparaison des frameworks d'agents (scores 1-5)", fontsize=13, pad=15)
cbar = plt.colorbar(im, ax=ax, fraction=0.025, pad=0.04)
cbar.set_label("Score (5 = meilleur)", fontsize=10)
plt.show()
_images/99382c3e46a338d76461212c3bfc4a170ab4d8985c76c1c34cf8b6bc0c456d83.png

Hide code cell source

# Scatter plot : maturite vs flexibilite (donnees simulees)
fw_data = {
    "LangChain":    {"maturity": 4.2, "flexibility": 3.8, "community": 9500, "year": 2022},
    "LangGraph":    {"maturity": 3.5, "flexibility": 4.8, "community": 4200, "year": 2024},
    "CrewAI":       {"maturity": 2.8, "flexibility": 3.2, "community": 5800, "year": 2024},
    "AutoGen":      {"maturity": 3.3, "flexibility": 4.2, "community": 7100, "year": 2023},
    "Sem. Kernel":  {"maturity": 3.8, "flexibility": 3.0, "community": 3200, "year": 2023},
    "Haystack":     {"maturity": 4.0, "flexibility": 3.5, "community": 3800, "year": 2020},
}

fig, ax = plt.subplots(figsize=(10, 7))

colors_fw = ["#4C72B0", "#55A868", "#DD8452", "#C44E52", "#8B6DAF", "#937860"]

for i, (name, d) in enumerate(fw_data.items()):
    size = d["community"] / 15
    ax.scatter(d["flexibility"], d["maturity"], s=size, c=colors_fw[i],
               alpha=0.7, edgecolors="white", linewidths=1.5, zorder=5)
    ax.annotate(name, (d["flexibility"], d["maturity"]),
                xytext=(8, 8), textcoords="offset points",
                fontsize=10, fontweight="bold", color=colors_fw[i])

ax.set_xlabel("Flexibilité (score)", fontsize=11)
ax.set_ylabel("Maturité (score)", fontsize=11)
ax.set_title("Frameworks d'agents : maturité vs flexibilité\n(taille = taille de la communauté)",
             fontsize=12)
ax.set_xlim(2.5, 5.3)
ax.set_ylim(2.3, 4.8)
plt.show()
_images/c9d3d9f0828c8ba4f0f5488fde7fac569d78412b85053e44a722a9c66a7ddb3e.png

Les observations principales de cette comparaison sont les suivantes :

  • LangChain a la communauté la plus large et la couverture d’intégrations la plus étendue, mais sa complexité et ses changements d’API fréquents peuvent freiner les projets en production.

  • LangGraph offre la plus grande flexibilité pour les workflows complexes grâce à son modèle de graphe d’état, au prix d’une courbe d’apprentissage plus raide.

  • CrewAI est le plus accessible pour les systèmes multi-agents simples, mais manque de maturité pour les déploiements en production.

  • AutoGen excelle dans les scénarios conversationnels multi-agents avec exécution de code.

  • Semantic Kernel et Haystack sont les mieux positionnés pour les environnements d’entreprise, avec une emphase sur la stabilité et l’intégration dans des écosystèmes existants.

Quand utiliser (ou ne pas utiliser) un framework#

L’adoption d’un framework n’est pas une décision anodine. Elle engage l’architecture du projet, la courbe d’apprentissage de l’équipe et la maintenance à long terme.

Quand un framework est utile#

Un framework se justifie lorsque le workflow est suffisamment complexe pour que la réimplémentation des briques de base (gestion d’état, routage, persistance, retry, observabilité) représente un effort significatif. Concrètement :

  • Le workflow contient des boucles ou du routage conditionnel non trivial.

  • Le système nécessite une persistance d’état entre les exécutions (checkpoints, reprise sur erreur).

  • Plusieurs agents doivent collaborer avec un protocole de communication structuré.

  • L’équipe a besoin d”intégrations pré-construites avec des fournisseurs de LLM, des bases vectorielles ou des outils externes.

  • Le projet vise la production et nécessite des fonctionnalités d’observabilité (traces, logs, métriques).

Quand un framework est superflu#

Pour de nombreux cas d’usage, un framework est une complexité inutile :

  • Un appel API direct (via le SDK OpenAI, Anthropic, etc.) suffit pour les tâches simples : résumé, classification, extraction d’information.

  • Un script Python de quelques dizaines de lignes peut implémenter un pipeline RAG basique sans framework.

  • Les prototypes et preuves de concept gagnent en rapidité et en clarté sans framework.

  • Lorsque le framework impose des abstractions incompatibles avec les besoins du projet, le contourner coûte plus cher que de construire sur mesure.

Exemple 53 (Construction sur mesure vs framework)

Un pipeline RAG minimal peut s’écrire en Python pur en moins de 30 lignes, sans aucun framework :

import openai
import numpy as np

def embed(texts, client):
    """Encode des textes en vecteurs."""
    resp = client.embeddings.create(model="text-embedding-3-small", input=texts)
    return np.array([e.embedding for e in resp.data])

def search(query_vec, doc_vecs, documents, k=3):
    """Recherche les k documents les plus similaires."""
    scores = doc_vecs @ query_vec
    top_k = np.argsort(scores)[-k:][::-1]
    return [documents[i] for i in top_k]

def rag(question, documents, client):
    """Pipeline RAG complet."""
    doc_vecs = embed(documents, client)
    q_vec = embed([question], client)[0]
    context = search(q_vec, doc_vecs, documents)
    prompt = f"Contexte : {chr(10).join(context)}\n\nQuestion : {question}"
    response = client.chat.completions.create(
        model="gpt-4o", messages=[{"role": "user", "content": prompt}])
    return response.choices[0].message.content

Ce code est plus court, plus lisible et plus facile à déboguer que l’équivalent LangChain. Le framework ne se justifie que lorsque la complexité dépasse ce niveau.

Remarque 77

La règle d’or est la suivante : commencer simple et ajouter de la complexité uniquement lorsque le besoin est avéré. Il est toujours plus facile de migrer d’un script Python vers un framework que de se défaire d’un framework devenu un obstacle. Cette approche incrémentale limite le risque de sur-ingénierie (over-engineering) et préserve l’agilité du projet.

Hide code cell source

# Diagramme de décision : framework ou pas ?
fig, ax = plt.subplots(figsize=(12, 8))
ax.set_xlim(0, 12)
ax.set_ylim(0, 10)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title("Arbre de décision : faut-il utiliser un framework ?",
             fontsize=13, fontweight="bold", pad=15)

def draw_box(ax, x, y, w, h, text, color, fontsize=8):
    rect = mpatches.FancyBboxPatch((x - w/2, y - h/2), w, h,
                                    boxstyle="round,pad=0.15",
                                    facecolor=color, edgecolor="white",
                                    alpha=0.85, linewidth=1.5)
    ax.add_patch(rect)
    fc = "white" if color not in ["#A8D8EA", "#FFF3CD", "white"] else "black"
    ax.text(x, y, text, ha="center", va="center",
            fontsize=fontsize, color=fc, fontweight="bold",
            multialignment="center")

# Noeud racine
draw_box(ax, 6, 9, 3.5, 0.8, "Le workflow a-t-il\ndes boucles ou du routage ?",
         "#4C72B0")

# Branche Non (gauche)
ax.annotate("", xy=(3, 7.6), xytext=(5, 8.6),
            arrowprops=dict(arrowstyle="->", color="gray", lw=1.5))
ax.text(3.7, 8.3, "Non", fontsize=9, color="gray", fontweight="bold")
draw_box(ax, 3, 7.2, 3.0, 0.8, "Nombre d'intégrations\nnécessaires > 5 ?",
         "#DD8452")

ax.annotate("", xy=(1.5, 5.6), xytext=(2.2, 6.8),
            arrowprops=dict(arrowstyle="->", color="gray", lw=1.5))
ax.text(1.3, 6.4, "Non", fontsize=9, color="gray", fontweight="bold")
draw_box(ax, 1.5, 5.2, 2.5, 0.7, "Script Python\ndirect", "#55A868")

ax.annotate("", xy=(4.5, 5.6), xytext=(3.8, 6.8),
            arrowprops=dict(arrowstyle="->", color="gray", lw=1.5))
ax.text(4.5, 6.4, "Oui", fontsize=9, color="gray", fontweight="bold")
draw_box(ax, 4.5, 5.2, 2.5, 0.7, "LangChain\n(ou Haystack)", "#4C72B0")

# Branche Oui (droite)
ax.annotate("", xy=(9, 7.6), xytext=(7, 8.6),
            arrowprops=dict(arrowstyle="->", color="gray", lw=1.5))
ax.text(8.3, 8.3, "Oui", fontsize=9, color="gray", fontweight="bold")
draw_box(ax, 9, 7.2, 3.0, 0.8, "Systeme\nmulti-agents ?",
         "#DD8452")

ax.annotate("", xy=(7.5, 5.6), xytext=(8.2, 6.8),
            arrowprops=dict(arrowstyle="->", color="gray", lw=1.5))
ax.text(7.2, 6.4, "Non", fontsize=9, color="gray", fontweight="bold")
draw_box(ax, 7.5, 5.2, 2.5, 0.7, "LangGraph\n(ou custom)", "#4C72B0")

ax.annotate("", xy=(10.5, 5.6), xytext=(9.8, 6.8),
            arrowprops=dict(arrowstyle="->", color="gray", lw=1.5))
ax.text(10.5, 6.4, "Oui", fontsize=9, color="gray", fontweight="bold")
draw_box(ax, 10.5, 5.2, 2.5, 0.7, "CrewAI, AutoGen\n(ou LangGraph)", "#C44E52")

plt.show()
_images/53fb59b2c0d3e7fbaa46940218407a384e5e2472ad15760bd0976044a3c22b67.png

Résumé#

Ce chapitre a présenté les frameworks d’agents, les abstractions qu’ils proposent et les arbitrages qu’ils imposent.

  1. L”écosystème des frameworks d’agents est en évolution rapide. Les principaux acteurs — LangChain, LangGraph, CrewAI, AutoGen, Semantic Kernel, Haystack — couvrent un spectre allant des chaînes linéaires simples aux systèmes multi-agents complexes.

  2. LangChain a popularisé les concepts de chaîne, prompt template, outil et mémoire. LCEL (LangChain Expression Language) permet de composer des pipelines de manière déclarative avec l’opérateur pipe. Le framework offre une couverture d’intégrations très large, mais fait l’objet de critiques sur sa complexité et l’instabilité de son API.

  3. LangGraph modélise les workflows comme des graphes d’état où les noeuds sont des fonctions et les arêtes des transitions (conditionnelles ou non). Ce paradigme permet de représenter des boucles, du routage conditionnel et de la persistance d’état, ce qui le rend adapté aux agents complexes et au contrôle humain via des checkpoints.

  4. Les patterns architecturaux récurrents — chaîne séquentielle, routeur, boucle d’agent, map-reduce, superviseur — sont indépendants des frameworks et constituent le vocabulaire commun de la conception d’agents. Ces patterns sont composables : un noeud peut contenir un sous-graphe complet.

  5. La comparaison des frameworks révèle des compromis entre courbe d’apprentissage, flexibilité, support multi-agents, taille de la communauté et maturité en production. Le choix dépend du contexte du projet.

  6. Un framework n’est pas toujours nécessaire. Pour les tâches simples, un appel API direct ou un script Python de quelques dizaines de lignes est souvent préférable. La règle d’or est de commencer simple et d’ajouter de la complexité uniquement lorsque le besoin est avéré.

  7. Construire un micro-framework en Python pur (état, noeuds, arêtes, routage conditionnel) est un exercice qui permet de comprendre les mécanismes internes des frameworks professionnels et de démystifier leur fonctionnement.