call_llm et les schémas d'outils
Le principe : un seul point de contact avec le fournisseur
Tout le reste de l'agent ne doit connaître qu'une fonction : call_llm(messages, tools). Si demain vous changez de modèle ou de fournisseur, vous ne modifiez qu'elle.
flowchart LR B["Boucle de l'agent"] --> C["call_llm(messages, tools)"] C --> G["Gemini (endpoint compatible OpenAI)"] C -.-> O["OpenAI"] C -.-> L["Modèle local (Ollama, vLLM...)"]
Nous utilisons le format chat completions popularisé par OpenAI, parce que beaucoup de fournisseurs l'acceptent : Gemini propose un endpoint compatible, de même que la plupart des serveurs de modèles locaux. On le parle avec la bibliothèque standard (urllib), donc zéro dépendance.
import json
import os
import urllib.request
BASE_URL = os.environ.get(
"LLM_BASE_URL", "https://generativelanguage.googleapis.com/v1beta/openai")
MODEL = os.environ.get("LLM_MODEL", "gemini-2.5-flash")
def call_llm(messages, tools):
"""Envoie la conversation + les outils, renvoie le message de l'assistant."""
payload = {"model": MODEL, "messages": messages}
if tools: # certains fournisseurs refusent "tools": []
payload["tools"] = tools
body = json.dumps(payload)
req = urllib.request.Request(
f"{BASE_URL}/chat/completions",
data=body.encode(),
headers={"Content-Type": "application/json",
"Authorization": f"Bearer {os.environ['LLM_API_KEY']}"},
)
with urllib.request.urlopen(req, timeout=60) as resp:
return json.load(resp)["choices"][0]["message"]Configuration, uniquement par variables d'environnement :
export LLM_API_KEY="votre-cle" # jamais dans le code, jamais dans git
# Optionnel, pour changer de fournisseur :
# export LLM_BASE_URL="https://api.openai.com/v1"
# export LLM_MODEL="nom-du-modele"Les outils : du Python ordinaire
Un outil est une fonction normale. Ici, le catalogue et le stock sont de simples dictionnaires pour que tout tienne dans un fichier ; en vrai, ce serait une base de données ou une API.
CATALOGUE = {"P1": ("Café en grains 1 kg", 18.0), "P2": ("Thé vert 200 g", 9.5)}
STOCK = {"P1": 12, "P2": 0}
def chercher_produit(texte: str) -> list:
t = texte.lower()
return [{"ref": r, "nom": n, "prix": p}
for r, (n, p) in CATALOGUE.items() if t in n.lower()]
def consulter_stock(ref: str) -> dict:
if ref not in STOCK:
raise ValueError(f"Référence inconnue : {ref}")
return {"ref": ref, "quantite": STOCK[ref]}Les schémas : ce que le modèle voit
Le modèle ne lit pas votre code. Il lit uniquement le nom, la description et le schéma des paramètres. Puis on garde une table nom → fonction pour exécuter ce qu'il demande.
TOOLS_PY = {"chercher_produit": chercher_produit,
"consulter_stock": consulter_stock}
TOOLS = [
{"type": "function", "function": {
"name": "chercher_produit",
"description": "Cherche des produits du catalogue par mot-clé.",
"parameters": {"type": "object",
"properties": {"texte": {"type": "string"}},
"required": ["texte"]}}},
{"type": "function", "function": {
"name": "consulter_stock",
"description": "Donne la quantité en stock d'une référence (ex. P1).",
"parameters": {"type": "object",
"properties": {"ref": {"type": "string"}},
"required": ["ref"]}}},
]| Élément du schéma | Rôle | Bonne pratique |
|---|---|---|
name | identifiant que le modèle renverra | verbe + objet, en snake_case : consulter_stock |
description | le prompt de l'outil | dire quand l'utiliser, donner un exemple de valeur |
properties | paramètres et leurs types | types simples (string, integer, boolean), enum quand la liste est fermée |
required | paramètres obligatoires | tout ce sans quoi la fonction ne marche pas |
Vous renommez le paramètre Python ref en reference dans consulter_stock, sans toucher au schéma. Que se passe-t-il ?
Le modèle ne voit que le schéma, qui annonce toujours ref. Votre code appelle donc la fonction avec un argument nommé qu'elle n'accepte plus : TypeError. Le schéma reste valide pour le fournisseur. Leçon : schéma et signature doivent évoluer ensemble.
Quelle description d'outil aidera le plus le modèle à bien l'utiliser ?
La troisième dit ce que fait l'outil, le format attendu (exemple P1) et quand l'appeler. La première est trop vague ; la deuxième décrit l'implémentation, qui n'aide en rien le modèle à décider.
Exercice : ajouter un outil prix_total
Écrivez la fonction prix_total(ref: str, quantite: int) -> dict qui renvoie {"ref", "quantite", "total"} à partir du catalogue, puis son schéma, et ajoutez-la à TOOLS_PY et TOOLS. La quantité doit être un entier strictement positif.
Voir la solution
def prix_total(ref: str, quantite: int) -> dict:
if ref not in CATALOGUE:
raise ValueError(f"Référence inconnue : {ref}")
if quantite < 1:
raise ValueError("La quantité doit être au moins 1")
return {"ref": ref, "quantite": quantite,
"total": round(CATALOGUE[ref][1] * quantite, 2)}
TOOLS_PY["prix_total"] = prix_total
TOOLS.append({"type": "function", "function": {
"name": "prix_total",
"description": "Calcule le prix total en euros pour une référence et une quantité. "
"Toujours l'utiliser plutôt que de calculer soi-même.",
"parameters": {"type": "object",
"properties": {"ref": {"type": "string"},
"quantite": {"type": "integer", "minimum": 1}},
"required": ["ref", "quantite"]}}})Deux points importants :
- la validation (
quantite < 1) est dans le code, même si le schéma ditminimum: 1: le schéma guide, le code garantit ; - la description dit explicitement de ne pas calculer soi-même. Un calcul fait par un LLM peut être faux ; un calcul fait par Python ne l'est pas.
Je sais faire
- Écrire une fonction
call_llmqui lit sa clé dans une variable d'environnement - Changer de fournisseur sans toucher au reste du code
- Décrire un outil en JSON Schema avec une description utile
- Valider les arguments dans le code, pas seulement dans le schéma