OnType 的裝置端語音識別由 mlx-swift-asr驅動。這是我們打造的一個開源 Swift 庫,用來在 Apple Silicon 上原生執行 Qwen3-ASR。 沒有 Python 執行時,沒有子程序橋接,也沒有雲端,只有 Swift → MLX → Neural Engine。
這篇文章會講清楚推理鏈路是怎麼工作的、那些讓它“感覺像即時響應”的延遲工程, 以及我們一路踩過的一些不明顯的坑。
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 裡的音訊轉文字路徑分成五個階段:
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 最煩的地方在於模型不會直接崩掉,它只是吐出垃圾結果, 要排查非常久。
Mel 頻譜圖會送入音訊塔,也就是三層 stride-2 Conv2d 加一個 Transformer 編碼器。這個階段會把時間維壓縮大約 8 倍,並把音訊特徵投影到文字解碼器的隱藏維度。
這裡還有一個細節:Conv2d 權重在模型載入時,需要從 PyTorch 的 OIHW 佈局轉置到 MLX 的 OHWI 佈局。權重清洗步驟會自動處理這件事。
編碼後的音訊特徵會按 Qwen3 的 chat template 格式併入文字 prompt。 prompt 裡的音訊佔位 token 會透過基於 cumsum 的索引方案, 替換成真實的編碼器輸出 embedding。這和多模態 LLM 處理影象 token 的架構是同一種思路。
這裡才是最有意思的效能工程部分。自迴歸解碼一次只生成一個 token。 最樸素的做法是:前向計算、取出 token、再做下一次前向。這樣是序列的。 GPU 在算的時候 CPU 在等,CPU 在取 token 的時候 GPU 又閒著。
我們用了雙緩衝的 asyncEval模式,把 GPU 和 CPU 工作重疊起來:
item()取出目前 token ID。這個操作會強制 GPU 同步,但這時 GPU 其實已經在平行計算下一輪 logits 了程式碼讀起來其實很反直覺:你要先準備下一步輸入 embedding, 把 forward pass 和 asyncEval排進去,然後 再提取目前 token。但正是這種流水線化,隱藏掉了原本會主導解碼耗時的同步延遲。
產生出的 token ID 會透過 BPE 解碼,再經過輸出解析,去掉特殊 token, 同時提取檢測到的語言。清洗後的文字接著進入我們的 逆文字規範化引擎,最終才到達游標位置。
MLX 會在執行時編譯 Metal compute kernel,也就是 JIT 編譯。 這導致應用程式啟動後的第一次轉寫會承擔大約 5 秒的 shader 編譯開銷。 從第二次開始才會很快。
這是整個庫裡影響最大的一處效能陷阱。沒有預熱時,第一次真實轉寫對 8 秒音訊往往需要 5 到 8 秒(大概只有 0.5 倍於即時的速度)。 做完預熱後,每次轉寫都能跑到 4 到 6 倍於即時的速度。
我們的預熱策略裡有幾個不明顯的點:
以下資料來自 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 毫秒。
有幾種安全機制,是我們踩坑之後才學會必須加上的:
ceil(audioDuration × 20) + 64個 token。 沒有這個限制時,某些異常輸入會讓模型在不輸出 EOS 的情況下產生成千上萬個 token, 導致 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 在真實產品裡的效果: 按住鍵、說話、鬆開就能輸入。裝置端、即時、私密。