Fine-tuning — Módulo 10: mergear, cuantizar y servir el modelo
Décimo módulo del Curso de Fine-tuning: del adapter entrenado al modelo que atiende tráfico. merge_and_unload de PEFT y la trampa de mergear sobre 4-bit, exportación a GGUF y AWQ/GPTQ, y la decisión central entre servir multi-LoRA en caliente con vLLM o servir el modelo mergeado, con VRAM, latencia y costo comparados.
☰ Índice del contenido
- La tesis del módulo
- Qué es exactamente mergear
- La trampa: mergear sobre un modelo cuantizado
- Exportar a otros formatos
- GGUF para llama.cpp y Ollama
- AWQ y GPTQ para vLLM
- La decisión central: multi-LoRA o mergeado
- La comparación con números
- El presupuesto de VRAM de servir
- Errores comunes
- La frase para llevarse
- Qué viene
- Material relacionado
En el módulo 9 terminaste con dos adapters: uno del SFT y uno del DPO. Juntos pesan quizás 400 MB y contienen todo el trabajo del curso. También son, en este preciso momento, completamente inútiles para cualquier persona que no seas tú con tu notebook de entrenamiento abierto. Un adapter no es un modelo: es un parche que necesita saber sobre qué modelo base se aplica, con qué versión de PEFT se cargó y qué precisión esperaba encontrar. Este módulo cubre el trecho entre ese archivo y un endpoint que responde a tráfico real, y la decisión de arquitectura que va a definir tu factura de inferencia durante el resto de la vida del proyecto.
La tesis del módulo
Mergear o no mergear no es un detalle de despliegue: es la decisión que fija tu costo de inferencia. Un modelo mergeado por variante es más rápido y ocupa una GPU por variante; un modelo base con adapters intercambiables en caliente sirve veinte variantes desde la misma VRAM y paga un poco de latencia por eso. Elegir mal es la diferencia entre una GPU y veinte.
Qué es exactamente mergear
Recordemos la aritmética de LoRA del módulo 5, porque de ahí sale todo lo demás. LoRA no modifica los pesos W del modelo: aprende dos matrices chicas A y B y, en cada paso hacia adelante, calcula
INFERENCIA CON ADAPTER (dos caminos)
x ──► ┌──────────────┐ ──┐
│ W (base) │ │
└──────────────┘ │ ┌───────┐
├──► │ + │ ──► salida
x ──► ┌──────────────┐ │ └───────┘
│ B · A · s │ ──┘
└──────────────┘
(s = alpha / r)
INFERENCIA MERGEADA (un camino)
x ──► ┌────────────────────┐ ──► salida
│ W' = W + B·A·s │
└────────────────────┘
Mergear es hacer esa suma una sola vez, de forma permanente: calcular W' = W + B·A·(alpha/r) para cada capa adaptada y guardar W' como los pesos nuevos del modelo. El resultado es un modelo normal y corriente, del mismo tamaño y forma que el base, que ya no sabe ni le importa que alguna vez existió un adapter.
Lo que ganas: cero overhead en inferencia. El camino paralelo desaparece, no hay dos multiplicaciones ni una suma extra por capa, y cualquier runtime que sepa cargar el modelo base sabe cargar el tuyo. Lo que pierdes: la modularidad. Ya no puedes intercambiar el adapter, y cada variante que entrenes es un modelo completo de 15 GB en disco en vez de un archivo de 200 MB.
"""Mergear un adapter LoRA sobre el modelo base.
Regla dura del módulo: se carga el base en fp16/bf16, NO en 4 bits.
El motivo, en la sección siguiente.
"""
import torch
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
BASE = "Qwen/Qwen2.5-7B-Instruct"
ADAPTER = "./adapters/dpo-v1"
SALIDA = "./modelos/qwen-soporte-v1"
# 1) El base en la precisión ALTA. Aunque hayas entrenado con QLoRA
# sobre 4 bits, el merge se hace acá, en 16 bits.
base = AutoModelForCausalLM.from_pretrained(
BASE,
dtype=torch.bfloat16,
device_map="cpu", # la CPU tiene más RAM que tu GPU; el merge
# es aritmética, no necesita acelerador
)
# 2) Aplicar el adapter encima.
modelo = PeftModel.from_pretrained(base, ADAPTER) # hereda el bf16 del base
# 3) Sumar y descartar la estructura PEFT. Devuelve un modelo
# transformers normal, sin rastro del adapter.
modelo = modelo.merge_and_unload()
# 4) Guardar. safe_serialization=True escribe safetensors, que es lo
# que esperan vLLM, TGI y el resto del ecosistema.
modelo.save_pretrained(SALIDA, safe_serialization=True,
max_shard_size="4GB")
# 5) El tokenizer VIAJA CON EL MODELO. Si lo olvidas, el modelo carga
# y responde basura porque el runtime toma otro tokenizer.
tokenizer = AutoTokenizer.from_pretrained(BASE)
tokenizer.save_pretrained(SALIDA)
print(f"Modelo mergeado en {SALIDA}")
El paso 5 no es decorativo. Si durante el SFT agregaste tokens especiales al tokenizer —marcadores de rol, tokens de herramienta, un pad_token nuevo— y guardas el modelo sin el tokenizer, el runtime va a cargar el tokenizer del base y los ids no van a calzar. El síntoma es un modelo que responde texto sintácticamente correcto y semánticamente delirante, y es un bug caro de encontrar porque nada falla con excepción.
Igual de importante: si agregaste tokens, hiciste model.resize_token_embeddings(...) durante el entrenamiento y el modelo mergeado tiene una matriz de embeddings más grande que el base. Ese cambio viaja en los pesos, pero solo tiene sentido con el tokenizer correcto al lado.
La trampa: mergear sobre un modelo cuantizado
Esta es la parte del módulo que arruina despliegues, y merece su propia sección.
Si entrenaste con QLoRA, el modelo base vivió durante todo el entrenamiento en 4 bits. La tentación es obvia: ya lo tengo cargado en 4 bits, le aplico el adapter y mergeo ahí mismo. Y PEFT te deja hacerlo, con distintos grados de resistencia según la versión y el backend, lo que empeora el problema: no falla, degrada.
Por qué degrada. Mergear es sumar. Los pesos del base están cuantizados a una grilla gruesa de 4 bits; el B·A·s del adapter son valores continuos en 16 bits, y en general pequeños comparados con los pesos base. Al sumar y volver a representar el resultado en la grilla de 4 bits, buena parte del delta cae dentro del paso de cuantización y simplemente desaparece por redondeo. La actualización que costó tres horas de GPU se pierde en el error de redondeo del formato.
MERGE SOBRE 4-BIT (mal)
W en 4 bits ┃──┃──┃──┃──┃ grilla gruesa
delta LoRA · pequeño y continuo
W + delta ┃──┃──┃──┃──┃ cae en el MISMO escalón
→ el delta se pierde
MERGE SOBRE FP16 (bien)
W en fp16 ┃┃┃┃┃┃┃┃┃┃┃┃ grilla fina
delta LoRA ·
W + delta ┃┃┃┃┃┃┃┃┃┃┃┃ el delta se conserva
│
▼
cuantizar DESPUÉS
De ahí sale la regla, y es corta: entrena en 4 bits si quieres, pero mergea en 16 bits y cuantiza después. El orden correcto de operaciones es siempre el mismo.
START ──► ┌──────────────┐
│ QLoRA: │
│ base 4-bit │ entrenamiento (módulo 6)
│ + adapter │
└──────┬───────┘
│ guarda SOLO el adapter
▼
┌──────────────┐
│ recargar │
│ base fp16 │ ← mismo id de HF, otra precisión
└──────┬───────┘
│
▼
┌──────────────┐
│ merge en │
│ fp16/bf16 │
└──────┬───────┘
│
▼
┌──────────────┐
│ cuantizar │ AWQ / GPTQ / GGUF
│ el mergeado │
└──────┬───────┘
│
▼
┌──────────────┐
│ servir │ ──► END
└──────────────┘
Hay un detalle práctico que este flujo expone: el merge necesita RAM suficiente para el modelo completo en 16 bits. Un 7B en bf16 son ~15 GB, un 13B ~26 GB, un 70B ~140 GB. Por eso el código de arriba usa device_map="cpu": la RAM del sistema es más barata y más abundante que la VRAM, y el merge es aritmética que no gana casi nada con acelerador. Para modelos grandes, low_cpu_mem_usage=True y guardar por shards evita el pico de memoria.
Cuidado: hay una asimetría numérica que conviene tener presente. Aunque mergees correctamente en fp16, el modelo resultante no es bit a bit idéntico al comportamiento del adapter aplicado en caliente: hay diferencias de redondeo en cada capa. Son pequeñas, pero si tu evaluación del módulo 8 te da un número apenas distinto después de mergear, no estás loco. La regla operativa es volver a correr la evaluación sobre el artefacto que vas a servir, no sobre el que entrenaste.
Exportar a otros formatos
Con el modelo mergeado en safetensors ya puedes servirlo. Pero según dónde vaya a correr, conviene otro formato.
GGUF para llama.cpp y Ollama
GGUF es el formato del ecosistema llama.cpp, explicado en GPU — Módulo 4: el modelo completo ya cuantizado, en un solo archivo, pensado para correr en CPU o en GPUs modestas. Es la ruta si tu modelo va a un notebook, a un servidor sin GPU decente o a Ollama.
La conversión son dos pasos: primero a GGUF sin cuantizar, después al nivel que quieras.
# El conversor vive en el repo de llama.cpp.
git clone https://github.com/ggml-org/llama.cpp
cd llama.cpp
pip install -r requirements.txt
# Paso 1: safetensors -> GGUF en 16 bits (sin cuantizar todavía).
python convert_hf_to_gguf.py ../modelos/qwen-soporte-v1 \
--outfile qwen-soporte-v1-f16.gguf \
--outtype f16
# Paso 2: cuantizar. Q4_K_M es el punto dulce del módulo 4 de GPU.
cmake -B build && cmake --build build --config Release
./build/bin/llama-quantize \
qwen-soporte-v1-f16.gguf \
qwen-soporte-v1-Q4_K_M.gguf \
Q4_K_M
# Probarlo antes de distribuirlo. Desde fines de 2025 `llama-cli` es la
# interfaz interactiva sobre el servidor; el binario de un solo disparo
# con -p/-n se llama `llama-completion` (mismos flags).
./build/bin/llama-completion -m qwen-soporte-v1-Q4_K_M.gguf \
-p "¿Puedo devolver un producto abierto?" -n 128
Para meterlo a Ollama hace falta un Modelfile con el template de chat correcto:
FROM ./qwen-soporte-v1-Q4_K_M.gguf
# El template DEBE coincidir con el que usaste al entrenar
# (módulo 3). Si entrenaste con ChatML y sirves con otro, el
# modelo responde peor sin que nada falle visiblemente.
TEMPLATE """{{ if .System }}<|im_start|>system
{{ .System }}<|im_end|>
{{ end }}<|im_start|>user
{{ .Prompt }}<|im_end|>
<|im_start|>assistant
"""
PARAMETER stop "<|im_end|>"
PARAMETER temperature 0.3
Ese comentario del template es la fuente de errores más común al exportar un modelo afinado. El formato con el que entrenaste, que definiste en el módulo 3, tiene que ser exactamente el que usa el runtime al servir. Un espacio de más, un token de rol distinto o un system prompt envuelto de otra forma degradan el modelo de forma silenciosa.
AWQ y GPTQ para vLLM
Si el destino es una GPU sirviendo tráfico, GGUF no es el formato. vLLM trabaja con safetensors y su propia familia de cuantizaciones: AWQ, GPTQ y FP8, todas explicadas en GPU — Módulo 7.
La diferencia con GGUF no es solo el archivo. AWQ y GPTQ son cuantizaciones calibradas: necesitan un conjunto de textos de muestra para decidir qué pesos son sensibles y merecen más precisión. Ese detalle tiene una consecuencia directa para ti.
"""Cuantizar el modelo mergeado a AWQ para vLLM con llm-compressor.
AutoAWQ, la librería clásica para esto, fue archivada en mayo de 2025;
su algoritmo vive ahora en llm-compressor (proyecto vLLM), y los
modelos que produce se sirven en vLLM sin pasos extra.
La calibración es el paso que importa: el conjunto de textos que le
pasas define qué activaciones considera importantes el algoritmo.
"""
from datasets import Dataset
from llmcompressor import oneshot
from llmcompressor.modifiers.quantization import QuantizationModifier
from llmcompressor.modifiers.transform.awq import AWQModifier
from transformers import AutoModelForCausalLM, AutoTokenizer
RUTA = "./modelos/qwen-soporte-v1"
modelo = AutoModelForCausalLM.from_pretrained(RUTA)
tokenizer = AutoTokenizer.from_pretrained(RUTA)
# CALIBRACIÓN CON TUS DATOS, no con un corpus genérico.
# El modelo lo afinaste para tu dominio; cuantizarlo calibrando sobre
# texto genérico le devuelve error justo en la jerga que entrenaste.
# 128-256 muestras de tu propio dataset bastan.
calibracion = Dataset.from_list([
{"text": ejemplo["texto"]}
for ejemplo in cargar_jsonl("dataset_sft.jsonl")[:256]
])
# W4A16_ASYM: pesos en 4 bits asimétricos, activaciones en 16 bits — el
# esquema AWQ clásico. lm_head se deja sin cuantizar.
receta = [
AWQModifier(),
QuantizationModifier(ignore=["lm_head"], scheme="W4A16_ASYM",
targets=["Linear"]),
]
oneshot(
model=modelo,
dataset=calibracion,
recipe=receta,
max_seq_length=1024,
num_calibration_samples=256,
)
modelo.save_pretrained("./modelos/qwen-soporte-v1-awq", save_compressed=True)
tokenizer.save_pretrained("./modelos/qwen-soporte-v1-awq")
Ese comentario sobre la calibración es la única idea original de esta sección: calibra con datos de tu dominio. Es gratis, son diez líneas, y es la diferencia entre una cuantización que preserva lo que entrenaste y una que introduce error justo donde tu modelo era especial.
| Formato | Runtime destino | Bits típicos | Necesita calibración | Cuándo |
|---|---|---|---|---|
| safetensors fp16/bf16 | vLLM, TGI, transformers | 16 | No | Máxima calidad, VRAM disponible |
| AWQ (vía llm-compressor) | vLLM, TGI | 4 | Sí | Serving en GPU, buen balance |
| GPTQ | vLLM, TGI | 4 / 8 | Sí | Alternativa a AWQ, más antiguo |
| FP8 | vLLM (GPU Hopper+) | 8 | No (o mínima) | H100/H200, casi sin pérdida |
| GGUF | llama.cpp, Ollama | 2–8 | No | CPU, notebook, GPU modesta |
La decisión central: multi-LoRA o mergeado
Acá está el módulo. Todo lo anterior fue preparación para esta elección, y no tiene una respuesta universal: tiene dos respuestas correctas según cuántas variantes vayas a servir.
Servir el modelo mergeado es lo intuitivo: tomas el safetensors del merge, lo levantas en vLLM y listo. Cada variante afinada es un proceso vLLM con su propia copia del modelo en VRAM.
Servir con multi-LoRA es lo que casi nadie sabe que existe y cambia la economía por completo: vLLM carga una copia del modelo base en VRAM y mantiene los adapters aparte, aplicando el que corresponda por request. El cliente indica en el campo model cuál adapter quiere, y vLLM lo enruta.
OPCIÓN A: MODELO MERGEADO (una GPU por variante)
┌──────────────────┐ ┌──────────────────┐
│ GPU 1 │ │ GPU 2 │
│ ┌────────────┐ │ │ ┌────────────┐ │
│ │ base+sopor.│ │ │ │ base+ventas│ │
│ │ 15 GB │ │ │ │ 15 GB │ │
│ └────────────┘ │ │ └────────────┘ │
└──────────────────┘ └──────────────────┘
cliente soporte cliente ventas
OPCIÓN B: MULTI-LoRA (una GPU, N variantes)
┌────────────────────────────────────────┐
│ GPU 1 │
│ ┌──────────────┐ ┌───────────────┐ │
│ │ base fp16 │ │ adapters │ │
│ │ 15 GB │ │ soporte 0,2GB │ │
│ │ (compartido)│ │ ventas 0,2GB │ │
│ │ │ │ legal 0,2GB │ │
│ └──────────────┘ └───────────────┘ │
└────────────────────────────────────────┘
request dice cuál adapter aplicar
Levantar multi-LoRA en vLLM son dos flags:
# --enable-lora activa el mecanismo.
# --lora-modules registra los adapters con el nombre que usará el cliente.
# --max-loras es cuántos adapters pueden estar ACTIVOS a la vez en un
# batch; el resto se intercambia desde CPU cuando hacen falta.
# --max-lora-rank debe ser >= al r más alto de tus adapters (módulo 5).
vllm serve Qwen/Qwen2.5-7B-Instruct \
--enable-lora \
--lora-modules soporte=/adapters/soporte-v3 \
ventas=/adapters/ventas-v2 \
legal=/adapters/legal-v1 \
--max-loras 4 \
--max-lora-rank 32 \
--max-model-len 8192
Y del lado del cliente, el adapter se pide como si fuera un modelo distinto:
"""Un solo endpoint, tres modelos lógicos, una sola copia en VRAM."""
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="vacio")
# El campo `model` NO es el modelo base: es el nombre que registraste
# en --lora-modules. vLLM aplica ese adapter solo a este request.
r = client.chat.completions.create(
model="soporte",
messages=[{"role": "user",
"content": "¿Puedo devolver un producto abierto?"}],
temperature=0.3,
)
print(r.choices[0].message.content)
# El request siguiente puede pedir otro adapter sin recargar nada.
r2 = client.chat.completions.create(
model="legal",
messages=[{"role": "user",
"content": "Redacta la cláusula de garantía legal."}],
)
También se pueden cargar y descargar adapters en caliente sin reiniciar el servidor, levantando vLLM con la variable VLLM_ALLOW_RUNTIME_LORA_UPDATING=True y llamando a los endpoints /v1/load_lora_adapter y /v1/unload_lora_adapter. Eso convierte el despliegue de una versión nueva de tu modelo en una llamada HTTP en vez de un rolling restart.
La comparación con números
Órdenes de magnitud para un modelo 7B en bf16 sobre una GPU de 80 GB, con adapters de r=16. Los números exactos dependen de tu hardware y tu tráfico; lo que importa es la forma de la relación.
| Eje | Modelo mergeado | Multi-LoRA en vLLM |
|---|---|---|
| VRAM, 1 variante | ~15 GB | ~15,2 GB |
| VRAM, 5 variantes | ~75 GB (5 copias) | ~16 GB (base + 5 adapters) |
| VRAM, 20 variantes | ~300 GB → 4 GPUs | ~19 GB, una GPU |
| Latencia por token | Referencia | +5 a +15% aprox |
| Throughput | Máximo | Algo menor, batches mixtos |
| Desplegar versión nueva | Subir 15 GB, reiniciar | Subir 200 MB, hot-swap |
| Almacenamiento por variante | ~15 GB | ~200 MB |
| Cuantizable a 4 bits | Sí, sin problema | Con restricciones según backend |
| Complejidad operativa | Baja | Media |
Y la regla de decisión, que es más simple que la tabla:
¿cuántas variantes vas a servir?
│
1 ──────────┴────────── 2 o más
│ │
▼ ▼
┌───────────┐ ¿cambian seguido o son
│ MERGEAR │ de clientes distintos?
│ y servir │ │
└───────────┘ no ─────┴───── sí
│ │
▼ ▼
┌───────────┐ ┌────────────┐
│ mergear │ │ MULTI-LoRA │
│ ambos │ │ en vLLM │
└───────────┘ └────────────┘
Una sola variante: mergea. El overhead de multi-LoRA no te compra nada y el modelo mergeado es más rápido, más simple de operar y cuantizable sin restricciones.
Varias variantes, sobre todo por cliente o por dominio: multi-LoRA. Es donde la arquitectura brilla y donde ahorra un orden de magnitud en GPU. El caso canónico es el SaaS que afina un adapter por cliente: veinte clientes son veinte archivos de 200 MB sobre un solo modelo base, no veinte GPUs.
Iteración rápida: multi-LoRA aunque sea una sola variante. Si estás entrenando versiones nuevas cada semana, poder hacer hot-swap de un archivo de 200 MB en vez de subir 15 GB y reiniciar el servidor cambia el ritmo de trabajo. Muchos equipos usan multi-LoRA en desarrollo y mergean para producción cuando la variante se estabiliza.
Ojo: la combinación de multi-LoRA con el modelo base cuantizado a 4 bits tiene soporte parcial y variable según la versión de vLLM y el backend. Si tu plan es servir adapters en caliente sobre un base AWQ para ahorrar VRAM, verifícalo con tu versión exacta antes de diseñar la arquitectura alrededor de eso. La ruta sin sorpresas es base en fp16/bf16 con multi-LoRA, o base cuantizado con el adapter mergeado.
El presupuesto de VRAM de servir
Cerramos con la cuenta que hay que saber hacer, que es la misma de GPU — Módulo 2 con una línea extra:
pesos del modelo (7B en bf16) ~15,0 GB
adapters LoRA (r=16, 5 variantes) ~1,0 GB
KV cache (lo que sobra, PagedAttn) ~50,0 GB
buffers de activación y overhead ~2,0 GB
───────────────────────────────────────────
GPU de 80 GB cabe con margen ✓
La línea del KV cache es la que decide cuántos usuarios concurrentes atiendes, no la de los pesos. Y ahí está el argumento económico definitivo de multi-LoRA: cada modelo mergeado extra le roba ~15 GB al KV cache, o sea a la concurrencia. Servir cinco modelos mergeados en una GPU de 80 GB no es solo apretado: es apretado y te deja atendiendo una fracción de los usuarios, porque el espacio para conversaciones se lo comieron cuatro copias redundantes de los mismos pesos.
Errores comunes
- Mergear sobre el modelo cargado en 4 bits. No lanza error y degrada el resultado, porque el delta del adapter se pierde en el redondeo de la grilla de cuantización. Recarga el base en fp16/bf16 para mergear, y cuantiza después.
- Guardar el modelo sin el tokenizer. Si agregaste tokens especiales durante el entrenamiento, el runtime cargará el tokenizer del base, los ids no calzarán y el modelo responderá texto coherente y sin sentido. Guarda siempre
save_pretraineddel tokenizer junto al modelo. - Servir con un chat template distinto del de entrenamiento. El formato que definiste en el módulo 3 tiene que ser byte a byte el del serving. Un token de rol distinto o un espacio de más degradan el modelo sin errores visibles.
- Calibrar AWQ/GPTQ con texto genérico. Si afinaste para un dominio y calibras la cuantización sobre wikitext, introduces error justo en la jerga que entrenaste. Usa 128–256 muestras de tu propio dataset.
- No reevaluar después de mergear y cuantizar. El artefacto que sirves no es el que entrenaste: pasó por un merge y una cuantización, ambos con pérdida numérica. Corre la evaluación del módulo 8 sobre el archivo final, no sobre el checkpoint.
- Desplegar N modelos mergeados sin saber que existe multi-LoRA. Es el error más caro del módulo, y se mide en GPUs. Cinco variantes son cinco copias de 15 GB o una copia más cinco archivos de 200 MB.
- Mergear el adapter del SFT y el del DPO por separado y sumarlos. No es aditivo: el DPO se entrenó encima del SFT. El orden correcto es mergear el SFT al base, y sobre ese modelo mergear el adapter del DPO.
La frase para llevarse
“El adapter es lo que entrenaste; el modelo servido es lo que tus usuarios reciben. Entre los dos hay un merge, una cuantización y un chat template, y cada uno de los tres puede botar en silencio todo el trabajo anterior.”
Qué viene
Módulo 11: destilación, continued pretraining y otras técnicas. Con el modelo entrenado, evaluado y servido, se abre el catálogo de lo que se puede hacer más allá del SFT y el DPO. Vamos a ver la destilación de un modelo grande a uno chico —que suele ser la técnica más rentable de todas y tiene una letra chica legal que hay que leer—, el continued pretraining para meterle un dominio o un idioma nuevo al modelo sin que olvide lo que sabía, RAFT para que aprenda a usar el contexto recuperado e ignorar distractores, el model merging con SLERP y TIES, y cómo entrenar tool-calling y salida estructurada.
Material relacionado
Preguntas sobre este curso
Regístrate gratis para hacer preguntas y guardar tu progreso.
✉ Entérate de lo nuevo
Recibe un aviso cuando publique nuevos cursos, workshops o investigaciones. Sin spam.