Los modelos de chat quieren conversar. Tu pipeline quiere un registro. La brecha entre «aquí tienes un párrafo amable sobre el ticket» y {"category": "billing", "priority": "high"} es donde la mayoría de las integraciones con LLM se rompen en silencio — una valla de markdown perdida, una coma final, una clave alucinada, y el json.loads de más abajo revienta a las 3 de la madrugada. Este artículo trata de forzar un JSON válido y útil de Claude y Gemini, usando la misma forma de petición de OpenAI tras un único base_url, y la capa de validación que lo lleva de calidad de demo a calidad de producción.
Hay tres herramientas para el trabajo: response_format con json_object, response_format con json_schema y las llamadas nativas a herramientas/funciones. No son intercambiables, y elegir la equivocada es la razón más común de que una función de salida estructurada parezca inestable. Recorreremos cada una: cuándo recurrir a ella, cómo diseñar el esquema y cómo validar y reparar lo que vuelve.
Modo JSON: parseable garantizado, no correcto garantizado
La palanca más simple es response_format={"type": "json_object"}. Restringe al modelo a emitir JSON sintácticamente válido — sin preámbulo en prosa, sin valla ```json, sin disculpas. Lo que no hace es imponer tu forma. Todavía tienes que describir los campos en el prompt, y el modelo aún puede omitir una clave, inventarse una o poner una cadena donde querías un booleano.
# response_format=json_object: el modelo queda restringido a emitir JSON
# sintácticamente válido. NO impone TU forma — todavía tienes que describir los
# campos en el prompt. Llamada idéntica para Claude y Gemini tras un único base_url.
from openai import OpenAI
import json
client = OpenAI(
api_key="sk-brievio-...",
base_url="https://api.brievio.com/v1",
)
resp = client.chat.completions.create(
model="claude-sonnet-4-6", # o "gemini-2.5-flash" — mismo código
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Extract the support ticket fields. Reply ONLY with a JSON object "
"with keys: category (one of billing|bug|feature|other), "
"priority (low|medium|high), summary (string), "
"needs_human (boolean)."
),
},
{"role": "user", "content": "I was charged twice this month, please refund."},
],
)
data = json.loads(resp.choices[0].message.content) # parseable garantizado
print(data["category"], data["priority"]) # "billing" "high"
# json_object garantiza: que parsea. NO garantiza que tus claves existan,
# que los enums sean válidos ni que los tipos sean correctos. Para eso está la validación.Esta es la herramienta correcta cuando la forma es simple, cuando controlas el prompt con rigor o cuando vas a validar de todos modos (lo vas a hacer). El modelo mental: json_object te compra la garantía de que json.loads no va a reventar. No te compra la garantía de que el objeto signifique lo que crees. Trata esa diferencia como el meollo del asunto.
Modo JSON Schema: restringe la forma, no solo la sintaxis
Cuando quieres que los nombres de campo, los tipos y los enums se impongan — no solo se soliciten — recurre a json_schema. El esquema viaja con la petición, y con strict: true (donde la familia del modelo lo admite) el output queda restringido a coincidir. Los dos campos que hacen que «strict» signifique algo de verdad son additionalProperties: false (sin claves sorpresa) y un array required completo (sin claves que falten).
# response_format=json_schema: el esquema se envía al modelo y el output queda
# restringido a él. Pon strict=True para la garantía firme donde se admita.
# additionalProperties=False + claves required es lo que da sentido a "strict".
schema = {
"name": "support_ticket",
"strict": True,
"schema": {
"type": "object",
"additionalProperties": False,
"properties": {
"category": {"type": "string", "enum": ["billing", "bug", "feature", "other"]},
"priority": {"type": "string", "enum": ["low", "medium", "high"]},
"summary": {"type": "string"},
"needs_human": {"type": "boolean"},
},
"required": ["category", "priority", "summary", "needs_human"],
},
}
resp = client.chat.completions.create(
model="claude-sonnet-4-6",
response_format={"type": "json_schema", "json_schema": schema},
messages=[
{"role": "system", "content": "Extract the support ticket fields."},
{"role": "user", "content": "I was charged twice this month, please refund."},
],
)
ticket = json.loads(resp.choices[0].message.content)
# Con json_schema estricto, category es demostrablemente uno de los cuatro enums —
# no hace falta un "if category not in (...)" defensivo en el camino feliz.Aquí va la advertencia honesta, y es importante: el soporte estricto de json_schema varía según la familia del modelo. Algunos modelos respetan cada restricción, incluido el additionalProperties: false anidado; otros tratan el esquema como una pista fuerte en lugar de una gramática firme, sobre todo en objetos profundamente anidados, uniones (anyOf) o estructuras recursivas. Brievio pasa tu response_format directamente al modelo genuino de primera mano, así que lo que obtienes es el comportamiento real del modelo real — no una emulación descafeinada. Pero eso también significa que los límites nativos del modelo son tus límites. La regla práctica: solicita el esquema y luego valida igualmente. Nunca dejes que «strict» te disuada del paso de validación.
Modo JSON vs. llamadas a herramientas: cuándo usar cuál
Las llamadas a herramientas/funciones también devuelven JSON estructurado — los argumentos vuelven como una cadena JSON asociada al nombre de una función. Entonces, ¿cuál usas? La distinción es de intención, no de formato:
- Usa el modo JSON cuando el JSON es la respuesta. Estás extrayendo campos, clasificando, resumiendo en un registro o generando un objeto de configuración. Hay exactamente una forma que quieres de vuelta, siempre.
response_formatencaja mejor — un solo output, sin la ceremonia de la llamada a función, sin la fontanería detool_choice. - Usa las llamadas a herramientas cuando el modelo elige una acción. Puede que llame a
get_weather, o asearch_db, o responda en prosa — y quieres que el modelo decida cuál, posiblemente llamando a varias. Las llamadas a funciones están hechas para el despacho: muchas formas candidatas, y el modelo elige. Forzar eso a través de un único objeto JSON resulta incómodo. - La zona gris: una sola llamada forzada a herramienta como salida estructurada. Poner
tool_choicepara exigir una función concreta es una forma consagrada de obtener salida estructurada en modelos anteriores ajson_schema. Sigue funcionando y es un buen plan de respaldo. Pero si un modelo admitejson_schema, ese camino es más directo y deja menos cosas que razonar.
Si tu carga de trabajo va de verdad sobre acciones y despacho en lugar de un registro fijo, la mecánica y las trampas entre modelos viven en uso de herramientas en Claude y Gemini. Para todo lo que sea «dame este objeto», quédate con response_format.
Diseñar un esquema que el modelo pueda acertar de verdad
Un esquema es un prompt. La forma en que lo modelas cambia la tasa de acierto tanto como la elección del modelo. Algunas reglas que dan frutos en ambas familias:
- Prefiere lo plano a lo profundamente anidado. Tres niveles de anidamiento con objetos opcionales es donde el modo estricto se tambalea. Si puedes aplanar
address.cityacity, hazlo, y luego reorganiza después de validar. - Usa enums para cualquier conjunto cerrado.
"priority": {"enum": ["low","medium","high"]}es mucho más fiable que unastringlibre que pospocesas. Los enums son la característica de esquema con mayor apalancamiento. - Nombra los campos como lo haría una persona.
needs_human_reviewgana anh_flag. El modelo rellena los campos bien nombrados con más precisión porque el nombre lleva la instrucción. - Pon una
descriptionen los campos ambiguos. Una línea por campo dentro del esquema resuelve la mayoría de los casos de «el modelo lo adivinó mal» sin reescribir el prompt. - Haz explícita la opcionalidad. Si un campo puede faltar, déjalo fuera de
requiredo modélalo como una unión anulable — no esperes que el modelo se invente un valor centinela. Decide quién es dueño del caso «ausente», tú o el modelo. - Evita números libres cuando sirva un tipo acotado. Una valoración entera de 1 a 5 como enum de
[1,2,3,4,5]rinde mejor que «un número del 1 al 5» en el prompt.
Validar y reparar: la capa que se publica
La mayor mejora de fiabilidad es tratar el output del modelo como la petición de un cliente no confiable: parséalo, valídalo contra tu esquema real y, ante un fallo, reintenta una vez con el error de vuelta. Un modelo de Pydantic (o zod, o validación de JSON Schema en tu lenguaje) atrapa los casos que se cuelan incluso en el modo estricto — y el turno de reparación arregla la mayoría, porque el modelo es bueno corrigiendo un error que le señalas directamente.
# Nunca confíes en un output que no hayas validado. Trata al modelo como un
# cliente no confiable: parsea -> valida contra tu esquema -> reintenta una vez
# con el error de vuelta. Esta es la capa que convierte "suele funcionar" en "se publica".
from pydantic import BaseModel, ValidationError
from typing import Literal
class Ticket(BaseModel):
category: Literal["billing", "bug", "feature", "other"]
priority: Literal["low", "medium", "high"]
summary: str
needs_human: bool
def extract(text: str, model: str, retries: int = 1) -> Ticket:
messages = [
{"role": "system", "content": "Extract the support ticket fields as JSON."},
{"role": "user", "content": text},
]
for attempt in range(retries + 1):
resp = client.chat.completions.create(
model=model,
response_format={"type": "json_object"},
messages=messages,
)
raw = resp.choices[0].message.content
try:
return Ticket.model_validate_json(raw) # parsea + valida en un solo paso
except ValidationError as e:
if attempt == retries:
raise
# Turno de reparación: muéstrale al modelo exactamente qué estuvo mal.
messages += [
{"role": "assistant", "content": raw},
{"role": "user", "content": f"That failed validation: {e}. Re-emit valid JSON only."},
]
raise RuntimeError("unreachable")Fíjate en lo que hace el turno de reparación: le muestra al modelo su propio output erróneo y el error de validación exacto, y luego le pide que lo vuelva a emitir. Un reintento resuelve la inmensa mayoría de los fallos; si aun así falla, quieres enterarte, así que deja que lance la excepción. No entres en un bucle infinito quemando tokens — acota los reintentos, registra el payload en crudo y alerta sobre los fallos duros. Un fallo de validación persistente suele significar que el esquema pide algo que la entrada no puede sostener, no que el modelo esté roto.
Dos notas de producción. Primera, pon un max_tokens generoso: un JSON que se trunca a mitad de objeto es JSON inválido, y un tope de tokens demasiado ajustado es una causa principal de fallos de parseo en registros grandes. Segunda, mantén la temperature baja (0 a 0.3) para extracción y clasificación — quieres que la misma entrada produzca el mismo registro, y la creatividad no es una virtud cuando estás rellenando una struct.
Una sola forma, ambas familias de modelos
Cada fragmento de arriba corre contra Claude Sonnet 4.6 y Gemini 2.5 Flash cambiando una sola cadena — el campo model. Ese es el sentido de enrutar la salida estructurada a través de Brievio: el contrato de response_format con forma de OpenAI es idéntico, así que puedes hacer A/B con un modelo más barato en una tarea de extracción, o recurrir a otra familia durante un incidente, sin reescribir tu parseo ni tu validación. La petición que envías es la petición que recibe el modelo genuino de primera mano — aquí está exactamente qué coincide y a qué prestar atención cuando dependes de la compatibilidad con OpenAI.
Un flujo de trabajo práctico: prototipa con json_schema estricto en Sonnet, confirma que tu validador pasa en un conjunto reservado y luego prueba el mismo esquema en Flash. Si el modelo más barato supera tu tasa de validación, has recortado coste sin un solo cambio de código — y como Brievio reporta conteos de tokens honestos y factura a cero las llamadas fallidas 4xx/5xx, tus reintentos y turnos de reparación no esconden una sorpresa de medición. Compara los modelos en la página de modelos, y el contrato completo de petición/respuesta del chat vive en la documentación de chat.
La conclusión
Recurre a json_object cuando la forma sea simple y seas dueño del prompt; recurre a json_schema con strict: true, additionalProperties: false y un array required completo cuando quieras imponer la estructura; recurre a las llamadas a herramientas cuando el modelo esté eligiendo una acción en lugar de producir un registro fijo. Elijas lo que elijas, diseña el esquema plano y cargado de enums, y luego siempre parsea-valida-repara — porque el soporte estricto varía según la familia del modelo, y la capa de validación es la diferencia entre una función de salida estructurada que hace demo y una que sobrevive al tráfico real. El mismo código, el mismo contrato, el modelo genuino — en Claude y Gemini, tras un único base URL.