如何用 MLX 在 Apple Silicon 上构建设备端语音识别
OnType 的设备端语音识别由 mlx-swift-asr驱动。这是我们构建的一个开源 Swift 库,用来在 Apple Silicon 上原生运行 Qwen3-ASR。 没有 Python 运行时,没有子进程桥接,也没有云端,只有 Swift → MLX → Neural Engine。
这篇文章会讲清楚推理链路是怎么工作的、那些让它“感觉像即时响应”的延迟工程, 以及我们一路踩过的一些不明显的坑。
为什么是 Qwen3-ASR,为什么是 MLX
Qwen3-ASR 是阿里巴巴 Qwen 团队推出的语音识别模型。我们发布的是 0.6B 参数版本,量化到 6 bit 后,磁盘占用大约 400MB。它采用 Whisper 风格的音频编码器(Conv2d → Transformer),再接入使用 Grouped Query Attention、RoPE 和 SwiGLU 激活的 Qwen3 文本解码器。
Apple 的 MLX 框架是专门为 Apple Silicon 上的机器学习推理设计的。它相对于通用运行时的关键优势在于: MLX 理解 M 系列芯片的统一内存架构。CPU、GPU 和 Neural Engine 共享同一块内存池,不需要在主机内存和设备内存之间反复拷贝数据。 对于一个实时 ASR 管线来说,音频会持续流入,中间表示也会立刻被消费, 这就直接消掉了一整类延迟开销。
我们使用的是 mlx-swift,也就是原生 Swift 绑定,因此整条路径都留在 Apple 的生态里:Swift → MLX → Metal → Neural Engine。 整个过程中没有 Python 解释器。
推理管线
mlx-swift-asr 里的音频转文字路径分成五个阶段:
阶段 1:Mel 频谱图
16kHz 的原始音频会被转换成 128 bin 的 log-mel spectrogram。 参数与 WhisperFeatureExtractor 完全一致:400 样本 FFT 窗口(25ms), 160 样本 hop(10ms),以及 Slaney 风格的 mel filterbank 归一化。
Hann window 和 mel filterbank 矩阵会在启动时计算一次, 并缓存为静态属性。这只是一个小优化(每次转写大约能省 10ms), 但在流式管线里,每个 chunk 都会走这条路径,积少成多。
一个不太明显的兼容性细节是:我们会丢掉最后一个 STFT frame, 以匹配 PyTorch 的 torch.stft(center=True) 行为。 frame 数量差一个,就会让编码器发生维度不匹配。 这种 bug 最烦的地方在于模型不会直接崩掉,它只是吐出垃圾结果, 要排查非常久。
阶段 2:音频编码
Mel 频谱图会送入音频塔,也就是三层 stride-2 Conv2d 加一个 Transformer 编码器。这个阶段会把时间维压缩大约 8 倍,并把音频特征投影到文本解码器的隐藏维度。
这里还有一个细节:Conv2d 权重在模型加载时,需要从 PyTorch 的 OIHW 布局转置到 MLX 的 OHWI 布局。权重清洗步骤会自动处理这件事。
阶段 3:构造 Prompt
编码后的音频特征会按 Qwen3 的 chat template 格式并入文本 prompt。 prompt 里的音频占位 token 会通过基于 cumsum 的索引方案, 替换成真实的编码器输出 embedding。这和多模态 LLM 处理图像 token 的架构是同一种思路。
阶段 4:通过双缓冲生成 token
这里才是最有意思的性能工程部分。自回归解码一次只生成一个 token。 最朴素的做法是:前向计算、取出 token、再做下一次前向。这样是串行的。 GPU 在算的时候 CPU 在等,CPU 在取 token 的时候 GPU 又闲着。
我们用了双缓冲的 asyncEval 模式,把 GPU 和 CPU 工作重叠起来:
- 在提取当前 token 之前,先排队下一次前向计算
- 调用
item()取出当前 token ID。这个操作会强制 GPU 同步,但这时 GPU 其实已经在并行计算下一轮 logits 了 - 等到我们真正需要下一轮 logits 时,它们往往已经算好了
代码读起来其实很反直觉:你要先准备下一步输入 embedding, 把 forward pass 和 asyncEval 排进去,然后再提取当前 token。但正是这种流水线化,隐藏掉了原本会主导解码耗时的同步延迟。
阶段 5:Token 解码与清理
生成出的 token ID 会通过 BPE 解码,再经过输出解析,去掉特殊 token, 同时提取检测到的语言。清洗后的文本接着进入我们的 逆文本规范化引擎,最终才到达光标位置。
Metal 预热:5 秒冷启动
MLX 会在运行时编译 Metal compute kernel,也就是 JIT 编译。 这导致应用启动后的第一次转写会承担大约 5 秒的 shader 编译开销。 从第二次开始才会很快。
这是整个库里影响最大的一处性能陷阱。没有预热时,第一次真实转写对 8 秒音频往往需要 5 到 8 秒(大概只有 0.5 倍实时速度)。 做完预热后,每次转写都能跑到 4 到 6 倍实时速度。
我们的预热策略里有几个不明显的点:
- 用噪声,不用静音。 静音会产生接近 0 的 mel 值,不一定能覆盖所有 kernel 路径。低幅随机噪声能保证整条管线都跑过更丰富的计算分支。
- 用真实长度的音频。 我们用 8 秒音频做预热, 这样 batched Conv2d 编码器会处理大约 8 个 chunk,更接近真实批次维度。 如果只用 2 秒音频(大约 2 个 chunk),第一次真实转写在更大 batch 尺寸下仍可能触发额外的 Metal pipeline state 编译。
- 首次运行使用非零 temperature。 如果用 greedy decoding(temperature=0),模型在噪声输入下会立刻输出 EOS, 从而一个 token 都不生成,导致自回归解码循环的 kernel 根本没编译。 temperature=1.0 则会强制生成 token,把完整解码路径走一遍。
- 做两轮预热。 第一轮使用 temperature sampling 编译所有 kernel,第二轮使用 greedy decoding 验证快速路径。 然后清掉内存缓存,但保留 shader 缓存。
基准数据
以下数据来自 M1 Pro 上、6 bit 量化的 Qwen3-ASR 0.6B。 这里统计的是纯推理耗时,不包含模型加载和 Metal 预热:
| 音频时长 | 推理时间 | RTF | 速度 |
|---|---|---|---|
| 1.58s | 0.27s | 0.172 | 5.8 倍实时 |
| 2.56s | 0.41s | 0.159 | 6.3 倍实时 |
| 0.98s | 0.26s | 0.264 | 3.8 倍实时 |
| 1.19s | 0.29s | 0.239 | 4.2 倍实时 |
典型 RTF 范围是 0.15 到 0.27,也就是 3.7 到 6.3 倍实时速度。 这和 Python 版 MLX 实现相比也很有竞争力,但我们是一个原生 Swift 库, 没有任何 Python 开销。
对于 OnType 的实时语音输入场景,这意味着 ASR 引擎处理语音的速度比语音到达的速度还快。等用户松开热键时, 大部分音频其实已经被转写完了,通常只剩最后一个 chunk 需要处理, 耗时不到 300 毫秒。
防护栏
有几种安全机制,是我们踩坑之后才学会必须加上的:
- 基于时长的 token 上限。 最大生成长度被限制为
ceil(audioDuration × 20) + 64个 token。 没有这个限制时,某些异常输入会让模型在不输出 EOS 的情况下生成成千上万个 token, 导致 GPU 一直空转。 - 重复检测。 如果同一个 token 连续重复 10 次, 我们就直接停止。这能抓住模型陷入死循环的退化输出。
- 最小音频长度。 短于一个 FFT 窗口的音频 (400 样本,也就是 25ms)会直接返回空结果。 没有这个检查的话,STFT 里的 reflection padding 会因为 range 非法而崩溃。
- MLX 错误转换。 MLX 默认会对 GPU 错误直接
fatalError。我们会用withError包裹关键路径,把这些错误转成 Swift 的throws, 这样应用就能在 HUD 里展示错误,而不是直接崩掉。
开源
mlx-swift-asr 采用 MIT 协议,地址在 github.com/ontypehq/mlx-swift-asr。它是一个独立 Swift package。只要把它加进你的 Package.swift,你就能得到一个三行 API 的设备端语音识别:
let stt = try await Qwen3ASRSTT.loadWithWarmup(from: modelDirectory)
let result = try await stt.transcribe(file: audioURL)
print(result.text) // "Hello, world."
print(result.rtf) // 0.17 (5.8x real-time)如果你在做任何需要 Apple Silicon 上语音识别的东西,比如语音输入、 转写工具或无障碍产品,可以试试看。基准也可以通过 swift test --filter Benchmark 复现。
试试 OnType,看看 mlx-swift-asr 在真实产品里的效果: 按住键、说话、松开就能输入。设备端、实时、私密。