Capítulo 5 de 21 · básico
Salida estructurada y modo JSON
Qué cubre esta sesión
Todos los capítulos hasta ahora terminaban con el modelo entregándote un string. Eso está bien cuando lo lee un humano. Se vuelve un problema en el momento en que lo tiene que leer otro pedazo de código. He perdido más horas de las que quisiera admitir con un regex que parseaba la respuesta del modelo a la perfección, hasta el día en que le antepuso "Sure! Here's the JSON:". Esta hora mata esa clase entera de bugs. Le entregas al modelo un schema, y el SDK te regresa un objeto que ya lo cumple — tipado, validado, sin parsear nada.
El schema es el contrato. Declaras la forma una sola vez como modelo de Pydantic, y ambos proveedores la hacen cumplir: los campos correctos, los tipos correctos, una lista donde pediste una lista. El ejemplo básico extrae un evento de calendario de una oración y lo convierte en un objeto real, así que la diferencia con "dame algo de JSON" queda concreta desde la primera corrida.
OpenAI
La Responses API trae un helper parse hecho justo para esto. Pasas tu modelo
de Pydantic como text_format y la respuesta del modelo te llega ya validada en
output_parsed — un CalendarEvent de verdad, no un string al que le tienes
que hacer json.loads y rezar. Si el modelo no puede satisfacer el schema, la
llamada falla de forma ruidosa en lugar de entregarte texto malformado que
rompe tres funciones más adelante.
"""Week 4 - Structured output (OpenAI).
Stop parsing prose. Hand the model a schema and get back an object whose
shape you can rely on. Here the schema is a Pydantic model; the Responses
API `parse` helper validates the model's reply against it and gives you a
typed object, not a string you have to pick apart.
"""
import os
import sys
from dotenv import find_dotenv, load_dotenv
from openai import OpenAI
from pydantic import BaseModel
load_dotenv(find_dotenv())
if not os.environ.get("OPENAI_API_KEY"):
sys.exit("OPENAI_API_KEY is not set. Put it in the course-root .env file.")
client = OpenAI()
# region: schema
# The schema is the contract. The model must return exactly these fields,
# with these types — participants is a list of strings, not a comma blob.
class CalendarEvent(BaseModel):
name: str
date: str
participants: list[str]
# endregion
TEXT = "Lunch with Ana and Diego next Friday to go over the Q3 roadmap."
# region: parse-call
# `responses.parse` with `text_format` forces the reply to match the model
# and returns it already parsed on `output_parsed` — a real CalendarEvent.
response = client.responses.parse(
model="gpt-5.4-nano",
input=f"Extract the calendar event from this text:\n{TEXT}",
text_format=CalendarEvent,
)
event = response.output_parsed
# endregion
print(type(event).__name__, "->")
print(" name:", event.name)
print(" date:", event.date)
print(" participants:", event.participants)
Lo que hay que internalizar es que el schema no es una sugerencia en el prompt —
se hace cumplir en tiempo de decodificación. participants está tipado como
list[str], así que recibes una lista, no un "Ana, Diego" que luego tienes que
partir mientras te preocupas por la coma de Oxford. Los tipos que declaras son
los tipos que recibes. Esa es toda la razón para usar esto en lugar de pedir
JSON de buena manera en el prompt.
Gemini
El mismo modelo de Pydantic, la misma garantía, conectado distinto. Gemini
recibe el schema en su GenerateContentConfig como response_schema, y pones
response_mime_type en application/json para que sepa que debe emitir JSON.
El objeto validado te llega en response.parsed; el texto JSON crudo sigue
disponible en response.text si lo quieres.
"""Week 4 - Structured output (Gemini).
Same schema, same job, so the two SDKs read side by side. Gemini takes the
schema on its config as `response_schema` and, with the JSON mime type set,
hands the parsed object back on `response.parsed`.
"""
import os
import sys
from dotenv import find_dotenv, load_dotenv
from google import genai
from google.genai import types
from pydantic import BaseModel
load_dotenv(find_dotenv())
if not os.environ.get("GEMINI_API_KEY"):
sys.exit("GEMINI_API_KEY is not set. Put it in the course-root .env file.")
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
# region: schema
class CalendarEvent(BaseModel):
name: str
date: str
participants: list[str]
# endregion
TEXT = "Lunch with Ana and Diego next Friday to go over the Q3 roadmap."
# region: parse-call
# response_mime_type pins JSON; response_schema pins the shape. Gemini
# returns the validated object on `.parsed`, the raw JSON on `.text`.
response = client.models.generate_content(
model="gemini-3.1-flash-lite",
contents=f"Extract the calendar event from this text:\n{TEXT}",
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=CalendarEvent,
),
)
event = response.parsed
# endregion
print(type(event).__name__, "->")
print(" name:", event.name)
print(" date:", event.date)
print(" participants:", event.participants)
Una sola definición del schema, dos SDKs, y otra vez la única diferencia real es
dónde se conecta el schema: un argumento text_format en OpenAI, un campo de
configuración response_schema en Gemini. Escribes el modelo de Pydantic una
vez y puedes apuntar cualquiera de los dos proveedores hacia él. La forma de tus
datos deja de ser un asunto por proveedor, que es exactamente lo que quieres
cuando más adelante andes cambiando de modelo.
Ponlo a trabajar
Tres aplicaciones web con docker-compose bajo code/showcase/<slug>/, la misma
rutina de siempre: bash bootstrap-secrets.sh, docker compose up --build,
http://localhost:3000. Cada una regresa JSON que podrías alimentar directo al
siguiente sistema, en lugar de un párrafo que tendrías que raspar.
Showcase 1 — Extraer campos
Pega un texto desordenado — la firma de un correo, una presentación reenviada — y recibe de vuelta un objeto de contacto limpio: nombre, email, teléfono, empresa. Los campos son opcionales en el schema, así que todo lo que el texto no menciona regresa como null, en lugar de que el modelo se invente un teléfono plausible para llenar el hueco. Ese comportamiento de null-en-vez-de-adivinar es consecuencia directa de cómo tipaste el schema.
Showcase 2 — Clasificar a JSON
En la semana dos le sacamos una etiqueta como texto y cruzamos los dedos para
que respetara el formato. Esto hace la misma clasificación, pero el schema
convierte el formato en garantía: una etiqueta de un conjunto fijo, un float de
confianza, una lista de razones. Puedes hacer branch sobre result.label sin
revisar primero si el modelo lo envolvió en una oración, porque el campo
estructuralmente siempre está ahí.
Showcase 3 — Llenado de formularios
El más cercano al trabajo real. Una nota de gasto de una línea se convierte en
un renglón estructurado listo para insertar — comercio, monto como número,
moneda, una categoría de tu conjunto, una fecha. El schema hace doble trabajo
aquí: le da forma a la petición y valida la respuesta, así que amount es un
float que puedes sumar y category es una de las tuyas, no texto libre que
tengas que conciliar después.
Los tres corren el mismo backend/main.py, byte por byte, despachando según
PROVIDER, así que PROVIDER=gemini docker compose up --build cambia de SDK
sin tocar la lección. La decisión de diseño que los mantiene honestos es que el
valor de retorno son datos validados, no texto que casualmente parece datos — la
diferencia que solo notas el día en que al modelo le da por ponerse platicador.
Córrelo
El README de esta carpeta tiene la versión de Python, la línea de instalación,
las dos variables de entorno y el comando exacto para los ejemplos básicos y
cada showcase. Las llaves salen del .env no rastreado en la raíz del curso,
igual que todas las demás semanas.
Conclusiones
En el momento en que el output de un modelo alimenta código en lugar de a una persona, deja de pedir JSON en la prosa y empieza a declarar un schema. Convierte "casi siempre parseable" en "validado o falló" — y "casi siempre" es la frase que te despierta a las 2am. Escribe la forma una vez como modelo de Pydantic, entrégasela a cualquiera de los dos proveedores y recibe de vuelta un objeto tipado. Extracción, clasificación, llenar un registro: la misma jugada cada vez. Todo lo que los capítulos de agentes hacen después con tool calls es esta misma maquinaria apuntada a otro objetivo, así que volverla reflejo desde ahora rinde para el resto del libro.