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

python
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.

json
{
  "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 required en 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 dans required.
  • additionalProperties doit être false à 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 $ref ni 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

python
# 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.

python
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.

python
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.

python
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']}")
Construisez votre schéma plus vite : Utilisez le JSON Schema Generator pour créer et raffiner votre schéma visuellement — collez un objet JSON d'exemple et il génère un schéma de départ automatiquement. Copiez le résultat directement dans votre 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.

js
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.