Aller au contenu
Développement medium

Tool Calling avec Ollama : créer un agent IA local en Python

17 min de lecture

Imaginez un assistant qui répond à « Quelle heure est-il à Tokyo et combien font 234 × 17 ? » : pour la première moitié il consulte une API horloge, pour la seconde il fait du calcul. Un LLM seul ne peut faire ni l'un ni l'autre de manière fiable. Le tool calling (ou function calling) lui donne le droit de demander l'appel de fonctions Python que vous exécutez, puis lui réinjectez le résultat. C'est le mécanisme qui transforme un chatbot en agent autonome, et qui marche entièrement en local avec Ollama en 2026.

  • Ce qu'est le tool calling et comment il diffère des sorties structurées
  • Connecter un premier outil Python à un LLM Ollama
  • Gérer plusieurs outils et laisser le LLM choisir
  • Construire une boucle agentique qui chaîne plusieurs appels d'outils dans une même requête utilisateur

Tool calling vs Structured Outputs : ne pas confondre

Section intitulée « Tool calling vs Structured Outputs : ne pas confondre »
Structured OutputsTool Calling
ButForcer le format de la sortieDemander au LLM d'invoquer une fonction
Le modèle produit…Du JSON conforme à un schémaUne intention d'appel : nom de fonction + arguments
Qui exécute ?Personne — c'est la réponse finaleVous (le code Python) exécutez la fonction
Cas d'usageExtraction d'entités, classificationAgents, recherche, intégrations API

→ Si vous n'avez pas lu, commencez par Sorties structurées avec Ollama pour le contraste.

Quand on dit que « le LLM appelle un outil », c'est un raccourci. En réalité, le code Python orchestre tout. Voici le vrai cycle :

  1. Vous envoyez au LLM la question utilisateur + la liste des outils disponibles (sous forme de schéma JSON).
  2. Le LLM décide s'il a besoin d'un outil. Si oui, il renvoie un objet tool_calls avec le nom et les arguments. Il n'exécute rien.
  3. Votre code exécute la fonction Python correspondante.
  4. Vous renvoyez au LLM la même conversation + un nouveau message de rôle tool contenant le résultat.
  5. Le LLM formule une réponse finale en langage naturel pour l'utilisateur, en s'appuyant sur ce résultat.

C'est vous qui orchestrez. Le LLM n'a aucun accès au système.

Fenêtre de terminal
# Modèle recommandé en 2026 (le plus stable sur tool calling)
ollama pull qwen3
# Dépendances Python
uv add "ollama==0.6.2"

On va donner au LLM un seul outil : une fonction get_weather(city) qui retourne une météo fictive. Le but est de voir le flow complet bout en bout.

01-single-tool.py
from ollama import Client
MODEL = "qwen3"
HOST = "http://localhost:11434"
# 1. La fonction Python qui sera appelée
def get_weather(city: str) -> dict:
"""Retourne une météo factice pour une ville française."""
db = {
"Paris": {"temp_c": 14, "condition": "Nuageux", "humidite": 78},
"Marseille": {"temp_c": 22, "condition": "Ensoleillé", "humidite": 45},
}
return db.get(city, {"error": f"Ville inconnue : {city}"})
# 2. Le schéma de l'outil (JSON Schema standard)
TOOLS = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Obtenir la météo actuelle d'une ville française.",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "Nom de la ville."},
},
"required": ["city"],
},
},
}
]
client = Client(host=HOST)
messages = [
{"role": "user", "content": "Quel temps fait-il à Marseille ?"},
]
# Tour 1 : on envoie la question + les outils
response = client.chat(model=MODEL, messages=messages, tools=TOOLS, options={"temperature": 0})
msg = response["message"]
print("Le LLM a-t-il décidé d'appeler un outil ?", bool(msg.get("tool_calls")))
# Tour 2 : on exécute l'outil et on lui renvoie le résultat
messages.append(msg)
for call in msg["tool_calls"]:
if call["function"]["name"] == "get_weather":
args = call["function"]["arguments"]
result = get_weather(args["city"])
messages.append({"role": "tool", "content": str(result), "name": "get_weather"})
# Tour 3 : le LLM formule la réponse finale pour l'utilisateur
final = client.chat(model=MODEL, messages=messages, options={"temperature": 0})
print(final["message"]["content"])

