---
name: structured-outputs
description: >
  Patrones para obtener respuestas estructuradas y validadas de LLMs usando
  Pydantic v2. Incluye reask iterativo, validación semántica con LLM,
  caching multinivel y hooks de ciclo de interacción. Cargar cuando se
  construya cualquier sistema que requiera outputs JSON/tipados de un LLM,
  o cuando un agente necesite respuestas validadas programáticamente.
version: "1.0.0"
herramientasPermitidas: [Read]
evolvable: true  # default para skill estandar
exclusiones:
  - "No cargar para validación de inputs de usuario en endpoints REST (formularios, API bodies) — para validación de inputs de API cargar `fastapi-experto` con Pydantic schemas de request."
  - "No cargar para construir pipelines RAG o agentes con LangChain — para RAG cargar `rag-arquitectura`, para agentes cargar `langchain-langraph`."
  - "No cargar para streaming de respuestas LLM en tiempo real donde no se necesita validación del output completo — el reask requiere la respuesta completa para validar."
  - "No cargar para evaluar la calidad semántica de un corpus de documentos existentes sin pipeline LLM activo — para evaluación de calidad de documentos usar el skill de dominio correspondiente."
---
# Structured Outputs — Respuestas validadas de LLMs

## Cuándo NO cargar

- La tarea es validar inputs de usuario en endpoints REST: usar Pydantic schemas en FastAPI directamente.
- La tarea es construir un pipeline RAG: cargar `rag-arquitectura`.
- La tarea es construir agentes con LangGraph: cargar `langchain-langraph`.
- La tarea es streaming de LLM sin necesidad de validar el output completo: el reask requiere respuesta completa.


## Problema que resuelve

Los LLMs generan texto libre. Cuando tu código necesita un objeto tipado
(JSON, modelo Pydantic, enum), parsear la respuesta directamente es frágil:
falla silenciosamente, requiere regex, y no hay garantía de estructura.

Structured outputs resuelve esto con un ciclo:
**generar → validar → reask si falla → retornar objeto tipado**.

---

## Patrón core: response_model + validación Pydantic

```python
from pydantic import BaseModel, Field
from typing import Literal

class ExtractionResult(BaseModel):
    entidades: list[str] = Field(..., min_length=1)
    sentimiento: Literal["positivo", "negativo", "neutro"]
    confianza: float = Field(..., ge=0.0, le=1.0)
    resumen: str = Field(..., max_length=500)

# El modelo debe devolver JSON que valide contra este schema.
# Si falla validación → reask automático (ver sección siguiente).
```

La clave es definir el modelo Pydantic con **constraints reales**:
`min_length`, `ge/le`, `Literal`, `max_length`. Esto convierte errores
de formato en errores de validación detectables y corregibles.

---

## Patrón reask — retry inteligente con feedback

El retry convencional repite la misma llamada. El **reask** envía el error
de validación de vuelta al LLM para que se auto-corrija:

```
Intento 1: LLM genera → Pydantic valida → ValidationError("confianza must be <= 1.0")
Intento 2: LLM recibe error + respuesta previa → genera corregido → Pydantic valida → OK
```

### Implementación del ciclo reask

```python
from pydantic import ValidationError
import json

def obtener_structured(
    llm_client,
    mensajes: list[dict],
    response_model: type[BaseModel],
    max_retries: int = 3,
) -> BaseModel:
    """Ciclo reask: genera, valida, corrige hasta max_retries."""
    intentos_fallidos = []

    for intento in range(1, max_retries + 1):
        # Agregar errores previos como contexto
        mensajes_con_feedback = mensajes.copy()
        if intentos_fallidos:
            feedback = "\n".join(
                f"Intento {i}: {err}" for i, err in enumerate(intentos_fallidos, 1)
            )
            mensajes_con_feedback.append({
                "role": "user",
                "content": (
                    f"Tu respuesta anterior no pasó validación:\n{feedback}\n\n"
                    f"Corrige y responde de nuevo en JSON válido según el schema."
                ),
            })

        respuesta = llm_client.messages.create(
            model="claude-sonnet-4-6-20250514",
            max_tokens=2048,
            system=f"Responde SOLO en JSON válido según este schema:\n"
                   f"{json.dumps(response_model.model_json_schema(), indent=2)}",
            messages=mensajes_con_feedback,
        )

        try:
            texto = respuesta.content[0].text
            resultado = response_model.model_validate_json(texto)
            return resultado
        except (ValidationError, json.JSONDecodeError) as e:
            intentos_fallidos.append(str(e)[:200])

    raise RuntimeError(
        f"Reask agotado después de {max_retries} intentos. "
        f"Errores: {intentos_fallidos}"
    )
```

