Vous avez livré une fonctionnalité qui appelle GPT-4 pour extraire des données structurées à partir de factures soumises par les utilisateurs.
En développement, ça marche parfaitement — le modèle retourne un objet JSON propre à chaque fois. Puis en production,
à 2 heures du matin, vous recevez une alerte Sentry : JSON.parse: unexpected token. Le modèle a décidé de préfixer sa
réponse avec « Bien sûr ! Voici le JSON que vous avez demandé : » avant le payload réel. Une semaine plus tard, même fonctionnalité,
bug différent : le modèle retourne totalAmount au lieu de total_amount, et votre
écriture en base de données en aval abandonne silencieusement le champ. Si vous avez bricolé via prompt-engineering pour contourner la fiabilité
des sorties LLM, OpenAI Structured Outputs
est le correctif que vous attendiez.
Structured Outputs, sorti par OpenAI en août 2024, vous laisse fournir un
JSON Schema
via le paramètre response_format et recevoir une réponse garantie valide qui
correspond exactement à ce schéma. C'est différent de l'ancien mode JSON ({"type": "json_object"}),
qui assurait seulement que la sortie était du JSON valide — pas qu'elle correspondait à une forme particulière. C'est aussi distinct
du function calling, qui route la sortie du modèle dans un appel d'outil mais ajoute sa propre couche de cérémonie.
Structured Outputs est le chemin le plus propre : décrivez la forme que vous voulez, récupérez exactement cette forme, à chaque fois.
Sous le capot, OpenAI utilise le décodage contraint — l'échantillonnage de tokens du modèle est guidé par votre schéma donc
il est littéralement impossible qu'il produise une réponse invalide.
Votre première sortie structurée
from openai import OpenAI
import json
client = OpenAI()
response = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[
{
"role": "user",
"content": "Extract the vendor name, invoice number, and total amount from this text: "
"Invoice #INV-2024-0892 from Acme Supplies Ltd. Total due: $1,450.00"
}
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "invoice_extract",
"strict": True,
"schema": {
"type": "object",
"properties": {
"vendor_name": {"type": "string"},
"invoice_number": {"type": "string"},
"total_amount": {"type": "number"}
},
"required": ["vendor_name", "invoice_number", "total_amount"],
"additionalProperties": False
}
}
}
)
data = json.loads(response.choices[0].message.content)
print(data)
# {"vendor_name": "Acme Supplies Ltd", "invoice_number": "INV-2024-0892", "total_amount": 1450.0}Trois choses à remarquer ici. Premièrement, le modèle est gpt-4o-2024-08-06 — Structured Outputs
nécessite un modèle qui le supporte explicitement (le snapshot -2024-08-06 ou ultérieur pour GPT-4o, ou
gpt-4o-mini). Deuxièmement, response_format.type est "json_schema", pas
"json_object". Troisièmement, "strict": True est ce qui vous donne la garantie —
sans ça, vous retombez dans le best-effort. Le champ name est un label que le modèle voit ;
il n'a aucun effet sur le parsing mais rend vos logs d'API lisibles.
Concevoir le JSON Schema
Voici un schéma plus réaliste pour une tâche d'extraction de catalogue produit — le genre que vous utiliseriez pour tirer des données structurées depuis des descriptions de produit non structurées, des listings e-commerce, ou des fiches techniques PDF. Utilisez le JSON Schema Generator pour construire et valider votre schéma visuellement avant de le câbler dans vos appels d'API.
{
"type": "object",
"properties": {
"product_name": {
"type": "string",
"description": "The full commercial name of the product"
},
"sku": {
"type": "string",
"description": "Stock keeping unit identifier"
},
"price_usd": {
"type": "number",
"description": "Price in US dollars, numeric only"
},
"in_stock": {
"type": "boolean"
},
"categories": {
"type": "array",
"items": { "type": "string" }
},
"dimensions": {
"type": "object",
"properties": {
"width_cm": { "type": "number" },
"height_cm": { "type": "number" },
"depth_cm": { "type": "number" }
},
"required": ["width_cm", "height_cm", "depth_cm"],
"additionalProperties": false
}
},
"required": [
"product_name", "sku", "price_usd",
"in_stock", "categories", "dimensions"
],
"additionalProperties": false
}- Toutes les propriétés doivent être dans
requireden mode strict. Vous ne pouvez pas avoir de champs optionnels. Si un champ peut ne pas exister dans les données source, utilisez un type union :{"type": ["string", "null"]}et incluez-le toujours dansrequired. additionalPropertiesdoit êtrefalseà chaque niveau d'objet. Cela s'applique récursivement — vos objets imbriqués en ont besoin aussi, pas seulement la racine.- Types supportés en mode strict :
string,number,integer,boolean,null,array,object. Les unions de types (["string", "null"]) sont autorisées. - Pas de
$refni de schémas récursifs en mode strict. Tout doit être inliné. Si vous avez besoin d'une définition réutilisable, copiez-la. - Ajoutez généreusement des champs
description. Le modèle les lit. Dire « Price in US dollars, numeric only — do not include currency symbols » vous donne une sortie plus propre qu'espérer que le modèle devine juste. - Les enums fonctionnent.
{"type": "string", "enum": ["pending", "shipped", "delivered"]}est pleinement supporté et le modèle n'émettra jamais qu'une de ces trois valeurs.
Mode strict vs non-strict
# Strict mode — guaranteed conformance, tighter schema rules
response_format_strict = {
"type": "json_schema",
"json_schema": {
"name": "product_extract",
"strict": True, # <-- the key flag
"schema": product_schema
}
}
# Non-strict — more schema flexibility, best-effort conformance
response_format_lenient = {
"type": "json_schema",
"json_schema": {
"name": "product_extract",
"strict": False,
"schema": product_schema
}
}Avec strict: True, OpenAI pré-traite votre schéma la première fois qu'il est utilisé
et met en cache le décodeur contraint. Le premier appel avec un nouveau schéma prend légèrement plus de temps ; les appels
suivants avec le même schéma sont rapides. Ce que vous obtenez en retour : la sortie du modèle est structurellement
garantie — vous pouvez appeler json.loads() puis accéder aux champs directement sans
vérifications défensives. Ce que vous abandonnez : $ref, anyOf à travers des variantes structurelles,
et les schémas récursifs ne sont pas supportés. Le mode non-strict accepte une plus large gamme de
fonctionnalités de JSON Schema
mais retombe en best-effort — le modèle essaye de suivre le schéma mais n'est pas contraint
au niveau des tokens. Pour les pipelines d'extraction en production, utilisez toujours le mode strict. Les restrictions de schéma
sont gérables une fois que vous les comprenez.
Objets et tableaux imbriqués
Les structures imbriquées fonctionnent bien, mais chaque objet imbriqué a besoin de son propre "additionalProperties": false
et de son propre tableau "required" listant toutes les propriétés. Une erreur courante est d'appliquer les règles strictes
à l'objet racine et d'oublier les enfants — OpenAI rejettera le schéma avec une erreur de validation.
from openai import OpenAI
import json
client = OpenAI()
order_schema = {
"type": "object",
"properties": {
"order_id": {"type": "string"},
"customer": {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"}
},
"required": ["name", "email"],
"additionalProperties": False # required on nested objects too
},
"line_items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"description": {"type": "string"},
"quantity": {"type": "integer"},
"unit_price": {"type": "number"}
},
"required": ["description", "quantity", "unit_price"],
"additionalProperties": False # required on array item schemas too
}
},
"total_usd": {"type": "number"}
},
"required": ["order_id", "customer", "line_items", "total_usd"],
"additionalProperties": False
}
response = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[{
"role": "user",
"content": (
"Parse this order: Order #ORD-5531 for Jane Smith ([email protected]). "
"2x Wireless Keyboard at $49.99 each, 1x USB Hub at $29.99. Total: $129.97"
)
}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "order_extract",
"strict": True,
"schema": order_schema
}
}
)
order = json.loads(response.choices[0].message.content)
for item in order["line_items"]:
print(f"${item['unit_price']:.2f} x{item['quantity']} {item['description']}")Gérer les refus
Même avec Structured Outputs, le modèle peut refuser de répondre — typiquement quand le prompt
déclenche une politique de contenu (lui demander d'extraire des données de quelque chose de nuisible). Quand cela arrive,
finish_reason est "stop" mais message.content est null
et message.refusal contient le texte du refus. Si vous ne vérifiez pas cela, vous obtiendrez
une AttributeError quand vous tenterez d'appeler json.loads(None). Surveillez aussi
finish_reason == "length" — si la réponse a été coupée à cause de max_tokens,
le JSON sera incomplet et non parsable indépendamment de Structured Outputs.
import json
from openai import OpenAI
client = OpenAI()
def extract_invoice(raw_text: str) -> dict | None:
response = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[{"role": "user", "content": f"Extract invoice fields: {raw_text}"}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "invoice_extract",
"strict": True,
"schema": invoice_schema
}
},
max_tokens=1024
)
choice = response.choices[0]
if choice.finish_reason == "length":
raise ValueError("Response truncated — increase max_tokens or simplify your schema")
if choice.message.refusal:
# Model refused to answer — log and return None rather than crashing
print(f"Model refused: {choice.message.refusal}")
return None
return json.loads(choice.message.content)
result = extract_invoice("Invoice #2024-441 from BuildRight Inc., due $3,200 by Dec 15")
if result:
print(result["vendor_name"], result["total_amount"])Utiliser le même schéma pour la validation
Un pattern sous-utilisé : utilisez le même JSON Schema
que vous passez à OpenAI pour aussi valider les données arrivant d'autres sources — webhooks, uploads de fichiers,
API tierces. Cela vous donne une unique source de vérité pour la forme de vos données. En Python, utilisez la
bibliothèque jsonschema. En Node.js, utilisez Ajv.
Vous pouvez aussi coller votre schéma dans le Validateur JSON pour faire un rapide
sanity check manuel sans écrire de code.
import json
import jsonschema
from jsonschema import validate, ValidationError
# The same schema used in your OpenAI call
invoice_schema = {
"type": "object",
"properties": {
"vendor_name": {"type": "string"},
"invoice_number": {"type": "string"},
"total_amount": {"type": "number"}
},
"required": ["vendor_name", "invoice_number", "total_amount"],
"additionalProperties": False
}
def validate_invoice(data: dict) -> bool:
try:
validate(instance=data, schema=invoice_schema)
return True
except ValidationError as e:
print(f"Validation failed: {e.message}")
print(f" Path: {' -> '.join(str(p) for p in e.path)}")
return False
# Validate a payload from a webhook — same schema, zero extra work
webhook_payload = json.loads(request_body)
if not validate_invoice(webhook_payload):
return HTTPResponse(status=400, body="Invalid invoice payload")
# Validate the OpenAI output too, for belt-and-suspenders safety
llm_output = json.loads(openai_response.choices[0].message.content)
assert validate_invoice(llm_output), "LLM output failed schema validation — check schema definition"
print(f"Processing invoice {llm_output['invoice_number']} for ${llm_output['total_amount']}")response_format OpenAI.Version JavaScript / Node.js
Le SDK Node.js OpenAI
reflète l'API Python presque exactement. La différence principale est que strict se place à l'intérieur de
l'objet json_schema de la même manière, et vous parsez la réponse avec JSON.parse().
La validation avec Ajv est l'équivalent Node.js
de la bibliothèque Python jsonschema — elle est plus rapide et a un excellent support TypeScript.
import OpenAI from "openai";
import Ajv from "ajv";
const client = new OpenAI();
const ajv = new Ajv();
const invoiceSchema = {
type: "object",
properties: {
vendor_name: { type: "string" },
invoice_number: { type: "string" },
total_amount: { type: "number" },
line_items: {
type: "array",
items: {
type: "object",
properties: {
description: { type: "string" },
amount: { type: "number" }
},
required: ["description", "amount"],
additionalProperties: false
}
}
},
required: ["vendor_name", "invoice_number", "total_amount", "line_items"],
additionalProperties: false
};
const validateInvoice = ajv.compile(invoiceSchema);
async function extractInvoice(rawText) {
const response = await client.chat.completions.create({
model: "gpt-4o-2024-08-06",
messages: [{ role: "user", content: `Extract invoice fields: ${rawText}` }],
response_format: {
type: "json_schema",
json_schema: {
name: "invoice_extract",
strict: true,
schema: invoiceSchema
}
}
});
const choice = response.choices[0];
if (choice.message.refusal) {
throw new Error(`Model refused: ${choice.message.refusal}`);
}
const data = JSON.parse(choice.message.content);
// Validate even though strict mode guarantees structure —
// useful for catching schema drift between environments
if (!validateInvoice(data)) {
console.error("Schema validation errors:", validateInvoice.errors);
throw new Error("Output failed schema validation");
}
return data;
}
const invoice = await extractInvoice(
"Invoice #INV-881 from Nordic Parts AS. " +
"3x Brake Pads at $28.50 each. Total: $85.50"
);
console.log(`${invoice.vendor_name} — Invoice ${invoice.invoice_number}`);
invoice.line_items.forEach(item =>
console.log(` ${item.description}: $${item.amount}`)
);Pour conclure
Structured Outputs élimine toute une catégorie de bugs de production — le genre où le modèle
retourne du JSON presque juste qui casse votre parseur à 2 heures du matin. Le workflow est simple : concevez votre
schéma soigneusement (chaque objet imbriqué a besoin de additionalProperties: false et d'un tableau
required complet), mettez strict: true, et gérez les refus et la troncature explicitement.
Une fois le schéma en place, vous pouvez le réutiliser dans toute votre stack — dans l'appel OpenAI, dans la validation de webhooks,
dans vos fixtures de test — avec des bibliothèques comme jsonschema (Python) ou
Ajv (Node.js). Si vous partez de
zéro sur un schéma, le JSON Schema Generator est le moyen le plus rapide
d'obtenir un schéma de base fonctionnel à partir d'un payload d'exemple. Les jours où l'on bricolait via prompt-engineering pour obtenir une sortie
JSON fiable sont révolus — utilisez l'outil qui a été construit pour ça.