Has publicado una funcionalidad que llama a GPT-4 para extraer datos estructurados de facturas enviadas por usuarios. En desarrollo funciona perfectamente — el modelo devuelve un objeto JSON limpio cada vez. Luego, en producción, a las 2 de la madrugada, recibes una alerta de Sentry: JSON.parse: unexpected token. El modelo decidió prologar su respuesta con "¡Claro! Aquí tienes el JSON que pediste:" antes del payload real. Una semana después, misma funcionalidad, bug diferente: el modelo devuelve totalAmount en vez de total_amount, y tu escritura aguas abajo en la base de datos descarta silenciosamente el campo. Si llevas tiempo haciendo prompt-engineering para sortear la fiabilidad de la salida de los LLM, OpenAI Structured Outputs es el arreglo que estabas esperando.

Structured Outputs, lanzado por OpenAI en agosto de 2024, te deja proporcionar un JSON Schema vía el parámetro response_format y recibir una respuesta garantizada-válida que coincide exactamente con ese esquema. Esto es diferente del antiguo modo JSON ({"type": "json_object"}), que solo aseguraba que la salida era JSON válido — no que coincidiera con ninguna forma en particular. También es distinto del function calling, que enruta la salida del modelo hacia una tool call pero añade su propia capa de ceremonia. Structured Outputs es el camino más limpio: describe la forma que quieres, recibes exactamente esa forma, cada vez. Por debajo, OpenAI usa decodificación restringida — el muestreo de tokens del modelo está guiado por tu esquema, así que literalmente no puede producir una respuesta inválida.

Tu primera salida estructurada

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}

Tres cosas a notar. Primero, el modelo es gpt-4o-2024-08-06 — Structured Outputs requiere un modelo que lo soporte explícitamente (el snapshot -2024-08-06 o posterior para GPT-4o, o gpt-4o-mini). Segundo, response_format.type es "json_schema", no "json_object". Tercero, "strict": True es lo que te da la garantía — sin él vuelves al territorio de best-effort. El campo name es una etiqueta que el modelo ve; no tiene efecto en el parseo pero hace tus logs de API legibles.

Diseñando el JSON Schema

Este es un esquema más realista para una tarea de extracción de catálogo de productos — del tipo que usarías para extraer datos estructurados de descripciones de producto no estructuradas, listados de e-commerce o fichas técnicas en PDF. Usa el Generador de JSON Schema para construir y validar tu esquema visualmente antes de cablearlo en tus llamadas a la 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
}
  • Todas las propiedades deben estar en required en modo estricto. No puedes tener campos opcionales. Si un campo podría no existir en los datos de origen, usa un tipo unión: {"type": ["string", "null"]} e inclúyelo siempre en required.
  • additionalProperties debe ser false en cada nivel de objeto. Esto aplica recursivamente — tus objetos anidados también lo necesitan, no solo la raíz.
  • Tipos soportados en modo estricto: string, number, integer, boolean, null, array, object. Las uniones de tipos (["string", "null"]) están permitidas.
  • No se permite $ref ni esquemas recursivos en modo estricto. Todo debe estar inline. Si necesitas una definición reutilizable, cópiala.
  • Añade campos description generosamente. El modelo los lee. Decir "Price in US dollars, numeric only — do not include currency symbols" te da una salida más limpia que esperar que el modelo adivine bien.
  • Los enums funcionan. {"type": "string", "enum": ["pending", "shipped", "delivered"]} está totalmente soportado y el modelo solo emitirá uno de esos tres valores.

Modo estricto vs no estricto

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
    }
}

Con strict: True, OpenAI preprocesa tu esquema la primera vez que se usa y cachea el decodificador restringido. La primera llamada con un esquema nuevo tarda un poco más; las llamadas posteriores con el mismo esquema son rápidas. Lo que obtienes a cambio: la salida del modelo está estructuralmente garantizada — puedes llamar a json.loads() y luego acceder a campos directamente sin comprobaciones defensivas. Lo que cedes: $ref, anyOf entre variantes estructurales y los esquemas recursivos no están soportados. El modo no estricto acepta un rango más amplio de características de JSON Schema pero cae en best-effort — el modelo intenta seguir el esquema pero no está restringido a nivel de token. Para pipelines de extracción en producción, usa siempre el modo estricto. Las restricciones del esquema son manejables una vez las entiendes.

Objetos y arrays anidados

Las estructuras anidadas funcionan bien, pero cada objeto anidado necesita su propio "additionalProperties": false y su propio array "required" listando todas las propiedades. Un error común es aplicar reglas estrictas al objeto raíz y olvidarse de los hijos — OpenAI rechazará el esquema con un error de validación.

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']}")

Manejo de rechazos

Incluso con Structured Outputs, el modelo puede rechazar responder — típicamente cuando el prompt dispara una política de contenido (pedirle que extraiga datos de algo dañino). Cuando esto ocurre, finish_reason es "stop" pero message.content es null y message.refusal contiene el texto del rechazo. Si no compruebas esto, obtendrás un AttributeError cuando intentes llamar a json.loads(None). Vigila también finish_reason == "length" — si la respuesta se cortó por max_tokens, el JSON estará incompleto y sin poder parsearse, independientemente 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"])

Usar el mismo esquema para validación

Un patrón infrautilizado: usa el mismo JSON Schema que pasas a OpenAI para también validar datos que entran desde otras fuentes — webhooks, subidas de archivos, APIs de terceros. Esto te da una única fuente de verdad para la forma de tus datos. En Python, usa la biblioteca jsonschema. En Node.js, usa Ajv. También puedes pegar tu esquema en el Validador JSON para hacer una rápida comprobación manual sin escribir código.

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']}")
Construye tu esquema más rápido: Usa el Generador de JSON Schema para crear y refinar tu esquema visualmente — pega un objeto JSON de muestra y genera un esquema inicial automáticamente. Copia el resultado directamente en tu response_format de OpenAI.

Versión JavaScript / Node.js

El SDK de Node.js de OpenAI refleja la API de Python casi exactamente. La principal diferencia es que strict va dentro del objeto json_schema de la misma manera, y parseas la respuesta con JSON.parse(). La validación con Ajv es el equivalente en Node.js a la biblioteca jsonschema de Python — es más rápida y tiene excelente soporte 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}`)
);

Para cerrar

Structured Outputs elimina toda una categoría de bugs de producción — del tipo en que el modelo devuelve JSON casi-correcto que rompe tu parser a las 2 de la madrugada. El flujo es directo: diseña tu esquema con cuidado (cada objeto anidado necesita additionalProperties: false y un array required completo), pon strict: true, y maneja rechazos y truncaciones explícitamente. Una vez el esquema está en su sitio, puedes reutilizarlo en toda tu stack — en la llamada a OpenAI, en validación de webhooks, en tus fixtures de test — con bibliotecas como jsonschema (Python) o Ajv (Node.js). Si empiezas desde cero con un esquema, el Generador de JSON Schema es la forma más rápida de obtener un esquema base funcional a partir de un payload de muestra. Los días de hacer prompt-engineering para conseguir una salida JSON fiable se acabaron — usa la herramienta que se construyó para ello.