Salida estructurada


Primero, veamos en qué consiste el problema. La salida estructurada es la práctica de lograr que un modelo devuelva datos que tu código pueda leer directamente: un campo sobre el que puedas tomar decisiones en lugar de un párrafo que tengas que analizar a mano. Este capítulo trata de cómo lograr que eso sea confiable.
Hasta ahora el modelo te entrega texto, lo cual está bien cuando una persona lo lee. Pero el software no puede hacer mucho con un párrafo. Para actuar sobre la respuesta de un modelo desde tu código, necesitas que esté en forma de datos: un campo que puedas leer, un valor sobre el que puedas decidir, un registro que puedas guardar.
El cambio es pequeño, pero transforma todo lo que viene después. En cuanto el modelo devuelve { "sentiment": "negative" } en vez de una oración sobre lo insatisfecho que parece estar el cliente, tu código puede enrutar el ticket, actualizar un panel o enviar una alerta. Lo interesante es cómo lograr que el modelo produzca datos limpios cuando lo único que hace es predecir texto, y la respuesta revela una palanca que quizás no sabías que tenías.
Pide JSON
El primer instinto es pedirlo en el prompt: "responde como JSON", el formato de texto plano que usa el software para intercambiar datos, escrito como campos y valores dentro de llaves. Pruébalo y en su mayoría funciona, y ahí está la trampa. El modelo sigue sin hacer nada más que predecir texto probable, y a veces el texto probable es un bloque de código, o un amigable "¡Claro, aquí tienes!" antes de los datos, o un comentario al final. Cualquiera de esos casos rompe el código que lee el JSON. Pedirlo con buenas maneras inclina la predicción hacia el JSON; no la obliga.
Por eso los proveedores te dan una palanca real. Activar el modo JSON con response_format hace más que pedirlo: restringe al modelo para que la sintaxis vuelva como JSON válido.
import json
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": 'Extract the city and the temperature. Reply as JSON: { "city": string, "tempC": number }.'},
{"role": "user", "content": "It's about 18 degrees in Oslo right now."},
],
response_format={"type": "json_object"}, # restringe la salida a sintaxis JSON válida
)
data = json.loads(response.choices[0].message.content)
print(data["city"], data["tempC"]) # "Oslo" 18response_format={"type": "json_object"} hace que la salida sea sintácticamente un JSON válido, así que json.loads no fallará por prosa suelta. Aun así debes describir en el prompt los campos que quieres, porque el modo JSON garantiza JSON válido, no los campos específicos que tenías en mente. Descarta "no es JSON en absoluto", no "JSON con la forma equivocada". Hay una cosa que todavía no puede garantizar: si la respuesta se corta a mitad de camino, puedes recibir medio objeto JSON, así que es sintaxis confiable, no una garantía absoluta en todos los casos.
response_format en modo JSON restringe la sintaxis para que vuelva como JSON válido. No garantiza que los campos coincidan con lo que pediste, y una respuesta cortada a medio camino puede llegar como medio objeto, así que describe la forma en el prompt y verifica lo que llega. Fija la forma con un esquema
El modo JSON mantiene la sintaxis válida, pero el modelo todavía podría renombrar un campo, omitirlo o anidar las cosas de otra manera. Para eliminar ese margen, dale un esquema: una descripción exacta de los campos y tipos que debe tener la salida, un formulario que el modelo está obligado a completar. Con strict: true, la salida queda garantizada para coincidir con ese formulario.
import json
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": "Extract the contact details from the message."},
{"role": "user", "content": "Hi, I'm Mara Lin, reach me at [email protected]."},
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "contact",
"strict": True, # aplica el esquema de forma exacta
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"},
},
"required": ["name", "email"],
"additionalProperties": False,
},
},
},
)
contact = json.loads(response.choices[0].message.content)
# {"name": "Mara Lin", "email": "[email protected]"}¿Cómo puede un esquema ser una garantía firme si el modelo solo predice tokens? Porque el proveedor restringe qué tokens puede predecir. En cada paso el modelo sigue calificando a todos los tokens posibles, pero el sistema elimina los que romperían tu esquema, y el modelo elige entre lo que queda.
Si el esquema dice que el siguiente campo debe ser email, los tokens que abrirían cualquier otro campo se quitan de la mesa antes de que el modelo elija. Se eliminan los tokens que no cumplen con la forma, así que el modelo no puede desviarse de ella.
Hay un efecto más sutil que vale la pena conocer. Los nombres de los campos que elijas pasan a ser parte de lo que el modelo lee al predecir el valor, así que un nombre claro guía hacia una mejor respuesta. Si le pides que rellene tempC, el modelo se inclina hacia un número en Celsius; si le pides que rellene value, tiene mucho menos con qué guiarse. Nombrar los campos con claridad es en parte instrucción, en parte esquema.
strict: true obtiene exactamente los campos y tipos indicados, porque el sistema elimina cualquier token que rompería la forma antes de que el modelo elija. Eso es una restricción real, no una petición amable. Nombra tus campos con claridad también, ya que el nombre del campo es contexto que el modelo lee al predecir el valor. Me tomó un tiempo confiar en que un nombre como tempC realmente influye. Valida de todos modos
Un esquema restringe la forma, pero de todas maneras trata la salida como datos provenientes del mundo exterior. La forma puede ser válida mientras el contenido está mal: un correo extraído que en realidad tiene un error de tipeo, un número que el modelo adivinó, un campo dejado en blanco porque la entrada no lo contenía. Y la llamada puede fallar igual por motivos comunes, como cortarse por max_tokens y llegar como JSON truncado. Analiza a la defensiva: una forma válida no es un valor correcto.
import json
def parse_contact(raw):
try:
data = json.loads(raw)
if not data.get("name") or not data.get("email"):
return None # presente pero vacío
return data
except json.JSONDecodeError:
return None # no es JSON válido (por ejemplo, una respuesta truncada)
contact = parse_contact(response.choices[0].message.content)
if not contact:
pass # maneja la falla: reintenta, vuelve a preguntar o muestra un error amigableEsta es la lección de la alucinación con otro disfraz: que la forma esté bien no hace que los valores estén bien. Un esquema te garantiza obtener un campo name; no garantiza que el nombre sea correcto. Un try/except alrededor del análisis más una verificación de los valores es un seguro barato contra la respuesta que está bien formada y aun así está mal.
try/except y revisa los valores antes de confiar en ellos. Decide de antemano qué pasa cuando falla la validación, un reintento o un error amigable, para que no sea una carrera contra el tiempo después. Dos patrones: clasificar y extraer
La mayor parte del trabajo de salida estructurada tiene una de estas dos formas.
La clasificación ordena una entrada en una de un conjunto fijo de etiquetas. Un enum en el esquema restringe la salida exactamente a esas etiquetas, usando el mismo truco de poda de tokens: en la posición de la etiqueta, solo se pueden predecir los valores permitidos, así que el modelo no puede inventar una categoría que no esté en tu lista.
# fragmento de esquema para clasificación
{"type": "string", "enum": ["billing", "technical", "general"]}La extracción saca campos específicos de texto libre, como el ejemplo de contacto de antes: un nombre, una fecha, un monto, una lista de nombres de productos. Juntas, clasificar y extraer cubren una gran parte de las funciones reales de IA: enrutar tickets de soporte, etiquetar contenido, convertir correos en registros, leer recibos. Ambas convierten a un generador de texto impreciso en un componente confiable sobre el que tu código puede construir.
enum para que el modelo no pueda inventar una categoría fuera de tu lista. La extracción saca campos con nombre de texto libre hacia un registro. Ambas convierten a un generador de texto impreciso en un componente confiable, y entre las dos cubren una gran parte de las funciones prácticas de IA. En la práctica
Convertir un correo de soporte desordenado en un ticket estructurado, combinando clasificación y extracción en un solo esquema:
import json
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": "Turn the support email into a ticket."},
{"role": "user", "content": email_text},
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "ticket",
"strict": True,
"schema": {
"type": "object",
"properties": {
"category": {"type": "string", "enum": ["billing", "technical", "general"]},
"urgency": {"type": "string", "enum": ["low", "medium", "high"]},
"summary": {"type": "string"},
},
"required": ["category", "urgency", "summary"],
"additionalProperties": False,
},
},
},
)
ticket = json.loads(response.choices[0].message.content)
# {"category": "billing", "urgency": "high", "summary": "Double-charged for last month's subscription."}Una sola llamada clasifica el correo de dos maneras y extrae un resumen, devolviendo un registro que tu código puede enrutar y guardar, con la forma garantizada y los valores todavía dignos de revisión. Hasta ahora todo ha sido texto que entra y texto que sale. A continuación le das al modelo un sentido completamente distinto: en Embeddings, el texto se convierte en números que puedes comparar por significado, la base de la búsqueda y del trabajo con tus propios documentos.

