volver al blog

Salidas estructuradas: deja de parsear texto de un LLM

llmia-generativapython

El json.loads que peta a las tres de la mañana

Casi todo el que ha llevado un LLM a producción tiene la misma cicatriz. Le pediste al modelo que devolviera un JSON, lo metiste en un json.loads y funcionó en las pruebas. Un mes después, el modelo decidió envolver la respuesta en un bloque de código, añadir un “Claro, aquí tienes:” delante y colar una coma final. Tu parser se rompió y el error saltó de madrugada.

El problema no era el modelo. Era que le pediste texto libre y luego fingiste que era una estructura. Las salidas estructuradas existen justo para cerrar ese hueco: en lugar de rezar para que el JSON venga bien formado, obligas al modelo a que solo pueda generar tokens que respeten tu esquema.

Qué es una salida estructurada de verdad

Una salida estructurada no es “pídele amablemente que responda en JSON”. Es un contrato. Defines un esquema, JSON Schema o un modelo de Pydantic, lo pasas a la API y el proveedor restringe la generación para que la respuesta encaje en ese esquema token a token.

La diferencia práctica es enorme. Con la vía de “por favor devuelve JSON”, el formato correcto es probable pero no seguro. Con salida estructurada, el campo edad es un entero porque el decodificador no tiene permitido emitir otra cosa. Dejas de validar sintaxis y pasas a validar solo lo que importa: que los datos tengan sentido.

Salida estructurada o function calling: no son lo mismo

Aquí hay confusión constante, y elegir mal cuesta código de más. Los dos mecanismos usan esquemas por debajo, pero resuelven problemas distintos.

NecesitasUsaEjemplo
Datos en una forma concretaSalida estructuradaExtraer nombre, importe y fecha de una factura
Que el modelo decida qué acción ejecutarFunction callingUn agente que elige entre buscar, calcular o responder
Clasificar en categorías cerradasSalida estructuradaEtiquetar un ticket como bug, duda o mejora
Orquestar una herramienta externaFunction callingLlamar a tu API de inventario con los argumentos correctos

La regla que uso: si el resultado es dato, salida estructurada; si el resultado es decisión sobre qué hacer, function calling. Muchas aplicaciones serias usan las dos, y no pasa nada. Se convierten en un problema solo cuando intentas forzar una a hacer el trabajo de la otra.

El esquema es tu prompt, trátalo como tal

El error más caro no está en el código, está en el diseño del esquema. Tres cosas que aprendí a fuerza de reintentos.

Las descripciones de los campos no son documentación. Viajan dentro del JSON Schema hasta el modelo y moldean lo que genera. Un campo total con la descripción “importe final con IVA incluido, en euros” produce resultados distintos que un total a secas. Es ingeniería de prompts metida en el esquema.

El orden de los campos importa, porque el modelo genera de izquierda a derecha. Si pones primero el veredicto y después el razonamiento, el modelo se compromete a una respuesta antes de pensarla. Pon el razonamiento antes que la conclusión y le das espacio para trabajar el problema. Es el mismo principio que el “piensa paso a paso”, pero horneado en la estructura.

Y no metas cincuenta campos en un esquema gigante. El anidamiento profundo dispara la tasa de error y ralentiza la compilación del esquema. Dos o tres niveles como mucho. Si necesitas extraer mucho, parte la tarea en varias llamadas: sale más barato de depurar y más fiable.

Código real: Pydantic como fuente de verdad

En Python, Pydantic es el estándar de facto para esto. Defines el esquema una vez y lo usas como contrato con el modelo y como validación de la respuesta. Un ejemplo de extracción, comentado en lo que de verdad marca la diferencia:

from pydantic import BaseModel, Field
from openai import OpenAI

client = OpenAI()

class Factura(BaseModel):
    # El razonamiento va PRIMERO: el modelo piensa antes de comprometerse.
    razonamiento: str = Field(
        description="Breve explicación de qué campos se han localizado y dónde."
    )
    # Las descripciones son prompt: viajan al modelo dentro del esquema.
    proveedor: str = Field(description="Nombre fiscal de quien emite la factura.")
    total: float = Field(description="Importe final con IVA incluido, en euros.")
    fecha: str = Field(description="Fecha de emisión en formato AAAA-MM-DD.")

respuesta = client.chat.completions.parse(
    model="gpt-5.6",
    messages=[
        {"role": "system", "content": "Extrae los datos de la factura."},
        {"role": "user", "content": texto_factura},
    ],
    # El esquema restringe la generación: el modelo NO puede salirse de él.
    response_format=Factura,
)

# Comprueba SIEMPRE por qué paró antes de tocar el resultado.
if respuesta.choices[0].finish_reason == "stop":
    factura = respuesta.choices[0].message.parsed  # ya es un objeto Factura
    print(factura.total)  # 1289.5, un float, no una cadena que reza por ser número
else:
    # Rechazo por seguridad, límite de tokens... aquí no hay JSON válido.
    manejar_fallo(respuesta)

Fíjate en lo que ya no está. No hay json.loads, ni un try/except alrededor del parseo, ni una expresión regular para arrancar el JSON de un bloque de código. Esa capa entera desaparece porque el proveedor garantiza la forma. Lo que queda es validar el contenido, que es donde tu atención vale algo.

Valida igual, aunque te lo garanticen

Esto es lo que separa un prototipo de algo que aguanta. Que el proveedor garantice el esquema no significa que siempre recibas un objeto válido. Hay dos grietas que muerden en producción.

La primera: si el modelo rechaza la petición por seguridad, la respuesta no cumple tu esquema. No trae tu JSON, trae un mensaje de rechazo. Por eso se comprueba finish_reason en OpenAI o stop_reason en Anthropic antes de acceder al resultado parseado. Saltarse esa comprobación es el fallo silencioso clásico.

La segunda: el esquema garantiza la forma, no la verdad. Un total será un número, pero puede ser un número absurdo. La validación de negocio sigue siendo tuya. Aquí es donde una librería como Instructor gana su sitio: envuelve el SDK del proveedor, valida con Pydantic y, si algo no cuadra, reintenta pasándole al modelo el error de validación como contexto. El modelo se autocorrige con una señal concreta en vez de que tú parchees a mano.

Para tu próxima integración

Si sigues sacando datos de un LLM con json.loads y un try/except, ese bloque es deuda técnica esperando su turno. Cámbialo por un esquema.

Empieza pequeño: un modelo de Pydantic con descripciones de verdad en cada campo, el razonamiento antes que las conclusiones y un esquema por tarea. Comprueba el motivo de parada antes de tocar el resultado y valida las reglas de negocio aunque la forma venga garantizada. No es más código; es menos, y del que no te despierta de madrugada.

Fuentes