做语音转写项目以后,我对一个问题的感受越来越明显:
ASR模型跑通,并不代表语音系统真正可用。
实验室里拿一段十几秒的干净录音测试,识别结果往往不错。但一旦进入真实会议环境,问题很快就出来了:
-
空调声、键盘声导致误识别;
-
说话人停顿以后,下一句话开头被截掉;
-
多人连续讲话时出现断句异常;
-
长时间会议转写越来越慢;
-
CPU和内存持续升高;
-
端侧设备运行一段时间以后发热明显;
-
断网环境下模型和服务不好维护。
这类问题不能靠"换一个更大的模型"全部解决。
真正需要优化的是

这一整条链路。
本文就从这个角度,把几个比较典型的问题拆开分析。
一、先把问题定义清楚:ASR为什么"测试很好,实际不好"?
一个完整的离线语音处理链路,大致可以设计成:

问题恰恰出在这里。
ASR只是其中一个环节。
如果麦克风采集的声音质量不好,ASR模型拿到的输入本身就是有问题的。
如果VAD切错了,模型甚至没有机会看到完整的一句话。
如果任务调度设计不合理,即使模型本身速度不错,系统仍然可能出现排队。
所以实际优化时,第一原则是:
不要一上来就改ASR模型,先确认问题到底发生在哪一层。
二、第一个问题:噪音环境下,为什么识别结果突然下降?
这是最常见的问题。
例如会议室里有人敲键盘,空调持续运行,旁边还有人在低声讨论。
这时候ASR可能出现:
正常:
"项目计划下周开始执行"
异常:
"项目计划下周......开始执行"
甚至出现一些完全不存在的文字。
解决思路
不要直接:
麦克风 → ASR
而是增加音频前处理。
比较基础的流程是:

其中,**VAD(Voice Activity Detection)**非常关键。
它的目标不是识别文字,而是判断:
当前这段声音是不是有效的人声?
WebRTC等方案本身就包含VAD相关实现,并且实际处理时通常会按照较小的音频帧进行判断。
三、第二个问题:VAD为什么容易把一句话切掉?
这是实际项目里非常容易踩的坑。
比如用户说:
"我们今天主要讨论三个问题。"
如果VAD太敏感,可能切成:
我们今天
主要讨论三个问题
甚至:
我们今天主要
讨论三个问题
更麻烦的是一句话结尾。
用户说完以后停顿500ms,然后继续说:
"第一个是项目进度。"
如果系统把500ms停顿当成一句话结束,那么最终可能变成两个非常短的片段。
解决方法:不要用"一帧决定结果"
可以采用一个简单的状态机。

核心思想是:
检测到一次静音,不要立即结束。
给它一个缓冲窗口。
例如:
public class VadStateMachine {
private static final int END_SILENCE_MS = 700;
private boolean speaking = false;
private long silenceStart = -1;
public boolean process(boolean speechDetected, long now) {
if (speechDetected) {
speaking = true;
silenceStart = -1;
return true;
}
if (!speaking) {
return false;
}
if (silenceStart < 0) {
silenceStart = now;
}
if (now - silenceStart >= END_SILENCE_MS) {
speaking = false;
silenceStart = -1;
return false;
}
return true;
}
}
这个代码虽然简单,但里面体现了一个很重要的工程思想:
语音边界判断需要考虑上下文,而不是只看当前帧。
实际项目中还可以进一步增加:
-
最短语音长度;
-
最短静音长度;
-
前置padding;
-
后置padding;
-
连续多帧确认;
-
噪声环境自适应阈值。
这样可以明显减少"吞掉句首"和"过早截断"的问题。
四、第三个问题:为什么长会议越转越慢?
假设一场会议持续两个小时。
最简单的实现可能是:

这种方式短音频没什么问题。
但长音频很容易出现内存压力和异常恢复困难。
更合理的方式应该是:

这里实际上涉及一个非常重要的知识点:
生产者-消费者模型
音频处理线程负责产生任务:
Producer
ASR线程负责消费任务:
Consumer
两者之间使用阻塞队列连接。
Java实现可以非常简单:
BlockingQueue<AudioSegment> queue =
new LinkedBlockingQueue<>(100);
ExecutorService executor =
Executors.newFixedThreadPool(2);
executor.submit(() -> {
while (!Thread.currentThread().isInterrupted()) {
AudioSegment segment = queue.take();
String text = asrService.recognize(
segment.getAudio()
);
resultService.save(
segment.getStart(),
segment.getEnd(),
text
);
}
});
这样做有两个好处。
第一,音频采集不需要等待ASR结束。
第二,可以通过队列长度控制系统压力。
如果:
队列长度 > 阈值
就说明当前ASR处理速度跟不上输入速度。
这时候应该:
-
降低并发;
-
增加Worker;
-
缩短分片;
-
调整模型;
-
或者启用硬件加速。
而不是无限创建线程。
五、第四个问题:为什么"并发越高"反而越慢?
这是很多刚开始做AI推理服务时容易遇到的问题。
假设设备只有有限的CPU资源。
同时启动10个ASR任务:
Task1
Task2
Task3
...
Task10
并不代表速度会变成原来的10倍。
很可能变成:
CPU争抢
↓
缓存命中率下降
↓
内存压力增加
↓
上下文切换
↓
整体延迟增加
因此端侧语音服务更适合采用:
有限Worker + 有界队列
而不是无限并发。
例如:
int workers = Math.max(
1,
Runtime.getRuntime().availableProcessors() / 2
);
ThreadPoolExecutor executor =
new ThreadPoolExecutor(
workers,
workers,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(50),
new ThreadPoolExecutor.CallerRunsPolicy()
);
这里的 CallerRunsPolicy 也有一个好处。
当队列满了以后,提交任务的线程自己执行任务,相当于给系统施加背压。
这比任务无限堆积更加安全。
六、第五个问题:模型太大,端侧设备跑不动怎么办?
这个问题才涉及模型优化。
常见手段包括:
-
模型剪枝;
-
蒸馏;
-
FP16;
-
INT8量化;
-
图优化;
-
硬件加速;
-
模型分层加载。
其中量化比较常见。
以ONNX Runtime为例,它支持动态量化和静态量化,也提供了量化调试能力。需要注意的是,量化并不是无损操作,精度可能发生变化,所以不能只比较模型大小,还应该重新跑验证集。
一个简单的动态INT8量化示例:
from onnxruntime.quantization import (
quantize_dynamic,
QuantType
)
quantize_dynamic(
model_input="asr_fp32.onnx",
model_output="asr_int8.onnx",
weight_type=QuantType.QInt8
)
但是这里有一个误区:
模型变小 ≠ 一定跑得更快。
实际速度还和CPU指令集、推理后端、内存访问以及模型算子有关。
ONNX Runtime官方文档也明确说明,量化带来的性能收益依赖具体模型和硬件,某些老旧硬件上甚至可能出现收益不明显的情况。
所以正确做法是:

