Image for post Salidas Estructuradas de LLMs con Instructor: Extrayendo Datos Validados en Python

Salidas Estructuradas de LLMs con Instructor: Extrayendo Datos Validados en Python


El Problema: LLMs y el Caos del Texto Libre

Los modelos de lenguaje grandes (LLMs) como GPT-4, Claude o Gemini son extraordinariamente capaces generando texto. Sin embargo, cuando necesitas integrar sus respuestas en tu aplicación, el texto libre se convierte en un problema. ¿Cómo parseas "El usuario se llama María García y tiene 28 años" de forma confiable? ¿Qué pasa si el modelo decide responder "María tiene veintiocho años" o incluye información adicional no solicitada?

Este problema se multiplica en aplicaciones reales: extracción de entidades de documentos, clasificación de tickets de soporte, análisis de sentimiento estructurado, o cualquier pipeline donde necesites datos tipados y validados para procesar downstream.

La solución tradicional involucra prompts complicados pidiendo JSON, parseo manual con json.loads(), y código defensivo para manejar cuando el modelo inevitablemente genera JSON malformado. Este enfoque es frágil y tedioso.

Instructor: La Solución Elegante

Instructor es una biblioteca Python de código abierto que resuelve este problema de raíz. Utiliza Pydantic para definir el esquema de salida que esperas, y se encarga automáticamente de:

  • Convertir tu modelo Pydantic a instrucciones para el LLM
  • Validar que la respuesta cumpla con el esquema
  • Reintentar automáticamente si la validación falla
  • Soportar múltiples proveedores de LLM con una API unificada

La biblioteca funciona "parcheando" el cliente de OpenAI (u otros proveedores) para aceptar un parámetro response_model que especifica exactamente qué estructura esperas recibir.

Conceptos Clave

Modelos Pydantic como Contratos

En Instructor, defines la estructura de datos que necesitas usando clases Pydantic. Estas clases actúan como un contrato: especifican los campos, sus tipos, y opcionalmente validaciones adicionales.

from pydantic import BaseModel, Field

class Usuario(BaseModel):
    nombre: str = Field(description="Nombre completo del usuario")
    edad: int = Field(ge=0, le=150, description="Edad en años")
    email: str | None = Field(default=None, description="Correo electrónico si está disponible")

El atributo description en cada Field es importante: Instructor lo utiliza para dar contexto al LLM sobre qué información debe extraer para cada campo.

El Parámetro response_model

El núcleo de Instructor es el parámetro response_model. Cuando lo pasas a una llamada de completions, Instructor intercepta la respuesta del LLM, la valida contra tu modelo Pydantic, y te devuelve una instancia tipada en lugar de texto crudo.

Reintentos Automáticos

Si el LLM genera una respuesta que no cumple con la validación (por ejemplo, un campo requerido faltante o un valor fuera de rango), Instructor puede reintentar automáticamente, incluyendo el error de validación en el siguiente prompt para guiar al modelo hacia una respuesta correcta.

Implementación Paso a Paso

Instalación

Instructor requiere Python 3.9 o superior. Instálalo junto con el cliente del proveedor LLM que utilizarás:

# Instalación básica con soporte para OpenAI
# pip install instructor openai

# Para usar con Anthropic Claude
# pip install instructor anthropic

# Para usar con Google Gemini
# pip install instructor google-generativeai

Configuración Inicial

Configura tu cliente con Instructor. La biblioteca usa un patrón de "patching" que envuelve el cliente original:

import os
import instructor
from openai import OpenAI
from pydantic import BaseModel, Field

# Configura tu API key como variable de entorno
# export OPENAI_API_KEY="tu-clave-aqui"

# Crea el cliente de OpenAI y aplica el patch de Instructor
client = instructor.from_openai(OpenAI())

# Define tu modelo de datos
class Persona(BaseModel):
    nombre: str = Field(description="Nombre completo de la persona")
    ocupacion: str = Field(description="Profesión o trabajo actual")
    habilidades: list[str] = Field(description="Lista de habilidades técnicas")

Extracción Básica de Datos

Veamos cómo extraer información estructurada de texto no estructurado:

import os
import instructor
from openai import OpenAI
from pydantic import BaseModel, Field

client = instructor.from_openai(OpenAI())

class Persona(BaseModel):
    nombre: str = Field(description="Nombre completo")
    ocupacion: str = Field(description="Profesión actual")
    experiencia_anos: int = Field(ge=0, description="Años de experiencia laboral")
    tecnologias: list[str] = Field(description="Tecnologías que domina")

texto = """
Ana Rodríguez es desarrolladora backend con 5 años de experiencia.
Trabaja principalmente con Python, FastAPI y PostgreSQL. También
tiene conocimientos de Docker y Kubernetes para despliegues.
"""

persona = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[
        {"role": "user", "content": f"Extrae la información de esta persona:\n\n{texto}"}
    ],
    response_model=Persona,
)

