自己写个模型路由器:一台机器上管好八套部署
上一篇讲了我怎么在 2×RTX 3090 上部署 Qwen3.8-27B,最后落到 llama.cpp + GSQ 量化。但真实使用里从来不是"一套部署用到底":服务化的 vLLM、秒切的 llama.cpp、跑长文档的双卡 262K、追求质量的 MoE 高档位……两套引擎、七八个权重、不同的显存排他方式,手动切换是不可维护的。
所以最终形态是我自己写的一个模型路由器:纯 Python 标准库,几百行,一个 OpenAI 兼容的反代,背后挂多个"档位"。这篇讲它的设计和几个月使用里撞出来的教训。
设计:档位化的推理服务
路由器监听一个端口,客户端照常发 OpenAI 格式的请求,model 字段填档位名,路由器负责:
- 按需拉起:档位进程(vLLM 容器或 llama-server)第一次被请求时才启动,vLLM 冷启 3-5 分钟、llama.cpp 约 10 秒;
- 互斥运行:显存就那么点,同一时间只跑一个档位,切换时自动停旧起新;
- 空闲回收:半小时没请求就卸载,显存全部释放;
- 透传:请求体原样转发,
model字段在出口改写成上游真实模型名,客户端无感。
我的档位表:
vllm-huihui-多流-131K— 服务化主力,多并发,冷启 3-5 分钟vllm-fp8-单流极速-200K— FP8 单流低延迟llamacpp-gsq-单卡-131K-mtp— 日常主力,秒切llamacpp-gsq-双卡双流-131Kx2-mtp— 双实例并发llamacpp-gsq-262K-双卡单流-mtp— 层切分跑极限长上下文strata-125b-moe— MoE 高档位(见MoE 那篇)
细节上有几个值得抄的做法:档位名直接写"后端 + 布局 + 上下文",切换成本一眼可见;/v1/models 的 loaded 字段做真实健康探测而不是读状态机标记(状态机会把默认档标记为"目标"但不真的加载,谎报过 loaded);再加一个 WATCHDOG 线程定期自查,发现"容器在跑但路由器不知情"就告警。
四个教训,都是撞出来的
双卡层切分不要用行切分。 给 262K 档配双卡时,直觉加了 -sm row(行切分),结果与 GSQ 模型的 MTP 结构不兼容,报错是莫名其妙的 “does not support split buffers”。删掉它用默认层切分,一样双卡协作。教训:llama.cpp 的多卡模式不是通用开关,跟模型结构有耦合。
带外操作会毁掉一切状态管理。 有一次容器被手动 docker start 拉起来,路由器的空闲回收完全看不见它——它只管自己拉起的进程,于是显存就那么被无名占用着。加了 WATCHDOG 之后这种事 30 秒内就会在日志里大喊。教训:所有档位操作必须过路由器,没有例外;绕过它等于放弃回收能力。
冷加载速度是隐形体验杀手。 权重放机械盘时直读 205 MB/s,冷启动 55 秒;挪到 NVMe 后 2.3 GB/s,6 秒。平时有页缓存感觉不到,但空闲自动卸载开着,冷加载天天发生,这 50 秒的差距每天都在被放大。顺带的坑:调试期手工拉起的实例会和路由器互相"认领"、互相残杀,测试要么全走路由器,要么把它的状态清干净。
memlock 是最隐蔽的坑。 MoE 档要把 47G 权重 mlock 锁进内存,而 Linux 默认每进程只能锁 8MB。锁失败报出来的错误是 “out of memory”——跟显存毫无关系,会让你盯着 nvidia-smi 白查半小时。解法:systemd 服务里加 LimitMEMLOCK=infinity(由服务拉起的子进程自动继承)。
为什么不直接用现成方案
看过几个推理网关项目,功能更全(负载均衡、限流、多租户),但我的需求里最重的是档位互斥 + 按需启停 + 空闲回收这个闭环,恰好是消费级单机场景特有的;纯标准库几百行,坏了能一口气读完,比引入一个重依赖划算。如果哪天要多机、多副本,那确实该换正式网关——单机折腾,自带的轮子刚好。
参考
- vLLM
- llama.cpp
- OpenAI API 规范 — 路由器透传的接口格式
- 姊妹篇:27B 部署与量化、MoE 实测