给 AI Agent 舰队搭本地兜底模型:一次 mlx_lm.server 绑定卡死的排障实录

date
Jul 15, 2026
slug
apple-silicon-fleet-fallback-llm
status
Published
summary
M1 Ultra 上部署本地兜底大模型,踩过 mlx绑IP卡死/socat转发卡死/HF Xet下载卡 三个真坑
tags
Apple Silicon
MLX
本地LLM
排障
AI Agent
type
Post
URL
notion image
notion image
notion image

给 AI Agent 舰队搭本地兜底模型:一次 mlx_lm.server 绑定卡死的排障实录

当所有云端模型同时挂掉,你的 AI Agent 还能不能出字?这篇记录我怎么在一台 Apple Silicon Mac 上部署一个本地大模型,作为整个 Agent 舰队的"最后一道兜底"——以及路上踩的三个把我坑了两小时的真坑。

为什么要一个"永远不会被触发"的兜底

我有一组自建的 AI Agent 跑在多台机器上,主力模型走云端商业 API,还配了好几层 fallback:主模型挂了自动落到备用云端 A,再挂落到备用云端 B……
但这条链再长,本质上都是"云端"。理论上存在一种极端情况:所有云端端点在同一时刻全部不可用(服务商集体抽风、网络出口全断、被限流雪崩)。概率极低,但真发生时,Agent 就彻底哑了。
所以我想加最后一环:一个纯本地、离线可用的大模型端点,挂在所有 fallback 链的最末尾。平时永远不触发,只在"云端全灭"这种核战级场景兜底。慢一点无所谓——有总比没有强。
硬件是一台 M1 Ultra、128GB 统一内存的 Mac Studio。模型选了 GLM-4.5-Air 的 3-bit DWQ 量化版(106B 参数的 MoE,量化后约 47GB),用 Apple 的 MLX 框架跑,实测单机生成约 34 tok/s,跨机器调用约 21-23 tok/s——作为兜底完全够用。
听起来很简单:下模型、起 server、配 fallback。结果三个环节各埋了一个坑。

坑一:mlx_lm.server 绑非环回 IP,HTTP 层直接卡死

MLX 官方提供了 mlx_lm.server,一条命令就能起一个 OpenAI 兼容的推理端点。我要让舰队里其他机器通过内网虚拟组网(我用的是 Tailscale)访问它,自然就绑到了这台机器的内网 IP 上:
```bash
mlx_lm.server --model ~/models/<model> --host <内网IP> --port <端口>
`
server 起来了,日志显示 Starting httpd at <内网IP> on port ...,端口也在 LISTEN。但——任何请求都超时curl /v1/models 挂住十秒返回 code 000,一个字都不吐。
一开始我以为是模型加载慢(47GB 首次加载确实要几分钟)。但 /v1/models 这个接口根本不需要模型加载完,它应该秒回。这不对劲。
关键的分水岭实验:把 host 改成 `127.0.0.1`,秒回 200。 改回内网 IP,又卡死。
我用最原始的工具逐层排查,把"网络问题"彻底排除:
```bash

裸 TCP 发最小 HTTP 请求(绕开所有代理、HTTP 库的花样)

printf 'GET /v1/models HTTP/1.0\r\n\r\n' | nc 127.0.0.1 <端口> # → 200 OK
printf 'GET /v1/models HTTP/1.0\r\n\r\n' | nc <内网IP> <端口> # → 空,卡死
`
再用 Python socket 精确定位卡在哪一步:
```python
import socket
s = socket.create_connection(("<内网IP>", 端口), timeout=5) # connect 成功!
s.sendall(b"GET /v1/models HTTP/1.0\r\n\r\n")
s.recv(4096) # ← 卡在这里,recv 超时
`
TCP 三次握手成功,但数据阶段收不到响应。 这排除了防火墙、路由、端口占用——如果是那些问题,connect 那步就会失败。
最后一个对照实验一锤定音:我用 Python 标准库的 http.server 绑同一个内网 IP,起一个玩具 HTTP 服务:
```python
import http.server, socketserver

绑 <内网IP>:<另一个端口>,返回简单 200

`
它收发完全正常。 同一个 IP、同一台机器,标准库没问题,mlx_lm.server 就卡。
结论:这不是网络、不是操作系统、不是 IP 绑定本身的问题,而是 mlx_lm.server 底层那个 BaseHTTPServer 实现,在处理来自非环回地址的连接时出了问题——只有 127.0.0.1 的连接能被正常处理。
⚠️ 严谨声明:以上是我在这套具体环境(这个版本的 mlx-lm + macOS + Tailscale overlay 网卡)下复现并推断出的结论,根因未做到源码级最终确认。connect 成功只能证明 TCP 握手完成,并不能 100% 排除 MTU、overlay 数据路径等网络层因素——严格做法是继续抓包(tcpdump)+ 看服务端日志坐实。但"标准库绑同一 IP 正常、mlx_lm.server 卡"这个对照,已足够让我优先怀疑应用层并绕过它。
💡 教训:当"绑 localhost 正常、绑对外 IP 卡死"时,别急着怀疑网络。用 nc + Python socket 把 connect 阶段和 recv 阶段分开测——connect 成功但 recv 超时,强烈指向应用层(server 实现)而非网络层,是很高效的第一刀。

