流式语音合成这件事,真正做过线上服务的人都知道,难点从来不在"能不能合成出声音",而在"第一帧声音多久能出来"和"整句话说完到底花了多少时间"这两件事上。vLLM-Omni 这套推理框架把大语言模型领域里已经打磨得很成熟的 PagedAttention、连续批处理那套思路搬到了多模态生成上,TTS 就是其中一个典型场景。我最近拿它跑了一轮流式 TTS 的压测,重点盯的就是异步分块策略、TTFP(Time To First Packet,首包时间)和端到端延迟这三者之间怎么互相拉扯。这篇就把我踩过的坑、测出来的数据、以及最后定下来的分块参数完整摊开讲一遍,适合已经在做 TTS 服务、或者正准备把自研 TTS 往推理框架上迁移的同行参考。如果你只是想知道"vLLM-Omni 能不能跑 TTS",答案是能,但真正值钱的是它怎么在流式和延迟之间做取舍。
1. 为什么流式 TTS 的延迟账不能只算总时长
1.1 端到端延迟是个被平均掉的假指标
大多数人评估 TTS 性能,习惯看一个数:从文本进来到音频全部生成完,总共花了多少毫秒。这个数在离线批处理场景下没问题,但放到实时对话、语音助手、有声书朗读这类场景里,它几乎没有任何指导意义。原因很简单——用户感知到的"快慢"不是由总时长决定的,而是由第一段有意义的声音什么时候到达播放端决定的。
我做过一个很直观的对比实验:方案 A 总耗时 800ms,但前 600ms 一个字节音频都没吐出来,最后 200ms 一次性把整段音频推完;方案 B 总耗时 1100ms,但从第 150ms 开始就持续有音频块流出来。实测下来,几乎所有测试者都认为方案 B "更快"。这就是流式 TTS 的核心:延迟要按时间轴切段看,而不是按总和看。
所以在 vLLM-Omni 上做 TTS,我第一件事就是把延迟拆成三个独立指标来监控:
- TTFP(Time To First Packet):从请求进入到第一个音频块可被消费的时间。这是用户感知的"响应速度"。
- 块间间隔(Inter-Chunk Gap):相邻两个音频块之间的时间差。这个值抖动太大会导致播放卡顿,比总延迟更影响体验。
- 端到端总延迟(E2E Latency):从请求进入到最后一个音频块生成完毕。这个指标决定的是资源占用和吞吐,不是体验。
这三个指标在 vLLM-Omni 里是互相耦合的,你没法同时把三个都压到最低。理解它们的耦合关系,是后面所有调参的前提。
1.2 TTFP 和 E2E 延迟为什么天然对立
要理解这个对立,得先看 TTS 模型的自回归生成特性。以常见的自回归声学模型为例,它是一帧一帧(或者一个 token 一个 token)往外吐声学特征的,然后这些特征再经过声码器(vocoder)转成波形。这里有个关键点:声码器通常需要一定长度的上下文才能合成出高质量音频。
假设你的声码器需要至少 N 帧声学特征才能合成一个稳定的音频块。那么:
- 如果你为了压低 TTFP,只攒了很少的帧就急着送去声码器,合成出来的音频质量会明显下降,甚至出现爆音、截断。
- 如果你为了保证质量,攒够足够多的帧再合成,那 TTFP 就必然被推高。
这就是 TTFP 和 E2E 延迟对立的根源。vLLM-Omni 的异步分块机制,本质上就是在解这道题:怎么在保证音频质量的前提下,让第一个块尽可能早地流出去,同时不让后续块的生成被阻塞。
1.3 vLLM-Omni 在 TTS 场景下的调度特点
vLLM-Omni 相比普通推理框架,最大的不同是它把请求的调度粒度做得很细。传统 TTS 服务往往是一个请求独占一个模型实例,从头跑到尾;vLLM-Omni 则是把多个请求的生成步骤交错执行,用连续批处理(continuous batching)把 GPU 利用率拉满。
这对流式 TTS 意味着两件事。第一,单个请求的 TTFP 会受到同批次其他请求的影响——如果同批次里有个长文本请求正在生成,你的短请求可能被"挤"在后面。第二,分块策略必须和调度器配合,否则你精心设计的块边界会被调度器的批处理逻辑打乱。
我在实测中发现,vLLM-Omni 默认的调度策略对 TTS 并不完全友好,因为 TTS 的生成步长和 LLM 的 token 生成步长在时间分布上差异很大。声学模型的单步计算量比 LLM 单 token 大得多,如果直接套用 LLM 的批处理参数,很容易出现"批内某个请求拖慢整批"的情况。后面第 3 节会详细讲怎么调这个。
2. 异步分块到底在分什么:块大小、块边界与流水线
2.1 分块的两个维度:声学帧维度和请求维度
很多人一听"异步分块"就以为是简单地把音频切成固定大小的块往外推,其实在 vLLM-Omni 里,分块发生在两个维度上,而且这两个维度的策略是独立的。
第一个维度是声学帧维度,也就是把自回归模型生成的连续声学帧按一定数量切成 chunk。这个 chunk 大小直接决定 TTFP 和块间间隔。chunk 越小,TTFP 越低,但声码器调用次数越多,单块质量越难保证;chunk 越大,质量越稳,但 TTFP 越高。
第二个维度是请求维度,也就是把一次 TTS 请求拆成多个可独立调度的子任务。比如一段长文本,可以先按句子或语义段拆开,让前面的句子先合成、先流出,后面的句子在后台继续生成。这个维度决定的是长文本场景下的整体体验。
这两个维度必须配合。我见过有人只调帧维度的 chunk 大小,结果长文本请求的 TTFP 依然很高,因为整个请求还是被当成一个整体在调度。正确的做法是:请求维度先拆,让每个子请求足够短;然后在每个子请求内部再用帧维度分块,控制首包时间。
2.2 块边界选在哪里:固定长度 vs 语义边界
块边界的选择是个容易被忽视但影响很大的细节。固定长度分块实现简单,但会在句子中间切断,导致声码器在合成时缺少上下文,音频质量下降。语义边界分块(按标点、按停顿)质量好,但块长度不均匀,TTFP 的抖动会变大。
我在实际项目里的折中方案是两级边界:优先在语义边界(句号、问号、逗号后的自然停顿)切分,但如果两个语义边界之间的距离超过一个上限阈值,就在中间强制插入一个固定长度切分点。这样既保证了大部分块的语义完整性,又避免了超长块拖高 TTFP。
具体阈值怎么定,取决于你的声码器对上下文的敏感程度。我用的声码器在 20 帧以内的上下文缺失时质量下降不明显,所以我把强制切分点设在了大约对应 200ms 音频的位置。这个值需要你自己测,不同声码器差异很大。
2.3 流水线设计:生成、声码、传输三段解耦
异步分块真正发挥威力的地方,是把"声学帧生成""声码器合成""网络传输"这三段做成流水线。如果这三段是串行的——生成完一块、声码一块、传一块——那分块带来的收益会被串行等待吃掉大半。
vLLM-Omni 的异步能力允许你把这三段解耦:声学帧生成器持续往一个队列里塞帧,声码器从队列里取帧合成音频块,传输层再从另一个队列里取音频块往外推。三段各自有独立的缓冲队列,互不阻塞。
这里有个坑:队列深度不能设太大。我一开始把声码器前面的队列设得很深,想着"多攒点帧质量更好",结果 TTFP 直接飙到 900ms 以上,因为声码器要等队列攒到一定水位才开始工作。后来改成浅队列 + 立即触发,TTFP 降到了 200ms 出头。队列深度的本质是"用延迟换吞吐",在流式场景下这个交换通常不划算。
3. 把 TTFP 压到 200ms 以内的参数组合
3.1 首块最小帧数的确定方法
首块最小帧数是影响 TTFP 最直接的参数。它的含义是:声学模型至少生成多少帧,才触发第一次声码器合成。这个值太小,首块音频质量差;太大,TTFP 高。
确定这个值的方法不是拍脑袋,而是做一次质量-延迟扫描。我的做法是:固定其他参数,把首块最小帧数从 4 帧开始,每次加 2 帧,一直到 32 帧,每个配置生成 50 条测试文本,然后人工评估首块音频的质量(是否有爆音、截断、音质劣化),同时记录 TTFP。
实测数据大致是这样的(不同模型和硬件会有差异,这里给的是趋势):
| 首块最小帧数 | 平均 TTFP | 首块质量评价 |
|---|---|---|
| 4 | 约 90ms | 明显爆音,不可用 |
| 8 | 约 130ms | 轻微爆音,边缘可用 |
| 12 | 约 180ms | 质量可接受 |
| 16 | 约 230ms | 质量良好 |
| 24 | 约 340ms | 质量优秀但 TTFP 偏高 |
| 32 | 约 450ms | 质量优秀,TTFP 不可接受 |
从这张表能看出来,12 帧是个拐点——再往下质量掉得很快,往上 TTFP 涨得很快。我最后选的是 12 帧作为首块最小帧数,配合后面要讲的预热策略,把 TTFP 稳定在了 180-200ms。
注意:这个扫描必须用你自己的声码器和测试集做,不同声码器对上下文长度的敏感度差异极大。我见过有的声码器 8 帧就能出干净音频,也见过需要 20 帧以上的。
3.2 后续块的滑动窗口策略
首块定下来之后,后续块不能简单沿用首块的帧数,否则块间间隔会不均匀。后续块我用的是滑动窗口策略:每个后续块包含的帧数 = 首块帧数 + 一个增量,但这个增量不是固定的,而是根据当前队列里积压的帧数动态调整。
逻辑是这样的:如果声学模型生成速度快于声码器消费速度,队列会积压,这时候可以适当增大块大小,减少声码器调用次数;如果队列快空了,说明声码器消费快于生成,这时候要减小块大小,避免声码器空转等待。
这个动态调整让块间间隔的抖动从最初的 ±80ms 降到了 ±25ms 左右,播放端的卡顿感基本消失。实现上就是一个简单的反馈控制:每合成完一块,看一眼队列水位,水位高于高阈值就增大下一块,低于低阈值就减小下一块。
3.3 预热请求:让第一个真实请求不用等冷启动
即使参数调得再好,第一个请求的 TTFP 总是比后续请求高,因为模型权重加载、CUDA kernel 编译、显存分配这些冷启动开销都压在了第一个请求上。我实测第一个请求的 TTFP 能到 600ms 以上,而稳定后只有 200ms。
解决办法是预热请求:服务启动后,立刻用一段固定的短文本发一个合成请求,把这条请求的音频直接丢弃,目的只是让模型跑完一次完整的生成流程,把冷启动开销提前消化掉。预热请求的文本要覆盖典型的输入长度,太短了预热不充分,太长了启动慢。
我一般会发 3 条预热请求,长度分别是短、中、长,覆盖不同的生成路径。这样正式流量进来时,TTFP 基本就是稳定值了。这个技巧看起来简单,但在实际线上环境里,它能把 P99 的 TTFP 拉低一大截,因为线上流量往往有突发性,冷启动请求混在突发流量里会拖垮整个批次的响应。
4. 端到端延迟的取舍:什么时候该牺牲 TTFP
4.1 长文本场景下的策略切换
前面讲的都是短请求的优化思路,但真实业务里长文本很常见——有声书朗读、长文档播报,一段文本可能对应几分钟的音频。这种场景下,如果还死磕 TTFP,反而会出问题。
原因是长文本的声学帧总数很大,如果每个块都按最小帧数切,声码器调用次数会爆炸,GPU 在声码器上的开销会超过声学模型本身。我测过一个 5 分钟音频的请求,按 12 帧切块,声码器被调用了上千次,端到端延迟比按 48 帧切块高了将近 40%。
所以长文本场景要动态切换策略:请求开始时用小块快速出首包,保证 TTFP;首包出来之后,逐步增大块大小,把端到端延迟压下来。这个切换点我设在首包生成之后,从第 3 个块开始逐步放大,大概 5 个块之内过渡到稳定的大块模式。
4.2 批处理对延迟的放大效应
vLLM-Omni 的连续批处理是把双刃剑。批处理能大幅提升吞吐,但会放大单个请求的延迟,尤其是当批次里请求长度差异很大时。
我做过一组对比:单独跑一个短请求,TTFP 是 190ms;把它和一个长请求放进同一批,短请求的 TTFP 涨到了 340ms。原因是调度器在批处理时,短请求的生成步骤要和长请求的步骤对齐,长请求的每一步计算量更大,短请求被迫等待。
缓解办法有两个。一是按长度分桶:把长度相近的请求放进同一批,避免长短混批。二是限制批大小:批大小不是越大越好,超过某个点之后,延迟的增长会快于吞吐的收益。我实测下来,在保证 TTFP 稳定的前提下,批大小控制在 8 以内比较合适,具体值取决于你的 GPU 显存和模型大小。
4.3 用优先级队列保护交互式请求
如果你的服务同时承载交互式请求(语音助手、实时对话)和批处理请求(有声书、离线合成),那必须做请求分级。交互式请求对 TTFP 极度敏感,批处理请求对总延迟敏感但对首包不敏感。
我的做法是给请求打优先级标签,交互式请求进高优先级队列,批处理请求进低优先级队列。调度器优先满足高优先级队列,低优先级队列在高优先级空闲时才被调度。这样交互式请求的 TTFP 不会被批处理请求拖累,而批处理请求在系统空闲时依然能跑满吞吐。
这个分级策略在 vLLM-Omni 里需要自己实现一层调度包装,因为框架默认的调度器不区分请求优先级。实现不复杂,核心就是在请求入队时按优先级排序,然后按顺序喂给框架的调度接口。
5. 实测中暴露的三个反直觉问题
5.1 块越小 TTFP 不一定越低
这个结论乍看违反直觉,但实测确实如此。当块大小小到一定程度后,TTFP 不再下降,反而开始上升。原因是声码器本身有固定的启动开销(kernel launch、内存拷贝等),块太小的话,这个固定开销占比过高,声码器还没合成完第一块,时间就耗掉了。
我测到的拐点在 8 帧左右:从 16 帧降到 8 帧,TTFP 从 230ms 降到 130ms;但从 8 帧降到 4 帧,TTFP 反而回升到 150ms 以上,而且质量崩了。所以"块越小越好"是个误区,存在一个最优区间。
5.2 声码器才是延迟瓶颈,不是声学模型
我一开始把优化重点全放在声学模型上,调批大小、调调度策略,折腾了半天 TTFP 只降了 30ms。后来用 profiler 一看,声码器占了整个流水线 60% 以上的时间。声学模型生成 12 帧可能只要 40ms,但声码器把这 12 帧转成波形要 100ms 以上。
这个发现改变了我的优化方向。我把声码器换成了更轻量的版本,并且把声码器也做了批处理(多个请求的帧攒在一起过一次声码器),TTFP 直接从 200ms 降到了 130ms 左右。所以做流式 TTS 优化,第一步应该是 profile,搞清楚瓶颈到底在哪一段,别凭直觉猜。
5.3 网络传输的抖动比想象中大
在本地测试时,TTFP 稳定在 180ms,一上线上环境就变成了 250ms 以上,而且抖动很大。排查后发现是网络传输层的缓冲策略问题。默认的 socket 缓冲会把小音频块攒起来再发,虽然减少了包数量,但引入了额外延迟。
解决办法是关闭 Nagle 算法(设置 TCP_NODELAY),并且把发送缓冲区调小,让每个音频块生成后立即发出。这个改动让线上 TTFP 回到了 200ms 以内,抖动也小了很多。这个坑很隐蔽,因为本地测试时网络延迟极低,缓冲策略的影响看不出来,一上真实网络就暴露了。
6. 一套可复现的调参流程
6.1 从 profile 开始,别从调参开始
我把整个调参流程总结成了一条主线:先 profile,再定瓶颈,最后调参数。跳过 profile 直接调参,大概率是在优化非瓶颈环节,白费力气。
profile 的工具用 PyTorch Profiler 或者 nsight 都行,重点看三个时间段的占比:声学模型生成、声码器合成、数据传输。哪段占比最高,就先优化哪段。我遇到的大部分情况是声码器占比最高,其次是声学模型,传输层占比通常最小但抖动最大。
6.2 参数扫描的顺序:先粗后细
确定瓶颈之后,参数扫描要有顺序,不能一上来就细调。我的顺序是:
- 先定首块最小帧数:这是影响 TTFP 最大的参数,用第 3.1 节的扫描方法定下来。
- 再定批大小:在首块帧数固定的前提下,扫描批大小,找到 TTFP 和吞吐的平衡点。
- 然后调滑动窗口参数:优化块间间隔的均匀性。
- 最后调队列深度和网络参数:这些是微调,放在最后。
每一步只调一个变量,其他固定,否则你分不清是哪个参数起了作用。这个原则听起来简单,但实际调参时很容易手痒同时改好几个,结果数据一团乱。
6.3 用 P99 而不是平均值做决策
最后一个经验:所有指标都看 P99,别看平均值。流式 TTS 的体验是由最差的那 1% 请求决定的,平均值再好看,只要有 1% 的请求 TTFP 超过 1 秒,用户就会觉得"这个语音助手很卡"。
我在调参时会把每个配置的 P99 TTFP、P99 块间间隔、P99 端到端延迟都记录下来,用这三个 P99 值做决策。有时候某个配置的平均值更好看,但 P99 更差,这种配置我会直接淘汰。稳定比快更重要,这是流式服务的一条铁律。
调完这一轮,我的服务在稳定状态下 TTFP 的 P99 控制在 220ms 以内,块间间隔抖动在 ±30ms 以内,端到端延迟相比优化前降了约 35%。这套参数不是通用的,你的模型、硬件、网络环境都会影响最终值,但调参的思路和顺序是可以直接复用的。真正花时间的从来不是找到那组"神奇参数",而是建立一套能持续观测、持续迭代的调参流程。