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
| Aspecto | Sin JSON mode | Con JSON mode | Structured Outputs (schema estricto) |
|---|---|---|---|
| Garantía de JSON válido | ❌ | ✅ | ✅ |
| Garantía de schema correcto | ❌ | ❌ | ✅ |
| Keys extras posibles | Sí | Sí | ❌ (con additionalProperties: false) |
| Complejidad de setup | Baja | Baja | Media |
| Compatibilidad Anthropic | ✅ (instrucciones) | ❌ (solo OpenAI) | ✅ (tool use) |
| Costo adicional | Ninguno | Ninguno | Ninguno |
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
- OpenAI Structured Outputs Guide — JSON mode, json_schema y comparativa de ambos
- OpenAI JSON Mode vs Structured Outputs — Cuándo usar cada uno
- Anthropic Tool Use — Cómo usar tool use para structured output en Claude
- Pydantic v2 Validators —
field_validator,model_validator, coerción de tipos - JSON Schema Specification — Entender el schema usado en
response_format - Python json module — Referencia del módulo estándar
json