2630 字
13 分钟
16GB Mac mini 上从 LLM 到多模态 VLM service:一次完整踩坑实录

那天我打开浏览器想测一下家里的 mlx 服务,curl 直接报 Could not resolve host。明明前两天还好好的。我对着终端骂了一句”我那操蛋的 mlx 服务呢”,然后开始顺藤摸瓜——结果一个下午顺出来一长串原本不打算碰的东西:Tailscale Service VIP 概念、KV cache 量化、Qwen3.5-9B 居然是个多模态模型、tag 的 zero-trust ACL……最后干脆把整套服务从 mlx_lm.server 升级成了 mlx_vlm.server,端到端能处理文本+图像。

整个过程踩的坑有点多,写下来留作以后参考,也给同样想在 16GB mac mini 上自建 multimodal LLM endpoint 的人省点时间。

第一个谜题:消失的 service 域名#

事情从这条命令开始:

Terminal window
$ curl https://service.<tailnet>.ts.net/
curl: (6) Could not resolve host: service.<tailnet>.ts.net

但浏览器(在另一台笔记本上)能访问。我一开始的反应是”啊 DNS 缓存挂了”,但顺着查发现根本不是缓存问题:

Terminal window
$ dig @100.100.100.100 +short service.<tailnet>.ts.net # Tailscale MagicDNS
()
$ dig @1.1.1.1 +short service.<tailnet>.ts.net # 公网 DNS
()
$ host service.<tailnet>.ts.net
Host service.<tailnet>.ts.net not found: 3(NXDOMAIN)

mac 这台机器上,无论走 Tailscale 的 MagicDNS resolver 还是公网 DNS,都解析不出。那为什么浏览器(在另一台笔记本)能通?带着这个谜题继续往下挖。

Tailscale Service VIP:跟 device hostname 完全是两码事#

打开 Tailscale Admin Console 的 Services 页面,发现里面除了我熟悉的 omlx 之外还有一个 service,描述写着 “service discovery”,endpoint tcp:443,host 显示在线。

这就跟我之前对 Tailscale 的认知有偏差了。我一直以为 *.<tailnet>.ts.net 都是 device hostname(设备名)。比如我的 mac mini 是 mac-mini.<tailnet>.ts.net,Tailscale tailnet 里没有任何叫 “service” 的设备,所以一直奇怪这名字哪来的。

查了下文档才明白:

Each Tailscale Service consists of a MagicDNS name, a TailVIP (Tailscale Virtual IP address), a resource definition, and one or more back-end hosts.

也就是说,Tailscale Service VIP 是一个独立于设备的虚拟资源,它有自己的 100.x.x.x 虚拟 IP(叫 TailVIP),有自己的 hostname,跟任何具体设备解耦。一个或多个设备可以 advertise 自己作为这个 service 的 host,控制平面分配虚拟 IP,DNS 也是发布到 service 名字而非设备名。

我可以通过 tailscale debug netmap 拉到完整状态:

Terminal window
$ tailscale debug netmap | python3 -c "
import sys, json
d = json.load(sys.stdin)
self = d.get('SelfNode', {})
print('AllowedIPs:', self.get('AllowedIPs'))
print('CapMap.service-host:', (self.get('CapMap') or {}).get('service-host'))
"
AllowedIPs: ['100.x.x.63/32', '100.x.x.110/32']
CapMap.service-host: [{'svc:omlx': ['100.x.x.110', 'fd7a:115c:...']}]

第一行的 100.x.x.63 是这台 mac 自己的设备 IP,第二个 100.x.x.110 才是 svc 这个 service 的 VIP。CapMap.service-host 这个 cap 才是真正告诉客户端”我这台 mac 是 svc 的 host”的。

tailscale serve status --json 显示的本地 serve 配置:

{
"Services": {
"svc:omlx": {
"TCP": {"8000": {"HTTP": true}},
"Web": {
"omlx.<tailnet>.ts.net:8000": {
"Handlers": {"/": {"Proxy": "http://127.0.0.1:8000"}}
}
}
}
}
}

接收 svc VIP 的 8000 端口流量,TLS 终止后转发到本地 127.0.0.1:8000。但当时本地 8000 端口根本没人监听(我本地 proxy 实际跑在 8097),这条链路本身就是断的。

而那个 service service 是另外的,host 不是这台 mac mini,是家里的 Raspberry Pi 在跑一个 service discovery 反代,把外部访问路由到内部的 omlx 后端。所以浏览器(其他设备)能通是因为它们能解析 service.<tailnet>.ts.net → pi 的 service VIP → pi 反代 → omlx。但 mac 自己解析不到 service.* 的原因,最后才发现,是另一个故事——这个坑在文末再说。

顺手 benchmark:MLX-4bit vs OptiQ-4bit#

既然要修这条链路,我顺便想把模型从原本的 unsloth/gemma-4-E4B-it-UD-MLX-4bit 换成 Qwen3.5-9B。HF cache 里有两个 4-bit 量化版本:

  • mlx-community/Qwen3.5-9B-MLX-4bit:标准均匀 4-bit 量化
  • mlx-community/Qwen3.5-9B-OptiQ-4bit:mixed-precision,敏感层 8-bit + 鲁棒层 4-bit,平均 4.5 bpw

OptiQ 听起来更高级,model card 还宣称 64k context decode 速度提升 40-62%。但宣传归宣传,得自己测。

Terminal window
mlx_lm.generate \
--model mlx-community/Qwen3.5-9B-MLX-4bit \
--max-tokens 256 --temp 0 \
--prompt "$(cat prompt.txt)" # 59 tokens prompt

短 context(prompt 59 tok / 生成 256 tok)的两组数据:

量化Prompt tok/sGen tok/sPeak Mem
MLX-4bit106.7921.625.25 GB
OptiQ-4bit43.2919.526.26 GB

OptiQ 全面输了:prompt 处理慢 2.5 倍,generation 也慢 10%,内存还多 1 GB。原因分析:mixed-precision matmul(8-bit/4-bit 混合)在 Metal 上的 kernel fusion 不如 uniform 4-bit 高效。OptiQ 真正的杀手锏(“+40-62% decode”)需要配合 mlx-optiq 包的特殊 KV cache serving runtime,stock mlx-lm 只能吃到 mixed-precision weights,反而被混合 matmul 拖慢。

结论:选 MLX-4bit

16GB 跑 64k context:必须靠 KV 量化#

接着想验证一个更野心的问题——16GB mac mini 能跑 64k input 吗?

先算账。Qwen3.5-9B 的 text_config(从 config.json 解析):

num_hidden_layers: 32
num_key_value_heads: 4 # GQA
head_dim: 256
max_position_embeddings: 262144 # native 256k

KV cache per token (fp16):

2 (K+V) × 32 layers × 4 kv_heads × 256 head_dim × 2 bytes
= 131072 bytes ≈ 128 KB / token
64k tokens × 128 KB ≈ 8.0 GB

加上权重 5.25 GB,total 13.25 GB。16GB mac 减系统 4GB 大概 12GB 可用,几乎一定 OOM 或者重 swap

mlx-lm 原生支持 --kv-bits N 量化 KV cache,8-bit 直接砍半:

Terminal window
mlx_lm.generate \
--model mlx-community/Qwen3.5-9B-MLX-4bit \
--max-tokens 64 --temp 0 \
--kv-bits 8 --kv-group-size 64 --quantized-kv-start 0 \
--prompt "$(cat long_prompt.txt)" # 62812 tokens

实测:

模型 / ContextPrompt tok/sGen tok/sPeak Mem
MLX-4bit / 62812 tok + KV8164.939.7811.57 GB
OptiQ-4bit / 62812 tok + KV8159.549.2812.61 GB

11.57 GB,留 ~3.5 GB headroom 给系统,能跑。Generation 从短 context 的 21.6 tok/s 掉到 9.78 tok/s(每个新 token 都要 attend 62k 个 KV 位置,正常代价)。Prompt processing 6.4 分钟(62812 ÷ 165 ≈ 381 秒)——不是实时但可接受。

OptiQ 在 64k 还是输,验证了”它的 KV 优化要装 mlx-optiq 才能拿到”的猜想。

Plot twist:Qwen3.5-9B 居然是多模态模型#

测着测着发现一个奇怪的细节:模型生成的回答总是带 “thinking process” 风格的开头。我以为是 Qwen3 的 reasoning mode,结果一看 config.json

{
"architectures": ["Qwen3_5ForConditionalGeneration"],
"model_type": "qwen3_5",
"image_token_id": ...,
"video_token_id": ...,
"vision_start_token_id": ...,
"text_config": {...},
"vision_config": {...}
}

Qwen3_5ForConditionalGeneration、有 vision_config、有 image/video token —— 这根本是个多模态 VL 模型!snapshot 目录里果然躺着 preprocessor_config.jsonprocessor_config.jsonvideo_preprocessor_config.json,processor 类是 Qwen3VLProcessor

我之前一直以为 Qwen3.5-9B 是 dense LM(类似 Qwen3-8B / Qwen3-14B),结果 Qwen3.5 这一代直接走的是早期融合多模态路线。HF 文档原话:“Qwen3.5 integrates breakthroughs in multimodal learning, with early fusion training on multimodal tokens”。

更打击我的是顺手查了一下当前在跑的 unsloth/gemma-4-E4B-it-UD-MLX-4bit

"architectures": ["Gemma4ForConditionalGeneration"]
# 包含 vision_config + audio_config + image_token_id + audio_token_id

Gemma 4 也是多模态,而且是图像+音频。我之前一直在用纯文本 endpoint 跑这个模型,完全浪费了它的多模态能力

那既然两个候选模型都是 VL 的,干脆把整个栈升级到 mlx_vlm.server

切换到 mlx_vlm.server 的连环坑#

mlx-vlmmlx-lm 是两个独立包,专门处理 VLM。装好之后看 CLI:

Terminal window
$ uv pip install --python ~/gemma4-mlx/.venv/bin/python 'mlx-vlm>=0.4.4'
$ ~/gemma4-mlx/.venv/bin/mlx_vlm.server --help
usage: mlx_vlm.server [-h] [--model MODEL] [--host HOST] [--port PORT]
[--prefill-step-size PREFILL_STEP_SIZE]
[--kv-bits KV_BITS]
[--kv-quant-scheme {uniform,turboquant}]
[--kv-group-size KV_GROUP_SIZE]
[--max-kv-size MAX_KV_SIZE]
[--quantized-kv-start QUANTIZED_KV_START] [--reload]

mlx_lm.server 接口高度兼容(同样有 --model/--host/--port),但有几个致命差异

坑 1:不支持 --log-level。我那个 LM-Studio 风格的 JIT proxy 里,硬编码了 --log-level WARNING 传给后端。换到 mlx_vlm.server 直接 spawn 失败。

坑 2:proxy.py 的 args 是硬编码的,没法从外面注入 KV 量化等参数。原代码:

self.proc = await asyncio.create_subprocess_exec(
MLX_BIN,
"--model", MODEL,
"--host", MLX_HOST,
"--port", str(MLX_PORT),
"--log-level", MLX_LOG_LEVEL,
...
)

改造成支持 MLX_EXTRA_ARGS 环境变量的版本:

proxy.py
import shlex
MLX_EXTRA_ARGS = shlex.split(os.environ.get("MLX_EXTRA_ARGS", ""))
async def _spawn(self) -> None:
args = [
MLX_BIN,
"--model", MODEL,
"--host", MLX_HOST,
"--port", str(MLX_PORT),
*MLX_EXTRA_ARGS,
]
log.info("spawning %s model=%s port=%d extra=%s",
os.path.basename(MLX_BIN), MODEL, MLX_PORT, MLX_EXTRA_ARGS)
self.proc = await asyncio.create_subprocess_exec(*args, ...)

改完之后,plist 通过 MLX_EXTRA_ARGS 传任意 backend 参数。--log-level 也变成 plist 里”如果用 mlx_lm 就传,用 mlx_vlm 就不传”。干净多了。

坑 3:mlx_vlm 隐式依赖 torch + torchvision。重启之后 chat completion 报:

Failed to load model:
Qwen3VLVideoProcessor requires the Torchvision library but it was not found.
Qwen3VLVideoProcessor requires the PyTorch library but it was not found.

mlx-vlm 自己的 inference 用 mlx,但它复用 transformers 的 processor,那些 processor 里有 torch 调用。装上:

Terminal window
$ uv pip install --python ~/gemma4-mlx/.venv/bin/python torch torchvision

Torch 占大约 200MB 磁盘,但只在 processor 路径用,不参与 forward,运行时内存不会涨。

坑 4:launchctl reload 必须 bootout + bootstrap,不能 kickstart。kickstart 只重启进程,不重新读 plist 的 EnvironmentVariables,导致 env 改了不生效。正确姿势:

Terminal window
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.user.mlx-lm-server.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.user.mlx-lm-server.plist

最终 plist 关键字段:

~/Library/LaunchAgents/com.user.mlx-lm-server.plist
<key>MLX_MODEL</key>
<string>mlx-community/Qwen3.5-9B-MLX-4bit</string>
<key>MLX_BIN</key>
<string>/Users/me/gemma4-mlx/.venv/bin/mlx_vlm.server</string>
<key>MLX_EXTRA_ARGS</key>
<string>--kv-bits 8 --kv-quant-scheme uniform --kv-group-size 64 --quantized-kv-start 0 --max-kv-size 65536</string>
<key>IDLE_SECONDS</key>
<string>600</string>
<key>SPAWN_TIMEOUT</key>
<string>240</string>

IDLE_SECONDS=600 配合 proxy.py 里的 watchdog,10 分钟空闲自动 kill 后端释放内存——LM Studio 那种 unload-if-unused 同款机制。

验证:文本 + 图像端到端#

文本:

Terminal window
$ curl http://100.x.x.110:8000/v1/chat/completions -H "Content-Type: application/json" \
-d '{"model":"mlx-community/Qwen3.5-9B-MLX-4bit",
"messages":[{"role":"user","content":"Say hi in 3 words."}],
"max_tokens":20,"temperature":0}'
{"choices":[{"message":{"content":"Hello there!"}}],
"usage":{"prompt_tps":2.23,"generation_tps":24.32,"peak_memory":6.07}}

图像(生成一张 224x224 蓝色 PNG,base64 塞进 image_url):

Terminal window
$ curl http://100.x.x.110:8000/v1/chat/completions -H "Content-Type: application/json" \
-d '{
"model":"mlx-community/Qwen3.5-9B-MLX-4bit",
"messages":[{"role":"user","content":[
{"type":"image_url","image_url":{"url":"data:image/png;base64,..."}},
{"type":"text","text":"What color? One word."}
]}],
"max_tokens":10,"temperature":0
}'
{"choices":[{"message":{"content":" Blue"}}],
"usage":{"prompt_tps":59.26,"generation_tps":42.26,"peak_memory":6.25}}

模型准确识别颜色,warm cache 状态下 generation 42 tok/s——比纯文本还快,因为 vision encoder 一旦 loaded 后续 forward 非常轻。

最后那个坑:tag 的 zero-trust ACL#

回到开头那个谜题——为什么 mac 自己解析不到 service.<tailnet>.ts.net,但其他设备能?

诊断证据链:

Terminal window
$ tailscale debug netmap | jq '.SelfNode.Tags'
["tag:server"]
$ tailscale debug netmap | jq '[.Peers[].CapMap | keys] | flatten | unique'
["suggest-exit-node"]

mac 这台带 tag:server 标签,但 netmap 里所有 peer 的 CapMap 都没有 service-host 或类似的 cap 推送给 mac。这是 ACL grants 主动屏蔽的:tag 的设备作为 client 不被允许访问其他 internal services(zero-trust 模型,防止某台 server 被攻陷之后横向移动 / 防 SSRF)。

具体表现:

行为结果
dig @100.100.100.100 svc.<tailnet>.ts.netNXDOMAIN
公网 DNS (1.1.1.1, 8.8.8.8)也是空
CapMap.service-host for foreign servicesnull
curl https://svc.<tailnet>.ts.net/timeout

非 tag 的设备(笔记本、手机、浏览器)都在 grants 的 client 列表里,DNS 和 cert 都正常解。mac 是被设计成”只能被访问,不能主动访问”。

所以我从 mac 上自己做端到端测试根本是死胡同。正确姿势:直接 hit 自己 service 的 VIP IP(绕开 DNS):

Terminal window
$ curl http://100.x.x.110:8000/v1/models # 自己持有的 svc:llm VIP
{"data":[{"id":"mlx-community/Qwen3.5-9B-MLX-4bit",...}]}

或者去笔记本/手机上测公网入口。

我把这条 ACL 行为当成永久 reference 写到了 ~/.claude/CLAUDE.md 里,免得下次又花半小时重新追溯一遍。

收获总结#

一次本来只想 “curl 一下看看服务挂没挂” 的小事,最后摸出了一整条栈:

  1. Tailscale Service VIP ≠ device hostname,是独立的虚拟资源,有自己的 100.x VIP,host 必须主动 advertise + admin 批准
  2. OptiQ 量化在 stock mlx-lm 上跑反而比 MLX-4bit 慢,它的卖点 KV 优化要装 mlx-optiq 才能吃到
  3. 16GB mac mini 跑 64k context 可行,但必须 --kv-bits 8,peak ~11.6 GB,留 3.5 GB headroom
  4. Qwen3.5 / Gemma 4 都是早期融合多模态模型,用 mlx_lm.server 跑等于浪费一半能力
  5. mlx_vlm.server 作为 backend drop-in,CLI 兼容 mlx_lm.server,但隐式依赖 torch + torchvision,且不接受 --log-level
  6. launchctl 重载必须 bootout/bootstrap,kickstart 不会重读 env
  7. tag 是单向访问,从这种 host 测自己的 tailnet service 要直接 hit VIP IP,端到端测要用其他设备

mac mini 这台机器现在跑着 Qwen3.5-9B VLM,10 分钟闲置自动卸载模型,32k 输入实时响应、64k 输入 6 分钟 prefill,文本 + 图像都能处理,OpenAI-compatible API。家用 LLM endpoint,16GB 真的够。

Sources:

16GB Mac mini 上从 LLM 到多模态 VLM service:一次完整踩坑实录
https://blog.lishuyu.top/posts/2026-04-23-mac-mini-vlm-service/
作者
猫猫魔女
发布于
2026-04-23
许可协议
CC BY-NC-SA 4.0