第一次把语音识别接到线上,用户上传一段录音,接口返回 200,正文是空字符串。
排查了半小时,最后发现是 WAV 头里声明的采样率和实际数据对不上,解码出来全是 0。这次之后我养成了一个习惯:ASR 的第一个问题永远是"音频本身对不对",不是模型好不好。
三个必须先定下来的量
部署之前,我会先把三个数写在纸上:模型档位、实时率、并发。这三个数定了,机器规格才有依据。
实时率(RTF) 是最核心的指标,定义是"处理耗时 / 音频时长"。RTF 小于 1 才能比实时快。给一段 60 秒音频,模型花了 20 秒处理完,RTF 就是 0.33。
| 档位 | 显存量级 | GPU 上 RTF | 中文识别体感 |
|---|---|---|---|
| tiny | 1G 以内 | 0.02 - 0.05 | 专有名词基本认不出 |
| base | 1-2G | 0.04 - 0.08 | 日常对话够用,口音重会崩 |
| small | 2-4G | 0.08 - 0.15 | 多数场景够用 |
| medium | 5-6G | 0.15 - 0.25 | 稳定 |
| large | 8-10G | 0.3 - 0.6 | 最稳,显存也最吃紧 |
RTF 这几个区间是我在自己那台卡上量到的量级,不同框架的量化方式和批处理策略会让数字差不少,别当精确值。
反直觉:用户不在乎字错率,在乎一次过
我一开始的优化目标是压低 WER(字错率),为此直接上了最大的模型。
上线之后看反馈,用户抱怨的不是"识别错了几个字",而是"经常要重录"。有个做口播的用户说得直白:错三个字我改一下就行,返工二十分钟我受不了。
于是我做了对比实验,同样的模型让用户手动改那几个错字和重录,多数人选了改字。
结论是:降 WER 的边际收益远低于降低"一次通过率"。所以我反过来把资源投在噪声抑制和 VAD(语音活动检测)上,让输入更干净,模型档位反而降了一档。
坑一:端到端延迟由分片大小决定,不由模型决定
这是流式 ASR 最反直觉的一点。
同样用 small 模型,我把流式分片从 1 秒改成 0.5 秒,端到端延迟几乎没变;但从 1 秒改成 3 秒,延迟直接涨了一大截。
原因是流式识别的等待时间结构:
端到端延迟 ≈ 分片长度 + 分片长度 × RTF
模型再快,分片不开完你也拿不到文字。第一句话要等一个完整分片出来才开始显示,这段固定开销跟模型无关。
所以流式场景的分片大小是个用户体验的直接旋钮:
| 分片 | 首字延迟 | 尾部精度 | 我的建议 |
|---|---|---|---|
| 0.5s | 低 | 差,断句容易断错 | 只在强实时场景用 |
| 1-2s | 中 | 中 | 通用默认 |
| 3-5s | 高 | 好 | 追求准确率时用 |
| 整段上传 | 最高 | 最好 | 离线转写用 |
补充一个细节:分片边界要带重叠,否则一个词正好被切在两片中间,两半都识别不出来。我用 200ms 重叠,代价是 20% 额外算力,换边界词基本不丢。
坑二:长音频会把显存吃穿
用户传一段一小时的会议录音,我直接整段丢给模型,显存爆了。
大模型做转写有两种方式:非自回归的整段编码,或者自回归的逐帧解码。前者对音频长度是线性显存,后者更糟,注意力随长度平方增长。
我现在的做法是:先用 VAD 把音频切成语义完整的片段,每段上限 30 秒,段间留 1 秒重叠。
ffmpeg -i input.m4a -ar 16000 -ac 1 -c:a pcm_s16le \
-af "highpass=f=100,lowpass=f=7800,silencedetect=n=-45dB:d=0.6" \
-f wav output.wav
这一行命令里有四个值得注意的地方:
-ar 16000 -ac 1强制重采样成 16k 单声道,这是绝大多数 ASR 模型的输入要求。不做这一步,模型拿到 44.1k 的数据要么报错要么识别乱。- 高通低通滤掉低频隆隆声和高频嘶声,这一步在噪声环境下的收益比换模型明显。
silencedetect找出静音段,配合 VAD 做切分依据。- 不要加
-threads auto。ffmpeg 默认会按核数起线程,在容器里它按宿主机核数算,一个容器起 64 个线程直接把 CPU 打满,其他服务全被拖慢。固定成-threads 2之后干净了。
切段之后再送模型,单段 30 秒的显存占用是可控的,并发数也可以稳定提升。
坑三:拼接处出现重复字
切段解决了显存,但带来了新问题:相邻两段都有重叠,拼接后经常出现"今天今天我们讨论"或者半个词重复。
处理办法是在拼接时做重叠区去重。我用最朴素的办法——把重叠部分的文本用最长公共前缀裁掉:
def merge_with_overlap(prev: str, cur: str, overlap_chars: int = 20) -> str:
a, b = prev[-overlap_chars:], cur[:overlap_chars]
for k in range(overlap_chars, 0, -1):
if a[-k:] == b[:k]:
return prev + cur[k:]
return prev + cur
这个函数不完美——中文里重复片段可能正好是真的重复。但它比不处理强,而且逻辑简单,出错时一眼能看出来。
更稳的做法是把切分点交给 VAD 而不是固定时长,让切分落在静音处,就不会切在词中间。我后来两个都用,VAD 负责主要切分,固定 30 秒只作兜底上限。
并发上限怎么定
并发不是"显存装得下模型就能并发多少"。显存要同时装:模型权重 + 批处理中的样本 + KV cache。
给个粗算的起点:
可并发条数 ≈ (总显存 - 权重占用 - 预留 2G) / 单条推理峰值显存
单条推理峰值显存受两个东西影响:音频长度和是否开 beam search。开了 beam search,beam 宽度每翻一倍,显存占用也跟着翻。
我最后的选择是 medium 模型 + 不开 beam + 30 秒切段 + 动态批处理,把并发控制交给框架调度器,而不是自己在应用层排队。应用层排队反而容易堆积出难看的尾延迟。
监控上盯三个数就够:队列等待时长、RTF 的 P95、显存占用率。只有第一个经常是瓶颈——GPU 算力大部分时间是闲的,闲在等请求。
各家的显卡规格、实例配额和计费方式一直在变,文章里的量级只用来做判断,实际配置请以官方页面为准。