上一期 Qwen3.8 27B 算得最准,但有两个毛病:慢(一年的账要 11 分钟),以及有一次明明算对了,回答却被截断,什么都没说出来。这一期不换题、不换模型,只研究设置:在一台 48GB 的 Mac 上,怎么让它更快,扛得住长上下文,而且不会自己停下来。

实验设置

  • 机器:一台 48GB 内存的 Apple Silicon Mac(M5 Pro)。
  • 推理:oMLX 0.7.2,内存保护上限保持我原来的 40 GB 不变。
  • 测速:直接调用 oMLX 的接口,不经过 harness;流式输出、贪心解码、关掉思考,每次输出 640 token 以上。一个短提示,一个约 2.5 万 token 的长提示。
  • 长上下文:把 oMLX 自己的源码拼成约 12 万 token,在中间藏一句暗号,让模型找出暗号并总结这是什么软件。
  • 内存:每 5 秒记录一次 oMLX 进程占用的内存。

每个配置只测了一次,内存里每次只放一个模型。

两种 MTP,不是一回事

推测解码的原理很简单:一个很小的「草稿」先猜接下来几个 token,大模型一次把这几个一起检查,猜对的部分就等于白赚。Qwen3.8 有两种方式做这件事:

  • 外挂草稿头:一个单独的小文件(约 0.2 GB),挂在主模型旁边。这是我原来用的。
  • 模型自带的 MTP 层:草稿层就在模型文件里,oMLX 把这种叫 Lightning MTP,对应另一个开关。

我一开始看到 Lightning MTP 的开关是关的,以为加速一直没开。其实外挂草稿头一直在工作:那 25 到 30 tok/s 本来就是加速后的速度。而 Lightning MTP 在我的模型上根本开不了,因为很多社区做的 4-bit 版本会把自带的 MTP 层删掉。我的那个版本里一层都没有。下载前可以这样数一下:

python3 -c "import json; m=json.load(open('model.safetensors.index.json'))['weight_map']; print(sum('mtp' in k for k in m))"

输出 0 就是没有。我换了 oMLX 作者自己做的 Qwen3.8-27B-oQ4e-mtp(17 GB,保留了 29 个 MTP 张量),oMLX 一识别出来就自动打开了 Lightning MTP。草稿也猜得更准了:每一轮平均产出的 token 从 2.0 到 2.3 个提高到 2.85 到 3.08 个。

TurboQuant:省内存,不提速,而且有个坑

TurboQuant 把上下文缓存(KV cache)压缩成 4-bit。它的作用是省内存,让同样的内存装下更长的上下文,并不能让生成变快。

坑在这里:它和外挂草稿头不能同时开。我在旧模型上两个都打开以后,oMLX 并不是只关掉其中一个,而是这个模型的整套设置都不加载了,日志里只有一行:

vlm_mtp_enabled and turboquant_kv_enabled cannot both be True; choose one speculative path per model

我当时有备份,马上恢复了。教训是:改 oMLX 设置前先备份,改完去日志里确认没有 Failed to load settings。换成自带 MTP 层的新模型以后,Lightning MTP 和 TurboQuant 就可以同时开了。

结果

配置短提示2.5 万 token12 万 token内存峰值
A:原来的(外挂草稿头)28.8–30.6 tok/s24.3 tok/s✓未测
B:新模型 + Lightning MTP32.9 tok/s30.0 tok/s✓31 GB
C:新模型 + Lightning MTP + TurboQuant34.9 tok/s27.3 tok/s✓28 GB

B 在长上下文时最快,但我选了 C。我的 oMLX 大约在 33 到 34 GB 时开始拒绝请求,而 B 在 12 万 token 时已经到了 31 GB。harness 现在允许「13 万输入 + 3.2 万输出」,B 在这个上限附近很可能撞线。C 慢了 9%,换来 3 GB 的余量。和原来比,C 短提示快约 15%,长上下文快 12%。

TurboQuant 省下的内存比我想的少,原因是 Qwen3.8 用的是混合注意力:64 层里只有 16 层存完整的上下文缓存,缓存本来就不大。

真正的等待:读入

长代码任务真正耗时的不是生成,而是读入。Qwen 读入长上下文的速度大约是每秒 300 token,12 万 token 第一次要读 7 分钟左右,生成反而只要几十秒。好在 oMLX 会缓存读过的前文,同一个会话里只有第一次慢。Ornith 读入快 5 倍左右:9.2 万 token 只用了 58 秒。

另一个我担心的情况也测了:刚跑完一个 12 万 token 的请求,缓存还占着内存,紧接着换一批内容再发 13.5 万 token。这种情况我以前被拒绝过,这次顺利通过,暗号也找到了。

「自己停了」的真凶:客户端的上限

上一期 Qwen 算对了却说不出来,原因不在 oMLX:oMLX 早就允许 20 万 token 的上下文。卡住的是 harness 里的设置:上下文 4.9 万、每步输出 1.6 万。模型想得久一点,就会撞上这个输出上限。

规则很简单:每一层的上限都要对齐,oMLX 的上下文上限要大于等于 harness 的「上下文 + 输出」。这样在 oMLX 拒绝之前,harness 会先自动压缩对话。我现在的设置:

oMLX 上下文 / 输出harness 上下文 / 输出
Qwen3.8 27B204,800 / 32,768131,072 / 32,768
Ornith 35B-A3B131,072 / 32,76898,304 / 32,768

可以直接抄的设置

oMLX 里这个模型的设置(只列出改过的项):

"Qwen3.8-27B-oQ4e-mtp": {
  "mtp_enabled": true,
  "turboquant_kv_enabled": true,
  "max_context_window": 204800,
  "max_tokens": 32768
}

改完要在 oMLX 后台重新加载设置,再去日志确认生效。

局限

每个配置只测了一次,短提示的速度差异在误差范围内。测速是直接调 oMLX,没经过 harness;harness 每一轮还要多带几千到一万多 token 的系统提示和工具定义,实际用起来会更慢。我没测「13 万输入加 3.2 万输出同时用满」的极端情况,没测 TurboQuant 对答案质量的影响,也没试 oMLX 的另一种推测解码 DFlash。数据只来自一台机器。