Structured Outputs y Function Calling: Cómo Forzar que los LLMs Generen Datos Confiables

Un LLM genera tokens. Si le pides que conteste una pregunta, devuelve texto. Si le pides que extraiga datos de un documento, también devuelve texto. El problema es que el texto es inherentemente impredecible. Un modelo puede cambiar de formato entre una llamada y otra, omitir campos, incluir texto adicional o simplemente fallar en la estructura que esperabas.

Hasta hace poco, la solución era parsearlo después: regex, JSON parsing con fallbacks, validación manual. Frágil. Ahora tenemos mejor: structured outputs y function calling. No son lo mismo, pero resuelven el mismo problema de fondo — convertir la salida probabilística de un modelo en datos confiables.

El Problema Real: Cuando la Libertad del Lenguaje es tu Enemigo

Imagina que necesitas extraer datos de facturas. Entregas 100 facturas a un modelo y pides que extraiga el monto, la fecha y el número de factura. Parece simple. Pero en la realidad:

  • 30 facturas vienen con monto como número limpio: 1500.50
  • 45 vienen con moneda incluida: $1500.50 o 1500.50 USD
  • 20 vienen como texto: mil quinientos pesos con cincuenta centavos
  • 5 simplemente faltan porque el modelo no las encontró, pero no lo dice explícitamente

Ahora tienes un pipeline roto. Tu base de datos no acepta valores inconsistentes, tus reportes se quiebran, y gastas horas escribiendo validadores y parseadores para cada caso edge.

Structured outputs resuelve esto en el origen: le dices al modelo exactamente qué formato quieres y el modelo está obligado a respetarlo.

Structured Outputs: El Contrato Garantizado

Structured outputs funcionan mediante JSON Schema. Le defines al modelo un schema y el modelo garantiza que su respuesta cumple con él. No es adivinanza, no es parsing posterior. Es una restricción en el token generation.

Ejemplo Básico: Extracción de Facturas

Define el schema que esperas:

{
  "type": "object",
  "properties": {
    "invoice_number": {
      "type": "string",
      "description": "Invoice identifier, exact as appears"
    },
    "date": {
      "type": "string",
      "format": "date",
      "description": "Invoice date in YYYY-MM-DD"
    },
    "amount": {
      "type": "number",
      "description": "Total amount in numeric form"
    },
    "currency": {
      "type": "string",
      "enum": ["USD", "EUR", "MXN", "ARS"],
      "description": "Currency code"
    },
    "vendor": {
      "type": "string",
      "description": "Company name that issued the invoice"
    },
    "status": {
      "type": "string",
      "enum": ["paid", "pending", "overdue"],
      "description": "Payment status based on document content"
    }
  },
  "required": ["invoice_number", "date", "amount", "currency", "vendor"]
}

Ahora llama al modelo con este schema (ejemplo con Python + Claude API):

import anthropic
import json

client = anthropic.Anthropic()

invoice_text = """
INVOICE #INV-2026-0847
Date: March 15, 2026
From: TechSupplies Inc.
Total: $2,350.75 USD
Status: Pending payment
"""

# Define schema
schema = {
    "type": "object",
    "properties": {
        "invoice_number": {"type": "string"},
        "date": {"type": "string", "format": "date"},
        "amount": {"type": "number"},
        "currency": {"type": "string", "enum": ["USD", "EUR", "MXN", "ARS"]},
        "vendor": {"type": "string"},
        "status": {"type": "string", "enum": ["paid", "pending", "overdue"]}
    },
    "required": ["invoice_number", "date", "amount", "currency", "vendor"]
}

response = client.messages.create(
    model="claude-opus-4-6",
    max_tokens=1024,
    messages=[
        {"role": "user", "content": f"Extract invoice data:\n\n{invoice_text}"}
    ],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "invoice",
            "schema": schema
        }
    }
)

# Guaranteed valid JSON matching schema
data = json.loads(response.content[0].text)
print(f"Invoice: {data['invoice_number']}")
print(f"Amount: {data['amount']} {data['currency']}")

El resultado está garantizado: será un JSON válido que respeta el schema. No hay try-except. No hay "el modelo olvidó el campo currency". Sucede.

Function Calling: Cómo Forzar que el Modelo Llame Herramientas

Function calling es el lado opuesto: en lugar de estructurar la respuesta del modelo, estructuras las acciones que el modelo puede tomar.

El modelo no genera texto. Genera un JSON que especifica: "Deberías llamar a la función X con los parámetros Y". Tu código ejecuta esa función y devuelve el resultado. El modelo decide qué hacer con el resultado.

Es el mecanismo detrás de los agentes.

Ejemplo: Un Agente que Consulta Bases de Datos

Imagina que quieres que un modelo responda preguntas sobre clientes, pero no puede acceder directamente a la BD. Defines funciones:

tools = [
    {
        "name": "search_customers",
        "description": "Search customers by name, email or ID",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "Search term (name, email, or customer ID)"
                },
                "limit": {
                    "type": "integer",
                    "description": "Maximum results to return (1-10)",
                    "default": 5
                }
            },
            "required": ["query"]
        }
    },
    {
        "name": "get_customer_orders",
        "description": "Get order history for a specific customer",
        "input_schema": {
            "type": "object",
            "properties": {
                "customer_id": {
                    "type": "string",
                    "description": "The unique customer ID"
                },
                "last_n_orders": {
                    "type": "integer",
                    "description": "Number of recent orders to fetch",
                    "default": 10
                }
            },
            "required": ["customer_id"]
        }
    }
]

Ahora un loop de agente:

def agentic_loop(user_query: str, max_iterations: int = 5) -> str:
    messages = [
        {"role": "user", "content": user_query}
    ]
    
    for _ in range(max_iterations):
        response = client.messages.create(
            model="claude-opus-4-6",
            max_tokens=2048,
            tools=tools,
            messages=messages
        )
        
        # Check if model wants to use tools
        if response.stop_reason == "tool_use":
            # Process tool calls
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    tool_name = block.name
                    tool_input = block.input
                    
                    # Execute tool (mock for example)
                    if tool_name == "search_customers":
                        result = database.search_customers(
                            query=tool_input["query"],
                            limit=tool_input.get("limit", 5)
                        )
                    elif tool_name == "get_customer_orders":
                        result = database.get_orders(
                            customer_id=tool_input["customer_id"],
                            limit=tool_input.get("last_n_orders", 10)
                        )
                    
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": json.dumps(result)
                    })
            
            # Feed results back to model
            messages.append({"role": "assistant", "content": response.content})
            messages.append({"role": "user", "content": tool_results})
        
        else:  # stop_reason == "end_turn"
            # Model finished, extract final answer
            for block in response.content:
                if hasattr(block, 'text'):
                    return block.text
    
    return "Max iterations reached"

# Use it
answer = agentic_loop("¿Cuál fue el último pedido de clientes con email @empresa.com?")
print(answer)

El modelo decide qué herramientas llamar, en qué orden y con qué parámetros. Tu código ejecuta esas herramientas. El modelo ve los resultados y decide si necesita más información o puede responder.

Structured Outputs vs Function Calling: Cuándo Usar Cada Uno

Aspecto Structured Outputs Function Calling
Controla La RESPUESTA del modelo Las ACCIONES del modelo
Caso de uso Extracción de datos, clasificación, generación estructurada Integración con herramientas, APIs, automatización
Ejemplo "Extrae nombre, edad, email de este CV" "Busca en la BD, llama la API, luego responde"
Garantía JSON válido que cumple schema Llamadas correctamente formadas a funciones
Responsabilidad de ejecución El modelo genera todo Tu código ejecuta; el modelo decide qué
Complejidad Baja a media Media a alta (requiere loop + manejo de herramientas)

Puedes usar ambos simultáneamente: function calling para que el modelo decida qué herramientas usar, y structured outputs para que los datos que devuelva esas herramientas sean confiables.

Limitaciones y Trade-offs Reales

Structured Outputs no es Validación Completa

El schema garantiza estructura, no semántica. Si defines un enum ["paid", "pending", "overdue"] para status, el modelo no dirá "almost paid" — dirá uno de esos tres valores. Pero si el contenido real del documento dice "paid" y el modelo entiende "pending", no hay nada que lo corrija. El schema garantiza formato, no precisión.

Performance Cost

Structured outputs requieren que el modelo "piense" en cómo respetar el schema mientras genera tokens. Puede añadir latencia (~5-15% más en Claude) y en algunos casos incrementar tokens usados. Mide antes de optimizar.

Function Calling Requiere Loop Correcto

Un loop de agente mal implementado puede:

  • Entrar en loop infinito (el modelo siempre pide más datos)
  • Fallar si una herramienta devuelve error y el modelo no sabe cómo proceder
  • Gastar tokens de forma ineficiente si el modelo usa herramientas innecesarias

Siempre implementa límites de iteraciones, timeouts y manejo de errores explícito.

Schema Demasiado Restrictivo

Si tu schema es muy rígido, el modelo puede verse forzado a omitir información útil o ser impreciso. Un schema sobre-especificado puede empeorar la calidad. Equilibrio: sé específico en lo que importa, flexible en lo que no.

Patrones Avanzados en Producción

Multi-step Extraction con Validación Encadenada

Cuando necesitas extraer datos complejos, puedes encadenar llamadas: primero extrae estructura básica, luego valida y enriquece:

def robust_extraction(raw_text: str) -> dict:
    # Step 1: Initial structured extraction
    step1 = client.messages.create(
        model="claude-opus-4-6",
        max_tokens=2048,
        messages=[{"role": "user", "content": f"Extract data from:\n{raw_text}"}],
        response_format={
            "type": "json_schema",
            "json_schema": {"name": "extracted", "schema": basic_schema}
        }
    )
    data = json.loads(step1.content[0].text)
    
    # Step 2: Validate and enrich
    step2 = client.messages.create(
        model="claude-opus-4-6",
        max_tokens=1024,
        messages=[
            {"role": "user", "content": 
             f"Validate and complete this data:\n{json.dumps(data)}\n\nOriginal text:\n{raw_text}"}
        ],
        response_format={
            "type": "json_schema",
            "json_schema": {"name": "validated", "schema": enhanced_schema}
        }
    )
    
    return json.loads(step2.content[0].text)

Graceful Degradation en Function Calling

Si una herramienta falla, informa al modelo claramente:

try:
    result = search_customers(query=tool_input["query"])
    tool_results.append({
        "type": "tool_result",
        "tool_use_id": block.id,
        "content": json.dumps(result),
        "is_error": False
    })
except Exception as e:
    tool_results.append({
        "type": "tool_result",
        "tool_use_id": block.id,
        "content": f"Error: Database unavailable. {str(e)}",
        "is_error": True
    })

El modelo verá que la herramienta falló y ajustará su respuesta. Sin esto, el modelo asume que la herramienta funcionó.

Cuándo NO Usar Esto

No uses structured outputs si:

  • El resultado es puramente narrativo — un resumen o explicación no requiere schema. La estructura añade complejidad innecesaria.
  • La salida es altamente variable — si no sabes qué campos tendrá el resultado, el schema será constantemente incorrecto.
  • El modelo debe explorar creativamente — la restricción de schema puede aplanar la creatividad.

No uses function calling si:

  • No necesitas interacción entre el modelo y sistemas externos — si el modelo puede responder desde su conocimiento, es sobreingenierización.
  • La latencia es crítica — cada iteración del agente suma latencia.
  • Tu stack de herramientas es muy inestable — si las herramientas fallan frecuentemente, el agente fallará.

La Realidad en 2026

Structured outputs y function calling son ahora el estándar para aplicaciones serias de LLMs. No son opcionales si quieres confiabilidad. Cada modelo nuevo —Opus 4.6, GPT-5.4, Gemini 3.1— ha mejorado el soporte para ambos.

La tendencia es clara: los modelos están siendo forzados a ser herramientas predecibles, no generadores de texto sin restricciones. Eso es un cambio fundamental en cómo construimos con IA. Ya no es "qué dice el modelo", es "qué decide hacer el modelo dentro de los límites que le pusimos".

Para desarrolladores que ya entienden LLMs, esto es el salto siguiente: de la experimentación a la ingeniería.