Agents LLM#

Ce chapitre ouvre la Partie IV de cet ouvrage, consacrée aux agents et systèmes multi-agents. Jusqu’ici, nous avons utilisé les LLM comme des systèmes réactifs : un prompt entre, une réponse sort. Les agents LLM franchissent un seuil conceptuel majeur en transformant le modèle de langage en moteur de raisonnement au sein d’une boucle autonome capable de percevoir son environnement, de prendre des décisions et d’agir sur le monde extérieur.

L’idée d’agent autonome n’est pas nouvelle — elle traverse l’intelligence artificielle depuis ses origines, des agents rationnels de Russell et Norvig aux systèmes multi-agents de Wooldridge. Ce qui change avec les LLM, c’est la généralité du raisonnement : un seul modèle peut comprendre des instructions en langage naturel, décomposer un problème complexe en sous-tâches, sélectionner des outils appropriés et synthétiser les résultats, le tout sans programmation explicite de chaque cas.

Ce chapitre pose les fondements : nous définirons rigoureusement ce qu’est un agent LLM, formaliserons sa boucle de fonctionnement, introduirons les mécanismes de tool use, de planification et de mémoire, puis construirons un agent complet en Python. Les chapitres suivants aborderont les frameworks existants, les architectures multi-agents et les applications pratiques.

Qu’est-ce qu’un agent LLM ?#

Un agent LLM est un système qui utilise un grand modèle de langage comme noyau décisionnel pour accomplir des tâches de manière autonome. Contrairement à un simple appel de modèle, l’agent interagit avec son environnement en boucle : il observe, raisonne, agit, puis observe le résultat de son action pour décider de la suite.

Définition 63 (Agent LLM)

Un agent LLM est un système \(\mathcal{A} = (\mathcal{M}, \mathcal{T}, \mathcal{E}, \pi)\) où :

  • \(\mathcal{M}\) est un grand modèle de langage servant de moteur de raisonnement,

  • \(\mathcal{T} = \{t_1, \ldots, t_n\}\) est un ensemble d”outils (fonctions exécutables),

  • \(\mathcal{E}\) est un environnement avec lequel l’agent interagit,

  • \(\pi : \mathcal{O}^* \to \mathcal{T} \cup \{\texttt{stop}\}\) est une politique dérivée de \(\mathcal{M}\) qui, étant donnée l’historique des observations \(o_1, \ldots, o_k \in \mathcal{O}^*\), sélectionne le prochain outil à invoquer ou décide de terminer.

L’agent opère en boucle jusqu’à ce que la politique émette \(\texttt{stop}\) ou qu’une condition d’arrêt externe soit atteinte.

La distinction clé avec une chaîne (chain) ou un pipeline est le caractère dynamique de la prise de décision. Dans un pipeline, la séquence d’étapes est fixée à l’avance par le développeur. Dans un agent, c’est le LLM lui-même qui décide, à chaque itération, quelle action entreprendre.

Remarque 70 (Agent vs chaîne)

La distinction entre un agent et une chaîne repose sur la source du contrôle de flux :

  • Chaîne/Pipeline : le développeur encode la séquence \(t_1 \to t_2 \to \cdots \to t_n\). Le LLM exécute chaque étape mais ne choisit pas l’ordre.

  • Agent : le LLM décide dynamiquement à chaque pas quelle action entreprendre. La séquence n’est pas prédéterminée.

# Pipeline : séquence fixe
resultat = traduire(resumer(extraire(document)))

# Agent : décision dynamique à chaque étape
while not terminé:
    action = llm.decider(observations)
    resultat = executer(action)
    observations.append(resultat)

En pratique, il existe un spectre entre ces deux extrêmes : certaines architectures (comme les routers) offrent un contrôle de flux partiel au LLM.

Remarque 71 (Spectre d’autonomie)

Les systèmes basés sur les LLM s’étalent sur un spectre d’autonomie croissante :

  1. Appel unique : un prompt, une réponse (zéro autonomie).

  2. Chaîne : séquence fixe d’appels (autonomie nulle, composition fixe).

  3. Routeur : le LLM choisit parmi un ensemble fini de branches (autonomie limitée).

  4. Agent réactif : boucle perception-action, le LLM décide à chaque pas (autonomie modérée).

  5. Agent planificateur : l’agent décompose un objectif en sous-tâches avant d’agir (autonomie élevée).

  6. Système multi-agents : plusieurs agents collaborent ou se coordonnent (autonomie distribuée).

Plus l’autonomie augmente, plus les capacités croissent — mais aussi les risques d’erreur, de boucle infinie ou de comportement inattendu.

Hide code cell source

# Import des librairies Python
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns
import json
import re
from dataclasses import dataclass, field
from typing import Callable, Any

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

Hide code cell source

