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

1 · DatasetJSONL chat2 · EntrenarTRL+PEFT · 4090NF4 · paged_adamw3 · Adapter~17 MBsafetensors4 · RegistroMinIO / S3versionado + sha2565 · ServirvLLM --enable-loracarga en calienteProductor (4090) ───────────────▶ Consumidor (4090 o cluster)el mismo equipo puede ser productor y consumidor

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:

PaqueteRol en el flujoPor qué la versión importa
torchruntime de tensores y kernels CUDAel ABI de CUDA tiene que casar con el driver y con bitsandbytes; un salto mayor rompe los kernels 4-bit.
transformerscarga el base, el tokenizer y el chat_templatetiene que conocer la arquitectura del SLM que uses; un modelo nuevo necesita una versión que lo soporte.
peftimplementa LoRA/QLoRA: inyecta las matrices A,B y escribe el adapter_config.jsonese adapter_config.json es el que vLLM lee al servir; versiones viejas escriben campos que el serving no entiende.
trlel SFTTrainer: el bucle de entrenamiento supervisadointegra peft de forma nativa; su API (SFTConfig) cambia entre versiones, de ahí el pin.
bitsandbytesla cuantización NF4 y el paged_adamw_8bitla pieza más sensible: un binario mal compilado da dequant corrupto o cuelga al primer paso.
accelerateorquesta dispositivo, precisión mixta y device_mapbackend silencioso de casi todo; desalinearlo con transformers da errores crípticos.
datasetscarga el JSONL (y permite streaming si el corpus es grande)poco sensible; cualquier 3.x reciente sirve.
vllmel serving multi-LoRAentorno 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é:

CampoQué esNota operativa
messagesla conversación completa, lista de turnosuna conversación por línea JSONL; es lo que apply_chat_template convierte en tokens.
rolequién habla: system, user, assistantel adapter aprende a producir los turnos assistant; los user/system son contexto, no objetivo.
contentel texto del turnoel 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ónQué hacePor qué este valor / cuándo cambiarlo
load_in_4bit=Truecarga los pesos del base en 4-bites 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=Truecuantiza las propias constantes de escalaahorra ~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.bfloat16precisión del matmul tras deshacer la cuantización al vueloBF16 en Ada/Hopper (4090, H100); usa float16 solo en GPUs sin BF16.

LoraConfig — la forma del adapter

OpciónQué hacePor qué este valor / cuándo cambiarlo
r=8rank del adapter: su capacidad de corrección4-8 para tarea estrecha (agresivo); súbelo a 16-64 solo si el eval muestra underfitting.
lora_alpha=16factor de escala del delta (efectivo α/r)convención común α=2r; modula cuánto “pesa” el adapter sobre el base.
lora_dropout=0.05regularización sobre el adapter0.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/cabezafijo para un LLM generativo.
target_modules=[q,k,v,o]qué matrices reciben adaptersolo 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ónQué hacePor qué este valor / cuándo cambiarlo
per_device_train_batch_size=1microbatch por GPU1 en 24 GB; el batch real lo construye gradient_accumulation_steps.
gradient_accumulation_steps=16acumula 16 microbatches antes de actualizarbatch efectivo = 1×16 = 16 sin pagar su VRAM de golpe; súbelo si bajas la secuencia y quieres más batch efectivo.
gradient_checkpointing=Truerecomputa activaciones en el backward en vez de guardarlasimprescindible 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 RAMmenos VRAM de estados y el airbag que evita el OOM en los picos.
learning_rate=2e-4tasa de aprendizaje del adapter1e-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 LRcosine o linear; cosine suele dar una bajada suave al final.
warmup_ratio=0.03calienta el LR el primer 3 % de pasosevita la inestabilidad de los primeros steps.
num_train_epochs=3pasadas completas al dataset1-3; vigila la eval loss para no sobreajustar.
bf16=Trueprecisión de cómputo y del adapterBF16 en Ada/Hopper; fp16=True si tu GPU no tiene BF16.
max_length=2048longitud máxima de secuenciala palanca #1 de VRAM de activaciones: acórtala lo primero si hay OOM.
eval_strategy/eval_steps/save_stepscadencia de validación y checkpointajú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:

ComponenteVRAM aprox.Palanca si hay OOM
Base 8B NF4 (congelado)~4.0 GB— (fijo)
Adapter + grad + estados Adam~0.3–0.7 GBbajar r
Activaciones (batch × secuencia)~6–14 GBbajar 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 / variableQué controlaCómo dimensionarlo
--enable-loraactiva el soporte de adaptersobligatorio; sin él, vLLM ignora cualquier model que sea un adapter.
--max-loras 8nº de adapters distintos en un mismo batchmás adapters por batch encarece los kernels SGMV; 8-32 es razonable. No es el total cargable.
--max-lora-rank 8rank máximo que el servidor reservaponlo igual al rank real de tus adapters (8 aquí); inflarlo desperdicia VRAM y rendimiento.
--max-cpu-loras 64adapters 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=pathdeclara adapters estáticos al arrancarútil para los fijos; omítelo si todo va por carga dinámica/Resolver.
VLLM_ALLOW_RUNTIME_LORA_UPDATING=Truehabilita los endpoints de carga/descarga en calienteimprescindible 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_template y sirves con otro. Usa el del base en ambos lados.
  • Mismo base exacto (revisión incluida) en train y serve. Un adapter entrenado sobre Qwen3-8B no 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.
  • r demasiado bajo = underfitting si la tarea exige reescribir mucho comportamiento. Sube r solo 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

Referencias