
简介面向深度学习中文语音识别研究与课程设计、毕业设计场景压缩包提供从模型训练到服务部署的完整工程实现。核心包含Keras与PyTorch双后端语音模型、训练/评估/预测脚本、HTTP与gRPC服务端及客户端源码并附Dockerfile、依赖清单、配置文件与thchs30等数据列表便于复现实验和二次开发。资源共48个文件以26个Python脚本为主辅以13个txt说明、lst数据列表、proto接口定义、Dockerfile、JSON配置及HTML页面整体仅5.97MB结构紧凑。目前已有65人学习/下载。对搭建中文语音识别基线系统的学习者可据此理解语音特征提取、声学模型训练、语言模型融合和服务化推理流程节省环境配置与代码组织时间。1. 这个 zip 里装的不是模型是一套关于中文语音的「约定」我拿到过很多个「基于深度学习的中文语音识别系统」的 zip 交付包解压之后第一反应都一样先找 README再找模型文件然后迫不及待扔一条 wav 进去期望它吐出一句人话。但这类包通常不是「一个模型」而是一整套关于中文语音的约定——采样率、帧长、词典、blank 索引、cmvn 参数这些约定只要错一个模型就变成黑匣子。这套东西从零搭起来并不玄学真正难的是让训练数据和线上场景保持一致。这篇文章顺着解压、推理、训练、部署的顺序把一套能跑的中文语音识别系统拆开讲适合刚入门的工程师也适合想快速复现的毕业设计。目标只有一个让 zip 里的东西真的在你自己机器上转起来。2. 帧对齐、词典与部署场景中文语音识别先选框架再动手2.1 为什么端到端方案成了中文 ASR 的默认起点把「深度学习」四个字先放一边中文语音识别最早走的是 GMM-HMM 做声学模型、配合词典和 n-gram 语言模型分两条 pipeline 拼出来的路线。这套东西在短句近场场景下很稳但训练链路长换一个说话人就要重调一遍字表一大词典的维护成本也跟着上来。深度学习方案真正的优势不是「参数多」而是把「音频特征到文本序列」改成同一个模型里的端到端对齐训练目标直接就是最终输出文字。中文有一个天然特性音节结构清楚大约 400 个无调音节、1300 个带调音节但常用字覆盖到 3000 字以上才能撑起日常文本而且字与字之间没有像英文那种天然空格边界。CTC 的单调对齐假设放在中文上非常合适它允许每一帧输出 blank 或重复折叠最终只要求整条序列的概率最大正好匹配中文音节的线性顺序。Attention 类模型提供全局上下文能提升长句效果但推理时没有单调约束长音频内容易漂移做流式又麻烦。RNN-T 对低延迟场景更友好每帧可以输出零到多个 token但训练复杂度高工程上要处理延迟惩罚不是改改配置就能跑的东西。三类方案的取舍我一般这么看建模方式对齐约束是否支持流式训练成本部署成本中文落地现状CTC帧与输出单调对齐可做低低基线稳配合外部语言模型效果最好AttentionLAS 类全局依赖无单调约束难高中高离线整句效果好长句易漂移RNN-T每帧可输出多 token天然支持高高低延迟场景主流调参成本高2.2 部署场景决定框架边界不盲目追最热模型框架选型不是看谁的论文指标高而是看你的音频从哪来、识别结果用在哪。离线批量转写比如把一批会议录音变成文字我一般会选「CTC 基线模型 外部 n-gram 语言模型 rescoring」的组合这个路线实现成熟、问题好定位。整句识别偶尔出现串字靠语言模型打分就能救回来一部分。流式识别就不一样了。麦克风边说边出字模型必须在有限延迟内给出结果这就强制要求因果结构。CTC 配合因果卷积或 GRU 能做局部解码但工业落地里更多人愿意上 RNN-T因为它对「已经说了几个字」有显式建模。只是 RNN-T 的延迟惩罚和数据需求都更高如果团队没有专门的 ASR 经验我建议先用 VAD 切句、再对每个短句做离线识别先把业务跑起来这类取巧方案在大多数非实时场景够用。设备端 CPU 部署又是另一套约束。CTC 的线性层计算量不大配合时序卷积或 LSTM参数量可以控制在 80M 以内用 onnxruntime 在 CPU 上就能跑。这时候要特别注意int8 量化会把 CTC 输出的概率分布压平CER 可能明显上升量化后必须拿回归音频重新验收不要因为「F1 指标没变」就跳过这一步。2.3 建模单元选字还是选拼音中文 ASR 的建模单元是很多人拿到 zip 之后忽略的第一个关键配置。直接建模汉字最简单字表 30005000 常用字模型输出维度等于字表大小加一blank。好处是解码出来就是文本不需要二次转换坏处是遇到生僻字直接废掉OOV 没有兜底。建模拼音带声调能利用发音学规律任意文本都可以通过词典映射成拼音序列但同音字问题会转移到后处理必须再接一层「拼音转汉字」或语言模型工程链明显拉长。如果业务里经常出现中英混读比如「WiFi 密码是 xxx」「2024 年计划」就得在字表里加大写字母和数字 token或者在文本归一化阶段先把数字转成中文、英文保留原样。这个决定要放在训练之前做定不然后面加 token 等于从头训。拿到 zip 后第一件事就是看 conf 里的 vocab.txt 行数对不对得上模型输出维度这一点我在下一章会再提一遍因为它值得。3. 解压到最小推理脚本16k wav 变成文本的四步流水线3.1 解压后先认「四件套」目录解压完别急着跑先花十分钟把目录认清楚。大多数中文 ASR 工程包逃不出这四类东西目录/文件内容落地时我关注什么model/模型结构定义.py 或 .onnx输入特征维度、输出 vocab 大小checkpoints/训练好的参数.pt / .ckpt / .onnx与 model 结构是否匹配、epoch 和 lossconf/特征参数、训练参数采样率、fbank 维度、帧长帧移、blank 索引data/示例 wav 和标注 jsonl跑回归验证用千万别删很多交付包喜欢把 data 里那几条示例音频留着那是故意的。它对应着 README 里某个预期输出是你验证「环境没问题」的基准。我的血泪经验是一定先用这组示例音频跑通确认输出和文档一致再拿自己的音频来试。否则环境问题、模型问题、数据问题混在一起最后只能靠猜。3.2 最小推理脚本torchaudio 特征 CTC 解码推理链路就四步读 wav → 提 fbank 特征 → 模型 forward → 解码出文本。下面这段代码假设 zip 里的模型是 PyTorch 格式、conf 里约定采样率 16k、blank 索引为 0这是最常见的配置。import torch import torchaudio def load_wav(path, sample_rate16000): wav, sr torchaudio.load(path) if sr ! sample_rate: wav torchaudio.functional.resample(wav, sr, sample_rate) if wav.size(0) 1: wav wav.mean(dim0, keepdimTrue) # 双声道取均值降为单声道 return wav def make_feature(wav, num_mel_bins80): feat torchaudio.compliance.kaldi.fbank( wav, num_mel_binsnum_mel_bins, frame_length25, # 帧长 25ms frame_shift10, # 帧移 10ms ) # 简单 cmvn训练时一般用全局均值和方差这里用样本内统计 feat (feat - feat.mean(dim0)) / (feat.std(dim0) 1e-8) return feat.unsqueeze(0) # [1, T, D] model torch.load(checkpoints/ctc_model.pt, map_locationcpu) model.eval() with open(conf/vocab.txt, encodingutf-8) as f: vocab [line.strip() for line in f if line.strip()] wav load_wav(data/example.wav) feat make_feature(wav) with torch.no_grad(): logits model(feat) # [1, T_out, vocab_size] prob logits.log_softmax(dim-1) pred prob.argmax(dim-1)[0] # [T_out] # CTC 解码折叠重复字符并跳过 blank这里约定 blank 是索引 0 text [] last None for idx in pred.tolist(): if idx 0: last None continue if idx ! last: text.append(vocab[idx - 1]) # 词典从索引 1 开始 last idx print(.join(text))逻辑说明torchaudio.compliance.kaldi.fbank生成的 kaldi 风格 fbank 是 ASR 训练里最通用的特征形态和训练侧参数必须完全一致frame_length25、frame_shift10的意思是每 25ms 一帧、帧间重叠 15ms这是保证输入侧稳定的基本盘。cmvn 的严格做法是从标注数据统计全局均值方差推理脚本里用样本内 mean/std 只是为了一开始能跑通正式部署必须换成训练时统计好的文件否则模型见的特征分布和训练不一样。参数上num_mel_bins80是中文 ASR 常用配置偶尔也有 40 或 83含能量维度的做法以 conf 里写死的为准。torch.load注意map_locationcpu不然在无 GPU 的机器上会报错。解码部分先把重复标签折叠、再跳过 blank这是 CTC「多帧对应一个字」假设的直接体现。3.3 跑通后先做回归验证别急着调参推理脚本能出字只是第一步接下来做三个最小验证。第一把同一段 wav 连续跑两遍输出必须完全一致如果 CPU 和 GPU 结果不同多半是 cmvn 或浮点精度导致后续调参时要固定环境。第二准备三条短音频一条安静室内、一条带底噪、一条只有 0.5 秒的超短句确认基本行为正常超短句最容易暴露「模型把静音尾帧也当成音频」的问题。第三拿 zip 里的示例音频先对 README 输出别拿自己录的音频开局。这三点都过了说明环境是干净的这个包已经可以当黑匣子用。下一步才轮到训练自己的数据或者换自己的模型进去。4. 训练闭环从一批中文音频到收敛模型的三个关键环节4.1 标注文件长什么样jsonl 格式与数据校验训练数据的入口是标注文件。常见做法是用一行一条的 jsonl里面带音频路径、转写文本和时长{wav: /data/audio/001.wav, text: 今天天气不错, duration: 2.34} {wav: /data/audio/002.wav, text: 请播放周杰伦的歌, duration: 3.10}训练之前先跑一遍校验脚本这一步能避免后面九成的训练翻车。我自己会在数据准备阶段固定执行下面的检查import json, os from pathlib import Path import torchaudio def validate_data(jsonl_path, expect_sr16000): with open(jsonl_path, encodingutf-8) as f: items [json.loads(line) for line in f if line.strip()] errors [] for it in items: wav_path it[wav] if os.path.isabs(it[wav]) else os.path.join(/data/audio, it[wav]) if not Path(wav_path).exists(): errors.append((missing, it[wav])) continue if not it.get(text) or not it[text].strip(): errors.append((empty_text, it[wav])) continue wav, sr torchaudio.load(wav_path) if sr ! expect_sr: errors.append((sample_rate, it[wav], sr)) dur wav.size(1) / sr if abs(dur - it.get(duration, 0)) 0.2: errors.append((duration_mismatch, it[wav], dur)) return items, errors这段脚本只做四件事查缺失文件、查空文本、查采样率、查时长标注是否和真实音频一致。前两项是低级错误后两项直接影响训练时的 batch 组装和 loss 计算。采样率不一致尤其隐蔽——torchaudio 默认会把数据读成原始采样率如果你的标注里混了一条 8k 的音频特征维度没变但频带信息完全错位模型经常靠「猜」来拟合它最后损失曲线看着正常、识别结果全乱。4.2 最小训练循环CTC loss、学习率、梯度裁剪数据校验通过后训练循环本身不需要太多花活。我用 PyTorch 训练 CTC 模型时核心代码和参数长期保持着这套骨架import torch import torchaudio from torch.utils.data import Dataset, DataLoader LR 1e-4 # 初始学习率ASR 常用区间 1e-4 ~ 5e-4 BATCH_SIZE 16 GRAD_CLIP 5.0 # 梯度裁剪阈值 BLANK_INDEX 0 MAX_FRAMES 600 # 超过 600 帧的样本直接截断 class AsrDataset(Dataset): def __init__(self, items, vocab): self.items items self.dict {ch: i 1 for i, ch in enumerate(vocab)} def __len__(self): return len(self.items) def __getitem__(self, i): item self.items[i] wav, sr torchaudio.load(item[wav]) if sr ! 16000: wav torchaudio.functional.resample(wav, sr, 16000) if wav.size(0) 1: wav wav.mean(dim0, keepdimTrue) feat torchaudio.compliance.kaldi.fbank( wav, num_mel_bins80, frame_length25, frame_shift10 ) feat (feat - feat.mean(0)) / (feat.std(0) 1e-8) feat feat[:MAX_FRAMES] text [self.dict.get(ch, len(self.dict) 1) for ch in item[text]] return feat, torch.tensor(text, dtypetorch.long) def collate(batch): feats, texts zip(*batch) lens [f.size(0) for f in feats] max_len max(lens) feat_padded feats[0].new_zeros((len(feats), max_len, feats[0].size(1))) for i, f in enumerate(feats): feat_padded[i, :f.size(0)] f text_cat torch.cat(texts) # 一维拼接避开 padding/blank 混淆 target_lengths torch.tensor([t.size(0) for t in texts], dtypetorch.long) return feat_padded, text_cat, target_lengths def train_one_epoch(model, loader, opt, device): model.train() total 0.0 for feats, text_cat, target_lengths in loader: feats feats.to(device) text_cat text_cat.to(device) target_lengths target_lengths.to(device) opt.zero_grad() logits model(feats) # [B, T_out, V] log_probs logits.log_softmax(dim-1) input_lengths torch.full( (feats.size(0),), log_probs.size(1), dtypetorch.long, devicedevice ) loss torch.nn.functional.ctc_loss( log_probs.transpose(0, 1), # [T_out, B, V] text_cat, # (sum(target_lengths),) input_lengths, target_lengths, blankBLANK_INDEX, reductionmean, zero_infinityTrue, ) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), GRAD_CLIP) opt.step() total loss.item() return total / len(loader)参数说明学习率 1e-4 是中文 ASR 里偏稳的起点新手别一上来就开 1e-3CTC loss 对学习率很敏感。GRAD_CLIP5.0是阶梯、不是摆设长音频样本很容易在某一步产生超大梯度裁一下能避免 loss 突然跳到 NaN。zero_infinityTrue处理「目标序列为空」的情况虽然训练数据里不该有空文本但这一行能防止单条坏数据把整个 loss 变成 inf。一个容易踩的细节在input_lengthsCTC 要求的长度是模型输出的时间步数T_out不是输入音频帧数。如果你的 encoder 带了 stride2 的下采样或者 Transformer 做了裁剪就必须由模型返回实际输出长度或者在训练循环里像上面这样直接取log_probs.size(1)。把原始帧数当成输入长度传进去是 CTC loss 报错和训练不对齐的高频原因。4.3 数据增强是防止「训练 loss 降、验证 CER 不降」的关键模型结构确定后影响最终识别效果的往往是数据侧。SpecAugment 是性价比最高的增强给 fbank 特征随机遮蔽若干频带和时间段频率遮蔽范围 F 取 30、时间遮蔽范围 T 取 40 是比较常见的起点。它能强迫模型不依赖某一小段频谱泛化能力明显变好。加噪是第二个重点。随机叠加背景噪声信噪比 SNR 在 520dB 之间随机取值比固定 SNR 效果好得多。还有个反直觉的点如果训练数据全是单人录音val loss 看着很漂亮一换说话人就崩。这种「假收敛」在中文 ASR 里很常见解决方式是训练集里保证至少 5 个以上不同说话人男女比例别太偏。时长分布也要看batch 里混入 0.5 秒和 20 秒的音频梯度方差会很大按时长分桶以后训练稳定度能上一个台阶。5. 中文 ASR 踩坑清单训练期和推理期分开排查5.1 训练阶段的三个典型翻车现场第一个坑loss 初始值就不正常或者直接 NaN。现象第一个 step 的 loss 是几百跑两步变成 NaN。最常见的原因是学习率偏大、音频里混入坏文件、特征出现 inf。原因拆开看坏文件包括全静音、截断的 wav、采样率异常它们会让 fbank 统计值异常学习率太大则让 log_softmax 之后出现极端概率反向传播梯度爆炸。解决先跑 4.1 的校验脚本过滤坏数据再把初始学习率降到 1e-5 跑两个 step看 loss 是否「先升后降」。确认正常后逐步把学习率调回去。同时把zero_infinityTrue和clip_grad_norm_打开这两个开关能在数据偶发异常时兜底。第二个坑训练 loss 降了验证集 CER 纹丝不动。现象train loss 从几十降到 1 以下验证集字错率卡在 0.4 上下不动。原因模型把说话人音色、背景噪声、录制设备当成了识别线索而不是真正学语言的声学对应另一个常见原因是验证集文本里有大量生僻字不在字表里CER 永远下不去。解决先统计验证集文本的 OOV 比例超过 1% 就要补字表或者做文本归一化。然后上 SpeclAugment 和多说话人数据。最容易被忽略的一点验证集和训练集的时长分布、噪声环境要尽量一致否则评估结果反映的是「domain shift」不是模型真实水平。第三个坑梯度爆炸和显存 OOM 交替出现。现象loss 在某一步跳到 NaN或者直接 CUDA out of memory。原因batch 里长短音频混在一起长样本贡献了极端梯度显存被长序列的 attention 矩阵撑爆。解决按时长 bucketing把相近时长的样本放一个 batchclip_grad_norm_放到每个 step 最后一层都没问题有条件就开混合精度 AMP显存占用直接省一半。还不行就降 batch sizeASR 任务上 batch 16 和 batch 32 的最终效果差距并不悬殊。5.2 推理与交付阶段的四个边界问题第四个坑线上音频采样率和训练配置不一致。现象本地跑得好好的模型接到线上识别结果全乱或者有时候好有时候坏。原因线上音频是 8k 电话录音训练数据是 16k或者用户上传的是 44.1k 音乐文件模型特征按 16k 的频带分布计算信息完全不匹配。解决在推理入口统一重采样为 16k 单声道其他采样率一律先转再进模型。这块要写到接口文档里不能只靠模型自己适应。第五个坑长音频直接喂给模型导致超时或漂移。现象一条 10 分钟音频送进去等了很久没返回或者后半段全是重复字。原因attention 模型对长序列的显存和时间复杂度都是平方级增长CTC 虽然好一些但超过训练时最大长度的序列也会开始乱输出。解决用 VAD 把长音频切成 515 秒的短句再逐条识别最后按时间戳拼接结果。VAD 的静音阈值要按实际环境调切太碎会让语义断裂、切太粗又等于没切。第六个坑中英混读和数字乱码。现象「请播放周杰伦的《晴天》」识别结果是「请播放周杰伦的清甜」或者「2024 年」变成一串乱码。原因字表里没有英文和数字 token模型把英文音节强行映射成谐音汉字。解决训练前明确文本归一化规则——数字转中文读法、英文保留原样并加入字表必要时单独加一组英文小写 token。这个决策必须发生在训练前推理阶段只能靠后处理打补丁效果有限。第七个坑标点符号和语气词标注规格不一致。现象训练数据里有的标注带句号逗号有的不带「嗯」「啊」有时保留有时删除。模型训练时目标不一致输出就飘忽不定。原因标注规范没有写死。解决训练前定一条硬规则——标点全部删除语气词按业务需求决定统一留或不留。CER 评估时也要用同一套规则过滤标点否则不同标注风格之间对比没有意义。6. 把 CTC 解码换成 beam search一个能立刻降低 CER 的改动如果整个 zip 项目已经能训能推接下来最值得做的一个改动是把贪心解码换成 beam search。argmax是每帧独立取概率最高的标签完全忽略了上下文约束beam search 维护 Top-K 条候选序列让字符之间的转移概率参与排序中文场景下 CER 通常能降 25 个点。下面是一个教学用的简化 beam search真正的工业实现在重复折叠时还要区分「以 blank 结尾」和「以非 blank 结尾」两套状态这里先看核心思路def ctc_beam_search(log_probs, beam_size5, blank0): # log_probs: [T, V]已经取过 log_softmax beams [(0.0, [])] for t in range(log_probs.size(0)): new_beams [] for score, seq in beams: for idx in range(log_probs.size(1)): p log_probs[t, idx].item() if idx blank: new_beams.append((score p, seq)) elif seq and seq[-1] idx: new_beams.append((score p, seq)) # 连续相同字在此折叠 else: new_beams.append((score p, seq [idx])) new_beams.sort(keylambda x: x[0], reverseTrue) beams new_beams[:beam_size] return beamsbeam_size 从 1 调到 10 通常就有明显变化再往上收益递减。这个版本没有显式建模「同一字符重复两次」的语言学情况严格场景还是建议用带前后状态的实现或者对接外置 n-gram 语言模型做 rescoring——在验证集上先算一版纯 CTC 的 CER再接语言模型看 CER 变化接完不降就检查文本归一化。我最初跑这套系统时盯着 argmax 能出字就以为完事了后来把 beam search 宽度从 1 调到 10同一批测试音频 CER 从 0.31 降到 0.27才意识到解码侧还有这么多可抠的空间。现在我每到一个新环境都先固定录音格式、固定解码参数、再做回归对比把这三件事列成 checklist。识别系统的落地能力往往就藏在这些参数细节里。希望帮到你。本文还有配套的精品资源点击获取