Módulo 2: Zero-Shot y Few-Shot Prompting
5. Output Formatting y Parsing
Descripción de la cápsula
En producción, el output del LLM debe ser parseable por código. Un output "casi JSON" o con texto extra alrededor rompe tu pipeline. En esta cápsula aprenderás los cuatro formatos principales (JSON, XML, Markdown, CSV), técnicas para maximizar consistencia de formato, extracción robusta con regex cuando el output se desvía, y manejo de outputs malformados con retry logic.
El foco es producción: necesitas que tu código funcione el 99.9% de las veces, no el 80%. Para eso, el sistema de parsing debe manejar todas las variaciones que los LLMs pueden generar, desde JSON puro hasta JSON envuelto en markdown con texto introductorio.
Por qué importa: Si tu clasificador devuelve "TÉCNICO" el 95% del tiempo pero "El ticket es de tipo TÉCNICO." el 5% restante, tienes un bug en producción que se manifesta de forma intermitente — el peor tipo de bug. Esta cápsula te da las herramientas para eliminarlo.
Los 4 Formatos Principales
JSON — El estándar para APIs
JSON es el formato más usado cuando el output va a ser procesado por código. Fácil de parsear, extensible, y familiar para todos los LLMs.
from openai import OpenAI
import json
client = OpenAI()
# Extracción básica con JSON
def extraer_entidades(texto: str) -> dict:
"""
Extrae personas, organizaciones y lugares del texto.
Usa JSON mode para garantizar JSON válido.
"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": """
Extrae entidades del texto.
Formato de respuesta (JSON exacto):
{"personas": ["nombre1", ...], "organizaciones": ["org1", ...], "lugares": ["lugar1", ...]}
Si no hay entidades de algún tipo, usa lista vacía. Solo JSON, nada más.
"""
},
{"role": "user", "content": texto}
],
temperature=0,
response_format={"type": "json_object"} # Garantiza JSON válido (OpenAI)
)
return json.loads(response.choices[0].message.content)
# Test
textos = [
"María García de Google visitó la oficina de Microsoft en Madrid.",
"El CEO de Apple se reunió con el presidente de Francia en París.",
"No hay personas ni lugares mencionados en este texto."
]
for t in textos:
resultado = extraer_entidades(t)
print(f"Input: {t[:60]}")
print(f"Output: {resultado}\n")
Output:
Input: María García de Google visitó la oficina de Microsoft en M
Output: {'personas': ['María García'], 'organizaciones': ['Google', 'Microsoft'], 'lugares': ['Madrid']}
Input: El CEO de Apple se reunió con el presidente de Francia en P
Output: {'personas': ['CEO de Apple', 'presidente de Francia'], 'organizaciones': ['Apple'], 'lugares': ['París']}
Input: No hay personas ni lugares mencionados en este texto.
Output: {'personas': [], 'organizaciones': [], 'lugares': []}
XML — Para estructura anidada y Claude
XML es especialmente efectivo con Claude, que está optimizado para seguir instrucciones con tags. También útil para documentos con estructura jerárquica.
from anthropic import Anthropic
import xml.etree.ElementTree as ET
ant_client = Anthropic()
def extraer_analisis_xml(texto: str) -> dict:
"""Extrae análisis estructurado en formato XML (óptimo para Claude)."""
response = ant_client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=500,
system="""
Analiza el texto y responde ÚNICAMENTE con XML en este formato exacto:
<analisis>
<sentimiento>POSITIVO|NEGATIVO|NEUTRO</sentimiento>
<temas>
<tema>tema1</tema>
<tema>tema2</tema>
</temas>
<resumen>resumen en una oración</resumen>
</analisis>
Sin texto antes ni después del XML.
""",
messages=[{"role": "user", "content": texto}]
)
raw_xml = response.content[0].text.strip()
# Parsear XML
root = ET.fromstring(raw_xml)
return {
"sentimiento": root.find("sentimiento").text,
"temas": [t.text for t in root.findall("temas/tema")],
"resumen": root.find("resumen").text
}
# Test
texto = "El nuevo iPhone tiene una cámara increíble pero la batería dura poco. Los fans están divididos."
try:
resultado = extraer_analisis_xml(texto)
print(f"Análisis: {resultado}")
except ET.ParseError as e:
print(f"Error parsing XML: {e}")
Markdown — Para output legible por humanos
def generar_reporte_markdown(datos: dict) -> str:
"""Genera reporte en Markdown para visualización."""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": """
Genera un reporte ejecutivo en Markdown con exactamente esta estructura:
## Resumen Ejecutivo
[2-3 oraciones]
## Métricas Clave
| Métrica | Valor | Tendencia |
|---------|-------|-----------|
[filas de datos]
## Recomendaciones
1. [Primera recomendación]
2. [Segunda recomendación]
3. [Tercera recomendación]
Solo Markdown. Sin texto adicional.
"""
},
{"role": "user", "content": str(datos)}
],
temperature=0.2
)
return response.choices[0].message.content
# Test
datos_ventas = {
"Q1": 1200000, "Q2": 1500000, "Q3": 1100000, "Q4": 1800000,
"top_producto": "Plan Enterprise", "retention_rate": "87%"
}
print(generar_reporte_markdown(datos_ventas))
CSV — Para datos tabulares
import csv
import io
def extraer_datos_csv(texto: str) -> list[dict]:
"""Extrae datos estructurados en formato CSV."""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": """
Extrae los productos mencionados en el texto.
Formato CSV exacto (con header):
nombre,precio,disponible
Reglas:
- Una fila por producto
- precio: solo número sin símbolo de moneda (ej: 29.99)
- disponible: true o false
- Si no hay precio: dejar vacío (dos comas seguidas)
Solo CSV. Sin texto adicional.
"""
},
{"role": "user", "content": texto}
],
temperature=0
)
raw_csv = response.choices[0].message.content.strip()
reader = csv.DictReader(io.StringIO(raw_csv))
return list(reader)
texto_productos = "Tenemos el laptop Pro por $1299 (disponible) y el mouse básico por $25 (agotado)."
productos = extraer_datos_csv(texto_productos)
print(f"Productos extraídos: {productos}")
Técnicas para Garantizar Consistencia de Formato
Técnica 1: Instrucción explícita prominente
La instrucción de formato debe ser imposible de ignorar:
# ❌ Instrucción enterrada
prompt = """
Eres un extractor de datos. Analiza el texto y encuentra los datos relevantes.
Asegúrate de ser preciso. El formato de respuesta debe ser JSON con las keys: nombre, email.
El texto puede contener varios tipos de información. Solo extrae nombre y email.
Texto: [...]
"""
# ✅ Instrucción prominente y al final
prompt = """
Extrae nombre y email del texto.
FORMATO OBLIGATORIO (solo esto, nada más):
{"nombre": "string o null", "email": "string o null"}
Texto: [...]
"""
Técnica 2: Ejemplo concreto de output
def extraer_contacto(texto: str) -> dict:
"""Extrae contacto con ejemplo de output explícito."""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": """
Extrae nombre y email del texto.
Ejemplo de output correcto: {"nombre": "Juan García", "email": "juan@mail.com"}
Ejemplo si falta email: {"nombre": "Ana Pérez", "email": null}
Ejemplo si no hay nada: {"nombre": null, "email": null}
Responde SOLO con el JSON. Sin texto antes ni después.
"""
},
{"role": "user", "content": texto}
],
temperature=0,
response_format={"type": "json_object"}
)
return json.loads(response.choices[0].message.content)
# Test con casos edge
casos = [
"Contactar a Miguel en miguel@empresa.com",
"Solo hay un nombre: Roberto",
"Este mensaje no tiene datos de contacto"
]
for c in casos:
print(f"'{c}' → {extraer_contacto(c)}")
Técnica 3: JSON Mode (OpenAI) y JSON Schema
OpenAI ofrece dos niveles de garantía para JSON:
# Nivel 1: JSON Mode — garantiza JSON válido, sin schema fijo
response_json_mode = client.chat.completions.create(
model="gpt-4o-mini",
messages=[...],
response_format={"type": "json_object"}, # JSON válido garantizado
temperature=0
)
# Nivel 2: Structured Outputs — JSON que cumple un schema JSON Schema específico
from pydantic import BaseModel
class ContactoOutput(BaseModel):
nombre: str | None
email: str | None
# Con parse() de OpenAI (requiere pydantic)
response_structured = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[...],
response_format=ContactoOutput # Schema garantizado, validado por Pydantic
)
contacto = response_structured.choices[0].message.parsed
print(f"nombre={contacto.nombre}, email={contacto.email}")
Técnica 4: Etiquetas de delimitación
Usa etiquetas para marcar exactamente dónde empieza y termina el output:
SYSTEM = """
Clasifica el sentimiento del texto.
Responde con la categoría entre etiquetas:
<sentimiento>POSITIVO|NEGATIVO|NEUTRO</sentimiento>
Solo eso. Sin texto antes ni después de las etiquetas.
"""
def extraer_de_etiquetas(raw: str, tag: str) -> str | None:
"""Extrae contenido entre etiquetas XML."""
import re
match = re.search(f'<{tag}>(.*?)</{tag}>', raw, re.DOTALL)
return match.group(1).strip() if match else None
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": "El producto es increíble, lo recomiendo."}
],
temperature=0
)
sentimiento = extraer_de_etiquetas(response.choices[0].message.content, "sentimiento")
print(f"Sentimiento: {sentimiento}") # POSITIVO
Parsing Robusto: Manejo de Outputs Malformados
Los LLMs pueden devolver JSON envuelto en markdown, con texto antes/después, o con pequeños errores de syntax. Tu parser debe manejar todo esto:
import re
import json
def parse_json_robusto(raw: str) -> dict | None:
"""
Parser de JSON multi-estrategia.
Maneja: JSON puro, ```json...```, texto+JSON, trailing commas.
Returns None si no puede parsear.
"""
raw = raw.strip()
# Estrategia 1: JSON puro
try:
return json.loads(raw)
except json.JSONDecodeError:
pass
# Estrategia 2: Extraer de bloque markdown ```json...``` o ```...```
match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', raw, re.DOTALL)
if match:
try:
return json.loads(match.group(1).strip())
except json.JSONDecodeError:
pass
# Estrategia 3: Encontrar primer { ... } balanceado
start = raw.find('{')
if start == -1:
return None
depth = 0
for i, char in enumerate(raw[start:], start):
if char == '{':
depth += 1
elif char == '}':
depth -= 1
if depth == 0:
candidate = raw[start:i+1]
# Limpiar trailing commas (error JSON común)
candidate = re.sub(r',\s*([}\]])', r'\1', candidate)
try:
return json.loads(candidate)
except json.JSONDecodeError:
break
return None
# Test con outputs problemáticos reales
outputs_problematicos = [
'{"nombre": "Juan", "email": "juan@mail.com"}', # Perfecto
'```json\n{"nombre": "Ana"}\n```', # Markdown
'Aquí está el resultado:\n{"nombre": "Pedro"}', # Con texto
'{"nombre": "Carlos",}', # Trailing comma
'```\n{"nombre": "Luis"}\n```', # Sin "json" label
]
for output in outputs_problematicos:
resultado = parse_json_robusto(output)
status = "✅" if resultado else "❌"
print(f"{status} '{output[:50]}' → {resultado}")
Output:
✅ '{"nombre": "Juan", "email": "juan@mail.com"}' → {'nombre': 'Juan', 'email': 'juan@mail.com'}
✅ '```json\n{"nombre": "Ana"}\n```' → {'nombre': 'Ana'}
✅ 'Aquí está el resultado:\n{"nombre": "Pedro"}' → {'nombre': 'Pedro'}
✅ '{"nombre": "Carlos",}' → {'nombre': 'Carlos'}
✅ '```\n{"nombre": "Luis"}\n```' → {'nombre': 'Luis'}
Retry Logic con Feedback
Cuando el primer intento falla, el retry incluye feedback específico de qué falló:
def parse_con_retry(
raw: str,
esquema_esperado: str,
max_retries: int = 2
) -> dict:
"""
Intenta parsear output, con retry con feedback si falla.
Args:
raw: Output del modelo
esquema_esperado: Descripción del formato esperado para el feedback
max_retries: Número de intentos adicionales
"""
# Primer intento: parsing directo
resultado = parse_json_robusto(raw)
if resultado is not None:
return resultado
# Retry con feedback
for intento in range(max_retries):
print(f" Retry {intento + 1}: output no fue JSON válido")
fix_prompt = f"""
El output anterior no fue JSON válido.
Output que recibí:
{raw[:500]}
Necesito exactamente este formato:
{esquema_esperado}
Responde ÚNICAMENTE con el JSON válido. Sin texto adicional.
"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": fix_prompt}],
temperature=0,
response_format={"type": "json_object"} # Fuerza JSON en retry
)
raw = response.choices[0].message.content
resultado = parse_json_robusto(raw)
if resultado is not None:
print(f" ✅ Retry {intento + 1} exitoso")
return resultado
raise ValueError(f"No se pudo parsear después de {max_retries} reintentos. Último output: {raw[:200]}")
# Uso
ESQUEMA = '{"sentimiento": "POSITIVO|NEGATIVO|NEUTRO", "confianza": 0.0-1.0}'
# Simular un output malformado inicial
raw_inicial = "El sentimiento es positivo con alta confianza." # No es JSON
try:
resultado = parse_con_retry(raw_inicial, ESQUEMA)
print(f"Resultado final: {resultado}")
except ValueError as e:
print(f"Error: {e}")
Validación con Pydantic
Después de parsear JSON, valida que cumple el schema esperado:
from pydantic import BaseModel, Field, ValidationError
from typing import Literal
class AnalisisSentimiento(BaseModel):
sentimiento: Literal["POSITIVO", "NEGATIVO", "NEUTRO"]
confianza: float = Field(ge=0.0, le=1.0)
aspecto_principal: str | None = None
def clasificar_con_validacion(texto: str) -> AnalisisSentimiento:
"""Clasifica y valida con Pydantic."""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": """
Analiza el sentimiento.
JSON: {"sentimiento": "POSITIVO|NEGATIVO|NEUTRO", "confianza": 0.0-1.0, "aspecto_principal": "string o null"}
Solo JSON.
"""
},
{"role": "user", "content": texto}
],
temperature=0,
response_format={"type": "json_object"}
)
data = json.loads(response.choices[0].message.content)
try:
return AnalisisSentimiento(**data)
except ValidationError as e:
# Sanitizar valores fuera de rango antes de fallar
if "confianza" in data:
data["confianza"] = max(0.0, min(1.0, float(data.get("confianza", 0.5))))
return AnalisisSentimiento(**data)
# Test
textos = [
"El producto es excelente, muy recomendado",
"Pésima experiencia, nunca más",
"Normal, ni bueno ni malo"
]
for t in textos:
resultado = clasificar_con_validacion(t)
print(f"'{t[:40]}' → {resultado.model_dump()}")
Comparación de Formatos
| Formato | Parseable por código | Legible por humanos | Tamaño relativo | Cuándo usar |
|---|---|---|---|---|
| JSON | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | Medio | APIs, datos estructurados, pipeline |
| JSON Schema | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | Medio | Producción, validación estricta |
| XML | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | Verboso | Claude, documentos jerárquicos |
| Markdown | ⭐⭐ | ⭐⭐⭐⭐⭐ | Variable | Reportes humanos, visualización |
| CSV | ⭐⭐⭐⭐ | ⭐⭐ | Muy compacto | Tablas simples, exportación |
| String exacto | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | Mínimo | Clasificación, sí/no, una palabra |
Conexión con el Proyecto
En el Few-Shot Classification System (cápsula 08) el output de clasificación siempre pasa por:
parse_json_robusto()para extraer el JSON del output del LLMAnalisisSentimiento(o schema equivalente) para validar con Pydanticparse_con_retry()si el primer intento falla- Sanitización de valores fuera de rango antes de fallar con error
Este pipeline garantiza que el sistema no se rompe con outputs inesperados.
Troubleshooting
Problema 1: JSON con trailing comma
Causa: El modelo genera {"a": 1, "b": 2,} — no es JSON estándar.
Solución:
# Limpiar antes de json.loads
raw_clean = re.sub(r',\s*([}\]])', r'\1', raw)
json.loads(raw_clean)
Problema 2: Output truncado
Causa: max_tokens insuficiente para el JSON completo.
Solución:
# Estimar tokens necesarios: 1 token ≈ 4 chars de JSON
# Si el output esperado es ~500 chars, pon max_tokens=150+
max_tokens = len(json.dumps(schema_esperado)) // 3 # Con buffer
Problema 3: Strings con comillas sin escapar
Causa: "texto con "comillas" internas" — JSON inválido.
Solución: Usa JSON mode en OpenAI (garantiza JSON válido) o Structured Outputs para evitar este problema. Si ya tienes el output, el retry con feedback suele corregirlo.
Problema 4: El modelo añade "Claro, aquí está el JSON:" antes
Causa: Instrucción de formato no suficientemente estricta.
Solución:
# Añadir al system prompt:
"Tu respuesta debe empezar DIRECTAMENTE con { y terminar con }."
"Sin saludos, sin explicaciones, sin texto antes o después del JSON."
Problema 5: XML mal formado (tags no cerradas)
Causa: El modelo genera <sentimiento>POSITIVO sin tag de cierre.
Solución: Usa BeautifulSoup con parser leniente para XML irregular:
from bs4 import BeautifulSoup
soup = BeautifulSoup(raw_xml, "xml") # Parser más tolerante que ElementTree
sentimiento = soup.find("sentimiento").text
Ejercicios
Ejercicio 1: Parser multi-formato (Fácil)
Implementa una función que detecte automáticamente si el output es JSON, XML, o string puro, y lo parsee apropiadamente.
Ver solución
def auto_parse(raw: str) -> dict | str:
"""
Detecta formato y parsea automáticamente.
Returns: dict (para JSON/XML) o str (para texto plano)
"""
raw = raw.strip()
# Intento JSON
resultado_json = parse_json_robusto(raw)
if resultado_json:
return resultado_json
# Intento XML
if raw.startswith("<") or "</" in raw:
try:
root = ET.fromstring(raw)
# Convertir XML simple a dict
return {child.tag: child.text for child in root}
except ET.ParseError:
pass
# Texto plano
return raw
# Test
outputs = [
'{"key": "value"}',
'<resultado><valor>42</valor></resultado>',
'POSITIVO'
]
for o in outputs:
print(f"'{o}' → {auto_parse(o)} (tipo: {type(auto_parse(o)).__name__})")
Ejercicio 2: Validar schema complejo (Medio)
Crea un schema Pydantic para el output del Prompt Analyzer (con clasificacion, componentes, sugerencias, calidad) y una función que parsee y valide.
Ver solución
from pydantic import BaseModel, Field
from typing import Literal
class ClasificacionOutput(BaseModel):
tecnica: Literal["zero-shot", "few-shot", "chain-of-thought", "mixto"]
confianza: float = Field(ge=0.0, le=1.0)
class ComponentesOutput(BaseModel):
instruccion: Literal["presente", "ausente", "implicito"]
contexto: Literal["presente", "ausente", "implicito"]
output_format: Literal["presente", "ausente", "implicito"]
class PromptAnalysisOutput(BaseModel):
clasificacion: ClasificacionOutput
componentes: ComponentesOutput
sugerencias: list[str] = Field(min_length=1, max_length=6)
puntuacion: int = Field(ge=0, le=100)
def parse_prompt_analysis(raw: str) -> PromptAnalysisOutput | None:
data = parse_json_robusto(raw)
if not data:
return None
try:
return PromptAnalysisOutput(**data)
except ValidationError as e:
print(f"Validación falló: {e}")
return None
Ejercicio 3: Retry con feedback específico (Difícil)
Implementa una versión mejorada de parse_con_retry que en el mensaje de feedback especifique exactamente qué parte del schema falló (campo faltante, tipo incorrecto, valor fuera de rango).
Ver solución
def parse_con_retry_detallado(
raw: str,
schema_cls: type[BaseModel],
max_retries: int = 2
) -> BaseModel:
"""Retry con feedback detallado del error de validación."""
for intento in range(max_retries + 1):
data = parse_json_robusto(raw)
if data:
try:
return schema_cls(**data)
except ValidationError as e:
# Extraer errores específicos de Pydantic
errores = []
for error in e.errors():
campo = ".".join(str(l) for l in error["loc"])
tipo_error = error["type"]
mensaje = error["msg"]
errores.append(f" - Campo '{campo}': {tipo_error} — {mensaje}")
error_detail = "\n".join(errores)
if intento < max_retries:
fix_prompt = f"""
El JSON anterior tiene errores de validación:
{error_detail}
JSON inválido recibido:
{raw[:300]}
Schema esperado: {schema_cls.model_json_schema()}
Corrige y devuelve SOLO el JSON válido.
"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": fix_prompt}],
temperature=0,
response_format={"type": "json_object"}
)
raw = response.choices[0].message.content
else:
if intento < max_retries:
fix_prompt = f"El output no es JSON válido: '{raw[:100]}'. Devuelve solo JSON."
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": fix_prompt}],
temperature=0,
response_format={"type": "json_object"}
)
raw = response.choices[0].message.content
raise ValueError(f"Parsing fallido después de {max_retries} reintentos")
Resumen
En esta cápsula aprendiste:
- Formatos: JSON para APIs/código, XML para Claude/jerarquía, Markdown para humanos, CSV para tablas, string exacto para clasificación
- Técnicas de consistencia: Instrucción prominente al final, ejemplo de output, JSON mode (OpenAI), Structured Outputs, etiquetas XML de delimitación
- Parser robusto: Multi-estrategia: JSON puro → markdown → primer
{...}→ limpiar trailing commas - Retry logic: Primer intento directo, retry con feedback específico si falla, JSON mode forzado en retry
- Validación Pydantic: Parsear + validar schema + sanitizar valores fuera de rango + feedback de error específico
Próxima cápsula: Boundary testing — qué pasa cuando recibes inputs vacíos, adversariales, o extremadamente largos, y cómo hacer tus prompts defensivos.
Recursos adicionales
- OpenAI JSON Mode — Documentación de
response_formaty Structured Outputs con JSON Schema - OpenAI Structured Outputs (Pydantic) — Cómo usar
client.beta.chat.completions.parse()con modelos Pydantic - Pydantic v2 Validators — Validadores avanzados para schemas complejos
- Anthropic Structured Outputs — JSON Schema nativo de Anthropic (beta)
- Python json module — Documentación completa con manejo de errores
- Python re module — Referencia de expresiones regulares para el parser multi-estrategia