### Diferencia con retry genérico

| Aspecto | Retry genérico | Reask |
|---------|---------------|-------|
| Feedback al LLM | Ninguno — repite idéntico | Envía error de validación |
| Probabilidad de éxito | Baja (misma entrada = misma salida) | Alta (LLM corrige con contexto) |
| Tokens consumidos | Duplicados sin valor | Incrementales con información nueva |
| Trazabilidad | Solo cuenta de intentos | Errores específicos por intento |

---

## Validación semántica con LLM (llm_validator)

Pydantic valida **estructura**. Un LLM puede validar **semántica**:

```python
from pydantic import field_validator

class ResumenDocumento(BaseModel):
    titulo: str
    resumen: str
    temas: list[str]

    @field_validator("resumen")
    @classmethod
    def validar_resumen_es_coherente(cls, v: str) -> str:
        """Validación estática: longitud mínima y sin placeholders."""
        if len(v) < 50:
            raise ValueError("Resumen demasiado corto (mínimo 50 caracteres)")
        placeholders = ["PENDIENTE", "PLACEHOLDER", "...", "[insertar"]
        for p in placeholders:
            if p.lower() in v.lower():
                raise ValueError(f"Resumen contiene placeholder: '{p}'")
        return v


def validar_semanticamente(
    llm_juez,
    respuesta: BaseModel,
    regla: str,
) -> tuple[bool, str]:
    """Usa un LLM como juez para validar restricciones semánticas."""
    prompt = f"""Evalúa si esta respuesta cumple la regla.

Regla: {regla}
Respuesta: {respuesta.model_dump_json(indent=2)}

Responde SOLO con JSON: {{"cumple": true/false, "razon": "..."}}"""

    resultado = llm_juez.messages.create(
        model="claude-haiku-4-5-20251001",  # Haiku es suficiente para validación
        max_tokens=256,
        messages=[{"role": "user", "content": prompt}],
    )
    import json
    evaluacion = json.loads(resultado.content[0].text)
    return evaluacion["cumple"], evaluacion["razon"]
```

**Cuándo usar**: Cuando las restricciones no son expresables como tipos
(ej: "el resumen no contradice el documento original", "las entidades
mencionadas existen en el contexto dado").

---

## Caching de respuestas LLM — 3 niveles

Cachear respuestas validadas evita llamadas redundantes al LLM.

### Estrategia de clave de caché

```python
import hashlib
import json

def generar_cache_key(
    modelo: str,
    mensajes: list[dict],
    response_model: type[BaseModel],
) -> str:
    """Clave basada en modelo + mensajes + schema del response_model."""
    payload = json.dumps({
        "model": modelo,
        "messages": mensajes,
        "schema_hash": hashlib.sha256(
            json.dumps(response_model.model_json_schema()).encode()
        ).hexdigest()[:16],
    }, sort_keys=True)
    return hashlib.sha256(payload.encode()).hexdigest()
```

### Niveles de caché

| Nivel | Tecnología | TTL | Cuándo usar |
|-------|-----------|-----|-------------|
| L1 | `functools.lru_cache` | Duración del proceso | Misma sesión, mismas queries |
| L2 | Disco (`shelve`/`diskcache`) | Horas/días | Queries repetitivos entre sesiones |
| L3 | Redis (`SETEX`) | Configurable | Múltiples instancias/usuarios |

### Invalidación

La clave incluye el hash del schema Pydantic. Si cambias el modelo
(agregas un campo, cambias un tipo), la clave cambia automáticamente
y el caché se invalida sin intervención manual.

---

## Hooks de ciclo de interacción LLM

Puntos de intercepción en el ciclo genera→valida→reask:

```python
from typing import Callable, Any
from dataclasses import dataclass, field
from enum import Enum

class HookLLM(Enum):
    ANTES_LLAMADA = "antes_llamada"       # Antes de enviar al LLM
    DESPUES_RESPUESTA = "despues_respuesta"  # Respuesta recibida (antes de validar)
    ERROR_VALIDACION = "error_validacion"   # Validación Pydantic falló
    ULTIMO_INTENTO = "ultimo_intento"       # Último reask antes de fallar
    ERROR_API = "error_api"                 # Error de red/API

@dataclass
class HooksLLM:
    _handlers: dict[HookLLM, list[Callable]] = field(default_factory=dict)

    def on(self, evento: HookLLM, handler: Callable) -> None:
        self._handlers.setdefault(evento, []).append(handler)

    def emit(self, evento: HookLLM, **kwargs: Any) -> None:
        for handler in self._handlers.get(evento, []):
            handler(**kwargs)

# Uso: tracking de tokens acumulativo por reintentos
hooks = HooksLLM()
tokens_acumulados = {"input": 0, "output": 0}

def rastrear_tokens(**kwargs):
    usage = kwargs.get("usage", {})
    tokens_acumulados["input"] += usage.get("input_tokens", 0)
    tokens_acumulados["output"] += usage.get("output_tokens", 0)

hooks.on(HookLLM.DESPUES_RESPUESTA, rastrear_tokens)
```

---

## Tipos de response_model por caso de uso

| Caso | Patrón | Ejemplo |
|------|--------|---------|
| Extracción simple | `BaseModel` con campos tipados | `class Usuario(BaseModel): nombre: str; edad: int` |
| Clasificación | `Literal` o `Enum` | `categoria: Literal["bug", "feature", "pregunta"]` |
| Respuesta opcional | `BaseModel` con campo `encontrado: bool` | Cuando el LLM puede no tener la respuesta |
| Lista de items | `list[MiModelo]` como campo | `items: list[Producto]` |
| Uniones discriminadas | `Annotated[Union[...], Field(discriminator="tipo")]` | Diferentes schemas según el tipo |

---

## Interfaz unificada multi-proveedor (referencia)

El patrón `from_provider` abstrae el proveedor de LLM:

```python
# Patrón conceptual — no es una dependencia de swl-ses
# Referencia: instructor.from_provider()

# OpenAI
client = crear_cliente_structured("openai", model="gpt-4o")

# Anthropic
client = crear_cliente_structured("anthropic", model="claude-sonnet-4-6-20250514")

# La interfaz es idéntica: client.create(response_model=MiModelo, messages=[...])
```

**Nota**: swl-ses es mono-proveedor (Anthropic). Este patrón se documenta
como referencia para proyectos que necesiten soportar múltiples LLMs.
El `hooks/lib/model-router.js` existente ya hace routing por complejidad
dentro de Anthropic — podría evolucionar a routing por proveedor si se requiere.

---

## MUST DO / MUST NOT DO

### Obligatorio
- Definir constraints reales en Pydantic (`Field(ge=0)`, `Literal[...]`, `max_length`)
- Usar reask (no retry ciego) cuando la respuesta del LLM falla validación
- Incluir el JSON Schema del modelo en el system prompt
- Trackear tokens acumulados incluyendo reintentos, no solo la llamada exitosa
- Cachear respuestas validadas con clave que incluya hash del schema

### Prohibido
- NUNCA parsear JSON con regex — usar `model_validate_json()`
- NUNCA reintentar sin enviar el error de vuelta al LLM (retry ciego)
- NUNCA confiar en la respuesta sin validación Pydantic
- NUNCA usar `temperature > 0.3` para structured outputs (reduce determinismo)
- NUNCA cachear respuestas que fallaron validación
- NUNCA usar `Any` como tipo en response_model — pierde toda garantía

---

## Tabla de referencias

| Tema | Archivo |
|------|---------|
| Patrones avanzados: prompt chaining, LLM-as-judge, caching | [prompt-engineering/recursos/patrones-avanzados.md](../prompt-engineering/recursos/patrones-avanzados.md) |
| RAG con structured outputs | [langchain-langraph/SKILL.md](../langchain-langraph/SKILL.md) |
| Validación de schemas JSON | [schemas/](../../schemas/) |

---

## Gotchas / Errores comunes no obvios

**`model_validate_json()` lanza `ValidationError` con el mensaje "1 validation error for X: JSON input should be string, bytes or bytearray" cuando el output del LLM envuelve el JSON en bloques de código Markdown**: Claude y GPT frecuentemente responden con ` ```json\n{...}\n``` ` aunque el system prompt diga "responde SOLO en JSON". Causa: los modelos fueron entrenados en conversaciones donde el JSON se muestra en bloques de código por legibilidad; el system prompt de "solo JSON" reduce pero no elimina este comportamiento, especialmente en Haiku y GPT-3.5. Fix: antes de llamar a `model_validate_json()`, limpiar el texto con `texto = re.sub(r'^```(?:json)?\s*|\s*```$', '', texto.strip())`. Alternativamente, usar `tool_use` de Anthropic o `response_format` de OpenAI para forzar JSON estructurado a nivel de API.

**El ciclo reask con `max_retries=3` puede consumir 6-9x más tokens que una llamada exitosa sin que el código lo detecte porque los tokens de los intentos fallidos se acumulan silenciosamente**: un sistema que procesa 1,000 documentos por hora y tiene 15% de tasa de fallo en el primer intento gasta 1.15x los tokens esperados. Si el 5% necesita el tercer reintento, el gasto real es 1.35x. Sin tracking explícito, el costo de producción supera el estimado del prototipo en 20-40%. Causa: el código de ejemplo del skill acumula `intentos_fallidos` pero no registra el total de tokens usados incluyendo los intentos fallidos. Fix: registrar `usage.input_tokens + usage.output_tokens` por cada intento (exitoso o fallido) y emitir el total acumulado como métrica. Establecer un alerta si el ratio `tokens_reales / tokens_exitosos` supera 1.5x.

**La validación semántica con LLM juez (`validar_semanticamente`) genera un loop infinito cuando el campo `cumple: false` + `reask` del ciclo exterior incluye el error del juez en el mensaje de corrección pero el LLM principal no puede satisfacer la regla semántica**: si la regla es "el resumen no debe contradecir el documento original" y el documento original no está en el contexto de corrección, el LLM nunca puede saber qué contradice. Agota los 3 reintentos, eleva `RuntimeError`, y el pipeline se detiene. Causa: la validación semántica con LLM juez verifica constraints que el LLM corrector no puede satisfacer sin el contexto completo de la regla. Fix: al incluir el error del juez en el mensaje de reask, incluir también el contexto relevante: "Tu resumen contradice el siguiente fragmento del documento original: [cita]". Si el contexto es demasiado grande para el mensaje de reask, reducir el scope de la validación semántica.

**`functools.lru_cache` como L1 de caché de embeddings en un servidor FastAPI con múltiples workers no comparte el caché entre procesos, causando que cada worker tenga su propio caché independiente y se llame a la API de embeddings N veces para la misma query en N workers**: con 4 workers uvicorn, la misma query frecuente ("¿cuáles son los horarios?") llama a la API de embeddings 4 veces en lugar de 1. El L1 aparece con 100% de hit rate en cada worker individualmente, pero el hit rate real del sistema es mucho menor. Causa: `lru_cache` es por-proceso; en producción con multiprocessing, el caché no es compartido. Fix: para el L1 inter-proceso, usar una variable de módulo compartida con `multiprocessing.Manager().dict()` o, más simple, subir directamente al L2 (diskcache/Redis) que sí es compartido entre workers.
