HN 614 分的 16.9MB 语音模型,我在 1 核 2G 上实测了一遍

语音识别模型这两年越做越大,动辄几个 GB,挑硬件像挑显卡。所以 Cactus 上周发布的 Whistle 让我多看了两眼:一个 16.9 MB 的单文件模型,CPU 直接跑,零依赖,宣称词错率(WER)在 LibriSpeech test-clean 上做到 4.3,比 145.3 MB 的 Whisper base 还低。

这帖子在 Hacker News 上现在是 614 分、135 条评论。官方博客给的数据挺漂亮,但我盯着 benchmark 那栏的注释看了很久:Ten seconds of audio on an Apple M4 Pro CPU------所有速度数据都是 M4 Pro 跑的。

M4 Pro 是台好机器,但它回答不了我的问题:一台 1 核 2G 的最低配云主机,能不能跑?首词延迟和解码速度会烂到什么程度?这种问题只有自己跑一遍才知道。

装包的坑比预期多

pip install cactus-needle 本身不难,难的是国内环境下装完跑不通。 第一个坑在装的时候。默认 pip 镜像源里搜不到这个包,会直接报 "No matching distribution found",得指定官方 PyPI 源:

arduino 复制代码
pip install cactus-needle -i https://pypi.org/simple

装完后按照官方文档的样子写两行代码:

go 复制代码
import needle
print(needle.transcribe("clip.wav")["text"])

然后卡住了。进程一动不动挂了三分钟,我以为沙盒内存爆了------后来翻源码才发现原因:引擎的二进制不在 wheel 里 。pip install 装的只是 Python 壳,首次调用时它会去 HuggingFace 下载对应平台的 libneedle.so,而沙盒直连 huggingface.co 是不通的,请求就那么静默挂着。加个镜像环境变量就好了:

ini 复制代码
HF_ENDPOINT=https://hf-mirror.com python3 bench.py

模型权重 whistle.cact 也一样走 HF 下载。下载完我第一时间验了下官方那个最抓眼球的数字:文件 16,919,407 字节,16.9 MB,和宣传分毫不差。这点值得夸,很多模型宣传页的体积数字和实际发布物对不上。

还有一个不算坑的观察:引擎和模型是分离的。同一个 libneedle.so 既能加载 Whistle 也能加载同门的文本模型 Needle,一个二进制吃两种 .cact 文件。所以 16.9 MB 是语音权重本身的开销,引擎底座是共享的。

1 核 2G 上的真实速度

我的沙盒是 1 核 CPU、1959 MB 内存,Python 3.13,cactus-needle 3.1.3。测试音频来自 LibriSpeech test-clean 的公开样本,外加自己用 edge-tts 合成的中、德语片段。每个文件先空跑一遍让引擎热身,再计时。 先看官方宣称的核心数字和我的实测对比(官方口径是 10 秒音频):

指标 官方(M4 Pro,10s 音频) 我的沙盒(10.4s 音频)
首词延迟 ttft 11.1 ms 3366 ms
解码速度 1319 tokens/s 138.7 tokens/s
模型体积 16.9 MB 16.9 MB(一致)

解码速度缩水到约 1/9,这个比例基本符合两台机器的算力差距,不意外。 有意思的是 ttft 曲线。官方文档专门强调 Whistle 的首词延迟"跟着音频长度走",不像 Whisper 那样永远补齐到 30 秒。我把不同长度的音频都跑了一遍:

音频长度 官方 ttft 我的沙盒 ttft
3.3 s ---(官方只给了 5/10/30s) 102 ms
6.6 s --- 2129 ms
10.4 s 11.1 ms(10s 档) 3366 ms
20.3 s --- 6590 ms

短音频是个惊喜:3 秒的片段热身之后 102 毫秒出首词,这个延迟做语音唤醒、做智能家居的本地指令识别,体感上就是"说完就出字"。但曲线的斜率很陡------大约每秒音频要多花 320 毫秒等待,官方机器上这个数字是 1.2 毫秒左右。这意味着 Whistle 的卖点"低延迟",在低配设备上会随音频变长迅速衰减:20 秒的录音要等 6.6 秒才出第一个字,说句话都比这快了。

顺带一提,20.3 秒那根音频是我把三段 LibriSpeech 拼起来的,转录结果完整且顺序无误,三段内容按原顺序出现,没有串段。转录上限官方写的是单次 30 秒、320 个 token,我没测到边界。

英语 10.7%,德语全对

速度说完了,说准头。 LibriSpeech test-clean 那条 10.4 秒的样本(朗读的是《一个青年艺术家的画像》开篇),Whistle 的输出是:

He hoped there would be stew for dinner, turnips and carrots and bruised potatoes and fat mutton pieces to be laid out in thick, peppered, flour fat and sauce.

LibriSpeech 的官方参考转录是 ... TO BE LADLED OUT IN THICK PEPPERED FLOUR FATTENED SAUCE。逐词对齐后:ladled 认成了 laid out(多出一个词),fattened 认成了 fat。28 个词里 3 处编辑,单条 WER 10.7%。

这个数字要放在正确的坐标系里看:官方的 4.3 是 2620 条全量测试的平均值,我这是单条音频,不代表模型整体水平。但它至少说明了两件事------真实朗读音频上识别质量确实在线,错的地方也都错在实词上(ladled、fattened 这种不常见的词),不是乱来。

德语是另一番景象。我让 edge-tts 合成了一句文本已知的德语:

复制代码
Gute Reise funktioniert überall mit dem selben Motor, nicht wahr?

Whistle 的输出和输入逐词一致 ,一字不差,语言检测也正确返回 de,ttft 158 毫秒。官方博客把 FLEURS 多语言平均列为自己的领先项,这句测试让我信了大半。 词级时间戳也顺手验了。开启 word_timestamps=True 后每个词都带起止时间和概率:

json 复制代码
{"word": ""Stuffed"", "start": 0.56, "end": 0.96, "probability": 0.798},
{"word": "into", "start": 0.96, "end": 1.2, "probability": 0.991}

边界对得挺齐,概率也算合理。这个功能做字幕或者高亮跟读都用得上。

喂了一句中文,它开始胡说

接下来是我最想测的部分。官方支持七种语言:英、德、法、西、意、荷、波兰。没有中文------这很正常,16.9 MB 的模型塞不下太多东西。但我好奇的不是"能不能识别中文",而是把它喂中文会发生什么。

答案:不会报错,不会拒绝,它会用高置信度胡说八道。 我合成了这句中文音频:"大语言模型的上下文窗口已经扩展到一百万个标记,但是有效注意力仍然远远小于这个数字。" Whistle 的输出:

Taiyam was in the Shansawan tongue hole, eating hot and thou ye by wangopiao tea, taiyo sau tu ye li, jun ya ya ya and sau you ti the shoots.

语言检测返回 en。部分词的概率高得吓人------"was" 0.978、"in" 0.959。你能看出它其实在挣扎着音译:wangopiao 大概是"万(个)标(记)"的碎片,Taiyam 是"大语言"的音,但它把这些声音强行拼成了英语句子,语气流畅,一本正经。

这就是小模型语言边界的真实形态:它不会告诉你"我不懂中文",它只会给你一段看起来像模像样的译文 。如果你拿它做语音入口而不做语言白名单,中文用户的输入就会变成这样一段噪音进入下游。官方文档其实写了 language 参数可以强制指定,但实测强制 language="en" 再喂同一段中文音频,输出还是那堆乱码------参数管的是解码时的语言 token,管不住音频本身是什么语言。

静音处理倒是干净的。我生成了一段 5 秒纯静音,返回空文本、空语言、ttft 为 0,和官方说的"低于响度阈值直接返回,不进束搜索"完全一致。这个设计比很多同类强------不少模型遇到静音会硬凑出一句"Thank you."之类的幻听。

沙盒之外,它真正的战场

测完把结果摆在一起看,1 核 2G 上"能用但不快":短音频延迟优秀,长音频 ttft 是短板,解码速度稳定在 122-152 tokens/s。跑一批离线转写没有问题,做实时交互就得控制单次音频长度。 但回到这个模型的定位------移动端、可穿戴、机器人、智能家居、单片机。它对标的是 M4 Pro 上 11 毫秒出首词的世界,1 核 2G 的云主机根本不是它的主场。以它的目标设备看,16.9 MB、零依赖、单文件,这些才是核心卖点,速度数据我这份顶多算个"下限参考"。

真正让我印象最深的反而是那句中文幻觉。模型不认识的语言它不打算承认,你问它就答,答得还挺自信。真要接进项目,语言白名单或者前置的语言判断大概是免不了的------这部分功夫模型替你省不了。

实测用到的就这些:pip install cactus-needle(注意镜像源),模型走 HF 镜像下载,测试音频 LibriSpeech 公开样本加 edge-tts 合成。复现方法和完整 JSON 数据都贴在文末,后面有新版本我会再跑一轮对比。

附录:复现方法与原始数据

环境三件套:

arduino 复制代码
pip install cactus-needle -i https://pypi.org/simple
export HF_ENDPOINT=https://hf-mirror.com

第二个环境变量不能省------引擎二进制 libneedle.so 和模型权重都从 HuggingFace 拉,国内直连会静默挂死。最小计时脚本:

sql 复制代码
import time, needle

result = needle.transcribe("sample.wav", word_timestamps=True)
print(result["text"])
print(result["language"])
if "words" in result:
    print(result["words"][:3])

转录返回值里的 ttft_ms 和 decode_tps 就是引擎自己吐出来的字段,文中表格的数字直接来自下面这些 JSON,原样未动。 bench_results.json(速度基准,热身+两条样本):

csharp 复制代码
{
 "load_wall_s": null,
 "warmup": {
  "wall_s": 0.466,
  "text": ""Stuffed into you!" his belly counselled him.",
  "ttft_ms": 104.5,
  "decode_tps": 152.7
 },
 "runs": [
  {
   "name": "sample1",
   "wall_s": 3.789,
   "text": "He hoped there would be stew for dinner, turnips and carrots and bruised potatoes and fat mutton pieces to be laid out in thick, peppered, flour fat and sauce.",
   "language": "en",
   "ttft_ms": 3376.1,
   "decode_tps": 138.7
  },
  {
   "name": "sample2",
   "wall_s": 0.214,
   "text": ""Stuffed into you!" his belly counselled him.",
   "language": "en",
   "ttft_ms": 102.2,
   "decode_tps": 152.1
  },
  {
   "name": "sample3",
   "wall_s": 2.342,
   "text": "After early nightfall the yellow lamps would light up here and there the squalid quarter of the brothels.",
   "language": "en",
   "ttft_ms": 2129.3,
   "decode_tps": 146.1
  },
  {
   "name": "sample4",
   "wall_s": 2.47,
   "text": "Tienen las ramas medios emergidas, revoloteamos en algunos pájaros de Immérico y Legendario Trumaki.",
   "language": "es",
   "ttft_ms": 2123.8,
   "decode_tps": 144.8
  }
 ]
}

long20_result.json(20.3 秒拼接长音频):

json 复制代码
{
 "text": "He hoped there would be stew for dinner, turnips and carrots and bruised potatoes and fat mutton pieces to be laid out in thick, peppered, flour fat and sauce, stuffed into you, his belly counselled him. After early nightfall the yellow lamps would light up here and there the squalid quarter of the brothels.",
 "language": "en",
 "ttft_ms": 6589.9,
 "decode_tps": 122.0
}

zh_de_results.json(中文幻觉与德语全对,词级概率):

json 复制代码
{
 "zh": {
  "text": "Taiyam was in the Shansawan tongue hole, eating hot and thou ye by wangopiao tea, taiyo sau tu ye li, jun ya ya ya and sau you ti the shoots.",
  "language": "en",
  "words": [
   {
    "word": "Taiyam",
    "start": 0.48,
    "end": 0.8,
    "probability": 0.384
   },
   {
    "word": "was",
    "start": 0.8,
    "end": 0.96,
    "probability": 0.978
   },
   {
    "word": "in",
    "start": 0.96,
    "end": 1.2,
    "probability": 0.959
   },
   {
    "word": "the",
    "start": 1.2,
    "end": 1.28,
    "probability": 0.987
   },
   {
    "word": "Shansawan",
    "start": 1.28,
    "end": 1.84,
    "probability": 0.512
   },
   {
    "word": "tongue",
    "start": 1.84,
    "end": 2.16,
    "probability": 0.528
   },
   {
    "word": "hole,",
    "start": 2.16,
    "end": 2.56,
    "probability": 0.459
   },
   {
    "word": "eating",
    "start": 2.64,
    "end": 2.72,
    "probability": 0.141
   },
   {
    "word": "hot",
    "start": 2.72,
    "end": 3.04,
    "probability": 0.43
   },
   {
    "word": "and",
    "start": 3.04,
    "end": 3.2,
    "probability": 0.846
   },
   {
    "word": "thou",
    "start": 3.2,
    "end": 3.36,
    "probability": 0.358
   },
   {
    "word": "ye",
    "start": 3.36,
    "end": 3.6,
    "probability": 0.71
   },
   {
    "word": "by",
    "start": 3.6,
    "end": 3.68,
    "probability": 0.266
   },
   {
    "word": "wangopiao",
    "start": 3.68,
    "end": 4.32,
    "probability": 0.342
   },
   {
    "word": "tea,",
    "start": 4.32,
    "end": 4.48,
    "probability": 0.11
   },
   {
    "word": "taiyo",
    "start": 4.8,
    "end": 5.28,
    "probability": 0.534
   },
   {
    "word": "sau",
    "start": 5.28,
    "end": 5.44,
    "probability": 0.54
   },
   {
    "word": "tu",
    "start": 5.44,
    "end": 5.68,
    "probability": 0.536
   },
   {
    "word": "ye",
    "start": 5.68,
    "end": 5.84,
    "probability": 0.236
   },
   {
    "word": "li,",
    "start": 5.84,
    "end": 6.08,
    "probability": 0.582
   },
   {
    "word": "jun",
    "start": 6.16,
    "end": 6.32,
    "probability": 0.394
   },
   {
    "word": "ya",
    "start": 6.32,
    "end": 6.56,
    "probability": 0.279
   },
   {
    "word": "ya",
    "start": 6.56,
    "end": 6.72,
    "probability": 0.718
   },
   {
    "word": "ya",
    "start": 6.72,
    "end": 6.88,
    "probability": 0.803
   },
   {
    "word": "and",
    "start": 6.88,
    "end": 6.96,
    "probability": 0.295
   },
   {
    "word": "sau",
    "start": 6.96,
    "end": 7.2,
    "probability": 0.947
   },
   {
    "word": "you",
    "start": 7.2,
    "end": 7.36,
    "probability": 0.346
   },
   {
    "word": "ti",
    "start": 7.36,
    "end": 7.52,
    "probability": 0.366
   },
   {
    "word": "the",
    "start": 7.52,
    "end": 7.6,
    "probability": 0.386
   },
   {
    "word": "shoots.",
    "start": 7.6,
    "end": 7.92,
    "probability": 0.76
   }
  ],
  "ttft_ms": 2754.7,
  "decode_tps": 136.9
 },
 "de": {
  "text": "Gute Reise funktioniert überall mit dem selben Motor, nicht wahr?",
  "language": "de",
  "ttft_ms": 158.2,
  "decode_tps": 148.1
 }
}

