Design et implémentation de systèmes de tool calling pour agents IA. Function calling, API wrapping, schema design, MCP server, parallel calls, sécurité et monitoring. Se déclenche avec "tool calling", "function calling", "tools agent", "API tools", "agent tools", "créer un outil", "custom tool", "tool schema", "MCP tool". Also triggers on "function calling schema", "give my agent tools".
Design et implémentation de systèmes de tool calling pour agents IA. Function calling, API wrapping, schema design, MCP server, parallel calls, sécurité et monitoring. Se déclenche avec "tool calling", "function calling", "tools agent", "API tools", "agent tools", "créer un outil", "custom tool", "tool schema", "MCP tool". Also triggers on "function calling schema", "give my agent tools".
Tool Calling Architect
Critères de décision : quel pattern choisir ?
Situation
Pattern recommandé
Outil ponctuel dans un seul agent
Function calling inline (JSON schema dans le prompt)
Outil partagé entre plusieurs agents
MCP Server (stdio ou HTTP+SSE)
Enchaînement prévisible de 3+ étapes
Pipeline déterministe, pas tool chaining LLM
Actions parallèles indépendantes
Parallel tool calling natif (OpenAI/Anthropic)
Outil exposé à des LLMs tiers/clients
MCP Server avec authentification
Wrapping d'API REST existante
Tool = thin wrapper + validation Pydantic
Workflow en 10 étapes
1. Définir le contrat de l'outil
Avant d'écrire du code, complète cette fiche :
Nom : snake_case explicite (get_invoice_by_id, pas get_data)
Description : une phrase claire sur CE QUE l'outil fait + QUAND l'utiliser (le LLM lit cette phrase pour décider d'appeler ou non)
Inputs : chaque paramètre typé, obligatoire ou optionnel, avec valeurs autorisées si enum
Output : format de retour standardisé (, ou )
status
data
error
Effets de bord : lecture seule ? écriture ? irréversible ?
Règle : si la description de l'outil est ambiguë pour un développeur humain, elle le sera aussi pour le LLM.
2. Rédiger le JSON Schema
Format OpenAI-compatible (compatible Anthropic, Gemini, Mistral) :
SEARCH_WEB_SCHEMA = {
"name": "search_web",
"description": (
"Recherche des informations récentes sur le web. ""Utilise cet outil uniquement quand tu as besoin d'informations ""publiées après ta date de coupure ou de données factuelles précises."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Requête en langage naturel, la plus spécifique possible"
},
"max_results": {
"type": "integer",
"description": "Nombre max de résultats retournés (1-10)",
"default": 5
},
"language": {
"type": "string",
"enum": ["fr", "en", "ar"],
"description": "Langue des résultats",
"default": "fr"
}
},
"required": ["query"],
"additionalProperties": False# bloque les paramètres inconnus
}
}
Pièges courants dans les schémas :
Oublier "additionalProperties": False → le LLM invente des champs
Description trop courte → appels au mauvais moment
Trop de paramètres optionnels → le LLM les omet aléatoirement
3. Implémenter le tool (Python)
from pydantic import BaseModel, Field, field_validator
import httpx
classSearchInput(BaseModel):
query: str = Field(..., min_length=1, max_length=500)
max_results: int = Field(5, ge=1, le=10)
language: str = Field("fr", pattern="^(fr|en|ar)$")
@field_validator("query") @classmethoddefno_injection(cls, v: str) -> str:
forbidden = ["<script", "DROP TABLE", "--"]
ifany(f in v for f in forbidden):
raise ValueError("Requête invalide")
return v.strip()
asyncdefsearch_web(query: str, max_results: int = 5, language: str = "fr") -> dict:
try:
inp = SearchInput(query=query, max_results=max_results, language=language)
except Exception as e:
return {"status": "error", "code": "INVALID_INPUT", "message": str(e)}
try:
asyncwith httpx.AsyncClient(timeout=8.0) as client:
resp = await client.get(
"https://search-api/v1/search",
params=inp.model_dump(),
headers={"Authorization": f"Bearer {SEARCH_API_KEY}"}
)
resp.raise_for_status()
items = resp.json().get("items", [])[:inp.max_results]
return {"status": "success", "data": items}
except httpx.TimeoutException:
return {"status": "error", "code": "TIMEOUT", "message": "Search API timeout after 8s"}
except httpx.HTTPStatusError as e:
return {"status": "error", "code": f"HTTP_{e.response.status_code}", "message": str(e)}
except Exception as e:
return {"status": "error", "code": "UNKNOWN", "message": str(e)}
4. Dynamic tool loading
Ne passe jamais 20+ outils dans chaque prompt. Charge uniquement les outils pertinents :
TOOL_REGISTRY = {
"search": {"schema": SEARCH_WEB_SCHEMA, "fn": search_web, "tags": ["read", "web"]},
"send_email": {"schema": SEND_EMAIL_SCHEMA, "fn": send_email, "tags": ["write", "email"]},
"query_db": {"schema": QUERY_DB_SCHEMA, "fn": query_db, "tags": ["read", "db"]},
}
defget_tools_for_task(tags: list[str]) -> list[dict]:
return [
t["schema"] for t in TOOL_REGISTRY.values()
ifany(tag in t["tags"] for tag in tags)
]
# Utilisation
tools = get_tools_for_task(["read", "web"])
Seuils pratiques :
1–5 outils : pas de filtrage nécessaire
6–15 outils : filtrer par catégorie selon le contexte
16+ outils : filtrage sémantique (embeddings) ou multi-agent avec spécialisation
5. Parallel tool calling
Les LLMs modernes (GPT-4o, Claude 3.5+, Gemini 1.5+) retournent plusieurs tool_calls en un seul tour. Exécute-les en parallèle :
import asyncio
asyncdefdispatch_tool(tool_call) -> dict:
name = tool_call.function.name
args = json.loads(tool_call.function.arguments)
fn = TOOL_REGISTRY[name]["fn"]
result = await fn(**args)
return {
"tool_call_id": tool_call.id,
"role": "tool",
"content": json.dumps(result)
}
# Dans la boucle agentif response.tool_calls:
results = await asyncio.gather(
*[dispatch_tool(tc) for tc in response.tool_calls],
return_exceptions=True# une erreur n'annule pas les autres
)
messages.extend(results)
6. Tool chaining — quand laisser le LLM décider vs. pipeline déterministe
LLM-driven chaining → quand les étapes sont imprévisibles (exploration, recherche)
Pipeline déterministe → quand l'ordre est toujours le même (ETL, workflow métier)
Sandboxing — outils d'exécution de code dans Docker ou subprocess avec timeout
Rate limiting — par outil ET par session (ex. max 10 calls/min pour send_email)
Permission scoping — l'agent reçoit uniquement les outils autorisés pour la session
Budget par outil — coût max par appel pour les APIs payantes (éviter les loops infinis)
Audit log — chaque appel loggé avec tool_name, user_id, timestamp, inputs (sans secrets)
# Exemple rate limiting simplefrom collections import defaultdict
import time
call_counts: dict[str, list[float]] = defaultdict(list)
defcheck_rate_limit(tool_name: str, user_id: str, max_calls: int = 10, window: int = 60):
key = f"{tool_name}:{user_id}"
now = time.time()
call_counts[key] = [t for t in call_counts[key] if now - t < window]
iflen(call_counts[key]) >= max_calls:
raise PermissionError(f"Rate limit: {max_calls} appels/{window}s pour {tool_name}")
call_counts[key].append(now)