# 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.

- URL: https://blog.laozhang.ai/es/posts/structured-outputs-vs-function-calling
- Published: 2026-07-28
- Updated: 2026-10-07
- Author: LaoZhang AI Team (https://blog.laozhang.ai/es/about)
- Topic: Guías de API
- Tags: OpenAI API, Structured Outputs, Function Calling, Responses API, JSON Schema

---
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](https://developers.openai.com/api/docs/guides/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](https://blog.laozhang.ai/es/posts/chat-completions-to-responses-api-function-calling-migration) 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](https://developers.openai.com/api/docs/guides/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](https://developers.openai.com/api/docs/guides/function-calling#strict-mode).

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.

```json
{
  "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.

![Fichas ficticias que distinguen un libro disponible, una consulta sin resultados y la falta del identificador](https://blog.laozhang.ai/posts/es/structured-outputs-vs-function-calling/img/valores-desconocidos.webp)

Structured Outputs admite un [subconjunto de JSON Schema](https://developers.openai.com/api/docs/guides/structured-outputs#supported-schemas): 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](https://developers.openai.com/api/docs/guides/function-calling#strict-mode).

## 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.

```python
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](https://blog.laozhang.ai/es/posts/openai-api-key-free-trial) explica la preparación del proyecto backend.

```python
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:

```json
{"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](https://developers.openai.com/api/docs/guides/function-calling#handling-function-calls) 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](https://developers.openai.com/api/docs/guides/structured-outputs#refusals).

| 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](https://developers.openai.com/api/docs/guides/function-calling#tool-choice).

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:

1. **JSON:** se puede parsear.
2. **Esquema:** contiene las claves, tipos y valores permitidos.
3. **Significado:** los valores corresponden al libro, usuario y estado correctos.
4. **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.

![Reserva ficticia en la que un timeout lleva a consultar el estado de la operación antes de elaborar la respuesta final](https://blog.laozhang.ai/posts/es/structured-outputs-vs-function-calling/img/confirmar-reserva.webp)

## 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](https://developers.openai.com/api/docs/guides/structured-outputs#json-mode).

### ¿`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](https://developers.openai.com/api/docs/guides/structured-outputs#tips-and-best-practices).

### ¿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.

## Fuentes

Páginas externas que cita esta guía, en el orden en que aparecen. Última actualización: 2026-10-07.

- [guía oficial de Structured Outputs](https://developers.openai.com/api/docs/guides/structured-outputs) (developers.openai.com)
- [Referencia de function calling](https://developers.openai.com/api/docs/guides/function-calling) (developers.openai.com)
