Módulo 3: Structured Outputs y System Prompts

2. JSON Mode y Response Format

Descripción

OpenAI ofrece JSON mode que fuerza al modelo a devolver JSON válido. Anthropic ofrece structured output con JSON Schema. En esta cápsula aprenderás a usar ambos mecanismos, la diferencia entre "JSON válido" y "schema correcto", cómo manejar errores de parsing, y estrategias de retry robustas cuando el output falla.

Por qué importa: En producción, json.loads() lanzando una excepción a las 3am es un incidente. El JSON mode elimina el problema de parsing en OpenAI, pero no garantiza que el schema sea correcto. Necesitas ambos: JSON válido + validación de schema. Esta cápsula cubre cómo lograr ambos de forma confiable.


El Problema: Texto Libre vs JSON Estructurado

Sin ningún mecanismo de structured output, el modelo puede dar:

# Lo que pediste
{"sentiment": "positive", "confidence": 0.95}

# Lo que puedes recibir
"El sentimiento es positivo con una confianza de aproximadamente 95%."
"Sentiment: positive\nConfidence: 0.95"
```json
{"sentiment": "positive", "confidence": 0.95}

{"sentiment": "positive", "confidence": .95} # JSON inválido: .95 sin 0 {"Sentiment": "Positive", "Confidence": "0.95"} # Keys con capitalización distinta


**Sin JSON mode:** Necesitas regex, parsing frágil, y manejo de múltiples formatos.
**Con JSON mode:** El modelo garantiza JSON sintácticamente válido. Tú validas el schema.

---

## OpenAI: JSON Mode con `response_format`

### Uso básico

```python
from openai import OpenAI
import json

client = OpenAI()

def extraer_entidades(texto: str) -> dict:
    """
    Extrae entidades usando JSON mode de OpenAI.
    Garantiza JSON válido en la respuesta.
    """
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": """
                Extrae entidades nombradas del texto.
                Devuelve JSON con este schema:
                {"personas": ["..."], "organizaciones": ["..."], "lugares": ["..."]}
                
                Si no hay entidades de un tipo, usa lista vacía.
                """
            },
            {"role": "user", "content": texto}
        ],
        response_format={"type": "json_object"},  # Activa JSON mode
        temperature=0
    )
    
    return json.loads(response.choices[0].message.content)

# Test
resultado = extraer_entidades("María García trabaja en Google en Mountain View.")
print(resultado)
# {"personas": ["María García"], "organizaciones": ["Google"], "lugares": ["Mountain View"]}

Limitaciones de JSON mode básico

# JSON mode garantiza JSON válido, pero NO garantiza el schema correcto

# El modelo podría devolver:
{
    "nombres": ["María García"],   # Key diferente (nombres vs personas)
    "companies": ["Google"],       # En inglés
    "locations": ["Mountain View"] # Key diferente
}

# O añadir keys no solicitadas:
{
    "personas": ["María García"],
    "organizaciones": ["Google"],
    "lugares": ["Mountain View"],
    "fecha": null,              # Key extra no pedida
    "total_entidades": 3        # Key extra no pedida
}

Para schema estricto: Usa Structured Outputs con schema JSON (ver abajo) o function calling (cápsula 03).


OpenAI: Structured Outputs con Schema Estricto

OpenAI ofrece Structured Outputs que validan contra un JSON Schema específico:

from openai import OpenAI
import json

client = OpenAI()

def extraer_con_schema(texto: str) -> dict:
    """
    Extrae entidades con schema estrictamente definido.
    """
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": "Extrae entidades nombradas del texto."
            },
            {"role": "user", "content": texto}
        ],
        response_format={
            "type": "json_schema",
            "json_schema": {
                "name": "entity_extraction",
                "strict": True,  # Schema estricto
                "schema": {
                    "type": "object",
                    "properties": {
                        "personas": {
                            "type": "array",
                            "items": {"type": "string"},
                            "description": "Nombres propios de personas"
                        },
                        "organizaciones": {
                            "type": "array",
                            "items": {"type": "string"},
                            "description": "Nombres de empresas, organizaciones"
                        },
                        "lugares": {
                            "type": "array",
                            "items": {"type": "string"},
                            "description": "Ciudades, países, lugares"
                        }
                    },
                    "required": ["personas", "organizaciones", "lugares"],
                    "additionalProperties": False  # No permite keys extra
                }
            }
        },
        temperature=0
    )
    
    return json.loads(response.choices[0].message.content)

# El output siempre tendrá exactamente personas, organizaciones, lugares
resultado = extraer_con_schema("Apple fue fundada en Cupertino por Steve Jobs.")
print(resultado)
# {"personas": ["Steve Jobs"], "organizaciones": ["Apple"], "lugares": ["Cupertino"]}

Anthropic: Structured Output con JSON Schema

Anthropic ofrece structured output nativo desde la API de mensajes:

import anthropic

client = anthropic.Anthropic()

def extraer_anthropic(texto: str) -> dict:
    """
    Extrae entidades con schema estricto usando Anthropic.
    """
    response = client.messages.create(
        model="claude-3-5-haiku-20241022",
        max_tokens=500,
        messages=[
            {
                "role": "user",
                "content": f"Extrae las entidades nombradas de: '{texto}'"
            }
        ],
        tools=[
            {
                "name": "extract_entities",
                "description": "Extrae entidades nombradas del texto",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "personas": {
                            "type": "array",
                            "items": {"type": "string"},
                            "description": "Nombres de personas"
                        },
                        "organizaciones": {
                            "type": "array",
                            "items": {"type": "string"}
                        },
                        "lugares": {
                            "type": "array",
                            "items": {"type": "string"}
                        }
                    },
                    "required": ["personas", "organizaciones", "lugares"]
                }
            }
        ],
        tool_choice={"type": "auto"}
    )
    
    # Extraer el resultado del tool use
    for block in response.content:
        if block.type == "tool_use":
            return block.input
    
    raise ValueError("No se obtuvo tool use response")

resultado = extraer_anthropic("María García trabaja en Google en Madrid.")
print(resultado)

Alternativa Portable: Instrucciones Explícitas + Parsing Robusto

Para proyectos que necesitan funcionar en múltiples proveedores sin JSON mode:

from openai import OpenAI
import anthropic
import json
import re
from typing import Literal

oai_client = OpenAI()
ant_client = anthropic.Anthropic()

def limpiar_json(texto: str) -> str:
    """
    Extrae JSON de una respuesta que puede tener texto adicional.
    """
    texto = texto.strip()
    
    # Caso 1: JSON puro
    if texto.startswith('{') or texto.startswith('['):
        return texto
    
    # Caso 2: Envuelto en ```json ... ``` o ``` ... ```
    match = re.search(r'```(?:json)?\s*([\s\S]*?)\s*```', texto)
    if match:
        return match.group(1).strip()
    
    # Caso 3: JSON embebido en texto
    match = re.search(r'\{[\s\S]*?\}', texto)
    if match:
        return match.group()
    
    raise ValueError(f"No se encontró JSON en: {texto[:100]}")

SYSTEM_PORTABLE = """
Analiza el texto y devuelve ÚNICAMENTE un JSON válido con este schema exacto:
{"sentimiento": "POSITIVO|NEGATIVO|NEUTRO", "confianza": 0.0-1.0, "palabras_clave": ["...", "..."]}

