Salidas Estructuradas en LLMs: Function Calling, JSON Mode e Instructor para Aplicaciones Confiables
Un LLM devuelve texto. Una aplicación necesita datos. Ese abismo entre ambas realidades es donde fracasa la mayoría de los sistemas de IA en producción.
El patrón más común en proyectos que arrancan rápido: parsear la respuesta del modelo con un json.loads() envuelto en un try/except. Funciona el 70% del tiempo. El 30% restante, la aplicación silencia el error y devuelve datos corruptos o simplemente falla. Ese 30% es inaceptable en producción.
Existe un conjunto de técnicas y herramientas que resuelven este problema de raíz: salidas estructuradas. No como un workaround, sino como una primitiva de diseño en sistemas LLM serios.
El Problema Real: Texto vs. Datos
Los modelos de lenguaje son generadores de tokens. No son motores de serialización. Cuando pedimos a un LLM que devuelva JSON, el modelo está aprendiendo estadísticamente que después de { viene algo que parece JSON — no que está construyendo un objeto. Esa diferencia sutil tiene consecuencias prácticas:
- Campos faltantes o con nombres inconsistentes (
emailvscorreovsemail_address) - Tipos de datos incorrectos (
"true"como string en lugar de booleano) - JSON truncado cuando el output supera el límite de tokens
- Texto narrativo mezclado con el JSON ("Aquí te presento el resultado: {…}")
- Campos con valores inventados cuando el modelo no tiene la información
Cada uno de estos casos es un bug en producción. La solución no es un prompt más largo — es estructurar la generación desde la arquitectura.
Nivel 1: JSON Mode
La solución más simple disponible en la mayoría de los providers modernos es el JSON mode: forzar al modelo a que su output sea JSON válido, sin texto adicional. No garantiza el esquema, pero garantiza parseabilidad.
import anthropic
import json
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
messages=[
{
"role": "user",
"content": """Extrae los datos del siguiente contrato y devuelve SOLO un JSON
con estos campos: cliente (string), monto (number), fecha_inicio (string ISO 8601),
servicios (array de strings).
Contrato: La empresa Nexo Technologies S.A. contrató servicios de consultoría
por $45,000 USD con inicio el 1 de abril de 2026. Los servicios incluyen
auditoría técnica, capacitación de equipos y migración de infraestructura."""
}
]
)
data = json.loads(response.content[0].text)
print(data)
# {
# "cliente": "Nexo Technologies S.A.",
# "monto": 45000,
# "fecha_inicio": "2026-04-01",
# "servicios": ["auditoría técnica", "capacitación de equipos", "migración de infraestructura"]
# }
El JSON mode es el piso mínimo. Es útil cuando el esquema es simple y el prompt es suficientemente explícito. Los problemas aparecen cuando el esquema es complejo, cuando hay campos opcionales o cuando necesitamos validación semántica (no solo sintáctica).
Nivel 2: Function Calling / Tool Use
El estándar real de la industria para salidas estructuradas es tool use (también llamado function calling). La idea es que, en lugar de pedirle al modelo que "devuelva JSON", le decimos que "llame a una función con estos parámetros". El modelo genera los argumentos de la función, no texto libre.
La diferencia arquitectural es significativa: el modelo ha sido entrenado específicamente para generar argumentos de función válidos, no para serializar texto como JSON. El resultado es mucho más robusto.
import anthropic
import json
client = anthropic.Anthropic()
tools = [
{
"name": "registrar_contrato",
"description": "Registra los datos estructurados de un contrato comercial",
"input_schema": {
"type": "object",
"properties": {
"cliente": {
"type": "string",
"description": "Nombre completo o razón social del cliente"
},
"monto_usd": {
"type": "number",
"description": "Monto total del contrato en dólares estadounidenses"
},
"fecha_inicio": {
"type": "string",
"description": "Fecha de inicio del contrato en formato ISO 8601 (YYYY-MM-DD)"
},
"servicios": {
"type": "array",
"items": {"type": "string"},
"description": "Lista de servicios contratados"
},
"moneda_original": {
"type": "string",
"enum": ["USD", "EUR", "MXN", "COP"],
"description": "Moneda original del contrato"
},
"requiere_nda": {
"type": "boolean",
"description": "Si el contrato requiere acuerdo de confidencialidad"
}
},
"required": ["cliente", "monto_usd", "fecha_inicio", "servicios"]
}
}
]
response = client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
tools=tools,
tool_choice={"type": "tool", "name": "registrar_contrato"},
messages=[
{
"role": "user",
"content": """Nexo Technologies S.A. firmó un contrato por $45,000 USD
con inicio el 1 de abril de 2026 para auditoría técnica, capacitación
de equipos y migración de infraestructura. Requieren NDA."""
}
]
)
tool_call = next(b for b in response.content if b.type == "tool_use")
datos = tool_call.input
print(datos)
# {
# "cliente": "Nexo Technologies S.A.",
# "monto_usd": 45000,
# "fecha_inicio": "2026-04-01",
# "servicios": ["auditoría técnica", "capacitación de equipos", "migración de infraestructura"],
# "moneda_original": "USD",
# "requiere_nda": true
# }
El parámetro tool_choice: {"type": "tool", "name": "..."} es clave: fuerza al modelo a usar esa herramienta específica en lugar de dejar la decisión a su criterio. En pipelines de extracción, nunca dejar esto al modelo — siempre forzar la herramienta.
Nivel 3: Instructor + Pydantic — Validación con Tipo
Tool use resuelve la sintaxis. Pero no resuelve la semántica: ¿qué pasa si el modelo devuelve una fecha en formato incorrecto? ¿Si el monto es negativo? ¿Si un campo requerido tiene un valor que no pasa la lógica de negocio?
La librería Instructor combina Pydantic con tool use para agregar validación de esquema con tipado fuerte, reintentos automáticos y descripciones por campo directamente en el modelo.
pip install instructor pydantic anthropic
import instructor
import anthropic
from pydantic import BaseModel, Field, validator
from typing import Optional
from datetime import date
from enum import Enum
class Moneda(str, Enum):
USD = "USD"
EUR = "EUR"
MXN = "MXN"
COP = "COP"
class Contrato(BaseModel):
cliente: str = Field(
description="Nombre completo o razón social del cliente"
)
monto_usd: float = Field(
gt=0,
description="Monto total en USD. Siempre positivo."
)
fecha_inicio: date = Field(
description="Fecha de inicio. Formato: YYYY-MM-DD"
)
servicios: list[str] = Field(
min_length=1,
description="Lista de servicios contratados. Al menos uno."
)
moneda_original: Moneda = Field(
default=Moneda.USD,
description="Moneda en que se denominó el contrato"
)
requiere_nda: bool = Field(
default=False,
description="True si el contrato incluye acuerdo de confidencialidad"
)
@validator("cliente")
def cliente_no_vacio(cls, v):
if not v.strip():
raise ValueError("El nombre del cliente no puede estar vacío")
return v.strip()
client = instructor.from_anthropic(anthropic.Anthropic())
contrato = client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
response_model=Contrato,
messages=[
{
"role": "user",
"content": """Nexo Technologies S.A. firmó un contrato por $45,000 USD
con inicio el 1 de abril de 2026 para auditoría técnica, capacitación
de equipos y migración de infraestructura. Requieren NDA."""
}
]
)
print(type(contrato)) # <class '__main__.Contrato'>
print(contrato.cliente) # "Nexo Technologies S.A."
print(contrato.fecha_inicio) # datetime.date(2026, 4, 1)
print(contrato.monto_usd) # 45000.0
print(contrato.requiere_nda) # True
La ventaja principal: el resultado ya es un objeto Python tipado, no un diccionario. Se puede pasar directamente a un ORM, serializar a una base de datos o validar con reglas de negocio sin capas adicionales de transformación.
Reintentos Automáticos con Validación
Instructor incluye un mecanismo de reintento que, cuando la validación de Pydantic falla, reenvía el error al modelo para que autocorrija:
import instructor
import anthropic
from pydantic import BaseModel, Field, validator
class ContratoEstricto(BaseModel):
monto_usd: float = Field(gt=0, lt=10_000_000)
@validator("monto_usd")
def validar_monto_razonable(cls, v):
if v < 1000:
raise ValueError(f"Monto {v} USD es menor al mínimo procesable ($1,000)")
return v
client = instructor.from_anthropic(
anthropic.Anthropic(),
mode=instructor.Mode.ANTHROPIC_TOOLS,
)
contrato = client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
max_retries=3,
response_model=ContratoEstricto,
messages=[{"role": "user", "content": "El contrato es por cincuenta dólares."}]
)
# Si después de 3 intentos sigue fallando, lanza InstructorRetryException
Nivel 4: Extracción de Múltiples Entidades
Un patrón frecuente en producción: extraer múltiples instancias de una entidad desde un texto no estructurado — un correo con varios contratos, un documento con múltiples partes involucradas, etc.
from pydantic import BaseModel
from typing import Optional
import instructor
import anthropic
class Persona(BaseModel):
nombre: str
rol: str # "cliente", "proveedor", "intermediario", "garante"
email: Optional[str] = None
empresa: Optional[str] = None
class DocumentoLegal(BaseModel):
partes_involucradas: list[Persona]
fecha_firma: Optional[str] = None
jurisdiccion: Optional[str] = None
tipo_documento: str # "contrato", "addendum", "NDA", "carta_intención"
client = instructor.from_anthropic(anthropic.Anthropic())
texto = """
El presente acuerdo es suscrito entre María González (mgonzalez@nexo.com),
Directora de Operaciones de Nexo Technologies, como cliente, y el equipo de
consultoría representado por Carlos Méndez (c.mendez@consultora.io), como
proveedor de servicios. Actúa como garante Roberto Salas de Inversiones Salas S.A.
El documento se firma en Ciudad de México bajo legislación mexicana.
"""
resultado = client.messages.create(
model="claude-opus-4-6",
max_tokens=2048,
response_model=DocumentoLegal,
messages=[{"role": "user", "content": f"Extrae las entidades de este documento:\n\n{texto}"}]
)
for parte in resultado.partes_involucradas:
print(f"[{parte.rol.upper()}] {parte.nombre} — {parte.empresa or 'Sin empresa'}")
# [CLIENTE] María González — Nexo Technologies
# [PROVEEDOR] Carlos Méndez — consultora.io
# [GARANTE] Roberto Salas — Inversiones Salas S.A.
Nivel 5: Generación con Restricciones a Nivel de Tokens (Outlines)
Para casos donde necesitamos máximo control sobre la generación — modelos open-source corriendo localmente — existe un enfoque más profundo: restringir qué tokens puede generar el modelo en cada posición usando un autómata de estados finitos derivado del esquema JSON.
La librería Outlines implementa esto con modelos locales (vía transformers o llama.cpp):
pip install outlines transformers
import outlines
from pydantic import BaseModel
from typing import Literal
class ClasificacionTicket(BaseModel):
categoria: Literal["bug", "feature_request", "consulta", "facturación", "otro"]
urgencia: Literal["baja", "media", "alta", "crítica"]
requiere_escalacion: bool
equipo_destino: Literal["ingeniería", "soporte", "ventas", "finanzas"]
model = outlines.models.transformers("Qwen/Qwen2.5-1.5B-Instruct")
generator = outlines.generate.json(model, ClasificacionTicket)
ticket = "El sistema de pagos no procesa tarjetas desde las 2am. Tenemos 300 clientes bloqueados."
resultado: ClasificacionTicket = generator(
f"Clasifica este ticket de soporte:\n{ticket}"
)
print(resultado.categoria) # "bug"
print(resultado.urgencia) # "crítica"
print(resultado.requiere_escalacion) # True
print(resultado.equipo_destino) # "ingeniería"
Con Outlines, la validación ocurre durante la generación, no después. El modelo literalmente no puede generar un valor inválido para categoria porque los tokens posibles están restringidos al vocabulario del Literal. Cero fallos de validación, cero reintentos necesarios.
Comparativa de Estrategias
| Estrategia | Garantía de esquema | Validación semántica | Soporte de provider | Overhead |
|---|---|---|---|---|
| Prompt + json.loads() | ❌ Ninguna | ❌ | Universal | Cero |
| JSON Mode | ✅ JSON válido | ❌ | OpenAI, Anthropic, Gemini | Mínimo |
| Tool Use / Function Calling | ✅ JSON + esquema | ❌ | OpenAI, Anthropic, Gemini, Mistral | Bajo |
| Instructor + Pydantic | ✅ JSON + esquema + tipos | ✅ Con validators | Multi-provider (wrapper) | Medio (reintentos) |
| Outlines (local) | ✅ Garantía a nivel de token | ✅ Enum/Literal | Solo modelos locales | Bajo (en inferencia) |
Patrones de Producción que Funcionan
1. Separar el schema del prompt
El error más común es mezclar las instrucciones de la tarea con la definición del esquema en el mismo mensaje. El esquema va en la definición de la herramienta; el prompt se enfoca solo en la tarea:
# ❌ Mal: todo mezclado en el prompt
"Extrae el nombre, email y empresa. Devuelve JSON con campos: nombre (string), email (string), empresa (string)."
# ✅ Bien: descripción de campos en el schema, prompt enfocado en la tarea
"Extrae los datos de contacto del siguiente texto:"
# + schema con description por campo
2. Campos opcionales vs. requeridos con intención
Si un campo puede no estar en el texto fuente, marcarlo como Optional con valor None es mejor que forzar al modelo a inventar algo. El modelo que "no sabe" alucinará — el modelo que "puede devolver None" simplemente devuelve None.
class Contacto(BaseModel):
nombre: str # Siempre requerido
email: Optional[str] = None # Puede no estar
telefono: Optional[str] = None # Puede no estar
empresa: Optional[str] = None # Puede no estar
confianza: float = Field( # Meta-campo de incertidumbre
ge=0.0, le=1.0,
description="Nivel de confianza en la extracción (0.0 a 1.0)"
)
3. El campo de confianza
Agregar un campo confianza: float al esquema permite que el modelo exprese incertidumbre de forma estructurada. En lugar de alucinar, puede indicar que el dato extraído tiene 0.4 de confianza — y el sistema puede decidir si enviarlo a revisión humana o procesarlo automáticamente. Es un patrón infrautilizado que cambia radicalmente cómo se diseñan los pipelines de extracción.
4. Manejo de errores de reintento
from instructor.exceptions import InstructorRetryException
try:
resultado = client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
max_retries=3,
response_model=MiEsquema,
messages=[{"role": "user", "content": texto}]
)
except InstructorRetryException as e:
log_extraction_failure(texto, str(e))
queue_for_human_review(texto)
Caso Práctico: Pipeline de Extracción de Facturas
Un pipeline que combina todas las técnicas anteriores en un sistema real:
import instructor
import anthropic
from pydantic import BaseModel, Field, validator
from typing import Optional
from datetime import date
class LineaFactura(BaseModel):
descripcion: str
cantidad: float = Field(gt=0)
precio_unitario: float = Field(gt=0)
subtotal: float = Field(gt=0)
class Factura(BaseModel):
numero_factura: str
emisor: str
receptor: str
fecha_emision: date
fecha_vencimiento: Optional[date] = None
lineas: list[LineaFactura] = Field(min_length=1)
subtotal: float = Field(gt=0)
impuesto_porcentaje: float = Field(ge=0, le=100)
total: float = Field(gt=0)
moneda: str = Field(default="USD")
confianza_extraccion: float = Field(ge=0, le=1)
@validator("total")
def validar_total_consistente(cls, total, values):
if "subtotal" in values and "impuesto_porcentaje" in values:
esperado = values["subtotal"] * (1 + values["impuesto_porcentaje"] / 100)
if abs(total - esperado) / esperado > 0.01:
raise ValueError(
f"Total {total} no es consistente con subtotal {values['subtotal']} "
f"+ impuesto {values['impuesto_porcentaje']}%"
)
return total
client = instructor.from_anthropic(anthropic.Anthropic())
def extraer_factura(texto_factura: str) -> Factura:
return client.messages.create(
model="claude-opus-4-6",
max_tokens=2048,
max_retries=2,
response_model=Factura,
messages=[
{
"role": "user",
"content": f"Extrae todos los datos de esta factura:\n\n{texto_factura}"
}
]
)
factura = extraer_factura(texto_crudo_de_pdf)
if factura.confianza_extraccion < 0.7:
enviar_a_revision(factura)
else:
registrar_en_erp(factura)
Este pipeline valida tipos, verifica consistencia matemática del total contra subtotal + impuesto, y enruta automáticamente según la confianza del modelo. Sin una línea de parsing manual.
Cuándo No Usar Structured Outputs
Las salidas estructuradas agregan latencia y costo de tokens por la definición del esquema. Los casos donde no valen la pena:
- Generación de texto narrativo sin extracción de datos
- Chatbots conversacionales donde la respuesta la consume directamente el usuario
- Pipelines donde el siguiente paso es otro LLM, no un sistema que requiera datos tipados
- Clasificaciones binarias simples resolubles con prompt y string matching
El criterio es directo: si el output del LLM va a ser procesado por código — guardado en base de datos, pasado a una API, usado en lógica condicional — necesita ser estructurado. Si va a ser leído por un humano, probablemente no.
El Costo Real de Ignorar Esto
Los sistemas que parsean respuestas de LLM sin garantías estructurales acumulan deuda técnica de una forma particular: funcionan bien en demos y pruebas iniciales, donde los casos son simples y los prompts están pulidos. En producción, con datos reales y usuarios reales, los edge cases se multiplican. El código de manejo de errores crece, los prompts se vuelven cada vez más específicos (y frágiles), y eventualmente el sistema requiere revisión humana constante para corregir extracciones fallidas.
Construir con salidas estructuradas desde el inicio no es over-engineering. Es la diferencia entre un sistema que escala y uno que requiere mantenimiento continuo para sobrevivir.