Structured Outputs vs. function calling: qué usar en OpenAI
Structured Outputs fija la forma del JSON final; function calling permite pedir trabajo a tu aplicación. Combínalos si necesitas consultar datos externos y entregar después un objeto estable a la interfaz.
En esta página

Usa Structured Outputs con text.format si la siguiente pantalla o proceso necesita un JSON con campos definidos y los datos ya están en el contexto. Usa function calling si el modelo debe pedir una consulta o una acción a tu aplicación. Combina ambos cuando ese trabajo externo tenga que acabar en un objeto tipado para otro consumidor.
La diferencia decisiva es quién hace el trabajo: un function_call es una petición del modelo; tu aplicación ejecuta la función. Un JSON que cumple el esquema puede contener un dato equivocado. Tampoco prueba que se haya enviado un correo, reservado un libro o realizado un reembolso.
| Necesidad | Qué elegir | Qué comprobar después |
|---|---|---|
| Extraer una referencia del texto proporcionado | text.format | Que el valor proceda del texto, además de cumplir el esquema |
| Consultar las existencias actuales de un libro | Function calling | Que la aplicación consulte el catálogo permitido y devuelva su resultado |
| Consultar existencias y alimentar una tarjeta de disponibilidad | Function calling + text.format | Que el objeto final coincida con el resultado de la consulta |
| Dar una explicación que solo leerá una persona | Texto normal | Que la respuesta sea útil; no hace falta añadir un esquema por rutina |
Structured Outputs también se aplica a los argumentos de una función
No son dos garantías de JSON enfrentadas. OpenAI permite usar Structured Outputs para la respuesta final y, mediante strict: true, para los argumentos de las funciones. Lo que cambia es el destinatario: la interfaz recibe la respuesta final; el código de tu aplicación recibe la petición de herramienta. Así lo distingue la guía oficial de Structured Outputs.
En Responses API, el formato final se configura en text.format. La herramienta se define en tools, con type, name, parameters y strict al mismo nivel. En Chat Completions, la respuesta estructurada usa response_format y la función tiene otra estructura. No mezcles los cuerpos de ambas API. Si estás cambiando una integración existente, la guía para migrar el bucle de function calling a Responses desarrolla esa tarea.
Comprueba también que el modelo admita la función que vas a usar. Por ejemplo, la documentación actual de function calling exige Responses para las llamadas a herramientas de GPT-6 Astra y GPT-6.1 Sol; esto no significa que todas las peticiones de texto a todos los modelos necesiten la misma ruta. La elección del formato y la compatibilidad del modelo son decisiones distintas. Referencia de function calling.
Diseña un esquema que permita decir «no lo sé»
required exige que una clave esté presente; no garantiza que el dato exista. En modo estricto, todos los campos de cada objeto deben figurar en required, y cada objeto debe tener additionalProperties: false. Para representar un dato ausente puedes permitir null. Estas son las condiciones del modo estricto.
Este esquema describe una tarjeta de disponibilidad. El identificador puede faltar; disponibilidad puede ser desconocida; estado distingue una consulta terminada de una entrada insuficiente o un error. Son decisiones de esta aplicación, no estados oficiales de OpenAI.
{
"type": "object",
"properties": {
"libro_id": { "type": ["string", "null"] },
"estado": {
"type": "string",
"enum": ["consultado", "no_encontrado", "sin_datos", "error"]
},
"disponibilidad": {
"type": ["string", "null"],
"enum": ["disponible", "agotado", null]
}
},
"required": ["libro_id", "estado", "disponibilidad"],
"additionalProperties": false
}Si utilizas a la vez un tipo anulable y enum, incluye null en el enum. Permitirlo solo en type no basta: el valor debe cumplir ambas restricciones. Y una tarjeta con estado: "consultado" y disponibilidad: null seguirá pasando este esquema; una regla de negocio debe rechazar esa combinación.