# Visualisation du spectre d'autonomie
niveaux = [
    "Appel unique", "Chaîne", "Routeur",
    "Agent réactif", "Agent\nplanificateur", "Multi-agents"
]
autonomie = [0.05, 0.15, 0.35, 0.55, 0.75, 0.95]
complexite = [0.1, 0.25, 0.4, 0.6, 0.8, 0.95]
couleurs = sns.color_palette("viridis", n_colors=len(niveaux))

fig, ax = plt.subplots(figsize=(10, 3))
for i, (nom, auto, comp) in enumerate(zip(niveaux, autonomie, complexite)):
    ax.scatter(auto, 0.5, s=300 + comp * 500, color=couleurs[i],
               edgecolors="black", linewidth=1.5, zorder=5)
    ax.annotate(nom, (auto, 0.5), textcoords="offset points",
                xytext=(0, 28 if i % 2 == 0 else -35), ha="center",
                fontsize=9, fontweight="bold")

ax.set_xlim(-0.05, 1.05)
ax.set_ylim(0, 1)
ax.set_xlabel("Autonomie croissante →", fontsize=12)
ax.set_yticks([])
ax.arrow(0.0, 0.5, 0.97, 0, head_width=0.05, head_length=0.02,
         fc="gray", ec="gray", alpha=0.3, zorder=1)
ax.set_title("Spectre d'autonomie des systèmes LLM", fontsize=13)
plt.show()
_images/efa431d5efc843faa7c3ceeb4f126c705b83d37c6632ac387e750a68ef0101b4.png

La boucle perception-action#

Le fonctionnement d’un agent repose sur une boucle itérative qui alterne raisonnement et interaction avec l’environnement. Cette boucle est l’analogue, pour les agents LLM, de la boucle perception-action des agents classiques en IA.

Définition 64 (Boucle perception-action)

La boucle perception-action d’un agent LLM se décompose en quatre phases répétées :

  1. Percevoir : l’agent reçoit une observation \(o_t\) de l’environnement (résultat de l’action précédente, ou requête initiale de l’utilisateur).

  2. Raisonner (think) : le LLM analyse l’historique \((o_1, a_1, \ldots, o_t)\) et produit un raisonnement interne \(r_t\) (souvent en chain-of-thought).

  3. Agir (act) : le LLM sélectionne une action \(a_t \in \mathcal{T} \cup \{\texttt{stop}\}\) sur la base de \(r_t\).

  4. Observer : l’environnement exécute \(a_t\) et renvoie \(o_{t+1}\).

L’agent s’arrête lorsque \(a_t = \texttt{stop}\) ou après un nombre maximal d’itérations \(T_{\max}\).

Ce cycle reprend le paradigme classique des agents rationnels tel que formalisé par Russell et Norvig. La différence fondamentale est que le raisonnement est effectué par un LLM en langage naturel plutôt que par un algorithme de recherche symbolique ou une politique apprise par renforcement.

Hide code cell source

# Diagramme de la boucle agent
fig, ax = plt.subplots(figsize=(8, 8))
ax.set_xlim(-3, 3)
ax.set_ylim(-3, 3)
ax.set_aspect("equal")
ax.axis("off")

etapes = ["Percevoir", "Raisonner", "Agir", "Observer"]
angles = [np.pi / 2, 0, -np.pi / 2, np.pi]
colors = ["#4C72B0", "#DD8452", "#55A868", "#C44E52"]
radius = 1.8

positions = []
for angle in angles:
    x = radius * np.cos(angle)
    y = radius * np.sin(angle)
    positions.append((x, y))

for i, (etape, (x, y), color) in enumerate(zip(etapes, positions, colors)):
    circle = plt.Circle((x, y), 0.55, color=color, ec="black", lw=2, alpha=0.85)
    ax.add_patch(circle)
    ax.text(x, y, etape, ha="center", va="center", fontsize=11,
            fontweight="bold", color="white")

for i in range(4):
    x1, y1 = positions[i]
    x2, y2 = positions[(i + 1) % 4]
    dx = x2 - x1
    dy = y2 - y1
    norm = np.sqrt(dx**2 + dy**2)
    dx_n, dy_n = dx / norm, dy / norm
    ax.annotate("",
                xy=(x2 - dx_n * 0.6, y2 - dy_n * 0.6),
                xytext=(x1 + dx_n * 0.6, y1 + dy_n * 0.6),
                arrowprops=dict(arrowstyle="-|>", color="gray", lw=2.5))

ax.text(0, 0, "Agent\nLLM", ha="center", va="center", fontsize=14,
        fontweight="bold", style="italic", color="#333333",
        bbox=dict(boxstyle="round,pad=0.3", facecolor="lightyellow",
                  edgecolor="gray", alpha=0.8))
ax.set_title("Boucle perception-action d'un agent LLM", fontsize=14, pad=20)
plt.show()
_images/966da2a75b1d746ee64cb7966cc47924c4f7ec2b2ccc2d69a9f7e2c14aebfde8.png

Le paradigme ReAct (Yao et al., 2023) formalise cette boucle en alternant explicitement des étapes de Reasoning (pensée) et d”Action dans le prompt du modèle. Chaque itération produit une trace de la forme : Thought (raisonnement en langage naturel), Action (outil à invoquer avec ses paramètres), Observation (résultat retourné par l’outil). Cette structuration améliore la traçabilité et la fiabilité de l’agent.

