演示里的 TTS 往往没有问题。页面点一下,完整文本很短,声音自然、连贯,听起来像已经可以交付。

把同一套能力放进 Voice Agent 以后,用户的感受却常常变成另一回事:机器人停顿一会儿才开口;开口后第一句还没说完,用户已经开始追问;用户打断了,它又把旧话补完。

我不建议先把问题归到“音色模型慢”。真正把体感拖垮的,通常是 TTS 之前迟迟没有一段可合成的文本,或是音频已经到了浏览器、却还在播放队列里等位置。Demo 常把这些等待藏掉了。

本文只讨论一个问题:怎样判断一条 TTS 链路到底慢在文本、合成、传输还是播放。文中的本地 Demo 使用可控提示音模拟流式 TTS;它验证的是时序和协议,不比较任何厂商的音色或真实性能。

目录

Demo 里被省掉的那段等待

单独试用 TTS 时,输入通常是一整句已经写好的文案:

完整文本 -> TTS 请求 -> 音频 -> 播放

在线客服不是这个顺序。它要等 LLM 持续输出,再从增量文本里找到一个既能自然朗读、又不会切坏数字和地址的边界。第一句还没播完,第二句的音频又要提前准备;否则每句话之间都会重新等一次首包。

用户 浏览器播放队列 流式 TTS 分句器 LLM 用户 浏览器播放队列 流式 TTS 分句器 LLM 增量文本 第一段可合成文本 第一块 PCM 第一段可听音频 后续文本 下一段文本

Demo 常见的误导是:先等完整答案生成,再送进 TTS。此时看上去 TTS 调用很快,实际上用户已经在前面等了整段答案。另一个误导更隐蔽:把收到 WebSocket 音频包当作“已经开口”。浏览器还可能为了连续播放而把它排在现有音频之后。

所以,TTS 快不快不能只问一个数字。

别把 TTS 首包当成用户等待

我会把一轮语音回复至少拆成七个点:

t0  系统决定开始回复
t1  LLM 输出第一段增量文本
t2  分句器拿到第一段可合成文本
t3  服务端发起第一段 TTS 请求
t4  TTS 返回第一块 PCM
t5  客户端收到第一块 PCM
t6  浏览器把音频排进播放时间轴
t7  用户真正听到声音

其中 t4 - t3 是供应商或模型侧的首包时间,值得测,但它不是用户侧等待。实际体验更接近 t7 - t0

如果分句器在 t2 前等了很久,继续压缩 TTS 首包可能没有多少收益;如果 t6 时已有 800 ms 待播音频,下一包即使立刻送到也要等。把这两种情况混成一个 total_latency_ms,排障会失去方向。

我还会额外记录 t2 - t0。这个指标能直接回答一个常被忽略的问题:LLM 已经开始写了,但系统究竟什么时候敢把第一段交给 TTS?

一次可复现的本地测量

项目中已有一个 FastAPI + WebSocket 的流式 TTS Demo。它固定使用 PCM16、24 kHz、单声道,用本地提示音替代真实语音;首包等待和每包间隔都是显式实验参数。

这不是为了伪造一个好看的结果,恰恰相反:它让我们可以把变量控制住。Demo 的默认设置是每 4 个字符模拟一次 LLM 增量、间隔 28 ms;Mock TTS 的首包等待设为 180 ms;音频包每 40 ms 一块产出。

连续运行 5 次的本地记录中,服务端首个二进制 PCM 包分别为 298.3、297.8、297.2、298.8、299.5 ms,中位数 298.3 ms。最后一次记录中:

first_text_segment_ms = 116.8
tts_first_packet_ms   = 182.2
first_pcm_ms          = 299.0

这组数只说明 Demo 的时序是自洽的:先等到可合成文本,再等待 Mock TTS 的首包,随后得到 PCM。180 ms 是代码中的人为设置,绝不能写成某家 TTS 服务的实测成绩。

