Back

Structured Output de LLMs en 2026: Dejá de Parsear JSON con Regex y Hacelo Bien

Ya te pasó. Le pedís a GPT "devolveme un objeto JSON con el nombre, email y score de sentimiento del usuario." Te devuelve un JSON perfectamente formateado... envuelto en un bloque de código markdown. Con una explicación servicial. Y un disclaimer sobre cómo es una IA.

Entonces escribís un regex para sacar los code fences. Después otro regex para el comentario al final. Después te devuelve JSONL en vez de JSON. Después envuelve todo en {"result": ...} sin que se lo hayas pedido. Funciona perfecto durante 10,000 requests y explota catastróficamente en el request 10,001 porque el nombre del usuario tenía un caracter de comillas.

Este es el problema del structured output, y en 2026, no deberías estar resolviéndolo a mano.

Todos los proveedores principales de LLMs ahora ofrecen structured output nativo. Las herramientas (Pydantic para Python, Zod para TypeScript) maduraron enormemente. Y sin embargo, la mayoría de los desarrolladores siguen parseando strings crudos o usando function calling como un hack improvisado.

Esta guía cubre todo: cómo funciona el structured output por dentro, cómo implementarlo en OpenAI, Anthropic y Gemini, los ecosistemas de Python y TypeScript, y — lo más importante — las trampas de producción que te van a morder si no las conocés.


Por Qué el Structured Output Importa (Más de Lo Que Pensás)

El problema fundamental con los LLMs en producción:

Los LLMs son generadores de texto.
Tu aplicación necesita estructuras de datos.
En el espacio entre ambos es donde viven los bugs.

Cuando hacés JSON.parse() de una respuesta cruda de un LLM, estás asumiendo varias cosas peligrosas:

  1. La salida es JSON válido (puede que no)
  2. El JSON tiene los campos que esperás (puede que no)
  3. Los tipos de los campos son correctos (strings vs numbers vs booleans)
  4. Los valores están en rangos esperados (sentiment: -1 a 1, no "positive")
  5. La respuesta no contiene campos extra que no pediste
  6. El formato es consistente con diferentes inputs

El structured output elimina los seis problemas restringiendo la salida del modelo al nivel de generación de tokens — no después.

Los Tres Niveles de Control de Salida

Nivel 1: Prompt Engineering (No confiable)
  "Devolvé JSON con campos: name, email, score"
  → Funciona 80-95% del tiempo
  → Falla silenciosamente en edge cases
  → Sin garantías de tipo

Nivel 2: Function Calling / Tool Use (Mejor)
  Definís un schema de función, el modelo la "llama"
  → Funciona 95-99% del tiempo
  → El schema es una pista, no una restricción
  → Puede producir valores inválidos dentro de tipos válidos

Nivel 3: Native Structured Output (El mejor)
  Decodificación restringida con JSON Schema
  → Funciona 100% del tiempo (validez de schema garantizada)
  → Usa máquinas de estado finito para enmascarar tokens inválidos
  → Tipos Y valores se fuerzan en tiempo de generación

Si es producción, tenés que estar en Nivel 3.


Cómo Funciona Realmente el Structured Output

La mayoría de los devs lo tratan como caja negra: "Le paso un schema, me devuelve JSON válido." Pero entender el mecanismo importa para debugging y optimización.

Decodificación Restringida (La Magia Detrás del Telón)

Cuando un LLM genera texto, predice el siguiente token de un vocabulario de ~100,000+ tokens. Normalmente, cualquier token puede seguir a otro. El structured output agrega una capa de restricción:

Generación normal:
  Probabilidades: {"hello": 0.3, "{": 0.1, "The": 0.2, ...}
  → Cualquier token puede ser seleccionado

Generación restringida (esperando inicio de objeto JSON):
  Probabilidades: {"hello": 0.3, "{": 0.1, "The": 0.2, ...}
  Máscara: {"hello": 0, "{": 1, "The": 0, ...}
  → Solo "{" y tokens de whitespace son válidos
  → El modelo DEBE producir "{"

Esto se implementa con una Máquina de Estado Finito (FSM) que trackea dónde estás en el JSON schema:

FSM para {"name": string, "age": integer}:

