把 llama-swap 玩成热切框架:16GB 显存分时复用的多backend复合方案
起因:一张 16GB 卡,排队等活的四类负载
一张 16GB 显存的卡,怎么同时养聊天、生图、语音、检索一整支杂牌军?我 PC(RTX 4070 Ti Super,16GB 显存)上此刻排队等 GPU 的,就是四类互不相让的推理负载:
- LLM 主力模型:聊天 / agent / coding,十几个 GGUF 档位随叫随换;
- CV 工作流:生图、超分、像素处理,跑在 ComfyUI 上;
- ASR 转写:会议录音转写 + 说话人分离,用的是 MOSS-Transcribe-Diarize;
- 常驻小模型:embedding、reranker、决策模型,CPU 上不打扰 GPU。
这四类谁都吃显存,16GB 决定了它们不可能同时驻留 GPU。最早我给 ComfyUI 单独写过一套切换器(comfyui-llm-switcher,下文细讲),LLM 侧靠 llama-swap 管生命周期,两边各管各的,中间靠脚本缝缝补补。
后来有一天我盯着 llama-swap 的 config.yaml 突然反应过来一件事:
llama-swap 对”模型”的抽象其实只有三样东西:一条启动命令、一个监听端口、一次健康检查。
cmd 里写什么它根本不在乎。llama-server 只是最常见的那个答案,不是唯一的答案。只要你的后端进程能”启动后在指定端口上监听、提供健康端点、死掉能被 SIGTERM 收走”,llama-swap 的全套生命周期管理——按需拉起、ttl 自动卸载、热切换、分组调度、统一日志——就全部免费赠送。
于是 llama-swap 在我这套系统里的角色,从”GGUF 热切换代理”升级成了通用热切框架:一个 :9292 端口的 OpenAI 兼容总线,底下插着四种 backend,打通的是 llama.cpp、vLLM、ComfyUI 三大模型生态——GGUF 世界的一份权重、vLLM 世界的一份 safetensors、ComfyUI 世界的一堆自定义节点,从同一个端口进出。下文先交代总体架构,然后逐个讲 vLLM、Strata、ComfyUI 三个新 backend 怎么接进来、踩过哪些坑,最后给实测推理数据和一份浓缩的在役调度配置。
总体架构
graph TB
Client[Hermes Agent / new-api / 各种脚本]
LS[llama-swap :9292<br/>OpenAI 兼容总线]
subgraph GPU[GPU 分时复用 - 同一时刻只有一家在显存里]
LLAMA[llama-server<br/>GGUF 主力与档位家族]
STRATA[Strata serve<br/>qwen3.8-flash-next 256k]
VLLM[vLLM serve<br/>MOSS-Transcribe-Diarize]
COMFY[ComfyUI adapter<br/>CV 工作流 backend]
end
subgraph CPU[cpu-group 常驻]
EMB[embedding / reranker]
DEC[决策模型 CPU 版]
end
Client --> LS
LS --> LLAMA
LS --> STRATA
LS --> VLLM
LS --> COMFY
LS --> EMB
LS --> DEC
协议层面本来就是 llama-swap 的看家本领。我特意翻了它的源码核实路由:OpenAI 协议全家桶原生都在——chat completions、completions、embeddings、rerank、responses API、audio(transcriptions / speech)、images(generations / edits)各有专用路由,连 Anthropic 的 /v1/messages 和决策模型的 /v1/systemone 都支持,甚至还给生图类 backend 预留了 /v1/loras 这种端点。总线上能跑的每一类请求——对话、向量、重排、语音、出图——llama-swap 都有对应的转发路径。
客户端因此完全不需要知道后端是谁:body 里的 model 字段写 "mtd:0.9b" 还是 "qwen3.8-flash-next:gsq-rco-q3-256k",llama-swap 负责把对应引擎拉起来、原样转发、用完定时卸载。
关键的一个实现细节(后面每个 backend 都会踩到):llama-swap 只做 ${PORT} 的文本替换,不注入环境变量。所有”把端口告诉后端”的动作都得自己在 cmd 里想办法。
Backend 1:vLLM —— 一个启动器接入任意 vLLM 模型
ASR 是我最近的需求:把会议录音转成带说话人分离的文字稿。模型选了 OpenMOSS-Team 的 MOSS-Transcribe-Diarize(0.9B),这是 vLLM 的地盘,llama.cpp 跑不了它。
我没有为这个模型单独部署一套 vLLM 服务,而是写了个通用启动器 serve-vllm.sh,把运行时和模型解耦:vLLM 的 venv(_runtime/vllm-env)建在模型仓库的挂载卷里,跟具体模型无关;模型路径、serve 名、端口都是位置参数。config.yaml 里接一个新 vLLM 模型只需要一段:
1 | |
注意 ${PORT} 是第三个位置参数传给脚本的——这就是前面说的”llama-swap 不注入环境变量”的坑,vLLM 只认 --port,所以由启动器代劳。
几个刻意的调优:
--enforce-eager:砍掉 CUDA graph 捕获,vLLM 冷启动时间大幅缩短。热切场景下模型是”用完就走”的旅客,不是常驻户,图编译的收益根本摊不平;ttl: 120:语音条目用完 2 分钟自动卸载让位给 LLM。这个 ttl 策略明显比聊天主力(300s)激进——ASR 是批处理型负载,一次跑完就闲,没必要占着显存;VLLM_MAX_MODEL_LEN=32768:按需压上下文,转写一条语音用不了多少;长会议转写前可以临时提到 131072。
整套 vLLM 运行时就装在 llama-swap 自己的容器里跑(venv 放挂载卷),踩了一串有意思的坑,都修在启动器里:
| 坑 | 症状 | 修法 |
|---|---|---|
| torchcodec 原生库 wheel 无 RUNPATH | vllm serve 在 multimodal 导入阶段直接崩 |
启动器显式把 torch/nvidia 库目录拼进 LD_LIBRARY_PATH |
| triton JIT 需要 C 编译器,容器没有 gcc | gumbel 采样 kernel 编译失败 | venv 里塞一个 zig 的 cc shim,CC 指过去 |
| 精简镜像没有 python3-dev | triton 编译找不到 Python.h |
头文件持久存在挂载卷的 venv include 里,启动时自愈补进 /usr/include |
| JIT 产物随容器重建丢失 | 每次重建都重新编译 | TRITON_CACHE_DIR 放挂载卷 |
这套启动器写完之后的爽点是:vLLM 全家桶里任何模型,加一段 yaml 就接入,不用新容器、不用新端口、不用新运维面。MOSS-Transcribe-Diarize 只是第一个住客。
Backend 2:Strata —— 一个引擎碾压手动调参
先交代背景:Qwen3.8-Flash-Next 是 125B 的 MoE——模型由 24,576 个专家组成,每个 token 实际只激活 10 个左右。理论上这正是对消费级显卡最友好的架构,但我在 llama.cpp 上实际挤出来的效果并不理想:参数调到手软(--n-cpu-moe、--override-tensor 逐层挪张量,再到后来进主干的 GPU 专家 LRU 缓存 --moe-cache-mib),128k 上下文的天花板也就是 pp 400+ / tg 20-30,离”放心当主力”还差着一截。
Strata(MIT 协议)走了一条完全不同的路:不是想办法把模型塞进 GPU,而是把模型铺到整台机器上。它 README 里那个”图书馆”比喻非常精准:
- 显卡是书桌:约 4,000 个最常被用到的专家常驻显存,摊开着随时用;
- 内存是书架:全部 24,576 个专家都住在 RAM 里,某个 token 要用到不在书桌上的专家,CPU 就地算完,GPU 根本不用停;
- SSD 是档案馆:29GB 的 n-gram 查找表放在盘上,每个 token 只读几行,压根不占 RAM。
再叠加两件套:MTP 投机采样——小模型猜后面几个词、大模型一次验完,同样的答案提前 1.6-1.8 倍到手;以及 Flash-Next 自带的线性注意力(GDN)——decode 对上下文长度近似 O(1),官方实测从 1K 到 262K 生成速度几乎不衰减,256k 上下文在这个引擎里近乎白送。
而它的 serve/server.py 直接讲 OpenAI 协议——这不就是 llama-swap 想看的姿势吗?
接入只隔了一层薄壳。llama-swap 条目:
1 | |
wrapper 里 exec python serve/server.py --engine strata --config strata-iq3_s-256k.json --port "${PORT}"。env PORT=${PORT} 前缀是同一招:llama-swap 不注入环境变量,但 Strata 服务恰好读 PORT 环境变量,那就文本替换 + env 前缀把它接上。
引擎本体的安装记两个点:
- Strata 没有 Linux 预编译包:在 llama-swap 同款 base 镜像的一次性容器里编译——编译环境 = 运行环境,产物放进挂载卷就原地成为 llama-swap 的 backend,不用跨环境对依赖;
- CUDA 版本错位:引擎按 CUDA 13 编译,llama-swap 镜像里只有 CUDA 12.8。解法是把 cuda13 的运行库目录前置挂载 +
LD_LIBRARY_PATH顶在前面(见上面 yaml 里的/Strata-data/cuda13/lib),不动镜像。
收益是碾压级的:同一份模型从 llama.cpp 切到 Strata,pp 从 400+ 冲到 1000-2000、tg 从 20-30 拉到 75 上下,上下文也从 128k 加到了 256k。辛苦手动调参摸出的 llama.cpp 极限,被一个引擎的整机内存设计直接碾过——这个加速效果也直接导致我把本地主模型从 Qwen3.8-27B 切到了 qwen3.8-flash-next,当前主力就是这个 256k 的 Strata 条目。
Backend 3:ComfyUI —— 把 CV 工作流伪装成一个”模型”
这条最能说明”热切框架”四个字的含金量。ComfyUI 不是 OpenAI 兼容服务:提交任务是 POST /prompt(body 是 workflow JSON),取结果要轮询 GET /history/{prompt_id}。要把它挂上总线,就得做一层定制的中转封装——这个项目叫 comfyui-llm-switcher。
思路很直白:把 ComfyUI 注册成总线上的一个”模型”,中间垫一层协议转换。客户端发的就是标准 OpenAI 请求,prompt、图片尺寸、负向提示词这类参数从请求体里解析出来,映射成 ComfyUI workflow 的节点参数——既支持直接覆盖节点 inputs,也支持 {{ width }} 这种占位符递归替换;workflow 按 POST /prompt 提交,轮询 GET /history/{prompt_id} 收结果,出图再组装成 OpenAI 响应还回去。
sequenceDiagram
participant Client as OpenAI 客户端
participant LS as llama-swap
participant Adapter as Go adapter
participant Sched as 协议转换层
participant CFY as ComfyUI
Client->>LS: POST /v1/chat/completions model=comfyui
LS->>Adapter: 拉起上游, 转发请求
Adapter->>Sched: 纯转发 chat 请求
Sched->>Sched: 协议转换 请求体解析出参数, 映射为 workflow 节点参数
Sched->>CFY: 换出 LLM, POST /prompt 提交 workflow
Sched->>CFY: 轮询 history 拿结果
Sched->>LS: 返回结果, nvitop 轮询显存, 换回 LLM
两级结构,各司其职:
- Go adapter:llama-swap 眼里它是”模型进程”,实际是个纯转发代理,收到 SIGTERM 只调一次协议转换层的
/cleanup; - 协议转换层(宿主机上的任务调度器):干脏活的地方——解析 OpenAI 请求、按任务类型选 workflow、做参数映射、把输入图片编码塞进
LoadImage节点、解析SaveImage节点的结构化返回。
真正见功力的是 GPU 仲裁:16GB 装不下 LLM + 生图模型,必须保证”LLM 换出 → 生图 → LLM 换回”这个生命周期在任何异常路径下都闭合。核心是 ensure_llm_restored 模式:
- 用 nvitop 的 Python API 轮询 GPU 显存(不是解析 nvidia-smi 文本),安全判定条件写死:ComfyUI 显存退回 1GB 以下 + 目标 LLM 显存进入绝对优势区(≥7.5GB)才算恢复成功;
- ComfyUI 没有
/unloadAPI 时直接systemctl restart comfyui(注意是 restart 不是 kill——unit 配了Restart=always,kill 反而触发自动重启把显存又吃回去); - 正常恢复失败 → 强制重启兜底(docker restart llama-swap)→ 连续 3 次失败 → 飞书 webhook 告警求救。
原则只有一条:无论中间哪一步炸了,LLM 最终必须可用——对话断不起,生图失败重跑一次就行。
这条链路打通的意义大于它的工程形态:非 OpenAI 协议的服务照样能挂进 llama-swap 总线,代价只是厚薄不同的适配层。协议这层一旦被打通,ComfyUI 背后的整个 CV 自定义节点生态——SDXL、SUPIR 超分、任意社区工作流——就成了热切框架里随取随用的一个”模型”。
实测推理数据
先说清楚定位:热切框架本身不加速,它省的是”显存-时间”——保证任何时刻显存里坐着的是当前任务最该坐的那位。单 token 速度取决于引擎和量化选得好不好。拿我机器上的真实数字说话:
| 配置 | prefill (pp) | decode (tg) | 备注 |
|---|---|---|---|
| Qwen3.8-Flash-Next GSQ-RCO-IQ3_S 128k(llama.cpp) | 400+ | 20-30 | 调参调到手软的 MoE 档 |
| Qwen3.8-27B GSQ-RCO-IQ3_S 128k(llama.cpp) | 1400 | 20-30 | 稠密 27B,原主力 |
| 同模型切到 Strata 引擎 IQ3_S 256k | 1000-2000 | 75 左右 | 整机分层内存 + MTP + 线性注意力,现主力 |
ASR 那条线单独记一笔:MTD 冷启动约 27 秒——含 vLLM 模型加载、ffmpeg 转码、转写全流程——在 ttl 120s 的换入换出节奏下完全可接受;模型热着的时候单次转写约 2 秒,语音消息的体感是流畅的。
复合调度:ttl 分层与 cpu-group
多 backend 之后,config.yaml 里真正做”分时复用”的是这两样东西:
ttl 分层——按负载形态给不同的卸载时限:
| 负载 | ttl | 理由 |
|---|---|---|
| OCR / embedding 类小模型 | 60s | 即用即走,一秒都不多占 |
| VLM 小模型、ASR (mtd) | 120s | 批处理型,一次跑完就闲 |
| 主力 LLM(llama.cpp / Strata) | 300s | 对话有连续性,换入成本高(10-30s) |
| 全局 healthCheckTimeout | 1200s | vLLM 冷启动慢,健康窗口必须给够 |
groups 物理隔离——embedding、reranker、决策模型这类高频小模型全部 CUDA_VISIBLE_DEVICES="" 纯 CPU 化,编进一个 swap: false, persistent: true 的 cpu-group。它们永远不跟 GPU 负载抢显存,也不会被热切换误伤。GPU 侧的换入换出再激烈,检索和决策链路纹丝不动。
最后把在役的 config.yaml 浓缩一份放这里(条目逐字照抄在役配置,只删掉了本文没提到的模型),当个完整形态的示例:
1 | |
一点收尾
回头看,llama-swap 官方 README 说的是 “swap models on-demand”,我实际用它做的是 swap engines:llama.cpp、Strata、vLLM 三个推理引擎加一个协议转换出来的 ComfyUI,四种 backend 打通了 GGUF、vLLM、ComfyUI 三大模型生态,共享同一个 OpenAI 兼容入口、同一套生命周期管理、同一块 16GB 显存。
这个方案的适用画像很清晰:单机、单卡、负载类型多但每种都不是 7×24、换入成本可以接受(GGUF 量化换入快,vLLM 靠 enforce-eager 压冷启动)。如果你的场景是”一张卡要养聊天、生图、语音、检索一整支杂牌军”,别给每个引擎建一套独立运维面——把它们统统变成 llama-swap 的 upstream,分时复用,一个端口走天下。
而且这张总线没有封顶的意思:TTS、视频理解、下一个还没出现的新引擎——只要能”监听指定端口”,位置早就给它留好了。接入成本总结成一句话:写一个启动脚本,加一段 yaml,完事。