Sortie réelle testée sur H100 avec qwen3 :

Le LLM a-t-il décidé d'appeler un outil ? True
get_weather({'city': 'Marseille'}) → {'temp_c': 22, 'condition': 'Ensoleillé', 'humidite': 45}
Il fait actuellement 22°C à Marseille avec un ciel ensoleillé. L'humidité est à 45%. 🌞

Dans la vraie vie, vous donnez plusieurs outils au LLM et il choisit le bon selon la question. La logique est la même, on enrichit juste la liste TOOLS et on ajoute un dispatcher.

02-multi-tools.py
def calculer(expression: str) -> str:
"""Évalue une expression mathématique simple."""
allowed = set("0123456789+-*/. ()")
if not all(c in allowed for c in expression):
return "Expression non autorisée."
return str(eval(expression, {"__builtins__": {}}, {}))
def recherche_doc(query: str) -> str:
"""Retourne un extrait fictif de doc interne."""
db = {
"ollama": "Ollama est un runtime local pour LLM open-source.",
"h100": "NVIDIA H100 : 80 Go HBM3, idéal pour LLM jusqu'à 70B paramètres.",
}
for key, val in db.items():
if key in query.lower():
return val
return "Aucun résultat."
TOOLS = [
{"type": "function", "function": {
"name": "calculer",
"description": "Calculer une expression mathématique.",
"parameters": {"type": "object", "properties": {"expression": {"type": "string"}}, "required": ["expression"]},
}},
{"type": "function", "function": {
"name": "recherche_doc",
"description": "Rechercher dans la doc interne (Ollama, H100).",
"parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]},
}},
]
DISPATCH = {"calculer": calculer, "recherche_doc": recherche_doc}
# Le LLM choisit lequel utiliser
client = Client(host="http://localhost:11434")
def ask(question: str) -> None:
messages = [{"role": "user", "content": question}]
response = client.chat(model="qwen3", messages=messages, tools=TOOLS, options={"temperature": 0})
msg = response["message"]
if not msg.get("tool_calls"):
print("", msg.get("content", ""))
return
messages.append(msg)
for call in msg["tool_calls"]:
name = call["function"]["name"]
result = DISPATCH[name](**call["function"]["arguments"])
print(f" Tool: {name}{result}")
messages.append({"role": "tool", "content": str(result), "name": name})
final = client.chat(model="qwen3", messages=messages, options={"temperature": 0})
print("", final["message"]["content"])
ask("Combien font 234 * 17 ?")
ask("C'est quoi le H100 ?")

Sortie réelle :

Tool: calculer → 3978
→ 234 multiplié par 17 égale **3 978**.
Tool: recherche_doc → NVIDIA H100 : 80 Go HBM3, idéal pour servir des LLM jusqu'à 70B paramètres.
→ Le NVIDIA H100 est une carte graphique de la série Hopper, dotée de 80 Go de mémoire HBM3...

Le vrai pouvoir du tool calling, c'est de chaîner plusieurs outils dans une même requête utilisateur. Exemple : « Quel est le réalisateur d'Inception et combien de temps dure le film en heures et minutes ? ». Le LLM doit :

  1. Appeler chercher_film(titre="Inception") → durée en minutes
  2. Appeler convertir_minutes(minutes=148) → format « 2h28 »
  3. Formuler la réponse finale en combinant les deux résultats

Il faut une boucle qui continue tant que le LLM appelle des outils. Avec un garde-fou MAX_TURNS pour éviter qu'il tourne en rond.

