
Je suis Jules Ginhac, Co-Founder & ingénieur IA chez Apogée Consult à Lyon. Je conçois et déploie des architectures IA génératives (RAG, agents, LLMOps) pour des PME, startups et organisations publiques.
Function calling fiable : les patterns de schéma JSON qui ne cassent jamais
- ia-produit
Le function calling LLM échoue rarement sur les cas simples. Il échoue presque toujours de la même façon sur les cas limites. Voici les patterns de schéma qui éliminent ces échecs.
Function calling fiable : les patterns de schéma JSON qui ne cassent jamais
Le function calling fonctionne bien sur les démos. Il casse de façon prévisible en production, presque toujours à cause du schéma plutôt que du modèle. Après avoir débogué des dizaines de pipelines de tool use en production, nous avons identifié les patterns qui éliminent la majorité des échecs sans avoir à changer de modèle.
Pourquoi les schémas causent des problèmes
Le modèle ne "comprend" pas votre schéma. Il génère un JSON qui ressemble à ce que le schéma demande, en se basant sur la description des champs, les exemples, et l'inférence statistique de ses données d'entraînement. Quand votre schéma est ambigu ou sous-contraint, le modèle comble les ambiguïtés avec des valeurs plausibles qui ne sont pas forcément celles que vous attendez.
Les cinq erreurs les plus fréquentes que nous avons rencontrées :
- Champs optionnels omis au lieu d'être null.
- Énumérations respectées dans le texte, ignorées dans le JSON.
- Tableaux vides omis au lieu de retourner
[]. - Nombres retournés comme strings.
- Dates dans des formats non spécifiés.
Pattern 1 : rendre les champs optionnels explicites
Un champ marqué comme non-requis dans le schéma JSON peut être omis par le modèle ou retourné à null. Si votre code suppose l'un ou l'autre, vous aurez des KeyError ou des NoneType en production.
Règle : toujours déclarer le comportement attendu dans la description du champ, pas seulement dans le type.
# Trop ambigu, le modèle peut omettre ou null
{
"name": "get_contact",
"parameters": {
"type": "object",
"properties": {
"email": {"type": "string"},
"phone": {"type": "string"}
},
"required": ["email"]
}
}
# Explicite, comportement prévisible
{
"name": "get_contact",
"parameters": {
"type": "object",
"properties": {
"email": {
"type": "string",
"description": "Adresse email de contact. Toujours présent."
},
"phone": {
"type": ["string", "null"],
"description": "Numéro de téléphone. Null si absent du document, jamais omis."
}
},
"required": ["email", "phone"]
}
}En rendant phone required avec un type ["string", "null"], vous forcez le modèle à toujours inclure le champ. Votre code peut ensuite tester if result.phone is not None de façon fiable.
Pattern 2 : contraindre les énumérations avec des exemples
Les énumérations via enum dans le schéma JSON sont respectées par la plupart des modèles dans ~95 % des cas. Les 5 % restants correspondent aux formulations ambiguës ou aux valeurs proches.
# Risque d'ambiguïté : "urgent" vs "high" vs "haute"
{
"priority": {
"type": "string",
"enum": ["low", "medium", "high", "urgent"]
}
}
# Avec description + exemple forcé
{
"priority": {
"type": "string",
"enum": ["low", "medium", "high", "urgent"],
"description": (
"Niveau de priorité. Utilise exactement l'une de ces valeurs : "
"low, medium, high, urgent. "
"Si le document mentionne 'critique' ou 'bloquant', utilise 'urgent'."
)
}
}Pour les cas critiques, ajoutez une validation post-appel qui rejette les valeurs hors enum et relance l'appel avec un message d'erreur explicite :
from typing import Literal
def validate_and_retry(result: dict, schema: dict, client, messages: list) -> dict:
errors = validate_against_schema(result, schema) # votre validateur JSON Schema
if not errors:
return result
# Relance avec le retour d'erreur dans le contexte
messages.append({"role": "tool", "content": str(result)})
messages.append({
"role": "user",
"content": f"Erreur de validation : {errors}. Corrige le JSON."
})
retry_result = call_with_tool(client, messages, schema)
return retry_resultNe faites pas plus de deux tentatives. Si le modèle échoue deux fois sur le même schéma, le problème vient probablement du schéma, pas du modèle.
Pattern 3 : les tableaux vides vs absents
Un champ tableau non présent dans la réponse du modèle peut signifier "aucun élément" ou "le modèle a oublié ce champ". La distinction est critique quand votre logique aval traite différemment un tableau vide et un tableau absent.
# Schéma qui force le tableau même vide
{
"line_items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"description": {"type": "string"},
"amount": {"type": "number"}
},
"required": ["description", "amount"]
},
"description": (
"Liste des lignes de détail. "
"Retourne un tableau vide [] si aucune ligne n'est identifiable. "
"Ne jamais omettre ce champ."
)
}
}Combiné avec "required": ["line_items"] au niveau parent, ce pattern rend le champ toujours présent.
Pattern 4 : les types numériques
Les modèles tendent à retourner des nombres comme strings quand ils les lisent dans un contexte textuel ("le montant est de 1 234,56 EUR"). Le type JSON "number" n'est pas toujours respecté.
{
"amount": {
"type": "number",
"description": (
"Montant en euros, nombre décimal. "
"Exemples corrects : 1234.56, 0.5, 100. "
"Ne pas inclure de symbole monétaire ni de séparateur de milliers."
)
}
}En pratique, ajoutez toujours une couche de coercition après la désérialisation :
def coerce_numeric_fields(data: dict, numeric_fields: list[str]) -> dict:
for field in numeric_fields:
if field in data and isinstance(data[field], str):
try:
# Nettoie les formats locaux avant conversion
cleaned = data[field].replace(' ', '').replace(',', '.')
cleaned = ''.join(c for c in cleaned if c.isdigit() or c == '.')
data[field] = float(cleaned)
except (ValueError, AttributeError):
data[field] = None
return dataPattern 5 : les dates
Sans contrainte de format, les modèles retournent des dates dans une variété de formats : "15 mars 2025", "15/03/2025", "2025-03-15", "March 15, 2025". Votre parser en aval gère rarement tous ces formats.
{
"date": {
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$",
"description": (
"Date au format ISO 8601 : YYYY-MM-DD. "
"Exemple : 2025-03-15. "
"Si la date est incomplète (mois ou jour manquant), utilise null."
)
}
}La clé pattern n'est pas supportée par tous les modèles, mais combinée avec la description explicite, elle réduit les erreurs de format de façon significative.
La validation comme filet de sécurité
Même avec des schémas robustes, des erreurs passent en production. La validation JSON Schema côté application n'est pas optionnelle, c'est le filet de sécurité qui empêche une mauvaise réponse du modèle de corrompre votre base de données.
import jsonschema
def validate_tool_result(result: dict, schema: dict) -> tuple[bool, list[str]]:
validator = jsonschema.Draft7Validator(schema)
errors = list(validator.iter_errors(result))
return len(errors) == 0, [e.message for e in errors]
# Usage
is_valid, error_messages = validate_tool_result(parsed_result, tool_schema)
if not is_valid:
logger.error("Tool result validation failed", extra={
"errors": error_messages,
"result": parsed_result
})
raise ToolValidationError(error_messages)La question qui détermine votre niveau de paranoïa : est-ce que votre application écrit des données irréversibles sur la base d'un appel LLM ? Si oui, la validation est bloquante. Si non, vous pouvez logger et continuer.
L'amélioration que nous explorons actuellement : générer automatiquement les descriptions de champs à partir des exemples de données réelles du domaine, plutôt que de les écrire manuellement. Les descriptions générées depuis des exemples concrets semblent mieux calibrées que celles rédigées de façon générique.