Tool use et function calling#

Les outils (tools) sont le mécanisme par lequel un agent LLM interagit avec le monde extérieur. Un outil est une fonction que l’agent peut invoquer pour obtenir de l’information ou produire un effet. Le tool use (ou function calling) est la capacité du LLM à identifier qu’un outil est nécessaire, à sélectionner le bon outil et à formuler correctement ses arguments.

Définition 65 (Définition d’outil)

Un outil (tool) pour un agent LLM est un triplet \(t = (\texttt{name}, \texttt{desc}, f)\) où :

  • \(\texttt{name} \in \Sigma^*\) est un identifiant unique,

  • \(\texttt{desc} \in \Sigma^*\) est une description en langage naturel de la fonctionnalité (utilisée par le LLM pour décider quand invoquer l’outil),

  • \(f : \mathcal{P} \to \mathcal{R}\) est une fonction exécutable qui, étant donné des paramètres \(p \in \mathcal{P}\) (typiquement décrits par un schéma JSON), renvoie un résultat \(r \in \mathcal{R}\).

La spécification de l’outil comprend en outre un schéma JSON décrivant les paramètres attendus, leurs types et contraintes.

Exemple 47 (Schéma JSON d’un outil)

Un outil de recherche web pourrait être spécifié ainsi :

{
  "name": "web_search",
  "description": "Recherche des informations sur le web. Utile pour trouver des faits récents, des données actualisées ou des informations que le modèle ne connaît pas.",
  "parameters": {
    "type": "object",
    "properties": {
      "query": {
        "type": "string",
        "description": "La requête de recherche"
      },
      "num_results": {
        "type": "integer",
        "description": "Nombre de résultats à retourner",
        "default": 5
      }
    },
    "required": ["query"]
  }
}

Le LLM reçoit cette spécification dans son contexte et génère un appel structuré lorsqu’il juge l’outil pertinent.

Hide code cell source

# Définition d'une classe Tool
@dataclass
class Tool:
    """Représente un outil utilisable par un agent."""
    name: str
    description: str
    parameters: dict
    function: Callable

    def execute(self, **kwargs) -> str:
        """Exécute l'outil avec les paramètres donnés."""
        return self.function(**kwargs)

    def schema(self) -> dict:
        """Retourne le schéma JSON de l'outil."""
        return {
            "name": self.name,
            "description": self.description,
            "parameters": self.parameters,
        }

Hide code cell source

# Implémentation de trois outils concrets

def calculatrice(expression: str) -> str:
    """Évalue une expression mathématique simple."""
    try:
        # Restreindre aux opérations sûres
        allowed = set("0123456789+-*/.() ")
        if not all(c in allowed for c in expression):
            return f"Erreur : caractères non autorisés dans '{expression}'"
        result = eval(expression)  # sûr car filtré
        return f"Résultat : {result}"
    except Exception as e:
        return f"Erreur de calcul : {e}"

def recherche_web(query: str, num_results: int = 3) -> str:
    """Simule une recherche web (mock)."""
    resultats_mock = {
        "météo paris": "Paris : 8°C, ciel couvert, vent 15 km/h. Prévisions : pluie légère demain.",
        "météo lyon": "Lyon : 12°C, ensoleillé, vent faible. Prévisions : beau temps.",
        "prix électricité france": "Prix moyen : 0.2516 €/kWh en 2025 (tarif réglementé).",
        "population france": "Population France : 68.4 millions d'habitants (2025).",
    }
    for cle, val in resultats_mock.items():
        if cle in query.lower():
            return val
    return f"Résultats pour '{query}' : aucune donnée disponible dans la base mock."

def meteo(ville: str) -> str:
    """Simule un service météo (mock)."""
    donnees = {
        "paris": {"temp": 8, "condition": "couvert", "vent": 15},
        "lyon": {"temp": 12, "condition": "ensoleillé", "vent": 5},
        "marseille": {"temp": 15, "condition": "ensoleillé", "vent": 20},
        "lille": {"temp": 5, "condition": "pluvieux", "vent": 25},
    }
    ville_lower = ville.lower()
    if ville_lower in donnees:
        d = donnees[ville_lower]
        return (f"Météo {ville} : {d['temp']}°C, {d['condition']}, "
                f"vent {d['vent']} km/h")
    return f"Météo non disponible pour {ville}."

# Instanciation des outils
outil_calc = Tool(
    name="calculatrice",
    description="Évalue une expression mathématique. Utile pour tout calcul numérique.",
    parameters={"type": "object", "properties": {
        "expression": {"type": "string", "description": "Expression mathématique à évaluer"}
    }, "required": ["expression"]},
    function=lambda **kw: calculatrice(kw["expression"]),
)

outil_meteo = Tool(
    name="meteo",
    description="Obtient la météo actuelle d'une ville française.",
    parameters={"type": "object", "properties": {
        "ville": {"type": "string", "description": "Nom de la ville"}
    }, "required": ["ville"]},
    function=lambda **kw: meteo(kw["ville"]),
)

outil_recherche = Tool(
    name="recherche_web",
    description="Recherche des informations sur le web.",
    parameters={"type": "object", "properties": {
        "query": {"type": "string", "description": "Requête de recherche"}
    }, "required": ["query"]},
    function=lambda **kw: recherche_web(kw["query"]),
)

# Test des outils
print("=== Test des outils ===")
print(outil_calc.execute(expression="(25 * 4) + 17.5"))
print(outil_meteo.execute(ville="Paris"))
print(outil_recherche.execute(query="prix électricité france"))
=== Test des outils ===
Résultat : 117.5
Météo Paris : 8°C, couvert, vent 15 km/h
Prix moyen : 0.2516 €/kWh en 2025 (tarif réglementé).

Exemple 48 (Agent calculatrice)

Un agent calculatrice illustre le mécanisme de base du tool use. L’utilisateur pose une question en langage naturel ; l’agent identifie qu’un calcul est nécessaire, formule l’expression, invoque l’outil et intègre le résultat dans sa réponse :

Utilisateur : Combien coûte le chauffage d'un appartement de 50 m²
              pendant 30 jours à 0.25 €/kWh si la consommation est
              de 120 kWh/mois ?
Agent (think): Je dois calculer 120 * 0.25 pour obtenir le coût mensuel.
Agent (act)  : calculatrice(expression="120 * 0.25")
Observation  : Résultat : 30.0
Agent (think): Le coût est de 30 €. Je peux répondre.
Agent (act)  : stop
Réponse      : Le chauffage coûte 30,00 € par mois.

Remarque 72 (Ancrage et fiabilité)

Le tool use joue un rôle d”ancrage (grounding) : en permettant à l’agent d’accéder à des sources de données externes (calculatrice, bases de données, API), on réduit les hallucinations et on augmente la fiabilité factuelle. Un agent bien conçu préfère invoquer un outil plutôt que de « deviner » un fait ou un calcul.

Cet ancrage est particulièrement important pour :

  • Les données numériques : les LLM font fréquemment des erreurs de calcul.

  • Les informations temporelles : les connaissances du modèle ont une date de coupure.

  • Les faits vérifiables : noms, dates, statistiques.

Planification et décomposition de tâches#

Face à une tâche complexe, un agent efficace ne se lance pas directement dans l’exécution : il planifie. La planification consiste à décomposer un objectif de haut niveau en sous-tâches séquentielles ou parallèles, puis à les exécuter méthodiquement.

Définition 66 (Décomposition de tâches)

La décomposition de tâches (task decomposition) est le processus par lequel un agent transforme un objectif \(G\) en une séquence ordonnée de sous-objectifs \((g_1, g_2, \ldots, g_m)\) tels que :

  1. Chaque \(g_i\) est réalisable par un ou quelques appels d’outils.

  2. Les dépendances entre sous-objectifs sont respectées : si \(g_j\) dépend du résultat de \(g_i\), alors \(i < j\).

  3. L’achèvement de tous les sous-objectifs implique l’achèvement de \(G\).

La décomposition peut être statique (planification complète avant exécution) ou dynamique (re-planification après chaque étape).

Deux grandes stratégies de planification se distinguent :

Planification descendante (top-down). L’agent commence par formuler un plan global, puis le raffine en sous-étapes de plus en plus concrètes. Cette approche est souvent réalisée par un prompt dédié demandant au LLM de « lister les étapes nécessaires avant de commencer ».

Raffinement itératif. L’agent exécute une première étape, observe le résultat, puis décide de l’étape suivante. Le plan émerge pas à pas. Cette approche est plus robuste face à l’incertitude mais peut manquer de cohérence globale.

Exemple 49 (Prompt de planification)

Un prompt typique pour la planification descendante :

Tu es un assistant qui décompose les tâches complexes en étapes.
Objectif : Analyser les ventes du trimestre et préparer un rapport.

Décompose cet objectif en sous-tâches numérotées, en précisant
pour chaque sous-tâche quel outil utiliser :

1. [recherche_db] Extraire les données de ventes Q4
2. [calculatrice] Calculer les totaux par catégorie
3. [calculatrice] Calculer les variations vs Q3
4. [generateur_graphiques] Créer les visualisations
5. [redaction] Rédiger le résumé exécutif

Cette décomposition explicite permet à l’agent de suivre un plan structuré et de vérifier l’achèvement de chaque étape.

Remarque 73 (Récupération d’erreur)

Un agent robuste doit gérer les erreurs lors de l’exécution. Lorsqu’un outil échoue ou retourne un résultat inattendu, l’agent peut :

  1. Réessayer avec des paramètres corrigés.

  2. Choisir un outil alternatif (par exemple, passer de la recherche web à la base de données locale).

  3. Re-planifier en adaptant le plan restant.

  4. Escalader en demandant une clarification à l’utilisateur.

