Sortie structurée et appel de fonctions
Demandez du JSON à un modèle et il produira gentiment du presque-JSON : une virgule finale, une clé sans guillemets, une phrase amicale avant la première accolade. L'échantillonnage libre peut toujours émettre un jeton invalide, parce que l'échantillonneur ne sait rien de votre schéma. La sortie structurée referme cet écart, et l'appel de fonctions est le même mécanisme avec un autre chapeau.
Pourquoi le JSON libre casse
Chaque jeton généré est un échantillon d'une distribution. À chaque étape, un peu de masse de probabilité — faible, mais jamais nulle au-dessus de la température 0 — repose sur des jetons qui casseraient votre format : une accolade fermante avant son ouvrante, une virgule après le dernier élément du tableau, un true minuscule écrit True (une habitude Python cuite dans les données d'entraînement). Multipliez « presque toujours valide » sur 200 jetons de sortie et vous obtenez des cassures intermittentes et non reproductibles — le pire genre de bug. Même la température 0 rend seulement la sortie déterministe, pas conforme au schéma.
L'échelle des solutions
| Échelon | Mécanisme | Garantie | Où |
|---|---|---|---|
| Prompt seul (« réponds en JSON valide ») | demander poliment | aucune — l'échantillonnage peut encore se tromper | tout moteur |
| Mode JSON | prompté plus restreint à la syntaxe JSON | JSON syntaxiquement valide ; forme non garantie | APIs compatibles OpenAI, format: "json" d'Ollama |
| Contraint par schéma | schéma → grammaire → logits masqués | la sortie parse et correspond toujours au schéma | llama.cpp (json_schema / GBNF), guided_json de vLLM, structured outputs d'Ollama, outlines / xgrammar |
Chaque échelon coûte de la flexibilité et achète du déterminisme. Utilisez l'échelon du haut dès que le consommateur est du code.
Décodage contraint par grammaire
Compilez le schéma en grammaire. À chaque étape de décodage, chaque jeton qui ne pourrait pas prolonger un document valide voit son logit mis à −infini avant l'échantillonnage. La sortie invalide n'est pas improbable — elle est impossible. llama.cpp livre des grammaires GBNF ; une grammaire de nombre décimal tient en une ligne :
root ::= "-"? [0-9]+ ("." [0-9]+)?
La même idée enveloppe JSON Schema. Contre un serveur llama.cpp local (endpoint compatible OpenAI) :
import json, requests
schema = {
"type": "object",
"properties": {
"title": {"type": "string"},
"component": {"type": "string",
"enum": ["auth", "upload", "ui", "api"]},
"severity": {"type": "string",
"enum": ["low", "medium", "high"]}
},
"required": ["title", "component", "severity"],
"additionalProperties": False
}
r = requests.post("http://localhost:8080/v1/chat/completions", json={
"messages": [{"role": "user",
"content": "Extrais le bug : " + report}],
"response_format": {
"type": "json_schema",
"json_schema": {"name": "bug", "strict": True,
"schema": schema}
},
"temperature": 0
})
bug = json.loads(r.json()["choices"][0]["message"]["content"])
Ollama fait pareil avec un JSON Schema dans le champ format ; vLLM avec guided_json. Même mécanisme, trois orthographes — les noms de champs exacts de votre moteur vivent dans paramètres moteur.
Contraint n'est pas correct
La grammaire garantit la forme, pas la vérité. Le modèle peut émettre {"severity": "low"} pour un rapport de crash, ou bourrer un champ chaîne de remplissage. Le masquage peut aussi nuire aux champs ouverts : quand le modèle veut un saut de paragraphe et que la grammaire interdit le saut de ligne, la qualité chute. Règles pratiques :
- Des enums étroits plutôt que des chaînes libres dès que l'espace de valeurs est connu.
requiredpour chaque champ que vous consommez ;additionalProperties: falsepour arrêter les inventions.- Gardez les champs chaîne courts ; sortez la longue prose de la réponse contrainte.
- Mettez quand même un exemple travaillé de la sortie voulue dans le prompt — il dirige le contenu, ce que la grammaire ne peut pas.
Appel de fonctions et d'outils
L'appel d'outils, c'est de la sortie structurée plus une convention : le modèle renvoie quelle fonction appeler et avec quels arguments ; votre code l'exécute et ajoute le résultat. La forme tools d'OpenAI est le dialecte de facto que les moteurs locaux imitent :
{
"tools": [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Météo actuelle pour une ville",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string"},
"unit": {"type": "string", "enum": ["C", "F"]}
},
"required": ["city"]
}
}
}]
}
Le modèle répond avec tool_calls dans le message ; vous renvoyez la sortie en message role: "tool" et la boucle continue. Les modèles sont entraînés pour des dialectes d'outils précis — les gabarits de lignée Hermes et Qwen sont les mieux supportés sur les moteurs locaux (llama.cpp avec --jinja, Ollama, vLLM avec le choix d'outil automatique). Un modèle sans entraînement aux outils produit des appels plausibles aux noms d'arguments hallucinés quoi que vous contraigniez, choisissez donc d'abord le modèle pour le travail : choisir un modèle.
Vérifiez et le parse et le contenu
Deux vérifications après chaque appel. D'abord, le parsing — json.loads réussit (la grammaire le garantit généralement ; le mode JSON pas toujours). Ensuite, la sémantique : clés requises présentes, enums dans les bornes, nombres plausibles, chaînes non vides. En cas d'échec, réessayez une fois avec l'erreur de validation renvoyée dans le prompt — les retries contraints sont bon marché et réussissent généralement au premier rebond.
Cette boucle exacte — émettre un appel, valider, exécuter, observer — est ce que les agents font tourner toute la journée : agents.
Voir aussi : agents · prompting · échantillonnage · RAG