keepbx.cn
← 目录

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 协助整理,事实与结论经作者核对。