简介:面向中文语音识别学习与开发实践的一份完整工程源码,聚焦如何以深度学习完成从声学特征到文本序列的建模。内容围绕 Keras、TensorFlow 环境下的 CNN 特征提取、LSTM 序列建模、注意力机制与 CTC 损失函数展开,适合具备一定神经网络基础、希望理解中文语音识别项目全流程的读者参考。压缩包共 47 个文件,以 24 个 Python 脚本为核心,配合 14 个 txt 词典与数据列表、3 个 md 说明文档及 lst、yml 等配置文件,整体约 6.5MB,目录覆盖数据预处理、模型定义、训练测试与语言模型等模块。已有 1363 人学习下载。读者可据此梳理 MFCC 特征处理、CNN-LSTM-Attention-CTC 模型搭建、训练评估与部署接口的实现思路,并借助现成脚本复现和调参,减少从零搭建中文语音识别系统的试错成本。
1. 语音识别项目:一段 8 秒录音暴露的问题
上周帮人看一段 8 秒的客服录音转写,模型把「方案」识别成了「发暗」。这不是模型坏了,是它压根没见过带键盘声、带口音、带麦克风底噪的真人录音。这套基于深度学习的中文语音识别系统要解决的就是这类问题:输入一段 wav,输出一串汉字,模型、解码器、语言模型全在自己机器上跑,不依赖外部接口。它给的是完整链路——数据清单怎么准备、80 维 fbank 怎么提、Conformer 编码器加 CTC 怎么训、解码怎么从 logits 里捞出句子,最后包一层 HTTP 服务给业务调用。适合三类人:想把 ASR 全链路摸一遍的算法和后端同学;要给会议记录、字幕、工单录音做垂直微调的开发者;以及只想在本地离线跑一个能用识别器的人。
2. 系统全景与技术选型:一条语音要过哪三道手
端到端语音识别听着玄,拆开看就是一条固定长度的流水线:音频进来,特征出去;特征进来,每个时间片对应的音素或汉字概率出去;概率进来,一句话出去。真正决定项目好不好用的不是模型多深,而是这三段之间的接口约定得够不够死——采样率、帧移、词表顺序、blank 位置,任何一处对不上,训练时 loss 照降,推理时全是乱码。
2.1 三段式管线:特征、声学模型、解码器
第一段是特征。16kHz 单声道 wav 先被切成 25 毫秒一帧、10 毫秒一帧移的短时窗,每帧算一组 mel 滤波器能量,取 80 维。这一步的意义是把波形压成「这台机器听得懂、且和人耳感知接近」的表示。第二段是声学模型,输入(B, T, 80),经过下采样和若干层编码器,输出(B, T/4, V),V 是词表大小,每个位置给出该位置上每个 token 的分数。第三段是解码,把这些分数翻译成一个合法句子,CTC 负责去重去 blank,语言模型负责把「发暗」纠回「方案」。
| 阶段 | 输入 | 输出 | 关键参数 |
|---|---|---|---|
| 特征提取 | 16k 单声道 wav | (T, 80)fbank | 25ms 窗长、10ms 帧移、80 mel |
| 声学模型 | (B, T, 80) | (B, T/4, V)logits | 12 层 Conformer、d_model=256、4 倍下采样 |
| 解码 | logits + n-gram LM | 汉字串 | beam=10、LM 权重 alpha≈0.6 |
把这三段串起来的最短路径大概长这样,我一般先写这一个函数,跑通了再往里填细节:
# asr/infer.py 的骨架:三段式管线的入口 import torch def transcribe(wav_path: str, model, tokenizer, decoder) -> str: wave, sr = load_audio(wav_path, target_sr=16000) # 1. 读音频并强制重采样到 16k feats = extract_fbank(wave, sr, n_mels=80) # 2. (T, 80),T 是帧数 with torch.no_grad(): logits, out_lens = model(feats.unsqueeze(0)) # 3. (1, T//4, V) log_probs = logits[0].log_softmax(dim=-1).cpu().numpy() return decoder.decode(log_probs) # 4. 解码成汉字串这里的T//4来自下采样层,Conformer 常见的做法是两层 stride=2 的卷积把时间轴压到四分之一,好处是显存和后续注意力的计算量都降一个量级;代价是时间分辨率变粗,快速连读时容易吞字。tokenizer和decoder的标签顺序必须与训练时完全一致,这一条后面还会反复出现,属于这个项目里最容易出错的地方。
提示:
load_audio里一定要做重采样。手上有 44.1k 的录音、8k 的电话录音,如果混着喂进去,fbank 的频带分布完全不一样,模型表现会明显变差。
2.2 技术栈与选型:为什么是 Conformer + CTC
框架用 PyTorch,特征用 torchaudio 里的 kaldi 兼容实现,训练走单机多卡 DDP,配置文件用 yaml 管理。这套组合不是唯一解,但在这个项目尺度上比较省心:torchaudio 的fbank输出和 Kaldi 对得上,方便和别人的结果比对;yaml 配置让「换模型不改代码」成为可能。
| 组件 | 选型 | 为什么 |
|---|---|---|
| 声学模型 | Conformer | 卷积抓局部发音、注意力抓长距离上下文,中文连读和同音字都吃这套 |
| 损失函数 | CTC | 不需要帧级对齐标注,只有「音频 + 整句文本」就能训 |
| 解码 | CTC prefix beam search + KenLM | 纯贪心错字太多,n-gram 能把同音字纠回来 |
| 服务 | FastAPI | 异步、自带 OpenAPI 文档,接业务方省事 |
模型这一层值得多说两句。中文的难点不在音素识别,而在同音字:拼音fangan对应的候选有一大把,光靠声学模型分不出来,必须让语言模型参与决策。CTC 的好处是训练简单、不需要强制对齐,坏处是它假设各帧独立,输出天然偏「碎」,所以解码阶段必须做合并重复和去 blank,再叠一层语言模型打分。另一种常见做法是 Attention Encoder-Decoder,效果通常更好,但推理时自回归逐字生成,延迟高、还容易重复输出。RNN-T 介于两者之间,适合流式,但实现复杂度上一个台阶。这个项目选 CTC,是把「能训起来、能改成流式、推理快」放在了「极限准确率」前面。
2.3 目录结构:每个文件夹管哪一段
speech-asr/ ├── configs/ # yaml 配置:训练、模型、解码各一份 │ ├── train_conformer.yaml │ └── decode_ctc.yaml ├── data/ # 清单与词表,不放音频本体 │ ├── train.jsonl │ ├── dev.jsonl │ ├── test.jsonl │ └── vocab.txt ├── asr/ │ ├── dataset/ # Dataset、collate、特征提取 │ ├── model/ # conformer.py、subsampling.py、ctc.py │ ├── decode/ # greedy.py、beam_search.py、lm/ │ ├── train.py # 训练入口,支持 DDP │ ├── infer.py # 单条/批量推理 │ └── server.py # HTTP 服务 ├── tools/ # 算 CER、导出 ONNX、切分长音频 └── exp/ # 训练产物:ckpt、日志、tensorboarddata/只放清单不放音频,是为了让几千小时的语料能挂在不同机器上跑,清单里写相对路径或者对象存储的 key,换环境不用改代码。exp/每次训练按时间戳开一个子目录,checkpoint、日志、tensorboard 全丢进去,模型变差的时候能立刻回滚到上一版——这个习惯救过我不少次。tools/单独拎出来是因为算 CER 的脚本会被反复用,塞在训练代码里迟早会被改坏。
3. 数据管线:从 wav 到 batch 的四个环节
语音识别的项目,八成的时间在数据上。模型结构抄一抄就能跑,数据管线不对,训练十条都白搭。这一章按数据流动的顺序走一遍:清单怎么组织、特征怎么提、词表怎么建、变长序列怎么拼成一个 batch。
3.1 数据清单与标注格式:jsonl 比 txt 省事
清单用 jsonl,一行一条,字段固定:
{"wav": "wav/BAC009S0001W0001.wav", "duration": 3.42, "text": "我们下周一讨论这个方案"} {"wav": "wav/BAC009S0001W0002.wav", "duration": 5.18, "text": "这个季度的目标是降低返修率"}duration是给分桶用的,没有它就得在训练启动时把每条音频读一遍才能知道长度,几千小时的数据这一趟能白等半小时。text字段要做严格规整:只留汉字,去掉标点、数字、英文,数字统一转成中文读法。理由很简单,CER 的计算是逐字比对的,如果标注里有「2024」而模型输出「二零二四」,算出来就是四个错误,指标直接失真。
注意:规整逻辑要在训练、验证、测试三份清单上用同一个函数处理,别一份手动改了、另一份没改。这种不一致导致的指标偏差非常隐蔽。
3.2 特征提取:80 维 fbank 的三个参数
import torch import torchaudio def extract_fbank(wave: torch.Tensor, sr: int, n_mels: int = 80, dither: float = 0.0) -> torch.Tensor: """wave: (N,) 单通道浮点波形;返回 (T, n_mels)""" fbank = torchaudio.compliance.kaldi.fbank( wave.unsqueeze(0), num_mel_bins=n_mels, # 80 是当前主流,40 太糊、128 收益有限 sample_frequency=sr, frame_length=25, # 毫秒,对应 16k 下 400 个采样点 frame_shift=10, # 毫秒,对应 160 个采样点,一秒钟 100 帧 dither=dither, # 训练时给 1.0 加扰动,推理必须归零 energy_floor=0.0, window_type="hamming", ) # 倒谱均值归一化:抹掉录音设备和说话人声道带来的整体偏移 fbank = fbank - fbank.mean(dim=0, keepdim=True) return fbankframe_length和frame_shift决定了时间分辨率:25ms 覆盖大约 3 到 4 个汉语音节的部分,10ms 的帧移让 CTC 有机会对齐到声母韵母的边界。这两个值一旦定下就不能改,改了模型权重直接作废,因为输入的时间轴含义变了。dither是个小细节但容易出问题——训练时给一点随机扰动能让模型对底噪更鲁棒,推理时必须关掉,否则同一段音频两次识别结果不一样,做 A/B 测试时会被这种现象误导。
n_mels=80是这几年的默认值。40 维在安静环境下够用,一旦有背景音乐或者远场拾音就明显掉点;128 维带来的提升很小,参数量和计算量却实打实涨上去。中文语音识别还有一个常见增强是加一阶、二阶差分特征,端到端模型一般不用,交给卷积层自己学。
3.3 词表:中文按字建还是按 subword 建
中文的词表策略有两种:按字建,5000 到 6000 个常用字就能覆盖日常对话;按 subword(BPE)建,把高频词切成更长的单元,序列变短、训练更快。这个项目选按字,原因有三个:字表小、CTC 输出维度低、和 CER 的评测口径完全一致(CER 本来就是按字算的);换成 BPE 之后序列长度从 20 降到 12 左右,训练确实快一点,但错一个 subword 就错好几个字,定位问题反而更麻烦。
from collections import Counter def build_vocab(transcripts, min_count: int = 2): """transcripts: 所有标注文本的迭代器""" counter = Counter() for line in transcripts: counter.update(list(line.strip())) # 中文直接按字切 chars = sorted(c for c, n in counter.items() if n >= min_count) vocab = ["<blank>", "<unk>"] + chars # blank 必须固定在 0 号位 return vocabmin_count=2是把只出现过一次的生僻字丢掉,换成<unk>。blank 必须放在 0 号位,这是 CTC loss 的默认约定,挪到别的位置训练不会报错,但收敛会莫名其妙变慢,因为模型要额外学「0 号 token 其实不发音」这件事。<unk>放 1 号位,解码时遇到它会直接暴露「这段音频里有词表外的字」,比静默吞掉强。
3.4 Dataset 与变长 batch:按帧数分桶而不是按条数
语音样本长度差异极大,短的 1 秒、长的 30 秒。如果按条数组 batch,一批里混进两条长音频,显存就不够用了。正确做法是按帧数分桶:把长度接近的样本放在一起,每个 batch 的总帧数控制在阈值以内。
import json import torch from torch.nn.utils.rnn import pad_sequence class AsrDataset(torch.utils.data.Dataset): def __init__(self, manifest: str, vocab: dict, max_frames: int = 3000): self.items = [json.loads(l) for l in open(manifest, encoding="utf-8")] self.vocab = vocab self.max_frames = max_frames def __len__(self): return len(self.items) def __getitem__(self, idx): item = self.items[idx] wave, sr = load_audio(item["wav"], target_sr=16000) feats = extract_fbank(wave, sr) # (T, 80) feats = feats[: self.max_frames] # 超长音频直接截断,别硬撑 tokens = [self.vocab.get(c, 1) for c in item["text"]] # 1 是 <unk> return { "feat": feats, "feat_len": feats.size(0), "tokens": torch.tensor(tokens, dtype=torch.long), "token_len": len(tokens), } def collate(batch): feats = pad_sequence([b["feat"] for b in batch], batch_first=True) # pad 到本 batch 最长 feat_lens = torch.tensor([b["feat_len"] for b in batch]) tokens = pad_sequence([b["tokens"] for b in batch], batch_first=True) token_lens = torch.tensor([b["token_len"] for b in batch]) return {"feat": feats, "feat_len": feat_lens, "tokens": tokens, "token_len": token_lens}pad_sequence只在 batch 内部对齐,不同 batch 的T可以不一样,这是变长训练的关键。feat_len和token_len必须一路传到 loss 里去,CTC 靠这两个张量知道哪些位置是 padding、不该参与计算。如果忘了传,padding 部分会被当成真实的静音帧去对齐,模型会学到「长音频结尾全是 blank」这种错误的规律。
max_frames=3000对应 30 秒,超过就截断。更严谨的做法是把长音频在数据准备阶段按静音切分成短段,但截断在工程上够用,而且能防止某条坏数据把整个 batch 显存撑爆。
4. 模型与训练:Conformer 编码器加 CTC 的逐段拆解
模型这块我把代码拆成三层看:单层 Conformer Block 长什么样、一个训练步里 loss 怎么算、解码怎么把 logits 变成句子。三层分开看都不复杂,合起来容易乱的地方在张量的形状变换上,(B, T, V)和(T, B, V)转错一次,之后所有报错都在误导你。
4.1 Conformer Block:卷积和注意力怎么拼
import torch import torch.nn as nn class ConformerBlock(nn.Module): def __init__(self, d_model=256, n_head=4, conv_kernel=15, dropout=0.1): super().__init__() self.ffn1 = FeedForward(d_model, dropout) # Macaron 结构的前半个前馈 self.attn = RelPosMHSA(d_model, n_head, dropout) # 带相对位置编码的多头注意力 self.conv = ConvModule(d_model, conv_kernel, dropout) # 深度可分离卷积,抓局部细节 self.ffn2 = FeedForward(d_model, dropout) # 后半个前馈 self.norm = nn.LayerNorm(d_model) def forward(self, x, mask=None): # x: (B, T, d_model) x = x + 0.5 * self.ffn1(x) # 系数 0.5 是论文里的经验值,残差相加更平稳 x = x + self.attn(x, mask) # 全局建模,处理连读和长距离依赖 x = x + self.conv(x) # 局部建模,处理声母韵母的快速变化 x = x + 0.5 * self.ffn2(x) return self.norm(x) # Pre-Norm 结构,最后一层再归一化d_model=256、n_head=4是这个项目在单卡 24G 上能跑起来的配置,层数 12。再往上加,中文的 CER 提升大概只有零点几个百分点,训练时间却翻倍,性价比不高。conv_kernel=15覆盖约 150ms 的上下文,差不多是三个音节的跨度,能抓住协同发音;改成 7 就明显感觉连读的地方错得多了。
注意第一段和最后一段前馈都乘了 0.5,这是 Conformer 论文里的 Macaron 设计,目的是让每个子层的贡献更均衡。残差分支不乘这个系数,注意力层和卷积层也不乘——如果手滑把这个系数套到所有分支上,训练能收敛,但收敛速度会慢一截,而且很难看出原因。
下采样层放在编码器前面,两层 stride=2 的卷积,把 T 压到 T/4。这一步对显存影响很大,30 秒的音频原本有 3000 帧,压完只剩 750 帧,注意力矩阵从 3000×3000 降到 750×750,差了一个数量级。
4.2 训练步:CTC loss 与混合精度
import torch from torch.cuda.amp import autocast, GradScaler def train_step(model, batch, criterion, optimizer, scaler): feats = batch["feat"].cuda(non_blocking=True) feat_lens = batch["feat_len"].cuda(non_blocking=True) tokens = batch["tokens"].cuda(non_blocking=True) token_lens = batch["token_len"].cuda(non_blocking=True) optimizer.zero_grad(set_to_none=True) with autocast(): # 混合精度,显存大约省四成 logits, out_lens = model(feats, feat_lens) # (B, T/4, V) log_probs = logits.log_softmax(dim=-1).transpose(0, 1) # CTC 需要 (T, B, V) loss = criterion(log_probs, tokens, out_lens, token_lens) scaler.scale(loss).backward() scaler.unscale_(optimizer) # 反缩放后才能裁剪 torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) # CTC 前期容易梯度爆炸 scaler.step(optimizer) scaler.update() return loss.item()形状变换是这里最容易出错的地方。PyTorch 的nn.CTCLoss要求输入是(T, B, V),而模型输出习惯写(B, T, V),所以必须transpose(0, 1)。如果忘了转,CTC 会把 batch 维当时间维,能跑通、不报错,loss 也照样在降,但学出来的东西完全不对,属于最难排查的一类问题。
log_softmax要在模型外做,不能写成CTCLoss(..., log_softmax=True)又自己再算一遍,重复 softmax 会让概率分布过平,模型输出大量 blank。clip_grad_norm_的阈值 5.0 是经验值,CTC 在训练前期梯度的动态范围很大,不裁剪的话偶尔会梯度爆炸,loss 突然变成nan,之后权重全是nan,只能重头再来。
4.3 学习率与显存:warmup 不是可选项
def warmup_lr(step, warmup_steps=10000, peak_lr=2e-3, min_lr=1e-5): """Noam 式调度:先线性升温,再按 step 的平方根衰减""" if step < warmup_steps: return peak_lr * step / max(1, warmup_steps) return max(min_lr, peak_lr * (warmup_steps / step) ** 0.5)warmup_steps=10000看起来很长,但 Transformer 类的模型前几千步梯度非常不稳定,直接从 2e-3 开始训,大概率前几百步就崩了。peak_lr=2e-3配合d_model=256是我试出来比较稳的点,换更大的模型要相应调小。
显存这块的血泪经验:先跑一个 batch,用torch.cuda.max_memory_allocated()打出峰值,再倒推 batch 内总帧数的上限。不要凭感觉设batch_size=16,因为语音的 batch 是按帧数算的,16 条 3 秒的和 16 条 20 秒的完全不是一个量级。我一般的做法是给max_frames设 3000,让 sampler 按长度分桶,这样显存曲线是平的。
提示:混合精度训练时,
GradScaler的unscale_一定要在梯度裁剪之前调用。顺序反了裁剪的是缩放后的梯度,阈值 5.0 就失去了意义。
4.4 解码:贪心只能用来验证模型还活着
贪心解码三行就够,但它只适合在训练过程中看看模型有没有跑偏,不能拿去上线:
def greedy_decode(logits, tokenizer): """logits: (T, V) 的 log_softmax 结果""" ids = logits.argmax(dim=-1).tolist() out, prev = [], -1 for i in ids: if i != prev and i != 0: # 合并连续重复,去掉 blank out.append(i) prev = i return tokenizer.decode(out)问题在于 CTC 的帧间独立性假设太强,很多位置的概率是模糊的,贪心只看每个位置的最大值,没有全局视角。中文里同音字一多,错字率立刻上去。实际用的是前缀 beam search,再叠加 n-gram 语言模型:
from pyctcdecode import build_ctcdecoder # labels 的顺序必须和模型输出的 V 维完全一致,一个位置错位就全乱 decoder = build_ctcdecoder( labels=vocab, # ["<blank>", "<unk>", "一", "丁", ...] kenlm_model_path="lm/zh_giga.arpa", # n-gram 语言模型 alpha=0.6, # LM 权重,中文常见 0.4~0.8 beta=1.0, # 长度补偿,短句多时适当调大 ) log_probs = logits[0].log_softmax(dim=-1).cpu().numpy() # (T, V) text = decoder.decode(log_probs)alpha控制语言模型的话语权。调大了会把更多「听起来像但不像人话」的候选压下去,垂直领域里如果模型没在语言模型语料里见过相关词汇,调大反而会引入系统性错误。我的做法是准备一份 200 条的领域测试集,alpha从 0.4 扫到 0.9,每档算一次 CER,选最低的那档存进配置文件。
beta是长度惩罚,防止 beam search 偏向过短的输出。中文平均句长偏短,beta=1.0基本够用;如果发现输出经常吞掉句尾几个字,可以调到 1.5 试试。
4.5 包一层服务:从脚本到接口
from fastapi import FastAPI, UploadFile, File import tempfile, os app = FastAPI() model, tokenizer, decoder = load_all("exp/20250310/best.pt") # 启动时加载一次 @app.post("/asr") async def asr(file: UploadFile = File(...)): with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: f.write(await file.read()) path = f.name try: text = transcribe(path, model, tokenizer, decoder) # 复用 infer.py 的函数 finally: os.unlink(path) # 临时文件一定要删 return {"text": text, "duration": get_duration(path)}模型必须在启动时加载一次并常驻,model.eval()和torch.no_grad()都要加上。decode 器也一样,build_ctcdecoder每次调用都要重新加载 kenlm,几百毫秒就这么没了,接口 P99 会难看得离谱。
5. 避坑与排查:训练不收敛、CER 虚高这些坑
这一章全是我自己踩过的。语音识别的问题现象很统一——「看起来在训练,但结果就是不对」——原因却分散在数据、模型、解码三处。下面五条按排查顺序排,遇到问题先照着走一遍,比盯着 loss 曲线瞎猜快。
5.1 loss 降到 20 就不动,输出全是 blank
现象:训练十几个 epoch,loss 卡在 20 上下小幅震荡,推理输出空字符串或一个字。
原因:CTC 的 blank 占比天然很高,如果学习率偏大或者 warmup 太短,模型会退化解——对所有帧都预测 blank,loss 能降到一个不高不低的平台,但什么都没学会。另一个常见原因是词表里 blank 不在 0 号位,CTC 用默认blank=0,等于让模型把某个真实汉字当成了 blank。
解决:先把 warmup 拉长到 10000 步以上、峰值学习率降到 1e-3 试一轮;再打印vocab[0]确认是<blank>。如果前两条都没问题,检查tokens里有没有混进 0,真实汉字的 id 是 0 的话,这批样本会被当成全 blank 标签。
5.2 训练 loss 很低,但 dev 的 CER 高达 0.8
现象:训练集 loss 掉到个位数,验证集一算 CER 接近 1,输出全是高频字。
原因:最常见是解码器标签顺序和模型输出不一致。build_ctcdecoder的labels如果和训练时的vocab顺序不同,argmax 出来的 id 映射到错误的字上,模型学得再好也没用。其次是验证集标注没做规整,带标点、带空格,逐字比对时全算成错误。
解决:把vocab存成 txt 并用 md5 校验,训练和解码都读同一个文件;验证集用和训练集完全相同的清洗函数处理。这两步做完,CER 一般立刻从 0.8 掉到 0.2 以内。
5.3 安静测试集 CER 只有 0.08,真实录音全线崩
现象:实验室录的数据 CER 很好看,换成客服、会议的真实录音,错字率翻三倍。
原因:训练数据太干净,麦克风单一、无背景音、语速均匀。模型学到了「这个特定声学条件下的映射」,而不是「中文发音的映射」。
解决:加数据增强——速度扰动(0.9 到 1.1 倍)、音量扰动、加噪、加混响,SpecAugment 在频域和时间域做掩码也很有用。真实录音补个几十小时的效果远超过继续堆模型层数。另外推理前做一次倒谱均值归一化(CMVN),能抹掉一部分录音设备差异,这一步几乎零成本。
5.4 训练到一半显存爆了,但 batch_size 没改
现象:前几个 epoch 正常,某个 step 突然 OOM,重跑之后又在差不多位置挂。
原因:数据是按帧数分桶的,但分桶逻辑如果只按训练集长度排序、没限制单 batch 总帧数,遇到几条长音频凑在一起就会超。或者某条坏数据时长异常(比如标注成 3 秒实际是 3 分钟)。
解决:在 sampler 里加硬限制,一个 batch 的总帧数不超过max_frames;数据准备阶段扫一遍duration分布,把超过 60 秒的挑出来单独处理。另外max_frames的截断要在特征提取之后立刻做,不要等到 collate 再截,否则前面的计算已经吃掉了显存。
5.5 服务上线后单条推理 2 秒以上
现象:本地脚本跑一条 5 秒音频不到 300ms,包成接口之后要 2 秒。
原因:三个地方容易遗漏——模型没调eval(),dropout 和 BN 还在训练模式,不仅慢还不稳定;解码器每次请求都重新构建,kenlm 加载就占了大头;fbank 提取跑在 CPU 单线程上,没开torch.set_num_threads。
解决:启动时加载并常驻模型与解码器,model.eval()和torch.no_grad()都加上;模型可以转 half 精度,推理速度大概提升三成,CER 影响在 0.1 个百分点以内,值不值看业务;torch.set_num_threads(4)让特征提取并行起来。这三条做完,P99 一般能压到 500ms 以内。
6. 二次开发:换领域数据、加热词、上流式
拿到这个项目之后,绝大多数人不会从零训,而是在已有权重上做垂直领域适配。我一般分三步走,每一步都先跑基线再动手。
第一步,先量基线。用你的领域数据(比如工单录音)切出 200 条做测试集,人工校对,跑一遍原始模型算 CER。这个数字是后面所有改动的参照,没有它,改完你根本判断不了是变好还是变坏。
第二步,小学习率微调。冻结编码器前 8 层,只训后 4 层加 CTC 头,学习率设到原训练的十分之一,跑 3 到 5 个 epoch 就够。
# 只解冻后 4 层编码器和输出层,其余冻结 for name, p in model.named_parameters(): p.requires_grad = any(k in name for k in ["encoder.layers.8", "encoder.layers.9", "encoder.layers.10", "encoder.layers.11", "ctc"]) optimizer = torch.optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=2e-4)冻结前 8 层的理由是底层学的是通用声学特征(清浊音、共振峰),换领域也用得上;顶层学的是具体场景的映射,必须重训。全量微调也能收敛,但小数据集上非常容易过拟合,验证集 CER 先降后升,很难停在合适的位置。
第三步,加解码侧偏置。领域专有名词(产品名、型号、缩写)在训练语料里出现得少,模型永远识别不对。不改模型也能救:把这些词整理成一个热词表,在解码时做上下文偏置,或者直接往语言模型的语料里补。
| 改造场景 | 改动点 | 验证指标 |
|---|---|---|
| 垂直领域适配 | 冻结前 8 层,微调后 4 层 + CTC 头 | 领域测试集 CER,对比基线 |
| 专有名词识别 | 热词表 + 解码偏置 / 补 LM 语料 | 名词召回率,别只看整体 CER |
| 实时字幕 | chunk 切分 + 右侧上下文 overlap | 首字延迟、边界丢字率 |
| 端侧部署 | 导出 ONNX,量化为 int8 | 单线程 RTF、CER 掉点幅度 |
流式改造单独说一句:把长音频按 1 秒切片、每片带 300ms 右侧上下文再喂给模型,能兼顾延迟和准确率。边界丢字是这类改造最常见的问题,原因通常是切片处刚好切在一个字的中间,右侧上下文就是带来处理它的。我一般的验证方法是专门造一组「跨切片句子」的测试集,看边界位置的错误率是不是明显高于句中位置。
从那以后我每次动模型或解码参数之前,都先把基线 CER 和环境音测试集跑一遍存档,改完再跑一遍对比——不然改了两天,连是变好还是变坏都说不清,只能靠感觉,那是最消耗精力的事。希望帮到你。
本文还有配套的精品资源,点击获取