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
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.
{
"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
requireden 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 enrequired. additionalPropertiesdebe serfalseen 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
$refni esquemas recursivos en modo estricto. Todo debe estar inline. Si necesitas una definición reutilizable, cópiala. - Añade campos
descriptiongenerosamente. 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
# 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.
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.
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.
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 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.
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.