Desarrollo IATutorial

Structured Outputs en 2026: Dejá de Parsear Esperanzas, Parseá Schemas

3 de septiembre de 2026
11 min de lectura
Tutorial de structured outputs con Zod y Pydantic
Compartir:

Todo pipeline de LLMs en producción que vi se rompe en el mismo lugar: el momento en que el texto crudo del modelo se encuentra con JSON.parse. El modelo devuelve un JSON casi válido con una coma de más, inventa un campo que tu base de datos no tiene, o pierde silenciosamente una clave requerida — y tu pipeline muere a las 3 AM.

En este tutorial te muestro la salida a la 2026: structured outputs con garantías de schema. Strict mode de OpenAI, generateText del Vercel AI SDK con Output + Zod, strict tool use de Anthropic y validación con Pydantic e instructor — todo con código real que verifiqué contra la documentación oficial.

1. El Problema: JSON.parse No Es una Estrategia

Librado a su suerte, un LLM genera texto que parece JSON pero sin ninguna garantía. Un campo requerido ausente, un número devuelto como string, un valor de enum que nunca definiste, una clave extra que tu ORM rechaza — cualquiera de estas convierte una demo en un incidente. Insistir en el prompt (’devolvé SOLO JSON válido’) baja la tasa de fallos pero nunca la elimina, porque nada en el proceso de decodificación prohíbe realmente los tokens inválidos.

El Error Más Común

Shippear JSON mode y darlo por terminado. El JSON mode de OpenAI (response_format: { type: ’json_object’ }) garantiza que la salida parsea como JSON — y nada más. Nombres de campos, tipos, claves requeridas y valores de enum siguen quedando al humor del modelo. Para todo lo que tu código consume por nombre, necesitás Structured Outputs, no JSON mode.

Los structured outputs lo resuelven en la capa de muestreo: con constrained decoding, el proveedor compila tu JSON Schema en una gramática y el modelo físicamente no puede emitir tokens que la violen. Cada clave requerida presente, cada tipo correcto, cada valor de enum de tu lista. Esa garantía — disponible en OpenAI, Anthropic y vía librerías como Vercel AI SDK e instructor — es de lo que trata este tutorial.

2. Conceptos Mínimos Antes de Tocar Código

Seis ideas cubren el 90% de lo que necesitás. Con esto claro, cada ejemplo de código de abajo se lee como castellano simple.

🔒

Constrained decoding

el mecanismo central

Tu schema se compila en una gramática que restringe qué tokens puede emitir el modelo. La adherencia al schema se impone durante la generación, no se chequea después.

📐

Subset estricto de JSON Schema

reglas que hay que seguir

La raíz debe ser un objeto, cada propiedad listada en required, additionalProperties: false en cada objeto. Keywords de valor como pattern o minimum no se imponen — validalas vos.

⚙️

strict: true

el flag que importa

En OpenAI (response_format json_schema) y Anthropic (definiciones de tools) este flag cambia de ’el modelo hace lo que puede’ a ’el modelo no puede violar el schema’. Activalo siempre.

🛑

Refusals

la excepción deliberada

Cuando el modelo se niega por seguridad, la salida no va a matchear tu schema a propósito. OpenAI devuelve un campo refusal; Anthropic un stop reason de refusal. Ramificá antes de parsear.

🧰

Response format vs tool use

dos puertas, misma garantía

Usá response_format (u output_config) cuando el modelo te responde directamente en JSON; usá tools estrictas cuando el modelo llama tus funciones. Ambas dan garantías en modelos soportados.

Validá igual

confiá, después verificá

El strict mode garantiza forma, no semántica: una fecha de fin anterior al inicio igual parsea. Mantené validadores de Pydantic y refinamientos de Zod para reglas de negocio, más un test de conformidad en CI.

El modelo mental a conservar

JSON mode dice ’el modelo intenta devolver JSON, verificalo vos’. Structured outputs dice ’el decodificador no puede emitir tokens que violen tu schema’. Uno es una promesa, el otro es física. Por defecto usá structured outputs en cada integración nueva en 2026.

