把 llama-swap 玩成热切框架:16GB 显存分时复用的多backend复合方案

起因:一张 16GB 卡,排队等活的四类负载

一张 16GB 显存的卡,怎么同时养聊天、生图、语音、检索一整支杂牌军?我 PC(RTX 4070 Ti Super,16GB 显存)上此刻排队等 GPU 的,就是四类互不相让的推理负载:

  1. LLM 主力模型:聊天 / agent / coding,十几个 GGUF 档位随叫随换;
  2. CV 工作流:生图、超分、像素处理,跑在 ComfyUI 上;
  3. ASR 转写:会议录音转写 + 说话人分离,用的是 MOSS-Transcribe-Diarize;
  4. 常驻小模型: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
2
3
4
5
models:
"mtd:0.9b":
cmd: env VLLM_NO_USAGE_STATS=1 VLLM_MAX_MODEL_LEN=32768 VLLM_GPU_UTIL=0.85 VLLM_USE_FLASHINFER_SAMPLER=0 \
/ssd-models/_runtime/serve-vllm.sh /ssd-models/OpenMOSS/MOSS-Transcribe-Diarize mtd:0.9b ${PORT}
ttl: 120

注意 ${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
2
3
4
"qwen3.8-flash-next:gsq-rco-q3-256k":
cmd: env PORT=${PORT} LD_LIBRARY_PATH=/Strata-data/cuda13/lib:/usr/local/cuda/lib64 \
/Strata/run-strata-256k.sh
ttl: 300

wrapper 里 exec python serve/server.py --engine strata --config strata-iq3_s-256k.json --port "${PORT}"。env PORT=${PORT} 前缀是同一招:llama-swap 不注入环境变量,但 Strata 服务恰好读 PORT 环境变量,那就文本替换 + env 前缀把它接上。

引擎本体的安装记两个点:

  1. Strata 没有 Linux 预编译包:在 llama-swap 同款 base 镜像的一次性容器里编译——编译环境 = 运行环境,产物放进挂载卷就原地成为 llama-swap 的 backend,不用跨环境对依赖;
  2. 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 没有 /unload API 时直接 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
# 宏:公共参数只写一遍
macros:
host: "0.0.0.0"
n_gpu_layers_max: "999" # 全 GPU
n_gpu_layers_cpu: "0" # 纯 CPU
ctx_size_16k: "16384"
ctx_size_32k: "32768"
ctx_size_64k: "65536"
ctx_size_128k: "131072"

healthCheckTimeout: 1200 # vLLM 冷启动慢,健康窗口给足

models:
# ===== 主力(ttl 300s:对话有连续性)=====
"qwen3.8-flash-next:gsq-rco-q3-256k": # Strata 引擎,接线见 Backend 2
cmd: env PORT=${PORT} LD_LIBRARY_PATH=/Strata-data/cuda13/lib:/usr/local/cuda/lib64 /Strata/run-strata-256k.sh
ttl: 300

"qwen3.8:27b-gsq-rco-q3-128k": # llama.cpp 主力,原主力
cmd: |
env GGML_CUDA_DISABLE_GRAPHS=1 llama-server
--host ${host} --port ${PORT}
--model /models/ISTA-DASLab/Qwen3.8-27B-GSQ-RCO-GGUF/Qwen3.8-27B-GSQ-RCO-IQ3_S.gguf
--mmproj /models/ISTA-DASLab/Qwen3.8-27B-GSQ-RCO-GGUF/mmproj-Qwen3.8-27B-BF16.gguf --no-mmproj-offload
-c ${ctx_size_128k} -ctk q4_0 -ctv q4_0 -kvu -kvo -ctkd q4_0 -ctvd q4_0
-t 14 -tb 14
--n-gpu-layers ${n_gpu_layers_max}
--flash-attn on --load-mode none --fit off --parallel 1
--jinja --chat-template-file /ssd-models/froggeric/Qwen-Fixed-Chat-Templates/chat_template.jinja
--reasoning on --reasoning-preserve --reasoning-effort medium --reasoning-format deepseek
ttl: 300

# ===== ASR(ttl 120s:批处理型,用完让位 LLM)=====
"mtd:0.9b": # vLLM,接线见 Backend 1
cmd: env VLLM_NO_USAGE_STATS=1 VLLM_MAX_MODEL_LEN=32768 VLLM_GPU_UTIL=0.85 VLLM_USE_FLASHINFER_SAMPLER=0 /ssd-models/_runtime/serve-vllm.sh /ssd-models/OpenMOSS/MOSS-Transcribe-Diarize mtd:0.9b ${PORT}
ttl: 120

# ===== 随取随用小模型(ttl 60-120s)=====
glm-ocr:
cmd: |
llama-server
--host ${host} --port ${PORT}
--model /models/ggml-org/GLM-OCR-GGUF/GLM-OCR-Q8_0.gguf
--mmproj /models/ggml-org/GLM-OCR-GGUF/mmproj-GLM-OCR-Q8_0.gguf
--n-gpu-layers ${n_gpu_layers_max}
--flash-attn on
ttl: 60

"qwen3.5:0.8b":
cmd: |
llama-server
--port ${PORT}
--model /ssd-models/unsloth/Qwen3.5-0.8B-MTP-GGUF/Qwen3.5-0.8B-UD-Q6_K_XL.gguf
--mmproj /ssd-models/unsloth/Qwen3.5-0.8B-MTP-GGUF/mmproj-BF16.gguf
--n-gpu-layers ${n_gpu_layers_max}
--ctx-size ${ctx_size_64k}
--flash-attn on
--jinja --chat-template-file /ssd-models/froggeric/Qwen-Fixed-Chat-Templates/chat_template.jinja
--chat-template-kwargs '{"enable_thinking": false}'
--reasoning off
--threads 14
--threads-batch 8
--host ${host}
ttl: 120

# ===== 纯 CPU 常驻(物理隔离,不占显存)=====
"qwen3-embedding:0.6b-cpu":
cmd: |
env CUDA_VISIBLE_DEVICES="" llama-server
--host ${host} --port ${PORT}
--model /ssd-models/Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf
--n-gpu-layers ${n_gpu_layers_cpu}
--load-mode none
--threads 14
--pooling last
--embeddings
ttl: 60

"qwen3-reranker:0.6b-cpu":
cmd: |
env CUDA_VISIBLE_DEVICES="" llama-server
--host ${host} --port ${PORT}
--model /ssd-models/Voodisss/Qwen3-Reranker-0.6B-GGUF-llama_cpp/Qwen3-Reranker-0.6B-Q6_K.gguf
--n-gpu-layers ${n_gpu_layers_cpu}
--load-mode none
--ctx-size ${ctx_size_32k}
--threads 14
--pooling rank
--embeddings
--reranking
ttl: 60

"qwen3.5:0.8b-cpu":
cmd: |
env CUDA_VISIBLE_DEVICES="" llama-server
--host ${host} --port ${PORT}
--model /ssd-models/unsloth/Qwen3.5-0.8B-MTP-GGUF/Qwen3.5-0.8B-UD-Q6_K_XL.gguf
--mmproj /ssd-models/unsloth/Qwen3.5-0.8B-MTP-GGUF/mmproj-BF16.gguf
--n-gpu-layers ${n_gpu_layers_cpu}
--load-mode none
--ctx-size ${ctx_size_64k}
--jinja --chat-template-file /ssd-models/froggeric/Qwen-Fixed-Chat-Templates/chat_template.jinja
--chat-template-kwargs '{"enable_thinking": false}'
--reasoning off
--threads 14
--threads-batch 8
ttl: 120

"d3-mini:q4-cpu":
cmd: |
env CUDA_VISIBLE_DEVICES="" llama-server
--host ${host} --port ${PORT}
--model /ssd-models/ggml-org/vllm-d3-mini-GGUF/vllm-d3-mini-Q4_K_M.gguf
--n-gpu-layers ${n_gpu_layers_cpu}
--load-mode none
-c ${ctx_size_16k}
--threads 14
--threads-batch 14
--parallel 1 --ctx-checkpoints 0 -b 16384 -ub 16384
ttl: 300

groups:
cpu-group: # 常驻不换入换出,检索与决策链路永不误伤
swap: false
persistent: true
exclusive: false
members:
- qwen3-embedding:0.6b-cpu
- qwen3-reranker:0.6b-cpu
- qwen3.5:0.8b-cpu
- d3-mini:q4-cpu

一点收尾

回头看,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,完事。


把 llama-swap 玩成热切框架:16GB 显存分时复用的多backend复合方案
https://bipedalbit.net/2026/10/22/把llama-swap玩成热切框架-16GB显存分时复用的多backend复合方案/
作者
Bipedal Bit
发布于
2026年10月22日
许可协议