Runbook QLoRA: del dataset al adapter servido en multi-LoRA (procedimiento operativo)
Este es el compañero operativo de QLoRA y multi-LoRA al límite en modelos pequeños. Aquel post desmonta el porqué —NF4, doble cuantización, paged optimizers, la matemática del adapter—; este es el cómo, con comandos que se copian y pegan. Si no has leído el de fundamentos, léelo antes: aquí damos por sabido qué es un adapter, por qué el base vive en 4-bit y por qué el gradiente solo toca el adapter.
TL;DR
Un procedimiento reproducible en cinco fases: (1) fijar el entorno con versiones pineadas; (2) preparar el dataset en formato chat; (3) entrenar el adapter QLoRA con TRL + PEFT en una RTX 4090 (24 GB, Ada Lovelace) usando gradient checkpointing, gradient accumulation y paged_adamw_8bit; (4) validar y versionar el adapter como artefacto de megabytes; (5) servirlo en vLLM con --enable-lora, cargándolo en caliente sin reiniciar el servidor y resolviéndolo desde almacenamiento de objetos. Todo on-premise, en hardware de consumo, sin sacar un dato del perímetro. Lo que sigue son los comandos exactos y el presupuesto de memoria que separa “cabe” de “OOM”.
El flujo de extremo a extremo
Fase 0 — Entorno y versiones
QLoRA es sensible a las versiones de bitsandbytes, transformers, peft y trl: combinaciones desalineadas dan errores de dequant o adapters que no cargan en vLLM. Fija el entorno y no lo toques a mitad de campaña. Versiones de referencia a junio de 2026 (verifica las concretas de tu índice; el pin exacto importa menos que la coherencia entre ellas):
python -m venv .venv && source .venv/bin/activate
pip install --upgrade pip
# Entrenamiento (productor)
pip install "torch>=2.4" \
"transformers>=4.50" \
"peft>=0.14" \
"trl>=0.15" \
"bitsandbytes>=0.45" \
"accelerate>=1.2" \
"datasets>=3.2"
# Serving (consumidor) — en su propio entorno/imagen
pip install "vllm>=0.8"
Qué hace cada pieza y por qué está pineada:
| Paquete | Rol en el flujo | Por qué la versión importa |
|---|---|---|
torch | runtime de tensores y kernels CUDA | el ABI de CUDA tiene que casar con el driver y con bitsandbytes; un salto mayor rompe los kernels 4-bit. |
transformers | carga el base, el tokenizer y el chat_template | tiene que conocer la arquitectura del SLM que uses; un modelo nuevo necesita una versión que lo soporte. |
peft | implementa LoRA/QLoRA: inyecta las matrices A,B y escribe el adapter_config.json | ese adapter_config.json es el que vLLM lee al servir; versiones viejas escriben campos que el serving no entiende. |
trl | el SFTTrainer: el bucle de entrenamiento supervisado | integra peft de forma nativa; su API (SFTConfig) cambia entre versiones, de ahí el pin. |
bitsandbytes | la cuantización NF4 y el paged_adamw_8bit | la pieza más sensible: un binario mal compilado da dequant corrupto o cuelga al primer paso. |
accelerate | orquesta dispositivo, precisión mixta y device_map | backend silencioso de casi todo; desalinearlo con transformers da errores crípticos. |
datasets | carga el JSONL (y permite streaming si el corpus es grande) | poco sensible; cualquier 3.x reciente sirve. |
vllm | el serving multi-LoRA | entorno o imagen aparte: no mezcles su stack con el bitsandbytes de entrenamiento. |
La regla de oro: coherencia entre los cuatro de arriba (transformers, peft, trl, bitsandbytes) pesa más que el número exacto de cada uno. Fíjalos al empezar una campaña y no los muevas hasta cerrarla.
Comprueba que la GPU y CUDA están sanos antes de empezar; un bitsandbytes mal compilado se manifiesta tarde:
python -c "import torch, bitsandbytes; print(torch.cuda.get_device_name(0), torch.cuda.is_available())"
nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv
Para 100 % soberanía: descarga el base una vez desde tu mirror interno de Hugging Face (o un MinIO con los pesos) y exporta HF_HOME a un volumen local. Nada de este flujo necesita salir del perímetro.
Fase 1 — Preparar el dataset
El formato canónico para una tarea conversacional es JSONL, una conversación por línea, con la plantilla de chat del modelo. No inventes un formato propio: usa el chat_template del tokenizer del base, porque cualquier desajuste entre cómo entrenas y cómo sirves degrada la calidad de forma silenciosa.
{"messages":[{"role":"system","content":"Eres un asistente de soporte de redes."},{"role":"user","content":"El AP del ala norte no levanta tras el corte."},{"role":"assistant","content":"Confirma primero el PoE del puerto..."}]}
{"messages":[{"role":"user","content":"Genera el cambio de VLAN para el cliente 42."},{"role":"assistant","content":"interface GigabitEthernet0/3\n switchport access vlan 42..."}]}
Qué es cada campo y por qué:
| Campo | Qué es | Nota operativa |
|---|---|---|
messages | la conversación completa, lista de turnos | una conversación por línea JSONL; es lo que apply_chat_template convierte en tokens. |
role | quién habla: system, user, assistant | el adapter aprende a producir los turnos assistant; los user/system son contexto, no objetivo. |
content | el texto del turno | el system fija la persona/tarea; mantenlo idéntico al que usarás en producción o el adapter se desalinea. |
Reglas operativas que ahorran disgustos: cuida la proporción de ejemplos (un dataset de tarea estrecha bien curado de 2.000–20.000 ejemplos rinde más que 200.000 ruidosos), deduplica, y reserva un 5–10 % como split de validación que NO entra en el entrenamiento. La construcción del corpus a partir de señal de producción la cubre Retrain: cerrar el bucle.
Fase 2 — El script de entrenamiento
Script mínimo y completo con TRL + PEFT. Entrena un adapter r=8 sobre un SLM de 8B cuantizado a NF4. Cada bloque tiene su porqué comentado.
# train_qlora.py
import torch
from datasets import load_dataset
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig
from trl import SFTConfig, SFTTrainer
BASE = "Qwen/Qwen3-8B" # o el SLM que sirvas; usa SIEMPRE el mismo en train y serve
OUT = "adapters/soporte-redes-v1"
# 1) Base congelado y cuantizado a 4-bit NF4 con doble cuantización
bnb = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # NormalFloat, cuantil-óptimo para pesos gaussianos
bnb_4bit_use_double_quant=True, # cuantiza las constantes de escala -> ~0.37 bits/param menos
bnb_4bit_compute_dtype=torch.bfloat16 # los matmuls se hacen en BF16 tras dequant al vuelo
)
tok = AutoTokenizer.from_pretrained(BASE)
model = AutoModelForCausalLM.from_pretrained(
BASE, quantization_config=bnb, torch_dtype=torch.bfloat16, device_map={"": 0}
)
# 2) El adapter: rank bajo, solo proyecciones de atención (agresivo). Sube target_modules si el eval lo pide.
peft_cfg = LoraConfig(
r=8, lora_alpha=16, lora_dropout=0.05, bias="none", task_type="CAUSAL_LM",
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
)
ds = load_dataset("json", data_files={"train": "data/train.jsonl",
"eval": "data/eval.jsonl"})
# 3) Config de entrenamiento pensada para caber en 24 GB
cfg = SFTConfig(
output_dir=OUT,
per_device_train_batch_size=1, # batch real pequeño
gradient_accumulation_steps=16, # batch EFECTIVO = 1*16 = 16, sin pagar su VRAM de golpe
gradient_checkpointing=True, # recomputa activaciones en backward: cambia compute por memoria
optim="paged_adamw_8bit", # paged optimizer: el airbag contra los picos de VRAM
learning_rate=2e-4, lr_scheduler_type="cosine", warmup_ratio=0.03,
num_train_epochs=3, bf16=True,
max_length=2048, # acota la secuencia: las activaciones escalan con ella
logging_steps=10, eval_strategy="steps", eval_steps=100, save_steps=200,
report_to="none",
)
trainer = SFTTrainer(model=model, args=cfg, peft_config=peft_cfg,
train_dataset=ds["train"], eval_dataset=ds["eval"],
processing_class=tok)
trainer.train()
trainer.save_model(OUT) # guarda SOLO el adapter (MB), no el base
BitsAndBytesConfig — cómo se cuantiza el base
| Opción | Qué hace | Por qué este valor / cuándo cambiarlo |
|---|---|---|
load_in_4bit=True | carga los pesos del base en 4-bit | es la base de QLoRA: sin esto el 8B no cabe ni para entrenar. |
bnb_4bit_quant_type="nf4" | usa el formato NF4 (cuantil-óptimo para pesos gaussianos) | existe "fp4", pero NF4 rinde mejor en pesos de transformer; deja NF4. |
bnb_4bit_use_double_quant=True | cuantiza las propias constantes de escala | ahorra ~0.37 bits/param (cientos de MB en un 8B); el margen que separa “cabe” de “OOM”. Déjalo en True. |
bnb_4bit_compute_dtype=torch.bfloat16 | precisión del matmul tras deshacer la cuantización al vuelo | BF16 en Ada/Hopper (4090, H100); usa float16 solo en GPUs sin BF16. |
LoraConfig — la forma del adapter
| Opción | Qué hace | Por qué este valor / cuándo cambiarlo |
|---|---|---|
r=8 | rank del adapter: su capacidad de corrección | 4-8 para tarea estrecha (agresivo); súbelo a 16-64 solo si el eval muestra underfitting. |
lora_alpha=16 | factor de escala del delta (efectivo α/r) | convención común α=2r; modula cuánto “pesa” el adapter sobre el base. |
lora_dropout=0.05 | regularización sobre el adapter | 0.05-0.1 con datasets pequeños (evita overfit); 0 si el corpus es grande. |
bias="none" | no entrena los términos de bias | "none" es el estándar; "all"/"lora_only" rara vez aportan y cuestan params. |
task_type="CAUSAL_LM" | tipo de objetivo/cabeza | fijo para un LLM generativo. |
target_modules=[q,k,v,o] | qué matrices reciben adapter | solo atención = barato y agresivo; añade gate_proj/up_proj/down_proj (MLP) si la tarea exige reescribir más comportamiento y el eval lo pide. |
SFTConfig — el presupuesto de memoria y el bucle
| Opción | Qué hace | Por qué este valor / cuándo cambiarlo |
|---|---|---|
per_device_train_batch_size=1 | microbatch por GPU | 1 en 24 GB; el batch real lo construye gradient_accumulation_steps. |
gradient_accumulation_steps=16 | acumula 16 microbatches antes de actualizar | batch efectivo = 1×16 = 16 sin pagar su VRAM de golpe; súbelo si bajas la secuencia y quieres más batch efectivo. |
gradient_checkpointing=True | recomputa activaciones en el backward en vez de guardarlas | imprescindible en 4090: ~20-30 % más lento a cambio de mucha menos VRAM. |
optim="paged_adamw_8bit" | optimizer Adam en 8-bit + estados paginables a RAM | menos VRAM de estados y el airbag que evita el OOM en los picos. |
learning_rate=2e-4 | tasa de aprendizaje del adapter | 1e-4–3e-4 es el rango típico de QLoRA; los adapters toleran LR más alto que un full fine-tune. |
lr_scheduler_type="cosine" | curva de decaimiento del LR | cosine o linear; cosine suele dar una bajada suave al final. |
warmup_ratio=0.03 | calienta el LR el primer 3 % de pasos | evita la inestabilidad de los primeros steps. |
num_train_epochs=3 | pasadas completas al dataset | 1-3; vigila la eval loss para no sobreajustar. |
bf16=True | precisión de cómputo y del adapter | BF16 en Ada/Hopper; fp16=True si tu GPU no tiene BF16. |
max_length=2048 | longitud máxima de secuencia | la palanca #1 de VRAM de activaciones: acórtala lo primero si hay OOM. |
eval_strategy/eval_steps/save_steps | cadencia de validación y checkpoint | ajústalas al tamaño del dataset; evaluar a menudo cuesta tiempo. |
Las cuatro piezas que hacen que quepa en una 4090 son: per_device_train_batch_size=1 + gradient_accumulation_steps (batch efectivo grande sin su coste de memoria de golpe), gradient_checkpointing=True (recomputar activaciones en lugar de guardarlas) y optim="paged_adamw_8bit" (paginar estados a RAM en los picos). Quita cualquiera de las tres con secuencias largas y verás el OOM.
Alternativa declarativa con Axolotl si prefieres YAML sobre Python (mismo resultado):
base_model: Qwen/Qwen3-8B
load_in_4bit: true
adapter: qlora
lora_r: 8
lora_alpha: 16
lora_target_modules: [q_proj, k_proj, v_proj, o_proj]
sequence_len: 2048
micro_batch_size: 1
gradient_accumulation_steps: 16
gradient_checkpointing: true
optimizer: paged_adamw_8bit
learning_rate: 0.0002
num_epochs: 3
bf16: true
datasets:
- path: data/train.jsonl
type: chat_template
Fase 3 — Lanzar y monitorizar
# Lanzamiento simple en una GPU
python train_qlora.py
# En otra terminal: vigila la VRAM. Si se acerca al techo, baja max_length o sube grad accumulation.
watch -n 2 nvidia-smi --query-gpu=memory.used,memory.total,utilization.gpu --format=csv
Presupuesto aproximado de VRAM al entrenar el 8B en la 4090, y qué tocar cuando aprieta:
| Componente | VRAM aprox. | Palanca si hay OOM |
|---|---|---|
| Base 8B NF4 (congelado) | ~4.0 GB | — (fijo) |
| Adapter + grad + estados Adam | ~0.3–0.7 GB | bajar r |
| Activaciones (batch × secuencia) | ~6–14 GB | bajar max_length, batch_size; subir grad_accum |
| Buffers dequant / workspace | ~1–2 GB | — |
Tabla de remedios rápidos de OOM, en orden de coste: (1) baja max_length; (2) confirma gradient_checkpointing=True; (3) sube gradient_accumulation_steps y baja per_device_train_batch_size a 1; (4) usa paged_adamw_8bit (ya en el script); (5) como último recurso baja r. Si tras todo eso no cabe, la secuencia o el modelo son demasiado grandes para 24 GB: o acotas, o subes de hardware.
Fase 4 — Validar el adapter
Nunca promociones un adapter por la training loss. Mide contra el split de validación reservado y contra un puñado de prompts reales.
# quick_eval.py
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
from peft import PeftModel
bnb = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True, bnb_4bit_compute_dtype=torch.bfloat16)
tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-8B")
base = AutoModelForCausalLM.from_pretrained("Qwen/Qwen3-8B", quantization_config=bnb, device_map={"": 0})
model = PeftModel.from_pretrained(base, "adapters/soporte-redes-v1") # base + adapter
msgs = [{"role": "user", "content": "El AP del ala norte no levanta tras el corte."}]
ids = tok.apply_chat_template(msgs, add_generation_prompt=True, return_tensors="pt").to(0)
print(tok.decode(model.generate(ids, max_new_tokens=256)[0], skip_special_tokens=True))
Para un veredicto serio, pasa el adapter por tu suite de evals (la capa que describe Evals LLM) y compara contra el base sin adapter y contra la versión anterior del adapter. Promociona solo si gana en la métrica de la tarea sin regresar en seguridad/formato.
Fase 5 — Versionar el adapter como artefacto
El adapter es un par de ficheros de MB (adapter_model.safetensors + adapter_config.json). Trátalo como un artefacto versionado, firmado y trazable, no como un fichero suelto.
# Checksum reproducible + subida a almacenamiento de objetos interno (MinIO/S3)
sha256sum adapters/soporte-redes-v1/adapter_model.safetensors > adapters/soporte-redes-v1/SHA256
aws --endpoint-url https://minio.interno s3 cp \
adapters/soporte-redes-v1/ s3://adapters/soporte-redes/v1/ --recursive
Convención que funciona: s3://adapters/<tarea-o-cliente>/<version>/. Inmutable por versión, con su SHA256. Borrar un cliente es borrar un prefijo de MB, no reentrenar nada. Versionar 500 adapters cuesta lo que cuesta versionar 500 ficheros de configuración pesados.
Fase 6 — Servir en multi-LoRA con vLLM
El consumidor carga un base compartido y aplica el delta del adapter por request. Arranque con adapters estáticos declarados:
VLLM_ALLOW_RUNTIME_LORA_UPDATING=True \
vllm serve Qwen/Qwen3-8B \
--enable-lora \
--max-loras 8 \ # nº máx de adapters DISTINTOS por batch (no el total cargable)
--max-lora-rank 8 \ # = al rank máximo de tus adapters; no lo infles (gasta memoria)
--max-cpu-loras 64 \ # adapters cacheados en RAM para swap rápido a VRAM
--lora-modules soporte-redes=/srv/adapters/soporte-redes/v1
Cada flag, qué controla y cómo dimensionarlo:
| Flag / variable | Qué controla | Cómo dimensionarlo |
|---|---|---|
--enable-lora | activa el soporte de adapters | obligatorio; sin él, vLLM ignora cualquier model que sea un adapter. |
--max-loras 8 | nº de adapters distintos en un mismo batch | más adapters por batch encarece los kernels SGMV; 8-32 es razonable. No es el total cargable. |
--max-lora-rank 8 | rank máximo que el servidor reserva | ponlo igual al rank real de tus adapters (8 aquí); inflarlo desperdicia VRAM y rendimiento. |
--max-cpu-loras 64 | adapters cacheados en RAM listos para paginar a VRAM | ≥ nº de adapters activos; es el “banquillo” desde el que se hace swap rápido. |
--lora-modules name=path | declara adapters estáticos al arrancar | útil para los fijos; omítelo si todo va por carga dinámica/Resolver. |
VLLM_ALLOW_RUNTIME_LORA_UPDATING=True | habilita los endpoints de carga/descarga en caliente | imprescindible para /v1/load_lora_adapter; sin él, el servidor es estático. |
--max-loras limita los adapters distintos por batch, no cuántos puedes tener cargados; el grueso vive en CPU (--max-cpu-loras) y se pagina a VRAM bajo demanda. Pon --max-lora-rank al rank real (8 aquí): inflarlo desperdicia memoria y rendimiento. Las peticiones eligen adapter por el campo model:
curl http://localhost:8000/v1/chat/completions -H "Content-Type: application/json" -d '{
"model": "soporte-redes",
"messages": [{"role":"user","content":"El AP del ala norte no levanta tras el corte."}]
}'
# model:"Qwen/Qwen3-8B" (sin adapter) usa el base pelado en el mismo servidor
Carga en caliente de un adapter nuevo sin reiniciar (gracias a VLLM_ALLOW_RUNTIME_LORA_UPDATING=True):
curl -X POST http://localhost:8000/v1/load_lora_adapter -H "Content-Type: application/json" -d '{
"lora_name": "cliente-42",
"lora_path": "/srv/adapters/cliente-42/v3"
}'
# y para liberar VRAM/CPU cuando un cliente queda inactivo:
curl -X POST http://localhost:8000/v1/unload_lora_adapter -H "Content-Type: application/json" -d '{"lora_name":"cliente-42"}'
Para multi-tenant a escala, evita declarar cientos de adapters a mano: el LoRAResolver resuelve y carga el adapter desde almacenamiento local o S3 la primera vez que llega un model desconocido, así el servidor se mantiene fino y los adapters se traen perezosamente desde tu MinIO. Los internals de cómo se batchean miles de adapters concurrentes (kernels SGMV, unified paging, el gather/scatter heterogéneo) están en Multi-LoRA serving; este runbook solo los enciende. Para exprimir el throughput de decode del base en una 4090, combina esto con lo de Optimizando el decode en vLLM.
Servir multi-adapter vs fusionar por tarea
Dos arquitecturas de despliegue, y el procedimiento cambia:
Servir multi-LoRA (lo de arriba). Un base compartido + N adapters en caliente. Es el patrón soberano por defecto: footprint mínimo, aislamiento por cliente, hot-swap. Usa QLoRA estándar y no fusiones nada.
Fusionar por tarea. Si quieres un único artefacto cuantizado-y-adaptado por tarea (sin adapter en runtime), no fusiones un adapter QLoRA estándar en el base 4-bit: la fusión reintroduce precisión que NF4 no representa y al recuantizar pierdes parte de lo aprendido. Para ese caso entrena con QA-LoRA (quantization-aware), que fusiona limpio sobre un base cuantizado. Es una decisión de arquitectura, no de calidad; el detalle conceptual está en el post de fundamentos.
Checklist de gotchas operativos
- Plantilla de chat coherente entre entrenamiento y serving. El desajuste más común y más silencioso: entrenas con un
chat_templatey sirves con otro. Usa el del base en ambos lados. - Mismo base exacto (revisión incluida) en
trainyserve. Un adapter entrenado sobreQwen3-8Bno es válido sobre otra revisión del modelo. --max-lora-rank≥ rank de TODOS los adapters servidos juntos, pero no más: inflarlo gasta VRAM.- Presupuesto KV vs
--max-loras. El cuello en serving no son los adapters (MB), es el KV cache y la concurrencia; mira Roofline invertido para el régimen del SLM. rdemasiado bajo = underfitting si la tarea exige reescribir mucho comportamiento. Subersolo si el eval lo pide.- No promociones por training loss. Valida contra split reservado + prompts reales + regresión de seguridad.
- Versiona e inmutabiliza cada adapter con su
SHA256; nunca sobrescribas una versión servida.
Aplicado a la infraestructura on-premise
En una RTX 4090 (24 GB) el mismo equipo es productor y consumidor: entrenas el adapter de un cliente en horas y lo sirves en el mismo servidor sobre el base compartido. Es el caso canónico para demos multi-tenant y prototipos de plataforma.
En un cluster genérico 4×H100 SXM (320 GB, NVLink, FP8 nativo) QLoRA deja de ser necesario para caber, pero sirve para paralelizar la producción (varios jobs de adapter a la vez) y para mantener el formato cuantizado consistente entre entrenamiento y un serving serio de cientos de adapters concurrentes. El base puede ir en FP8 nativo; la mecánica del runbook no cambia, solo la escala.
Ver también
- QLoRA y multi-LoRA al límite en modelos pequeños — el post de fundamentos: el porqué de NF4, doble cuantización, paged optimizers y la matemática del adapter. Este runbook es su cara ejecutable.
- Multi-LoRA serving — los internals del consumidor que aquí solo encendemos: SGMV, unified paging, batching heterogéneo de miles de adapters.
- Optimizando el decode en vLLM — cómo exprimir el throughput de decode del base sobre el que sirves los adapters en una 4090.
- Retrain: cerrar el bucle feedback → dataset → adapter — de dónde sale el dataset de la Fase 1.
- Evals LLM: la capa después del tracing — cómo validar el adapter de la Fase 4 con criterio, no con la training loss.
- Roofline invertido en modelos pequeños — el régimen de rendimiento que explica por qué el cuello del serving es el KV cache, no los adapters.
- Cuantización agresiva: del 4-bit al ternario — qué pasa con el base cuantizado por debajo de NF4 bajo el adapter.
Referencias
- Dettmers, T., Pagnoni, A., Holtzman, A., Zettlemoyer, L. QLoRA: Efficient Finetuning of Quantized LLMs. NeurIPS 2023. https://arxiv.org/abs/2305.14314
- Hugging Face TRL — PEFT integration (SFTTrainer + QLoRA): https://huggingface.co/docs/trl/peft_integration
- Hugging Face PEFT: https://github.com/huggingface/peft
- bitsandbytes: https://github.com/bitsandbytes-foundation/bitsandbytes
- vLLM — LoRA Adapters (serving, carga dinámica, LoRAResolver): https://docs.vllm.ai/en/stable/features/lora/
- Axolotl: https://github.com/axolotl-ai-cloud/axolotl
- Xu, Y. et al. QA-LoRA: Quantization-Aware Low-Rank Adaptation. ICLR 2024. https://arxiv.org/abs/2309.14717