Agentes de voz en tiempo real: la latencia es el producto
Un cliente me pidió una demo de un asistente telefónico para reservas. La monté en un fin de semana: transcripción con Whisper, un LLM para razonar, un TTS decente para responder. En mis pruebas funcionaba. Entendía todo, respondía bien, la voz sonaba natural.
Se la enseñé por teléfono a alguien de su equipo. Preguntó por una mesa para el sábado. Silencio. Y entonces hizo lo que hace cualquiera cuando no le contestan: dijo "¿hola?".
Ese "¿hola?" me enseñó lo único que de verdad importa al construir agentes de voz en tiempo real: el agente no había fallado. Había tardado 1,2 segundos en empezar a hablar. Y 1,2 segundos, en una conversación, no se perciben como lentitud. Se perciben como que la llamada se ha cortado.
La latencia no es una métrica que optimizas al final. Es el producto.
Qué es un agente de voz en tiempo real
Un agente de voz en tiempo real es un sistema que escucha al usuario, decide qué responder y contesta hablando, todo dentro de la misma conversación y sin pasar por turnos escritos. Se diferencia de un chatbot en que el canal es audio continuo, y de un asistente de voz clásico en que quien decide es un LLM con acceso a herramientas, no un árbol de intenciones.
Se construye de dos maneras: encadenando reconocimiento de voz (STT), modelo de lenguaje (LLM) y síntesis de voz (TTS), o con un modelo speech-to-speech nativo que recibe audio y emite audio. La diferencia entre las dos no está en lo bonita que suena la voz. Está en la latencia, y en cuánta información sobrevive por el camino.
El listón lo puso la evolución, no OpenAI
Hay un dato que explica por qué 1,2 segundos rompen la ilusión. Un estudio publicado en PNAS por Stivers y su equipo midió los huecos entre turnos en conversaciones reales de diez lenguas, de comunidades indígenas tradicionales a lenguas mayoritarias.
El resultado fue incómodamente uniforme. La moda del hueco entre que uno termina de preguntar y el otro empieza a responder cae entre 0 y +200 ms en todas las lenguas estudiadas, con una moda global de 0 ms y una mediana entre lenguas de +100 ms. Las diferencias entre unas lenguas y otras caben en un margen de 250 ms respecto a la media global.
Piensa en lo que implica. Nadie escucha una pregunta, la entiende, formula la respuesta y la articula en 200 milisegundos. No da tiempo. Lo que hacemos los humanos es predecir: empezamos a construir la respuesta mucho antes de que el otro termine.
Tu agente no predice. Espera. Y ese hueco es exactamente donde la gente cuelga.
Este es el presupuesto de latencia con el que trabajo cuando monto uno de estos. No son cifras de ningún benchmark: es la repartición que me funciona para no pasarme del umbral.
| Tramo | Objetivo | Qué lo dispara |
|---|---|---|
| Detección de fin de turno (VAD) | 200-500 ms | silence_duration_ms alto, eagerness: "low" |
| Primer token del modelo | 200-400 ms | contexto largo sin caché, modelo grande |
| Primer audio de salida | 100-300 ms | TTS sin streaming |
| Red y jitter | 50-200 ms | WebSocket en móvil, sin buffer adaptativo |
| Total percibido | por debajo de 800 ms | por encima de 1 s el usuario dice "¿hola?" |
Por qué el pipeline encadenado suena mal en un agente de voz
El diseño por defecto del developer que viene de chatbots de texto es el pipeline encadenado: STT → LLM → TTS. Es el que se entiende y el que puedes montar con tres proveedores que ya conoces.
Tiene dos problemas de fondo que no se arreglan cambiando de proveedor.
El primero es que las latencias suman. Cada etapa tiene su propio tiempo hasta el primer byte, y ninguna empieza hasta que la anterior le da algo con lo que trabajar. Puedes tener un STT rápido, un LLM rápido y un TTS rápido, y aun así un conjunto lento, porque mides cada pieza aislada mientras el usuario mide la cadena entera.
Se mitiga con streaming agresivo — transcripciones parciales al modelo, tokens al TTS según salen — pero eso convierte tu pipeline en un problema de concurrencia, no en tres llamadas HTTP.
El segundo es peor, porque no se arregla con ingeniería. El texto intermedio es un cuello de botella de información.
Cuando alguien dice "no…" con duda, alargando la vocal, y cuando dice "No." tajante, tu STT te entrega la misma palabra. Has tirado a la basura el tono, la vacilación, el énfasis, la prisa, el enfado. Toda la información que un humano usa para decidir cómo responder desaparece antes de que el modelo la vea. Después le pides al TTS que reconstruya emoción a partir de texto plano, y suena a lo que es: una reconstrucción.
Esto no mata el pipeline encadenado. Lo coloca en su sitio: es la arquitectura correcta para transcribir audio por lotes y extraer datos — una nota de voz, una reunión grabada, un mensaje asíncrono. Ahí Whisper sigue siendo excelente, y lo conté en detalle en captura y procesamiento de audio en Angular usando Whisper.
Lo que no puedes es coger la arquitectura de transcripción por lotes y esperar que sostenga una conversación.
Qué cambia con speech-to-speech en un agente de voz
El modelo speech-to-speech elimina el viaje de ida y vuelta por el texto. Entra audio, sale audio, y el mismo modelo que entiende es el que habla.
La consecuencia técnica es que la prosodia sobrevive. El modelo no lee una transcripción de lo que dijiste: procesa el audio, con sus pausas y su entonación, igual que procesa cualquier otra modalidad. Si te resulta raro que un modelo "escuche", la idea general está en qué es un modelo multimodal: el audio también acaba siendo tokens.
La consecuencia práctica es que desaparecen dos saltos de red y dos colas de espera.
A cambio pierdes flexibilidad. No puedes cambiar la voz sin cambiar de modelo, no tienes un punto intermedio en texto donde auditar lo que el agente está a punto de decir, y estás casado con un proveedor. Es un trade-off real, y hay negocios regulados donde ese checkpoint de texto no es negociable.
Pero si tu producto es una conversación, la conversación gana.
Los cuatro problemas de un agente de voz en producción
Aquí es donde la demo del fin de semana se separa del producto.
Barge-in: el agente tiene que callarse
Cuando el usuario empieza a hablar mientras el agente habla, el agente debe cortar. Inmediatamente. Un agente que termina su frase mientras tú le hablas encima resulta insoportable en tres segundos.
El detalle sucio es que cancelar la respuesta no basta. Al cortar, el servidor cree que ha dicho todo el audio que generó, pero el usuario solo ha oído lo que le dio tiempo a reproducirse. Si no corriges esa divergencia, el historial de la conversación contiene frases que el usuario nunca escuchó, y el modelo seguirá razonando como si las hubiera dicho.
Por eso existe conversation.item.truncate: le dices al servidor en qué milisegundo exacto se quedó el audio realmente reproducido. Y ese milisegundo tiene que venir de tu reproductor, no de los bytes que has recibido del socket. Recibir no es reproducir. Este es el bug número uno de todo el que monta esto por primera vez.
Detección de turno: saber cuándo ha terminado de hablar
El VAD por volumen — silencio durante X milisegundos, luego respondo — funciona bien hasta que alguien dice "quiero reservar para… espera que mire… el sábado". Ese "espera que mire" incluye una pausa, y tu agente entra a saco a media frase.
La Realtime API de OpenAI ofrece dos modos: server_vad, que trocea por silencio con parámetros de threshold, prefix_padding_ms y silence_duration_ms, y semantic_vad, que usa un modelo para estimar la probabilidad de que hayas terminado y ajusta el timeout de forma dinámica, controlado con eagerness (low, auto/medium, high).
La regla práctica: eagerness: "low" cuando el usuario tiene que pensar o recordar datos, high cuando son respuestas cortas de sí/no. Esto se nota más en el resultado final que cambiar de modelo.
Transporte: WebSocket no es la respuesta por defecto
La documentación oficial es clara. WebRTC para clientes de navegador y móvil que capturan o reproducen audio directamente. WebSocket cuando tu servidor ya recibe audio crudo de un pipeline de medios o de una centralita. SIP para telefonía.
La razón de fondo es que WebRTC trae de fábrica lo que en WebSocket tendrías que construir tú: control de jitter, adaptación a pérdida de paquetes, cancelación de eco. En una wifi doméstica no notarás la diferencia. En 4G en movimiento, sí — y es justo el escenario donde vive un agente de voz de verdad.
Desde el navegador, el flujo recomendado pasa por tu backend: el cliente genera la oferta SDP, tu servidor la reenvía a https://api.openai.com/v1/realtime/calls con la configuración de sesión y devuelve la respuesta. Nunca expongas la API key en el cliente; para eso están los secretos efímeros de /v1/realtime/client_secrets.
Coste: el audio se paga como audio
Muchos proyectos se caen justo aquí, después de la demo. Precios oficiales consultados el 31 de julio de 2026, por millón de tokens:
| Modelo | Audio entrada | Audio entrada cacheada | Audio salida |
|---|---|---|---|
gpt-realtime-2.1 |
$32,00 | $0,40 | $64,00 |
gpt-realtime-2.1-mini |
$10,00 | $0,30 | $20,00 |
gemini-3.1-flash-live-preview |
$3,00 (~$0,005/min) | — | $12,00 (~$0,018/min) |
Lo interesante no es la cifra absoluta, es la comparación dentro del mismo modelo. gpt-realtime-2.1 cobra $4,00 por millón de tokens de texto de entrada y $32,00 por millón de tokens de audio de entrada. Ocho veces más por el mismo millón de tokens, solo por la modalidad.
El otro número que deberías tener tatuado: el audio de entrada cacheado cuesta $0,40 frente a $32,00. Ochenta veces menos. En una conversación larga, donde cada turno reenvía todo el contexto anterior, el caché deja de ser una optimización y pasa a ser la diferencia entre un producto viable y uno que no. Si nunca has mirado de cerca cómo se cuentan los tokens y por qué el idioma influye en la factura, escribí sobre ello en tokens en español y el coste del tokenizador.
El modelo mini cuesta exactamente 3,2 veces menos en audio no cacheado, tanto de entrada como de salida. Para el 80% de los agentes de voz reales — reservas, soporte de primer nivel, cualificación de leads — es más que suficiente.
El código: una sesión realtime de verdad
Esto es Node/Bun con TypeScript, a nivel de protocolo. Lo pongo así a propósito: los SDKs te esconden justo las partes que necesitas entender.
import WebSocket from "ws";
import { z } from "zod";
// Tus dos piezas: el reproductor de audio del cliente y tu API de negocio.
declare const player: {
enqueue(chunk: Buffer): void;
stop(): void;
playedMs(): number; // ms realmente reproducidos al usuario
};
declare const reservas: { buscar(q: unknown): Promise<unknown> };
const MODEL = "gpt-realtime-2.1";
const ws = new WebSocket(`wss://api.openai.com/v1/realtime?model=${MODEL}`, {
headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
});
const send = (event: Record<string, unknown>) => ws.send(JSON.stringify(event));
ws.on("open", () => {
send({
type: "session.update",
session: {
type: "realtime",
instructions:
"Eres el asistente de reservas de Bar Nostrum. Frases cortas. " +
"Nunca leas listas largas en voz alta: ofrece dos opciones como mucho.",
audio: {
input: {
// OJO: format es un objeto, no la cadena "pcm16" de la beta antigua.
format: { type: "audio/pcm", rate: 24000 },
turn_detection: {
type: "semantic_vad",
eagerness: "low", // el usuario tiene que recordar fechas
interrupt_response: true, // permite barge-in
},
},
output: { voice: "cedar" },
},
tools: [
{
type: "function",
name: "buscar_disponibilidad",
description: "Consulta mesas libres para una fecha y nº de comensales.",
parameters: {
type: "object",
properties: {
fecha: { type: "string", description: "Formato YYYY-MM-DD" },
comensales: { type: "integer" },
},
required: ["fecha", "comensales"],
},
},
],
},
});
});
Ahora el bucle de eventos. Fíjate en player.playedMs(): ese es el punto donde casi todo el mundo se equivoca.
let currentItemId: string | null = null;
ws.on("message", async (raw) => {
const event = JSON.parse(raw.toString());
switch (event.type) {
// El usuario habla mientras el agente habla → barge-in
case "input_audio_buffer.speech_started": {
if (!currentItemId) break;
// Defensivo: con interrupt_response:true el servidor ya cancela solo.
// Lo dejo por si algún día bajas a server_vad sin interrupción.
send({ type: "response.cancel" });
send({
type: "conversation.item.truncate",
item_id: currentItemId,
content_index: 0,
// CLAVE: lo que el usuario ha OÍDO, no lo que has recibido del socket.
audio_end_ms: player.playedMs(),
});
player.stop();
currentItemId = null;
break;
}
case "response.output_audio.delta": {
currentItemId = event.item_id;
player.enqueue(Buffer.from(event.delta, "base64"));
break;
}
case "response.function_call_arguments.done": {
await handleToolCall(event.call_id, event.arguments);
break;
}
case "response.done": {
currentItemId = null;
break;
}
}
});
Y la tool call, que es donde esto deja de ser una demo:
const BuscarDisponibilidad = z.object({
fecha: z.iso.date(),
comensales: z.number().int().min(1).max(20),
});
async function handleToolCall(callId: string, rawArgs: string) {
// 1. Valida SIEMPRE. El modelo alucina argumentos igual que alucina texto.
const parsed = BuscarDisponibilidad.safeParse(JSON.parse(rawArgs));
if (!parsed.success) {
return sendToolOutput(callId, { error: "argumentos_invalidos" });
}
// 2. Si la tool tarda, habla antes de que el silencio se note.
const filler = setTimeout(() => {
send({
type: "response.create",
response: {
// CLAVE: out-of-band. Sin esto, cuando la tool resuelva lanzarás un
// segundo response.create mientras el relleno sigue generando audio.
conversation: "none",
instructions:
"Di una frase muy corta indicando que estás consultando. " +
"No inventes el resultado.",
},
});
}, 400);
const mesas = await reservas.buscar(parsed.data);
clearTimeout(filler);
sendToolOutput(callId, mesas);
}
function sendToolOutput(callId: string, output: unknown) {
send({
type: "conversation.item.create",
item: {
type: "function_call_output",
call_id: callId,
output: JSON.stringify(output),
},
});
send({ type: "response.create" });
}
Ese setTimeout de 400 ms resuelve el silencio incómodo. Mientras la tool consulta tu API, el agente dice "déjame que lo mire" y luego encadena con el resultado. Es lo que hace un humano al teléfono, y sin ello cualquier consulta que tarde más de medio segundo se siente como una caída.
La validación con Zod no es opcional. Los argumentos de una tool call son texto generado por un modelo, y tratarlos como datos de confianza es la misma clase de error que confiar en el body de una petición HTTP sin validarlo. Si quieres afinar esa capa, la trabajo a fondo en el curso de Zod para validación en TypeScript.
Si prefieres abstracción, el SDK de agentes de OpenAI para JavaScript expone RealtimeAgent, RealtimeSession y un helper backgroundResult para tools largas. El diseño de herramientas es el mismo que expliqué en el servidor de herramientas con el Agent SDK de Anthropic.
Cuándo NO usar un agente de voz
La voz se está metiendo en sitios donde estorba.
No uses voz cuando el usuario tenga que dar datos exactos y largos: un IBAN, un email, una referencia alfanumérica. Deletrear "bezael arroba dominicode punto com" por teléfono es peor experiencia que un input de texto, siempre.
No uses voz cuando el resultado sea una lista para comparar. La voz es un canal estrictamente serial: no puedes escanear, ni volver atrás, ni ver tres precios a la vez.
No uses voz cuando el error sea caro e irreversible. Confirmar una transferencia bancaria con un VAD que puede cortarte a media frase es pedir un incidente.
La voz gana en tres escenarios: cuando las manos están ocupadas, cuando el input es abierto y ambiguo — describir un problema es más rápido hablando que rellenando diez campos — y cuando el canal ya es voz porque el usuario ha llamado por teléfono.
Si tu caso no encaja en ninguno, un formulario gana. Y un formulario cuesta cero dólares por millón de tokens.
Qué hacer hoy
Ese número —los milisegundos hasta que el agente empieza a hablar— es tu producto. El prompt, la voz y las funcionalidades solo importan si está por debajo del umbral en el que la gente deja de sentir que habla con una máquina rota. Así lo mido:
- Monta una sesión realtime mínima con una sola tool enganchada.
- Instrumenta dos marcas de tiempo:
input_audio_buffer.speech_stoppedy el primerresponse.output_audio.delta. - Mide 20 turnos con tu red real y tu dispositivo real. En localhost todo va rápido.
- Si la mediana pasa de 800 ms, ataca en este orden: caché de audio de entrada,
gpt-realtime-2.1-mini, WebRTC en lugar de WebSocket, y frase de relleno para las tools lentas. - Vuelve a medir. Repite hasta que nadie diga "¿hola?".
Si quieres construir esto con método en lugar de a golpe de prueba y error, es el enfoque que enseño en Construye con IA: de la idea al producto con Claude Code. Y si prefieres no pelearte con esto en solitario, en Dominicode Labs es donde desmontamos este tipo de arquitecturas entre developers que están construyendo cosas parecidas.
Preguntas frecuentes
¿Merece la pena todavía el pipeline STT → LLM → TTS?
Sí, pero para otro problema. Es la arquitectura correcta cuando necesitas un checkpoint de texto auditable, cuando quieres cambiar de proveedor de voz sin tocar el resto, o cuando el trabajo es transcripción y análisis por lotes en lugar de conversación. Para una conversación en tiempo real, un modelo speech-to-speech nativo te ahorra saltos de red y conserva la información prosódica que el texto intermedio destruye.
¿WebSocket o WebRTC para un agente de voz en tiempo real?
La documentación de OpenAI recomienda WebRTC para clientes de navegador y móvil que capturan o reproducen audio directamente, WebSocket cuando tu servidor ya recibe audio crudo de un pipeline de medios, y SIP para telefonía. La diferencia se nota sobre todo en redes móviles: WebRTC trae control de jitter, adaptación a pérdida de paquetes y cancelación de eco de serie.
¿Cuánto cuesta un agente de voz con IA?
A 31 de julio de 2026, gpt-realtime-2.1 cuesta $32 por millón de tokens de audio de entrada y $64 de salida; el modelo mini, $10 y $20. Gemini publica precios por minuto para gemini-3.1-flash-live-preview: unos $0,005 por minuto de audio de entrada y $0,018 de salida. La clave del coste real no es el precio de lista sino el caché: en el modelo grande, el audio de entrada cacheado baja de $32 a $0,40 por millón.
¿Cómo evito que el agente corte al usuario cuando hace una pausa para pensar?
Usa detección de turno semántica en lugar de VAD por volumen. semantic_vad estima con un modelo la probabilidad de que el usuario haya terminado, en lugar de contar milisegundos de silencio, y ajusta el timeout dinámicamente. Con eagerness: "low" das más margen, que es lo que quieres cuando el usuario tiene que recordar una fecha o un dato.
¿Puedo usar Claude para un agente de voz en tiempo real?
No como modelo speech-to-speech nativo: los modelos de Anthropic aceptan texto e imagen como entrada y devuelven texto, no audio. Ahora bien, como cerebro de un pipeline encadenado con STT y TTS externos funcionan muy bien — Fable 5 para el trabajo más exigente, Opus 5 y Sonnet 5 para el grueso, y Haiku 4.5 cuando la latencia manda por encima de todo. Es la opción sólida si necesitas ese checkpoint de texto intermedio o si ya tienes tus herramientas montadas sobre el Agent SDK de Anthropic. Para latencia conversacional pura, hoy la vía nativa pasa por los modelos realtime de OpenAI o Gemini Live.
¿Cuánta latencia es aceptable en un agente de voz?
Por debajo de 800 ms desde que el usuario deja de hablar hasta que el agente emite el primer audio. A partir de un segundo la gente deja de percibirlo como lentitud y empieza a percibirlo como una llamada cortada. El estudio de Stivers y su equipo en PNAS muestra que en las diez lenguas analizadas los hablantes minimizan el silencio entre turnos, con una moda global de 0 ms y una mediana de +100 ms.
¿Se puede conectar un agente de voz a una centralita o a un número de teléfono?
Sí. Para telefonía la vía es SIP: la Realtime API acepta conexiones SIP además de WebRTC y WebSocket, y proveedores como Twilio o Telnyx enrutan el número hacia tu sesión. Si tu pipeline de medios ya te entrega audio crudo en el servidor, WebSocket también sirve. WebRTC está pensado para clientes de navegador y móvil.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
