Pídele a un modelo que responda en JSON y la mayoría de las veces json.loads() funciona sin problemas. Los fallos son los que te cuestan una sesión de debugging: un comentario de una línea dentro del objeto, un code fence de markdown envolviéndolo, una clave escrita userEmail cuando tu código espera user_email. Un prompt más estricto (“responde SOLO con el objeto JSON, sin ningún otro texto”) hace que estos casos sean más raros. No los hace imposibles, porque el modelo puede ignorar la petición igual. La salida estructurada elimina la petición de la ecuación: le entregas a la API un JSON Schema, y este restringe el muestreo de tokens del propio modelo para que la respuesta tenga que ajustarse a él.

JSON mode frente a salida estructurada: qué garantiza cada uno

El “JSON mode” (response_format: {"type": "json_object"} en la API antigua de OpenAI, y el equivalente en la mayoría de otros proveedores) garantiza una sola cosa: la salida se puede parsear como JSON. No dice nada sobre qué claves existen, qué tipo tiene cada una, o si un campo del que dependes está presente. Un modelo en JSON mode puede devolver {"result": "yes"} cuando tu código espera {"status": "approved", "confidence": 0.91}, y nada te avisa de que algo salió mal.

La salida estructurada (OpenAI la llama Structured Outputs, Anthropic la llama JSON outputs) es una garantía distinta: la respuesta coincide con el JSON Schema que proporcionas, con las claves requeridas presentes y el tipo correcto en cada una. El function calling resuelve un problema adyacente: que el modelo le pida a tu código que ejecute algo. Los dos se confunden con facilidad porque ambos pasan un esquema. Lo que cambia es qué moldea ese esquema:

FunciónQué garantizaPara qué sirve
JSON mode (json_object)Solo sintaxis JSON válidaFallback heredado, sin esquema que exigir
Salida estructurada (esquema strict)El JSON coincide exactamente con tu esquemaDar forma a la respuesta para el usuario o tu código
Function calling / tool useEl nombre y los argumentos del tool coinciden con su esquemaEl modelo decide invocar tu código

Un pipeline en producción suele usar las dos últimas juntas. El tool use strict evita que las llamadas de función de un agente lleguen malformadas, y un esquema strict en la respuesta final tipa el resumen que se devuelve al usuario.

Cómo el constrained decoding fuerza la coincidencia

El modelo nunca compara su respuesta con tu esquema después de generarla. El esquema cambia lo que tiene permitido generar, un token a la vez.

La capa de serving compila tu JSON Schema en una gramática: una máquina de estados que registra, en cada posición de la salida, qué tokens todavía llevarían a algo válido. Antes de muestrear cada token, todo token del vocabulario que la gramática descarta en esa posición ve su logit empujado a infinito negativo, así que tiene probabilidad cero tras el softmax. El modelo muestrea de lo que queda, y las probabilidades relativas entre los tokens que sobreviven se mantienen. Una clave mal escrita, una llave de cierre en el lugar equivocado, una cadena donde el esquema exige un número: en cuanto la gramática los prohíbe, esos tokens dejan de ser opciones.

Esa fase de compilación tiene un coste. Construir una gramática para un esquema nuevo requiere trabajo real, así que la primera petición contra ese esquema es más lenta que las siguientes. La API de Anthropic guarda en caché la gramática compilada durante 24 horas desde su último uso, y las llamadas posteriores con el mismo esquema se saltan la compilación.

El mecanismo también explica el límite. El constrained decoding fija solo la forma, nada más. La gramática sabe qué tokens son sintácticamente legales, no cuál es verdadero, así que el modelo puede seguir rellenando un campo "email" válido con una dirección inventada.

Cómo obtener JSON válido según el esquema en OpenAI, Claude y Ollama

La Responses API de OpenAI recibe el esquema en text.format, con strict activado:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from openai import OpenAI

client = OpenAI()

response = client.responses.create(
    model="gpt-6-astra",
    input="Extract the ticket details: 'Login fails with a 500 after the "
          "password reset flow, seen first on build 4.12, blocking release.'",
    text={
        "format": {
            "type": "json_schema",
            "name": "ticket",
            "strict": True,
            "schema": {
                "type": "object",
                "properties": {
                    "summary": {"type": "string"},
                    "build": {"type": "string"},
                    "severity": {"type": "string", "enum": ["low", "medium", "high", "blocking"]},
                },
                "required": ["summary", "build", "severity"],
                "additionalProperties": False,
            },
        }
    },
)
print(response.output_text)

Claude recibe el esquema en output_config.format, no en una definición de tool:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
curl https://api.anthropic.com/v1/messages \
  -H "content-type: application/json" \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2026-10-21" \
  -d '{
    "model": "claude-sonnet-5-5",
    "max_tokens": 1024,
    "messages": [{"role": "user", "content": "Extract the ticket details: ..."}],
    "output_config": {
      "format": {
        "type": "json_schema",
        "schema": {
          "type": "object",
          "properties": {
            "summary": {"type": "string"},
            "build": {"type": "string"},
            "severity": {"type": "string", "enum": ["low", "medium", "high", "blocking"]}
          },
          "required": ["summary", "build", "severity"],
          "additionalProperties": false
        }
      }
    }
  }'

El JSON vuelve en el contenido de texto de la respuesta, no en un bloque de tool call. El workaround antiguo (definir un tool falso, forzar al modelo a llamarlo, leer los argumentos) todavía funciona en modelos que solo soportan tool use. Donde existe salida estructurada nativa no lo necesitas: el decoder hace cumplir el esquema directamente, en lugar de confiar en que los argumentos del tool coincidan por casualidad.

