Cada sistema de LLM que he llevado a producción ha chocado con la misma pared, y no es la que todos te advierten. No es el razonamiento, ni la precisión, ni la latencia. Es el formato.
El prototipo funciona en un notebook. El modelo identifica entidades, clasifica el sentimiento y resume documentos con buena redacción. Luego, lo integras en un sistema real y todo se desmorona. El modelo envuelve el JSON en un bloque de código markdown. Agrega un "Claro, aquí tienes el JSON que pediste:" antes del payload real. Devuelve "true" como un string cuando el esquema pedía un booleano. Inventa un campo que no está en tu esquema. He tenido que depurar cada uno de estos errores en producción, y la mayoría explotaron un viernes a las 4 p. m.
Este es el problema de las salidas estructuradas (structured outputs). Lograr que funcionen correctamente es una de las partes más subestimadas de operar LLMs en producción, y las herramientas han mejorado más de lo que la mayoría de la gente cree.
El enfoque ingenuo: prompt engineering y regex
El primer instinto es pedirlo amablemente. Añades "Devuelve un JSON válido sin texto adicional" al prompt y lo lanzas. Cuando eso falla el 5% de las veces, envuelves el parseo en un try/except e intentas de nuevo. Cuando eso sigue sin ser suficiente, recurres a regex.
import re
import json
def extract_json(text: str) -> dict:
# Intenta encontrar JSON en la respuesta
match = re.search(r'\{{.*\}}', text, re.DOTALL)
if match:
return json.loads(match.group())
raise ValueError("No JSON found in response")Esto funciona hasta que deja de hacerlo. Las llaves anidadas rompen la regex. Las comas faltantes pasan la regex y hacen que json.loads explote. El modelo ocasionalmente devuelve un array cuando pediste un objeto. Terminas manteniendo un parser frágil para un problema que no debería existir en absoluto.
El problema real es que la decodificación de los LLM no tiene restricciones por defecto. En cada token, el modelo asigna una probabilidad a todo su vocabulario, y nada le impide elegir uno que viole tu esquema. La solución es restringir al decodificador mismo, no al prompt.
Decodificación restringida: forzando la estructura a nivel de token
La decodificación restringida (constrained decoding) cambia el paso de muestreo (sampling). Antes de cada token, el sistema filtra el vocabulario para dejar solo los tokens que mantienen la salida consistente con un esquema. Algunos equipos lo llaman generación guiada por gramática o logit masking. Es la misma idea con diferente nombre.
La mecánica:
- Defines un esquema: JSON Schema, un modelo de Pydantic, una gramática libre de contexto, una regex.
- Antes de cada muestra, el sistema verifica qué tokens del vocabulario mantienen la validez del esquema de salida.
- A cualquier otro token se le asigna un logit de infinito negativo, lo que reduce su probabilidad a cero.
- El modelo toma una muestra de los tokens válidos restantes.
Si el esquema dice que "age" es un entero y el modelo acaba de emitir "age":, solo los tokens que comienzan un entero válido tienen una probabilidad distinta de cero. El modelo literalmente no puede emitir "veinticinco" o null porque esos tokens no están permitidos.
Schema: {{ "age": integer, "name": string }}
Step 1: Generate `{{` → solo `{{` es válido (inicio de objeto)
Step 2: Generate `"age"` → solo se permiten nombres de campo válidos
Step 3: Generate `:` → requerido después del nombre del campo
Step 4: Generate `25` → solo se permiten tokens numéricos (tipo entero)
Step 5: Generate `,` → se permite coma o `}}` (quedan más campos)
...
Se garantiza que el resultado podrá ser parseado. No "probablemente". No "usualmente". Garantizado. El modelo sigue eligiendo el contenido —qué entero, qué string— pero la estructura está bloqueada por el esquema.
Aquí se ve con una entrada real y desordenada. A continuación, se muestran dos ejecuciones registradas de Gemini Flash con un esquema adjunto (un objeto de pedido: cliente, artículos con cantidades, prioridad, total opcional y fecha). Aliméntalo con un correo electrónico errático o un garabato de chat y observa cómo sale un JSON estructurado. Estas son ejecuciones reales capturadas y reproducidas:
Sin prosa, sin bloques de código markdown que limpiar: la restricción del esquema significa que el modelo solo puede emitir un objeto de pedido válido. Observa cómo mapea "envíanos 4 bolsas urgente" a una cantidad y prioridad "high", y "sin prisa cuando sea" a prioridad "low", omitiendo los campos que no están en el mensaje en lugar de alucinar un total. El bloque verde al final es el resultado parseado y válido según el esquema.
Soluciones modernas en la práctica
El ecosistema se ha estabilizado en un puñado de enfoques, cada uno con sus propias ventajas y desventajas.
Modo JSON a nivel de API y salidas estructuradas
OpenAI, Google y Anthropic admiten alguna forma de salida estructurada. OpenAI acepta un response_format: {{ type: "json_schema", json_schema: {{...}} }} y garantiza que la respuesta coincida con él. Gemini acepta response_mime_type: "application/json" con un esquema opcional. Estas son las opciones más fáciles de usar: pasas un esquema y recibes una salida válida. El detalle es que estás atado a un proveedor, y la cobertura de las características de JSON Schema varía; algunas construcciones que funcionan en OpenAI fallan silenciosamente en Gemini y viceversa.
Function calling y tool use
El function calling son salidas estructuradas con otro nombre. Defines una herramienta (tool) con un esquema de parámetros y el modelo emite una llamada que coincide con él. Este es el patrón por defecto para los agentes, y encaja cuando la salida es naturalmente una acción: una búsqueda, una escritura en base de datos, una solicitud de API.
tools = [{{
"type": "function",
"function": {{
"name": "extract_contact",
"parameters": {{
"type": "object",
"properties": {{
"name": {{"type": "string"}},
"email": {{"type": "string", "format": "email"}},
"phone": {{"type": "string"}}
}},
"required": ["name", "email"]
}}
}}
}}]Un ejemplo concreto: salida estructurada de Gemini y una llamada a función
Hablar de esquemas de forma abstracta cansa rápido. Aquí está la misma idea de extremo a extremo con Gemini y un pequeño dominio de eventos de calendario; primero como extracción de datos, luego como una llamada a una herramienta. Dos posturas del mismo decodificador restringido.
Paso 1: definir el esquema con Pydantic
from pydantic import BaseModel, Field
class MeetingRequest(BaseModel):
title: str = Field(description="Short meeting title")
attendees: list[str] = Field(description="Names or emails of attendees")
start_iso: str = Field(description="ISO 8601 start, e.g. 2026-05-20T12:00:00")
duration_minutes: int = Field(ge=5, le=480)Pydantic es la fuente de verdad para la estructura. Los strings en description se envían al modelo junto con los nombres de los campos y actúan como pistas contextuales. Las restricciones ge (mayor o igual) y le (menor o igual) se convierten en límites de JSON Schema, por lo que el decodificador enmascara cualquier token que empuje duration_minutes a 0 o 999. El modelo no puede elegir un valor fuera de rango porque esos tokens no están disponibles en el momento del muestreo.
Paso 2: llamar a Gemini con response_schema
from google import genai
client = genai.Client() # lee GEMINI_API_KEY
response = client.models.generate_content(
model="gemini-2.5-flash",
contents="Schedule lunch with Maria next Friday at noon for 1 hour",
config={{
"response_mime_type": "application/json",
"response_schema": MeetingRequest,
}},
)
meeting: MeetingRequest = response.parsed
print(meeting.title, meeting.start_iso, meeting.duration_minutes)Tres cosas están haciendo el trabajo aquí:
response_mime_type="application/json"activa el modo JSON en Gemini. Sin prosa, sin bloques de código, sin preámbulos amigables.response_schema=MeetingRequestes la restricción real. El SDK serializa la clase Pydantic a JSON Schema y la envía con la solicitud. El decodificador la usa para enmascarar cualquier token que viole la forma, los tipos o los límites.response.parsedte entrega una instancia deMeetingRequestya poblada. Sinjson.loads, sin try/except, sin cirugías defensivas de strings. Si el modelo hubiera producido un JSON inválido, la llamada falla claramente en lugar de devolver basura que rompa algo más adelante.
Ese es el patrón completo de salida estructurada. Esquema de entrada, objeto tipado de salida.
Paso 3: definir la herramienta (tool)
Mismo dominio, expuesto como algo que el modelo puede llamar en lugar de algo que debe completar:
def create_calendar_event(
title: str,
attendees: list[str],
start_iso: str,
duration_minutes: int,
) -> dict:
"""Create a calendar event and return its ID and status."""
# En código real, esto conectaría con Google Calendar o tu agenda.
event_id = "evt_" + start_iso.replace(":", "").replace("-", "")
return {{"event_id": event_id, "status": "created"}}Python puro. Sin archivos de esquema separados, sin decoradores. El SDK de Gemini lee la firma de la función y el docstring para construir el esquema de la herramienta a partir de ellos. Los nombres de los argumentos, los type hints y el docstring son el contrato; si renombras attendees a participants, el contrato que ve el modelo también cambia.
Paso 4: dejar que Gemini llame a la herramienta
from google.genai import types
response = client.models.generate_content(
model="gemini-2.5-flash",
contents="Book a 30 minute design review with Ana and Luis tomorrow at 10am",
config=types.GenerateContentConfig(
tools=[create_calendar_event],
),
)
# Gemini decidió llamar a la herramienta. El SDK no la ejecuta automáticamente por defecto.
for part in response.candidates[0].content.parts:
if part.function_call:
fc = part.function_call
result = create_calendar_event(**fc.args)
print(fc.name, "->", result)Algunos detalles que importan:
tools=[create_calendar_event]le dice al modelo qué está disponible. El modelo decide si llamarlo y con qué argumentos. El decodificador aplica la misma lógica de restricción que antes: cualquier llamada a herramienta que emita tendrá los nombres de argumentos y tipos correctos.- La respuesta incluye una parte
function_callcon unnamey un diccionarioargs. El SDK validaargscontra la firma de la función antes de entregarlo, por lo que usar**fc.argses seguro. - La ejecución automática es opcional. El SDK tiene un modo
automatic_function_callingque ejecuta la función por ti y envía el resultado de vuelta en un bucle. En producción, usualmente prefiero tener el control. El despacho manual me permite registrar la llamada, verificar un límite de tasa (rate limit), rechazar una operación con efectos secundarios si algo parece mal o usar un manejador de prueba (dry-run). El modelo propone; mi código decide.
Si quieres que el modelo reaccione al resultado —por ejemplo, para confirmar la reserva al usuario en lenguaje natural— envía el valor de retorno como una function_response en una llamada de seguimiento a generate_content. Ese es el bucle del agente: parsear, llamar, responder.
Mismo mecanismo, dos posturas
La salida estructurada y el function calling funcionan sobre el mismo decodificador restringido. La salida estructurada dice: "lo siguiente que emitas es un JSON con esta forma". El function calling dice: "lo siguiente que emitas es una llamada a esta función con estos argumentos, que también es un JSON de una forma conocida". Elige el enfoque que mejor represente lo que es la salida. Si son datos que vas a procesar, usa response_schema. Si es una acción que el sistema debe realizar, usa tools.
Instructor: salidas estructuradas nativas de Pydantic
Instructor es una librería de Python que parchea el cliente de OpenAI (y otros) para que las llamadas devuelvan modelos de Pydantic en lugar de strings. Tú defines tu tipo de salida e Instructor se encarga de la generación del esquema, la validación y los reintentos.
import instructor
from pydantic import BaseModel
from openai import OpenAI
client = instructor.from_openai(OpenAI())
class ContactInfo(BaseModel):
name: str
email: str
phone: str | None = None
company: str | None = None
contact = client.chat.completions.create(
model="gpt-4o",
response_model=ContactInfo,
messages=[{{
"role": "user",
"content": "Extract: John Smith, [email protected], works at Acme Corp"
}}]
)
print(contact.name) # "John Smith"
print(contact.email) # "[email protected]"Tu salida es un objeto de Python real con validación, valores por defecto e indicaciones de tipo. Cuando el modelo devuelve datos inválidos, Instructor reintenta con el error de validación en el siguiente turno, dándole al modelo una oportunidad justa de corregirse. Este es el camino que elijo en codebases de Python cuando no estoy usando directamente el SDK de Gemini.
Outlines: control a nivel de gramática para modelos abiertos
Outlines realiza decodificación restringida para modelos de pesos abiertos (open-weight) que alojas tú mismo. Compila un JSON Schema o una regex en una máquina de estados finitos que guía al modelo a través de la generación token por token. Es un enmascaramiento de logits real, sin reintentos: los tokens inválidos nunca llegan a ser muestreados.
import outlines
model = outlines.models.transformers("mistralai/Mistral-7B-v0.1")
schema = '''{{
"type": "object",
"properties": {{
"sentiment": {{""type": "string", "enum": ["positive", "negative", "neutral"]}},
"confidence": {{"type": "number", "minimum": 0, "maximum": 1}}
}},
"required": ["sentiment", "confidence"]
}}'''
generator = outlines.generate.json(model, schema)
result = generator("Review: The food was excellent but the service was slow.")
# se garantiza que result tenga sentiment en [positive, negative, neutral]
# y confidence como un float entre 0 y 1Compensaciones (Trade-offs)
Ningún enfoque único gana en todos los casos.
Las salidas estructuradas a nivel de API son las más fáciles de implementar y la latencia suele ser cercana a la generación simple. El inconveniente es que estás atado a un proveedor y la cobertura de esquemas varía entre ellos.
El function calling funciona entre proveedores y encaja naturalmente en el código de agentes. El problema es que forzar cada salida como una "llamada a herramienta" se siente poco natural cuando solo quieres extraer unos pocos campos de un string.
Instructor gana en experiencia de desarrollador para equipos de Python. El bucle de reintentos maneja casos borde para que no tenga que escribir el mismo try/except otra vez. El costo es algo de latencia extra en los reintentos y una dependencia fuerte del modo JSON subyacente.
Outlines ofrece la garantía más fuerte: cero reintentos, enmascaramiento a nivel de token. El precio es que debes alojar el modelo tú mismo y los esquemas complejos pagan un costo de compilación, además de una pequeña ralentización por cada token.
Cuándo importa realmente
Las salidas estructuradas no siempre son necesarias. Si estás enviando prosa en streaming a una interfaz de chat, el texto libre está bien. En el momento en que la salida del LLM alimenta a algo más —una escritura en base de datos, una llamada a una API, un componente de UI, un modelo posterior— necesitas estructura.
En producción, el LLM casi nunca es la última parada. Es un nodo de procesamiento en un pipeline, y los pipelines necesitan contratos. Las salidas estructuradas son la forma de escribir esos contratos de manera que el modelo no pueda violarlos.
Las herramientas son lo suficientemente maduras hoy en día como para que parsear salidas de LLM con regex en producción sea una elección, no una necesidad. Elige uno de los métodos anteriores, define tus esquemas y deja que el modelo dedique su capacidad al contenido real. El formato es un problema resuelto. No hay una buena razón para seguir resolviéndolo en el código de tu aplicación.