INICIO → esperar "{"
  → esperar "\"name\""
    → esperar ":"
      → esperar valor string
        → esperar "," o "}"
          → si ",": esperar "\"age\""
            → esperar ":"
              → esperar valor entero
                → esperar "}"
                  → FIN

En cada estado, la FSM enmascara todos los tokens que violarían el schema. El modelo sigue eligiendo el token válido más probable, preservando calidad mientras garantiza estructura.

Por Qué Es Mejor que Prompt Engineering

Prompt: "Devolvé un objeto JSON con 'score' como número entre 0 y 1"

Sin decodificación restringida:
  Puede dar: {"score": "0.85"}     ← string, no número
  Puede dar: {"score": 0.85, "confidence": "high"}  ← campo extra
  Puede dar: {"score": 85}         ← fuera de rango
  Puede dar: ¡Claro! Acá tenés el JSON: {"score": 0.85}  ← preámbulo

Con decodificación restringida:
  Siempre da: {"score": 0.85}      ← siempre válido

Implementación: OpenAI

El structured output de OpenAI es el más maduro. Está disponible en la API de Chat Completions con response_format.

Uso Básico

from openai import OpenAI from pydantic import BaseModel client = OpenAI() class SentimentAnalysis(BaseModel): sentiment: str # "positive", "negative", "neutral" confidence: float key_phrases: list[str] reasoning: str response = client.beta.chat.completions.parse( model="gpt-5-mini", messages=[ {"role": "system", "content": "Analizá el sentimiento del texto dado."}, {"role": "user", "content": "Este producto es absolutamente terrible. La peor compra de mi vida."} ], response_format=SentimentAnalysis, ) result = response.choices[0].message.parsed print(result.sentiment) # "negative" print(result.confidence) # 0.95 print(result.key_phrases) # ["absolutamente terrible", "peor compra"]

Con Enums y Objetos Anidados

from enum import Enum from pydantic import BaseModel, Field class Sentiment(str, Enum): positive = "positive" negative = "negative" neutral = "neutral" mixed = "mixed" class Entity(BaseModel): name: str type: str = Field(description="person, organization, product, or location") sentiment: Sentiment class FullAnalysis(BaseModel): overall_sentiment: Sentiment confidence: float = Field(ge=0.0, le=1.0) entities: list[Entity] summary: str = Field(max_length=200) topics: list[str] = Field(min_length=1, max_length=5) response = client.beta.chat.completions.parse( model="gpt-5-mini", messages=[ {"role": "system", "content": "Extraé un análisis estructurado del texto."}, {"role": "user", "content": "La nueva MacBook Pro de Apple es increíble, pero el keynote de Tim Cook fue aburrido."} ], response_format=FullAnalysis, ) result = response.choices[0].message.parsed # result.entities = [ # Entity(name="Apple", type="organization", sentiment="positive"), # Entity(name="MacBook Pro", type="product", sentiment="positive"), # Entity(name="Tim Cook", type="person", sentiment="negative"), # ]

TypeScript + Zod

import OpenAI from 'openai'; import { z } from 'zod'; import { zodResponseFormat } from 'openai/helpers/zod'; const client = new OpenAI(); const SentimentSchema = z.object({ sentiment: z.enum(['positive', 'negative', 'neutral', 'mixed']), confidence: z.number().min(0).max(1), entities: z.array(z.object({ name: z.string(), type: z.enum(['person', 'organization', 'product', 'location']), sentiment: z.enum(['positive', 'negative', 'neutral']), })), summary: z.string(), topics: z.array(z.string()).min(1).max(5), }); type Sentiment = z.infer<typeof SentimentSchema>; const response = await client.beta.chat.completions.parse({ model: 'gpt-5-mini', messages: [ { role: 'system', content: 'Extraé análisis estructurado del texto.' }, { role: 'user', content: 'El nuevo compilador de React es genial pero la documentación de migración está floja.' }, ], response_format: zodResponseFormat(SentimentSchema, 'sentiment_analysis'), }); const result: Sentiment = response.choices[0].message.parsed!; console.log(result.sentiment); // "mixed"

Implementación: Anthropic (Claude)

Anthropic usa tool use (function calling) como mecanismo para structured output. Definís una herramienta con un JSON schema, y Claude devuelve datos estructurados como si llamara a esa herramienta.

Uso Básico