坑二:socat 转发也卡死——fork 模式的陷阱

既然 mlx_lm.server127.0.0.1 好使,那思路很自然:让它只绑 localhost,前面加一个 TCP 转发,把内网 IP 的流量转发到 127.0.0.1
第一反应是用 socat,一行命令的经典方案:
```bash
socat TCP4-LISTEN:<端口>,bind=<内网IP>,reuseaddr,fork,nodelay TCP4:127.0.0.1:<内部端口>
`
socat 正常 LISTEN 了。然后——又卡死。经它转发的请求同样是握手成功、数据不通。
我一度以为是端口问题、是转发目标问题,换端口、换目标反复试,全卡。直到我用 Python socket 分别测 socat 转发端口和一个对照的标准 socketserver:
socat 握手成功但转发数据卡死。 从对照实验看,问题指向 socatfork 模式——它每来一个连接就 fork 一个子进程处理,而这个 fork 模式在我这台机器的虚拟网络接口上转发数据时会卡住(具体机制我没深挖到底,怀疑是子进程与虚拟网卡接口的某种交互问题,同样属于"此环境复现、根因未坐实")。
解法:不用 socat,自己写一个极简的 Python threading TCP 转发器——用线程而非 fork,双向字节泵(下面是核心逻辑的伪代码骨架,省略了 bind/accept 主循环和错误处理):
```python
import socket, threading
def pump(src, dst):
try:
while True:
data = src.recv(65536)
if not data:
break
dst.sendall(data)
finally:
for s in (src, dst):
try: s.shutdown(socket.SHUT_RDWR)
except Exception: pass
def handle(client):
upstream = socket.create_connection(("127.0.0.1", 内部端口), timeout=10)
t1 = threading.Thread(target=pump, args=(client, upstream), daemon=True)
t2 = threading.Thread(target=pump, args=(upstream, client), daemon=True)
t1.start(); t2.start()
t1.join(); t2.join()

主循环: 绑内网 IP, accept 后每连接开一个 handle 线程

`
这个 30 行的转发器一上,端点端到端秒通,跨机器调用 200、几十毫秒。
💡 教训socatfork 模式不是万灵药。在虚拟网络接口(VPN/overlay 网卡)上做转发时,fork 出来的子进程可能出诡异的数据卡死。一个纯 threading 的 Python 转发器反而更可控、更好调试。

坑三:HuggingFace 大模型下载,Xet 存储卡住某个分片

模型 47GB,分成 10 个 safetensors 分片。用官方 hf download 走代理下载,9 个分片顺利落地,卡在第 2 个分片死活下不动
现象很迷惑:.cache/huggingface/download/ 目录里那个分片只有一个 0 字节的 `.lock` 文件,没有 .incomplete 的实际下载中文件——也就是说下载进程根本没真正开始拉这个分片,卡在某个前置环节。
试了设 HF_HUB_ENABLE_HF_TRANSFER=1 加速,没用(这个变量已经废弃了,而且这个 repo 用的是 HuggingFace 较新的 Xet 存储,走的是不同的传输协议)。
解法:绕开 hf 客户端的 Xet 传输逻辑,直接用 curl 拉这个分片的 resolve URL(注意:绕的是客户端的 Xet 下载逻辑,不是 Xet 存储本身——文件照样从 Xet 后端取,只是换了个更朴素的 HTTP 客户端):
```bash
curl -L -C - \
"https://huggingface.co/<org>/<model>/resolve/main/model-00002-of-00010.safetensors" \
-o model-00002-of-00010.safetensors
`
-L 跟随重定向(Xet 存储会 302 跳到一个 CAS 网关域名),-C - 断点续传。curl 立刻开始稳定下载,几分钟拉完,补齐了缺的那一片。
💡 教训hf download 卡在某个分片、.cache 里只有 0 字节 lock 时,别死等。直接 curl 那个文件的 resolve/main/<filename> URL,绕开客户端的 Xet 逻辑,往往一把过。

最终架构

三个坑填完,整个兜底端点长这样:
`
舰队各 Agent ──(内网,fallback最末环)──▶ <内网IP>:<高端口>
│ Python threading TCP 转发
│ (launchd 常驻自启)
127.0.0.1:<内部端口>
│ mlx_lm.server
│ (launchd 常驻自启)
GLM-4.5-Air 3bit DWQ (~47GB)

© Ying Bun 2021 - 2026