我的听写工具占着 1.9GB 内存,产出的转写结果和一个 181MB 的模型一模一样。我发现这件事,只是因为别的东西没内存跑了。
2026-09-08 更新。天天用了两周之后,我隐约觉得 small 在中文上比大模型差一点,如果是真的,下面的结论就站不住。所以我又录了一段更难的,四个模型重跑了一遍,结果在文末新增的一节。一句话版本:「转写结果完全相同」只对第一段录音成立;第二段录音里,丢词的反而是大模型。一段录音本来就撑不起「没有存在的理由」这种话,那句我已经改软了。
起因
PushSense 在进程内跑 whisper.cpp,走 Metal。app 本身 5.5 MB——这个数字我一直挺得意,因为第一版是 Electron,200 MB。
模型是 ggml-large-v3-turbo.bin,磁盘上 1.6GB。我选它的方式和大多数人一样:它是还算快的里面最大的那个,所以想必是最好的。
同一台机器还在本地跑一个 35B 的模型,吃掉 20–30GB。这两件事一直相安无事,直到我开始注意「我没在说话的时候,它常驻了多少」。
别猜,量
第一个有意义的数字不是文件大小,是峰值常驻内存:
/usr/bin/time -l whisper-cli -m ggml-large-v3-turbo.bin -f sample.wav -l zh
→ 1,981,022,208 maximum resident set size
峰值 1.98GB——比文件本身还多 400MB,因为推理时要在权重之上分配中间张量。
然后我录了 27.7 秒,就按我平时说话的样子——中文里夹英文,不切语言:
所以今天我们大概来讨论一下现在这个成果⋯⋯我很多时候都没有把 email 发给客户,客户有时候很多 customer support 他们都会来找我,所以其实真的很多时候你也应该去 follow up 的
四个英文词嵌在中文里:ready、email、customer support、follow up。这才是真实的工作场景。
同一段录音,跑遍所有模型:
| 模型 | 文件 | 峰值内存 | 速度 | 结果 |
|---|---|---|---|---|
| base | 141MB | 346MB | 0.47s | ✗ "custom support"、"e-mail" |
| small-q5_1 | 181MB | 484MB | 1.11s | ✓ 四个全对 |
| turbo-q5_0 | 547MB | 695MB | 0.48s | ✓ 四个全对 |
| large-v3-turbo | 1,549MB | 1,889MB | 0.91s | ✓ 四个全对 |
| Breeze-ASR-25 q5_k | 1,031MB | 1,985MB | 1.49s | ✓ 四个全对 |
在这段录音上,181MB 的模型和 1.6GB 的模型,转写结果完全相同。547MB 那个也一样,逐字相同。(换一段录音就不成立了,见文末更新。)
质量悬崖确实存在,但位置和我以为的不一样:它在 base 和 small 之间,不在 small 和 large 之间。base 把 "customer support" 听成 "custom support"、把 "email" 听成 "e-mail";small 一个都不漏。
为什么这些内存比看上去更贵
我原以为权重是 mmap 进来的——那样这就不是问题,映射的文件页可以被回收,内存紧张时内核自己会处理。
不是。whisper.cpp 用 std::ifstream 读进 ggml_backend_alloc_ctx_tensors。我去查了源码:9,293 行里 mmap 出现 0 次,而请求这个功能的 issue #631 还开着。
所以那 1.4GB 是匿名脏页,内核收不回去。它直接从你正在跑的别的东西里扣——在我这里,就是那个我比听写工具更在乎的语言模型。
「用完就释放」是显而易见的出路,所以我也量了:调 whisper_free 并不会回到基线。进程内占用走的是 20MB → 1,774MB → 177MB。分配器留下的残留随模型大小增长。重新加载很便宜(small-q5_1 只要 55ms),所以按需加载仍然可行——但要按 80–180MB 常驻来算,不是零。
两件我以为会成立、结果没有的事
一个正好针对我这个问题微调过的模型。 Whisper 在中英混说上有架构缺陷——它每 30 秒窗口只分配一个语言 token,在 SEAME 中英混说基准上零样本 Whisper 是 58.3% / 73.5% MER。Breeze-ASR-25 就是冲着这个做的 Whisper-large-v2 微调版,在 TTS 合成音频上看起来是压倒性的胜利:turbo 把 "race condition" 听成 "Array's Condition"、把 "log" 听成 "Law",Breeze 全对。
换成我真实的声音,两个模型四个词全对——而且 Breeze 峰值 1,985MB,比 turbo 还高,因为量化压缩的是文件,不是运行时。
教训不在 Breeze 身上,而是:TTS 不能代表真人说话。合成音频把术语念得比人清楚得多,于是难的案例显得容易,干净的 A/B 显得结论明确。我那个漂亮的对比,量的其实是语音合成引擎。
Apple 自带的识别。 macOS 自带 SpeechTranscriber,中文离线包已经装好了——零下载、进程内约 6MB、延迟 0.13 秒。纸面上它直接让整个问题消失。
它把 latency 转成 "lentrensie"、race condition 转成 "raise fondition"、pull request 转成 "pol reques"——三次运行结果一致,不是噪声。技术术语准确率约 52%,whisper 是 84%。
零内存是个好价钱,可惜买的是个不能用的东西。
我改了什么
实际上就一行——目录里推荐的模型:
static let recommended = all.first { $0.id == "ggml-small-q5_1" }!
转写结果一样。484MB,不是 1,889MB。 1.4GB 还给了我真正想用它的地方。
turbo 以 q5_0 的形式留在列表里(547MB,不是 1.6GB),给需要在困难音频上留余量的人。1.6GB 的 f16 版本内存是 q5_0 的三倍,而在我目前测过的两段录音上,两者出的字一样。(原文写的是「没有存在的理由」。一段录音不够说这种话。)
我反复在学的那件事
我当初选 1.6GB 的模型,是因为「更大听起来更稳」,然后就再没验证过。验证花了二十分钟,换回 1.4GB。
上面每一个数字都来自我自己的机器、我自己的声音——这也是我唯一敢信它们的原因。唯一一次用合成音频,它自信地指向了错误的结论。
需要说清楚的保留:84% / 52% 那组术语准确率来自 5 段合成语音(25 个术语),方向可信,但不是精确值。真实录音里是商务词汇,不是更难的编程术语(SwiftUI、rebase、memory leak),那部分我还想测。另外 Apple 的 customizedLanguage(modelConfiguration:) 可以喂自定义词汇,理论上能补上它的差距——我还没试过。
更新:第二段更难的录音(2026-09-08)
上面的保留写了,真实录音里是商务词汇,不是编程术语。把 small 设成默认用了两周,我总觉得中文出来比 turbo 的时候糙一点。感觉不是测量,所以我录了 36 秒平时真正对 agent 说的话:用普通话抱怨一个网站首页,中间夹着 famous、designer、website、SwiftUI,结尾一句英文。同一台机器,同一个 whisper-cli,和 app 一样的 greedy 解码,语言设为中文。
| 模型 | 峰值内存 | 耗时 | 结果 |
|---|---|---|---|
| base | 363MB | 0.94s | ✗ "designer" 听成「迪士兰尔」,「有没有」听成「没有」;"Swift UI" 保住了 |
| small-q5_1 | 503MB | 0.77s | ✓ 每个实词都对,包括 "swift ui";所有口头禅原样保留(那个、来吧来来、呃、"you you're") |
| turbo-q5_0 | 832MB | 1.60s | ✗ 把 "SwiftUI" 整个丢了:「具体代码用来吧,来测试一下」;口头禅少一些 |
| large-v3-turbo | 1,876MB | 2.01s | ✗ 把 "SwiftUI" 整个丢了:「具体代码用来测试一下」;除此之外输出最干净 |
开 beam search(-bs 5)和自动语言检测,四个输出一个都没变。
所以转写结果确实不再相同,但差的方向和我预想的相反。两个 turbo 都把录音里唯一的编程术语悄悄删了,small 留住了。turbo 真正做得更好的地方是表面功夫:它把我的结巴和语气词去掉了,所以它的输出读起来更干净。我怀疑这两周「small 感觉更糙」的印象,就是这个。一份把每个「那个」和「呃」都写出来的转写,看着就是比一份悄悄漏掉一个你需要的词的转写差。
两段录音还是只有两段。现在能诚实说的是:商务那段,small 和 turbo 一样;开发那段,small 更忠实,turbo 更流畅。两段都没出现 small 丢中文字的情况。哪天找到一段它丢字的,就补到这里。默认模型暂时留在 small;去口头禅是可选的 LLM 那一步的活,不是换更大声学模型能解决的。
PushSense 免费使用,已提供签名公证的下载版本。