print(f"Nombre: {persona.nombre}")
print(f"Ocupación: {persona.ocupacion}")
print(f"Experiencia: {persona.experiencia_anos} años")
print(f"Tecnologías: {', '.join(persona.tecnologias)}")

La salida será un objeto Persona completamente tipado que puedes usar directamente en tu código, sin parseo manual ni validaciones adicionales.

Validaciones Avanzadas con Pydantic

Puedes aprovechar todo el poder de Pydantic para validaciones complejas:

from pydantic import BaseModel, Field, field_validator
from enum import Enum

class Prioridad(str, Enum):
    BAJA = "baja"
    MEDIA = "media"
    ALTA = "alta"
    CRITICA = "critica"

class TicketSoporte(BaseModel):
    titulo: str = Field(min_length=5, max_length=100)
    descripcion: str = Field(min_length=20)
    categoria: str = Field(description="Categoría del problema: bug, feature, consulta")
    prioridad: Prioridad
    pasos_reproducir: list[str] | None = Field(
        default=None,
        description="Pasos para reproducir el problema si aplica"
    )
    
    @field_validator("categoria")
    @classmethod
    def validar_categoria(cls, v: str) -> str:
        categorias_validas = {"bug", "feature", "consulta"}
        v_lower = v.lower()
        if v_lower not in categorias_validas:
            raise ValueError(f"Categoría debe ser una de: {categorias_validas}")
        return v_lower

Usando Instructor con Anthropic Claude

Instructor soporta múltiples proveedores. Aquí está cómo usarlo con Claude:

import os
import instructor
from anthropic import Anthropic
from pydantic import BaseModel, Field

# export ANTHROPIC_API_KEY="tu-clave-aqui"
client = instructor.from_anthropic(Anthropic())

class ResumenArticulo(BaseModel):
    titulo_sugerido: str = Field(description="Título conciso para el artículo")
    puntos_clave: list[str] = Field(description="3-5 puntos principales")
    sentimiento: str = Field(description="positivo, negativo, o neutro")
    palabras_clave: list[str] = Field(description="5 palabras clave relevantes")

articulo = """
El mercado de vehículos eléctricos continúa su expansión global.
Las ventas aumentaron un 35% respecto al año anterior, impulsadas
por mejoras en la infraestructura de carga y reducción de precios.
Sin embargo, persisten desafíos en la cadena de suministro de baterías.
"""

resumen = client.messages.create(
    model="claude-sonnet-4-20250514",
    max_tokens=1024,
    messages=[
        {"role": "user", "content": f"Analiza este artículo:\n\n{articulo}"}
    ],
    response_model=ResumenArticulo,
)

print(resumen.model_dump_json(indent=2))

Mini Proyecto: Clasificador de Emails Automatizado

Construyamos un clasificador que analiza emails y extrae información estructurada para automatizar su procesamiento:

import os
import instructor
from openai import OpenAI
from pydantic import BaseModel, Field
from enum import Enum

client = instructor.from_openai(OpenAI())

class CategoriaEmail(str, Enum):
    CONSULTA_PRODUCTO = "consulta_producto"
    SOPORTE_TECNICO = "soporte_tecnico"
    FACTURACION = "facturacion"
    QUEJA = "queja"
    SPAM = "spam"
    OTRO = "otro"

class UrgenciaEmail(str, Enum):
    BAJA = "baja"
    NORMAL = "normal"
    ALTA = "alta"
    URGENTE = "urgente"

class AccionRequerida(BaseModel):
    tipo: str = Field(description="Tipo de acción: responder, escalar, archivar, etc.")
    departamento: str | None = Field(
        default=None,
        description="Departamento responsable si requiere escalamiento"
    )
    plazo_sugerido: str | None = Field(
        default=None,
        description="Plazo sugerido para la acción: inmediato, 24h, 48h, 1 semana"
    )

class AnalisisEmail(BaseModel):
    asunto_detectado: str = Field(description="Resumen del asunto en máximo 10 palabras")
    categoria: CategoriaEmail
    urgencia: UrgenciaEmail
    sentimiento_cliente: str = Field(description="frustrado, neutro, satisfecho, enojado")
    requiere_respuesta: bool
    accion_recomendada: AccionRequerida
    entidades_mencionadas: list[str] = Field(
        description="Productos, servicios, o números de referencia mencionados"
    )
    resumen: str = Field(max_length=200, description="Resumen ejecutivo del email")

def clasificar_email(contenido_email: str) -> AnalisisEmail:
    """Clasifica y analiza un email usando LLM con salida estructurada."""
    return client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": """Eres un asistente de clasificación de emails para una empresa
                de software. Analiza el email y extrae información estructurada para
                ayudar al equipo de soporte a priorizarlo correctamente."""
            },
            {
                "role": "user",
                "content": f"Analiza este email:\n\n{contenido_email}"
            }
        ],
        response_model=AnalisisEmail,
        max_retries=2,  # Reintentar si la validación falla
    )

# Ejemplo de uso
email_ejemplo = """
De: carlos.mendez@empresa.com
Asunto: URGENTE - Sistema caído desde hace 3 horas

Hola,

Llevamos más de 3 horas sin poder acceder al sistema de facturación.
Esto es crítico porque tenemos que cerrar el mes fiscal HOY.

Ya reiniciamos los servidores locales pero el problema persiste.
El código de error que aparece es ERR-5021.

Necesitamos una solución INMEDIATA. Si no se resuelve en la próxima
hora, tendremos que escalar a dirección.

Referencia cliente: CLI-2024-4521

Carlos Méndez
Director Financiero
"""

if __name__ == "__main__":
    resultado = clasificar_email(email_ejemplo)
    
    print("=== Análisis de Email ===")
    print(f"Asunto: {resultado.asunto_detectado}")
    print(f"Categoría: {resultado.categoria.value}")
    print(f"Urgencia: {resultado.urgencia.value}")
    print(f"Sentimiento: {resultado.sentimiento_cliente}")
    print(f"Requiere respuesta: {'Sí' if resultado.requiere_respuesta else 'No'}")
    print(f"\nAcción recomendada:")
    print(f"  - Tipo: {resultado.accion_recomendada.tipo}")
    print(f"  - Departamento: {resultado.accion_recomendada.departamento}")
    print(f"  - Plazo: {resultado.accion_recomendada.plazo_sugerido}")
    print(f"\nEntidades: {', '.join(resultado.entidades_mencionadas)}")
    print(f"\nResumen: {resultado.resumen}")

Este clasificador puede integrarse en un pipeline de automatización donde los emails se procesan, clasifican, y enrutan automáticamente al departamento correcto basándose en datos estructurados y validados.

Errores Comunes y Depuración

Error: Validación Fallida Repetidamente

Si el LLM no logra generar datos válidos incluso después de reintentos, revisa tus descripciones de campos. Campos ambiguos confunden al modelo:

# Malo: descripción vaga
edad: int = Field(description="edad")

# Mejor: descripción específica
edad: int = Field(ge=0, le=120, description="Edad de la persona en años completos")

Error: Campos Opcionales No Reconocidos

Asegúrate de usar la sintaxis correcta de Python para tipos opcionales:

# Python 3.10+
campo: str | None = Field(default=None)

# Python 3.9 (necesita import)
from typing import Optional
campo: Optional[str] = Field(default=None)

Error: Modelo No Soportado

No todos los modelos soportan function calling. Verifica la documentación del proveedor. Para modelos sin soporte nativo, Instructor puede usar el modo JSON:

# Para modelos sin function calling nativo
client = instructor.from_openai(
    OpenAI(),
    mode=instructor.Mode.JSON,  # Usa prompt engineering en lugar de tools
)

Depuración: Ver la Respuesta Raw

Cuando necesites inspeccionar qué está devolviendo el LLM antes de la validación:

from instructor import Instructor

# Habilitar logging detallado
import logging
logging.basicConfig(level=logging.DEBUG)

# O inspeccionar la respuesta completa
resultado, completion = client.chat.completions.create_with_completion(
    model="gpt-4o-mini",
    messages=[...],
    response_model=TuModelo,
)
print(completion)  # Respuesta completa del API

Snippet de Prueba Rápida

Usa este código mínimo para verificar que tu instalación funciona:

import instructor
from openai import OpenAI
from pydantic import BaseModel

client = instructor.from_openai(OpenAI())

class Test(BaseModel):
    mensaje: str
    numero: int

resultado = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": "Di hola y dame un número del 1 al 10"}],
    response_model=Test,
)

print(f"Mensaje: {resultado.mensaje}")
print(f"Número: {resultado.numero}")
print("¡Instructor funciona correctamente!")

Aprendizaje Futuro y Próximos Pasos

Una vez domines los conceptos básicos de Instructor, puedes explorar funcionalidades avanzadas:

  • Streaming de respuestas parciales: Instructor soporta streaming donde recibes el objeto parcialmente construido mientras el LLM genera la respuesta, útil para interfaces de usuario responsivas.
  • Validadores personalizados con contexto LLM: Puedes crear validadores que usan el LLM para verificar la calidad semántica de las respuestas, no solo su estructura.
  • Modelos anidados complejos: Estructuras con múltiples niveles de anidamiento, listas de objetos, y uniones discriminadas para modelar datos del mundo real.
  • Integración con LangChain o LlamaIndex: Instructor puede complementar estas bibliotecas cuando necesitas salidas estructuradas en pipelines más complejos.
  • Caching de respuestas: Implementar caching basado en el hash del prompt y modelo para reducir costos en consultas repetidas.

La documentación oficial en python.useinstructor.com incluye ejemplos adicionales para casos de uso específicos como extracción de tablas, generación de código estructurado, y clasificación multi-etiqueta.

Instructor representa un cambio de paradigma en cómo interactuamos con LLMs: en lugar de tratar sus respuestas como texto que debemos parsear, las tratamos como datos estructurados que podemos usar directamente. Esta abstracción reduce significativamente el código boilerplate y los errores en aplicaciones de producción que dependen de modelos de lenguaje.