12GB 小模型路由器(推理篇):INT4、vLLM 与双 QLoRA 切换

0 阅读9分钟

系列位置:与同专栏「引子篇」「12GB 上 QLoRA / 训练环境篇」衔接;未读前文也可单篇跟做,训练侧需已有一份全精度底座与(可选)两份 LoRA adapter。

摘要HF 拉全精度底座 → INT4(W4A16)量化 → vllm serve → 双 QLoRA 按请求 model 切换。含校准集下载卡住时的处理、--max-model-len 必写避开 vllm._C 的工作目录、adapter 与整模区别、curl / 鉴权 / OpenAI 客户端示例与合并踩坑表。


为什么推理要和训练「分家」;vLLM 和 Ollama 不是一条协议

训练侧若用 conda(例如名为 rag-ft 的环境)已跑通 QLoRA,推理仍建议单独建 venv(例如 python -m venv .venv),避免和 transformers / trl / bitsandbytes 抢版本。

安装(只当用户、不开发 vLLM 源码时最常见):

source /path/to/vllm-venv/bin/activate
pip install vllm

若要从 vLLM 源码树可编辑安装,按官方仓库的 installation 文档走(pip install -e . 等),此处不展开。

装完确认:

vllm --help
vllm serve --help

和 Ollama:vLLM 提供的是 /v1/chat/completions 这类 OpenAI 兼容 HTTP;Ollama 是另一套 API。业务里并行实验时各接各的,不必把 vLLM 硬塞进 Ollama 适配层。

起服前照旧 nvidia-smi,对空闲显存有数。


量化之前:用 Hugging Face 把全精度底座拉到本地

INT4 脚本、from_pretrained、vLLM 第一条参数,都要 标准 HF 模型目录。Hub 上的 Instruct / Thinking 等不同后缀仓库,下载时把模型 id 换成你实际使用的那一个即可。

工具

pip install -U "huggingface_hub[cli]" hf_transfer

先登录(Thinking / 门控仓库强烈建议)

CLI 拉大仓库时,先登录能明显减少中途断流、权限被拒huggingface-cli login 或新版 hf auth login;也可设环境变量 HF_TOKEN。私有/门控模型必须 token。

下载

export HF_HUB_ENABLE_HF_TRANSFER=1
mkdir -p /path/to/models

经典 / 新版 CLI 二选一(以本机 hf --help 为准):

huggingface-cli download Qwen/Qwen3-4B-Thinking-2507 \
  --local-dir /path/to/models/base/Qwen3-4B-Thinking-2507 \
  --local-dir-use-symlinks False

# 或:hf download Qwen/Qwen3-4B-Thinking-2507 \
#   --local-dir /path/to/models/base/Qwen3-4B-Thinking-2507

国内可配 HF_ENDPOINT 或镜像(如 https://hf-mirror.com,以你网络为准)。

可选:Transformers 能否加载

python - <<'PY'
from transformers import AutoTokenizer, AutoModelForCausalLM
model_dir = "/path/to/models/base/Qwen3-4B-Thinking-2507"
tok = AutoTokenizer.from_pretrained(model_dir, use_fast=True)
_ = AutoModelForCausalLM.from_pretrained(model_dir, device_map="auto", torch_dtype="auto")
print("HF model load ok")
PY

显存紧时只测 tokenizer 或 device_map="cpu"


建议的目录约定(底座 / 量化产物 / LoRA / merge)

方便和后文命令对齐,可按需建:

mkdir -p /path/to/models/base /path/to/models/int4 \
  /path/to/models/lora /path/to/models/merged /path/to/data/qlora
  • 全精度底座:例如 .../base/Qwen3-4B-Thinking-2507
  • INT4 输出:例如 .../int4/Qwen3-4B-Thinking-2507-W4A16-G128
  • 两份 adapter:例如 .../lora/adapter-a.../lora/adapter-b(各含 adapter_config.json、权重)
  • merge 产物(可选):.../merged/...

INT4(W4A16)量化:依赖、脚本与校准集下载

硬件

vLLM 侧 INT4 W4A16 通常要求 NVIDIA Ampere 及以上(compute capability > 8.0);新卡对照官方量化文档。

依赖

已激活的 vLLM/量化用 venv里:

pip install llmcompressor datasets

最小量化脚本

将下面保存为例如 scripts/quantize_int4.py,把 MODEL_ID / SAVE_DIR 改成你的路径(MODEL_ID 指上节拉下来的 全精度 HF 目录):

from pathlib import Path

from datasets import load_dataset
from llmcompressor import oneshot
from llmcompressor.modifiers.quantization import GPTQModifier
from transformers import AutoModelForCausalLM, AutoTokenizer

MODEL_ID = "/path/to/models/base/Qwen3-4B-Thinking-2507"
SAVE_DIR = "/path/to/models/int4/Qwen3-4B-Thinking-2507-W4A16-G128"
NUM_CALIBRATION_SAMPLES = 128
MAX_SEQUENCE_LENGTH = 2048

tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)
model = AutoModelForCausalLM.from_pretrained(
    MODEL_ID,
    device_map="auto",
    dtype="auto",
    trust_remote_code=True,
)