Un modelo ejecutado en local con Ollama recibe el esquema a través del campo format de /api/chat:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
curl http://localhost:11434/api/chat -d '{
  "model": "llama3.2",
  "messages": [{"role": "user", "content": "Extract the ticket details: ..."}],
  "format": {
    "type": "object",
    "properties": {
      "summary": {"type": "string"},
      "build": {"type": "string"},
      "severity": {"type": "string", "enum": ["low", "medium", "high", "blocking"]}
    },
    "required": ["summary", "build", "severity"]
  },
  "stream": false
}'

El mecanismo es el mismo. El runtime llama.cpp de Ollama construye una gramática a partir del esquema y enmascara los tokens igual que las API alojadas.

Qué funciones de JSON Schema soportan los proveedores

Cada proveedor hace cumplir un subconjunto de JSON Schema, y los subconjuntos difieren lo bastante como para romper un esquema copiado de una API a otra.

OpenAI exige que todo objeto lleve "additionalProperties": false con cada una de sus propiedades listadas en required — no hay forma de marcar un campo como realmente opcional, así que modélalo como required y nullable. Anthropic es más permisivo: una propiedad que no esté en required es genuinamente opcional, y las propiedades required aparecen primero en la salida, seguidas de las opcionales que estén presentes. A partir de ahí, ambos difieren más. Anthropic soporta $ref/$defs locales, anyOf y const, pero rechaza esquemas recursivos, límites numéricos como minimum y maximum, y restricciones de longitud de cadena como maxLength. Los ejemplos de OpenAI incluyen un $ref: "#" recursivo para salidas en árbol, algo que la documentación de Anthropic descarta explícitamente.

Cuando tu esquema necesita una restricción que el proveedor no soporta, muévela a la description del campo (“debe ser un entero positivo menor que 100”) y compruébala tú mismo después del parseo. Los helpers de los SDK en ambas plataformas hacen esto automáticamente cuando construyes el esquema a partir de un modelo de Pydantic o Zod en vez de escribir JSON Schema a mano.

Cómo validar la salida estructurada después del parseo

Una forma válida no es una respuesta correcta. El esquema garantiza que severity sea uno de tus cuatro valores del enum. Si “blocking” es la decisión correcta para este ticket es una pregunta distinta. Trata el objeto parseado como tratarías el envío de un formulario: input que validar, no verdad establecida.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from pydantic import BaseModel, field_validator

class Ticket(BaseModel):
    summary: str
    build: str
    severity: str

    @field_validator("build")
    @classmethod
    def must_look_like_a_build_number(cls, v: str) -> str:
        if not v.replace(".", "").isdigit():
            raise ValueError(f"'{v}' doesn't look like a build number")
        return v

Instructor envuelve OpenAI, Anthropic y la mayoría de otros proveedores en un ciclo de validación y reintento. Le pasas un modelo de Pydantic. Ante un fallo de validación, devuelve el error al modelo y pregunta de nuevo, hasta un límite de reintentos que tú fijas, en lugar de lanzar un ValidationError al primer fallo. También es la vía práctica para conseguir una garantía de esquema en un proveedor o un modelo antiguo sin strict mode nativo: el ciclo de reintento sustituye a la validación que haría el decoder, y cada fallo cuesta un viaje de ida y vuelta extra.

Los validadores de campo detectan valores malformados en una sola respuesta. Para comprobar si las respuestas son correctas en cientos de entradas, y si un cambio de prompt las empeoró, necesitas un eval suite.

Cuándo la salida estructurada falla igual

El strict mode no cubre estos casos. Compruébalos antes de ponerte a buscar un bug en tu propio código:

  • Refusals. Un modelo puede negarse a responder, y el texto del rechazo no coincide con tu esquema. Comprueba stop_reason (o el equivalente) antes de parsear, no después de que el parseo lance un error.
  • Truncamiento. Llegar a max_tokens a mitad de un objeto produce JSON incompleto. La gramática controla qué token viene después, no cuánto espacio queda para terminar. Sube el límite o reduce el esquema.
  • Rutas sin forzar. JSON mode, una librería de reintento sobre un modelo sin strict mode, o un proveedor que ignora en silencio una palabra clave del esquema que no soporta pueden devolver "Blocking" cuando tu enum dice "blocking". Si alguna petición de tu sistema toma una de esas rutas, normaliza los valores al entrar.
  • Tamaño del esquema. Un esquema grande con muchas ramas anyOf añade tokens a cada petición, además del retraso de compilación de un esquema nuevo. Mantén los esquemas tan planos y pequeños como lo permita la tarea.

La garantía del proveedor es real, pero tu código de parseo no debería asumir que se cumplió.

JSON mode, esquema strict o function calling: cuál usar

Usa el JSON mode plano solo contra un modelo o una ruta de SDK sin soporte de esquemas, como fallback y no como opción por defecto. Usa un JSON Schema strict cuando estés dando forma a una respuesta para tu propio código o un usuario: extracción, clasificación, un resumen tipado. Usa el function calling cuando sea el modelo quien decida si ejecutar algo. Activa el strict mode también en la definición del tool, para que una llamada malformada falle en la API y no dentro de tu código.

Si todo lo que necesitas es una etiqueta de un enum corto, compara también un modelo system one. Cuando la salida está restringida a un conjunto conocido de respuestas, un modelo puede elegir una en lugar de generarla, y las concesiones de latencia y coste difieren lo bastante como para medirlas en tu propio tráfico.

Para todo lo demás, la opción por defecto es un esquema strict más un validador sobre el resultado parseado. El esquema mantiene correcta la forma. Comprobar que el contenido es correcto sigue siendo tarea tuya.

Artículos relacionados