Streaming VQ 这个名字,听起来像是一个具体的开源项目,但其实它更像是一整套工程范式:把向量量化(Vector Quantization, VQ)从离线批处理搬到流式场景时,从模型结构、码本更新、延迟控制到分布式训练,每一个环节都要重新设计。我最早接触到它,是在做实时音频编解码器的时候。当时团队想用神经网络压缩语音,从传统 Opus 切到端到端方案,模型里最核心的瓶颈不是卷积层,也不是注意力模块,而是那个看起来最简单的量化器。
先说一句防杠:在视频工程领域,VQ 也可能指 Video Quality,做画质评估的同学看到这个词别急着往下读。本文里的 VQ 是 Vector Quantization,也就是把连续向量映射到离散码本索引,再通过索引重建信号。Streaming VQ 解决的问题非常具体:在拿不到完整序列、不能等未来帧、延迟预算有限的条件下,如何既保持离散表征的效率,又让码本稳定更新,最后还能落地上线。适合读这篇文章的,是正在做实时语音通信、音频编解码、视频 tokenizer、向量检索 Embedding 压缩,或者任何跟"连续特征转离散索引"打交道的工程师和研究同学。
1. 为什么非要把 VQ 改成流式版
1.1 离线 VQ 是最舒服的,但它带着三个隐藏假设
VQ 的基本思想不复杂。给定一个输入向量z,和一个训练好的码本C = {c_1, c_2, ..., c_K},VQ 做的事情就是找到距离最近的码本向量c_i,然后用c_i去代替z,传输或者存储时只记录索引i。在离线场景里,整个流程非常干净:
import torch def vq_offline(z, codebook): # z: [B, T, D],codebook: [K, D] flat_z = z.reshape(-1, z.shape[-1]) dist = torch.cdist(flat_z, codebook) idx = dist.argmin(dim=1) z_q = codebook[idx] return z_q, idx这样一段代码背后,藏着三个"看不见的假设"。
第一个假设是完整序列可用。离线 VQ 在训练时可以一次性看到一段语音、一段视频或者一批 Embedding 的全部帧,torch.cdist会把当前帧跟所有码本向量做距离计算,不存在"未来帧还没到"的问题。第二个假设是码本可以反复训练。离线场景通常会基于整个数据集跑 K-Means 先初始化码本,然后再用 VQ-VAE 或者类似的损失函数联合微调,码本的更新是全局的、非实时的。第三个假设是延迟无所谓。离线 VQ 哪怕一帧一帧算,只要最终输出正确,中间多等 100 毫秒也没人管。
问题是,一旦进入流式场景,这三个假设全部失效。
1.2 流式场景给 VQ 加的四条硬约束
我在实际调模型时,最先感受到的是因果约束。流式编码器处理第t帧时,不能看到第t+1帧以后的信息。这让很多现成的卷积结构没法直接用,因为普通卷积的 padding 是左右对称的,哪怕你只是把kernel_size=5的卷积 padding 设为 2,它也意味着模型在算第t帧时看了左边两帧和右边两帧,这在流式推理里就是作弊。
第二条约束是状态管理。离线模型输入是[B, T, D],一次算完;流式模型输入是不断到达的 chunk,每个 chunk 可能只有 20 毫秒或者 40 毫秒,模型必须维护一个跨 chunk 的缓存,把上一段的历史特征传下去。这个缓存如果没设计好,VQ 的量化结果会跟着飘,尤其在高频细节上特别明显。
第三条约束是码本更新方式。离线时你可以每训练完一个 epoch 重新做一次 K-Means,但流式场景要求码本在每个 step 都有反馈,不能等完整数据。EMA 更新是一种做法,但 EMA 引入的decay参数、码本使用率监控、死码处理,都是新的工作量。
第四条约束是延迟预算。VQ 模块本身虽然通常只有一次argmin查找,但整个编解码链路里还会有卷积、归一化、网络传输、解码器自回归等环节。如果你把 VQ 优化到 1 毫秒,但前面的特征提取器偷看了 40 毫秒未来,那整体延迟依然是失败的。
1.3 真正需要 Streaming VQ 的场景
我梳理了几个最常见的落地场景,你可以对照看看自己属不属于目标用户。
| 场景 | 输入特征 | 核心目标 | 主要指标 |
|---|---|---|---|
| 实时语音通信 | 声学特征或编码器隐层 | 用最低比特率传语音 | 带宽、MOS、PESQ |
| 端侧语音识别前端 | wav2vec 类特征 | 降低特征存储与传输成本 | 字错误率、特征重建误差 |
| 视频 tokenizer | 视频特征图 | 把连续特征转为离散 token | 重建质量、FID |
| 向量检索的 Embedding 压缩 | 图片/文本向量 | 减少存储和带宽 | 召回率、内存占用 |
| 在线学习/强化学习 | 状态特征 | 把高维状态离散化 | 状态重建误差、策略收益 |
其中实时音频编解码器是我踩坑最深的地方,后面的经验也大多围绕这个场景展开。
2. 流式 VQ 的三种实现路线,按业务需求选
2.1 固定码本加因果编码器:最省事,适合分布稳定的任务
第一种实现思路是训练阶段就训练好码本,冻结它,推理阶段只做查表。这种做法最直观,也最容易让训练稳定,因为码本不会再变,整个系统的行为在训练和推理时完全一致。
具体操作上,先用一批代表性数据对 Encoder 输出的特征做 K-Means,得到初始码本,然后联合训练 Encoder 和 Decoder,码本用 codebook loss 和 commit loss 保持更新。等模型收敛后,把码本真正冻结,导出成一张查找表,推理时只做argmin。
它的优点很明确:无状态、可复现、硬件友好。在端侧芯片上,量化后的查找表可以放到 SRAM 里,一次距离计算就是有限次内积加比较,没有额外的状态更新逻辑。缺点是一旦线上数据分布偏移,比如说话人从普通话变成带重口音的外语,码本的覆盖能力会逐渐下降,重建质量会悄悄变差。所以这种路线更适合数据分布比较可控的场景,比如固定领域的关键词唤醒,或者短视频标签 Embedding 压缩。
2.2 EMA 码本在线更新:我建议作为默认方案
第二种路线是 EMA 码本,也是我在大多数项目里的默认选择。所谓 EMA,就是对码本向量和每个码字的使用计数做指数滑动平均。每次拿到新的特征z之后,先找最近邻,然后把命中的码本向量往z的方向拉一点,同时更新这个码字被使用的频率估计。
核心更新逻辑大概是这样的:
@torch.no_grad() def update_codebook_ema(codebook, counts, z, idx, decay=0.99): # codebook: [K, D],counts: [K],idx: [N] onehot = torch.zeros(codebook.shape[0], device=z.device) onehot.scatter_add_(0, idx, torch.ones_like(idx, dtype=torch.float32)) z_sum = torch.zeros_like(codebook) z_sum.index_add_(0, idx, z) counts.mul_(decay).add_(onehot * (1 - decay)) codebook.mul_(decay).add_(z_sum * (1 - decay)) codebook.div_(counts.unsqueeze(1).clamp_min(1e-5))为什么用 EMA 而不是直接用 batch 内均值替换?因为流式场景不能等一个 batch 全到齐再更新,EMA 可以做到"来一个特征就更新一点",而且历史信息通过decay保留下来,不会因为某个 batch 的局部特征剧烈波动而把码本带偏。
EMA 码本需要配合 commit loss 使用。因为码本不需要梯度更新,Commit loss 的作用就是把 Encoder 的输出往最近码本向量拉近,让编码器输出的分布和码本分布不要太远。通常这个 loss 的权重beta在 0.1 到 1.0 之间,我自己的习惯是从 0.25 起步。
2.3 残差向量量化 RVQ:把压缩率不够的问题拆开解
如果你只需要一个码本,命中率一般不会差。但一旦你想要很低的比特率,比如把 16kHz 语音压到 4kbps 以下,单个码本就不太够了。要么 K 特别大,比如 65536,训练容易死码;要么向量维度特别低,信息损失又太大。残差向量量化(RVQ)是现在音视频神经网络编解码器里的标准解。
RVQ 的做法是串多个 VQ 层。第一层对原始特征z做量化,残差r1 = z - z_q1送给第二层,第二层对r1量化,残差继续往下一层传。解码时把每一层的码本向量加回来:
def rvq_encode(z, codebooks): residual = z indices = [] for cb in codebooks: idx = torch.cdist(residual, cb).argmin(dim=1) indices.append(idx) z_q = cb[idx] residual = residual - z_q return indices def rvq_decode(codebooks, indices): z_q = 0 for cb, idx in zip(codebooks, indices): z_q = z_q + cb[idx] return z_qRVQ 的好处有两个。第一,每一层码本大小可以很小,比如 K=1024,但叠加 8 层之后表达能力远超单个大码本;第二,每层只负责修正前一层没编进去的残差,训练更稳定,码本使用率更容易拉高。
在线流式场景里,RVQ 的每一层都用同一个 EMA 更新方式,但要注意不同层的特征分布差异很大。第一层的残差动态范围大,最后一层其实就是白噪声级别的细节,如果所有层用同一个decay,最后一层码本要么不更新,要么更新得太快。我习惯把后面几层的 EMAdecay调大一点,比如 0.995 到 0.999,让细节层更平滑。
3. 手写一个能跑通的 Streaming VQ 模块
3.1 先确定模块边界
在实际工程里,Streaming VQ 不是一个孤立的argmin,它是插在 Encoder 和 Decoder 之间的一个模块。输入是 Encoder 输出的连续特征z,输出是量化后的特征z_q、每层码本索引indices和用于训练的 commit loss。模块内部需要维护码本、码本使用计数,以及上游因果卷积带来的缓存。
我建议把"量化器"和"因果编码器"拆成两个类。原因很简单:量化器可以复用到不同 Encoder 上,而因果编码器则跟 backbone 结构强绑定,混在一起后面想换 encoder 非常痛苦。
3.2 先把因果卷积做好
流式 VQ 的上游通常是一个卷积 Encoder,它必须处理成因果结构。普通Conv1d默认左右 padding 是一样的,要改成只有左侧 padding,右侧为 0。以kernel_size=5为例,普通 padding 是 2,因果 padding 是 4(左边补 4 个历史帧,右边不补)。这样输出长度和输入长度一样,但每个输出只依赖当前帧和过去 4 帧。
import torch.nn as nn import torch.nn.functional as F class CausalConv1d(nn.Module): def __init__(self, in_channels, out_channels, kernel_size, stride=1): super().__init__() self.pad = kernel_size - 1 self.conv = nn.Conv1d( in_channels, out_channels, kernel_size, stride ) def forward(self, x): # x: [B, C, T] x = F.pad(x, (self.pad, 0)) return self.conv(x)这里的F.pad(x, (self.pad, 0))表示只在时间维度的左边补kernel_size - 1个零。训练时可以一次性计算整段序列,推理时每来一个 chunk,就用缓存把需要的历史帧拼上。缓存长度就是kernel_size - 1,每个 chunk 处理完,要把旧缓存丢弃,只保留当前 chunk 的最后kernel_size - 1帧。
3.3 量化前向和梯度直通
VQ 最反直觉的地方在于,argmin这个操作不可导。靠梯度回传来更新 Encoder,必须绕过这个离散选择。最常用的做法是梯度直通(Straight-Through Estimator):前向时把量化后的码本向量当输出,反向时把误差直接传给量化前的特征,当作量化操作没有发生。
实现上,常用一个小技巧:
z_q = z + (codebook[idx] - z).detach()这个式子的意思很直白:前向输出是codebook[idx],但梯度计算时,z到输出之间被当作恒等映射,梯度可以无损传到 Encoder。量化器的可学习能力不是靠梯度更新码本,而是靠 EMA 在参数空间里更新码本,两套机制互不干扰。
完整的 PyTorch 风格实现大致长这样:
import torch import torch.nn as nn import torch.nn.functional as F class StreamingVQ(nn.Module): def __init__(self, dim, num_codes, ema_decay=0.99, commit_beta=0.25): super().__init__() self.dim = dim self.num_codes = num_codes self.ema_decay = ema_decay self.commit_beta = commit_beta self.register_buffer("codebook", torch.randn(num_codes, dim) * 0.02) self.register_buffer("counts", torch.zeros(num_codes)) self.register_buffer("z_sum", torch.zeros(num_codes, dim)) @torch.no_grad() def _update_ema(self, z, idx): onehot = torch.zeros(self.num_codes, device=z.device) onehot.scatter_add_(0, idx, torch.ones_like(idx, dtype=torch.float32)) self.counts.mul_(self.ema_decay).add_(onehot * (1 - self.ema_decay)) self.z_sum.mul_(self.ema_decay).add_( torch.zeros_like(self.z_sum).index_add_(0, idx, z) * (1 - self.ema_decay) ) self.codebook.copy_(self.z_sum / self.counts.clamp_min(1e-5)) def forward(self, z): # z: [B, T, D] orig_shape = z.shape flat_z = z.reshape(-1, self.dim) dist = torch.cdist(flat_z, self.codebook) idx = dist.argmin(dim=1) z_q_hat = self.codebook[idx] if self.training: self._update_ema(flat_z, idx) z_q = flat_z + (z_q_hat - flat_z).detach() commit_loss = F.mse_loss(flat_z, z_q_hat.detach()) * self.commit_beta return z_q.reshape(orig_shape), idx, commit_loss几个实现细节要注意。codebook、counts、z_sum要用register_buffer而不是nn.Parameter,否则会被优化器误更新。EMA 更新必须在torch.no_grad()环境下进行,因为这里全是手动状态更新。Commit loss 是对 Encoder 的输出做约束,不是为了更新码本,所以z_q_hat要detach()。
3.4 推理时的 chunk 状态到底怎么存
模型上线后,输入不会是一整段音频。服务端拿到的是一段 UDP 包,客户端拿到的是 20 毫秒一帧的 PCM。我在实现时会把推理封装成类似process_chunk的接口,让上游只传一个新的 chunk 进来,内部维护缓存,外部无状态。
缓存设计有一个常见坑:缓存存的是卷积输入,不是卷积输出。也就是说,对于kernel_size=5的因果卷积,每个 chunk 处理前要把上一次的最后 4 帧原始输入拼到当前 chunk 前面,处理完后再把当前 chunk 的最后 4 帧存下来。如果存的是卷积输出,下一个 chunk 拼接时,卷积感受野会发生错位,结果会莫名其妙多出边界噪声。
如果有多层因果卷积,每一层都要维护自己的缓存。我见过有人在项目里只缓存了第一层的输入,后面几层缓存漏了,量化结果在 chunk 边界处周期性抖动。排查这种问题最好的办法是:拿同一段音频,先离线整段过一遍,再把音频切成长度相同的 chunk 过一遍,对比两层输出的差异。差异只出现在边界处,基本就是缓存没对上。
4. 从训练到上线,最容易翻车的地方
4.1 训练时偷看未来是流式任务的第一大坑
我刚开始做流式音频编解码器的时候,吃过一次大亏。离线训练时指标很好看,重建音频的 PESQ 能到 3.8,但一放到实时通话链路里,延迟抖动和音质崩得很快。后来查了半天,发现问题不在 VQ,而在上游 Encoder 的普通卷积偷偷用了对称 padding。
对称 padding 意味着模型在算第t帧的时候,代码逻辑上"看过"了未来kernel_size // 2帧。离线评测时这不影响正确性,但部署时如果改成流式 chunk,模型拿不到未来帧,行为立刻变化。更麻烦的是,特征层面多看了几帧往往不会让训练 loss 直接崩溃,它只会让模型"过度自信",一上线就露馅。
解决方法是训练一开始就严格用 CausalConv1d,不要在训练完以后再"转换"。转换以后模型权重里的时间依赖已经固化了,哪怕你改 padding,也无法弥补它曾经依赖未来信息的事实。
4.2 码本坍缩:看起来在训练,其实已经死了
码本坍缩是 VQ 家族最经典的问题。现象很简单:K 个码字里只有十几个在频繁使用,其他码字几乎不被选中,码本的有效容量大幅下降,重建质量上不去。
原因通常是初始化不好,或者 EMA 更新太慢。如果某些码字一开始离所有特征都很远,它们就永远抢不到最近邻,counts越来越小,最后被 EMA 衰减到近乎 0。处理办法有两个层面。
第一层是监控。训练时每隔一定步数计算码本困惑度:
p = counts / counts.sum() perplexity = torch.exp(-(p * torch.log(p + 1e-10)).sum()) usage = perplexity / num_codes如果usage低于 0.7 到 0.8,我就要开始警惕了。第二层是激进一点的 reset 策略:当某个码字的counts连续很多步低于阈值,就把它重新初始化为当前 batch 中距离最远的特征向量。这个操作要在torch.no_grad()里做,不能影响正常梯度流。
4.3 多卡训练时 EMA 状态不同步
EMA 码本在单卡上实现很简单,多卡训练就麻烦不少。每一张卡有自己的一份counts和z_sum,如果不用 DDP 的 buffer 同步机制,最终保存的码本就是"某一张卡最后更新后的状态",其他卡上的训练过程中一直在用不同的码本。
这种情况下模型可能不会当场崩,因为每张卡的 Encoder 会逐渐适应自己那份码本,但保存 checkpoint 时码本和 Encoder 的配对关系可能已经错乱。加载到单卡做推理时,效果会跟你预期差很远。
我现在的做法是在每个训练 step 完成 EMA 更新后,对码本、counts、z_sum 三个 buffer 做一次broadcast,让所有 rank 保持同一状态。如果你用的是 FSDP,还要小心 buffer 被分片,最好单独把码本从分片参数里排除。
4.4 延迟不是一个模块的事
流式 VQ 本身几乎不耗时:一次cdist加一次argmin,在 GPU 上可能不到 0.1 毫秒。但整个系统的延迟不是 VQ 模块单独决定的,而是所有环节的加和。
| 环节 | 典型耗时 | 说明 |
|---|---|---|
| 音频采集缓冲 | 10-20ms | 每个 chunk 的 PCM 需要攒够 |
| 上游特征提取 | 10-30ms | 因果卷积 + 降采样 |
| VQ 量化 | <1ms | 查表 + 索引编码 |
| 网络传输与抖动 | 20-80ms | 实际网络的不可控项 |
| 解码器重建 | 10-30ms | 反卷积 + 语音合成 |
| 播放缓冲 | 10-20ms | 对抗网络抖动 |
所以我一般会在项目开始时就把延迟预算写在设计文档里,而不是等模型训完再去凑。如果总预算只有 150ms,特征提取和解码各占 30ms,留给网络抖动的是 80ms,那 VQ 本身再多 1ms 无关痛痒,但如果你把 Encoder 的降采样步长从 4 改成 8,省下来的可能只是计算量,延迟却会因为等待更多帧而翻倍。
4.5 评估只看重建误差,会漏掉真正的问题
我早期评估 VQ 效果时只看重建 SNR,指标一路涨,但实际听感不对劲。后来发现,SNR 对低频成分特别敏感,两个模型 SNR 差不多,高频细节可能差很多。做语音通信还得看 PESQ、STOI,做音乐得看频谱失真,做视频 tokenizer 还得看 FID。
另一个被漏掉的指标是码本使用率。模型表现好但不代表码本被均匀使用,如果 4096 个码字只用到 300 个,那你实际比特率里有一大半码字表达是浪费的。上线前我建议把以下指标列成一张检查表:
- 重建质量指标:PESQ / STOI / SNR / FID,至少选一个。
- 延迟指标:从输入 chunk 到输出 chunk 的端到端时延。
- 码本状态:困惑度、dead code 比例、不同层 RVQ 的 usage。
- 边界一致性:整段推理与 chunk 推理的结果差异。
5. 参数到底怎么调:一组实测参考
5.1 码本大小和特征维度的搭配
码本大小和特征维度没有严格公式,但有一个基本规律:特征维度越高,需要的码本容量越大。如果维度是 64,码本只有 256,模型表达力偏弱;如果维度是 256,码本 1024 又太容易过拟合。我常用的搭配如下。
| 特征维度 | 码本大小 | 适用场景 |
|---|---|---|
| 32 | 256 | 极低比特率的窄带语音 |
| 64 | 512-1024 | 宽带语音、简单特征压缩 |
| 128 | 1024-2048 | 音乐/语音通用,推荐默认 |
| 256 | 2048-4096 | 高保真重建或视频特征 |
这里有个实际经验:单层 VQ 的码本超过 4096 以后,训练难度会明显上升,死码概率增大。如果 K 需要超过 8192,我更建议拆成两层 RVQ,比如一层 K=2048,一层 K=2048,而不是一层 K=65536。
5.2 RVQ 层数和比特率的换算
做音频编解码器绕不开比特率。RVQ 的比特率可以算得很精确,公式是:
比特率 = 帧率 * RVQ层数 * log2(码本大小)
以 16kHz 采样率、20ms 帧长为例,每秒有 50 帧。如果每层码本大小是 1024,也就是log2(1024) = 10bit,8 层 RVQ 的比特率就是:
50 * 8 * 10 = 4000 bps
也就是 4kbps 左右。如果把层数加到 12,那就是 6kbps。这个公式反过来也很有用:你定好目标带宽之后,直接反推最多能允许多少层。
不同层的码本大小要不要都一样?我试过几种配置。第一层决定大结构,码本不能太小,至少 1024;最后一层是细节残差,512 甚至 256 都够。这样可以在不增加层数的情况下省一点 bits。
5.3 我现在的默认参数
每次从零开始做 Streaming VQ,我一般用下面这组参数起步:
- 特征维度:128。
- 单层码本大小:1024 或 2048。
- RVQ 层数:8 层起步。
- EMA decay:0.99,最后几层调到 0.995。
- commit loss 权重:0.25。
- 码本初始化:先随机初始化,再跑 500 步 K-Means 更新。
- 死码处理:每 100 步检查困惑度,低于阈值就 reset。
这组参数不是最优的,但一定不会在训练初期就炸。等指标跑起来以后,我一般会按这个顺序调:先看码本 usage,不足就减 K 或者做 reset;再看重建误差在哪个频段恶化,决定要不要加 RVQ 层数;最后才动 EMA decay 和 commit loss 权重。因为前两个直接影响模型表达能力,后两个只是训练稳定性的旋钮。
5.4 客观指标和主观体验要一起看
调参最怕只盯着一个数字。客观指标是下限,主观体验是上限。我这边的习惯是每天训练完,固定挑几条语音跑一遍 chunk 推理,导出成 WAV 自己听。很多问题在 PESQ 上根本不明显,但一听就能发现边界处有"咔哒"声,或者高频有咝咝的量化噪声。
VQ 这种离散模块尤其如此。连续模块只要 loss 下降,输出多半在变好;离散模块偶尔会出现 loss 下降但听感变差的情况,这是因为量化误差的分布不均匀,集中在某些帧上,而 PESQ 这类指标把它平均掉了。
后来我的调整方式里,增加了一条"逐帧误差曲线"的可视化。把z和z_q的误差按时间画出来,如果误差在 chunk 边界处出现周期性尖峰,那基本就是缓存或填充逻辑不对,而不是码本问题。这个工具帮我省了非常多排查时间。
Streaming VQ 不是一个能"装完库就能跑"的现成组件,它需要你同时照顾好因果结构、码本状态、延迟预算和训练分布。我现在的习惯是每到一个新项目,先把离线整段推理和流式 chunk 推理的结果对齐,再开始调码本参数。只有这两条路径的输出完全一致,后面所有优化才有意义。