Saltar al contenido principal

Migrar function calling de Chat Completions a Responses API

13 min de lecturaGuía de API

Cambiar chat.completions.create por responses.create no basta: hay que migrar el esquema, la salida tipada, la ejecución en la aplicación, el estado y las pruebas.

Migración del bucle de herramientas desde Chat Completions hasta function_call_output de Responses API unido mediante call_id

Migrar las llamadas a funciones de Chat Completions a Responses API no consiste solo en cambiar el endpoint. La aplicación debe recibir cada function_call, validar y ejecutar la función permitida, devolver un function_call_output con el mismo call_id y solicitar otra Response hasta obtener la respuesta final.

Esta guía parte de una integración que ya usa chat.completions.create y funciones personalizadas. No trata la antigua API Completions ni migra Assistants. La primera acción segura es probar una función no destructiva en staging, comparar su traza con la ruta actual y parar si cambia el resultado de negocio o el número de ejecuciones.

El contrato completo es:

definir → recibir function_call → validar y ejecutar en la aplicación → devolver function_call_output con el mismo call_id → verificar la respuesta final

La guía oficial de migración recomienda Responses API para proyectos nuevos, mientras Chat Completions sigue disponible. Por tanto, conviene migrar detrás de un flag reversible y demostrar paridad antes de mover tráfico real.

Qué cambia entre las dos APIs

ResponsabilidadChat CompletionsResponses APIPrueba necesaria
Método del clienteclient.chat.completions.create()client.responses.create()Las dos rutas aceptan la misma entrada
Contenido de entradamessagesinputSe conservan intención e instrucciones
Salida del modelochoices[0].messageElementos tipados en response.output[]Se recorre por type
Definición de funciónObjeto anidado en functionCampos planos de la herramientaSe revalida el JSON Schema
Solicitud de herramientamessage.tool_calls[]type: "function_call"Se procesan todos los elementos
Resultadorole: "tool" y tool_call_idtype: "function_call_output" y call_idEl ID coincide exactamente
EstadoReenvío de messagesHistorial manual, previous_response_id o ConversationSe elige un único owner del estado
Streamingchoices[].deltaEventos tipados de ResponsesSe rehacen parser, mocks y tests

Hay dos límites que evitan la mayoría de los errores:

  1. OpenAI no ejecuta una función personalizada. El modelo propone nombre y argumentos; tu backend valida, autoriza y ejecuta.
  2. response.output es heterogéneo. Puede contener texto, razonamiento u otras llamadas. No se debe asumir que output[0] es la función ni usar output_text para controlar el bucle.

La documentación oficial de llamada a funciones es la referencia para nombres de elementos, correlación por call_id, modo estricto, paralelismo y eventos de streaming.

Fija una línea base antes de migrar

Ejecuta una petición conocida en la ruta Chat Completions con una herramienta inocua. Por ejemplo, consulta el pedido ORD-1042 y registra:

  • entrada normalizada e instrucciones;
  • nombre y versión del esquema;
  • argumentos después de validarlos;
  • número de ejecuciones y clave de idempotencia;
  • resultado de la función;
  • respuesta final para el usuario;
  • latencia, tokens y clase de error.

El bucle antiguo suele tener esta forma abreviada:

js
const completion = await client.chat.completions.create({ model, messages, tools: [{ type: "function", function: { name: "get_order_status", description: "Consulta el estado actual de un pedido", parameters: { type: "object", properties: { order_id: { type: "string" } }, required: ["order_id"], additionalProperties: false }, strict: true } }] }); const calls = completion.choices[0].message.tool_calls ?? []; // Tras ejecutar cada función, se añadía: // { role: "tool", tool_call_id: call.id, content: JSON.stringify(result) }

Conserva temporalmente esta ruta. El criterio de éxito no es «la nueva API devolvió 200», sino «la misma entrada produjo un único efecto autorizado y una respuesta final equivalente».

