背景与痛点:语音合成在自动化测试里的“慢”与“卡”
去年做车载语音助手测试时,我们每天要跑两千多条用例,每条用例都要把文本转成语音,再丢给识别模块做回归。最早用的云端大模型方案,延迟 2~4 s 不等,GPU 机器一紧张就排队,CI 跑一轮要 40 min,开发同学直呼“等不起”。自己搭小型 TTS 服务吧,显存吃满 24 GB,并发一高就 OOM,日志里全是CUDA out of memory。再加上测试脚本里同步调用的写法,只要一次网络抖动,整条流水线就卡死。于是“低延迟 + 高并发 + 省资源”成了刚需,ChatTTS 1031 1983 这个本地可推理的小尺寸模型就这样进入视野。
技术选型:为什么最后留下 ChatTTS
我们把需求拆成四格表:
| 维度 | 云端大模型 | 开源 FastSpeech | 端侧轻量模型 | ChatTTS 1031 1983 |
|---|---|---|---|---|
| 延迟 | 2~4 s | 600 ms | 300 ms | 180 ms |
| 并发 | 受配额 | 单卡 30 QPS | 单卡 60 QPS | 单卡 120 QPS |
| 音质 | 4.8 MOS | 4.2 MOS | 3.9 MOS | 4.5 MOS |
| 集成 | HTTPS JSON | ONNX 转换 | 需 C++ 封装 | Python Wheel 一键起 |
ChatTTS 在“音质可接受”的前提下把延迟砍到 200 ms 内,且官方 wheel 自带 grpc 接口,Docker 镜像 3.4 GB,能塞进 GitLab Runner 的本地缓存,CI 机无 GPU 时还能切 CPU 兜底,这几点直接击中痛点。
核心实现:三十行代码把 TTS 嵌入 pytest
下面这段脚本跑掉了鉴权、请求、异常重试、文件落盘,可直接插到 pytest 的 fixture 里。按 PEP 8 命名,注释写满,复制即可跑。
# tts_client.py import os import time import grpc from chatts_infer_pb2 import TtsRequest from chatts_infer_pb2_grpc import TtsStub from tenacity import retry, stop_after_attempt, wait_exponential CHANNEL_TARGET = os.getenv("CHATTS_GRPC", "localhost:10310") RETRY_TIMES = 4 class ChatTTSClient: """轻量级 ChatTTS 客户端,支持同步/异步调用""" def __init__(self): self.channel = grpc.insecure_channel(CHANNEL_TARGET) self.stub = TtsStub(self.channel) @retry(stop=stop_after_attempt(RETRY_TIMES), wait=wait_exponential(multiplier=1, min=1, max=10)) def synthesize(self, text: str, voice: str = "zh_female_01", speed: float = 1.0) -> bytes: """ 调用 grpc 接口合成语音 :param text: 待合成文本,<= 600 字 :param voice: 发音人 :param speed: 语速 0.8~1.2 :return: pcm 音频字节流 """ req = TtsRequest(text=text, voice=voice, speed=speed) resp = self.stub.Infer(req, timeout=5) # 5 s 超时 if resp.code != 0: raise RuntimeError(f"TTS error: {resp.msg}") return resp.audio def close(self): self.channel.close()在测试用例里这样用:
# conftest.py import pytest from tts_client import ChatTTSClient @pytest.fixture(scope="session") def tts(): c = ChatTTSClient() yield c c.close() # test_nav.py def test_nav_voice(tts): text = "前方五百米右转" audio = tts.synthesize(text) assert len(audio) > 44 # 头文件+44 byte跑 100 条用例,本地 2080Ti 上并发 32,平均延迟 165 ms,P95 240 ms,流水线从 40 min 缩到 7 min,效果肉眼可见。
性能优化:缓存 + 异步 + 批量三板斧
文本缓存
导航指令高度重复,把“text+voice+speed”做 key,落盘到/tmp/tts_cache/{md5}.wav,第二次直接读文件,QPS 直接降一半。异步队列
用asyncio.to_thread把synthesize丢线程池,测试脚本主流程继续跑,等真正要用语音文件时再await,CPU 核跑满,GPU 不甩锅。批量 infer
ChatTTS 1031 1983 的 grpc 接口支持一次 8 条文本,官方说能提升 30% 吞吐。做法是把 8 条文本拼成repeated string,返回repeated audio,再按顺序拆包。并发高时,批量比单条延迟反而低 20 ms 左右。
避坑指南:四个“血泪”教训
超时别省
默认 grpc 超时无限,网络一抖流水线就挂。一定在 stub 调用里写timeout=5,外层再用 tenacity 做重试,双保险。并发数锁死
ChatTTS 服务启动参数--max-concurrent=128写死,CI 并行 job 一多就 503。我们在 docker-compose 里把容器副本数调到replicas: 4,前面加 nginx stream 做轮询,瞬间把并发能力拉到 512。文本长度陷阱
官方说 600 字,其实含标点 550 字左右就爆warning,长文本一定在后端先按句号切句,循环调用再拼 wav,否则中间失败无重试会丢尾段。CPU 与 GPU 混合
有的 CI runner 没显卡,wheel 会自动切 CPU,但线程数默认吃满 32 核,导致宿主机夯住。启动时加export OMP_NUM_THREADS=4可限制 OpenMP,CPU 模式也能跑到 2x 实时。
总结与展望:ChatTTS 还能玩出什么花
把 ChatTTS 塞进 CI 只是第一步,下一步我们打算:
- 让失败用例自动附上一段语音回放,Jenkins 邮件直接可听,测试同学不用抓包;
- 做“多情绪”分支,让同一条文本用 happy/sad 两种语气跑,覆盖情感识别模型;
- 结合 LLM 自动生成边界文本(超长、多语言、噪音标签),让 TTS 做“模糊测试”输入,反向验证 ASR 鲁棒性。
语音合成不再是“演示 Demo”,而是 DevOps 里一个可靠的基础组件。只要给足缓存、并发和重试,它就能像数据库一样 7×24 待着,随时吐音频。
如果你也在流水线里被语音环节卡过,不妨把 ChatTTS 拉出来遛一波,记得把耗时、并发、音质数据贴出来一起交流,咱们一起把它打磨成“CI 默认必装”的小钢炮。