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

AspectoSin contrato (texto libre)Con contrato (structured)
ParsingRegex frágil, casos edgejson.loads() + Pydantic
ErroresSilenciosos (valor incorrecto)Explícitos (ValidationError)
TestingDifícil de mockingMockeable con fixtures
MantenimientoCrece con cada variaciónCambiar el schema
IntegraciónParser por cada sistemaSchema 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ápsulaQué verás
01Introducción (esta)Por qué structured output, los 5 mecanismos, setup
02JSON mode y response formatOpenAI JSON mode, JSON Schema, Anthropic, retry
03Pydantic y function callingSchemas Pydantic, tools, validación, estructuras anidadas
04System prompt design patternsExpert, Analyst, Formatter, Guardian — 4 patrones base
05Guardrails y safetyPrompt injection, validación, content filtering
06Prompt templates y variablesf-strings, Jinja2, composición modular
07Multi-model structured outputAdapter patterns, fallback entre proveedores
08Proyecto: Structured Data ExtractorExtracció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:

MecanismoProblema que resuelveCápsula
JSON ModeTexto libre que no parsea02
JSON Schema / Structured OutputsJSON que no sigue el schema esperado02
Function CallingSchema garantizado + integración con código real03
PydanticJSON correcto pero tipos incorrectos (str vs float)03
System Prompt PatternsComportamiento inconsistente entre requests04
GuardrailsOutputs peligrosos o fuera de dominio05
Prompt TemplatesPrompts duplicados, difícil de versionear06
Multi-providerLock-in a un proveedor, sin fallback07

Recursos adicionales

  1. OpenAI Structured Outputs Guide — Comparativa entre JSON mode y json_schema
  2. Anthropic Structured Output — Tool use para schema estricto en Claude
  3. Pydantic v2 Docs — BaseModel, validators, JSON Schema generation
  4. Function Calling Guide (OpenAI) — Tool use, parallel tool calls, tool choice
  5. JSON Schema Spec — Entender los schemas usados en response_format y tools