Un cliente hace una pregunta a un agente de voz de IA y espera. El agente ya tiene una respuesta, está a medio camino de generar una respuesta larga de varios párrafos, pero el cliente no escucha nada durante cinco segundos. Cuando llega la respuesta, el cliente se siente frustrado, confundido y ya no presta atención

.

Las integraciones de texto a voz crean exactamente esa brecha. Una respuesta de una frase puede generarse en 600 milisegundos, pero la respuesta completa tarda cinco segundos y la integración espera a que llegue toda la respuesta antes de que comience la síntesis

.

Esta publicación muestra cómo cerrar esa brecha. Transmitimos cada oración a un modelo de conversión de texto a voz (TTS) de Kokoro-82M alojado en Amazon SageMaker AI tan pronto como un agente la produce, de modo que la síntesis comienza con la primera oración en lugar de con la respuesta final. Para devolver ese audio a un navegador también se necesita su propia solución, por lo que también nos ocupamos de los gastos que implica hacerlo y de lo que cuesta cada uno en la práctica.

Descripción general de la solución

Esta solución cierra la brecha al transmitir la síntesis a lo largo de la generación en vez de esperar a que termine. A medida que el agente produce cada frase, esa frase se transmite inmediatamente a un modelo de conversión de texto a voz de Kokoro-82M alojado, de modo que la reproducción del audio puede comenzar antes de que el agente termine de pronunciar su respuesta completa. Kokoro-82M solo contiene 82 millones de parámetros, lo suficientemente pequeños como para ejecutarse de forma económica en una sola instancia de GPU de nivel intermedio. Esto reduce la latencia y los costes de inferencia para un agente de voz que gestiona tráfico real

.

Kokoro-82M se ejecuta en un contenedor personalizado en un punto final de SageMaker AI en tiempo real, en una instancia de GPU ml.g5.xlarge. El contenedor expone dos rutas: POST /invocations, que transmite una respuesta para una entrada completa, y WS /invocations-bidirectional-stream, que mantiene abierta una conexión para

varios mensajes en ambas direcciones.

El siguiente diagrama muestra la ruta de solicitud del cliente al modelo:

Client connects to an Amazon SageMaker AI real-time endpoint on ml.g5.xlarge over InvokeEndpointWithBidirectionalStream. A Starlette ASGI app exposing GET /ping, POST /invocations, and WS /invocations-bidirectional-stream routes the WebSocket to a KPipeline running Kokoro-82M, which returns audio and control frames on the same connection.

Figura 1: Ruta de solicitud desde el cliente al punto final en tiempo real de SageMaker AI a través de una transmisión bidireccional

Requisitos previos

Antes de empezar, necesita:

  • Una cuenta de AWS con permisos para crear puntos de enlace de IA de SageMaker; AgentCore Runtime, una capacidad de Amazon Bedrock AgentCore; funciones de AWS Identity and Access Management (IAM); y un repositorio de Amazon Elastic Container Registry (Amazon ECR), que incluye como mínimo SageMaker:createEndpoint, SageMaker:createEndpointConfig, Sagemaker:createModel, SageMaker:invokeEndpointWithBidirectionalStream y Bedrock: invoque el modelo con acciones de ResponseStream.
  • El contenedor de muestras fija una PyTorch antigua con un aviso conocido (PYSEC-2026-2286, corregido en Torch 2.10.0). Revisa y actualiza el pin antes de cualquier uso que no sea una prueba de valor.
  • El kit de desarrollo en la nube de AWS (AWS CDK) v2 se instala y arranca en su cuenta de destino y en la región de AWS.
  • Un punto final de IA en tiempo real de SageMaker implementado desde un contenedor que declara la etiqueta com.amazonaws.sagemaker.capabilities.bidirectional-streaming=true Docker.
  • Python 3.12 o posterior, uv y Docker para crear la imagen del contenedor (el cliente HTTP/2 subyacente solo incluye ruedas prediseñadas para Python 3.12 y versiones posteriores).
  • El SDK de AWS para Python (Boto3) compatible con invoke_endpoint_with_bidirectional_stream.
  • Familiaridad con WebSockets y Starlette o con un marco ASGI comparable.
  • El código de implementación del repositorio de GitHub al que se hace referencia en esta publicación.

Tutorial

El tutorial primero implementa el punto final y el agente y, a continuación, analiza los cambios del lado del cliente que hacen que la transmisión incremental funcione de principio a fin.

Implemente el punto final y el agente

El punto final de Kokoro-82M y el tiempo de ejecución del agente se implementan mediante dos herramientas de CLI, cada una con sus propias pilas de AWS CDK:

