12GB 小模型路由器(实战篇):12GB 上 QLoRA 与训练环境

4 阅读7分钟

上篇(引子 + 路线)12GB 显存 + INT4 4B + vLLM + 双 QLoRA = 最小模型路由器.

摘要:在 12GB 级显卡上把 QLoRA / SFT 跑通的真实顺序:先对齐 LoRA 能做什么数据怎么构,再 Miniconda + PyTorch cu128 躲 5070 代际坑,依赖快照留档,训练前自检算清显存预算,最后记 SFT 脚本里几步最容易 OOM务必先 dry run(极少 step)再拉长训,渣显存直接满配开跑,常见是第一步就炸。INT4 量化、vllm serve、双 LoRA 在线切换放在下一篇,命令级步骤见文末工作流链接,本篇不堆长代码。


玩 LoRA 之前的大前提

动笔装环境、上训练之前,先把预期对齐,后面少骂街。

LoRA 能做什么、别指望它「涨智商」

主观体感,吹牛不负责版:LoRA/QLoRA 更像在给模型换一副习惯——格式、语气、领域偏好、门禁决策边界——不是把 4B 偷偷升级成 14B 的脑子。指望靠几十条样本把「推理深度」拉满,通常会失望。

我这边用下来更明显的收益之一是:在既定套路里回复更干脆、时延体感更好(和部署形态、是否 merge、是否走 vLLM 也有关);但内容质量、事实性、共情深度仍然大头靠 prompt、靠 RAG 把材料喂对。心理学知识库那条线:检索与拼装上下文SFT 是两条腿,别只练一条。

后面若玩得动,蒸馏(大→小、或强 teacher→当前底座)也在愿望单里——纯属路线图口嗨,真开坑了再单独写。

SFT 数据怎么构(清洗细节不赘述)

数据清洗(去重、编码、超长、脏字段)这里不展开。构样本时我坚持几条(和你手里那份 dry_run 训练脚本同一套思路即可,不必绑死文件名):

  • Prompt 结构:尽量直接复用线上真实在用的问法/模板(训练、上线同一套),避免「练的时候一种话、发布又换一种」,否则 LoRA 学的是对不齐的分布。
  • 结构化 JSON 回复:标签是固定 schema 时,监督信号要完整——不仅要有字段,还要有可学习的「标准答案」(assistant 端给出确定 JSON),别留半句让模型猜。
  • 非固定答案的长文本:没有唯一标答时,我会让样本至少包含期望的关键词、禁区或结构约束(例如必须点到某几个信息点),让梯度有个锚;纯开放式「随便写得好」很难稳定收敛,评测也容易变玄学。

Dry run 习惯(和渣显存强相关):正式拉满 max_steps 之前,一定要先跑极小 step 的 dry run(例如十几步),确认加载、构图、第一步 backward 都过,再开长训。跳过 dry_run、直接上全长,在 12GB 上非常容易第一步 OOM或跑到一半才爆,排错还更痛苦。


Day 1:Miniconda、rag-ft,以及 5070 的第一个真坑

动手顺序上我没再跟 micromamba / pyenv 死磕,直接 Miniconda:装环境、换机器、复现命令都省心得多。

训练侧我固定进一个环境:

conda activate rag-ft

创建时大致是(可按你当时实际改版本号):

conda create -n rag-ft python=3.10 -y
conda activate rag-ft

后面 PyTorch 别抄旧帖里的 cu118 / cu121 就完事——我这边 RTX 5070(Blackwell,sm_120 第一次踩的坑就是:轮子带的 CUDA 太老,和显卡架构不对付,导入或简单 cuda 矩阵乘会直接报不兼容。相当于 day1 存下来的第一个坑点:不是 Ubuntu 坏了,是 PyTorch wheel 与 GPU 代际没对齐

稳妥路线是跟着当前 PyTorch 官网 Linux + Pip 选项走 CUDA 12.8 索引cu128),例如:

conda activate rag-ft
pip uninstall -y torch torchvision torchaudio
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128

若稳定版仍别扭,再考虑 nightly + cu128(50 系有时只能靠更新的 nightly,这点也写进笔记里备查)。

装完 PyTorch 后,务必接着做下一节 「Day 2:训练前自检」 里的同一组命令,确认矩阵乘能跑通再上训练。


依赖快照:pip freeze 与核对说明

在已 conda activate rag-ft 的终端里,先看版本汇总:

echo "=== conda ===" && conda --version && conda info --envs | sed -n '1,20p'
echo "=== python / pip ===" && python --version && pip --version
echo "=== torch 系 ===" && pip show torch torchvision torchaudio | grep -E "^(Name|Version)"
echo "=== torch CUDA 运行时信息 ===" && python -c "import torch; print('torch', torch.__version__); print('cuda_available', torch.cuda.is_available()); print('torch.version.cuda', torch.version.cuda); print('device', torch.cuda.get_device_name(0) if torch.cuda.is_available() else None)"
echo "=== 驱动与 GPU ===" && nvidia-smi --query-gpu=name,driver_version,memory.total --format=csv,noheader && nvidia-smi | sed -n '1,12p'

需要全量依赖快照时:pip freeze > requirements-rag-ft.txt(公开前可脱敏路径)。

核对说明:下文是 rag-ft 训练向环境pip freeze 全文快照,与 PyTorch cu128 一致;transformers==5.5.0 较新,以本机已能跑通训练为准,他人复现若遇 API 变动可锁版本或按报错微调。vLLM 推理常见做法是另建虚拟环境(避免与训练栈抢轮子),见下一篇及 doc/作品集/工作流/vLLM本地启动与验收.md

