AIsaAIsaStudio English 打开 AIsa Studio

AWS 实测:在 SageMaker AI 上用 Kokoro-82M 为语音 Agent 实现低延迟流式合成

原标题:Stream low-latency speech from AI agents using Kokoro TTS on Amazon SageMaker AI

AWS 机器学习博客介绍在 SageMaker AI 实时端点上部署 Kokoro-82M TTS 模型,将 Agent 生成的句子逐句流式送入合成,而不用等完整响应。隔离测试中该方式把首个音频字节时间从 329.3 ms 降到 156.7 ms,端到端在部署的 AgentCore runtime 上比批处理快 3.5 倍。文章还对比了 SSE 中 base64 音频约 33% 的负载膨胀与 MP3 帧对齐问题,并给出二进制音频与 JSON 控制帧复用同一双向会话的替代方案。

为什么值得看

教程给出逐句流式合成语音的完整做法和实测数据,读者可直接复用其传输方案与陷阱排查经验。

阅读原文 ↗10月23日 AI 日报

本页的摘要、推荐理由与解读出自 AIsa Studio 的 AI 风向智能体,原文版权归原作者所有。

深度解读

发生了什么

AWS 在 Machine Learning 博客发布一篇实践文章,介绍如何用部署在 Amazon SageMaker AI 上的 Kokoro-82M 文本转语音(TTS)模型,为语音 AI Agent 降低出声延迟。核心思路是:不等 Agent 生成完整回复再合成语音,而是每产出一句话就立刻送入模型合成,让第一句的音频在整段回复尚未完成时就能开始播放。

文章用一个典型场景说明问题:一句话回复约 600 毫秒即可合成,但整段多段回复需要五秒,传统集成方式要等全部文本就绪才开始合成,用户在此期间听不到任何声音。