3. Paso 1 — Structured Outputs de OpenAI (Python + curl)

OpenAI expone structured outputs de dos formas equivalentes: declarar el JSON Schema crudo en response_format con strict: true, o pasarle al SDK un modelo de Pydantic y dejar que el helper parse derive el schema y devuelva un objeto tipado. Recomiendo la segunda — sin json.loads, sin validación manual, y los refusals aparecen como un campo de primera clase.

Python — parse tipado con Pydantic (recomendado)

from openai import OpenAI
from pydantic import BaseModel

client = OpenAI()

class SupportTicket(BaseModel):
    summary: str
    category: str
    severity: int
    account_id: str | None

completion = client.beta.chat.completions.parse(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "Extract the ticket into the schema."},
        {"role": "user", "content": "My checkout 500s with a saved card. Started today. Account: acct_8842."},
    ],
    response_format=SupportTicket,
)

ticket = completion.choices[0].message
if ticket.refusal:
    print("Refused:", ticket.refusal)
else:
    print(ticket.parsed)  # instancia de SupportTicket, garantizada por schema

¿Preferís HTTP crudo u otro lenguaje? La misma garantía está a un bloque response_format de distancia. Notá las tres reglas del strict mode dentro del schema: cada propiedad listada en required, additionalProperties en false, y enums explícitos. En los flagships más nuevos (la serie GPT-5 también soporta strict mode) la forma es idéntica — solo cambia el id del modelo, así que confirmá el soporte de strict en tu snapshot pineado en la documentación.

curl — Chat Completions con json_schema, strict true

curl https://api.openai.com/v1/chat/completions \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-4o-2024-08-06",
    "messages": [
      {"role": "system", "content": "Extract the ticket into the schema."},
      {"role": "user", "content": "My checkout 500s with a saved card. Account: acct_8842."}
    ],
    "response_format": {
      "type": "json_schema",
      "json_schema": {
        "name": "support_ticket",
        "strict": true,
        "schema": {
          "type": "object",
          "properties": {
            "summary": {"type": "string"},
            "category": {"type": "string", "enum": ["billing", "bug", "account", "other"]},
            "severity": {"type": "integer"},
            "account_id": {"anyOf": [{"type": "string"}, {"type": "null"}]}
          },
          "required": ["summary", "category", "severity", "account_id"],
          "additionalProperties": false
        }
      }
    }
  }'

Tip: nullable, no opcional

El strict mode no tiene concepto de ’campo opcional’: cada propiedad debe aparecer en required. Para permitir un valor ausente, uní el tipo con null (anyOf con null, o str | None en Pydantic) en vez de omitir la clave. Un schema que rompe esta regla falla con 400 antes de generar un solo token.

4. Paso 2 — generateText del Vercel AI SDK con Output + Zod

