4 个模型、1 个端口:本地推理网关的 Unix socket 设计
模型从 1 个变成 4 个之后,最烦的不是显存,是客户端配置。每多一个模型就多一个地址、多一个端口,脚本、家里中控、临时 curl 都要跟着改;端口开多了,还得记住哪个对外、哪个不该对外。
于是把「一模型一端口」改成「一个入口 + model 字段路由」:客户端永远只认一个 OpenAI 兼容地址,代理按请求里的 model 把请求送到对应引擎。
这篇文章讲这个网关的设计:一条路由键、两条传输、一个显存预算,以及三个实际踩过的坑。
约束
先把边界定死:
- 单机、单用户,只绑回环,不做公网鉴权;
- 引擎是独立进程(llama-server、whisper-server),不改造引擎本身;
- 一台 22 GB 显存的机器,同一时间最多跑 4 个引擎;
- 客户端只认 OpenAI 的
/v1接口; - 引擎崩了要能各自重启,机器重启后要恢复上次的引擎列表。
这些约束决定了后面的所有选择。尤其是「单机单用户」——它让 Unix socket 成为比 TCP 更合适的选择。
一个入口,两条传输
对外只有一个 TCP 端口,由代理监听。代理到引擎的传输分两种:
客户端(只认一个 OpenAI 端点)
│ POST /v1/chat/completions {"model": "..."}
▼
代理(一个 TCP 端口)
├── unix socket ── llama-server A
├── unix socket ── llama-server B
├── unix socket ── llama-server C
└── 回环临时端口 ── whisper-server(唯一例外)
llama.cpp 引擎每个进程一个 Unix socket,不占 TCP 端口:四个引擎同时跑,额外 TCP 端口是 0。socket 文件权限是 srwx------,只有本机同一用户能连,连文件系统这一层都替代理挡了一道。
whisper.cpp 是唯一的例外:它只支持 TCP。于是每个 whisper 引擎启动时向系统要一个回环随机端口,同时加 --inference-path /v1/audio/transcriptions,把它的原生接口伪装成 OpenAI 的转写端点。对外仍然只有一个入口,客户端不需要知道背后是 llama 还是 whisper。
为什么不给每个引擎一个固定 TCP 端口:
- 客户端要记住 N 个地址,改一个模型就改一次配置;
- 端口是暴露面,多一个就多一条要记得关的路;
- 端口会冲突,尤其是多个实例、容器、重启之后的残留;
- Unix socket 不经过网络栈,也绕开了「只绑回环还是不小心绑了所有网卡」的犹豫。
代价是它不能跨主机。代理和引擎必须在同一台机器、同一个文件系统命名空间里。这一条在单机场景是优点,在容器化场景会立刻变成问题。
路由键不是字符串相等
代理不解析业务,只看一个字段:model。
但「相等」没有想象中简单。调用方可能传文件名、去掉 .gguf 的名字、模型的显示名,甚至是历史配置里的别名。所以每个引擎启动时生成一组别名:
- 模型文件名;
- 去掉
.gguf/.bin后缀的名字; - 内部 model id;
- 路径的 basename。
全部转小写后比较。这样 Qwen3.6-35B-A3B-UD-Q4_K_M.gguf、Qwen3.6-35B-A3B-UD-Q4_K_M 和内部 id 都能命中同一个引擎。
GET /v1/models 不转发给任何引擎,而是把当前 ready 的引擎聚合成一个列表,并附上引擎 id、backend、状态、显存估算和关键参数。客户端因此可以自己发现「现在到底有哪些模型可用」。
失败也要说清楚:
- 传了 model 但没有匹配的引擎:404
model_not_found,响应里带available_models; - 没传 model 且只有一个 ready 引擎:直接用它;
- 没传 model 且有多个 ready 引擎:400
model_required,同样带可用列表。
{
"error": {
"message": "模型 xxx 没有在运行;可用:...",
"type": "invalid_request_error",
"code": "model_not_found"
},
"available_models": ["...", "..."]
}
把「可用列表」放进错误体,是一个很小的决定,但省掉了很多「为什么连不上」的来回。客户端拿到 404 之后可以自动降级,或者至少把可用模型打到日志里。
另外两条兜底规则:/props 和 /metrics 不带 ?model= 时走默认引擎;其他非 /v1/* 路径也走默认引擎。默认引擎 = 第一个 ready 的引擎(没有显式指定默认模型时)。规则简单,出问题时容易解释。
multipart 的 filename 陷阱
/v1/audio/transcriptions 是 multipart 请求,model 不在 JSON body 里,而在表单字段里。Python 3.13 移除了 cgi 模块,所以这里手写了一个解析器:找 boundary、逐 part 读 Content-Disposition、只认 name="model" 的那一段。
坑在于:上传的音频文件名里也可能出现 model。测试里专门放了一个 model-trap.wav——如果解析器图省事,对每个 part 的 disposition 做一次「包含 model」的判断,就会把文件名当成模型名,路由到错误的引擎。
所以判定必须是参数级的:
if _content_disposition_param(disposition, "name") == "model":
raw = body[header_end + sep_len:content_end]
value = raw.rstrip(b"\r\n").decode("utf-8", "replace").strip()
return value or None
另一个相关决定:音频请求体绝不写进调用记录。整段音频可能有几十 MB,早先默认把请求体完整入库,33 次 ASR 调用就把调用表从约 120 MB 撑到 200 MB。现在音频端点只记元数据、字节数和响应文本,不记请求体。
显存预算与启动顺序
启动引擎前算一笔账:
required = 模型大小 + mmproj 大小 + 1024 MiB 余量
然后拿 nvidia-smi 的实际剩余显存比较,不够就返回 409 insufficient_gpu,并带上 required_mib 和 free_mib。余量的作用是给 KV cache、CUDA context 和桌面留空间——22 GB 的卡,权重贴着上限放,剩几百 MiB 时一次长上下文就会 OOM。
这里有一个容易忽略的时序问题:停止一个引擎后立刻启动另一个,GPU 占用有缓存,2 秒内可能读到旧数字,把「其实够」误判成「不够」。所以预算校验前强制刷新一次。
还有两个上限:
max_engines默认 4,配置范围 1–16;- 同一模型只允许一个实例,重复启动返回
already_running。
开机恢复是串行的。不是性能考虑,而是避免两个大模型同时抢显存:第一个还在加载,第二个的预算校验看不到它,最后两个都 OOM。
模型元数据也不整份读。GGUF 头只读到 tokenizer.* 或前 300 个 KV 就停,关键字段(architecture、context_length、parameter_count)已经拿到;n_kv > 200000 直接放弃;解析异常时返回已经读到的部分。模型扫描要的是「够用」,不是「完整」。
流式与记账
代理对 SSE 是透传的:收到一块就 flush 一块,加 X-Accel-Buffering: no,并把这个连接标记为 close,避免中间层攒着不发。
但透传不等于不管。为了让调用记录拿到 token 用量,代理会在转发前给请求注入 stream_options.include_usage = true,这样 llama-server 的最后一个 SSE chunk 会带 usage。客户端断开时捕获 BrokenPipe,已经收到的部分照常记账,并在调用记录里注明断开。
这些都是「代理存在」的理由:如果只是转发字节,不如让客户端直连;代理要做的恰恰是客户端不该关心的那部分——统一入口、统一记账、统一失败语义。
实测
接口行为是本地复跑的,GPU 数字来自项目内的实测记录,本文没有复跑 GPU 基准:
| 项目 | 数字 | 来源 |
|---|---|---|
| 路由单测 | 10 个,7.0 s 全过 | 本次复跑 |
| 4 个 llama 引擎的额外 TCP 端口 | 0 | 项目记录 |
| whisper 引擎 | 每个 1 个回环随机端口 | 项目记录 |
| 35B-A3B(UD-Q4_K_M) | pp512 2192.5 t/s,tg128 105.6 t/s,峰值 21586 MiB | 项目实测 2026-10-12 |
| 27B(Q4_K_M) | pp512 710.5 t/s,tg128 27.2 t/s,峰值 16689 MiB | 项目实测 2026-10-12 |
| 8B 视觉 + 1.7B 语音 | 13.2 + 4.7 ≈ 18 GiB,可同时跑 | 项目记录 |
| whisper 11 s 音频 | 0.4–0.8 s,显存约 1.4 GiB | 项目记录 |
「4 个模型、1 个端口」是接口设计,不是并发承诺。四个引擎是上限;22 GB 的卡上,35B 这种贴顶的模型跑起来之后,基本塞不下第二个大模型。实际同时跑几个,由显存预算和 max_engines 一起决定。
取舍与边界
这个网关适合:单机、单用户、模型数量不多、客户端都讲 OpenAI 协议、希望「加模型不改客户端」的场景。
不适合:
- 多租户或多用户:代理没有鉴权、没有配额、没有按用户的限流;
- 多机或多副本:Unix socket 出不了本机,也没有请求级负载均衡;
- 需要弹性伸缩:引擎是本地子进程,不做调度;
- 高并发写入:代理是单进程 Python 线程模型,请求体和响应会完整过内存,单请求体上限 256 MB。
如果要跨容器,Unix socket 需要共享卷,或者退回 TCP + 鉴权;如果要跨机器,应该换 vLLM、TGI、Ray Serve 这类带调度和副本的推理服务,而不是继续给这个代理加功能。
这也是这个项目里最重要的取舍:「一个端口」是接口决定,不是性能决定。 它换来的是一致的使用体验和更小的暴露面,代价是放弃了横向扩展。
结尾
这个网关没有复杂的调度,也没有聪明的缓存。它只做了三件事:把 model 映射到一个引擎、把失败说清楚、在启动前算清显存。
真正难的不是写一个 Unix socket 客户端——那只有十几行;难的是提前决定「调用方应该知道什么、不应该知道什么」。一旦决定客户端只认一个入口,路由键、错误体、日志和显存预算都会顺着这个决定长出来。
初稿由 AI 协助整理,事实与结论经作者核对。