L’initiation au tool calling a posé les bases : @tool, schéma, ToolNode, boucle ReAct. Ce deep-dive va beaucoup plus loin — là où se joue la fiabilité réelle d’un agent en production : schémas d’arguments riches, injection de données cachées au modèle, mise à jour du graphe depuis un outil, gestion fine des erreurs et contraintes d’appel.

Ce que le modèle voit, et ce qu’il ne voit pas

Rappel essentiel : le modèle ne reçoit que le schéma d’un outil — son nom, ses paramètres typés, sa description. Il ne voit ni le corps, ni l’état du graphe, ni la configuration d’exécution. Cette asymétrie est une fonctionnalité, pas une limite : elle vous permet d’injecter des données dans un outil sans les exposer au modèle.

Fig.01 · Frontière d'un outil
limite (visible) state · config · id (injecté) modèle runtime outil historique_client()
Le modèle ne décide que des arguments « visibles ». L'état du graphe, la config et le tool_call_id sont injectés par le runtime, hors de portée du modèle.

Des schémas d’arguments riches avec Pydantic

Plus le schéma est précis, moins le modèle produit d’arguments invalides. Pydantic est l’outil de choix : types contraints, valeurs énumérées, champs imbriqués, descriptions lues par le modèle.

from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
from langchain_core.tools import tool

class Priorite(str, Enum):
    basse = "basse"
    normale = "normale"
    haute = "haute"

class Ticket(BaseModel):
    """Création d'un ticket de support."""
    sujet: str = Field(description="Titre court et explicite du problème")
    description: str = Field(description="Description détaillée, étapes de reproduction")
    priorite: Priorite = Field(default=Priorite.normale, description="Urgence du ticket")
    client_id: Optional[str] = Field(default=None, description="Identifiant client si connu")

@tool(args_schema=Ticket)
def creer_ticket(sujet: str, description: str, priorite: Priorite, client_id: str | None = None) -> str:
    """Crée un ticket dans l'outil de support et renvoie son numéro."""
    numero = support_api.create(sujet, description, priorite.value, client_id)
    return f"Ticket {numero} créé (priorité : {priorite.value})."

L’Enum contraint le modèle à trois valeurs : il ne pourra pas inventer une priorité « urgentissime ». Les Field(description=...) guident chaque argument.

Injecter l’état, la config et le tool_call_id

C’est la fonctionnalité avancée qui change tout. Un outil a souvent besoin de contexte que le modèle ne doit pas fournir : l’état courant du graphe, l’identifiant de l’utilisateur passé dans la config, ou le tool_call_id pour émettre un message de réponse. LangGraph permet de les injecter — ils disparaissent alors du schéma vu par le modèle.

from typing_extensions import Annotated
from langchain_core.tools import tool, InjectedToolCallId
from langchain_core.runnables import RunnableConfig
from langgraph.prebuilt import InjectedState

@tool
def historique_client(
    limite: int,                                        # ← visible : le modèle le fournit
    state: Annotated[dict, InjectedState],              # ← injecté : l'état du graphe
    config: RunnableConfig,                             # ← injecté : la config d'exécution
) -> str:
    """Renvoie les derniers échanges du client courant."""
    user_id = config["configurable"]["user_id"]        # vient de la config, pas du modèle
    deja_vus = state.get("messages", [])
    return crm.derniers_echanges(user_id, limite, exclure=deja_vus)

Du point de vue du modèle, cet outil n’a qu’un seul paramètre : limite. Les deux autres sont remplis par le runtime. C’est ainsi qu’on donne à un outil l’accès à l’utilisateur authentifié sans jamais laisser le modèle « choisir » de quel utilisateur il s’agit — une garantie de sécurité, pas seulement de confort.

Mettre à jour le graphe depuis un outil : Command

Par défaut, un outil renvoie une chaîne qui devient un ToolMessage. Mais un outil peut faire bien plus : modifier l’état du graphe et même router vers un autre nœud, en renvoyant un Command.

from langgraph.types import Command
from langchain_core.messages import ToolMessage

@tool
def definir_langue(
    langue: str,
    tool_call_id: Annotated[str, InjectedToolCallId],
) -> Command:
    """Mémorise la langue préférée de l'utilisateur pour toute la session."""
    return Command(update={
        # met à jour un champ d'état personnalisé…
        "langue_pref": langue,
        # …et ajoute le ToolMessage attendu par la boucle (relié par son id)
        "messages": [ToolMessage(f"Langue réglée sur {langue}.", tool_call_id=tool_call_id)],
    })

L’outil ne se contente plus de répondre au modèle : il écrit dans l’état partagé. C’est le mécanisme qui permet à un agent d’avoir des outils « à effet de bord structurel » — changer de contexte, charger un dossier, basculer de mode — tout en restant dans le flux du graphe.

Renvoyer un artefact volumineux : content_and_artifact

Certains outils produisent à la fois un résumé pour le modèle et une donnée lourde (un DataFrame, des octets, une liste de documents) qu’il serait absurde — et coûteux en tokens — d’injecter dans le contexte. Le format content_and_artifact sépare les deux : le content part au modèle, l’artifact reste disponible côté programme.

@tool(response_format="content_and_artifact")
def rechercher_documents(requete: str) -> tuple[str, list[dict]]:
    """Recherche documentaire ; renvoie un résumé au modèle et les documents bruts."""
    docs = retriever.invoke(requete)
    resume = f"{len(docs)} documents trouvés (pertinence ≥ 0.8)."
    return resume, docs        # (content → modèle, artifact → ToolMessage.artifact)

Le modèle lit « 12 documents trouvés » et décide de la suite ; les documents complets sont accessibles via ToolMessage.artifact pour un nœud de rerank ou de citation, sans jamais polluer la fenêtre de contexte.

ToolNode en profondeur

ToolNode n’est pas une boîte noire. Trois leviers méritent d’être maîtrisés.

Gestion des erreurs

Par défaut, ToolNode intercepte les exceptions et renvoie leur message au modèle sous forme de ToolMessage avec status="error". On peut personnaliser ce comportement :

from langgraph.prebuilt import ToolNode

# 1) message d'erreur fixe et propre
ToolNode(outils, handle_tool_errors="L'outil a échoué, reformule ta demande.")

# 2) gestion sur-mesure selon le type d'exception
def gerer(err: Exception) -> str:
    if isinstance(err, TimeoutError):
        return "Service momentanément indisponible, réessaie plus tard."
    return f"Erreur : {err}"

ToolNode(outils, handle_tool_errors=gerer)

# 3) désactiver : laisser l'exception remonter (et planter le graphe)
ToolNode(outils, handle_tool_errors=False)

Exécution parallèle et clé de messages

Quand le modèle demande plusieurs outils dans un même tour, ToolNode les exécute en parallèle. Si votre état n’utilise pas la clé standard messages, indiquez la vôtre avec messages_key. ToolNode peut aussi être utilisé hors de la boucle prédéfinie, partout où vous voulez exécuter des tool_calls.

Valider et signaler les erreurs d’arguments

Quand le modèle fournit des arguments invalides, Pydantic lève une ValidationError. Plutôt que de planter, on renvoie l’erreur au modèle pour qu’il corrige — un aller-retour souvent suffisant :

from pydantic import ValidationError

@tool
def virer(montant: float, iban: str) -> str:
    """Effectue un virement. Le montant doit être positif et l'IBAN valide."""
    try:
        ordre = OrdreVirement(montant=montant, iban=iban)   # validation Pydantic
    except ValidationError as e:
        return f"Arguments invalides : {e.errors()[0]['msg']}. Corrige et réessaie."
    return banque.executer(ordre)

Contraindre l’appel d’outil

bind_tools offre un contrôle fin sur comment le modèle appelle les outils :

# Forcer un outil précis (ex. toujours chercher avant de répondre)
modele.bind_tools(outils, tool_choice="rechercher_documents")

# Forcer l'usage d'au moins un outil (le modèle choisit lequel)
modele.bind_tools(outils, tool_choice="any")

# Interdire le parallélisme (un seul outil par tour — utile si effets de bord)
modele.bind_tools(outils, parallel_tool_calls=False)

Streaming des appels d’outils

En streaming, les tool_calls arrivent par fragments (les arguments JSON se construisent token par token). On les accumule via tool_call_chunks :

premier = True
gather = None
for chunk in modele_outille.stream(messages):
    gather = chunk if premier else gather + chunk
    premier = False
    for tc in chunk.tool_call_chunks:
        print(tc["name"], tc["args"])   # arguments partiels, en construction
# gather.tool_calls contient désormais les appels complets et parsés

C’est ce qui permet d’afficher « l’agent recherche… » en temps réel, avant même que les arguments complets ne soient disponibles.

Concevoir des outils qui marchent : la checklist

Tester ses outils, isolément

Un outil reste une fonction : testez-la sans LLM. Et testez le schéma que voit le modèle, car c’est lui qui détermine le comportement de l’agent :

# Tester la logique
assert creer_ticket.invoke({"sujet": "X", "description": "Y", "priorite": "haute"})

# Inspecter le schéma exposé au modèle
print(creer_ticket.args_schema.model_json_schema())   # le contrat réel

Bonnes pratiques & pièges

La synthèse opérationnelle de ce chapitre — à garder sous les yeux quand vous concevez la couche d’outils d’un agent destiné à la production :

Bonnes pratiques
  • Un outil = une intention atomique, nommée par ce qu'elle fait.
  • Injectez identité, droits et secrets via la config — jamais en argument visible du modèle.
  • Décrivez chaque champ (Field description) et contraignez les valeurs (Enum, Literal, strict=True).
  • Rendez les outils idempotents : un retry ou une reprise ne doit pas doubler un effet.
  • Renvoyez les erreurs comme messages exploitables, ET loggez l'erreur réelle dans LangFuse.
  • Désactivez parallel_tool_calls pour les outils à effet de bord (écritures concurrentes).
Pièges à éviter
  • Exposer le user_id en argument : le modèle — ou une injection de prompt — peut le falsifier.
  • Un outil fourre-tout gerer_compte(action=...) : le modèle se trompe régulièrement d'action.
  • Oublier le ToolMessage quand l'outil renvoie un Command : le tour suivant est rejeté par l'API.
  • Avaler les erreurs en silence : l'agent boucle indéfiniment sur un outil cassé.
  • Empiler 30 outils sur un seul agent : il confond et invente des appels.
  • Mettre une donnée volumineuse dans le content plutôt qu'en artifact : explosion de tokens.

Ce qu’il faut retenir

  • Le modèle ne voit que le schéma ; servez-vous-en pour injecter état, config et tool_call_id hors de sa portée.
  • Pydantic (Enum, Field, strict=True) réduit drastiquement les arguments invalides.
  • Un outil peut modifier le graphe via Command(update=..., messages=[...]).
  • content_and_artifact sépare le résumé (au modèle) de la donnée lourde (au programme).
  • ToolNode se règle finement : handle_tool_errors, parallélisme, messages_key.
  • Sécurité : identité injectée, idempotence, et HITL pour l’irréversible.

Des outils robustes sont la moitié d’un bon agent. L’autre moitié, c’est la façon dont on orchestre plusieurs étapes et plusieurs agents — l’objet du deep-dive sur les patterns d’orchestration.

#langgraph#tool-calling#pydantic#command#production