Si vivís en TypeScript, el Vercel AI SDK estandariza la generación estructurada entre proveedores vía generateText con un spec de output. Le pasás Output.object({ schema }) con tu schema de Zod, un prompt y un modelo; recibís un output totalmente tipado. El schema funciona también como validador, así que el tipo que ves en tu editor es la forma a la que el modelo fue constreñido. Nota: generateObject y streamObject están deprecados desde AI SDK 6 (PR #10754) y se eliminarán en una versión futura — generateText/streamText con Output es el reemplazo.

TypeScript — generateText con Output.object() y Zod

import { generateText, Output } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";

const { output } = await generateText({
  model: openai("gpt-4o-2024-08-06"),
  output: Output.object({
    schema: z.object({
      summary: z.string(),
      category: z.enum(["billing", "bug", "account", "other"]),
      severity: z.number(),
      accountId: z.string().nullable(),
    }),
  }),
  prompt: "Classify this ticket: My checkout 500s with a saved card. Account acct_8842.",
});

console.log(output.category); // tipado: "billing" | "bug" | "account" | "other"

Dos trampas que muerden a todos una vez

Primera: cuando el modelo no puede producir un objeto válido, generateText con output estructurado rechaza con AI_NoObjectGeneratedError — capturalo y reintentá o usá un fallback, nunca asumas que output existe. Segunda: con modelos de OpenAI usá .nullable(), nunca .optional() ni .nullish(): estos últimos generan patrones de JSON Schema fuera del subset soportado por OpenAI y la llamada falla con finish reason content-filter.

Arrays y choices — misma función, spec de Output

// Array de objetos: element describe UN elemento
const { output: heroes } = await generateText({
  model: openai("gpt-4o-2024-08-06"),
  output: Output.array({
    element: z.object({ name: z.string(), description: z.string() }),
  }),
  prompt: "Generate 3 hero descriptions for a fantasy game.",
});

// Clasificacion: conjunto fijo de valores, sin schema
const { output: genre } = await generateText({
  model: openai("gpt-4o-2024-08-06"),
  output: Output.choice({
    options: ["action", "comedy", "drama", "horror", "sci-fi"],
  }),
  prompt: "Classify this plot: astronauts cross a wormhole seeking a new home.",
});

5. Paso 3 — Strict Tools de Anthropic + Instructor con Pydantic

Dos caminos más completan la caja de herramientas. En Anthropic, strict tool use (strict: true en la definición del tool) compila tu input_schema en una gramática, así el input del bloque tool_use tiene validez de schema garantizada y el nombre del tool es siempre uno que definiste. En Python en general, la librería instructor te da la misma experiencia tipada que el helper parse de OpenAI — pero uniforme en más de 15 proveedores, con reintentos automáticos ante fallos de validación.

Python — strict tool use de Anthropic

import anthropic

client = anthropic.Anthropic()

tools = [
    {
        "name": "log_ticket",
        "description": "Log a classified support ticket.",
        "strict": True,
        "input_schema": {
            "type": "object",
            "properties": {
                "summary": {"type": "string"},
                "category": {"type": "string", "enum": ["billing", "bug", "account", "other"]},
                "severity": {"type": "integer"},
            },
            "required": ["summary", "category", "severity"],
            "additionalProperties": False,
        },
    }
]

response = client.messages.create(
    model="claude-opus-4-7",
    max_tokens=1024,
    tools=tools,
    messages=[{"role": "user", "content": "My checkout 500s with a saved card. Log it as a bug."}],
)

for block in response.content:
    if block.type == "tool_use":
        print(block.name, block.input)  # input con validez de schema garantizada

Y cuando querés independencia del proveedor — el mismo código contra OpenAI, Anthropic, Gemini o un modelo local — from_provider de instructor es la capa más fina que igual te da objetos tipados. Definís un modelo de Pydantic, lo pasás como response_model, y recibís la instancia directamente, con max_retries cubriendo los casos donde la validación falla.

Python — instructor con Pydantic (cualquier proveedor)

import instructor
from pydantic import BaseModel

client = instructor.from_provider("openai/gpt-4o-mini")

class User(BaseModel):
    name: str
    age: int

user = client.chat.completions.create(
    response_model=User,
    messages=[{"role": "user", "content": "John is 25 years old"}],
    max_retries=3,
)

print(user)  # User(name="John", age=25), totalmente tipado

Cuidado con el subset de schemas en Anthropic también

Las tools estrictas aceptan un subset de JSON Schema draft 2020-12: sin uniones top-level, sin restricciones numéricas o de strings (minimum, maxLength), sin pattern, y additionalProperties debe ser false. Los SDKs oficiales quitan keywords no soportadas y re-validan del lado del cliente — pero los schemas hechos a mano fuera del subset fallan con 400 antes de que el modelo corra. Mantené los schemas chicos y mové la lógica condicional a tu handler.

6. Una Rutina de Validación que Realmente Aguanta

Las garantías de schema eliminan una clase entera de bugs, pero no eliminan la disciplina de ingeniería. Esta es la rutina que corro en cada integración con structured outputs:

🔁 Cada response (milisegundos)

  1. 1. Chequeá primero el camino del refusal: message.refusal (OpenAI) o stop_reason de refusal (Anthropic) antes de tocar los datos parseados
  2. 2. Dejá que el objeto tipado haga su trabajo: ticket.parsed, result.output — nunca re-parsees strings de JSON crudo a mano
  3. 3. Imponé reglas de negocio post-parseo: rangos de fechas, consistencia entre campos, rangos de valores que el subset del schema no puede expresar

🧪 Cada deploy (CI, minutos)

  1. Test de conformidad: verificá una response viva por schema contra el JSON Schema exacto; fallá el build ante cualquier drift
  2. Simulacro de refusal: mandá un prompt adverso por schema y verificá que tu código tome la rama de refusal limpiamente
  3. Chequeo del modelo pineado: verificá que el snapshot pineado siga soportando strict mode — el soporte sigue a las versiones del modelo, no a tu código

🧹 Cada cambio de schema

  1. Auditoría del subset: ¿campo nuevo? Va en required, con additionalProperties: false en su objeto, y nullable en vez de opcional
  2. Latencia del primer request: los schemas nuevos pagan un costo de compilación único (cientos de ms) y después se cachean — precalentalos antes de medir
  3. Migrar rezagados: cada llamada json_object restante con forma conocida se vuelve un schema estricto; JSON mode queda solo para payloads genuinamente sin forma

7. Errores Comunes (Todos Arreglables en Minutos)

Casi cada fallo de structured outputs que debuggueé cae en uno de estos baldes. La columna izquierda es lo que hacen las integraciones que funcionan; la derecha es lo que te despierta de noche.

✅ Hacé esto

  • Activá strict: true en cada response_format y en cada definición de tool
  • Listá todos los campos en required; modelá la ausencia con uniones nullable
  • Poné additionalProperties: false en cada objeto, incluidos los anidados
  • Manejá refusals y truncamiento por max_tokens antes de parsear
  • Mantené validadores de Pydantic/Zod para la semántica que el schema no expresa
  • Pineá el snapshot del modelo y re-verificá el soporte de strict al actualizar

❌ No esto

  • ’Devolvé SOLO JSON válido’ en el system prompt en vez de un schema
  • JSON mode (json_object) para payloads que tu código consume por nombre
  • .optional() / .nullish() en schemas de Zod apuntando a strict mode de OpenAI
  • pattern, minimum o minLength como enforcement crítico
  • Arrays o uniones top-level como raíz del schema — envolvelos en un objeto
  • Asumir que la latencia del primer request es la latencia estable

Regla de oro

Si conocés los nombres de los campos, no hay razón para aceptar JSON inválido en 2026. Schemas estrictos para formas conocidas, JSON mode solo para payloads genuinamente libres, y un validador más un test para todo lo que llegue a producción.

Conclusión

Los structured outputs convierten la parte más frágil de las apps con LLMs — ’por favor devolvé JSON’ — en un contrato tipado impuesto en tiempo de generación. Viste el loop completo: response_format estricto en OpenAI con el helper parse, generateText con Output.object() y Zod en Vercel AI SDK, strict tool use en Anthropic, y extracción independiente del proveedor con instructor y Pydantic.

Elegí un camino esta semana y migrá un solo call site con JSON.parse: definí el schema, activá strict, agregá la rama de refusal y mantené un test de conformidad. Esa sola migración suele borrar más código de manejo de errores que cualquier otro cambio de su tamaño — y es la base sobre la que se va a parar cada agente, extractor y loop de tools que construyas después.

Machete del Tutorial

OpenAI

  • • helper parse + modelo Pydantic
  • • strict: true + required completo
  • • ramificá en message.refusal

Vercel + Anthropic

  • • generateText + Output.object()
  • • .nullable(), nunca .optional()
  • • tools estrictas + input_schema

Siempre

  • • Validá semántica post-parseo
  • • Test de conformidad en CI
  • • Pineá + verificá el modelo

Fuentes

Diego Rodriguez

Diego Rodriguez

Ingeniero Senior Full-Stack & AI

Diego tiene mas de 9 anos de experiencia construyendo aplicaciones potenciadas por IA de produccion, desde orquestacion de LLMs y pipelines RAG hasta deteccion de riesgos con ML y sistemas de trading algoritmico.

Conoce mas sobre Diego