uv run speech-infra deploy kokoro-82m
uv run agent-infra deploy --bedrock-model-id

speech-infra deploy aprovisiona el punto final Kokoro-82M. Esto incluye la base y las pilas de caché de modelos que crean las funciones de IAM subyacentes y sincronizan los pesos del modelo en Amazon Simple Storage Service (Amazon S3). A continuación, la pila de puntos finales crea la imagen del contenedor y crea el modelo de IA, la configuración de puntos finales y el punto final de SageMaker. agent-infra deploy aprovisiona el tiempo de ejecución de Amazon Bedrock AgentCore que aloja el agente Strands, que llama al punto final que acaba de implementar, por lo que debe implementar primero el punto final

.

Paso 1: Gestione el tipo de marco WebSocket que SageMaker AI realmente envía

El transporte bidireccional que utiliza SageMaker AI envía cada parte del mensaje de un cliente a su contenedor en forma de marco WebSocket binario. Una forma habitual de escribir un controlador WebSocket de Starlette es llamar a receive_text (), que lee el mensaje ["text"] y genera KeyError si esa clave no está presente. Como todos los mensajes que envía este cliente llegan en formato binario, ese controlador falla

en todas las solicitudes. Por lo

general, el error se diagnostica erróneamente. Un controlador de excepciones genérico que reenvía str (e) al cliente convierte KeyError ('texto') en un marco de error en el que se lee 'texto', que parece que falta un campo, no se trata de un problema de encuadre. El propio terminal informa a InService en todo momento, ya que acepta las conexiones y responde a todos los mensajes con un error cada vez

.

Acepte cualquiera de los tipos de marco de forma explícita en lugar de asumir que aparecerá uno:

desde starlette.websockets importar WebSocket, WebSocketDisconnect

definición asíncrona _receive_message (websocket: WebSocket) -> str:
 mensaje = await websocket.receive ()
 si el mensaje ["type"] == «websocket.disconnect»:
 levante WebSocketDisconnect (message.get («code», 1000))
 payload = message.get («texto»)
 si la carga útil es Ninguna:
 payload = message.get («bytes», b "») .decode («utf-8")
 carga útil
de retorno

Esto decodifica los bytes como UTF-8 cuando no hay ningún campo de texto, en lugar de asumir un solo tipo de marco. Pruébelo enviando un marco binario, ya que este modo de error produce un marco de error bien formado y un estado de punto final correcto, no un

bloqueo evidente.

Paso 2: Mantén la sesión abierta entre los mensajes