import anthropic from pydantic import BaseModel client = anthropic.Anthropic() class ExtractedData(BaseModel): name: str email: str company: str role: str urgency: str # "low", "medium", "high", "critical" response = client.messages.create( model="claude-sonnet-4-20260514", max_tokens=1024, tools=[{ "name": "extract_contact", "description": "Extraer información de contacto del email.", "input_schema": ExtractedData.model_json_schema(), }], tool_choice={"type": "tool", "name": "extract_contact"}, messages=[{ "role": "user", "content": """Extraé la info de contacto de este email: Hola, soy Sarah Chen de DataFlow Inc. Nuestro pipeline de producción está caído y necesitamos ayuda inmediata. Contactame en [email protected] — soy la VP de Engineering.""", }], ) tool_result = next( block for block in response.content if block.type == "tool_use" ) data = ExtractedData(**tool_result.input) print(data.name) # "Sarah Chen" print(data.urgency) # "critical"

TypeScript + Zod + Anthropic

import Anthropic from '@anthropic-ai/sdk'; import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; const client = new Anthropic(); const ContactSchema = z.object({ name: z.string(), email: z.string().email(), company: z.string(), role: z.string(), urgency: z.enum(['low', 'medium', 'high', 'critical']), }); const response = await client.messages.create({ model: 'claude-sonnet-4-20260514', max_tokens: 1024, tools: [{ name: 'extract_contact', description: 'Extraer información de contacto del email.', input_schema: zodToJsonSchema(ContactSchema) as Anthropic.Tool.InputSchema, }], tool_choice: { type: 'tool' as const, name: 'extract_contact' }, messages: [{ role: 'user', content: 'Extraé: Hola, soy John Park, CTO de Acme Corp ([email protected]). No es urgente.', }], }); const toolBlock = response.content.find( (block): block is Anthropic.ToolUseBlock => block.type === 'tool_use' ); const data = ContactSchema.parse(toolBlock!.input); console.log(data.urgency); // "low"

Implementación: Google Gemini

Gemini soporta structured output nativamente a través de su parámetro response_schema. Usa decodificación restringida similar a OpenAI.

Uso Básico

import google.generativeai as genai from pydantic import BaseModel from enum import Enum class Priority(str, Enum): low = "low" medium = "medium" high = "high" critical = "critical" class TaskExtraction(BaseModel): title: str assignee: str priority: Priority deadline: str | None tags: list[str] model = genai.GenerativeModel( "gemini-2.5-flash", generation_config=genai.GenerationConfig( response_mime_type="application/json", response_schema=TaskExtraction, ), ) response = model.generate_content( "Extraé la tarea: 'John tiene que arreglar el bug de login para el viernes. Está bloqueando prod. Taguealo como backend y auth.'" ) import json result = TaskExtraction(**json.loads(response.text)) print(result.priority) # "critical" print(result.tags) # ["backend", "auth"]

Tabla Comparativa de Proveedores

Antes de elegir proveedor para structured output, acá va cómo se comparan:

Feature              OpenAI           Anthropic         Gemini
─────────────────    ──────────────   ──────────────    ──────────────
Método               SO Nativo        Tool Use          SO Nativo
Decoded restringido  Sí               Parcial           Sí
100% schema válido   Garantizado      99%+              Garantizado
Streaming            Sí               Sí                Sí
Pydantic nativo      .parse()         Schema manual     Schema manual
Zod nativo           Helper           Conversión manual Conversión manual
Nested objects       Sí               Sí                Sí
Enums                Sí               Sí                Sí
Campos opcionales    Sí               Sí                Sí
Schemas recursivos   Limitado         Sí                Limitado
Max depth schema     5 niveles        Sin límite        Sin límite
Manejo de refusal    Sí               N/A               N/A

Recomendación: Si necesitás cumplimiento 100% del schema, usá el structured output nativo de OpenAI o Gemini. Si ya estás en Claude, el patrón de tool use funciona bien pero agregá validación con Pydantic/Zod como red de seguridad.


Patrones de Producción Que Realmente Funcionan

Patrón 1: El Sandwich de Validación

Nunca confíes directamente en la salida del LLM, ni con structured output. Siempre validá.

from pydantic import BaseModel, Field, field_validator from openai import OpenAI client = OpenAI() class ProductReview(BaseModel): rating: int = Field(ge=1, le=5) title: str = Field(min_length=5, max_length=100) pros: list[str] = Field(min_length=1, max_length=5) cons: list[str] = Field(max_length=5) would_recommend: bool @field_validator('title') @classmethod def title_not_generic(cls, v: str) -> str: generic_titles = ['good', 'bad', 'ok', 'fine', 'great'] if v.lower().strip() in generic_titles: raise ValueError(f'Título muy genérico: {v}') return v def extract_review(text: str) -> ProductReview: response = client.beta.chat.completions.parse( model="gpt-5-mini", messages=[ {"role": "system", "content": "Extraé una reseña de producto estructurada."}, {"role": "user", "content": text}, ], response_format=ProductReview, ) result = response.choices[0].message.parsed if response.choices[0].message.refusal: raise ValueError(f"Modelo rechazó: {response.choices[0].message.refusal}") # Re-validá aunque OpenAI garantice cumplimiento de schema # (atrapa violaciones de lógica de negocio que JSON Schema no puede expresar) return ProductReview.model_validate(result.model_dump())

Patrón 2: Retry con Escalamiento

El structured output falla raramente, pero pasa. Escalá con gracia:

from tenacity import retry, stop_after_attempt, wait_exponential @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=10), ) def extract_with_retry(text: str, schema: type[BaseModel]) -> BaseModel: try: response = client.beta.chat.completions.parse( model="gpt-5-mini", messages=[ {"role": "system", "content": "Extraé datos estructurados con precisión."}, {"role": "user", "content": text}, ], response_format=schema, ) result = response.choices[0].message.parsed return schema.model_validate(result.model_dump()) except Exception as e: print(f"Intento falló: {e}") raise try: review = extract_with_retry(user_text, ProductReview) except Exception: review = extract_with_retry(user_text, SimpleReview)

Patrón 3: Fallback Multi-Proveedor

No te ates a un solo proveedor. Armá una cadena de fallback:

import OpenAI from 'openai'; import Anthropic from '@anthropic-ai/sdk'; import { z } from 'zod'; import { zodResponseFormat } from 'openai/helpers/zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; const schema = z.object({ intent: z.enum(['question', 'complaint', 'feedback', 'request']), urgency: z.enum(['low', 'medium', 'high']), summary: z.string().max(200), action_required: z.boolean(), }); type TicketClassification = z.infer<typeof schema>; async function classifyTicket(text: string): Promise<TicketClassification> { // Probá OpenAI primero (structured output más rápido) try { const openai = new OpenAI(); const response = await openai.beta.chat.completions.parse({ model: 'gpt-5-mini', messages: [ { role: 'system', content: 'Clasificá el ticket de soporte.' }, { role: 'user', content: text }, ], response_format: zodResponseFormat(schema, 'ticket'), }); return schema.parse(response.choices[0].message.parsed); } catch (openaiError) { console.warn('OpenAI falló, cayendo a Claude:', openaiError); } // Fallback a Anthropic try { const anthropic = new Anthropic(); const response = await anthropic.messages.create({ model: 'claude-sonnet-4-20260514', max_tokens: 512, tools: [{ name: 'classify', description: 'Clasificar el ticket.', input_schema: zodToJsonSchema(schema) as Anthropic.Tool.InputSchema, }], tool_choice: { type: 'tool' as const, name: 'classify' }, messages: [{ role: 'user', content: `Clasificá: ${text}` }], }); const toolBlock = response.content.find( (b): b is Anthropic.ToolUseBlock => b.type === 'tool_use' ); return schema.parse(toolBlock!.input); } catch (anthropicError) { console.error('Ambos proveedores fallaron:', anthropicError); throw new Error('Todos los proveedores de LLM fallaron para structured output'); } }

Patrón 4: Streaming Structured Output

Para respuestas estructuradas largas, transmití resultados parciales:

from openai import OpenAI from pydantic import BaseModel client = OpenAI() class Article(BaseModel): title: str sections: list[dict] # {"heading": str, "content": str} tags: list[str] word_count: int with client.beta.chat.completions.stream( model="gpt-5", messages=[ {"role": "system", "content": "Generá un outline de artículo con secciones detalladas."}, {"role": "user", "content": "Escribí sobre WebAssembly en 2026."} ], response_format=Article, ) as stream: for event in stream: snapshot = event.snapshot if snapshot and snapshot.choices[0].message.content: partial = snapshot.choices[0].message.content print(f"Recibiendo: {len(partial)} chars...") final = stream.get_final_completion() article = final.choices[0].message.parsed print(f"Artículo: {article.title} ({article.word_count} palabras)")