Structured Outputs admite un subconjunto de JSON Schema: la raíz debe ser un objeto, no un anyOf de raíz. No admite, entre otras construcciones, allOf, not ni if/then/else. Algunas restricciones de cadenas, números y arrays admitidas por modelos base tienen limitaciones adicionales en modelos ajustados. Conviene mantener el esquema pequeño y llevar las relaciones entre campos al código de negocio.
Declara strict: true si lo necesitas. Omitir strict no garantiza un contrato estricto: Responses intenta normalizar el esquema y puede recurrir a function calling no estricto si no es compatible; en ese caso la herramienta de respuesta muestra strict: false. Chat Completions es no estricto por defecto. Un esquema incompatible enviado explícitamente con strict: true produce un error; quitarlo sin revisar el consumidor cambia la garantía. Comportamiento documentado.
Solo salida final: no añadas una consulta que no necesitas
Si el usuario ya aporta todos los datos, usa una petición con el esquema final y sin herramientas. Este fragmento construye el cuerpo de una petición a Responses; modelo debe ser un identificador compatible y esquema el objeto anterior. No ejecuta una llamada.
cuerpo = {
"model": modelo,
"input": [{
"role": "user",
"content": "Según esta ficha: LIB-204 está agotado. "
"Devuelve la tarjeta de disponibilidad. "
"No consultes otros datos ni inventes valores ausentes."
}],
"text": {"format": {
"type": "json_schema",
"name": "tarjeta_libro",
"strict": True,
"schema": esquema
}}
}Esto estructura lo que dice la ficha; no comprueba si sigue siendo cierto. Si la pantalla promete existencias actuales, necesitas consultar el sistema que las mantiene. Por eso el mismo esquema puede ser adecuado para dos tareas, mientras que la fuente de los valores cambia.
Ejemplo completo: ejecuta la consulta y devuelve la tarjeta final
El siguiente ejemplo permite reproducir el flujo sin credenciales y sin conexión. Usa un catálogo ficticio en memoria y un transporte simulado. Construye dos cuerpos de petición con la forma de Responses y demuestra las decisiones de la aplicación; no demuestra que OpenAI acepte el esquema ni que un modelo produzca esas respuestas.
El caso es de solo lectura: consultar LIB-204 y presentar sus existencias. La identidad y la lista de libros permitidos las aporta el servidor, nunca los argumentos del modelo. Para conectarlo más adelante, sustituye transporte_simulado por un adaptador autorizado que envíe los cuerpos a Responses y devuelva objetos con estos campos. La guía de clave API y primera llamada explica la preparación del proyecto backend.
import json
ESQUEMA = {
"type": "object",
"properties": {
"libro_id": {"type": ["string", "null"]},
"estado": {"type": "string", "enum": [
"consultado", "no_encontrado", "sin_datos", "error"
]},
"disponibilidad": {
"type": ["string", "null"],
"enum": ["disponible", "agotado", None]
}
},
"required": ["libro_id", "estado", "disponibilidad"],
"additionalProperties": False
}
HERRAMIENTA = {
"type": "function",
"name": "consultar_existencias",
"description": "Consulta un libro del catálogo permitido. No reserva.",
"strict": True,
"parameters": {
"type": "object",
"properties": {"libro_id": {"type": "string"}},
"required": ["libro_id"],
"additionalProperties": False
}
}
# Datos inventados; no representan usuarios ni un catálogo externo.
CATALOGO = {"LIB-204": "disponible", "LIB-205": "agotado"}
PERMITIDOS = {"lector_demo": {"LIB-204", "LIB-205", "LIB-999"}}
def comprobar_respuesta(respuesta):
if respuesta.get("status") != "completed":
raise ValueError("Respuesta no terminada: " + str({
"status": respuesta.get("status"),
"detalle": respuesta.get("incomplete_details"),
"error": respuesta.get("error")
}))
for item in respuesta.get("output", []):
if item.get("type") == "message":
for contenido in item.get("content", []):
if contenido.get("type") == "refusal":
raise ValueError("Rechazo: " + contenido["refusal"])
def consultar(llamada, lector, solicitado):
if llamada.get("name") != "consultar_existencias":
raise ValueError("Función no permitida")
argumentos = json.loads(llamada["arguments"])
if (not isinstance(argumentos, dict)
or set(argumentos) != {"libro_id"}
or not isinstance(argumentos["libro_id"], str)):
raise ValueError("Argumentos inválidos")
libro = argumentos["libro_id"]
if libro != solicitado or libro not in PERMITIDOS.get(lector, set()):
raise ValueError("El libro no pertenece a esta consulta autorizada")
# Aquí se ejecuta la consulta; la llamada del modelo no la ejecutó.
disponible = CATALOGO.get(libro)
return {
"libro_id": libro,
"estado": "no_encontrado" if disponible is None else "consultado",
"disponibilidad": disponible
}
def validar_tarjeta(tarjeta, esperado):
# Validador específico para este esquema, no para todo JSON Schema.
if not isinstance(tarjeta, dict) or set(tarjeta) != set(ESQUEMA["required"]):
raise ValueError("Claves incorrectas")
if tarjeta["libro_id"] is not None and not isinstance(tarjeta["libro_id"], str):
raise ValueError("Tipo de libro_id incorrecto")
if tarjeta["estado"] not in ESQUEMA["properties"]["estado"]["enum"]:
raise ValueError("Estado incorrecto")
if tarjeta["disponibilidad"] not in ESQUEMA["properties"]["disponibilidad"]["enum"]:
raise ValueError("Disponibilidad incorrecta")
if tarjeta != esperado:
raise ValueError("La tarjeta no coincide con el resultado de la consulta")
return tarjeta
def obtener_tarjeta(enviar, modelo, lector, libro):
if libro is None:
return {"libro_id": None, "estado": "sin_datos", "disponibilidad": None}
if not isinstance(libro, str) or libro not in PERMITIDOS.get(lector, set()):
raise ValueError("Entrada no autorizada")
primera = enviar({
"model": modelo,
"input": [{"role": "user", "content": "Consulta " + libro}],
"tools": [HERRAMIENTA],
"tool_choice": {"type": "function", "name": "consultar_existencias"},
"parallel_tool_calls": False
})
comprobar_respuesta(primera)
llamadas = [x for x in primera.get("output", [])
if x.get("type") == "function_call"]
if len(llamadas) != 1:
raise ValueError("Se esperaba una única consulta")
llamada = llamadas[0]
if not isinstance(llamada.get("call_id"), str) or not llamada["call_id"]:
raise ValueError("Falta call_id")
try:
esperado = consultar(llamada, lector, libro)
except (ValueError, KeyError, TypeError):
esperado = {"libro_id": libro, "estado": "error", "disponibilidad": None}
final = enviar({
"model": modelo,
# El servidor conserva la respuesta anterior, incluidos otros items.
"previous_response_id": primera["id"],
"instructions": "Devuelve la tarjeta según el resultado de la herramienta. "
"No cambies sus valores ni inventes existencias.",
"input": [{
"type": "function_call_output",
"call_id": llamada["call_id"],
"output": json.dumps(esperado)
}],
"tools": [HERRAMIENTA],
"tool_choice": "none",
"text": {"format": {
"type": "json_schema", "name": "tarjeta_libro",
"strict": True, "schema": ESQUEMA
}}
})
comprobar_respuesta(final)
if any(x.get("type") == "function_call" for x in final.get("output", [])):
raise ValueError("La fase final no debe solicitar herramientas")
textos = [c["text"] for x in final.get("output", [])
if x.get("type") == "message"
for c in x.get("content", []) if c.get("type") == "output_text"]
if len(textos) != 1:
raise ValueError("Falta un único objeto final")
return validar_tarjeta(json.loads(textos[0]), esperado)
def transporte_simulado(cuerpo):
if "previous_response_id" not in cuerpo:
libro = cuerpo["input"][0]["content"].split()[-1]
return {
"id": "resp_ficticia", "status": "completed",
"output": [{
"type": "function_call", "id": "fc_ficticia",
"call_id": "call_ficticia", "name": "consultar_existencias",
"arguments": json.dumps({"libro_id": libro})
}]
}
resultado = cuerpo["input"][0]
assert cuerpo["previous_response_id"] == "resp_ficticia"
assert resultado["call_id"] == "call_ficticia"
return {"status": "completed", "output": [{
"type": "message", "content": [{
"type": "output_text", "text": resultado["output"]
}]
}]}
if __name__ == "__main__":
print(json.dumps(obtener_tarjeta(
transporte_simulado, "modelo_ficticio", "lector_demo", "LIB-204"
), ensure_ascii=False))El resultado de esta simulación es:
{"libro_id": "LIB-204", "estado": "consultado", "disponibilidad": "disponible"}La primera petición fuerza una consulta porque la aplicación ya tiene un identificador autorizado y la tarea necesita datos del catálogo. La segunda desactiva herramientas y pide el objeto final. Son dos contratos separados: parameters limita los argumentos; text.format.schema limita la tarjeta.
La continuación usa previous_response_id, que enlaza con la respuesta conservada por el servidor. Si gestionas el historial por tu cuenta, conserva los elementos necesarios de toda response.output, incluidos los de razonamiento asociados a llamadas; no guardes únicamente output[0] o el texto. El SDK de JavaScript actual muestra toResponseInputItems para adaptar esos elementos al input. La guía oficial del bucle recoge ambas formas de continuación.
call_id relaciona la llamada con su resultado. El id del elemento identifica el elemento de respuesta; no sustituye al call_id. Ninguno de los dos es una autorización de usuario ni un comprobante del sistema externo.
En este caso, la tarjeta final reproduce exactamente tres valores ya calculados. Podrías devolverlos directamente desde la aplicación y ahorrar la segunda petición. El híbrido se justifica cuando el consumidor necesita una elaboración del modelo a partir del resultado —por ejemplo, una explicación estructurada de varias consultas—; aun entonces, valida los campos verificables contra las fuentes y evalúa por separado el texto libre.
Rechazo, salida incompleta y errores: no los conviertas en éxito
OpenAI documenta que una respuesta con status: "completed" puede contener un elemento refusal. También puede haber una respuesta incomplete con un motivo como max_output_tokens o content_filter. Comprueba el estado y los elementos de contenido antes de parsear el objeto final. Tratamiento de casos especiales.
| Situación | Comportamiento de la aplicación del ejemplo |
|---|---|
| Falta el libro | Devuelve sin_datos con valores nulos, sin inventar argumentos ni consultar |
| El catálogo no encuentra un libro permitido | Devuelve no_encontrado; no afirma que esté agotado |
| La herramienta pide otro libro o una función no autorizada | No ejecuta esa petición; devuelve un resultado de error |
| La respuesta está incompleta o contiene un rechazo | Detiene el flujo; no intenta completar JSON cortado |
| El modelo devuelve «agotado» cuando el catálogo dice «disponible» | Rechaza la tarjeta aunque el enum y los tipos sean válidos |
| No llega la llamada esperada, o llegan varias | Detiene este flujo de consulta única |
El código tiene un límite natural de dos peticiones; no reintenta sin fin. Para una aplicación que permita más herramientas, recorre todas las llamadas, relaciona cada resultado con su call_id y fija un límite de turnos. tool_choice: "auto" permite cero, una o varias llamadas; required exige una o más; forzar una función selecciona esa función. parallel_tool_calls: false limita a cero o una y, por sí solo, no obliga a llamar. Ninguno de esos ajustes concede permisos o prueba el éxito. Además, la documentación señala una excepción al modo estricto cuando un modelo ajustado hace varias llamadas en paralelo. Selección y paralelismo.
La lógica del ejemplo se comprobó localmente con respuestas sintéticas: consulta encontrada, no encontrada y sin dato; herramienta incorrecta, argumentos inválidos y libro no permitido; ausencia o multiplicidad de llamadas; rechazo, respuesta incompleta y objeto final semánticamente incorrecto. Eso prueba esas ramas del código, no el comportamiento del proveedor, el modelo, el SDK o un catálogo externo.
Si la función modifica datos, comprueba el efecto en su sistema
Consultar existencias no reserva un ejemplar. Para reservarlo necesitarías otra función, permisos apropiados y una confirmación del servicio de reservas. Mantén separadas estas cuatro comprobaciones:
- JSON: se puede parsear.
- Esquema: contiene las claves, tipos y valores permitidos.
- Significado: los valores corresponden al libro, usuario y estado correctos.
- Efecto: el sistema que ejecuta la acción confirma qué ocurrió.
En una reserva, registra un identificador de operación y usa una clave de idempotencia propia para deduplicar reintentos. Un timeout deja el resultado desconocido: consulta la operación antes de repetirla. Si el sistema confirmó la reserva pero falla la respuesta final del modelo, no vuelvas a reservar para obtener otro JSON. Puedes mostrar la confirmación del backend o repetir solo la elaboración de la respuesta.
Estas son medidas de diseño de la aplicación. El esquema de argumentos no aporta por sí mismo autorización, ejecución única ni recuperación de un efecto incierto. La frase «reserva completada» generada por el modelo tampoco sustituye al comprobante de la reserva.