ds = load_dataset("HuggingFaceH4/ultrachat_200k", split="train_sft")
ds = ds.shuffle(seed=42).select(range(NUM_CALIBRATION_SAMPLES))

def preprocess(example):
    text = tokenizer.apply_chat_template(example["messages"], tokenize=False)
    return {"text": text}

def tokenize(sample):
    return tokenizer(
        sample["text"],
        padding=False,
        max_length=MAX_SEQUENCE_LENGTH,
        truncation=True,
        add_special_tokens=False,
    )

ds = ds.map(preprocess)
ds = ds.map(tokenize, remove_columns=ds.column_names)

recipe = GPTQModifier(targets="Linear", scheme="W4A16", ignore=["lm_head"])

Path(SAVE_DIR).mkdir(parents=True, exist_ok=True)

oneshot(
    model=model,
    dataset=ds,
    recipe=recipe,
    max_seq_length=MAX_SEQUENCE_LENGTH,
    num_calibration_samples=NUM_CALIBRATION_SAMPLES,
)

model.save_pretrained(SAVE_DIR, save_compressed=True)
tokenizer.save_pretrained(SAVE_DIR)
print(f"INT4 model saved to: {SAVE_DIR}")

执行:

python scripts/quantize_int4.py

卡在 HuggingFaceH4/ultrachat_200k 时的三条路(很重要)

量化脚本默认要从 Hub 拉 ultrachat_200k,网络不稳时会一直卡。可以:

  1. 最快先试:若脚本或环境支持 离线 fallback(或你已缓存过数据),可强制离线试跑:
    HF_HUB_OFFLINE=1 python scripts/quantize_int4.py
    (若脚本报缺数据,再用 2/3。)

  2. 镜像
    export HF_ENDPOINT=https://hf-mirror.com
    python scripts/quantize_int4.py
    (镜像域名以你环境为准。)

  3. 预下载数据集到本地,再改脚本里 load_dataset 指向本地目录(最稳、最利于复现):

huggingface-cli download HuggingFaceH4/ultrachat_200k \
  --repo-type dataset \
  --local-dir /path/to/models/datasets/ultrachat_200k

下载完成后在脚本中改为从该路径加载(具体 API 以 datasets 文档为准,例如 load_dataset("path/to/dir", ...))。

量化阶段 CUDA OOM:减少 NUM_CALIBRATION_SAMPLES、缩短 MAX_SEQUENCE_LENGTH、或换更小底座试跑。

成功后应得到完整 INT4 目录,例如:.../int4/Qwen3-4B-Thinking-2507-W4A16-G128


vllm serve:单底座验收(INT4 已就绪)

工作目录(防 vllm._C

不要在 vLLM 源码仓库根目录里直接起服务,容易 import 到未编译的本地包。习惯:cd /tmp(或任意干净目录),再用 venv 里的 vllm 可执行文件绝对路径

12GB 必写 --max-model-len

有的模型 config.json 里上下文极大(例如量级 262144)。若不显式压 --max-model-len,vLLM 会按超大上下文去预留 KV,直接报 需要几十 GiB KV cache启动失败。务必写成业务可承受的值(如 2048 / 4096),再按显存微调。

起服示例(两档,按 OOM 情况试)

较宽松(示例):

cd /tmp
/path/to/vllm-venv/bin/vllm serve /path/to/models/int4/Qwen3-4B-Thinking-2507-W4A16-G128 \
  --served-model-name qwen3-4b-int4 \
  --host 127.0.0.1 \
  --port 8000 \
  --dtype auto \
  --max-model-len 2048 \
  --gpu-memory-utilization 0.75

仍紧张时再压上下文 + 降 utilization + enforce-eager(依官方说明与实测):

cd /tmp
/path/to/vllm-venv/bin/vllm serve /path/to/models/int4/Qwen3-4B-Thinking-2507-W4A16-G128 \
  --served-model-name qwen3-4b-int4 \
  --host 127.0.0.1 \
  --port 8000 \
  --dtype auto \
  --max-model-len 1024 \
  --gpu-memory-utilization 0.65 \
  --enforce-eager

说明:

  • --served-model-name:客户端 JSON 里的 model 字段写这个短名最省事。
  • 鉴权:需要时加 --api-key my-secret,请求头带 Authorization: Bearer my-secret
  • 端口占用:改 --port 8001 等。

进程默认占前台;长期跑可用 tmux / systemd / nohup 自行包一层。


验收:OpenAI 兼容 curl / Python

curl(无鉴权)

curl -sS http://127.0.0.1:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "qwen3-4b-int4",
    "messages": [{"role": "user", "content": "用一句话介绍你自己。"}],
    "max_tokens": 64
  }'