而不是看到"INT8"三个字就直接替换生产模型。
七、第六个问题:如何判断优化到底有没有效果?
这一步非常容易被忽略。
如果没有指标,所谓优化最后很容易变成:
"感觉快了一点。"
这在工程项目里是不够的。
建议至少记录下面几个指标。
| 指标 | 含义 |
|---|---|
| WER | 语音识别错误率 |
| RTF | 音频时长 / 推理耗时 |
| P95延迟 | 95%请求的延迟 |
| CPU | CPU平均/峰值 |
| Memory | 内存占用 |
| Queue Size | 等待任务数量 |
| VAD误检 | 非语音被判断为语音 |
| VAD漏检 | 人声被过滤 |
其中WER可以简单表示为:
WER = (S + D + I) / N
其中:
-
S:替换错误; -
D:删除错误; -
I:插入错误; -
N:参考文本词数。
例如参考文本:
今天讨论项目进度
识别结果:
今天讨论项目进度问题
多出了一个词,就属于插入错误。
八、一个更实用的测试方案
如果我要验证一套离线ASR系统,不会只准备一段干净音频。
建议至少准备五组数据:
Dataset-A
安静普通话
Dataset-B
空调/键盘噪声
Dataset-C
远距离讲话
Dataset-D
多人会议
Dataset-E
口音/专业术语
然后分别统计:
WER
RTF
P95延迟
CPU
内存
VAD误检率
VAD漏检率
这样最后得到的结果才有意义。
例如:
| 场景 | WER | RTF | P95延迟 |
|---|---|---|---|
| 安静环境 | 测试值 | 测试值 | 测试值 |
| 空调噪声 | 测试值 | 测试值 | 测试值 |
| 远场 | 测试值 | 测试值 | 测试值 |
| 多人会议 | 测试值 | 测试值 | 测试值 |
| 口音 | 测试值 | 测试值 | 测试值 |
这里我特意没有填"漂亮的数据"。
因为没有真实测试集和硬件环境,直接写一个98%、99%并没有意义。
工程文章最忌讳的就是拿没有测试依据的数据当结论。
九、最终落地架构可以这样设计
如果要把上面的方案真正做成一个离线会议语音服务,我比较推荐下面这种结构:

这个架构最大的特点不是复杂,而是模块之间职责比较清楚。
以后如果更换ASR模型,不需要把整个业务系统推倒重来。
换VAD,也只需要替换对应模块。
如果端侧硬件发生变化,也可以针对推理层重新优化。
十、实际项目中我更推荐"先定位,再优化"
最后总结一下。
真实会议场景中的ASR问题,大致可以按照下面的方法排查:

不要一看到识别错误,就马上换模型。
也不要一看到设备占用高,就直接增加线程。
语音系统本质上是一个完整的工程链路。
从我实际观察离线会议产品,包括熙瑾会悟这类产品时,一个比较明显的感受就是:真正决定体验的,往往不是某一个模型参数,而是模型、音频处理、调度和设备资源之间的配合。
十一、写在最后
以前我们说"语音识别",更多是在讨论:
能不能把声音变成文字?
现在进入端侧AI时代以后,这个问题已经不够了。
真正需要解决的是:
在有限算力、复杂噪声、长时间运行和离线环境下,系统能不能稳定地听懂人说的话?
所以一套成熟的端侧语音系统,至少应该同时考虑:
准确率、实时性、稳定性、功耗和数据安全。
模型只是基础。
VAD负责判断什么时候该听。
ASR负责判断说了什么。
声纹负责判断是谁说的。
任务调度负责保证系统不被压垮。
量化和硬件加速负责让模型真正跑得动。
这些能力组合起来,才是一套真正可以落地的离线语音方案。
我现在越来越认同一个判断:
离线语音,不只是能听,还要听得懂、反应快、功耗低。
这可能也是未来端侧语音真正需要解决的问题。