Las Trampas Que Nadie Te Cuenta

Trampa 1: El Impuesto por Complejidad del Schema

Cada restricción que agregás a tu schema aumenta la latencia. Schemas complejos con objetos profundamente anidados pueden duplicar o triplicar tu tiempo de respuesta.

Complejidad de schema vs latencia (gpt-5-mini, promedio):

Schema                          tok/s       Primer Token   Tiempo Total
─────────────────────────────  ──────────  ─────────────  ──────────
Sin schema (texto libre)        85 tok/s    ~200ms         ~500ms
Simple (3 campos)               78 tok/s    ~250ms         ~550ms
Medio (10 campos, 1 enum)       65 tok/s    ~350ms         ~800ms
Complejo (20+ campos, nested)   45 tok/s    ~500ms         ~1.5s
Muy complejo (recursivo)        30 tok/s    ~800ms         ~3s

Solución: Dividí schemas complejos en múltiples llamadas más chicas:

# ❌ Un schema gigante class FullDocumentAnalysis(BaseModel): entities: list[Entity] # 20+ campos cada uno sentiment: SentimentDetail # 10+ campos summary: Summary # 5 campos classification: Classification # 8 campos # ✅ Pipeline de schemas chicos class Step1_Entities(BaseModel): entities: list[SimpleEntity] class Step2_Sentiment(BaseModel): overall: str confidence: float aspects: list[str] class Step3_Classification(BaseModel): category: str subcategory: str priority: str import asyncio entities, sentiment, classification = await asyncio.gather( extract(text, Step1_Entities), extract(text, Step2_Sentiment), extract(text, Step3_Classification), )

Trampa 2: El Infierno del Versionado de Schemas

Tu app evoluciona. Tu schema evoluciona. Pero el LLM no sabe que renombraste user_name a name el martes pasado.

# Versión 1 (deploy en enero) class UserProfile_v1(BaseModel): user_name: str email_address: str age: int # Versión 2 (deploy en febrero) class UserProfile_v2(BaseModel): name: str # ¡renombrado! email: str # ¡renombrado! age: int location: str | None # campo nuevo

Solución: Usá versionado explícito de schemas y migración:

from pydantic import BaseModel, Field from typing import Literal class UserProfile(BaseModel): schema_version: Literal["2.0"] = "2.0" name: str = Field(alias="user_name") email: str = Field(alias="email_address") age: int location: str | None = None class Config: populate_by_name = True

Trampa 3: La Trampa del Array Vacío

Los LLMs odian devolver arrays vacíos cuando genuinamente no hay nada que extraer. Van a alucinar entradas para "llenar" el array.

# Input: "El clima está lindo hoy." # Esperado: {"entities": [], "topics": ["clima"]} # Real: {"entities": [{"name": "clima", "type": "concept"}], "topics": ["clima"]} # Solución: Hacé que los arrays vacíos sean explícitamente válidos class Extraction(BaseModel): entities: list[Entity] = Field( default_factory=list, description="Entidades nombradas en el texto. Devolver lista vacía [] si no hay ninguna." )

Trampa 4: Confusión de Enums con Valores Similares

class Priority(str, Enum): low = "low" medium = "medium" high = "high" critical = "critical" urgent = "urgent" # ← ¿En qué se diferencia de "critical"? # El modelo va a elegir inconsistentemente entre "critical" y "urgent" # porque NI ÉL sabe la diferencia. # Solución: Usá menos valores de enum, claramente distintos + descripciones class Priority(str, Enum): low = "low" # Puede esperar días/semanas medium = "medium" # Debe manejarse este sprint high = "high" # Necesita atención hoy critical = "critical" # Producción caída, arreglar YA

Trampa 5: Límites de Tokens y Truncamiento

El structured output no ignora los límites de tokens. Si tu schema requiere un campo summary con max_length=500 pero el modelo llega a max_tokens antes de completar el JSON, obtenés JSON inválido.