Una vez que el contenedor termine de sintetizar el audio de un mensaje, el instinto es cerrar la sesión, la forma en que termina un ciclo HTTP. Eso está mal aquí. Tras enviar el audio de un mensaje y un marco de control synthesis_complete, el bucle contenedor debería volver a esperar a recibir () el siguiente mensaje de la misma sesión aún abierta. Cierra la conexión solo cuando el cliente envíe un mensaje explícito de {"type»: «

close"}.

El siguiente diagrama muestra una sesión con dos frases en secuencia y que se cierra solo al final:

One bidirectional session sending two sentences, each followed by synthesis_start, PCM audio, and synthesis_complete frames, ending with an explicit close message.

Figura 2: Una sesión bidireccional con dos oraciones en secuencia, que se cierra solo al final

De esta forma, puedes enviar varias frases, una tras otra, en una sesión persistente, sin coste de reconexión entre ellas. Cerrar con impaciencia tras una respuesta forzaría a establecer una nueva conexión por frase, lo que eliminaría la mayor parte del beneficio de la latencia

.

Paso 3: Transmite frases a medida que el agente las produce

Una

vez establecido el comportamiento de la sesión, la solución para el problema de latencia por parte del cliente es una clase que normaliza el texto que llega en dos formas: una cadena completa conocida de antemano o una secuencia de símbolos en tiempo real. En cualquier caso, produce el mismo resultado: frases completas, listas para enviarse en cuanto cada una

esté completa.
importar re

_SENTENCE_RE = re.compile (r "(? <= [.!?]) \ s+»)


clase IncrementalSentenceChunker:
 def __init__ (self) -> Ninguna:
 uno mismo. _buffer = «»

 def feed (self, fragmento: str) -> lista [str]:
 uno mismo. _buffer += fragmento
 parts = _sentence_re.split (self). _buffer)
 si len (parts) == 1:
 devolución []
 uno mismo. _buffer = partes [-1]
 devuelve [s.strip () para s en partes [:-1] si s.strip ()]

 def flush (self) -> str | Ninguno:
 resto = uno mismo. _buffer.strip ()
 uno mismo. _buffer = «»
 devuelve el resto o Ninguno

Llama a feed () una vez por fragmento de texto a medida que llegue. Devuelve las frases recién completadas, reteniendo el texto final. Llama a flush () una vez, al final de la entrada, para eliminar el texto parcial que quede, incluso sin la puntuación final. Esto evita que una secuencia que termina antes de que llegue un punto elimine la última oración.

No se trata de síntesis concurrente. Cada controlador bidireccional es un bucle estrictamente secuencial: recibe un mensaje, sintetiza su audio, envía ese audio, envía synthesis_complete y, a continuación, recibe el siguiente mensaje. El audio de la oración dos no se genera mientras la oración uno sigue transmitiendo. Lo que compra el envío incremental es más limitado: la síntesis de la primera oración comienza en el instante en que existe, en lugar de esperar a recibir la respuesta completa. En el caso de una respuesta de varias frases con un tiempo de generación real entre la primera y la última frase, eso es todo lo que gana en términos de latencia

.

Lo hemos comprobado comparándolo con un terminal en tiempo real que envía una entrada de tres frases durante una sesión. Los tres fragmentos llegaron con un audio que coincidía con el texto enviado para

cada uno:
Trozo Texto Bytes de audio Duración (es)
1 «Hola a todos». 73.200 1,525
2 «Se trata de una comprobación posterior al despliegue». 108.000 2,250
3 «Tiene tres frases». 98.400 2,050

Paso 4: Elija un transporte para devolver el audio al cliente

La

transmisión gradual de texto al modelo resuelve la mitad del problema. La otra mitad consiste en enviar el audio resultante a lo que sea que lo renderice, normalmente un navegador. La mayoría de las interfaces de chat y agentes ya transmiten texto a través de eventos enviados por el servidor (SSE): son unidireccionales y son compatibles de forma nativa con la mayoría de los navegadores. El SSE es un protocolo de texto, no diseñado para transportar una carga binaria sin procesar. Un equipo que añade voz a una canalización de SSE existente recurre naturalmente al patrón que ya tiene: codificar el audio en base64 y empaquetarlo en

un evento JSON.

Medimos el coste de este enfoque en 24 clips, confirmamos con Amazon Transcribe que la calidad no difería entre las codificaciones y comparamos el MP3 con el AAC:

Candidato Bytes sin procesar 64 bytes en base Velocidad de bits
PCM sin procesar 2.396.400 3.195.200 384 kbps
MP3 48 k 308.304 411072 49,5 kbps
AAC 32 k fMP4 258.039 344,084 41,6 kbps

Base64 infla cada carga útil cerca del 33% esperado. El coste más sutil es la alineación de los marcos. Los eventos de SSE están delimitados por una línea en blanco, pero el audio que contienen está delimitado en fotogramas MP3. Los dos esquemas no se alinean necesariamente con la forma en que el transporte fragmenta los datos. En una respuesta mesurada, cuatro fotogramas MP3 lógicos llegaron en seis fragmentos de entrega, dos de los cuales terminaron a mitad del fotograma. Un consumidor que trata cada porción de entrega como si fuera una unidad completa fracasa en un tráfico normal y saludable. La solución almacena en búfer los bytes entrantes y solo actúa una vez que se encuentra un delimitador

:
búfer = b "»
para el evento en resp ["Body"]:
 si «PayloadPart» no está en el evento:
 continuar
 buffer += evento ["payloadPart"] ["Bytes"]
 mientras b»\n\n"está en el búfer:
 marco, buffer = buffer.split (b»\n\n«, 1)
 
asa (marco)

La alternativa que se usa hoy en día para la pierna orientada hacia el modelo omite por completo el encuadre en base64 y SSE. Multiplexa el audio binario sin procesar y los marcos de control JSON en la misma sesión bidireccional, ya con una entrada de texto incremental, y

los diferencia con una sola regla.
importar json


def _decode_control_frame (fragmento: bytes) -> dict | Ninguno:
 prueba:
 decodificado = json.loads (chunk.decode («utf-8"))
 excepto (UnicodeDecodeError, json.jsonDecodeError):
 devuelve Ninguno
 devuelve decodificado si es instance (decoded, dict
) o None

Un fragmento que no se decodifica como objeto JSON es el audio, que se reenvía tal como se recibe, sin ningún paso de codificación en ninguna parte de la ruta. Un caso extremo es importante: el audio PCM sin procesar puede comenzar legítimamente con el byte 0x7b, el carácter ASCII {, el mismo carácter que abre un objeto JSON. Esta regla evita clasificar mal ese audio, ya que requiere que todo el fragmento se analice como JSON válido, y no solo su primer byte, algo

que un marco PCM prácticamente nunca logrará.

Nota: Si su interfaz ya tiene una canalización SSE para texto en funcionamiento, el audio codificado en base64 dentro de un evento SSE es la opción pragmática. Cuesta aproximadamente un 33 por ciento de carga útil adicional y un paso para almacenar en búfer y volver a ensamblar, pero reutiliza la infraestructura que ya utilizas. Si estás creando la ruta de voz desde cero o la prioridad es la latencia, el enfoque unificado de binarios y JSON evita ambos costos y reutiliza

la conexión que ya contiene texto.

Paso 5: Reúne todo en un agente real

El caso más completo, de principio a fin, es un agente generador vivo que compone su respuesta ficha por ficha, sin forma de saber la cuarta oración hasta que exista la primera. Este ejemplo se ejecuta cuando un agente de Strands, respaldado por Amazon Bedrock, se implementa en el entorno de ejecución de Amazon Bedrock AgentCore y llama al punto final de Kokoro con el patrón del paso 4. En este diseño se conectan tres vías de comunicación: la que está orientada hacia el navegador y que devuelve los eventos enviados por el servidor a la persona que llama, la llamada en streaming del agente a Amazon Bedrock para generar texto y la sesión bidireccional del agente con el punto final de Kokoro para la síntesis

.

El siguiente diagrama muestra esos tres tramos de comunicación y dónde se produce la superposición entre la generación y la síntesis:

Figura 3: Los tres tramos de comunicación que conectan a la persona que llama, Amazon Bedrock y el punto final de Kokoro

La carga útil de la solicitud incluye un campo de modo, «bidi» o «por lotes», que se envía a dos rutas de código dentro de la misma función de punto de entrada. Un punto de referencia entre ellos cambia exactamente una variable: cuando comienza la síntesis. En run_bidi, dos tareas se ejecutan simultáneamente. Una agota el flujo de texto de Bedrock e introduce las frases completas en el fragmentador. El otro agota la sesión de Kokoro y emite fragmentos de audio a medida que llegan. Ambos forman una cola compartida, por lo que el consumidor muestra los eventos en el orden real de llegada: el audio de una frase temprana puede llegar a la persona que llama, mientras que Bedrock sigue generando una frase posterior. run_batch es lo contrario deliberado: espera a recibir la respuesta completa antes de realizar una llamada de síntesis,

manteniéndola como base justa.

En la otra dirección, el trayecto desde el contenedor del agente hasta la persona que llama usa base64, por una razón diferente a la descrita en el paso 4. El punto de entrada de BedrockAgentCoreApp debe generar diccionarios sencillos y serializables mediante JSON, que su SDK convierte automáticamente en datos: {json}\n\nframes. No hay forma de asignarle un marco binario sin procesar. Esto es una consecuencia del tiempo de ejecución gestionado de forma nativa de JSON, no de la opción que Step 4 midió y de la que se alejó. Tanto run_bidi como run_batch pagan el mismo coste en este tramo, por lo que la diferencia entre ambos es puramente temporal: cuando se emite

el primer fragmento de audio.

Pruebas y validación

Hemos aislado la contribución del transporte TTS a la hora de convertir el audio en primer lugar enviando el mismo bloque de texto de 267 caracteres y cuatro frases de dos maneras: como un mensaje (por lotes) y solo como la primera frase (incremental). Realizamos cinco ensayos cada uno con el criterio de valoración real

.

Como hemos observado anteriormente, enviar solo la primera frase redujo el tiempo medio hasta el primer byte de audio de 329,3 ms a 156,7 ms, lo que supone una mejora relativa del 52,4 por ciento, si se tiene en cuenta el propio transporte TTS.

A continuación, medimos el caso completo de principio a fin con un modelo de lenguaje real que producía el texto, y ejecutamos la misma línea de comandos en run_bidi y run_batch con el mismo modelo y punto final de Bedrock en vivo, de modo que la única variable es cuando comienza la síntesis.

Con la misma metodología, el tiempo que tarda el modo bidi en emitir el primer audio fue 2,4 veces más rápido que en el modo por lotes con un contenedor local, y 3,5 veces más rápido con el tiempo de ejecución de AgentCore implementado.

El siguiente gráfico muestra esa brecha en ambos entornos:

Bar chart comparing relative time to first audio for batch and bidi modes, locally and on a deployed Amazon Bedrock AgentCore runtime. Batch is normalized to 1.0x. Local bidi measures 0.42x (2.4 times faster). Deployed bidi measures 0.29x (3.5 times faster).

Figura 4: Tiempo relativo hasta el primer audio para los modos por lotes y bidi, localmente y en el tiempo de ejecución implementado

La brecha se amplía, en lugar de reducirse, en el caso de implementación una vez que se agrega un salto de red real. run_bidi absorbe ese salto dentro de la superposición que ya está aprovechando entre la generación y la síntesis. run_batch no tiene una superposición equivalente para ocultar ese costo, ya que hace una cosa a la vez, por lo que cada milisegundo adicional de sobrecarga de red recae directamente en

su espera en serie.

Para reproducir esta comparación con su propia implementación, ejecute la misma prueba bidi-vs-batch que expone el contenedor del agente:

uv run agent-infra demo --endpoint-url --prompt "»

De este modo, el mensaje pasa por ambos modos e indica la fecha y hora del primer evento de audio_chunk comparándola con agent_start para cada uno de ellos. Si, en cambio, ves errores, comprueba si aparece el síntoma KeyError: 'text' del paso 1. Un terminal que notifica a InService cuando todas las solicitudes fallan es señal de que se trata de un error, no de un problema de implementación

.

Limpiar

Para evitar cargos continuos, elimine los recursos que crea este tutorial en orden de implementación inverso, primero el agente y luego el punto final:

uv run agent-infra destroy --bedrock-model-id 
uv run speech-infra destroy kokoro-82m

agent-infra destroy elimina el recurso de ejecución de Amazon Bedrock AgentCore. speech-infra destroy elimina el modelo de IA, la configuración y el punto final de SageMaker. Tampoco vacía el bucket de Amazon S3 que almacena en caché los pesos del modelo ni elimina el repositorio de Amazon ECR que contiene la imagen del contenedor, ya que ambos se pueden compartir entre modelos. Si ya no los necesita, vacíe y elimine el bucket de Amazon S3 y elimine el repositorio de Amazon ECR manualmente, ya que un modelo almacenado en un punto de control y una imagen de contenedor seguirán generando pequeños gastos de almacenamiento hasta que se eliminen

.

Conclusión

Mostramos cómo eliminar una fuente de latencia evitable de un agente de voz, esperando la respuesta completa del agente antes de iniciar la síntesis de voz, y cómo cuantificar un segundo coste, más bajo: el audio codificado en base64 dentro del SSE infla la carga útil en aproximadamente un tercio y puede desalinearse con los límites de los fotogramas de MP3 si no se utiliza el búfer. La transmisión de cada frase a un punto final bidireccional de SageMaker AI tan pronto como un agente la produce reduce el tiempo de emisión del primer audio en más de la mitad de forma aislada, y entre 2,4 y 3,5 veces en un agente desplegado. La multiplexación del audio binario y de los fotogramas de control JSON durante esa sesión, en lugar de codificar el audio como texto, elimina la sobrecarga de tamaño y el trabajo de realineación de fotogramas

que requiere un relé SSE ingenuo.

Si va a añadir voz a un agente que ya transmite texto a través del SSE, comience con el enfoque en base64 del paso 4 y vuelva a utilizar el transporte unificado una vez que la latencia se convierta en la prioridad. Para obtener más información, consulte la documentación sobre inferencias en tiempo real de la IA de Amazon SageMaker y la documentación de Amazon Bedrock AgentCore. Si necesita ayuda para aplicar este patrón a su propia arquitectura, póngase en contacto con su equipo

de cuentas de AWS.


Acerca del autor

Joseph Cottingham

Joseph Cottingham

Joseph es ingeniero de aprendizaje automático en el Centro de innovación en inteligencia artificial generativa de AWS, donde diseña y ofrece sistemas de inteligencia artificial que permiten a las organizaciones pasar de la idea a la producción. Su experiencia técnica abarca el firmware, el software y el aprendizaje automático, y cuenta con una amplia experiencia en arquitecturas de alto rendimiento basadas en eventos que mantienen el rendimiento de las cargas de trabajo de IA a gran escala. Joseph se centra en la intersección entre la investigación de vanguardia y la realidad operativa, y ofrece soluciones que transforman materialmente las organizaciones y los sistemas en los que operan

.

¿Tiene un proyecto de software o plataforma SaaS en mente?

Hable directamente con nuestros arquitectos de software en SoftAndino. Le brindamos asesoría técnica y cotización inmediata sin compromiso.