Módulo 3: Structured Outputs y System Prompts
1. Introducción: Outputs Predecibles como Requisito de Producción
Descripción
En producción, el output de un LLM debe ser parseable por código. "Texto libre" no sirve cuando tu aplicación necesita extraer datos, validar respuestas o integrar con sistemas downstream. En este módulo aprenderás JSON mode, function calling, schemas con Pydantic, system prompt design patterns, guardrails y prompt templates para obtener outputs estructurados de forma confiable.
Por qué importa: Sin structured output, cada respuesta es un snowflake: única, impredecible, y potencialmente rota. Con structured output, tienes un contrato: el modelo devuelve datos que tu código puede consumir sin parsing frágil ni regex. La diferencia entre un prototipo y producción real está casi siempre en cómo manejas el output.
El Problema del Texto Libre en Producción
Qué falla
# Escenario: extracción de datos de facturas para sistema de contabilidad
# Sin estructura — lo que puede devolver el modelo:
respuesta_1 = "La factura tiene un total de $1,299 USD emitida el 15 de enero."
respuesta_2 = "Total: USD 1,299.00\nFecha: enero 15, 2025"
respuesta_3 = "1299 dolares, enero 2025"
respuesta_4 = "{'total': '$1,299', 'fecha': '15/01/2025'}" # Comillas simples
# Tu código espera:
# {"total": 1299.0, "moneda": "USD", "fecha": "2025-01-15"}
# Ninguna de las respuestas anteriores es directamente usable con json.loads()
Consecuencias:
- Regex frágil que falla con variaciones mínimas
- Parsing manual que crece en complejidad con cada caso edge
- Tests que fallan al actualizar el modelo
- Incidentes en producción a las 3am
El costo real
# Sin structured output: código de parsing típico en producción
def parsear_total_factura(texto: str) -> float:
# Intento 1: JSON
try:
data = json.loads(texto)
return float(data.get("total", data.get("monto", data.get("amount", 0))))
except json.JSONDecodeError:
pass
# Intento 2: regex para montos
patterns = [
r'\$\s*([\d,]+\.?\d*)',
r'total[:\s]*([\d,]+\.?\d*)',
r'USD\s*([\d,]+\.?\d*)',
r'([\d,]+\.?\d*)\s*(?:USD|MXN|EUR|dólares)',
]
for p in patterns:
m = re.search(p, texto, re.IGNORECASE)
if m:
return float(m.group(1).replace(",", ""))
# Fallback: buscar cualquier número grande
nums = re.findall(r'[\d,]+\.?\d+', texto)
if nums:
return max(float(n.replace(",", "")) for n in nums)
raise ValueError(f"No se pudo parsear total de: {texto[:100]}")
# Este código tiene 25 líneas, es frágil, y falla en casos edge
# Con structured output: 1 línea
# total = json.loads(respuesta)["total"] # Siempre funciona
Structured Output como Contrato de API
Un structured output define un contrato entre el LLM y tu código:
from pydantic import BaseModel, Field
from typing import Literal, Optional
from datetime import date
# El contrato
class FacturaData(BaseModel):
proveedor: str
numero_factura: str
fecha_emision: str # YYYY-MM-DD
total: float
moneda: Literal["USD", "EUR", "MXN", "GBP"]
items: list[dict]
class Config:
# Permite campos extra del modelo pero los ignora
extra = "ignore"
# Tu sistema sabe exactamente qué esperar:
# - proveedor siempre es str
# - total siempre es float
# - moneda siempre es uno de 4 valores posibles
# Si el LLM devuelve algo diferente → ValidationError → manejable, no silencioso
Analogía: Contrato vs Texto Libre
| Aspecto | Sin contrato (texto libre) | Con contrato (structured) |
|---|---|---|
| Parsing | Regex frágil, casos edge | json.loads() + Pydantic |
| Errores | Silenciosos (valor incorrecto) | Explícitos (ValidationError) |
| Testing | Difícil de mocking | Mockeable con fixtures |
| Mantenimiento | Crece con cada variación | Cambiar el schema |
| Integración | Parser por cada sistema | Schema compartido |
Los 5 Mecanismos de Structured Output
Este módulo cubre los 5 mecanismos principales:
1. JSON Mode (cápsula 02)
# OpenAI: garantiza JSON válido
response_format={"type": "json_object"}
Para: Cualquier output en JSON. Simple y efectivo.
2. JSON Schema / Structured Outputs (cápsula 02)
# OpenAI: garantiza schema estricto
response_format={"type": "json_schema", "json_schema": {...}}
Para: Cuando necesitas schema exacto con tipos estrictos.
3. Function Calling / Tool Use (cápsula 03)
# OpenAI: el modelo llama una función con argumentos tipados
tools=[{"type": "function", "function": {"name": "...", "parameters": {...}}}]
# Anthropic: equivalente con tool_choice
Para: Integración con código real, agentes, structured output garantizado.
4. Pydantic Integration (cápsula 03)
# Definir schema con Pydantic, generar JSON Schema automáticamente
schema = MiModel.model_json_schema()
Para: Validación de outputs, coerción de tipos, documentación automática.
5. Prompt Engineering para Estructura (cápsulas 04-06)
# System prompt design, guardrails, templates
system = "Eres un extractor de datos. Devuelve SIEMPRE JSON con schema X."
Para: Cuando JSON mode no está disponible o para reforzar estructura.
Cuándo Usar Cada Mecanismo
¿Necesitas schema 100% garantizado?
├── SÍ → Function Calling o JSON Schema (response_format con json_schema)
└── NO → JSON mode (response_format: json_object) es suficiente
¿El sistema necesita ejecutar código real (no solo parsear)?
├── SÍ → Function Calling / Tool Use
└── NO → JSON mode + Pydantic
¿Multi-proveedor (OpenAI + Anthropic)?
├── SÍ → Prompt engineering + parsing robusto (no puedes depender de features propietarios)
└── NO → Usa el mecanismo nativo del proveedor
¿Necesitas validación de tipos y coerción?
├── SÍ → Pydantic (siempre, independientemente del mecanismo)
└── NO → json.loads() puede ser suficiente
Setup del Módulo
# requirements.txt para este módulo
# openai>=1.0.0
# anthropic>=0.25.0
# python-dotenv>=1.0.0
# pydantic>=2.0.0
from openai import OpenAI
import anthropic
from pydantic import BaseModel
import json
import os
from dotenv import load_dotenv
load_dotenv()
oai_client = OpenAI()
ant_client = anthropic.Anthropic()
def verificar_setup():
"""Verifica que el setup esté correcto."""
# Test OpenAI
r = oai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Di exactamente: JSON_OK"}],
temperature=0,
max_tokens=10
)
assert "JSON_OK" in r.choices[0].message.content, "OpenAI setup incorrecto"
print("✅ OpenAI: OK")
# Test Anthropic
r = ant_client.messages.create(
model="claude-3-5-haiku-20241022",
messages=[{"role": "user", "content": "Di exactamente: JSON_OK"}],
temperature=0,
max_tokens=10
)
assert "JSON_OK" in r.content[0].text, "Anthropic setup incorrecto"
print("✅ Anthropic: OK")
# Test Pydantic
class TestModel(BaseModel):
campo: str
t = TestModel(campo="test")
assert t.campo == "test"
print("✅ Pydantic: OK")
verificar_setup()
Roadmap del Módulo 3
| # | Cápsula | Qué verás |
|---|---|---|
| 01 | Introducción (esta) | Por qué structured output, los 5 mecanismos, setup |
| 02 | JSON mode y response format | OpenAI JSON mode, JSON Schema, Anthropic, retry |
| 03 | Pydantic y function calling | Schemas Pydantic, tools, validación, estructuras anidadas |
| 04 | System prompt design patterns | Expert, Analyst, Formatter, Guardian — 4 patrones base |
| 05 | Guardrails y safety | Prompt injection, validación, content filtering |
| 06 | Prompt templates y variables | f-strings, Jinja2, composición modular |
| 07 | Multi-model structured output | Adapter patterns, fallback entre proveedores |
| 08 | Proyecto: Structured Data Extractor | Extracción de invoices, emails, artículos con schemas Pydantic |
Duración estimada: 1.25-1.5 hrs para el módulo completo.
Conexión con el Proyecto
En el Structured Data Extractor (cápsula 08) construirás:
- Extracción de datos de invoices con schema
InvoiceData(proveedor, monto, fecha, items) - Extracción de emails con schema
EmailData(remitente, asunto, acciones_requeridas) - Extracción de artículos con schema
ArticleData(titulo, resumen, puntos_clave, tono) - Retry logic para outputs malformados con feedback al modelo
- Fallback automático entre OpenAI y Anthropic
- Comparación de accuracy y costo entre mecanismos
Resumen
- Problema: Texto libre no es parseable, integrable, ni testeable en producción
- Solución: Structured output como contrato: schema definido, tipos validados, errores explícitos
- 5 mecanismos: JSON mode, JSON Schema, Function Calling, Pydantic, System Prompt Engineering
- Cuándo usar cada uno: Schema garantizado → tools; multi-proveedor → prompt engineering; siempre → Pydantic para validar
- En este módulo: Cada mecanismo en profundidad + proyecto integrador
Del Texto Libre al Sistema de Producción: Una Transformación Real
Para entender el impacto completo, veamos la misma funcionalidad con y sin structured output en un escenario de extracción de datos de correos electrónicos de proveedores:
Versión sin structured output (estado inicial)
import re
from openai import OpenAI
client = OpenAI()
def extraer_datos_email_sin_estructura(email_texto: str) -> dict:
"""
Versión sin structured output — frágil, difícil de mantener.
"""
prompt = f"""
Lee este correo y dime:
1. El proveedor
2. El monto total
3. La fecha de vencimiento
4. Si hay alguna acción urgente
Correo:
{email_texto}
"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0
)
texto_respuesta = response.choices[0].message.content
# Ahora toca parsear texto libre... buena suerte
datos = {}
# Intentar extraer proveedor (pattern frágil)
if "proveedor:" in texto_respuesta.lower():
linea = [l for l in texto_respuesta.split("\n") if "proveedor" in l.lower()]
if linea:
datos["proveedor"] = linea[0].split(":")[-1].strip()
# Intentar extraer monto (múltiples formatos posibles)
patrones_monto = [
r'\$\s*([\d,]+(?:\.\d{2})?)',
r'([\d,]+(?:\.\d{2})?)\s*(?:USD|MXN|EUR)',
r'monto[:\s]+([\d,]+(?:\.\d{2})?)',
r'total[:\s]+([\d,]+(?:\.\d{2})?)',
]
for patron in patrones_monto:
match = re.search(patron, texto_respuesta, re.IGNORECASE)
if match:
datos["monto"] = float(match.group(1).replace(",", ""))
break
# Intentar extraer fecha (formato totalmente variable)
patrones_fecha = [
r'\d{4}-\d{2}-\d{2}',
r'\d{1,2}/\d{1,2}/\d{4}',
r'\d{1,2}\s+de\s+\w+\s+de\s+\d{4}',
]
for patron in patrones_fecha:
match = re.search(patron, texto_respuesta)
if match:
datos["fecha_vencimiento"] = match.group(0)
break
# Urgente: buscar palabras clave
urgente_keywords = ["urgente", "inmediato", "hoy", "vence", "deadline"]
datos["tiene_urgencia"] = any(k in texto_respuesta.lower() for k in urgente_keywords)
return datos # Puede estar incompleto, con formatos mixtos, o incorrecto
# PROBLEMA: Este código tiene 50 líneas, es frágil, y falla silenciosamente.
# Si el modelo cambia su formato de respuesta → parsing roto sin error.
Versión con structured output (producción)
from pydantic import BaseModel, Field
from typing import Optional, Literal
import json
class DatosEmail(BaseModel):
proveedor: str
monto: Optional[float] = None
moneda: Literal["USD", "EUR", "MXN", "GBP", "DESCONOCIDA"] = "DESCONOCIDA"
fecha_vencimiento: Optional[str] = None # YYYY-MM-DD
tiene_urgencia: bool = False
acciones_requeridas: list[str] = Field(default_factory=list)
resumen: str
def extraer_datos_email_estructurado(email_texto: str) -> DatosEmail:
"""
Versión con structured output — robusta, mantenible, testeable.
"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": """
Eres un extractor de datos de correos de proveedores.
Extrae los datos solicitados y devuelve SOLO JSON válido con el schema indicado.
Si un campo no está disponible, usa null para los opcionales.
Fechas siempre en formato YYYY-MM-DD.
"""
},
{
"role": "user",
"content": f"""
Extrae los datos de este correo en JSON con este schema exacto:
{{
"proveedor": "nombre del proveedor",
"monto": null o número flotante,
"moneda": "USD|EUR|MXN|GBP|DESCONOCIDA",
"fecha_vencimiento": null o "YYYY-MM-DD",
"tiene_urgencia": true o false,
"acciones_requeridas": ["acción 1", "acción 2"],
"resumen": "resumen 1 oración"
}}
Correo:
{email_texto}
"""
}
],
response_format={"type": "json_object"}, # JSON mode
temperature=0
)
data = json.loads(response.choices[0].message.content)
return DatosEmail(**data) # Pydantic valida y convierte tipos
# VENTAJAS:
# - 1 línea para parsear: json.loads() + DatosEmail(**data)
# - ValidationError explícito si el schema no se respeta
# - Tipos correctos garantizados (monto es float, no string)
# - Testeable con fixtures: DatosEmail(proveedor="X", monto=100.0, ...)
# Test con email real
email_muestra = """
De: facturas@acmecorp.com
Asunto: Factura #INV-2025-0342 - Vencimiento Urgente
Estimado equipo,
La factura #INV-2025-0342 por USD 3,450.00 vence el 15 de enero de 2025.
Se requiere pago inmediato para evitar cargos por mora.
Atentamente,
ACME Corp
"""
resultado = extraer_datos_email_estructurado(email_muestra)
print(f"Proveedor: {resultado.proveedor}")
print(f"Monto: {resultado.monto} {resultado.moneda}")
print(f"Vencimiento: {resultado.fecha_vencimiento}")
print(f"Urgente: {resultado.tiene_urgencia}")
print(f"Acciones: {resultado.acciones_requeridas}")
# Output:
# Proveedor: ACME Corp
# Monto: 3450.0 USD
# Vencimiento: 2025-01-15
# Urgente: True
# Acciones: ['Procesar pago inmediato', 'Confirmar recepción de factura']
Diferencia clave: 50 líneas de regex frágil → 5 líneas de código limpio. Y si el modelo produce JSON malformado, obtienes un ValidationError explícito, no datos silenciosamente incorrectos.
Evolución: Del Prototipo a Producción
Este diagrama muestra cómo evoluciona el handling del output:
ETAPA 1 — Prototipo (días 1-7)
───────────────────────────────
prompt simple → texto libre → leerlo manualmente
✓ Funciona para demos y exploración
✗ No escalable, no testeable
ETAPA 2 — Primer sistema (semanas 2-4)
───────────────────────────────────────
prompt con instrucción de formato → regex parsing
✓ Funciona para casos comunes
✗ Falla en edge cases, difícil de mantener
ETAPA 3 — Sistema robusto (mes 2+)
────────────────────────────────────
JSON mode / function calling + Pydantic + retry logic
✓ Parseable garantizado
✓ Tipos validados
✓ Errores explícitos
✓ Testeable con fixtures
✓ Compatible con múltiples proveedores
ETAPA 4 — Producción full (mes 3+)
────────────────────────────────────
+ Fallback multi-provider
+ Guardrails (content filtering, injection detection)
+ Templates versionados
+ Monitoring de schema violations
+ A/B testing de prompts
Este módulo te lleva de la Etapa 2 a la Etapa 4.
Por Qué Cada Mecanismo Existe
Cada uno de los 5 mecanismos del módulo existe porque resuelve un problema específico:
| Mecanismo | Problema que resuelve | Cápsula |
|---|---|---|
| JSON Mode | Texto libre que no parsea | 02 |
| JSON Schema / Structured Outputs | JSON que no sigue el schema esperado | 02 |
| Function Calling | Schema garantizado + integración con código real | 03 |
| Pydantic | JSON correcto pero tipos incorrectos (str vs float) | 03 |
| System Prompt Patterns | Comportamiento inconsistente entre requests | 04 |
| Guardrails | Outputs peligrosos o fuera de dominio | 05 |
| Prompt Templates | Prompts duplicados, difícil de versionear | 06 |
| Multi-provider | Lock-in a un proveedor, sin fallback | 07 |
Recursos adicionales
- OpenAI Structured Outputs Guide — Comparativa entre JSON mode y json_schema
- Anthropic Structured Output — Tool use para schema estricto en Claude
- Pydantic v2 Docs — BaseModel, validators, JSON Schema generation
- Function Calling Guide (OpenAI) — Tool use, parallel tool calls, tool choice
- JSON Schema Spec — Entender los schemas usados en response_format y tools