accelerate==1.13.0
aiohappyeyeballs==2.6.1
aiohttp==3.13.5
aiosignal==1.4.0
annotated-doc==0.0.4
anyio==4.13.0
async-timeout==5.0.1
attrs==26.1.0
bitsandbytes==0.49.2
certifi==2026.2.25
charset-normalizer==3.4.7
click==8.3.2
cuda-bindings==12.9.4
cuda-pathfinder==1.2.2
cuda-toolkit==12.8.1
datasets==4.8.4
dill==0.4.1
exceptiongroup==1.3.1
filelock==3.25.2
frozenlist==1.8.0
fsspec==2026.2.0
h11==0.16.0
hf-xet==1.4.3
hf_transfer==0.1.9
httpcore==1.0.9
httpx==0.28.1
huggingface_hub==1.9.2
idna==3.11
Jinja2==3.1.6
markdown-it-py==4.0.0
MarkupSafe==3.0.3
mdurl==0.1.2
mpmath==1.3.0
multidict==6.7.1
multiprocess==0.70.19
networkx==3.4.2
numpy==2.2.6
nvidia-cublas-cu12==12.8.4.1
nvidia-cuda-cupti-cu12==12.8.90
nvidia-cuda-nvrtc-cu12==12.8.93
nvidia-cuda-runtime-cu12==12.8.90
nvidia-cudnn-cu12==9.19.0.56
nvidia-cufft-cu12==11.3.3.83
nvidia-cufile-cu12==1.13.1.3
nvidia-curand-cu12==10.3.9.90
nvidia-cusolver-cu12==11.7.3.90
nvidia-cusparse-cu12==12.5.8.93
nvidia-cusparselt-cu12==0.7.1
nvidia-nccl-cu12==2.28.9
nvidia-nvjitlink-cu12==12.8.93
nvidia-nvshmem-cu12==3.4.5
nvidia-nvtx-cu12==12.8.90
packaging==26.0
pandas==2.3.3
peft==0.18.1
pillow==12.1.1
propcache==0.4.1
psutil==7.2.2
pyarrow==23.0.1
Pygments==2.20.0
python-dateutil==2.9.0.post0
pytz==2026.1.post1
PyYAML==6.0.3
regex==2026.4.4
requests==2.33.1
rich==14.3.3
safetensors==0.7.0
sentencepiece==0.2.1
shellingham==1.5.4
six==1.17.0
sympy==1.14.0
tokenizers==0.22.2
torch==2.11.0+cu128
torchaudio==2.11.0+cu128
torchvision==0.26.0+cu128
tqdm==4.67.3
transformers==5.5.0
triton==3.6.0
trl==1.0.0
typer==0.24.1
typing_extensions==4.15.0
tzdata==2026.1
urllib3==2.6.3
xxhash==3.6.0
yarl==1.23.0

Day 2:跑训练前的自检 + 12GB 显存「预算」

上线先 conda activate rag-ft,再跑下面这组——不是仪式感,是血泪

Ubuntu 会自己更新内核这种事,谁懂:早上依赖装得好好的,晚上一跑全报错;查了半天发现是驱动/内核模块挂了,痛彻心扉。所以现在养成习惯:只要隔了系统更新或重启,先过一遍 GPU 自检再训

nvidia-smi
python -c "import torch; print(torch.__version__, torch.cuda.is_available(), torch.version.cuda)"
python -c "import torch; print(torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'no cuda')"
python -c "import torch; x=torch.randn(2048,2048,device='cuda'); y=x@x; print('ok', y.shape)"

nvidia-smi 看一眼的不只是「有没有卡」:注意 已占用显存——桌面合成、浏览器、上次没关的进程、甚至后台挂着的别的实验,都会吃掉一块。12GB 不是 12GB 全给你训练用;自检完心里要有个数:剩余预算 = 总显存 − 别人占的 − 你要留的安全余量(本机还要开桌面调东西的话,多留一截)。


训练脚本:哪些地方会「超出预算」?(SFT / dry_run 视角)

就算脚本按 ≤12GB 写了 auto-vram-safe、QLoRA、checkpointing,OOM 仍可能出现在不同步骤——不是「跑起来就安全了」,而是每一步都在花显存。建议:多打中间过程,至少在这些槛前后 print 一下,或临时加 torch.cuda.memory_allocated() / max_memory_allocated()(训完 reset_peak_memory_stats),方便对照 nvidia-smi

时间顺序记「容易超支」的点(和你那份 dry_run 训练脚本里的逻辑一致即可):

  1. AutoModelForCausalLM.from_pretrained
    加载权重 + device_map="auto" 布局 是一大口;若 QLoRA 没真正启用(走了 fallback 全精度),这里或下一步往往直接炸。

  2. Dataset.map(..., format_text) + apply_chat_template
    批量构图、截断到 max_seq_len序列长、batch map 默认行为 可能带来短时峰值(数据脏/极长样本时要警惕)。

  3. SFTTrainer 构造完成 → trainer.train() 第一步
    前向 + 反向 + 优化器状态(8bit 分页 Adam 仍占预算);gradient_accumulation 会改变「有效 batch」体感,eval 步也会再啃一口。

  4. 全程隐含项
    max_seq_lenper_device_train_batch_sizegrad_accum、是否 bf16、是否 4bit + double quant桌面/浏览器 是否同台抢显存——任一变动都可能把「刚好能跑」变成 OOM。

实操习惯:脚本已经按 12GB 拧过参数了,也别省掉过程输出——例如:清洗后样本数是否 [memory] QLoRA 4-bit enabled是否触发 [auto-vram-safe]from_pretrained 前后各打一行、map打一行、train 开跑前再打一行。回头翻日志,能分清是加载炸、构图炸还是 step 炸