La gestion d’erreur est un marqueur essentiel de la maturité d’un système d’agents : un agent naïf s’arrête au premier échec, tandis qu’un agent robuste adapte sa stratégie.

Hide code cell source

# Simulation d'une décomposition de tâches
taches = [
    ("Objectif", "Calculer le coût de\nchauffage mensuel", 0, "#8C564B"),
    ("Étape 1", "Obtenir la météo\nde la ville", 1, "#4C72B0"),
    ("Étape 2", "Déterminer si\nchauffage nécessaire", 2, "#DD8452"),
    ("Étape 3", "Calculer la\nconsommation", 3, "#55A868"),
    ("Étape 4", "Calculer le\ncoût total", 4, "#C44E52"),
]

fig, ax = plt.subplots(figsize=(10, 4))
for label, desc, x, color in taches:
    rect = mpatches.FancyBboxPatch((x * 2.2, 0.2), 1.8, 1.6,
                                     boxstyle="round,pad=0.15",
                                     facecolor=color, alpha=0.8,
                                     edgecolor="black", linewidth=1.5)
    ax.add_patch(rect)
    ax.text(x * 2.2 + 0.9, 1.35, label, ha="center", va="center",
            fontsize=9, fontweight="bold", color="white")
    ax.text(x * 2.2 + 0.9, 0.75, desc, ha="center", va="center",
            fontsize=7.5, color="white")

for i in range(4):
    ax.annotate("", xy=((i + 1) * 2.2, 1.0),
                xytext=(i * 2.2 + 1.8, 1.0),
                arrowprops=dict(arrowstyle="-|>", color="gray", lw=2))

ax.set_xlim(-0.3, 10.5)
ax.set_ylim(-0.1, 2.2)
ax.axis("off")
ax.set_title("Décomposition d'une tâche en sous-étapes", fontsize=13, pad=10)
plt.show()
_images/9a18d5208030a32db53dade564ae833364e79c4fe46a196af8be8fddd13b4490.png

Mémoire de l’agent#

Un agent efficace ne se limite pas au contexte immédiat : il exploite différentes formes de mémoire pour maintenir la cohérence à travers ses étapes d’exécution et, potentiellement, entre sessions.

Définition 67 (Mémoire de l’agent)

La mémoire d’un agent LLM se décline en trois types, par analogie avec la psychologie cognitive :

  1. Mémoire de travail (working memory) : le contexte courant du LLM, incluant le prompt système, l’historique de la conversation et les observations récentes. Elle est limitée par la fenêtre de contexte du modèle.

  2. Mémoire épisodique (episodic memory) : le stockage des expériences passées sous forme de paires (situation, action, résultat). Permet à l’agent de ne pas répéter les mêmes erreurs et d’exploiter des solutions déjà trouvées.

  3. Mémoire sémantique (semantic memory) : une base de connaissances persistante (souvent implémentée via un système RAG) dans laquelle l’agent peut chercher des faits, des procédures ou des documents.

En pratique, la mémoire de travail est implémentée par le contexte de la conversation (la suite de messages). La mémoire épisodique peut être un simple journal (log) des actions passées, stocké dans un fichier ou une base de données. La mémoire sémantique correspond au mécanisme de RAG étudié aux chapitres 10 et 11.

Hide code cell source

# Visualisation des trois types de mémoire
fig, ax = plt.subplots(figsize=(9, 5))
ax.set_xlim(-1, 11)
ax.set_ylim(-0.5, 6)
ax.axis("off")

# Agent central
agent_rect = mpatches.FancyBboxPatch((3.5, 2.0), 3, 1.5,
                                       boxstyle="round,pad=0.2",
                                       facecolor="#4C72B0", alpha=0.9,
                                       edgecolor="black", linewidth=2)
ax.add_patch(agent_rect)
ax.text(5, 2.75, "Agent LLM", ha="center", va="center",
        fontsize=13, fontweight="bold", color="white")

# Mémoire de travail
mem_w = mpatches.FancyBboxPatch((0.0, 4.2), 2.8, 1.2,
                                  boxstyle="round,pad=0.15",
                                  facecolor="#DD8452", alpha=0.8,
                                  edgecolor="black", linewidth=1.5)
ax.add_patch(mem_w)
ax.text(1.4, 4.8, "Mémoire\nde travail", ha="center", va="center",
        fontsize=10, fontweight="bold", color="white")

# Mémoire épisodique
mem_e = mpatches.FancyBboxPatch((3.6, 4.2), 2.8, 1.2,
                                  boxstyle="round,pad=0.15",
                                  facecolor="#55A868", alpha=0.8,
                                  edgecolor="black", linewidth=1.5)
ax.add_patch(mem_e)
ax.text(5, 4.8, "Mémoire\népisodique", ha="center", va="center",
        fontsize=10, fontweight="bold", color="white")