Bucle completo de Responses API en JavaScript

Este ejemplo consulta un pedido en memoria, por lo que no modifica datos. Recorre toda la salida tipada, valida los argumentos, usa una lista permitida, evita repetir un call_id, conserva los elementos necesarios para el estado manual y sigue llamando a Responses hasta llegar al texto final.

Instala el SDK actual de OpenAI, define OPENAI_API_KEY y OPENAI_MODEL, y guarda el código como bucle-responses.mjs.

js
import OpenAI from "openai"; const client = new OpenAI(); const model = process.env.OPENAI_MODEL; if (!model) { throw new Error("Define OPENAI_MODEL con un modelo que admita funciones."); } const orders = new Map([ ["ORD-1042", { status: "preparado", eta: "2026-07-30" }] ]); async function getOrderStatus({ order_id }) { return orders.get(order_id) ?? { status: "no_encontrado" }; } const handlers = Object.freeze({ get_order_status: getOrderStatus }); const tools = [{ type: "function", name: "get_order_status", description: "Devuelve el estado actual y la fecha estimada de un pedido.", strict: true, parameters: { type: "object", properties: { order_id: { type: "string", description: "Identificador con formato ORD-1234" } }, required: ["order_id"], additionalProperties: false } }]; function parseAndValidate(call) { if (!Object.hasOwn(handlers, call.name)) { throw new Error(`Función no permitida: ${call.name}`); } const args = JSON.parse(call.arguments); if ( typeof args.order_id !== "string" || !/^ORD-\d{4}$/.test(args.order_id) ) { throw new Error("order_id debe cumplir el formato ORD-1234"); } return args; } // Para mutaciones reales, sustituye este Map por almacenamiento duradero. const executionLedger = new Map(); async function executeOnce(call) { if (executionLedger.has(call.call_id)) { return executionLedger.get(call.call_id); } let result; try { const args = parseAndValidate(call); result = await handlers[call.name](args); } catch (error) { result = { error: "tool_execution_rejected", message: error instanceof Error ? error.message : "Error desconocido" }; } const outputItem = { type: "function_call_output", call_id: call.call_id, output: JSON.stringify(result) }; executionLedger.set(call.call_id, outputItem); return outputItem; } const instructions = [ "Usa get_order_status cuando el usuario proporcione un pedido.", "No inventes el estado.", "Si no existe, explícalo sin afirmar que el pedido es válido." ].join(" "); const input = [{ role: "user", content: "¿Cuál es el estado del pedido ORD-1042?" }]; let finalText = ""; for (let round = 0; round < 5; round += 1) { const response = await client.responses.create({ model, instructions, // se reenvían en cada ronda manual input, tools, parallel_tool_calls: false, // opción conservadora para empezar store: false }); // Conserva todos los elementos, incluido razonamiento relevante. input.push(...response.output); const calls = response.output.filter( (item) => item.type === "function_call" ); if (calls.length === 0) { finalText = response.output_text; if (!finalText) { throw new Error("La Response terminó sin función ni texto final."); } break; } const toolOutputs = await Promise.all(calls.map(executeOnce)); input.push(...toolOutputs); } if (!finalText) { throw new Error("El bucle superó el límite de cinco rondas."); } console.log(finalText);

Ejecución:

bash
npm install openai OPENAI_MODEL="modelo-compatible-con-funciones" node bucle-responses.mjs

El código es reproducible, pero este artículo no presenta una salida de OpenAI como si fuera un resultado observado. En staging debes ejecutarlo con una cuenta autorizada y un modelo actual, guardar la traza tipada y compararla con la línea base.

