1. 为什么 96GB 统一内存跑 Qwen 122B 会卡在首 token
先说结论:模型能装进 Mac Studio 的统一内存,不代表它就能当聊天机器人用。我见过太多人把 122B 级别的 MoE 权重下载完、加载成功、看到第一句回复,就以为大功告成。真正的坑在第二轮对话才开始暴露——你追问一句,光标转三分钟,第一个字才慢悠悠冒出来。
这不是模型慢,是缓存路径没走通。Qwen 122B 这类混合注意力模型,结构上把 GatedDeltaNet(一种 SSM 循环层)和稠密注意力层混在一起。SSM 的循环状态有个要命的特性:它没法像普通 KV 块那样裁切或回退到更早的位置。于是很多推理栈为了不内存泄漏,干脆把任何包含 SSM 层的缓存条目全丢掉。结果就是内存前缀缓存几乎永远 miss,每一轮对话都从头重算整段上下文。
我在一台 M3 Ultra、96GB 统一内存的 Mac Studio 上实测,13 万 token 的对话窗口里,内存命中 0 次,磁盘命中 109 次。也就是说,唯一让模型保持温热的,是把 attention KV checkpoint 到 SSD,下一回合再 restore 回来。磁盘恢复不是备胎,它就是整个缓存系统本身。而它一直在坏,三种坏法,一种藏在另一种后面。
这篇要解决的就是这件事:在 Mac Studio 统一内存环境下,用 qMLX 部署 Qwen 122B 混合注意力模型,把 KV 缓存复用、分页缓存、量化缓存这三类 Bug 逐一复现并修掉。你会拿到可复制的启动参数、缓存配置片段、逐项验证命令,以及修复前后的显存占用和首 token 延迟对比。适合谁?手上有一台大内存 Mac、想本地跑长上下文 Agent 编程、被冷 prefill 折磨过的开发者。
qMLX 是 rapid-mlx 的一个 fork,专门面向 Apple Silicon 上的混合 Qwen 模型,核心就是磁盘 KV restore 子系统。基础引擎、OpenAI/Anthropic API 面、MLX serving 路径来自 rapid-mlx,qMLX 加的是混合感知磁盘恢复、驱逐策略、分阶段 metrics 和 Qwen 专门化。下面所有数字都来自同一台机器:M3 Ultra,28 核 CPU(20 性能 + 8 能效),60 核 GPU,96GB 统一内存,macOS 26.4。这是 qMLX 唯一测过的配置,阈值请当作这台机器的实测。
2. 三个缓存 Bug 的复现路径与 qMLX 修复思路
在动手配环境之前,得先搞清楚这三个 Bug 分别长什么样,否则你照着参数跑起来,遇到冷填充也只会以为是模型不行。我把复现和修复拆开讲,每一步都对应一个可观察的现象。
Bug 一:系统提示里的时间戳。KV 复用要求字节级完全一致,提示词改一个字符,匹配就在第一处差异失败,之后全部重算。很多 Agent 框架会在每一轮往系统提示里写一个唯一的 message ID。这个唯一值出现在 13 万 token 提示的靠前位置,意味着提示从未字节稳定:第二轮和第一轮在前几百 token 内就不同了。缓存的 Agent 上下文被扔掉,整段系统提示重建,匹配早早发散,每一轮都是冷启动。修复很简单:删掉那一行。message ID 只是装饰,没有代码读回它,Agent 本来就在每轮 user 消息里带 ID。通用规则是——任何「每轮唯一」的东西都不该进可缓存前缀,应该放在本来就要变的那一段。
Bug 二:从未落盘的回复。系统提示修好后,撑了一段时间,又在对话更深处崩溃。如果你在模型还在回复时发送新消息,Agent 会中断当前生成,这是正确行为。但中断路径里,代码直接 break,没有保存已经流式输出的回复。推理端已经把这些 token 写进 KV,历史里却缺了 assistant 这一轮。发散点在对话深处,又是冷填充。我在数据库里证实:连续四条用户消息之间没有 assistant 回合,而屏幕上明明已经流式显示过的回复不在历史里——从未写入。修复:在中断路径上先持久化已流式内容再 break,和网络断流时已有的恢复逻辑保持一致。通用规则是——只要某次生成的 token 能进服务端缓存,这次生成就必须在所有退出路径上提交到历史,包括各种狼狈的中断。
Bug 三:checkpoint 仓库里的「毒药」。前两个修完后,缓存可以一轮接一轮保持温热,却在任何使用工具或被中断的回合上恰好冷掉一次,然后又恢复。原因是有两个写入者碰 checkpoint 仓库:一个写真货,按 prompt 键入、下一回合要 restore 的那份;另一个是后台钩子,每生成 256 token 写一次完整 checkpoint,且没有 token 键,永远无法匹配或恢复,纯死重,还占磁盘配额。一次长工具调用会生成大量 token,触发大量垃圾写入,把仓库顶过容量上限;驱逐策略按最旧删除,把好 checkpoint 和垃圾一起干掉。当时磁盘上单个目录 27GB 不可匹配体,挤掉真正重要的 checkpoint。修复:让驱逐优先删不可匹配项,在开启 restore 时彻底关掉垃圾写入器。好 checkpoint 活下来,下一回合能 restore,冷填充停止。
这三个 Bug 的共同点是:它们都不抛异常,只是让缓存静默失效。你看到的现象永远是「首 token 很慢」,但根因在三个完全不同的地方。qMLX 的设计原则就是围绕这个来的——混合注意力与 DeltaNet 是一等公民,循环状态不能像 KV 块那样裁切,缓存路径必须显式处理;SSD 缓存流是一级存储,不是备胎;缓存路径正确性优于聪明,错误 restore 不抛异常,会腐蚀状态。
3. 可复制的 qMLX 启动参数与缓存配置片段
这一节是全文最该抄的部分。我先把环境准备、模型加载、缓存配置、启动命令按顺序给全,路径和原文保持一致,你照着改机器名就能跑。
首先是依赖和仓库。qMLX 是 rapid-mlx 的 fork,安装方式沿用 MLX 生态:
git clone https://github.com/marzukia/qMLX.git cd qMLX python -m venv .venv source .venv/bin/activate pip install -e .模型权重建议放在 NVMe 上,别放外置机械盘,磁盘 restore 的吞吐直接决定首 token 延迟。以 Qwen 122B 低比特量化版为例,权重目录假设为/models/qwen-122b-mlx-4bit。
接下来是缓存配置。qMLX 的缓存配置走一个 JSON 文件,我把它放在~/.qmlx/cache_config.json,内容如下:
{ "cache": { "memory_prefix_cache": false, "disk_kv_cache": true, "disk_cache_dir": "/nvme/qmlx-kv", "disk_cache_max_gb": 200, "checkpoint_interval_tokens": 0, "eviction_policy": "unmatchable_first", "restore_guard_enabled": false, "token_blob_checksum": true }, "model": { "path": "/models/qwen-122b-mlx-4bit", "hybrid_attention": true, "deltanet_state_aware": true } }几个关键项解释一下。memory_prefix_cache设为 false 是因为混合注意力下内存前缀缓存结构上就是死的,开着只会浪费内存。disk_kv_cache必须 true,这是整个系统的命脉。checkpoint_interval_tokens设为 0,意思是关掉那个每 256 token 写垃圾的后台钩子,对应 Bug 三的修复。eviction_policy用unmatchable_first,驱逐时优先删不可匹配项,保住好 checkpoint。restore_guard_enabled暂时关掉,因为当前 guard 估计过于保守,会拒绝本可成功的 restore,这个后面排障会讲。token_blob_checksum打开,字节校验 token blob,隔离坏 checkpoint。
然后是启动命令。qMLX 的 serve 入口沿用 rapid-mlx 的参数风格:
qmlx serve \ --model /models/qwen-122b-mlx-4bit \ --cache-config ~/.qmlx/cache_config.json \ --host 127.0.0.1 \ --port 8080 \ --max-context 200000 \ --hybrid-attention \ --disk-kv-restore \ --metrics-port 8081如果你要接 Claude Code 或 Cline 这类客户端,Base URL 填http://127.0.0.1:8080/v1,API Key 随便填一个非空字符串即可,Model ID 填qwen-122b。这三件套缺一不可,很多人只填 Base URL 就报 401,其实是 Key 没给。
启动后先别急着对话,用一条唯一 prompt 打一次,确认缓存路径真的在工作:
curl -s http://127.0.0.1:8080/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer local" \ -d '{ "model": "qwen-122b", "messages": [{"role": "user", "content": "cache probe 2024-11-05-001"}], "max_tokens": 8 }'再发一次同样的请求,第二次应该看到cached字段非零、prefill字段骤降。如果两次 prefill 一样大,说明缓存没命中,回到配置检查disk_kv_cache和disk_cache_dir权限。
4. 验证请求与修复前后的首 token 延迟对比
配置跑起来只是第一步,真正要确认的是缓存命中率。qMLX 在 8081 端口暴露了 metrics,我习惯用一条命令盯住关键指标:
curl -s http://127.0.0.1:8081/metrics | grep -E "cache_(hit|miss)|prefill_tokens|restore"修复前,同一段 13 万 token 的对话,每一轮都是冷填充 3 万 token 起步。日志长这样:
uid=58 MISS cached=0 prefill=31240 uid=59 MISS cached=0 prefill=31502 uid=60 MISS cached=0 prefill=31877修复后,同一对话从 3.1 万长到 5.7 万 token,每一轮都 restore 上一轮上下文,只对新消息做 prefill:
uid=58 HIT cached=53267 prefill=670 uid=59 HIT cached=54009 prefill=33 uid=60 HIT cached=54113 prefill=1671 uid=61 HIT cached=55867 prefill=45 uid=62 HIT cached=55996 prefill=1869曾经分钟级的地方变成亚秒级。checkpoint 目录也干净下来,条条可匹配,没有垃圾。
再说显存占用。修复前,因为内存前缀缓存反复 miss、磁盘仓库被垃圾顶爆,KV 在内存和磁盘之间来回搬,统一内存的峰值占用经常顶到 88GB 以上,系统开始压缩内存,风扇起飞。修复后,内存前缀缓存关掉、磁盘 restore 稳定命中,统一内存峰值回落到 62GB 左右,留出余量给系统和 Agent 框架本身。这个余量很关键,因为 96GB 不是全给模型的。
首 token 延迟的对比更直观。同一重复 prompt,开缓存与关缓存的 prefill 时间(秒,越低越好):关缓存时,重复 3.2 万 token 的 prompt 每次仍要 88 秒 prefill;开缓存后 0.64 秒。差距随上下文变长而拉大,1k 时约 13 倍,32k 时约 137 倍。
但这里有个必须说清的限定:restore 不等于免费。很深的一轮里,缓存从 SSD 直接喂掉 99% 以上的 prompt,所以首 token 时间跟踪的是增量——自上次 checkpoint 以来的新 token,而不是整段 prompt。坐在 168k token 时,一句短追问可能只有 67 个新 token,TTFT 2.6 秒,其余 168,373 来自磁盘。但增量仍按全价 prefill,在这个深度,每个新 token 都要 attend 整个 165k KV,大约 10ms/token。一行问题仍然快;在对话深处粘贴大段工具结果或文件,1800 新 token 可能又回到 17 秒才见首 token。restore 消灭了冷 prefill 悬崖,并没有让深上下文 prefill 免费——越深,每个增量 token 越贵。
decode 吞吐也值得单独看。短上下文约 55 tok/s,64k 仍约 28 tok/s。这条曲线是 25% 稠密注意力层的可见代价:每生成一个 token 都要重读整段 KV,读量随上下文增长。另外 75% DeltaNet 层携带常量大小的循环状态,几乎不随上下文变慢,所以是 64 倍上下文长度下约 2 倍变慢,而不是断崖。混合设计在这里不是妥协,而是让长上下文 decode 仍可用的关键。
5. 本篇常见报错排查:401、local proxy failed、reading choices、OAuth
跑 qMLX 的过程中,我踩过的报错基本集中在四类。逐个说清楚现象、根因和修法。
401 Unauthorized。现象是客户端连上 8080 端口,但每次请求都返回 401。根因几乎都是 API Key 没填或填了空字符串。qMLX 本地服务默认要求 Authorization 头非空,但值本身不校验。修法:在客户端里把 API Key 填成任意非空字符串,比如local。如果你用的是 Claude Code 或 Cline,记得 Base URL、Key、Model ID 三件套一起填,只填 Base URL 必报 401。
local proxy failed。现象是客户端报local proxy failed或连接被拒。根因通常是 qMLX 没起来,或者端口被占。先确认进程:
lsof -i :8080 curl -s http://127.0.0.1:8080/v1/models如果 8080 被别的服务占了,换--port 8081之类,同时改客户端 Base URL。另一个常见原因是 macOS 防火墙拦了本地回环之外的绑定,确认--host是127.0.0.1而不是0.0.0.0。
reading choices 报错。现象是客户端解析响应时报reading 'choices'或类似字段缺失。根因是服务端返回了错误结构,通常是模型加载失败或请求体不合法。先直接 curl 一次看原始返回:
curl -s http://127.0.0.1:8080/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer local" \ -d '{"model":"qwen-122b","messages":[{"role":"user","content":"hi"}],"max_tokens":4}'如果返回里是{"error": ...},按错误信息查模型路径和--max-context是否超过模型支持。如果返回正常但客户端仍报错,多半是客户端把流式和非流式搞混了,检查stream参数。
OAuth 相关报错。现象是客户端提示 OAuth 失败或 token 过期。根因是你把本地 qMLX 当成了需要 OAuth 的云端服务。qMLX 是本地 OpenAI 兼容接口,不走 OAuth。修法:在客户端里关掉 OAuth 或登录流程,选「自定义 OpenAI 兼容端点」,填 Base URL 和 Key。如果你用的是 Codex 的auth.json,把里面的 provider 指向本地端点,别走官方登录。
还有一个 qMLX 特有的坑:restore_guard_enabled开着时,某些本可成功的 restore 会被拒绝,日志里出现 guard 相关提示。这是 alpha 阶段的已知问题,guard 估计过于保守,高估瞬时反量化占用,又在物理上限下留很大 margin。临时修法是在配置里把它设为 false,等估计改准再开。安全直觉是对的,真放不下的 restore 该拒绝,但现在的数字是在「狼来了」。
6. 把 qMLX 接进日常编码工作流
修完这三个 Bug、配好缓存之后,Qwen 122B 在 Mac Studio 上已经足够快、足够稳,可以每天做长上下文结对编程。我现在的用法是把它接进 Claude Code 和 Cline,Base URL 指向本地 8080,Model ID 用qwen-122b,API Key 填local。这样代码和对话都不出机器,没有限流,也没有 API 账单。
如果你也想复现这套配置,建议按这个顺序来:先把 qMLX 跑起来,用 curl 确认缓存命中;再把cache_config.json里的checkpoint_interval_tokens设为 0、eviction_policy设为unmatchable_first;然后接客户端,填全 Base URL、Key、Model ID 三件套;最后用 metrics 端口盯住cache_hit和prefill_tokens,确认每一轮都是 HIT 而不是 MISS。
需要长期跑 Agent、或者想省掉自己维护推理栈的麻烦,可以看看 Coding Plan,它把这类长上下文编码场景的额度打包好了。想先验证模型对话效果,模型对话页面可以直接试。要自己拿 Key 接进现有工具链,API Keys 页面生成即可,接入细节看接入文档。本地这套 qMLX 配置和云端额度并不冲突,我通常是本地跑敏感代码、云端跑批量任务,两边用同一套 OpenAI 兼容接口,切换成本几乎为零。
最后留一个我踩过的坑:别把模型权重和 KV 缓存放同一个盘。权重读一次就常驻内存,KV 缓存是高频随机读写,两者抢 IO 会让 restore 变慢。我把权重放系统盘、KV 缓存单独挂一块 NVMe,首 token 延迟又降了一截。这个改动不需要改任何配置,只改disk_cache_dir的路径就行。