# Mémoire sémantique
mem_s = mpatches.FancyBboxPatch((7.2, 4.2), 2.8, 1.2,
                                  boxstyle="round,pad=0.15",
                                  facecolor="#C44E52", alpha=0.8,
                                  edgecolor="black", linewidth=1.5)
ax.add_patch(mem_s)
ax.text(8.6, 4.8, "Mémoire\nsémantique", ha="center", va="center",
        fontsize=10, fontweight="bold", color="white")

# Flèches bidirectionnelles
for mx in [1.4, 5.0, 8.6]:
    ax.annotate("", xy=(mx, 4.2), xytext=(min(max(mx, 3.8), 6.2), 3.5),
                arrowprops=dict(arrowstyle="<->", color="gray", lw=1.8))

# Annotations
ax.text(1.4, 3.85, "contexte\ncourant", ha="center", fontsize=7.5,
        style="italic", color="gray")
ax.text(5.0, 3.85, "journal\nd'actions", ha="center", fontsize=7.5,
        style="italic", color="gray")
ax.text(8.6, 3.85, "RAG /\nbase de savoirs", ha="center", fontsize=7.5,
        style="italic", color="gray")

# Outils en bas
outils_rect = mpatches.FancyBboxPatch((2.5, 0.0), 5, 1.2,
                                         boxstyle="round,pad=0.15",
                                         facecolor="#8C564B", alpha=0.7,
                                         edgecolor="black", linewidth=1.5)
ax.add_patch(outils_rect)
ax.text(5, 0.6, "Outils : calculatrice, recherche, API, ...",
        ha="center", va="center", fontsize=10, color="white")
ax.annotate("", xy=(5, 1.2), xytext=(5, 2.0),
            arrowprops=dict(arrowstyle="<->", color="gray", lw=1.8))

ax.set_title("Architecture mémoire d'un agent LLM", fontsize=13, pad=15)
plt.show()
_images/890f36b38873fc1a9ba3b87240051fdf3a5f63ece377d4b951abd125a3c89d4e.png

La gestion de la mémoire de travail est un défi central : lorsque l’historique dépasse la fenêtre de contexte, il faut résumer ou élaguer les observations anciennes. Différentes stratégies existent : résumé glissant (sliding summary), suppression des observations les moins pertinentes, ou compression via un modèle auxiliaire. Ce problème rejoint directement les techniques de mémoire conversationnelle étudiées au chapitre 9.

Implémentation d’un agent simple#

Nous allons maintenant construire un agent complet en Python. Pour rester indépendant de tout service externe, nous utilisons un LLM simulé (mock) qui retourne des réponses prédéfinies en fonction de mots-clés détectés dans le contexte.

Hide code cell source

class MockLLM:
    """LLM simulé pour démonstration.

    Analyse le contexte et retourne des décisions prédéfinies
    basées sur la détection de mots-clés.
    """

    def __init__(self):
        self.call_count = 0

    def generate(self, messages: list[dict]) -> str:
        """Génère une réponse basée sur le contexte."""
        self.call_count += 1
        contexte = " ".join(m["content"] for m in messages).lower()

        # Première étape : obtenir la météo si mentionnée
        if "météo" in contexte and "météo paris" not in contexte:
            return json.dumps({
                "thought": "L'utilisateur demande la météo. Je dois utiliser l'outil meteo.",
                "action": "meteo",
                "parameters": {"ville": "Paris"},
            })

        # Deuxième étape : calcul si les données météo sont disponibles
        if "8°c" in contexte and "résultat" not in contexte:
            return json.dumps({
                "thought": "Il fait 8°C, c'est froid. Je dois calculer le coût de chauffage. "
                           "Consommation estimée : 150 kWh/mois, prix : 0.25 €/kWh.",
                "action": "calculatrice",
                "parameters": {"expression": "150 * 0.25"},
            })

        # Troisième étape : recherche complémentaire
        if "37.5" in contexte and "prix" not in contexte:
            return json.dumps({
                "thought": "Le coût est de 37.50 €. Vérifions le prix actuel de l'électricité.",
                "action": "recherche_web",
                "parameters": {"query": "prix électricité france"},
            })

        # Fin : synthèse
        return json.dumps({
            "thought": "J'ai toutes les informations. La météo à Paris est 8°C (froid), "
                       "le chauffage coûterait environ 37.50 €/mois.",
            "action": "stop",
            "parameters": {},
        })

mock_llm = MockLLM()
print("MockLLM initialisé.")
MockLLM initialisé.

Hide code cell source

class SimpleAgent:
    """Agent LLM simple avec boucle think-act-observe."""

    def __init__(self, llm, tools: list[Tool], max_steps: int = 10):
        self.llm = llm
        self.tools = {t.name: t for t in tools}
        self.max_steps = max_steps
        self.messages: list[dict] = []
        self.trace: list[dict] = []

    def _system_prompt(self) -> str:
        """Construit le prompt système avec la description des outils."""
        tool_descriptions = "\n".join(
            f"- {t.name} : {t.description}" for t in self.tools.values()
        )
        return (
            "Tu es un agent autonome. À chaque étape, tu dois répondre en JSON "
            "avec les clés : thought, action, parameters.\n"
            f"Outils disponibles :\n{tool_descriptions}\n"
            "Utilise action='stop' quand la tâche est terminée."
        )

    def think(self) -> dict:
        """Phase de raisonnement : interroge le LLM."""
        response = self.llm.generate(self.messages)
        try:
            decision = json.loads(response)
        except json.JSONDecodeError:
            decision = {"thought": response, "action": "stop", "parameters": {}}
        return decision

    def act(self, decision: dict) -> str | None:
        """Phase d'action : exécute l'outil sélectionné."""
        action = decision.get("action", "stop")
        if action == "stop":
            return None
        tool = self.tools.get(action)
        if tool is None:
            return f"Erreur : outil '{action}' non trouvé."
        params = decision.get("parameters", {})
        return tool.execute(**params)

    def observe(self, observation: str) -> None:
        """Phase d'observation : ajoute le résultat au contexte."""
        self.messages.append({"role": "tool", "content": observation})

    def run(self, query: str) -> str:
        """Exécute la boucle complète de l'agent."""
        # Initialisation
        self.messages = [
            {"role": "system", "content": self._system_prompt()},
            {"role": "user", "content": query},
        ]
        self.trace = []

        for step in range(self.max_steps):
            # Think
            decision = self.think()
            thought = decision.get("thought", "")
            action = decision.get("action", "stop")

            self.trace.append({
                "step": step + 1,
                "phase": "think",
                "content": thought,
            })

            # Act
            if action == "stop":
                self.trace.append({
                    "step": step + 1,
                    "phase": "stop",
                    "content": "Tâche terminée.",
                })
                return thought

            observation = self.act(decision)
            self.trace.append({
                "step": step + 1,
                "phase": "act",
                "content": f"{action}({decision.get('parameters', {})})",
            })
            self.trace.append({
                "step": step + 1,
                "phase": "observe",
                "content": observation,
            })

            # Observe
            self.observe(observation)
            self.messages.append({"role": "assistant", "content": json.dumps(decision)})

        return "Nombre maximal d'étapes atteint."

Hide code cell source

# Exécution de l'agent sur une tâche multi-étapes
agent = SimpleAgent(
    llm=mock_llm,
    tools=[outil_calc, outil_meteo, outil_recherche],
    max_steps=10,
)

query = ("Quelle est la météo à Paris ? Si c'est froid (< 15°C), "
         "calcule le coût de chauffage mensuel.")

print(f"Question : {query}\n")
print("=" * 60)

resultat = agent.run(query)

print("\n=== Trace d'exécution ===\n")
for entry in agent.trace:
    phase = entry["phase"].upper()
    print(f"  Étape {entry['step']} [{phase:7s}] : {entry['content']}")

print(f"\n{'=' * 60}")
print(f"Réponse finale : {resultat}")
Question : Quelle est la météo à Paris ? Si c'est froid (< 15°C), calcule le coût de chauffage mensuel.

============================================================

=== Trace d'exécution ===

  Étape 1 [THINK  ] : L'utilisateur demande la météo. Je dois utiliser l'outil meteo.
  Étape 1 [ACT    ] : meteo({'ville': 'Paris'})
  Étape 1 [OBSERVE] : Météo Paris : 8°C, couvert, vent 15 km/h
  Étape 2 [THINK  ] : Il fait 8°C, c'est froid. Je dois calculer le coût de chauffage. Consommation estimée : 150 kWh/mois, prix : 0.25 €/kWh.
  Étape 2 [ACT    ] : calculatrice({'expression': '150 * 0.25'})
  Étape 2 [OBSERVE] : Résultat : 37.5
  Étape 3 [THINK  ] : J'ai toutes les informations. La météo à Paris est 8°C (froid), le chauffage coûterait environ 37.50 €/mois.
  Étape 3 [STOP   ] : Tâche terminée.

============================================================
Réponse finale : J'ai toutes les informations. La météo à Paris est 8°C (froid), le chauffage coûterait environ 37.50 €/mois.

Propriété 16 (Complexité d’un agent)

La complexité d’un agent LLM peut se caractériser selon plusieurs axes :

  • Complexité en appels LLM : \(O(T)\)\(T\) est le nombre d’étapes de la boucle. Chaque étape nécessite au moins un appel au modèle.

  • Complexité en tokens : croissante à chaque étape car l’historique s’allonge. Au pas \(t\), le contexte contient \(O(t \cdot \bar{L})\) tokens où \(\bar{L}\) est la longueur moyenne d’une observation.

  • Fiabilité : décroît avec \(T\). Si chaque étape a une probabilité de succès \(p\), la probabilité de succès global est \(p^T\), ce qui décroît exponentiellement.

Il existe donc un compromis fondamental entre la complexité des tâches réalisables (qui requiert plus d’étapes) et la fiabilité (qui diminue avec le nombre d’étapes).

Hide code cell source

# Visualisation : trace d'exécution de l'agent
phase_colors = {
    "think": "#4C72B0",
    "act": "#55A868",
    "observe": "#DD8452",
    "stop": "#C44E52",
}

fig, ax = plt.subplots(figsize=(12, 4))

for i, entry in enumerate(agent.trace):
    phase = entry["phase"]
    color = phase_colors.get(phase, "gray")
    ax.barh(0, 1, left=i, height=0.6, color=color, edgecolor="white",
            linewidth=2, alpha=0.85)
    ax.text(i + 0.5, 0, phase.capitalize(), ha="center", va="center",
            fontsize=8, fontweight="bold", color="white", rotation=0)
    ax.text(i + 0.5, -0.55, f{entry['step']}", ha="center",
            fontsize=8, color="gray")

handles = [mpatches.Patch(color=c, label=l.capitalize())
           for l, c in phase_colors.items()]
ax.legend(handles=handles, loc="upper right", fontsize=9)
ax.set_xlim(-0.2, len(agent.trace) + 0.2)
ax.set_ylim(-1, 1)
ax.set_yticks([])
ax.set_xlabel("Déroulement temporel →", fontsize=11)
ax.set_title("Trace d'exécution de l'agent", fontsize=13)
plt.show()
_images/407d3c125d08bf97b3abcf98466b809ed1cb50b375da30c58e3f013d41a11230.png

Hide code cell source

# Complexité vs fiabilité : simulation
n_points = 60
steps = np.random.randint(1, 20, size=n_points)
p_per_step = 0.92
fiabilite = p_per_step ** steps + np.random.normal(0, 0.03, size=n_points)
fiabilite = np.clip(fiabilite, 0.05, 1.0)
complexite_tache = steps + np.random.normal(0, 0.8, size=n_points)

fig, ax = plt.subplots(figsize=(8, 5))
scatter = ax.scatter(complexite_tache, fiabilite, c=steps, cmap="viridis",
                     s=80, alpha=0.75, edgecolors="black", linewidth=0.5)
cbar = plt.colorbar(scatter, ax=ax)
cbar.set_label("Nombre d'étapes", fontsize=11)

# Courbe théorique
x_th = np.linspace(1, 20, 100)
y_th = p_per_step ** x_th
ax.plot(x_th, y_th, "--", color="red", linewidth=2, label=f"Théorique ($p={p_per_step}$)")

ax.set_xlabel("Complexité de la tâche (nombre d'étapes)", fontsize=11)
ax.set_ylabel("Fiabilité (probabilité de succès)", fontsize=11)
ax.set_title("Compromis complexité-fiabilité d'un agent", fontsize=13)
ax.legend(fontsize=10)
ax.set_ylim(0, 1.05)
plt.show()
_images/16bee8472299a9a92d4e782a2b558cba7c7a19732e87bac69bbf89e5e699daf1.png

Ce graphique illustre un phénomène bien connu des praticiens : les agents fonctionnent remarquablement bien sur des tâches en 2 à 5 étapes, mais deviennent fragiles au-delà de 10 étapes. C’est pourquoi la décomposition en sous-tâches et la conception d’outils fiables sont des priorités d’ingénierie.

Résumé#

Ce chapitre a posé les fondations de l’architecture agent dans le contexte des grands modèles de langage.

  1. Un agent LLM est un système autonome qui utilise un modèle de langage comme moteur de raisonnement pour percevoir, décider et agir dans une boucle itérative, contrairement aux chaînes dont le flux est prédéterminé.

  2. La boucle perception-action formalise le fonctionnement de l’agent en quatre phases cycliques : percevoir une observation, raisonner via le LLM, sélectionner et exécuter une action, puis observer le résultat. Le paradigme ReAct structure cette boucle explicitement.

  3. Le tool use permet à l’agent d’interagir avec le monde extérieur via des outils (fonctions exécutables), définis par un nom, une description et un schéma de paramètres JSON. Ce mécanisme d’ancrage réduit les hallucinations et étend les capacités du modèle.

  4. La planification transforme un objectif complexe en sous-tâches ordonnées. Les stratégies descendante (plan global puis raffinement) et itérative (étape par étape) offrent des compromis différents entre cohérence et adaptabilité.

  5. La mémoire de l’agent se décline en mémoire de travail (contexte courant), épisodique (journal des actions passées) et sémantique (base de connaissances). La gestion de la fenêtre de contexte est un défi central.

  6. La fiabilité d’un agent décroît exponentiellement avec le nombre d’étapes, ce qui impose un compromis fondamental entre ambition des tâches et robustesse de l’exécution. Des outils fiables, une bonne décomposition et une gestion d’erreurs sont essentiels.

  7. L’implémentation d’un agent repose sur une architecture relativement simple — une classe avec des méthodes think, act et observe — mais les défis pratiques (gestion du contexte, robustesse, sécurité) font toute la complexité des systèmes de production, comme nous le verrons dans les chapitres suivants.