可以先运行单元测试,确认分句、二进制包头和流式 PCM 输出没有回归:

cd demo
python3 -m unittest -v

当前用例覆盖四件事:中文增量文本能在句末切分;超过最大长度时优先选择逗号等软边界;包头能往返解析;Mock 流能稳定输出 PCM16。测试通过后再启动服务和基准脚本,才有讨论延迟的基础。

声音已经到达,为什么还会显得慢

音频抵达客户端不等于应该立即播放。

如果每收到一个 PCM 包就用 JavaScript 定时器“现在播”,包与包之间只要出现一点网络或主线程抖动,就会有空洞。听感是断句、咔哒声,或者每一句都像重新起了个头。

更稳妥的做法是在 Web Audio 的时间轴上排期。第一个包留出很小的启动缓冲,后续包从前一个包结束的位置开始:

const startAt = Math.max(nextPlayTime, audioContext.currentTime + 0.08);
source.start(startAt);
nextPlayTime = startAt + audioBuffer.duration;

这里的 nextPlayTime - audioContext.currentTime 就是待播队列深度。它太小,容易欠载;太大,用户打断时会听到一串旧话。两者之间没有通用常数,应该按电话、网页或 App 的网络条件分别测试。

我不建议为了“绝不卡顿”无限预取。两秒待播音频确实更稳,却会把用户每次打断都变成一次明显的尾音。客服场景通常宁可偶尔做降级提示,也不应该在用户说话后继续抢话。

中文客服的分句不能只追求短

把第一段切得更短,往往能降低 t2 - t0。但中文客服里,短不一定好。

“订单金额是一千二百零六元”如果被切在“一千二百”之后,语音听感会明显犹豫;地址、手机号、验证码和日期更不能按字符长度生硬截断。另一个常见问题是,LLM 的 Markdown、链接和枚举符号本来就不适合直接朗读,分句之前还要先清洗。

一个简单但够用的起点是:强句末标点立即提交;长句达到阈值时优先在逗号、冒号处切;最后一段在流结束时 flush();英文句点不直接当中文硬边界,以免误切版本号、URL 和小数。

生产环境还应加“最长等待时间”。有些模型会连续输出很长一段而不出现句末标点。一直等自然边界,看似保护韵律,实际是在拿首播等待换一个并不一定更好的停顿。

上线前应保留的指标

不要只把 tts_latency_ms 放进监控。至少需要一条按 turn_idsentence_index 关联的记录:

字段 用途
text_chars 判断短句和长句是否混在一起比较
t_text_ready 找到分句或 LLM 输出等待
t_tts_requestt_first_pcm 观察 TTS 首包,但不把它当用户体验
t_client_receivet_play_schedule 分开传输和浏览器排期
queued_msunderrun_count 判断是排队过深还是经常断流
cancel_requestedcancel_ack 检查打断是否真的生效

还要给每个音频包带上 turn_id。取消服务端任务只能阻止后续生成,无法收回已经在网络中的旧包;客户端没有轮次过滤,就可能把迟到的包排回新一轮对话。

真正值得优化的顺序通常是:先让第一段在完整答案生成前进入合成;再保持播放队列既不断流也不过深;最后才比较不同 TTS 的首包、音色和成本。顺序错了,模型换了几轮,用户仍然会觉得它慢。

FAQ

TTS 首包多少毫秒才算合格?

没有脱离终端和网络条件的统一答案。电话网关、浏览器、App 的播放缓冲不同。先把 t4 - t3t7 - t0 分开,再用 P50、P95 和欠载次数判断。

为什么不把每个 LLM token 立即送去合成?

请求会变多,韵律会碎,撤销也更困难。第一段要尽快出来,但后续内容需要可控的句子边界和有限预取。

打断后服务端已经取消,为什么还会有尾音?

可能是浏览器已排期的 AudioBufferSourceNode,也可能是网络中迟到的旧包。前端应停止已排期节点、清空时间轴,并按 turn_id 丢弃旧包。

参考资料

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