关键细节

  • 模型与部署:Kokoro-82M 只有 8200 万参数,可在单个中端 GPU 实例(ml.g5.xlarge)上低成本运行,以自定义容器部署在 SageMaker AI 实时端点上,容器暴露 POST /invocations 和 WS /invocations-bidirectional-stream 两条路由。
  • 实现要点包括:正确处理 SageMaker 双向流实际发送的二进制 WebSocket 帧(用 receive_text() 会因缺少 text 字段抛 KeyError,且端点状态仍显示 InService,容易误判);会话在每条消息合成完后保持打开,直到客户端显式发送 close 消息;用 IncrementalSentenceChunker 按句切分流式文本。
  • 传输方案对比:对 24 个音频片段实测显示,base64 编码会使负载膨胀约 33%,且 SSE 的空行分隔与 MP3 帧边界可能错位(一次测量中 4 个 MP3 帧被拆成 6 个交付块)。替代方案是在同一双向会话上复用原始二进制音频与 JSON 控制帧,并通过“整块能否解析为合法 JSON”来区分两者,避免 PCM 音频以字节 0x7b(即 {)开头被误判。
  • 实测结果:仅发送第一句话时,TTS 传输环节的首个音频字节平均时间从 329.3 毫秒降至 156.7 毫秒,相对提升 52.4%。端到端测试中,bidi 模式的首音频时间比 batch 模式快 2.4 倍(本地容器)和 3.5 倍(部署的 AgentCore 运行时)——网络跳数增加反而拉大了差距,因为 bidi 模式能将网络开销隐藏在生成与合成的重叠中。
  • 文章还提示示例容器固定的旧版 PyTorch 存在已知漏洞(PYSEC-2026-2286,已在 torch 2.10.0 修复),非概念验证使用前需升级。

为什么重要

语音 Agent 的用户体感很大程度上取决于首音频延迟。这篇文章的价值在于把一个笼统的“延迟问题”拆解为可量化、可分别优化的环节:合成启动时机、传输编码开销、帧对齐处理,并给出了每一项的实测数字。52.4% 的传输环节改善和 3.5 倍的端到端改善,为正在构建语音交互团队的架构决策提供了具体参照。此外,关于 base64+SSE 方案约 33% 负载膨胀和帧错位风险的分析,对已有 SSE 文本管道的团队尤其有参考意义——文章明确建议这类团队可先用 base64 方案,待延迟成为优先级再切换到二进制复用方案。

对谁有影响

  • 正在或计划为 AI Agent 添加语音能力的工程团队,尤其是使用 AWS 生态(SageMaker AI、Bedrock、AgentCore)的团队。
  • 关注 TTS 服务部署成本与延迟的从业者:8200 万参数的模型可跑在单个中端 GPU 上,直接影响推理成本结构。
  • 维护基于 SSE 的流式文本前端、考虑复用该管道传输音频的开发者。
全文翻译

以下翻译只覆盖原文的前一部分,其余内容请看下方原文。

一位客户向 AI 语音代理提出问题并等待。代理已经有了答案,它正在生成一个长段落的多段回复,但客户在五秒钟内什么也听不到。等到答案到达时,客户已经感到沮丧、困惑,并且不再关注了。

文本转语音(TTS)集成正是造成了这种延迟。一句简短的回复可能在 600 毫秒内生成,但完整回复需要五秒钟,而集成服务会等待所有内容生成完毕后才开始合成。

本文展示了如何缩小这一差距。我们将每个句子流式传输到托管在 Amazon SageMaker AI 上的 Kokoro-82M 文本转语音 (TTS) 模型,一旦代理生成了该句子,合成就会立即开始,而不是等待整个回复完成。将音频传回浏览器也需要专门的解决方案,因此我们还介绍了两种传输方式及其实际成本。

解决方案概述

该解决方案通过边生成边流式合成来缩小差距,而不是等待生成结束。随着代理生成每个句子,该句子会立即流式传输到托管的 Kokoro-82M 文本转语音模型,以便在代理说完整个回复之前就可以开始播放音频。Kokoro-82M 仅包含 8.2 百万个参数,足够小,可以在单个中高端 GPU 实例上低成本运行。这使得处理真实流量的语音代理既能保持低延迟,又能降低推理成本。

Kokoro-82M 在 SageMaker AI 实时端点上以自定义容器形式运行,使用 ml.g5.xlarge GPU 实例。该容器暴露了两个路由:POST /invocations,用于为单个完整输入流式传输响应;以及 WS /invocations-bidirectional-stream,用于在双向消息之间保持一个连接。

下图显示了从客户端到模型的请求路径:

客户端通过 InvokeEndpointWithBidirectionalStream 连接到 Amazon SageMaker AI 实时端点(位于 ml.g5.xlarge 上)。一个 Starlette ASGI 应用公开 GET /ping、POST /invocations 和 WS /invocations-bidirectional-stream 路由,将 WebSocket 路由到运行 Kokoro-82M 的 KPipeline,后者在同一连接上返回音频和控制帧。

图 1:通过双向流从客户端到 SageMaker AI 实时端点的请求路径

前提条件

在开始之前,您需要:

  • 一个 AWS 账户,拥有创建 SageMaker AI 端点、AgentCore 运行时(Amazon Bedrock AgentCore 的一项功能)、AWS Identity and Access Management (IAM) 角色以及 Amazon Elastic Container Registry (Amazon ECR) 存储库的权限,至少包括 sagemaker:CreateEndpoint、sagemaker:CreateEndpointConfig、sagemaker:CreateModel、sagemaker:InvokeEndpointWithBidirectionalStream 和 bedrock:InvokeModelWithResponseStream 操作。
  • 示例容器固定了一个较旧版本的 PyTorch,其中存在已知的安全建议 (PYSEC-2026-2286,已在 torch 2.10.0 中修复)。在任何非价值验证用途之前,请审查并升级此固定版本。
  • AWS Cloud Development Kit (AWS CDK) v2,已安装在您的目标账户和 AWS Region 中并完成引导。
  • 一个从声明了 com.amazonaws.sagemaker.capabilities.bidirectional-streaming=true Docker 标签的容器部署的 SageMaker AI 实时端点。
  • Python 3.12 或更高版本、uv 和 Docker,用于构建容器镜像(底层 HTTP/2 客户端仅提供针对 Python 3.12 及更高版本的预构建 wheel)。
  • 支持 invoke_endpoint_with_bidirectional_stream 的 Python AWS SDK (Boto3)。
  • 熟悉 WebSockets 以及 Starlette 或类似的 ASGI 框架。
  • 本帖子引用的 GitHub 仓库 中的部署代码。

逐步指南

逐步指南首先部署端点和代理,然后逐步解决使增量流式传输端到端工作的客户端更改。

部署端点和代理

Kokoro-82M 端点和代理运行时通过两个 CLI 工具进行部署,每个工具都包装了自己的 AWS CDK 堆栈:

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

speech-infra deploy 配置 Kokoro-82M 端点。这包括基础架构和模型缓存堆栈,它们创建底层的 IAM 角色并将模型的权重同步到 Amazon Simple Storage Service (Amazon S3)。然后,端点堆栈构建容器镜像并创建 SageMaker AI 模型、端点配置和端点。agent-infra deploy 配置托管 Strands 代理的 Amazon Bedrock AgentCore 运行时,该代理调用您刚刚部署的端点,因此请先部署端点。

第 1 步:处理 SageMaker AI 实际发送的 WebSocket 帧类型

SageMaker AI 使用的双向传输将客户端消息的每一部分作为二进制 WebSocket 帧交付给您的容器。编写 Starlette WebSocket 处理程序的常见方式是调用 receive_text(),它读取 message["text"],如果该键不存在则引发 KeyError。由于此客户端发送的每条消息都以二进制形式到达,因此该处理程序会在每次请求时失败。

这种失败通常被误诊。一个通用的异常处理程序将 str(e) 转发给客户端,会将 KeyError('text') 转换为读取 'text' 的错误帧,这看起来像是缺少字段,而不是帧结构问题。端点本身在整个过程中报告 InService,因为它接受连接并对每条消息做出响应,尽管每次都出错。

显式接受任一类型的帧,而不是假设会出现某一种:

from starlette.websockets import WebSocket, WebSocketDisconnect

async def _receive_message(websocket: WebSocket) -> str:
    message = await websocket.receive()
    if message["type"] == "websocket.disconnect":
        raise WebSocketDisconnect(message.get("code", 1000))
    payload = message.get("text")
    if payload is None:
        payload = message.get("bytes", b"").decode("utf-8")
    return payload

当没有文本字段时,这将字节解码为 UTF-8,而不是假设某种帧类型。通过发送二进制帧来测试它,因为这种故障模式会产生格式良好的错误帧和健康端点状态,而不是明显的崩溃。

第 2 步:在消息之间保持会话打开

当您的容器完成一条消息的音频合成后,本能的做法是关闭会话,就像 HTTP 周期结束一样。在这里这是错误的。在发送消息的音频和 synthesis_complete 控制帧后,容器循环应回到等待 receive() 以获取同一、仍处于打开状态的会话上的下一条消息。只有当客户端发送明确的 {"type": "close"} 消息时,才关闭连接。

下图显示了一个会话按顺序承载两个句子,仅在最后关闭:

一个双向会话发送两个句子,每个句子后跟随 synthesis_start、PCM 音频和 synthesis_complete 帧,最后以明确的关闭消息结束。

图 2:一个双向会话按顺序承载两个句子,仅在最后关闭

这样,您可以在一个持久会话上连续发送多个句子,它们之间没有重新连接的成本。在一个响应后立即急切地关闭将迫使每个句子建立新连接,从而抵消大部分延迟优势。

第 3 步:在代理生成时流式传输句子

有了会话行为后,解决延迟问题的客户端修复是一个类,它对以两种形状到达的文本进行规范化:一种是预先知道的完整字符串,另一种是实时令牌流。无论哪种方式,它都会产生相同的输出:完整的句子,一旦每个句子完成就准备发送。

import re

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


class IncrementalSentenceChunker:
    def __init__(self) -> None:
        self._buffer = ""

    def feed(self, fragment: str) -> list[str]:
        self._buffer += fragment
        parts = _SENTENCE_RE.split(self._buffer)
        if len(parts) == 1:
            return []
        self._buffer = parts[-1]
        return [s.strip() for s in parts[:-1] if s.strip()]

    def flush(self) -> str | None:
        remainder = self._buffer.strip()
        self._buffer = ""
        return remainder or None

每当文本片段到达时调用一次 feed()。它返回任何新完成的句子,保留尾随文本。在输入结束时调用一次 flush(),以释放剩余的任何部分文本,即使没有尾随标点符号。这样可以防止在句号到达之前结束的流丢失最后一个句子。

这不是并发合成。每个双向处理程序都是一个严格的顺序循环:接收消息,合成其音频,发送该音频,发送 synthesis_complete,然后接收下一条消息。句子二的音频不会在句子一仍在传输时生成。增量发送带来的好处更窄:句子一的合成立即在其存在时开始,而不是等待完整回复。对于在第一句和最后一句之间存在实际生成时间的多句回复,这就是全部的延迟优势。

我们针对一个通过一个会话发送三句输入的实时端点进行了验证。所有三个块都与发送给它们的文本匹配的音频一起到达:

块

文本

音频字节

持续时间 (秒)

1

“Hello there.”

73,200

1.525

2

“This is a post deployment check.”

108,000

2.250

3

“It has three sentences.”

98,400

2.050

第 4 步:选择将音频传回客户端的传输方式

将文本增量流式传输到模型解决了问题的一半。另一半是将生成的音频传送到渲染它的地方,通常是浏览器。大多数代理和聊天前端已经通过服务器发送事件 (SSE) 流式传输文本:单向的,并且大多数浏览器原生支持。SSE 是一种文本协议,不是为承载原始二进制负载而设计的。在为现有 SSE 管道添加语音的团队自然会选择他们已有的模式:对音频进行 base64 编码并将其包装在 JSON 事件中。

我们在 24 个剪辑中测量了这种方法的费用,并通过 Amazon Transcribe 确认质量在不同编码中没有差异,并比较了 MP3 和 AAC:

候选项

原始字节

Base64 字节

比特率

原始 PCM

2,396,400

3,195,200

384 kbps

MP3 48k

308,304

411,072

49.5 kbps

AAC 32k fMP4

258,039

344,084

41.6 kbps

Base64 使每个负载膨胀接近预期的 33%。更微妙的成本是帧对齐。SSE 事件由空行分隔,但内部的音频被分隔成 MP3 帧。这两种方案不一定与传输分块数据的方式对齐。在一次测量的响应中,四个逻辑 MP3 帧作为六个交付块到达,其中两个在帧中间结束。将每个交付块视为一个完整单元的消费者会在普通、健康的流量上失败。修复方法是缓冲传入的字节,只有在找到分隔符时才采取行动:

buffer = b""
for event in resp["Body"]:
    if "PayloadPart" not in event:
        continue
    buffer += event["PayloadPart"]["Bytes"]
    while b"\n\n" in buffer:
        frame, buffer = buffer.split(b"\n\n", 1)
        handle(frame)

今天用于面向模型部分的替代方法完全跳过了 base64 和 SSE 框架。它在已经承载增量文本输入的双向会话上复用原始二进制音频和 JSON 控制帧,并告诉它们分开

查看原文

A customer asks an AI voice agent a question and waits. The agent already has an answer, it’s midway through generating a long, multi-paragraph response, but the customer hears nothing for five seconds. By the time an answer arrives, the customer is frustrated, confused, and no longer paying attention.

Text-to-speech integrations create exactly that gap. A one-sentence response might generate in 600 milliseconds, but the full response takes five seconds, and the integration waits for all of it before synthesis starts.

This post shows how to close that gap. We stream each sentence to a hosted Kokoro-82M text-to-speech (TTS) model on Amazon SageMaker AI as soon as an agent produces it, so synthesis begins on sentence one instead of the finished response. Getting that audio back to a browser needs its own solution too, so we also cover both transports for doing it and what each costs in practice.

Solution overview

This solution closes the gap by streaming synthesis alongside generation instead of waiting for it to finish. As the agent produces each sentence, that sentence streams immediately to a hosted Kokoro-82M text-to-speech model, so audio playback can start before the agent finishes speaking its full response. Kokoro-82M holds only 82 million parameters, small enough to run cheaply on a single mid-tier GPU instance. That keeps both latency and inference cost low for a voice agent handling real traffic.

Kokoro-82M runs in a custom container on a SageMaker AI real-time endpoint, on an ml.g5.xlarge GPU instance. The container exposes two routes: POST /invocations, which streams a response for one complete input, and WS /invocations-bidirectional-stream, which keeps one connection open across many messages in both directions.

The following diagram shows the request path from client to model:

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.

Figure 1: Request path from the client to the SageMaker AI real-time endpoint over a bidirectional stream

Prerequisites

Before you begin, you need:

  • An AWS account with permissions to create SageMaker AI endpoints; AgentCore runtime, a capability of Amazon Bedrock AgentCore; AWS Identity and Access Management (IAM) roles; and an Amazon Elastic Container Registry (Amazon ECR) repository, including at minimum the sagemaker:CreateEndpoint, sagemaker:CreateEndpointConfig, sagemaker:CreateModel, sagemaker:InvokeEndpointWithBidirectionalStream, and bedrock:InvokeModelWithResponseStream actions.
  • The sample container pins an older PyTorch with a known advisory (PYSEC-2026-2286, fixed in torch 2.10.0). Review and upgrade the pin before any non-proof-of-value use.
  • AWS Cloud Development Kit (AWS CDK) v2, installed and bootstrapped in your target account and AWS Region.
  • A SageMaker AI real-time endpoint deployed from a container that declares the com.amazonaws.sagemaker.capabilities.bidirectional-streaming=true Docker label.
  • Python 3.12 or later, uv, and Docker to build the container image (the underlying HTTP/2 client only ships pre-built wheels for Python 3.12 and newer).
  • The AWS SDK for Python (Boto3) with support for invoke_endpoint_with_bidirectional_stream.
  • Familiarity with WebSockets and Starlette or a comparable ASGI framework.
  • The deployment code from the GitHub repository referenced in this post.

Walkthrough

The walkthrough first deploys the endpoint and agent, then works through the client-side changes that make incremental streaming work end to end.

Deploy the endpoint and agent

The Kokoro-82M endpoint and the agent runtime deploy through two CLI tools, each wrapping its own AWS CDK stacks:

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

speech-infra deploy provisions the Kokoro-82M endpoint. This includes the foundation and model-cache stacks that create the underlying IAM roles and sync the model’s weights into Amazon Simple Storage Service (Amazon S3). The endpoint stack then builds the container image and creates the SageMaker AI model, endpoint configuration, and endpoint. agent-infra deploy provisions the Amazon Bedrock AgentCore runtime hosting the Strands agent, which calls the endpoint you just deployed, so deploy the endpoint first.

Step 1: Handle the WebSocket frame type SageMaker AI actually sends

The bidirectional transport SageMaker AI uses delivers every piece of a client’s message to your container as a binary WebSocket frame. A common way to write a Starlette WebSocket handler is calling receive_text(), which reads message["text"] and raises KeyError if that key isn’t present. Because every message this client sends arrives as binary, that handler fails on every request.

The failure is commonly misdiagnosed. A generic exception handler that forwards str(e) to the client turns KeyError('text') into an error frame reading 'text', which looks like a missing field, not a framing issue. The endpoint itself reports InService throughout, since it accepts connections and responds to every message, with an error each time.

Accept either frame type explicitly instead of assuming one will show up:

from starlette.websockets import WebSocket, WebSocketDisconnect

async def _receive_message(websocket: WebSocket) -> str:
    message = await websocket.receive()
    if message["type"] == "websocket.disconnect":
        raise WebSocketDisconnect(message.get("code", 1000))
    payload = message.get("text")
    if payload is None:
        payload = message.get("bytes", b"").decode("utf-8")
    return payload

This decodes bytes as UTF-8 when no text field is present, rather than assuming one frame type. Test it by sending a binary frame, because this failure mode produces a well-formed error frame and a healthy endpoint status, not an obvious crash.

Step 2: Keep the session open between messages

After your container finishes synthesizing one message’s audio, the instinct is to close the session, the way an HTTP cycle ends. That’s wrong here. After sending a message’s audio and a synthesis_complete control frame, the container loop should go back to waiting on receive() for the next message on the same, still-open session. Close the connection only when the client sends an explicit {"type": "close"} message.

The following diagram shows one session carrying two sentences in sequence, closing only at the end:

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

Figure 2: One bidirectional session carrying two sentences in sequence, closing only at the end

This way, you can send several sentences, one after another, on one persistent session, with no reconnection cost between them. Closing eagerly after one response would force a new connection per sentence, undoing most of the latency benefit.

Step 3: Stream sentences as the agent produces them

With the session behavior in place, the client-side fix for the latency problem is a class that normalizes text arriving in two shapes: a complete string known up front, or a live token stream. It produces the same output either way: complete sentences, ready to send as soon as each one is complete.

import re

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


class IncrementalSentenceChunker:
    def __init__(self) -> None:
        self._buffer = ""

    def feed(self, fragment: str) -> list[str]:
        self._buffer += fragment
        parts = _SENTENCE_RE.split(self._buffer)
        if len(parts) == 1:
            return []
        self._buffer = parts[-1]
        return [s.strip() for s in parts[:-1] if s.strip()]

    def flush(self) -> str | None:
        remainder = self._buffer.strip()
        self._buffer = ""
        return remainder or None

Call feed() once per fragment of text as it arrives. It returns any newly completed sentences, holding back trailing text. Call flush() once, at the end of input, to release whatever partial text remains even without trailing punctuation. This keeps a stream that ends before a period arrives from dropping a final sentence.

This isn’t concurrent synthesis. Every bidirectional handler is a strictly sequential loop: receive a message, synthesize its audio, send that audio, send synthesis_complete, then receive the next message. Sentence two’s audio isn’t generated while sentence one’s still transmits. What incremental sending buys is narrower: synthesis on sentence one begins the instant it exists, rather than waiting for the full response. For a multi-sentence response with real generation time between its first and last sentence, that’s the entire latency win.

We verified this against a live endpoint sending a three-sentence input over one session. All three chunks arrived with audio matching the text sent for each:

Chunk

Text

Audio bytes

Duration (s)

1

“Hello there.”

73,200

1.525

2

“This is a post deployment check.”

108,000

2.250

3

“It has three sentences.”

98,400

2.050

Step 4: Choose a transport for getting audio back to the client

Streaming text into the model incrementally solves half the problem. The other half is getting the resulting audio out to whatever renders it, commonly a browser. Most agent and chat frontends already stream text over Server-Sent Events (SSE): one-directional, and supported natively by most browsers. SSE is a text protocol, not built to carry a raw binary payload. A team adding voice to an existing SSE pipeline naturally reaches for the pattern it already has: base64-encode the audio and wrap it in a JSON event.

We measured the cost of that approach across 24 clips, confirmed with Amazon Transcribe that quality didn’t differ across encodings, and compared MP3 against AAC:

Candidate

Raw bytes

Base64 bytes

Bitrate

Raw PCM

2,396,400

3,195,200

384 kbps

MP3 48k

308,304

411,072

49.5 kbps

AAC 32k fMP4

258,039

344,084

41.6 kbps

Base64 inflates every payload by close to the expected 33%. The subtler cost is frame alignment. SSE events are delimited by a blank line, but the audio inside is delimited into MP3 frames. The two schemes don’t necessarily align with how the transport chunks the data. In one measured response, four logical MP3 frames arrived as six delivery chunks, two ending mid-frame. A consumer that treats each delivery chunk as one complete unit fails on ordinary, healthy traffic. The fix buffers incoming bytes and acts only once a delimiter is found:

buffer = b""
for event in resp["Body"]:
    if "PayloadPart" not in event:
        continue
    buffer += event["PayloadPart"]["Bytes"]
    while b"\n\n" in buffer:
        frame, buffer = buffer.split(b"\n\n", 1)
        handle(frame)

The alternative used today for the model-facing leg skips base64 and SSE framing entirely. It multiplexes raw binary audio and JSON control frames over the same bidirectional session already carrying incremental text input, and tells them apart with one rule.

import json


def _decode_control_frame(chunk: bytes) -> dict | None:
    try:
        decoded = json.loads(chunk.decode("utf-8"))
    except (UnicodeDecodeError, json.JSONDecodeError):
        return None
    return decoded if isinstance(decoded, dict) else None

A chunk that fails to decode as a JSON object is audio, forwarded as received, with no encoding step anywhere in the path. One edge case matters: raw PCM audio can legitimately begin with byte 0x7b, the ASCII character {, the same character that opens a JSON object. This rule avoids misclassifying that audio because it requires the entire chunk to parse as valid JSON, not its first byte alone, something a PCM frame will essentially never do.

Note: If your frontend already has a working SSE pipeline for text, base64-encoded audio inside an SSE event is the pragmatic choice. It costs roughly 33 percent extra payload and a buffer-and-reassemble step, but reuses infrastructure you already operate. If you’re building the voice path fresh, or latency is the priority, the unified binary and JSON approach avoids both costs and reuses the connection already carrying text.

Step 5: Put it together in a real agent

The fuller, end-to-end case is a live, generative agent composing its response one token at a time, with no way to know sentence four until sentence one exists. This example runs as a Strands agent, backed by Amazon Bedrock, deployed to Amazon Bedrock AgentCore runtime, calling the Kokoro endpoint with the pattern from Step 4. Three communication legs connect in this design: the browser-facing leg carrying Server-Sent Events back to the caller, the agent’s streaming call to Amazon Bedrock for text generation, and the agent’s bidirectional session with the Kokoro endpoint for synthesis.

The following diagram shows those three communication legs, and where the overlap between generation and synthesis happens:

Caller sends a request to an agent container built with BedrockAgentCoreApp over leg 1, POST /invocations, SSE-shaped with base64 audio. The container calls Amazon Bedrock over leg 2, a streaming converse call for text deltas, and opens a bidirectional session to the Kokoro endpoint over leg 3, raw PCM with no base64, merging both streams back to the caller as Server-Sent Events.

Figure 3: The three communication legs connecting the caller, Amazon Bedrock, and the Kokoro endpoint

The request payload carries a mode field, either "bidi" or "batch", dispatched to two code paths inside the same entrypoint function. A benchmark between them changes exactly one variable: when synthesis starts. In run_bidi, two tasks run concurrently. One drains the Bedrock text stream and feeds completed sentences into the chunker. The other drains the Kokoro session and emits audio chunks as they arrive. Both push into one shared queue, so a consumer yields events in true arrival order: audio for an early sentence can reach the caller while Bedrock still generates a later one. run_batch is the deliberate opposite: it waits for the full response before one synthesis call, kept as a fair baseline.

Going the other direction, the leg from the agent container back to the caller does use base64, for a different reason than Step 4 describes. BedrockAgentCoreApp’s entrypoint must yield plain, JSON-serializable dictionaries, which its SDK turns into data: {json}\n\n frames automatically. There’s no way to hand it a raw binary frame. That’s a consequence of the JSON-native managed runtime, not the choice Step 4 measured and moved away from. Both run_bidi and run_batch pay that identical cost on this leg, so the measured difference between them is purely timing: when the first audio chunk is emitted.

Testing and validation

We isolated the TTS transport’s own contribution to time to first audio by sending the same 267-character, four-sentence block of text two ways: as one message (batch), and as only its first sentence (incremental). We ran five trials each against the live endpoint.

As measured earlier, sending only the first sentence cut mean time to first audio byte from 329.3 ms to 156.7 ms, a 52.4 percent relative improvement, isolated to the TTS transport itself.

We then measured the fuller, end-to-end case with a real language model producing the text, running the identical prompt through run_bidi and run_batch against the same live Bedrock model and endpoint, so the only variable is when synthesis starts.

Using that same methodology, bidi mode’s time to first audio measured 2.4 times faster than batch mode against a local container, and 3.5 times faster against the deployed AgentCore runtime.

The following chart shows that gap across both environments:

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

Figure 4: Relative time to first audio for batch and bidi modes, locally and on the deployed runtime

The gap widens, rather than narrows, for the deployed case once a real network hop is added. run_bidi absorbs that hop inside the overlap it’s already exploiting between generation and synthesis. run_batch has no equivalent overlap to hide that cost in, since it does one thing at a time, so every added millisecond of network overhead lands directly on its serial wait.

To reproduce this comparison against your own deployment, run the same bidi-vs-batch test the agent container exposes:

uv run agent-infra demo --endpoint-url <your-endpoint-url> --prompt "<your prompt>"

This sends the prompt through both modes and reports the timestamp of the first audio_chunk event against agent_start for each. If you see errors instead, check for the KeyError: 'text' symptom from Step 1. An endpoint reporting InService while every request fails signals that bug, not a deployment issue.

Clean up

To avoid ongoing charges, tear down the resources this walkthrough creates in reverse deployment order, agent first, then endpoint:

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

agent-infra destroy removes the Amazon Bedrock AgentCore runtime resource. speech-infra destroy removes the SageMaker AI model, endpoint configuration, and endpoint. Neither empties the Amazon S3 bucket that caches model weights or deletes the Amazon ECR repository holding the container image, since both can be shared across models. If you no longer need them, empty and delete the Amazon S3 bucket and remove the Amazon ECR repository manually, since a stored model checkpoint and container image continue to incur small storage charges until removed.

Conclusion

We showed how to remove one source of avoidable latency from a voice agent, waiting for an agent’s full response before starting speech synthesis, and how to quantify a second, quieter cost: base64-encoded audio inside SSE inflates payload by about a third and can misalign with MP3 frame boundaries if you don’t buffer for it. Streaming each sentence into a SageMaker AI bidirectional endpoint as soon as an agent produces it cut time to first audio by more than half in isolation, and by 2.4 to 3.5 times in a deployed agent. Multiplexing binary audio and JSON control frames over that session, instead of encoding audio as text, removes the size inflation and frame-realignment work a naive SSE relay requires.

If you’re adding voice to an agent that already streams text over SSE, start with the base64 approach in Step 4 and revisit the unified transport once latency becomes the priority. To learn more, see the Amazon SageMaker AI real-time inference documentation and the Amazon Bedrock AgentCore documentation. If you’d like help applying this pattern to your own architecture, contact your AWS account team.


About the author

原文版权归 AWS Machine Learning 及原作者所有,本站转载仅供阅读。如需删除,请联系 support@aisa.one。