NO incluyas texto antes ni después del JSON.
Ejemplo correcto: {"sentimiento": "POSITIVO", "confianza": 0.92, "palabras_clave": ["excelente", "rápido"]}
"""

def analizar_portable(texto: str, provider: Literal["openai", "anthropic"] = "openai") -> dict:
    """
    Funciona con ambos proveedores sin depender de JSON mode.
    """
    if provider == "openai":
        r = oai_client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[
                {"role": "system", "content": SYSTEM_PORTABLE},
                {"role": "user", "content": texto}
            ],
            response_format={"type": "json_object"},  # JSON mode para OpenAI
            temperature=0
        )
        return json.loads(r.choices[0].message.content)
    
    elif provider == "anthropic":
        r = ant_client.messages.create(
            model="claude-3-5-haiku-20241022",
            system=SYSTEM_PORTABLE,
            messages=[{"role": "user", "content": texto}],
            temperature=0,
            max_tokens=200
        )
        raw = r.content[0].text
        return json.loads(limpiar_json(raw))

# Test con ambos
for provider in ["openai", "anthropic"]:
    resultado = analizar_portable("El servicio fue rápido y el producto excelente.", provider)
    print(f"{provider}: {resultado}")

Manejo de Errores y Retry

Patrón de retry con feedback

from openai import OpenAI
import json
import time

client = OpenAI()

def extraer_con_retry(
    prompt_system: str,
    texto: str,
    max_retries: int = 3,
    delay_base: float = 1.0
) -> dict:
    """
    Extrae JSON con retry exponencial cuando el parsing falla.
    
    Estrategia:
    - Intento 1: JSON mode activado
    - Intento 2: Agrega feedback del error
    - Intento 3: Simplifica el prompt
    """
    last_error = None
    
    for intento in range(max_retries):
        try:
            if intento == 0:
                # Intento normal
                messages = [
                    {"role": "system", "content": prompt_system},
                    {"role": "user", "content": texto}
                ]
            elif intento == 1:
                # Agregar feedback del error anterior
                messages = [
                    {"role": "system", "content": prompt_system},
                    {"role": "user", "content": texto},
                    {"role": "assistant", "content": f"[Respuesta anterior que falló: {last_error}]"},
                    {"role": "user", "content": "La respuesta anterior no fue JSON válido. Devuelve SOLO el JSON, sin texto adicional."}
                ]
            else:
                # Simplificar: pedir el mínimo indispensable
                messages = [
                    {
                        "role": "user",
                        "content": f"""
Necesito JSON válido. Solo responde con el JSON, nada más.
Schema: {prompt_system[:200]}
Input: {texto[:200]}
"""
                    }
                ]
            
            response = client.chat.completions.create(
                model="gpt-4o-mini",
                messages=messages,
                response_format={"type": "json_object"},
                temperature=0,
                max_tokens=500
            )
            
            content = response.choices[0].message.content
            result = json.loads(content)
            
            if intento > 0:
                print(f"Éxito en intento {intento + 1}")
            
            return result
            
        except json.JSONDecodeError as e:
            last_error = str(e)
            if intento < max_retries - 1:
                wait = delay_base * (2 ** intento)  # Exponential backoff
                print(f"Intento {intento + 1} falló: {e}. Reintentando en {wait}s...")
                time.sleep(wait)
        except Exception as e:
            # Para otros errores (rate limit, etc.) también hacemos backoff
            if intento < max_retries - 1:
                wait = delay_base * (2 ** intento)
                time.sleep(wait)
            else:
                raise
    
    raise ValueError(f"No se pudo obtener JSON válido en {max_retries} intentos. Último error: {last_error}")

# Uso
SYSTEM = """
Analiza la reseña y devuelve JSON:
{"sentimiento": "POSITIVO|NEGATIVO|NEUTRO", "aspectos": {"comida": "positivo|negativo|null", "servicio": "positivo|negativo|null"}}
"""

resultado = extraer_con_retry(SYSTEM, "La comida estaba buena pero el servicio fue muy lento.")
print(resultado)

Validación con Pydantic

JSON mode garantiza JSON válido pero no que el schema sea correcto. Pydantic valida el schema:

from pydantic import BaseModel, Field, field_validator
from typing import Optional, Literal
from openai import OpenAI
import json

client = OpenAI()

class AnalisisSentimiento(BaseModel):
    sentimiento: Literal["POSITIVO", "NEGATIVO", "NEUTRO"]
    confianza: float = Field(ge=0.0, le=1.0)
    palabras_clave: list[str] = Field(max_length=10)
    
    @field_validator("palabras_clave")
    @classmethod
    def validate_keywords(cls, v: list[str]) -> list[str]:
        return [k.lower().strip() for k in v]  # Normalizar

def analizar_con_validacion(texto: str) -> AnalisisSentimiento:
    """
    Analiza texto y valida el output contra el schema Pydantic.
    """
    SYSTEM = """
    Analiza el sentimiento del texto.
    Devuelve JSON con exactamente estos campos:
    - sentimiento: "POSITIVO", "NEGATIVO" o "NEUTRO" (mayúsculas exactas)
    - confianza: número entre 0.0 y 1.0
    - palabras_clave: lista de 2-5 palabras que justifican el sentimiento
    """
    
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": SYSTEM},
            {"role": "user", "content": texto}
        ],
        response_format={"type": "json_object"},
        temperature=0
    )
    
    data = json.loads(response.choices[0].message.content)
    
    # Pydantic valida el schema y lanza ValidationError si algo está mal
    return AnalisisSentimiento(**data)

# Test
textos = [
    "El producto llegó rápido y en excelentes condiciones.",
    "Pésima experiencia. Nunca más compro aquí.",
    "Es un producto normal, ni bueno ni malo."
]

for texto in textos:
    try:
        resultado = analizar_con_validacion(texto)
        print(f"Input: {texto[:50]}...")
        print(f"  Sentimiento: {resultado.sentimiento} ({resultado.confianza:.0%})")
        print(f"  Keywords: {resultado.palabras_clave}\n")
    except Exception as e:
        print(f"Error: {e}")

Comparación: JSON Mode vs Sin JSON Mode

AspectoSin JSON modeCon JSON modeStructured Outputs (schema estricto)
Garantía de JSON válido
Garantía de schema correcto
Keys extras posibles❌ (con additionalProperties: false)
Complejidad de setupBajaBajaMedia
Compatibilidad Anthropic✅ (instrucciones)❌ (solo OpenAI)✅ (tool use)
Costo adicionalNingunoNingunoNinguno

Conexión con el Proyecto

En el Structured Data Extractor (cápsula 08), usarás JSON mode + Pydantic para:

  • Extraer datos de invoices con schema InvoiceData(proveedor, monto, fecha, items)
  • Manejar errores con retry automático
  • Validar el output contra el schema antes de retornar
  • Fallback a Anthropic si OpenAI falla

Troubleshooting

Problema 1: JSON con trailing comma o comillas simples

Síntoma: json.loads() lanza JSONDecodeError incluso con JSON mode activo.

Causa: En modelos menos potentes, el JSON mode a veces produce JSON casi-válido.

Solución:

import json
import re

def reparar_json(texto: str) -> dict:
    """Intenta reparar JSON con problemas comunes."""
    # Trailing comma: {"a": 1, "b": 2,} → {"a": 1, "b": 2}
    texto = re.sub(r',\s*}', '}', texto)
    texto = re.sub(r',\s*]', ']', texto)
    # Comillas simples → dobles (simple fix, no funciona con todos los casos)
    texto = texto.replace("'", '"')
    return json.loads(texto)

Problema 2: El modelo inventa keys no pedidas

Causa: JSON mode solo garantiza JSON válido, no schema correcto.

Solución: Usar schema estricto con additionalProperties: false o validar con Pydantic y manejo de extra = "ignore":

class MiSchema(BaseModel):
    model_config = {"extra": "ignore"}  # Ignora keys extras
    campo_requerido: str
    campo_opcional: Optional[str] = None

Problema 3: Output truncado (JSON incompleto)

Síntoma: JSON termina abruptamente: {"nombre": "Juan", "email": "

Causa: max_tokens demasiado bajo.

Diagnóstico:

print(response.choices[0].finish_reason)  # "length" = truncado

Solución: Aumentar max_tokens hasta que finish_reason == "stop".


Problema 4: Campos con tipos incorrectos

Síntoma: El precio llega como "1299" (string) en lugar de 1299.0 (float).

Solución: Pydantic hace coerción automática en algunos casos, pero es mejor especificarlo en el schema y en el prompt:

class ItemData(BaseModel):
    precio: float  # Pydantic convierte "1299" → 1299.0

# En el prompt: "precio: número decimal (sin comillas, ej: 1299.99)"

Ejercicios

Ejercicio 1: Implementar retry con feedback

Modifica la función extraer_con_retry para que en el segundo intento incluya el output erróneo y pida que lo corrija:

Ver solución
def retry_con_correccion(sistema: str, texto: str) -> dict:
    """
    Intento 1: Normal
    Intento 2: Muestra el output incorrecto y pide corrección
    """
    response1 = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": sistema},
            {"role": "user", "content": texto}
        ],
        response_format={"type": "json_object"},
        temperature=0
    )
    raw = response1.choices[0].message.content
    
    try:
        return json.loads(raw)
    except json.JSONDecodeError:
        # Segundo intento con el output incorrecto como contexto
        response2 = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[
                {"role": "system", "content": sistema},
                {"role": "user", "content": texto},
                {"role": "assistant", "content": raw},
                {"role": "user", "content": f"Ese JSON no es válido. El error es: parse error. Devuelve el mismo contenido pero como JSON válido."}
            ],
            response_format={"type": "json_object"},
            temperature=0
        )
        return json.loads(response2.choices[0].message.content)

Ejercicio 2: Comparar JSON mode OpenAI vs instrucciones

Ejecuta la misma tarea de extracción 5 veces: (a) sin JSON mode (solo instrucciones), (b) con JSON mode. Mide cuántas veces el output es JSON válido.

Ver solución
from openai import OpenAI
import json

client = OpenAI()

SISTEMA = 'Extrae nombre y email. Devuelve JSON: {"nombre": "...", "email": "..."}'
TEXTO = "Contactar a Juan García en juan@empresa.com"

resultados = {"sin_json_mode": 0, "con_json_mode": 0}

for _ in range(5):
    # Sin JSON mode
    r1 = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "system", "content": SISTEMA}, {"role": "user", "content": TEXTO}],
        temperature=0
    )
    try:
        json.loads(r1.choices[0].message.content)
        resultados["sin_json_mode"] += 1
    except json.JSONDecodeError:
        pass
    
    # Con JSON mode
    r2 = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "system", "content": SISTEMA}, {"role": "user", "content": TEXTO}],
        response_format={"type": "json_object"},
        temperature=0
    )
    try:
        json.loads(r2.choices[0].message.content)
        resultados["con_json_mode"] += 1
    except json.JSONDecodeError:
        pass

print(f"Sin JSON mode: {resultados['sin_json_mode']}/5 válidos")
print(f"Con JSON mode: {resultados['con_json_mode']}/5 válidos")
# Esperado: sin=3-5/5, con=5/5

Ejercicio 3: Pydantic con coerción de tipos

Diseña un schema Pydantic para una factura con: proveedor (str), monto (float), moneda (Literal["USD","EUR","MXN"]), items (list con nombre y cantidad). Prueba que maneje correctamente monto="1299.99" (string → float).

Ver solución
from pydantic import BaseModel, Field
from typing import Literal, Optional

class ItemFactura(BaseModel):
    nombre: str
    cantidad: int
    precio_unitario: Optional[float] = None

class Factura(BaseModel):
    proveedor: str
    monto_total: float  # Pydantic convierte "1299.99" → 1299.99
    moneda: Literal["USD", "EUR", "MXN"]
    items: list[ItemFactura] = []

# Test de coerción
factura = Factura(
    proveedor="TechCorp",
    monto_total="1299.99",  # String → float automáticamente
    moneda="USD",
    items=[{"nombre": "Laptop", "cantidad": "1"}]  # "1" → int
)
print(factura.model_dump())
# {"proveedor": "TechCorp", "monto_total": 1299.99, "moneda": "USD", "items": [...]}

Ejercicio 4 (Avanzado): Schema con validación personalizada

Crea un schema Pydantic para análisis de sentimiento donde: confianza debe estar entre 0 y 1 (si viene >1, dividir entre 100), y sentimiento debe normalizarse a mayúsculas.

Ver solución
from pydantic import BaseModel, Field, field_validator
from typing import Literal

class SentimentAnalysis(BaseModel):
    sentimiento: Literal["POSITIVO", "NEGATIVO", "NEUTRO"]
    confianza: float
    
    @field_validator("sentimiento", mode="before")
    @classmethod
    def normalizar_sentimiento(cls, v: str) -> str:
        """Normalizar a mayúsculas y mapear variantes."""
        v_upper = v.upper().strip()
        mapeo = {
            "POSITIVE": "POSITIVO",
            "NEGATIVE": "NEGATIVO",
            "NEUTRAL": "NEUTRO",
            "POSITIVO": "POSITIVO",
            "NEGATIVO": "NEGATIVO",
            "NEUTRO": "NEUTRO",
        }
        return mapeo.get(v_upper, v_upper)
    
    @field_validator("confianza", mode="before")
    @classmethod
    def normalizar_confianza(cls, v: float) -> float:
        """Si viene como porcentaje (>1), dividir entre 100."""
        if v > 1.0:
            return v / 100
        return v

# Test
s1 = SentimentAnalysis(sentimiento="positive", confianza=95)
print(s1)  # sentimiento=POSITIVO, confianza=0.95

s2 = SentimentAnalysis(sentimiento="NEGATIVO", confianza=0.87)
print(s2)  # sentimiento=NEGATIVO, confianza=0.87

Resumen

  • OpenAI JSON mode: response_format={"type": "json_object"} — garantiza JSON sintácticamente válido
  • Structured Outputs con schema: response_format={"type": "json_schema", ...} — garantiza schema específico
  • Anthropic: Tool use para schema estricto; instrucciones explícitas + parsing robusto para JSON libre
  • Retry pattern: Hasta 3 intentos con backoff exponencial; segundo intento incluye feedback del error
  • Pydantic: Valida schema + coerce tipos + sugerencias de corrección. Siempre validar con Pydantic después del JSON mode.
  • JSON mode no garantiza schema: Solo garantiza que json.loads() no falla. Siempre necesitas validación adicional.

Recursos adicionales

  1. OpenAI Structured Outputs Guide — JSON mode, json_schema y comparativa de ambos
  2. OpenAI JSON Mode vs Structured Outputs — Cuándo usar cada uno
  3. Anthropic Tool Use — Cómo usar tool use para structured output en Claude
  4. Pydantic v2 Validatorsfield_validator, model_validator, coerción de tipos
  5. JSON Schema Specification — Entender el schema usado en response_format
  6. Python json module — Referencia del módulo estándar json