我的听写工具占着 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。这才是真实的工作场景。

同一段录音,跑遍所有模型:

模型文件峰值内存速度结果
base141MB346MB0.47s✗ "custom support"、"e-mail"
small-q5_1181MB484MB1.11s✓ 四个全对
turbo-q5_0547MB695MB0.48s✓ 四个全对
large-v3-turbo1,549MB1,889MB0.91s✓ 四个全对
Breeze-ASR-25 q5_k1,031MB1,985MB1.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 解码,语言设为中文。

模型峰值内存耗时结果
base363MB0.94s✗ "designer" 听成「迪士兰尔」,「有没有」听成「没有」;"Swift UI" 保住了
small-q5_1503MB0.77s✓ 每个实词都对,包括 "swift ui";所有口头禅原样保留(那个、来吧来来、呃、"you you're")
turbo-q5_0832MB1.60s✗ 把 "SwiftUI" 整个丢了:「具体代码用来吧,来测试一下」;口头禅少一些
large-v3-turbo1,876MB2.01s✗ 把 "SwiftUI" 整个丢了:「具体代码用来测试一下」;除此之外输出最干净

开 beam search(-bs 5)和自动语言检测,四个输出一个都没变。

所以转写结果确实不再相同,但差的方向和我预想的相反。两个 turbo 都把录音里唯一的编程术语悄悄删了,small 留住了。turbo 真正做得更好的地方是表面功夫:它把我的结巴和语气词去掉了,所以它的输出读起来更干净。我怀疑这两周「small 感觉更糙」的印象,就是这个。一份把每个「那个」和「呃」都写出来的转写,看着就是比一份悄悄漏掉一个你需要的词的转写差。

两段录音还是只有两段。现在能诚实说的是:商务那段,small 和 turbo 一样;开发那段,small 更忠实,turbo 更流畅。两段都没出现 small 丢中文字的情况。哪天找到一段它丢字的,就补到这里。默认模型暂时留在 small;去口头禅是可选的 LLM 那一步的活,不是换更大声学模型能解决的。

PushSense 免费使用,已提供签名公证的下载版本。