12 : Construire des outils pour votre agent
Dans la Partie IV, nous allons construire un agent — une IA qui détermine ce qu'il faut faire avec la demande d'un utilisateur, puis le fait, au lieu de simplement y répondre comme un chatbot.
Voici la différence en action. Imaginez qu'un utilisateur demande : « Veuillez annuler la commande n°12345. » Un chatbot dirait quelque chose comme : « Allez dans Mon compte > Historique des commandes et cliquez sur le bouton "Annuler" pour cette commande. » À partir de là, c'est à l'utilisateur de suivre ces étapes lui-même. Un agent fait le travail à la place — il recherche la commande, vérifie si elle est éligible à l'annulation, et l'annule. Il agit.
Ce qui rend cela possible, ce sont les outils : un outil qui recherche une commande, un outil qui en annule une, un outil qui envoie un e-mail. Avec des outils, un LLM cesse d'être limité à la génération de texte et commence à réellement accomplir des choses.
Nous allons construire cela pièce par pièce tout au long de la Partie IV : d'abord les outils que l'agent utilisera (ce chapitre), puis le raccordement de ces outils à un LLM (Chapitre 13), et enfin la boucle de l'agent qui parcourt le cycle décider → agir → observer (Chapitre 14).
Ce chapitre couvre la première pièce — définir des outils, les connecter à des données réelles et gérer les erreurs en toute sécurité.
12.1) Définir des outils avec le décorateur @tool
12.1.1) Comment fonctionne un outil ?
Nous venons de voir un agent rechercher et annuler une commande à l'aide d'outils. Avant d'aller plus loin, voici une chose qu'il vaut la peine de clarifier : quand on dit « le LLM utilise un outil », on a l'impression que c'est le LLM qui l'appelle directement. Ce n'est pas le cas. Le LLM n'exécute jamais rien lui-même — tout ce qu'il fait, c'est demander qu'un outil soit appelé avec certains arguments. L'exécution réelle se produit dans notre code.
Pour que cela fonctionne, le LLM doit savoir quels outils existent et quand chacun s'applique. Ainsi, chaque outil est accompagné de trois éléments de métadonnées :
name— un identifiant court commeget_orderque le LLM utilise pour spécifier l'outil qu'il souhaite.description— une phrase décrivant ce que fait l'outil et quand l'utiliser. C'est ce que le LLM lit pour choisir le bon outil pour la tâche.- Schéma d'entrée — quels sont les paramètres de l'outil : leurs noms, types et signification. Le LLM en a besoin pour remplir correctement les arguments.
Rien de tout cela ne demande de travail supplémentaire de votre part. name provient directement du nom de la fonction, description provient de sa docstring, et le schéma d'entrée provient des indications de type des paramètres. Tout ce que vous avez à faire est d'attacher le décorateur @tool de LangChain.
12.1.2) Construire votre premier outil
Mettons cela en pratique. Écrivez une fonction avec des indications de type et une docstring, puis décorez-la avec @tool.
from langchain.tools import tool
@tool
def get_weather(city: str) -> str:
"""Obtient la météo actuelle pour une ville donnée."""
return f"Il fait toujours beau à {city} !"Vérifions ce que @tool a généré pour nous.
print(get_weather.name)
# Sortie : get_weather
print(get_weather.description)
# Sortie : Obtient la météo actuelle pour une ville donnée.
print(get_weather.args)
# Sortie : {'city': {'title': 'City', 'type': 'string'}}Le nom de la fonction get_weather est devenu son name, la docstring est devenue sa description, et l'indication de type city: str est devenue son schéma d'entrée. Les trois éléments de métadonnées de la section précédente ont été générés automatiquement. C'est ce que le LLM utilise pour choisir un outil et remplir ses arguments.
Une fois @tool attaché, la fonction devient un objet outil LangChain, ce qui signifie que vous ne pouvez plus l'appeler comme une fonction normale — get_weather("Paris") ne fonctionnera pas. Vous l'appelez avec .invoke() à la place, la même méthode d'exécution standard que nous avons utilisée pour les chaînes au Chapitre 6. Les arguments sont passés sous forme de dictionnaire :
result = get_weather.invoke({"city": "Paris"})
print(result)
# Sortie : Il fait toujours beau à Paris !12.1.3) Personnaliser name et description
Par défaut, name provient du nom de la fonction et description de la docstring. Vous pouvez remplacer les deux.
Passez un nom comme premier argument à @tool :
@tool("web_search")
def search(query: str) -> str:
"""Recherche des informations sur le web."""
return f"Résultats pour : {query}"
print(search.name)
# Sortie : web_searchVous pouvez également remplacer la description, en utilisant le paramètre description. C'est utile lorsque vous souhaitez conserver la docstring comme note pour les autres développeurs tout en donnant au LLM quelque chose de plus adapté :
@tool("calculator", description="Effectue des opérations arithmétiques. Utilisez ceci pour tout problème mathématique.")
def calc(expression: str) -> str:
"""Évalue une chaîne d'expression mathématique."""
return str(eval(expression)) # ATTENTION : eval() n'est pas sûr. Ne l'utilisez jamais en production.Tenez-vous-en au snake_case pour les noms d'outils — certains fournisseurs de LLM rejettent les noms contenant des espaces ou des caractères spéciaux.
12.1.4) Définir un schéma d'entrée avec Pydantic
Lorsqu'un outil prend plusieurs paramètres, ou que vous souhaitez décrire chacun individuellement, définissez plutôt le schéma d'entrée avec un modèle Pydantic. Ce sont les mêmes BaseModel et Field que nous avons utilisés pour la sortie structurée au Chapitre 7.
from pydantic import BaseModel, Field
from langchain.tools import tool
class WeatherInput(BaseModel):
"""Entrée pour les requêtes météo."""
location: str = Field(description="Nom de la ville (ex. : Séoul, Tokyo)")
units: str = Field(default="celsius", description="Unité de température (celsius ou fahrenheit)")
@tool(args_schema=WeatherInput)
def get_weather_detailed(location: str, units: str = "celsius") -> str:
"""Obtient la météo actuelle avec une unité de température choisie."""
temp = 22 if units == "celsius" else 72
return f"Météo actuelle à {location} : {temp} degrés {units[0].upper()}"Tout ce que vous écrivez dans Field(description=...) devient une partie du schéma d'entrée que le LLM lit, de sorte qu'il sait exactement ce que signifie chaque paramètre. La plupart du temps, les indications de type et une docstring claire sont tout ce dont vous avez besoin — n'utilisez args_schema que lorsque vous avez besoin de ce niveau de détail supplémentaire par paramètre.
12.2) Gérer les erreurs des outils
Dans le monde réel, les outils peuvent échouer — une connexion à une base de données est interrompue, ou une entrée que vous n'aviez pas prévue se présente. Dans cette section, nous allons gérer ces erreurs à l'intérieur de l'outil lui-même, afin que l'agent puisse réagir de manière sensée au lieu de s'arrêter net. Tout d'abord, configurons les fonctions sur lesquelles nos outils s'appuieront.
12.2.1) Mise en place : fonctions de recherche de produit
# product_service.py
PRODUCTS = {
1: {"name": "Wireless Mouse", "price": 29.99, "stock": 120},
2: {"name": "Mechanical Keyboard", "price": 89.99, "stock": 0},
3: {"name": "USB-C Hub", "price": 45.50, "stock": 35},
4: {"name": "Laptop Stand", "price": 39.00, "stock": 8},
}
def fetch_product(product_id: int) -> dict:
"""Recherche les informations d'un produit par son ID."""
product = PRODUCTS.get(product_id)
if product is None:
raise ValueError(f"Product with ID {product_id} not found.")
return product
def fetch_stock(product_id: int) -> int:
"""Renvoie la quantité en stock pour un produit."""
product = PRODUCTS.get(product_id)
if product is None:
raise ValueError(f"Product with ID {product_id} not found.")
return product["stock"]Les deux fonctions lèvent une ValueError lorsqu'on leur donne un ID de produit qui n'existe pas.
12.2.2) Gérer les erreurs dans un outil
Encapsulons fetch_product dans un outil get_product.
from langchain.tools import tool
from product_service import fetch_product
@tool
def get_product(product_id: int) -> str:
"""Recherche un produit par son ID. Renvoie son nom, son prix et son niveau de stock."""
product = fetch_product(product_id)
return f"Product {product_id}: {product['name']} — ${product['price']:.2f}, {product['stock']} in stock."Avec un ID valide, cela fonctionne comme prévu.
print(get_product.invoke({"product_id": 1}))
# Sortie : Product 1: Wireless Mouse — $29.99, 120 in stock.Mais passez un ID qui n'existe pas, et fetch_product lève une ValueError que rien n'attrape — l'exécution de l'agent s'arrête net.
print(get_product.invoke({"product_id": 99}))
# ValueError: Product with ID 99 not found.La correction est simple : attrapez l'exception à l'intérieur de l'outil et renvoyez une chaîne que le LLM peut comprendre, au lieu de la laisser se propager. Succès ou échec, l'outil renvoie toujours une chaîne, et le LLM utilise cette chaîne pour décider quoi faire ensuite.
from langchain.tools import tool
from product_service import fetch_product
@tool
def get_product(product_id: int) -> str:
"""Recherche un produit par son ID. Renvoie son nom, son prix et son niveau de stock."""
try:
product = fetch_product(product_id)
return f"Product {product_id}: {product['name']} — ${product['price']:.2f}, {product['stock']} in stock."
except ValueError as e:
return f"Error: {e}"
except Exception as e:
return f"Unexpected error looking up product {product_id}: {e}"print(get_product.invoke({"product_id": 1}))
# Sortie : Product 1: Wireless Mouse — $29.99, 120 in stock.
print(get_product.invoke({"product_id": 99}))
# Sortie : Error: Product with ID 99 not found.Un ID inexistant ne lève plus d'exception — il renvoie à la place un message d'erreur que le LLM peut comprendre.
Appliquons le même modèle à check_stock :
from product_service import fetch_stock
@tool
def check_stock(product_id: int) -> str:
"""Vérifie si un produit est actuellement en stock."""
try:
stock = fetch_stock(product_id)
if stock > 0:
return f"{stock} units available."
return "Out of stock."
except ValueError as e:
return f"Error: {e}"
except Exception as e:
return f"Unexpected error checking stock for product {product_id}: {e}"Les outils construits de cette manière peuvent être testés directement avec .invoke(). Assurez-vous qu'un outil fonctionne correctement de manière autonome avant de le raccorder à un LLM. Sinon, lorsque quelque chose tournera mal à l'intérieur de l'agent plus tard, vous ne pourrez pas dire si l'outil est cassé ou si le modèle a simplement fait un mauvais appel.