返回 JSON 含 choices 即通过。model 须与 --served-model-name(或未改时的默认名)一致。

curl(启用了 --api-key

curl -sS http://127.0.0.1:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer my-secret" \
  -d '{
    "model": "qwen3-4b-int4",
    "messages": [{"role": "user", "content": "用一句话介绍你自己。"}],
    "max_tokens": 64
  }'

OpenAI Python 客户端(可选)

pip install openai
from openai import OpenAI

client = OpenAI(
    base_url="http://127.0.0.1:8000/v1",
    api_key="not-needed",  # 未设 --api-key 时可占位
)
r = client.chat.completions.create(
    model="qwen3-4b-int4",
    messages=[{"role": "user", "content": "你好,简单回一句。"}],
    max_tokens=64,
)
print(r.choices[0].message.content)

若启用了 --api-key,把 api_key= 换成真实 token。

提醒:请求里的 max_tokens 不能超过服务端的有效上下文预算;输入长度 + max_tokens 须在 max-model-len 之内,否则易报错或截断异常。


双 QLoRA:结论、起服与请求

先把概念说清楚(避免和「整模」打架)

  • 可行一个 INT4(或 BF16)底座 + 两份 QLoRA adapter,按请求切换。
  • vLLM 原生--enable-lora + --lora-modules,不必先 merge。
  • 「adapter 能用」:指 serve 时 base 路径 + 运行时挂上 LoRA
  • 「adapter 不能当整模」:指 不能把 adapter 目录单独当成 vllm serve 的第一个 model 路径(那只是一包增量权重)。
  • merge 时机:要交付单一整模目录、或要简化线上 LoRA 管理、或发版冻结时再做;日常迭代优先不 merge

训练侧产出自检(两份 adapter 已训练完成时)

双 LoRA 上线前,建议确认:

  • 两个目录各有 adapter_model.safetensors(或等价)adapter_config.json
  • 有训练日志;base_model、数据版本、超参、A/B 任务边界有记录
  • 底座与 adapter 兼容性:在 BF16 全量底座上训的 adapter,挂到 INT4 整模上有时要对齐实测;更稳是 量化底座与训练底座同源、版本锁定

起服:同一进程挂两份 LoRA

cd /tmp
/path/to/vllm-venv/bin/vllm serve /path/to/models/int4/Qwen3-4B-Thinking-2507-W4A16-G128 \
  --host 127.0.0.1 \
  --port 18080 \
  --dtype auto \
  --max-model-len 2048 \
  --gpu-memory-utilization 0.65 \
  --enable-lora \
  --lora-modules \
    domain-a=/path/to/lora/adapter-a/final \
    domain-b=/path/to/lora/adapter-b/final

注意:

  • --host 只能是 IP/主机名端口用 --port,勿写成 --host 18080
  • --lora-modulesname=path,多组空格分隔;路径里有空格时整段加引号。
  • 双 LoRA 更吃显存max-model-lengpu-memory-utilization 要一起拧;max-model-len 仍必写,避免 config 默认超大上下文直接把 KV 撑爆。

请求里用 model 切 adapter

curl -sS http://127.0.0.1:18080/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "domain-a",
    "messages": [{"role": "user", "content": "请回答 A 领域问题"}],
    "max_tokens": 128
  }'

"model": "domain-b" 即换另一份 LoRA。

何时再 merge

  • 给外部只认「一个模型目录」时
  • 要减少运行时 LoRA 管理复杂度时
  • 最终版本冻结时

评测与迭代阶段默认 不 merge


踩坑速查(合并版)

现象方向
vllm._C / import 异常离开源码根目录;用 venv 里安装的 vllm
启动报巨大 KV / GiB cache写小 --max-model-len;检查 config 默认上下文是否离谱
CUDA OOM(推理)max-model-lengpu-memory-utilization;双 LoRA 更保守;试 --enforce-eager
CUDA OOM(量化)减校准条数、缩短序列、换小底座试跑
量化卡在 ultrachatHF_HUB_OFFLINE=1HF_ENDPOINT 镜像、或 预下载数据集改本地路径(见上文 §)
量化脚本报错确认 llmcompressor;GPU 架构是否满足 INT4
Chat / 模板报错模型需 chat template;少数需 --chat-template 指向模板文件(见 vLLM OpenAI 兼容服务文档)
客户端 model 对不上model 对齐 --served-model-name--lora-modules 里的 name
端口占用--port

建议在笔记里固定记录(复现用)

内容
完整 vllm serveint4 路径、served-model-namelora-modulesmax-model-len、port、是否 enforce-eager
显存idle / 首 token / 稳态
路由规则何种请求走 domain-a / domain-b
版本vLLM、驱动、底座 commit、adapter 训练快照是否同源