Preguntas frecuentes
¿Puedo usar Structured Outputs para elegir y ejecutar acciones?
Puedes diseñar un objeto que indique una acción y crear tu propio despachador. Tu código tendrá que validar, autorizar, ejecutar y devolver el resultado. El objeto no se ejecuta solo. Function calling ya proporciona una forma de solicitar herramientas y enlazar sus resultados; utiliza un despachador propio cuando su complejidad adicional responda a una necesidad concreta.
¿JSON mode sirve como sustituto de Structured Outputs?
JSON mode proporciona JSON válido, salvo los casos especiales documentados, pero no obliga a cumplir tu esquema. En Responses se activa con text.format: {"type": "json_object"} y requiere una instrucción explícita de producir JSON; la documentación también exige que «JSON» aparezca en el contexto. Si necesitas campos precisos, usa Structured Outputs en un modelo compatible o añade validación y un tratamiento de errores propio. Diferencias oficiales.
¿strict: true evita que el modelo invente información?
No. Restringe la forma de una salida compatible, pero no convierte sus valores en hechos. Diseña estados de información ausente y compara los datos verificables con su fuente. Tampoco elimina las ramas de rechazo o respuesta incompleta. Limitaciones y recomendaciones oficiales.
¿Siempre merece la pena combinar function calling y una salida tipada?
Combínalos si necesitas trabajo externo y una elaboración final del modelo para un consumidor tipado. Si el backend ya tiene el objeto final que vas a mostrar, devuélvelo directamente. Si todos los datos ya están en la petición, basta con text.format. La garantía útil es la que protege al siguiente consumidor sin añadir trabajo que no necesita.
Fuentes2
Páginas externas que cita esta guía, en el orden en que aparecen. Última actualización: 7 oct 2026.
Fuentes2
Páginas externas que cita esta guía, en el orden en que aparecen. Última actualización: 7 oct 2026.