Por qué están esos controles

  • strict: true, required y additionalProperties: false hacen explícito el contrato. El comportamiento exacto de strict y el subconjunto de JSON Schema pueden cambiar; compruébalos en la documentación actual.
  • Object.hasOwn(handlers, call.name) evita convertir un nombre generado por el modelo en ejecución arbitraria.
  • call.call_id se copia sin modificar. Es la unión entre la solicitud de función y el resultado que devuelve la aplicación.
  • Promise.all(calls.map(...)) no pierde llamadas. Aun así, el primer despliegue desactiva el paralelismo para simplificar la traza.
  • input.push(...response.output) conserva todos los elementos relevantes, no solo texto.
  • El límite de rondas detiene bucles sin fin.
  • Un error de validación vuelve como resultado estructurado; no provoca una repetición silenciosa de un efecto.

Para reembolsos, emails, cambios de pedido o pagos, el call_id no basta como idempotencia de negocio. Guarda en una base duradera una clave como refund:{order_id}:{amount}:{workflow_id}, confirma el cambio y reutiliza el resultado confirmado en un retry. El Map del ejemplo solo protege un proceso.

Paralelismo: procesa todas las llamadas o desactívalo

Responses puede devolver varios elementos function_call. No unas resultado y solicitud por posición; la correlación correcta usa el call_id original.

Para la primera migración, parallel_tool_calls: false reduce variables. Cuando lo habilites:

  • recopila todos los elementos cuyo type sea function_call;
  • valida cada llamada de forma independiente;
  • limita la concurrencia hacia servicios internos;
  • ejecuta lecturas independientes en paralelo y serializa escrituras que compitan;
  • devuelve exactamente un resultado por llamada aceptada;
  • conserva cada call_id;
  • espera a completar el lote antes de pedir la siguiente Response.

Una prueba negativa útil inyecta dos llamadas y un elemento que no sea función. Solo pasa si ambas llamadas se ejecutan una vez, los dos resultados conservan el ID correcto y el tercer elemento nunca llega al dispatcher.

Elige una estrategia de estado

La guía de estado de conversación documenta tres rutas. Ninguna es universalmente mejor.

EstrategiaQué envía la siguiente peticiónCuándo encajaRiesgo principal
Historial manualInput anterior, elementos relevantes de salida y resultadosNecesitas portabilidad, recorte explícito o store: falseTu código debe conservar orden y elementos requeridos
previous_response_idEntrada nueva más el ID anteriorQuieres payload pequeño y aceptas estado encadenadoLas instructions de nivel superior no se heredan; hay que reenviarlas
ConversationIdentificador de conversación persistenteVarios procesos necesitan estado duradero compartidoRetención, permisos, borrado y recuperación pasan a ser decisiones de arquitectura

No combines previous_response_id y Conversation en la misma petición. Antes de elegir estado alojado, revisa los requisitos de retención de tu organización: Responses se almacena por defecto salvo store: false, y el input anterior de una cadena sigue contando como tokens de entrada facturados. Son detalles operativos volátiles y deben confirmarse en la documentación oficial al desplegar.

El historial manual ofrece la mejor observabilidad durante la primera migración. Registra tipos e IDs, no datos sensibles. Después de demostrar paridad, cambia de estrategia solo si sus límites de recuperación y retención encajan.

Streaming: sustituye el reducer de eventos

Un consumidor de Chat Completions suele concatenar choices[].delta.content. Ese código no entiende los eventos tipados de Responses.

Para argumentos de funciones, procesa los eventos actuales indicados por OpenAI:

  • response.output_item.added para registrar el elemento;
  • response.function_call_arguments.delta para acumular JSON parcial;
  • response.function_call_arguments.done para cerrar los argumentos;
  • eventos de finalización y error para cerrar la traza.

Nunca ejecutes una función al recibir un delta. Acumula por identidad del elemento o llamada, espera a .done, parsea el JSON completo y pasa la llamada por el mismo validador, allowlist, control de idempotencia y correlación por call_id que la ruta no streaming.

Los fixtures deben cortar JSON en lugares incómodos: dentro de una cadena, después de una barra invertida y entre caracteres UTF-8. Ver texto completo en pantalla no demuestra que los argumentos hayan terminado.

Fallos observables y corrección

SíntomaRuptura probablePrimera comprobaciónCorrección segura
No aparece ninguna llamadaHerramienta ausente, schema incompatible o input insuficienteTipos de salida y nombres enviadosRevisa input y definición; no ejecutes nada por tu cuenta
La petición rechaza el schemaForma anidada antigua o error de strictJSON exacto enviadoAplana la función y declara los requisitos
La función corre, pero no hay respuesta finalEl bucle se detuvo tras ejecutarSegunda llamada a responses.createAñade el output y continúa
Error de llamada no encontradacall_id incorrecto o ausenteIDs de solicitud y resultadoCopia el ID exacto
El efecto ocurre dos vecesUn retry repite la mutaciónLedger duraderoDevuelve el resultado confirmado
Falta una llamada de un loteSolo se leyó output[0]Número de items tipadosProcesa el lote completo
Se pierde contexto o políticaEstrategia de estado inconsistenteInput e instrucciones siguientesConserva items y reenvía instrucciones
Hay texto en streaming, pero falla la herramientaSe reutilizó el parser de deltas de ChatNombres de eventos y argumentos finalesImplementa un reducer tipado

Separa en observabilidad el transporte, el modelo, la herramienta y el resultado de negocio. Un 200 OK sin resultado correlacionado es un fallo de aplicación.

Checklist de aceptación en staging

No publiques por una revisión de código o un único happy path:

  • La ruta Chat y la ruta Responses reciben la misma entrada normalizada, instrucciones, versión de schema y datos seguros.
  • Se registran por tipo todos los elementos de response.output; nada presupone que el primero sea texto o función.
  • Los argumentos válidos solo ejecutan un handler permitido.
  • JSON inválido, herramienta desconocida e ID incorrecto no provocan una llamada.
  • Cada function_call aceptado tiene exactamente un function_call_output con el call_id original.
  • Un fixture de dos llamadas demuestra que no se pierde ninguna; la configuración declara el paralelismo.
  • Los retries no repiten una mutación, incluso después de reiniciar el proceso.
  • El estado elegido funciona durante dos turnos e incluye persistencia de instrucciones y recuperación.
  • Streaming no ejecuta antes de response.function_call_arguments.done.
  • Mocks y snapshots usan elementos tipados, no supuestos sobre choices[0].
  • La respuesta final existe, se basa en el resultado y mantiene el efecto de negocio de la línea base.
  • Los logs excluyen secretos y payloads sensibles, pero conservan response ID, call ID, herramienta, versión, duración y clase de resultado.
  • Un feature flag permite volver a Chat Completions sin repetir un efecto ya confirmado.

Detén el despliegue ante un call_id sin pareja, una mutación duplicada, una llamada paralela perdida, una regresión de política de estado o un resultado de negocio distinto. Corrige esa capa, repite las pruebas negativas y amplía tráfico de forma gradual.

Fuentes y límites de proveedor

Usa la documentación actual de OpenAI como owner del contrato:

Azure OpenAI y los gateways «compatibles con OpenAI» pueden cambiar endpoint, autenticación, nombre de despliegue, estado, retención, streaming o herramientas. Prueba el bucle exacto de cada proveedor: una forma HTTP compatible o un 200 no demuestra semántica equivalente.

Si todavía falla la petición básica o la autenticación, resuélvelo aparte con la guía de clave de OpenAI API. Si aún decides entre invocar una función o pedir datos ajustados a un esquema, consulta Structured Outputs frente a function calling antes de migrar el bucle.

Tu siguiente paso es concreto: ejecuta la consulta inocua del pedido en staging, guarda las trazas antigua y nueva, confirma una sola ejecución y una respuesta final, y activa Responses para una fracción pequeña de tráfico detrás de un flag reversible.

#OpenAI API#Responses API#Chat Completions#Llamada a funciones#JavaScript
Share: