---
title: "Runbook QLoRA: del dataset al adapter servido en multi-LoRA (procedimiento operativo)"
url: https://blog.lo0.es/posts/qlora-runbook-fine-tuning-serving/
markdown: https://blog.lo0.es/posts/qlora-runbook-fine-tuning-serving/index.md
lang: es
date: 2026-06-09
lastmod: 2026-06-09
author: "José Manuel Román"
description: "Procedimiento operativo paso a paso para fine-tunear un SLM con QLoRA en una sola RTX 4090 (entorno, dataset, script TRL/PEFT, monitorización, validación, versionado del adapter) y servirlo en multi-LoRA con vLLM (flags, carga dinámica, LoRAResolver desde S3). Con comandos reales."
tags: ["qlora","lora","multi-lora","vllm","peft","trl","bitsandbytes","runbook","operativo","fine-tuning","slm","modelos-pequenos"]
translations:
  en: https://blog.lo0.es/en/posts/qlora-runbook-dataset-to-served-adapter/index.md
---

# 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](https://blog.lo0.es/posts/qlora-multi-lora-agresivo-slm/index.md). 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

> **Diagrama:** Pipeline operativo QLoRA: dataset, entrenamiento, artefacto adapter, registro, serving vLLM — [verlo en la versión HTML](https://blog.lo0.es/posts/qlora-runbook-fine-tuning-serving/)

## 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):

```bash
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:

```bash
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.

```jsonl
{"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](https://blog.lo0.es/posts/retrain-cerrar-el-bucle-feedback-dataset-adapter/index.md).

## 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.

```python
# 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):

```yaml
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

```bash
# 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.

```python
# 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](https://blog.lo0.es/posts/evals-llm-la-capa-despues-de-tracing/index.md)) 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.

```bash
# 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:

```bash
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`:

```bash
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`):

```bash
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](https://blog.lo0.es/posts/multi-lora-serving-fundamentos/index.md); 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](https://blog.lo0.es/posts/decode-optimizaciones-vllm/index.md).

## 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](https://blog.lo0.es/posts/qlora-multi-lora-agresivo-slm/index.md).

## 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](https://blog.lo0.es/posts/roofline-invertido-modelos-pequenos/index.md) 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

- [QLoRA y multi-LoRA al límite en modelos pequeños](https://blog.lo0.es/posts/qlora-multi-lora-agresivo-slm/index.md) — 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](https://blog.lo0.es/posts/multi-lora-serving-fundamentos/index.md) — los internals del consumidor que aquí solo encendemos: SGMV, unified paging, batching heterogéneo de miles de adapters.
- [Optimizando el decode en vLLM](https://blog.lo0.es/posts/decode-optimizaciones-vllm/index.md) — 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](https://blog.lo0.es/posts/retrain-cerrar-el-bucle-feedback-dataset-adapter/index.md) — de dónde sale el dataset de la Fase 1.
- [Evals LLM: la capa después del tracing](https://blog.lo0.es/posts/evals-llm-la-capa-despues-de-tracing/index.md) — cómo validar el adapter de la Fase 4 con criterio, no con la training loss.
- [Roofline invertido en modelos pequeños](https://blog.lo0.es/posts/roofline-invertido-modelos-pequenos/index.md) — 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](https://blog.lo0.es/posts/cuantizacion-agresiva-sub-4-bit-ternario/index.md) — 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>