Solución: Siempre poné max_tokens significativamente más alto que tu output esperado, y manejá el finish_reason:

response = client.beta.chat.completions.parse( model="gpt-5-mini", messages=[...], response_format=MySchema, max_tokens=4096, ) if response.choices[0].finish_reason == "length": raise ValueError("Respuesta truncada — aumentá max_tokens o simplificá el schema")

Trampa 6: La Trampa del Refusal (Solo OpenAI)

El structured output de OpenAI puede rechazar generar contenido si el input dispara filtros de seguridad.

response = client.beta.chat.completions.parse( model="gpt-5-mini", messages=[ {"role": "user", "content": "Analizá esta queja del cliente: [contenido potencialmente sensible]"} ], response_format=Analysis, ) parsed = response.choices[0].message.parsed refusal = response.choices[0].message.refusal if refusal: print(f"Modelo rechazó: {refusal}") elif parsed: process(parsed)

Pydantic vs Zod: La Comparación Definitiva

Feature                  Pydantic (Python)     Zod (TypeScript)
───────────────────────  ────────────────────  ────────────────────
Inferencia de tipo       Desde anotaciones     Desde .infer<>
Validación runtime       Built-in              Built-in
Export JSON Schema       .model_json_schema()  zodToJsonSchema()
Valores default          Field(default=...)    .default(value)
Validators custom        @field_validator      .refine() / .transform()
Objetos anidados         Nativo                Nativo
Unions discriminadas     Soportado             .discriminatedUnion()
Schemas recursivos       Soportado             z.lazy()
Serialización            .model_dump()         N/A (objetos planos)
Integración ORM          Sí (SQLAlchemy)       Drizzle/Prisma
Soporte OpenAI nativo    .parse()              zodResponseFormat
Integración Anthropic    .model_json_schema()  zodToJsonSchema()

Cuándo Usar Pydantic

from pydantic import BaseModel, Field, field_validator, model_validator class Invoice(BaseModel): items: list[LineItem] subtotal: float tax_rate: float = Field(ge=0, le=0.5) total: float @model_validator(mode='after') def validate_total(self) -> 'Invoice': expected = self.subtotal * (1 + self.tax_rate) if abs(self.total - expected) > 0.01: raise ValueError( f'Total {self.total} no coincide con ' f'subtotal {self.subtotal} × (1 + {self.tax_rate}) = {expected}' ) return self

Cuándo Usar Zod

const InvoiceSchema = z.object({ items: z.array(LineItemSchema), subtotal: z.number().positive(), taxRate: z.number().min(0).max(0.5), total: z.number().positive(), }).refine( (data) => Math.abs(data.total - data.subtotal * (1 + data.taxRate)) < 0.01, { message: 'total no coincide con subtotal × (1 + taxRate)' } ); type Invoice = z.infer<typeof InvoiceSchema>;

Patrón Avanzado: Composición de Schemas para Workflows Complejos

Las aplicaciones reales rara vez necesitan un solo schema. Acá va cómo componer schemas para un pipeline de extracción multi-paso:

from pydantic import BaseModel, Field from enum import Enum from openai import OpenAI client = OpenAI() # Paso 1: Clasificación rápida (modelo rápido y barato) class TicketType(str, Enum): bug = "bug" feature = "feature" question = "question" billing = "billing" class QuickClassification(BaseModel): type: TicketType language: str = Field(description="Lenguaje de programación si aplica, sino 'N/A'") needs_human: bool # Paso 2: Extracción detallada (solo para bugs, modelo más inteligente) class BugReport(BaseModel): title: str = Field(max_length=100) steps_to_reproduce: list[str] = Field(min_length=1) expected_behavior: str actual_behavior: str environment: dict[str, str] severity: str = Field(description="minor, major, or critical") # Paso 3: Ruteo automático class RoutingDecision(BaseModel): team: str = Field(description="backend, frontend, infra, or billing") priority: int = Field(ge=1, le=5) suggested_assignee: str | None auto_reply: str = Field(max_length=500) async def process_ticket(text: str): classification = await extract(text, QuickClassification, model="gpt-5-mini") if classification.needs_human: return route_to_human(text) details = None if classification.type == TicketType.bug: details = await extract(text, BugReport, model="gpt-5") context = f"Tipo: {classification.type}. " if details: context += f"Severidad: {details.severity}. Pasos: {details.steps_to_reproduce}" routing = await extract(context, RoutingDecision, model="gpt-5-mini") return { "classification": classification, "details": details, "routing": routing, }

