Structured outputs con JSON Schema en llamadas a LLMs: por qué igual validamos la respuesta
Pedirle a un modelo que devuelva JSON se puede hacer de dos formas muy distintas, y la diferencia importa más de lo que parece a primera vista. La primera es la que usamos durante años por necesidad: describir el formato esperado en el prompt ("responde solo con un JSON con estas claves") y confiar en que el modelo lo respete, para después intentar parsear lo que devolvió y descubrir, con cierta frecuencia, que agregó una frase antes del JSON, lo envolvió en bloques de markdown, o simplemente cambió el nombre de un campo porque le pareció más natural. La segunda es lo que los proveedores llaman salida estructurada: pasar un JSON Schema explícito junto con la petición y dejar que el servidor restrinja el muestreo de tokens para que la respuesta solo pueda tomar formas válidas según ese esquema.
La segunda opción es un salto real, no cosmético. En vez de pedir cortésmente un formato y esperar que el modelo coopere, el mecanismo de decodificación restringida descarta en cada paso los tokens que llevarían a una salida que no cumple el esquema, así que el modelo no puede, por ejemplo, abrir una llave sin cerrarla, usar un tipo de dato distinto al declarado para un campo, o inventar una clave que no está en la definición. Eso elimina de raíz una categoría completa de errores que antes resolvíamos con regex defensivas y reintentos.
Lo que la salida estructurada garantiza en realidad
Conviene ser precisos sobre qué cubre exactamente esta garantía, porque es más angosta de lo que el nombre sugiere: garantiza forma, no contenido. El esquema fuerza a que "precio" sea un número y no una cadena, a que "items" sea un array y no un objeto, a que una clave marcada como obligatoria aparezca en la respuesta. No garantiza que ese número sea el precio correcto, que ese array tenga la cantidad de elementos que correspondía, ni que el valor dentro de un campo obligatorio tenga algún sentido para el caso que estamos resolviendo.
Esto se vuelve evidente en cuanto el esquema obliga a un campo que el modelo no tiene forma de completar con información real. Si pedimos extraer datos de un documento y marcamos "numero_de_orden" como obligatorio, pero ese documento en particular no incluye ningún número de orden, el modelo no puede devolver null si el esquema no lo permite explícitamente como tipo válido, así que produce algo sintácticamente correcto y semánticamente inventado: una cadena vacía, un valor que parece un identificador pero no proviene de ningún lugar del texto. El esquema queda satisfecho. El dato es falso.
Dónde sigue fallando, incluso con el modo activado
Hay un segundo grupo de problemas que no tiene que ver con que el modelo "haga trampa", sino con los límites reales del mecanismo:
- Esquemas anidados profundos o con referencias (
$ref). El soporte de cada proveedor para las palabras clave completas de JSON Schema no es uniforme ni estático: una restricción comopattern,format, o una combinación deoneOfcon discriminador puede estar documentada pero aplicarse de forma parcial, o directamente ignorarse en el muestreo sin que la API devuelva ningún error. Eso significa que una validación que creíamos delegada al proveedor, en la práctica, nunca se estaba aplicando. - Truncamiento por límite de tokens. La decodificación restringida opera token por token sobre lo que el modelo va generando, pero no tiene control sobre cuántos tokens le vamos a permitir generar. Si la respuesta se corta por
max_tokensa mitad de un array anidado, el resultado es JSON incompleto -una llave sin cerrar, un array sin su corchete final- a pesar de que cada token individual que sí llegó a emitirse era válido según el esquema en ese punto. - Enums sintácticamente correctos pero semánticamente equivocados. El esquema puede limitar
"moneda"a un enum de tres valores válidos, y el modelo va a elegir siempre uno de esos tres. Eso no implica que elija el correcto para el documento que tiene enfrente; solo implica que no va a inventar un cuarto valor. - Coherencia entre campos. Ningún JSON Schema estándar expresa de forma nativa "la suma de
cantidad * precio_unitariode cada elemento deitemsdebe ser igual atotal". El esquema valida cada campo de forma aislada; la relación entre campos es responsabilidad nuestra.
Un caso concreto: extracción de líneas de un documento
En un pipeline de extracción de documentos con líneas de detalle -pedidos, remitos, cualquier documento con una tabla de ítems- definimos un esquema con un array items, cada uno con descripcion, cantidad, precio_unitario y un campo total a nivel del documento. La salida estructurada eliminó, de inmediato, los errores de formato que antes nos obligaban a reintentar la llamada: cero respuestas con JSON mal formado, cero campos con el tipo equivocado.
Lo que no eliminó fue que, en documentos con tablas mal escaneadas o con una fila cortada por un salto de página, el modelo generaba una fila completa y sintácticamente perfecta para la parte de la tabla que no pudo leer bien, con una cantidad y un precio que no correspondían a nada visible en el documento original. El esquema no tiene ninguna forma de detectar eso, porque desde su perspectiva no hay ningún error: todos los campos tienen el tipo correcto y están presentes. La única forma de detectarlo fue agregar, después de recibir la respuesta ya validada contra el esquema, una verificación de que la suma de las líneas coincidiera con el total declarado en el documento, y tratar cualquier discrepancia como señal de que al menos una línea era sospechosa y merecía revisión antes de confiar en el dato.
Qué seguimos validando del lado del cliente
from pydantic import BaseModel, ValidationError
class Item(BaseModel):
descripcion: str
cantidad: float
precio_unitario: float
class Documento(BaseModel):
items: list[Item]
total: float
def validar_respuesta(data: dict, finish_reason: str) -> Documento:
if finish_reason == "length":
raise ValueError("respuesta truncada por max_tokens, no se puede confiar en el JSON")
doc = Documento.model_validate(data)
suma_items = sum(i.cantidad * i.precio_unitario for i in doc.items)
if abs(suma_items - doc.total) > 0.01:
raise ValueError(
f"inconsistencia: suma de items {suma_items} != total declarado {doc.total}"
)
return doc
Esta validación corre después de que el proveedor ya aplicó su propia decodificación restringida, y no es redundante con ella: revisa exactamente lo que el esquema no puede revisar. Primero, el finish_reason: si la respuesta se truncó, descartamos el resultado sin intentar repararlo, porque un JSON "casi completo" no es un dato con el que valga la pena seguir. Segundo, parseamos contra un modelo de datos propio (Pydantic, Zod, o el validador que corresponda al lenguaje), no porque desconfiemos de que el esquema se aplicó -confiamos en eso- sino porque queremos un objeto tipado en nuestro propio código, no un diccionario suelto. Tercero, y es la parte que de verdad importa, aplicamos las reglas de negocio que ningún JSON Schema estándar puede expresar: coherencia entre campos, rangos razonables, consistencia con otros datos que ya teníamos del mismo documento.
Qué revisamos antes de confiar en una salida estructurada como dato validado
Antes de tratar la respuesta de un modo de salida estructurada como un dato ya verificado, confirmamos qué subconjunto de palabras clave de JSON Schema soporta realmente el proveedor que estamos usando, en vez de asumir que soporta la especificación completa porque la documentación lista las palabras clave sin aclarar el alcance; revisamos el finish_reason de cada respuesta antes de parsear nada, porque una respuesta truncada puede ser JSON inválido a pesar del modo estructurado; marcamos como obligatorios en el esquema solo los campos que el modelo puede completar con información real presente en el contexto, y dejamos todo lo demás como opcional o nulable para no forzar un valor inventado; y escribimos, para cada esquema que ponemos en producción, al menos una validación posterior que compruebe una relación entre campos que el esquema no puede expresar por sí solo, porque esa es la clase de error que el modo estructurado no está diseñado para prevenir.