feature_results.json(强制 language=en、词级时间戳):

sql 复制代码
{
 "sample4_forced_en": {
  "text": "Tien las ramas medios emergidas revoloteaman algunos bájaros de Immérico y Legendario Tromache.",
  "language": "en",
  "ttft_ms": 2151.3,
  "decode_tps": 142.8
 },
 "sample2_words": {
  "text": ""Stuffed into you!" his belly counselled him.",
  "language": "en",
  "words": [
   {
    "word": ""Stuffed",
    "start": 0.56,
    "end": 0.96,
    "probability": 0.798
   },
   {
    "word": "into",
    "start": 0.96,
    "end": 1.2,
    "probability": 0.991
   },
   {
    "word": "you!"",
    "start": 1.2,
    "end": 1.52,
    "probability": 0.996
   },
   {
    "word": "his",
    "start": 1.68,
    "end": 1.84,
    "probability": 0.831
   },
   {
    "word": "belly",
    "start": 1.84,
    "end": 2.16,
    "probability": 0.84
   },
   {
    "word": "counselled",
    "start": 2.16,
    "end": 2.72,
    "probability": 0.963
   },
   {
    "word": "him.",
    "start": 2.72,
    "end": 2.88,
    "probability": 0.999
   }
  ],
  "ttft_ms": 105.8,
  "decode_tps": 143.8
 },
 "sample2_keywords": {
  "text": ""Stuffed into you!" his belly counselled him.",
  "language": "en",
  "ttft_ms": 103.3,
  "decode_tps": 150.8
 },
 "silence": {
  "text": "",
  "language": "",
  "words": [],
  "ttft_ms": 0.0,
  "decode_tps": 0.0
 }
}
相关推荐
goehou10 小时前
AI 应用上线实战:从本地脚本到 Docker 容器化(密钥、健康检查、镜像瘦身)
docker·ai·llm·部署·教程·容器化
CopyCode10 小时前
Agent 开发不是模型训练:一个前端的入门认知
前端·llm·agent
流浪00110 小时前
大模型技术全景(十八):RAG 检索增强生成与知识时效性问题
llm·大语言模型·rag
桃西西呀11 小时前
Spring AI Alibaba 之四:拆开 ReactAgent 的图,看 ReAct 循环怎么拼出来
人工智能·spring·llm
桃西西呀12 小时前
Spring AI Alibaba 之三:graph-core 状态图引擎深拆
人工智能·spring·llm
承渊政道13 小时前
【从零开始大模型开发与微调:基于PyTorch与ChatGLM】(开源大模型ChatGLM使用详解)
人工智能·pytorch·开源·llm·chatglm
用户7196975933581 天前
Llama 3 精读|附 FP8 量化与长上下文实测
llm
架构师那点事儿1 天前
大模型如何私有化部署到生产环境
人工智能·架构·llm
浮生望1 天前
给 Agent 加记忆:截断、总结与向量检索三种方案怎么取舍
llm·agent