为什么 TTS Demo 好听,上线客服却显得很慢?

为什么 TTS Demo 好听,上线客服却显得很慢? 演示里的 TTS 往往没有问题。页面点一下完整文本很短声音自然、连贯听起来像已经可以交付。把同一套能力放进 Voice Agent 以后用户的感受却常常变成另一回事机器人停顿一会儿才开口开口后第一句还没说完用户已经开始追问用户打断了它又把旧话补完。我不建议先把问题归到“音色模型慢”。真正把体感拖垮的通常是 TTS 之前迟迟没有一段可合成的文本或是音频已经到了浏览器、却还在播放队列里等位置。Demo 常把这些等待藏掉了。本文只讨论一个问题怎样判断一条 TTS 链路到底慢在文本、合成、传输还是播放。文中的本地 Demo 使用可控提示音模拟流式 TTS它验证的是时序和协议不比较任何厂商的音色或真实性能。目录Demo 里被省掉的那段等待别把 TTS 首包当成用户等待一次可复现的本地测量声音已经到达为什么还会显得慢中文客服的分句不能只追求短上线前应保留的指标FAQ参考资料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 msMock 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 输出没有回归cddemo python3-munittest-v当前用例覆盖四件事中文增量文本能在句末切分超过最大长度时优先选择逗号等软边界包头能往返解析Mock 流能稳定输出 PCM16。测试通过后再启动服务和基准脚本才有讨论延迟的基础。声音已经到达为什么还会显得慢音频抵达客户端不等于应该立即播放。如果每收到一个 PCM 包就用 JavaScript 定时器“现在播”包与包之间只要出现一点网络或主线程抖动就会有空洞。听感是断句、咔哒声或者每一句都像重新起了个头。更稳妥的做法是在 Web Audio 的时间轴上排期。第一个包留出很小的启动缓冲后续包从前一个包结束的位置开始conststartAtMath.max(nextPlayTime,audioContext.currentTime0.08);source.start(startAt);nextPlayTimestartAtaudioBuffer.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 的首包、音色和成本。顺序错了模型换了几轮用户仍然会觉得它慢。FAQTTS 首包多少毫秒才算合格没有脱离终端和网络条件的统一答案。电话网关、浏览器、App 的播放缓冲不同。先把t4 - t3和t7 - t0分开再用 P50、P95 和欠载次数判断。为什么不把每个 LLM token 立即送去合成请求会变多韵律会碎撤销也更困难。第一段要尽快出来但后续内容需要可控的句子边界和有限预取。打断后服务端已经取消为什么还会有尾音可能是浏览器已排期的AudioBufferSourceNode也可能是网络中迟到的旧包。前端应停止已排期节点、清空时间轴并按turn_id丢弃旧包。参考资料FastAPI WebSocketsMDNAudioBufferSourceNode.start()MDNWebSocket.binaryTypePython asyncio Task Cancellation