Optimización de Costos: El Structured Output No Es Gratis

El structured output agrega overhead:

Factores de costo:

1. Tokens de schema: El JSON schema se incluye en el system prompt
   Schema simple (3 campos):   ~50 tokens  ($0.00001)
   Complejo (20 campos):       ~500 tokens ($0.0001)
   Muy complejo (nested):      ~2000 tokens ($0.0004)

2. Tokens de output: SO genera más tokens que texto libre
   "El sentimiento es positivo" = 5 tokens
   {"sentiment": "positive"}  = 7 tokens (~40% más)

3. Overhead de latencia: ~10-30% más

Impacto mensual (1M requests/día):
────────────────────────────────────────────
Enfoque                Tokens/req  Costo/mes   Latencia
Texto libre            50          $1,500      200ms
Structured simple      70          $2,100      250ms
Structured complejo    200         $6,000      400ms

Estrategia de ahorro:
  → Usá SO solo donde realmente lo necesitás
  → Texto libre para resúmenes, structured para extracción
  → Cacheá respuestas agresivamente
  → gpt-5-mini para clasificación, gpt-5 para extracción compleja

Framework de Decisión

No todo necesita structured output:

Usá Structured Output cuando:
  ✅ El output va directo a código (API responses, inserts en DB)
  ✅ Necesitás garantías de tipo (números tienen que ser números)
  ✅ Múltiples consumidores dependen de formato consistente
  ✅ Estás construyendo pipelines automatizados (sin humano en el loop)
  ✅ Extracción de datos de texto no estructurado

No uses Structured Output cuando:
  ❌ El output se muestra directo al usuario (chat, generación de contenido)
  ❌ Necesitás respuestas creativas y libres
  ❌ El schema sería más complejo que la tarea
  ❌ Estás prototipando y el schema cambia todos los días
  ❌ El costo es prioridad y texto libre funciona bien

Qué Viene Después

2026 Q1-Q2 (Ahora)

  • ✅ Structured output de OpenAI GA con streaming
  • ✅ Tool use de Anthropic estable en Claude Sonnet/Opus
  • ✅ Gemini 2.5 modo JSON nativo con enforcement de schema
  • 🔄 Pydantic v3 beta con hooks nativos para LLM
  • 🔄 Zod v4 con mejor compatibilidad JSON Schema

2026 Q3-Q4

  • Portabilidad de schemas cross-provider (un schema, cualquier LLM)
  • Streaming de objetos parciales con callbacks a nivel de campo
  • Auto-generación de schemas desde interfaces TypeScript
  • Decodificación restringida para imágenes y audio (SO multimodal)

2027 y Más Allá

  • Structured output se vuelve el default (texto libre pasa a ser opt-in)
  • LLMs que negocian cambios de schema en runtime
  • Validación embebida directamente en los pesos del modelo

Conclusión

El structured output en 2026 ya no es opcional para aplicaciones LLM en producción. Los días de parsear respuestas de GPT con regex y rezar se terminaron.

Los puntos clave:

  1. Usá structured output nativo (.parse() de OpenAI, response_schema de Gemini). No armes tu propio parser de JSON.
  2. Siempre validá con Pydantic o Zod, incluso cuando el proveedor garantiza cumplimiento del schema. La validación de lógica de negocio atrapa lo que JSON Schema no puede.
  3. Vigilá los costos. Schemas complejos son caros. Dividilos en llamadas más chicas y paralelas.
  4. Manejá edge cases: refusals, truncamiento, arrays vacíos y confusión de enums te van a morder en producción.
  5. Armá cadenas de fallback. Ningún proveedor es 100% confiable. Usá patrones multi-proveedor para paths críticos.

La pregunta real no es "¿debería usar structured output?" Es "¿por qué seguís parseando texto libre con regex en 2026?"

LLMStructured OutputOpenAIAnthropicGeminiPydanticZodAI EngineeringTypeScriptPython

Explora herramientas relacionadas

Prueba estas herramientas gratuitas de Pockit