03-agent-loop.py
from ollama import Client
MAX_TURNS = 5 # Garde-fou : interdit plus de 5 tours pour 1 question utilisateur
def chercher_film(titre: str) -> str:
db = {"Inception": {"realisateur": "Christopher Nolan", "annee": 2010, "duree_min": 148}}
return str(db.get(titre, {"error": f"Film inconnu : {titre}"}))
def convertir_minutes(minutes: int) -> str:
h, m = divmod(minutes, 60)
return f"{h}h{m:02d}"
TOOLS = [
{"type": "function", "function": {
"name": "chercher_film",
"description": "Métadonnées d'un film par titre.",
"parameters": {"type": "object", "properties": {"titre": {"type": "string"}}, "required": ["titre"]},
}},
{"type": "function", "function": {
"name": "convertir_minutes",
"description": "Convertir des minutes en heures+minutes.",
"parameters": {"type": "object", "properties": {"minutes": {"type": "integer"}}, "required": ["minutes"]},
}},
]
DISPATCH = {"chercher_film": chercher_film, "convertir_minutes": convertir_minutes}
client = Client(host="http://localhost:11434")
messages = [{"role": "user", "content": "Quel est le réalisateur d'Inception et combien dure le film en heures ?"}]
for turn in range(MAX_TURNS):
response = client.chat(model="qwen3", messages=messages, tools=TOOLS, options={"temperature": 0})
msg = response["message"]
if not msg.get("tool_calls"):
print(f"[Tour {turn+1}] Réponse finale : {msg['content']}")
break
messages.append(msg)
for call in msg["tool_calls"]:
name = call["function"]["name"]
result = DISPATCH[name](**call["function"]["arguments"])
print(f"[Tour {turn+1}] {name}{result}")
messages.append({"role": "tool", "content": str(result), "name": name})
else:
print(f"⚠️ Boucle interrompue après {MAX_TURNS} tours.")

Sortie réelle sur H100 avec qwen3 :

[Tour 1] chercher_film → {'realisateur': 'Christopher Nolan', 'annee': 2010, 'duree_min': 148}
[Tour 2] convertir_minutes → 2h28
[Tour 3] Réponse finale : Le film Inception a été réalisé par Christopher Nolan et dure 2h28.

Le LLM a bien chaîné les deux outils, sans intervention, et formulé une réponse synthétique.

Mesures réelles sur NVIDIA H100 avec Ollama 0.24 :

ModèleVRAMFiabilité tool callingHallucinations d'arguments
qwen35.2 GoExcellenteTrès rares
gemma49.6 GoExcellente (function calling natif entraîné)Rares
llama3.14.7 GoBonneOccasionnelles
mistral4.4 GoMoyenneFréquentes

Mon choix par défaut : qwen3. C'est le modèle qui hallucine le moins les arguments sur les tests intensifs 2026.

Le tool calling natif d'Ollama est parfait pour commencer. Pour des agents complexes (mémoire, multi-étapes, fallback, observabilité), passer à un framework :

  • PydanticAI : agents typés avec validation Pydantic à chaque étape
  • LangGraph : graphe d'états, agents avec mémoire persistante
  • smolagents : agents qui écrivent du code Python pour résoudre

Q : Mon modèle ne renvoie jamais de tool_calls, il répond en texte libre. R : Trois causes possibles : (1) le modèle ne supporte pas le tool calling — testez qwen3 ; (2) Ollama serveur trop ancien — passez en 0.5+ ; (3) la description des outils est trop vague pour le modèle.

Q : Les arguments sont mal typés (ex: "148" au lieu de 148). R : Vérifiez le type dans le schéma JSON. Si vous mettez "integer", Ollama force le type côté serveur. Sinon le modèle peut renvoyer une string.

Q : Comment gérer les erreurs d'exécution des outils ? R : Toujours encapsuler DISPATCH[name](**args) dans un try/except. En cas d'erreur, renvoyer au LLM un message du type {"error": "...", "details": "..."} — il saura formuler une réponse polie à l'utilisateur.

Q : Peut-on faire du tool calling en streaming ? R : Ollama supporte le streaming sur la réponse finale, mais pas pendant l'appel d'outil. Le tool_call arrive en un seul bloc.

  • Tool calling = LLM décide quoi appeler, vous exécutez, vous renvoyez le résultat, LLM répond. Cinq étapes claires.
  • Le LLM n'a aucun accès au système — il propose, vous disposez.
  • Boucle agentique = répéter le cycle jusqu'à ce que le LLM ne propose plus d'outil, avec un MAX_TURNS.
  • qwen3 est le modèle 2026 le plus fiable sur ce cas d'usage.
  • Validez en aval : un résultat d'outil correct ne garantit pas une réponse finale correcte du LLM.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracing. Aujourd'hui, ce site ne couvre même pas mes frais d'hébergement, d'électricité, de matériel, de logiciels, mais surtout de cafés.

Un soutien régulier, même symbolique, m'aide à garder ces ressources gratuites et à continuer de produire des guides de qualité. Merci pour votre appui.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn