aicert.study

Salida Estructurada con JSON Schema en Claude: Guía Práctica para Reducir Fallos de Parsing

15 de julio de 2026

Salida Estructurada con JSON Schema en Claude: Guía Práctica para Reducir Fallos de Parsing

La mayoría de los fallos de "parsing de JSON" en integraciones con Claude en producción no son bugs de JSON — son bugs de prompting disfrazados de error de parser. Si tu pipeline lanza json.JSONDecodeError o SyntaxError: Unexpected token, el problema normalmente no es un parser mejor. Es forzar estructura a nivel de la API, en vez de pedirla en texto libre.

El problema real: no tienes un bug de JSON

Pedirle al modelo que "responda en JSON" dentro de un prompt de texto plano trata la estructura como una sugerencia de estilo, no como un contrato. El modelo queda libre para:

  • Envolver la respuesta en json ... (genial para un humano leyendo el chat, inútil para JSON.parse()).
  • Abrir con una frase tipo "Claro, aquí está el JSON solicitado:" antes de la llave {.
  • Omitir un campo obligatorio cuando el input de origen es ambiguo.
  • Producir snake_case en una llamada y camelCase en la siguiente.

Nada de eso es un problema de parser. Es lo que pasa cuando la estructura es una sugerencia, no un contrato.

La solución: tratar la salida estructurada como tool use

La Claude API no tiene un "modo JSON" separado — y no lo necesita. El mecanismo de tool use (function calling) ya resuelve esto, porque traslada la garantía estructural del texto libre al input_schema de la herramienta. Cuando el modelo llama a una herramienta, tool_use.input llega como un objeto ya procesado por el SDK — no como un string que tengas que volver a parsear.

La parte que realmente hace esto confiable es forzar la llamada con tool_choice, en vez de dejar que el modelo decida si llama a la herramienta o simplemente responde en texto:

# pip install anthropic
import anthropic

client = anthropic.Anthropic()

INVOICE_SCHEMA = {
    "type": "object",
    "properties": {
        "invoice_number": {
            "type": "string",
            "description": "Identificador único de la factura, ej: INV-2026-0001."
        },
        "total_amount": {
            "type": "number",
            "description": "Monto total, sin símbolo de moneda."
        },
        "currency": {
            "type": "string",
            "enum": ["USD", "EUR", "BRL"]
        }
    },
    "required": ["invoice_number", "total_amount", "currency"]
}

# cualquier texto de entrada real — factura, correo, PDF extraído, etc.
document_text = """Factura INV-2026-0042
Monto total: $1.240,00"""

response = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=1024,
    tools=[{
        "name": "extract_invoice",
        "description": "Extrae los datos estructurados de la factura.",
        "input_schema": INVOICE_SCHEMA
    }],
    # fuerza la llamada — el modelo no puede "solo responder en texto"
    tool_choice={"type": "tool", "name": "extract_invoice"},
    messages=[{"role": "user", "content": document_text}]
)

tool_block = next(b for b in response.content if b.type == "tool_use")
data = tool_block.input  # ya es un dict — nada que pasar por json.loads()
print(data)
# {'invoice_number': 'INV-2026-0042', 'total_amount': 1240.0, 'currency': 'USD'}

Este fragmento está completo — exporta ANTHROPIC_API_KEY y ejecútalo tal cual. Dos decisiones hacen el trabajo real aquí:

  • tool_choice especifica el nombre exacto de la herramienta. Sin esto, el modelo aún puede preferir responder en texto plano cuando el input es ambiguo.
  • Cada campo del schema tiene una description. No es decorativa — es una instrucción de prompt incrustada en el schema, y el modelo la usa para decidir qué poner en cada campo.

Schemas que reducen la ambigüedad, no solo la validan

Un input_schema bien escrito hace el trabajo de un prompt entero. Tres hábitos que reducen errores de llenado antes incluso de que corra cualquier validación:

Prefiere enum sobre texto libre siempre que el conjunto de valores sea cerrado. "currency": {"type": "string"} deja al modelo libre para escribir "dólares", "USD" o "US$" en llamadas distintas. "enum": ["USD", "EUR", "BRL"] elimina esa variación estructuralmente — el modelo elige entre opciones, no inventa un string.

Evita el anidamiento profundo innecesario. Schemas con 4+ niveles de profundidad (un objeto dentro de un array dentro de un objeto dentro de otro objeto) aumentan la probabilidad de que el modelo omita un campo obligatorio en el nivel más interno. Aplana la estructura cuando el dominio lo permita.

Escribe la description pensando en alguien que nunca vio el documento de origen. "Nombre del cliente" es ambiguo si el documento tiene tanto un nombre de contacto como una razón social. "Razón social de la empresa compradora, tal como aparece en el encabezado de la factura" resuelve la ambigüedad sin necesitar un ejemplo.

El error silencioso: truncamiento por max_tokens

Un tool_use.input incompleto no lanza un error de schema — simplemente se detiene a la mitad, y el SDK te entrega lo que logró construir hasta ese punto. Si tu schema tiene una lista larga (decenas de line_items, por ejemplo), un max_tokens insuficiente corta la respuesta antes de que el JSON se cierre, y solo lo descubres al intentar leer un campo que nunca llegó.

Siempre revisa response.stop_reason antes de confiar en el resultado. Si el valor es "max_tokens", el objeto puede estar incompleto — trata eso como un fallo y reintenta con un presupuesto de tokens mayor, nunca como datos parcialmente válidos.

El patrón que resuelve el resto: validar y devolver el error al modelo

Ningún schema elimina el 100% de los errores. El objetivo realista no es "nunca fallar" — es fallar de una forma que se autocorrija. El patrón más robusto trata la respuesta del modelo como un intento, la valida contra el schema con una librería de verdad (no un try/except suelto), y devuelve el error específico de validación en el siguiente turno — dejando que el propio modelo corrija el campo que falló.

  1. Llama al modelo con tool_choice forzado
  2. Valida tool_use.input contra el schema
  3. Si es inválido, envía el error específico de vuelta en el siguiente turno
  4. Repite hasta validar o agotar los reintentos
# pip install jsonschema
from jsonschema import validate, ValidationError

def get_structured_output(client, schema, tool_name, tool_description, messages, max_retries=2):
    max_tokens = 1024
    for attempt in range(max_retries + 1):
        response = client.messages.create(
            model="claude-opus-4-8",
            max_tokens=max_tokens,
            tools=[{"name": tool_name, "description": tool_description, "input_schema": schema}],
            tool_choice={"type": "tool", "name": tool_name},
            messages=messages,
        )

        if response.stop_reason == "max_tokens":
            # no reutiliza el mensaje truncado — la doc oficial de Anthropic
            # recomienda reenviar con más presupuesto, no parchar una respuesta incompleta
            max_tokens *= 2
            continue

        # solo llega aquí con una respuesta completa — ahora sí entra al historial
        messages.append({"role": "assistant", "content": response.content})

        tool_block = next((b for b in response.content if b.type == "tool_use"), None)
        if tool_block is None:
            messages.append({"role": "user", "content": "Debes usar la herramienta para responder."})
            continue

        try:
            validate(instance=tool_block.input, schema=schema)
            return tool_block.input
        except ValidationError as e:
            messages.append({"role": "user", "content":
                f"Falló la validación: {e.message}. Llama a la herramienta de nuevo con el valor corregido."})

    raise RuntimeError("No se pudo obtener una salida estructurada válida tras los reintentos.")


# uso — reutilizando el schema y el document_text del ejemplo anterior
data = get_structured_output(
    client=client,
    schema=INVOICE_SCHEMA,
    tool_name="extract_invoice",
    tool_description="Extrae los datos estructurados de la factura.",
    messages=[{"role": "user", "content": document_text}],
)
print(data)

El detalle clave: el turno anterior del asistente (messages.append({"role": "assistant", ...})) tiene que entrar al historial antes de la corrección — sin eso, el modelo no tiene memoria de lo que intentó la última vez. Esto refleja exactamente el task statement "loops de validación y reintento" evaluado en el dominio de Prompt Engineering de la CCA-F: el modelo recibe retroalimentación específica sobre qué falló, en vez de un "inténtalo de nuevo" genérico.

A escala: el batch no valida el schema por ti

Procesar miles de documentos con la Message Batches API (hasta 10.000 solicitudes por lote, cada una tratada de forma independiente) tienta a asumir que el volumen elimina la necesidad de validación por elemento. No es así. Cada respuesta del lote todavía puede truncarse, todavía puede fallar la validación, y en un lote de 10 mil elementos, incluso una tasa de error del 1% son 100 casos que necesitan tratamiento. El patrón de validar y devolver el error sigue siendo la unidad de trabajo — el batch solo paraleliza cuántas veces corre esa unidad, no elimina la necesidad de ella.

Checklist rápido antes de pasar a producción

  • ¿Reemplazaste "responde en JSON" en el prompt por tools + input_schema?
  • ¿Forzaste la llamada con tool_choice: {"type": "tool", "name": "..."}?
  • ¿Cada propiedad del schema tiene una description específica, no genérica?
  • ¿Los campos de conjunto cerrado usan enum en vez de string libre?
  • ¿Revisas stop_reason == "max_tokens" antes de usar el resultado?
  • ¿Tu loop de reintento devuelve el error específico al modelo, en vez de un reintento ciego?

¿Listo para practicar para Claude Certified Architect — Foundations (CCA-F)?

Haz un simulacro de muestra gratis y mira tu desempeño, dominio por dominio.

Probar el simulacro