Tres semanas después de activar los resultados estructurados para un canal que analizaba los mensajes de confirmación de pago en registros de transacciones, noté que nuestro trabajo de conciliación comenzó a detectar un flujo pequeño y constante de discrepancias.
No fueron choques, ni tampoco filas mal formadas. Solo transacciones en las que el monto y el remitente coincidían perfectamente pero la fecha no era correcta. Algo así como del 2 al 3% del volumen de una semana determinada, lo suficiente como para notarlo, pero no lo suficiente como para ser obvio de inmediato.
Al principio supuse que se trataba de un error de zona horaria. Pero no fue así.
Cuando saqué los mensajes de origen sin procesar junto a los registros extraídos, apareció un patrón: cada transacción no coincidente provenía de un mensaje que nunca mencionaba una fecha en absoluto.
Algo así como “Pago recibido de Chinedu, ₦ 45 000, referencia TXN-82K91”. No hay fecha en ninguna parte del texto. Y el modelo había completado la fecha_transacción de todos modos, casi siempre la fecha en que se ejecutó el trabajo de extracción, con un margen de menos de una hora.
El esquema decía fecha_transacción: fecha, requerida. El modelo no pudo devolver nada. Entonces no fue así.
Había estado tratando “el JSON es válido” como la línea de meta de este proceso, y durante semanas parecía que lo era.
No lo es.
Es el punto donde se hace posible un tipo de falla más silenciosa, una que nunca arroja un error o falla una verificación de tipo, y no aparece hasta que algo posterior depende de que el valor sea real.
La mayor parte de lo que se escribe sobre resultados estructurados se detiene en “ya no puede devolver JSON roto”, como si eso resolviera la cuestión de la confiabilidad. Se resuelve una versión del mismo.
La trampa del esquema perfecto
Los resultados estructurados resuelven un problema real. Antes de la aplicación del esquema nativo, obtener JSON confiable de un LLM significaba analizadores de expresiones regulares, bucles de reintento e indicaciones que básicamente pedían al modelo: “SOLO generar JSON, sin rebajas, sin preámbulo”.
Con el moderno SDK de OpenAI Python y un modelo Pydantic, la mayor parte de esa categoría de dolor simplemente desaparece:
Ejecútelo con un mensaje limpio y funcionará exactamente como se anuncia. Cada clave presente, cada tipo correcto, no es necesario intentar/excepto solo para detectar una valla de rebajas perdida alrededor del JSON.
Entonces alguien te reenvía un mensaje como este:
pero al esquema no le importa que la fecha no esté allí. Todavía está marcado como obligatorio, por lo que algo tiene que llenar ese espacio, y nunca será el esquema el que se doblegue.
En cambio, el modelo busca lo que sea que lo lleve a un valor válido: la fecha actual, el límite de entrenamiento, una suposición que parezca plausible.
Lo que regresa se verifica perfectamente. También está completamente inventado y no hay nada en la respuesta en sí que le indique qué campos son cuáles.
Diseño de esquemas para la incertidumbre.
La solución es un cambio mental más que un cambio de código. Un campo vacío no es un error de extracción, a menudo es simplemente la verdad. Hacer que los campos sean anulables le quita presión al modelo para inventar algo:
Ahora, si falta la fecha, el modelo puede simplemente decirlo. Esto también trae consigo una distinción que es fácil de desdibujar, que es extracción versus inferencia.
La extracción es “dime exactamente qué hay en el texto”. Mientras que la inferencia es “dime qué implica”. Un mensaje que dice “pagado el martes” y un esquema que exige una fecha ISO, eso es inferencia, ya sea que quisieras solicitarla o no.
A veces, la inferencia es exactamente lo que desea, pero la decisión debe ser suya, no algo que el modelo tome por usted de forma predeterminada. Un campo que acepta valores NULL devuelve esa decisión a su propio código:
Pruebas y procedencia
Los campos que admiten valores NULL solucionan el problema de “inventar valores a partir de la nada”. No solucionan el otro, lo cual es honestamente peor: el modelo le da un valor y no tiene forma de saber si realmente leyó ese valor de la página o si coincidió con el patrón hasta llegar allí.
Con una respuesta de chat normal, al menos puedes verlo llegar a una respuesta. Los resultados estructurados saltan directamente al formulario final. Así que comencé a pedir un segundo campo junto a cada valor, el fragmento exacto del texto fuente que supuestamente lo respalda.
El contenedor Extraído genérico es un atajo, no una práctica recomendada. El valor ahora es un tipo de unión en lugar de un flotador limpio, lo que cuesta parte de la seguridad de tipo que tenía el esquema original.
Ese intercambio vale la pena una vez que un esquema tiene más de un par de tipos de campos, escribir ExtractedFloat, ExtractedDate, ExtractedString por separado es una tarea complicada en ese momento. Para uno o dos campos, mantenga las clases específicas, generalmente están más limpias.
El patrón se gana la vida de dos maneras. Ordenar la evidencia antes que el valor es importante porque las claves se generan en secuencia, por lo que el modelo tiene que escribir lo que está mirando antes de comprometerse con una respuesta, una pequeña muestra forzada de su trabajo.
Y le da al revisor algo concreto que verificar sin volver a leer la fuente. Si se completa el valor pero la evidencia está vacía o contiene texto que no está en la fuente en ninguna parte, esa falta de coincidencia es la alucinación que aparece en los datos mismos.
No sale gratis. En un lote de unos cientos de mensajes de transacción, agregar campos de evidencia en todo el esquema aumentó los tokens de salida en aproximadamente un tercio, y la latencia aumentó lo suficiente como para importar a escala de canalización.
No vale la pena por un código postal de cinco dígitos. Pero por una cifra financiera sobre la que alguien va a actuar, definitivamente vale la pena.
El límite entre generación y validación
En este punto, el esquema contiene muchas cosas: tipos que aceptan valores NULL para que no invente cosas, campos de evidencia para que pueda detectarlo cuando lo haga de todos modos.
Pero hay toda una categoría de incorrección que ninguno de esos aspectos toca, que es si el valor tiene algún sentido como un hecho sobre el mundo.
El esquema garantiza que el monto es flotante. No dice nada sobre si ese valor flotante es negativo o si la fecha_transacción es de alguna manera dentro de tres días.
Al principio intenté arreglar esto en el mensaje, con instrucciones como “la cantidad debe ser mayor que cero”, lo cual, en retrospectiva, era algo extraño pedirle a un modelo de lenguaje que hiciera cumplir. No es una calculadora. Un validador hace esto exactamente bien, siempre y de forma gratuita:
Entonces, ahora la API garantiza la estructura en el momento en que genera la respuesta, y Pydantic garantiza que los datos tengan sentido en el momento en que se analizan en el objeto, de la misma manera siempre, sin ningún LLM involucrado en esa segunda verificación.
Cuando el validador arroja, tiene opciones: enviar el registro a un humano o devolver el error exacto al modelo y dejar que lo intente nuevamente. Elegí el segundo, limitado en dos reintentos:
El límite MAX_RETRIES en realidad importa más de lo que parece. Mi primer instinto fue dejar que siguiera intentándolo, lo cual es un error. Dos intentos fallidos casi siempre significan que el documento fuente es el problema real, no el mensaje, y un tercer paso automatizado simplemente quema llamadas API sobre algo que un humano borra en diez segundos.
Nada de esto tampoco es específico de OpenAI, aunque todos los bloques de código aquí lo son. Cambie el uso de herramientas de Anthropic o una configuración autohospedada con vLLM y Outlines y el modelo de Pydantic no se mueve ni un centímetro, es solo la llamada API a su alrededor la que cambia.
Cuando esto funcionó por primera vez, mi listón de éxito era vergonzosamente bajo: ¿el modelo llenó el objeto sin romper mi analizador?
Mirando hacia atrás, esa barra recompensa por completo lo incorrecto, porque un modelo que llena con entusiasmo todos los campos, independientemente de lo que realmente hay delante de él, no es confiable. Es simplemente tener confianza, lo cual es algo diferente y más peligroso.
Los productos estructurados son realmente buenos en lo que hacen. Simplemente no hacen lo que originalmente pensé que hacían. Garantizan la forma, no la verdad, y una vez que dejas de preocuparte por los corchetes y las comillas, la verdadera pregunta sigue ahí esperando: ¿cada valor en este objeto tiene una razón real para existir?
Esa pregunta siempre fue la parte difícil. El esquema solía ocultármelo.