为什么 TTS Demo 好听,上线客服却显得很慢?
演示里的 TTS 往往没有问题。页面点一下,完整文本很短,声音自然、连贯,听起来像已经可以交付。
把同一套能力放进 Voice Agent 以后,用户的感受却常常变成另一回事:机器人停顿一会儿才开口;开口后第一句还没说完,用户已经开始追问;用户打断了,它又把旧话补完。
我不建议先把问题归到“音色模型慢”。真正把体感拖垮的,通常是 TTS 之前迟迟没有一段可合成的文本,或是音频已经到了浏览器、却还在播放队列里等位置。Demo 常把这些等待藏掉了。
本文只讨论一个问题:怎样判断一条 TTS 链路到底慢在文本、合成、传输还是播放。文中的本地 Demo 使用可控提示音模拟流式 TTS;它验证的是时序和协议,不比较任何厂商的音色或真实性能。
目录
Demo 里被省掉的那段等待
单独试用 TTS 时,输入通常是一整句已经写好的文案:
完整文本 -> TTS 请求 -> 音频 -> 播放
在线客服不是这个顺序。它要等 LLM 持续输出,再从增量文本里找到一个既能自然朗读、又不会切坏数字和地址的边界。第一句还没播完,第二句的音频又要提前准备;否则每句话之间都会重新等一次首包。
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_id 和 sentence_index 关联的记录:
| 字段 | 用途 |
|---|---|
text_chars |
判断短句和长句是否混在一起比较 |
t_text_ready |
找到分句或 LLM 输出等待 |
t_tts_request、t_first_pcm |
观察 TTS 首包,但不把它当用户体验 |
t_client_receive、t_play_schedule |
分开传输和浏览器排期 |
queued_ms、underrun_count |
判断是排队过深还是经常断流 |
cancel_requested、cancel_ack |
检查打断是否真的生效 |
还要给每个音频包带上 turn_id。取消服务端任务只能阻止后续生成,无法收回已经在网络中的旧包;客户端没有轮次过滤,就可能把迟到的包排回新一轮对话。
真正值得优化的顺序通常是:先让第一段在完整答案生成前进入合成;再保持播放队列既不断流也不过深;最后才比较不同 TTS 的首包、音色和成本。顺序错了,模型换了几轮,用户仍然会觉得它慢。
FAQ
TTS 首包多少毫秒才算合格?
没有脱离终端和网络条件的统一答案。电话网关、浏览器、App 的播放缓冲不同。先把 t4 - t3 和 t7 - t0 分开,再用 P50、P95 和欠载次数判断。
为什么不把每个 LLM token 立即送去合成?
请求会变多,韵律会碎,撤销也更困难。第一段要尽快出来,但后续内容需要可控的句子边界和有限预取。
打断后服务端已经取消,为什么还会有尾音?
可能是浏览器已排期的 AudioBufferSourceNode,也可能是网络中迟到的旧包。前端应停止已排期节点、清空时间轴,并按 turn_id 丢弃旧包。
参考资料
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐


所有评论(0)