Aller au contenu principal

Module 7 — Outils et appels de fonctions

Notre assistant lit, répond, et se souvient. Il ne fait rien encore. Ce module lui donne des mains : convertir un montant en devise étrangère, calculer un plafond selon la ville, ajouter une ligne dans le tableur d'équipe. Chacune de ces actions est une fonction Python que l'on déclare au modèle sous forme d'outil ; le modèle, quand la question l'exige, retourne le nom de l'outil et ses arguments, que la chaîne exécute et injecte dans la réponse.

Un outil est une fonction annotée

La façon la plus simple : le décorateur @tool sur une fonction typée.

from langchain_core.tools import tool

@tool
def convertir_en_eur(montant: float, devise_source: str) -> float:
"""Convertit un montant depuis une devise étrangère vers l'euro.

Args:
montant: Le montant à convertir, dans la devise source.
devise_source: Le code ISO de la devise (USD, GBP, CHF, ...).

Returns:
Le montant équivalent en euros, arrondi à 2 décimales.
"""
taux = {"USD": 0.92, "GBP": 1.17, "CHF": 1.05, "JPY": 0.0061}
return round(montant * taux[devise_source], 2)

Trois éléments composent la déclaration d'un outil, tous exploités par le modèle :

  • La signature typée décrit les arguments attendus (montant: float, devise_source: str) et leur type.
  • La chaîne de documentation est ce que le modèle lit pour choisir l'outil et remplir ses arguments.
  • Le nom de la fonction devient le nom de l'outil (convertir_en_eur).

Le décorateur @tool inspecte la fonction et produit un StructuredTool compatible avec l'appel d'outil natif de tous les fournisseurs modernes.

Argumentation stricte avec pydantic

Pour un outil critique où un type approximatif casse tout (une date au mauvais format, un négatif interdit), on décrit l'entrée par un modèle pydantic :

from pydantic import BaseModel, Field
from datetime import date

class ArgumentsAjoutLigne(BaseModel):
montant_eur: float = Field(..., gt=0, description="Montant TTC en euros, strictement positif")
categorie: str = Field(..., description="restauration, transport, hebergement, fournitures")
date_depense: date = Field(..., description="Date de la dépense (AAAA-MM-JJ)")
justificatif: str = Field(..., description="Nom du fichier justificatif")

@tool(args_schema=ArgumentsAjoutLigne)
def ajouter_ligne_tableur(montant_eur: float, categorie: str, date_depense: date, justificatif: str) -> str:
"""Ajoute une ligne dans le tableur d'équipe et renvoie l'identifiant."""
ligne_id = _tableur_client.append({
"montant_eur": montant_eur, "categorie": categorie,
"date_depense": date_depense.isoformat(), "justificatif": justificatif,
})
return f"Ligne #{ligne_id} ajoutée."

Le contrat est vérifié avant l'exécution : un montant_eur=-30 ou une date_depense="hier" déclenche une ValidationError propre. C'est cette validation qui distingue un outil de production d'un simple prompt qui espère.

Brancher les outils sur le modèle

bind_tools(...) dit au modèle quels outils il peut appeler :

from langchain_openai import ChatOpenAI

outils = [convertir_en_eur, ajouter_ligne_tableur]
modele_outille = ChatOpenAI(model="gpt-4o-mini", temperature=0).bind_tools(outils)

reponse = modele_outille.invoke(
"J'ai payé 45 USD au restaurant hier. Convertis en euros puis ajoute la ligne du 5 mars 2026, justificatif ticket-45.pdf."
)
print(reponse.tool_calls)

Le modèle ne répond pas en texte : il retourne un AIMessage dont .tool_calls est une liste de dictionnaires {"name": ..., "args": ..., "id": ...}. C'est à la chaîne de les exécuter et de renvoyer le résultat.

La boucle appel/résultat

Un patron manuel pour comprendre ce qui se joue :

from langchain_core.messages import ToolMessage

messages = [HumanMessage(content="Convertis 45 USD en EUR puis ajoute la ligne.")]
reponse = modele_outille.invoke(messages)
messages.append(reponse)

for appel in reponse.tool_calls:
fonction = {"convertir_en_eur": convertir_en_eur, "ajouter_ligne_tableur": ajouter_ligne_tableur}[appel["name"]]
resultat = fonction.invoke(appel["args"])
messages.append(ToolMessage(content=str(resultat), tool_call_id=appel["id"]))

reponse_finale = modele_outille.invoke(messages)
print(reponse_finale.content)

Trois messages naissent à chaque appel d'outil : la demande de l'utilisateur, la réponse du modèle contenant tool_calls, et un ToolMessage par résultat, corrélé par tool_call_id. Le second invoke reçoit la trace complète et rédige la réponse finale en langage naturel.

Le module 8 remplace cette boucle à la main par un agent qui la fait tourner automatiquement, mais il faut avoir vu la mécanique avant de la déléguer.

Écrire un bon outil

Un outil bien écrit est reconnaissable :

  • Une phrase de doc claire, à la première ligne. Le modèle lit d'abord la description pour choisir.
  • Un objectif atomique. convertir_puis_ajouter est deux outils, pas un.
  • Des types spécifiques. date plutôt que str, Literal["USD", "GBP", "CHF"] plutôt que str. Le modèle produit alors des arguments valides du premier coup.
  • Un retour scalaire ou une chaîne courte. Si l'outil renvoie 5 000 lignes, la réponse gaspille des jetons. Résumer ou paginer côté outil.
  • Une gestion d'erreur explicite. Un raise ValueError("devise inconnue") remonte dans le ToolMessage : le modèle peut alors s'excuser et redemander plutôt que de mentir.

Le piège de l'argument invalide

Un modèle propose parfois un argument que le schéma refuse : devise_source="EURO" au lieu de "EUR", montant="45.30 USD" au lieu d'un nombre. Trois attitudes possibles :

  • Rejeter et remonter l'erreur au modèle (défaut de LangChain) : le modèle apprend et retente. Simple mais coûte un aller-retour.
  • Nettoyer côté outil (accepter "EURO" et le convertir en "EUR") : rapide mais dilue le contrat.
  • Interdire strictement avec un Literal : le modèle ne propose plus que les valeurs autorisées.

La bonne combinaison : Literal pour les énumérations, pydantic pour les bornes numériques, remontée de l'erreur pour les cas rares.

Ne jamais exécuter d'entrée libre

Un outil qui prend une chaîne et l'exécute (exec, os.system, sqlalchemy.text) est une injection déguisée. Le modèle, même bienveillant, peut être conduit par une consigne malveillante à produire une chaîne dangereuse. Tout outil qui touche un système reçoit des arguments fortement typés, jamais du texte libre.

En résumé

  • Un outil est une fonction Python typée décorée par @tool ; sa signature et sa docstring sont ce que le modèle utilise pour choisir et remplir les arguments.
  • bind_tools([...]) donne au modèle la liste des outils disponibles ; il répond alors avec des tool_calls que la chaîne exécute et renvoie sous forme de ToolMessage.
  • Un args_schema pydantic (avec Literal, bornes, dates typées) est la meilleure défense contre les arguments invalides.
  • Un outil qui touche un système ne prend jamais une chaîne libre ; toute action externe passe par un contrat typé validé avant exécution.

Module suivant : automatiser la boucle appel/résultat avec un vrai agent, et donner au flux un contrôle explicite via LangGraph.