那天我打开浏览器想测一下家里的 mlx 服务,curl 直接报 Could not resolve host。明明前两天还好好的。我对着终端骂了一句”我那操蛋的 mlx 服务呢”,然后开始顺藤摸瓜——结果一个下午顺出来一长串原本不打算碰的东西:Tailscale Service VIP 概念、KV cache 量化、Qwen3.5-9B 居然是个多模态模型、tagmlx_lm.server 升级成了 mlx_vlm.server,端到端能处理文本+图像。
整个过程踩的坑有点多,写下来留作以后参考,也给同样想在 16GB mac mini 上自建 multimodal LLM endpoint 的人省点时间。
第一个谜题:消失的 service 域名
事情从这条命令开始:
$ curl https://service.<tailnet>.ts.net/curl: (6) Could not resolve host: service.<tailnet>.ts.net但浏览器(在另一台笔记本上)能访问。我一开始的反应是”啊 DNS 缓存挂了”,但顺着查发现根本不是缓存问题:
$ 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.netHost 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 拉到完整状态:
$ tailscale debug netmap | python3 -c "import sys, jsond = 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 才是 svcCapMap.service-host 这个 cap 才是真正告诉客户端”我这台 mac 是 svc
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"}} } } } }}接收 svc127.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%。但宣传归宣传,得自己测。
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/s | Gen tok/s | Peak Mem |
|---|---|---|---|
| MLX-4bit | 106.79 | 21.62 | 5.25 GB |
| OptiQ-4bit | 43.29 | 19.52 | 6.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: 32num_key_value_heads: 4 # GQAhead_dim: 256max_position_embeddings: 262144 # native 256kKV 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 直接砍半:
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实测:
| 模型 / Context | Prompt tok/s | Gen tok/s | Peak Mem |
|---|---|---|---|
| MLX-4bit / 62812 tok + KV8 | 164.93 | 9.78 | 11.57 GB |
| OptiQ-4bit / 62812 tok + KV8 | 159.54 | 9.28 | 12.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.json、processor_config.json、video_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_idGemma 4 也是多模态,而且是图像+音频。我之前一直在用纯文本 endpoint 跑这个模型,完全浪费了它的多模态能力。
那既然两个候选模型都是 VL 的,干脆把整个栈升级到 mlx_vlm.server。
切换到 mlx_vlm.server 的连环坑
mlx-vlm 跟 mlx-lm 是两个独立包,专门处理 VLM。装好之后看 CLI:
$ uv pip install --python ~/gemma4-mlx/.venv/bin/python 'mlx-vlm>=0.4.4'$ ~/gemma4-mlx/.venv/bin/mlx_vlm.server --helpusage: 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 环境变量的版本:
import shlexMLX_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 调用。装上:
$ uv pip install --python ~/gemma4-mlx/.venv/bin/python torch torchvisionTorch 占大约 200MB 磁盘,但只在 processor 路径用,不参与 forward,运行时内存不会涨。
坑 4:launchctl reload 必须 bootout + bootstrap,不能 kickstart。kickstart 只重启进程,不重新读 plist 的 EnvironmentVariables,导致 env 改了不生效。正确姿势:
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.user.mlx-lm-server.plistlaunchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.user.mlx-lm-server.plist最终 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 同款机制。
验证:文本 + 图像端到端
文本:
$ 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):
$ 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,但其他设备能?
诊断证据链:
$ 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
具体表现:
| 行为 | 结果 |
|---|---|
dig @100.100.100.100 svc.<tailnet>.ts.net | NXDOMAIN |
| 公网 DNS (1.1.1.1, 8.8.8.8) | 也是空 |
CapMap.service-host for foreign services | null |
curl https://svc.<tailnet>.ts.net/ | timeout |
非 tag
所以我从 mac 上自己做端到端测试根本是死胡同。正确姿势:直接 hit 自己 service 的 VIP IP(绕开 DNS):
$ 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 一下看看服务挂没挂” 的小事,最后摸出了一整条栈:
- Tailscale Service VIP ≠ device hostname,是独立的虚拟资源,有自己的 100.x VIP,host 必须主动 advertise + admin 批准
- OptiQ 量化在 stock mlx-lm 上跑反而比 MLX-4bit 慢,它的卖点 KV 优化要装
mlx-optiq才能吃到 - 16GB mac mini 跑 64k context 可行,但必须
--kv-bits 8,peak ~11.6 GB,留 3.5 GB headroom - Qwen3.5 / Gemma 4 都是早期融合多模态模型,用
mlx_lm.server跑等于浪费一半能力 - mlx_vlm.server 作为 backend drop-in,CLI 兼容
mlx_lm.server,但隐式依赖 torch + torchvision,且不接受--log-level - launchctl 重载必须 bootout/bootstrap,kickstart 不会重读 env
- 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: