Fine-Tuning de LLMs Open-Source con QLoRA y Unsloth: La Guía Completa 2026
Construiste un prototipo con GPT-4 o Claude. Funciona genial. Después llega la factura: $12,000 de llamadas API el mes pasado. Y crece un 40% mes a mes.
Este es el muro con el que choca todo ingeniero de IA. El abismo entre prototipo y producción donde los costos de APIs administradas se vuelven insostenibles, los requisitos de latencia se endurecen, y te das cuenta de que necesitás un modelo que realmente entienda tu dominio — no todo el conocimiento de internet.
El fine-tuning de un LLM open-source es la solución. Y gracias a QLoRA (Quantized Low-Rank Adaptation) y herramientas como Unsloth, ya no necesitás un clúster de GPUs A100 ni un doctorado en machine learning para hacerlo. Una sola GPU de consumo con 24GB de VRAM — una RTX 4090 o incluso una T4 gratuita de Google Colab — es suficiente para hacer fine-tuning de un modelo con miles de millones de parámetros activos que supere a GPT-4 en tu tarea específica.
Esta guía cubre todo desde cero hasta producción: por qué funciona el fine-tuning, cómo QLoRA lo hace viable en hardware de consumo, cómo preparar tu dataset, el código exacto de entrenamiento, estrategias de evaluación y despliegue. Todos los ejemplos de código están probados en producción.
¿Cuándo hacer Fine-Tuning en vez de Prompting?
Antes de meternos en el código, seamos precisos sobre cuándo el fine-tuning es la elección correcta. No siempre lo es.
Usá prompting / RAG cuando:
- Tu tarea es de propósito general (resúmenes, traducción, Q&A sobre documentos)
- Tus datos cambian frecuentemente (bases de conocimiento, tickets de soporte)
- Todavía estás explorando qué debería hacer el modelo
- Necesitás deployar en días, no semanas
Usá fine-tuning cuando:
- El modelo necesita aprender un estilo, formato o comportamiento específico que el prompting no puede producir de forma confiable
- Tenés una tarea bien definida con patrones de entrada/salida consistentes
- La latencia y el costo a escala importan (un modelo 7B fine-tuneado es 10-50x más barato por token que GPT-4)
- El modelo necesita entender profundamente terminología específica del dominio
- Querés reducir alucinaciones en hechos específicos del dominio
Los casos de uso más comunes de fine-tuning en producción:
| Caso de Uso | Dónde falla el Prompting | Ventaja del Fine-Tuning |
|---|---|---|
| Generación de código para APIs internas | El modelo no conoce tu SDK | Aprende tus patrones y convenciones específicos |
| Análisis de documentos médicos/legales | Los modelos genéricos son demasiado cautelosos | Salidas seguras y específicas del dominio |
| Extracción de datos estructurados | El formateo basado en prompts es frágil | Adherencia consistente al esquema |
| Matching de tono de soporte al cliente | Los system prompts derivan en conversaciones largas | Voz y personalidad incorporadas |
| Generación SQL para esquemas custom | El esquema en el contexto consume tokens | Conocimiento internalizado del esquema |
La clave está acá: el fine-tuning no le enseña al modelo conocimiento nuevo per se. Le enseña comportamientos nuevos. Un modelo fine-tuneado no memoriza tu base de datos — aprende cómo razonar sobre los patrones de tu dominio, producir outputs en tu formato específico y aplicar las convenciones de tu organización de forma consistente.
Entendiendo LoRA y QLoRA
El problema: el Fine-Tuning completo es caro
El fine-tuning completo tradicional actualiza cada parámetro del modelo. Para un modelo de 7B parámetros, eso significa:
- Memoria: ~28GB solo para los pesos del modelo en FP32, más ~28GB para estados del optimizador, más ~28GB para gradientes. Total: ~84GB de VRAM mínimo.
- Hardware: Múltiples GPUs A100 de 80GB.
- Costo: $10-50/hora en instancias GPU en la nube, corridas de entrenamiento de horas a días.
- Riesgo: Olvido catastrófico — el modelo pierde sus capacidades generales mientras aprende tu tarea específica.
LoRA: la revolución
LoRA (Low-Rank Adaptation) tuvo una insight clave: no necesitás actualizar todos los parámetros. Cuando hacés fine-tuning de un modelo pre-entrenado, los cambios de peso tienden a tener un rango intrínseco bajo. Esto significa que la matriz de actualización se puede descomponer en dos matrices mucho más pequeñas.
En vez de actualizar una matriz de pesos W de dimensiones d × k, LoRA congela W y entrena dos matrices pequeñas A (d × r) y B (r × k), donde r (el rango) es mucho menor que d y k:
Original: W (4096 × 4096) → 16.7M parámetros para actualizar
LoRA: A (4096 × 16) + B (16 × 4096) → 131K parámetros para actualizar
Reducción: 99.2% menos parámetros entrenables
El forward pass se convierte en: output = W·x + α·B·A·x, donde α es un factor de escala. Durante la inferencia, podés fusionar B·A de vuelta en W, así que hay cero latencia adicional comparado con el modelo original.
# Ilustración conceptual de LoRA import torch import torch.nn as nn class LoRALayer(nn.Module): def __init__(self, original_layer: nn.Linear, rank: int = 16, alpha: float = 32): super().__init__() self.original = original_layer self.original.weight.requires_grad = False # Congelar pesos originales d_in = original_layer.in_features d_out = original_layer.out_features # Matrices de descomposición de bajo rango self.lora_A = nn.Parameter(torch.randn(d_in, rank) * 0.01) self.lora_B = nn.Parameter(torch.zeros(rank, d_out)) self.scale = alpha / rank def forward(self, x): # Cómputo original (congelado) + actualización de bajo rango original_output = self.original(x) lora_output = (x @ self.lora_A @ self.lora_B) * self.scale return original_output + lora_output def merge(self): """Fusionar pesos LoRA al original para inferencia sin costo.""" self.original.weight.data += (self.lora_A @ self.lora_B).T * self.scale
QLoRA: accesible para todos
QLoRA (Quantized LoRA) agregó tres innovaciones que hicieron el fine-tuning accesible en hardware de consumo:
-
Cuantización NormalFloat de 4 bits (NF4): El modelo base se cuantiza a 4 bits usando un esquema de cuantización consciente de la distribución. Esto reduce un modelo de 7B de ~14GB (FP16) a ~3.5GB.
-
Doble Cuantización: Las constantes de cuantización también se cuantizan, ahorrando 0.37 bits adicionales por parámetro (~325MB en un modelo de 7B).
-
Optimizadores Paginados: Los estados del optimizador se descargan a RAM de CPU cuando la memoria GPU se queda corta. Esto previene crashes por OOM durante picos de entrenamiento.
El resultado: fine-tuning de un modelo 7B en una sola GPU de 24GB, o un modelo 13B en una GPU de 48GB. El desglose de memoria:
Fine-Tuning Completo (modelo 7B):
Pesos del modelo (FP32): ~28 GB
Estados del optimizador: ~28 GB
Gradientes: ~28 GB
Total: ~84 GB → Necesita 2x A100 80GB
Fine-Tuning con QLoRA (modelo 7B):
Pesos del modelo (NF4): ~3.5 GB
Adaptadores LoRA (FP16): ~0.1 GB
Estados del optimizador: ~0.4 GB
Gradientes + activaciones: ~4.0 GB
Total: ~8.0 GB → Entra en RTX 4090 (24GB) con espacio de sobra
¿La diferencia de calidad entre fine-tuning completo y QLoRA? En la mayoría de benchmarks, está dentro del 1-2% — un tradeoff insignificante para una reducción de 10x en requisitos de hardware.
Configurando tu entorno
Requisitos de Hardware
| GPU | VRAM | Tamaño máximo de modelo | Velocidad de entrenamiento |
|---|---|---|---|
| T4 (Colab Free) | 16GB | 7B (justo) | ~1.5 horas/epoch en 10K muestras |
| RTX 3090/4090 | 24GB | 7B (cómodo), 13B (justo) | ~45 min/epoch en 10K muestras |
| A100 40GB | 40GB | 13B (cómodo), 34B (justo) | ~20 min/epoch en 10K muestras |
| A100 80GB | 80GB | 70B con cuantización agresiva | ~15 min/epoch en 10K muestras |
Instalación
Usando Unsloth (recomendado para 2-5x de speedup sobre HuggingFace estándar):
# Crear un entorno limpio conda create -n finetune python=3.11 -y conda activate finetune # Instalar PyTorch con soporte CUDA pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 # Instalar Unsloth (maneja bitsandbytes, transformers, peft, trl automáticamente) pip install "unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git" pip install --no-deps xformers trl peft accelerate bitsandbytes # Para evaluación pip install rouge-score nltk scikit-learn
Eligiendo un modelo base (Marzo 2026)
La elección del modelo base importa más de lo que la mayoría piensa. Este es el panorama actual:
| Modelo | Parámetros | Contexto | Ideal para | Licencia |
|---|---|---|---|---|
| Llama 4 Scout | 109B total / 17B activos (MoE) | 10M | Flagship de Meta, contexto masivo, necesita H100 | Llama 4 Community |
| Llama 3.1 8B | 8B | 128K | Mejor ratio calidad/tamaño para principiantes | Llama 3.1 Community |
| Mistral Small 4 | 32B | 128K | Multilingüe, razonamiento fuerte, Apache licensed | Apache 2.0 |
| Gemma 3 27B | 27B | 128K | Multimodal, coding fuerte, ecosistema Google | Gemma License |
| Qwen 2.5 7B | 7B | 128K | Mejor para bilingüe chino + inglés | Apache 2.0 |
| Phi-4 14B | 14B | 16K | Compacto, razonamiento fuerte | MIT |
Recomendación para principiantes: Empezá con Llama 3.1 8B. Tiene la comunidad más grande, más tutoriales y el mejor ratio calidad/recursos. Una vez que tu pipeline funcione, intercambiá por modelos más grandes como Llama 4 Scout o Gemma 3 27B. Notá que Llama 4 Scout usa una arquitectura Mixture-of-Experts (MoE) — solo 17B parámetros están activos por token, lo que lo hace sorprendentemente eficiente a pesar de sus 109B parámetros totales.
Preparando tu Dataset
Acá es donde la mayoría de los proyectos de fine-tuning triunfan o fracasan. El modelo es tan bueno como los datos que le das. 500 ejemplos de calidad excepcional van a superar a 50,000 ejemplos mediocres.
Formato de datos: el Chat Template
El fine-tuning moderno usa el formato conversacional:
{ "conversations": [ {"role": "system", "content": "Sos un ingeniero backend senior especializado en optimización de PostgreSQL."}, {"role": "user", "content": "Esta query tarda 30 segundos en una tabla con 50M de filas:\n\nSELECT * FROM orders WHERE customer_id = 12345 AND status = 'pending' ORDER BY created_at DESC LIMIT 10;"}, {"role": "assistant", "content": "El problema es probablemente un índice compuesto faltante. Acá va el análisis:\n\n**Problema:** Sin un índice en `(customer_id, status, created_at)`, PostgreSQL hace un sequential scan sobre 50M de filas y después ordena los resultados.\n\n**Solución:**\n```sql\nCREATE INDEX CONCURRENTLY idx_orders_customer_status_created\nON orders (customer_id, status, created_at DESC);\n```\n\n**Por qué funciona:**\n1. El índice cubre las tres columnas del WHERE y ORDER BY\n2. `created_at DESC` en el índice coincide con tu ORDER BY, eliminando la operación de sort\n3. PostgreSQL puede usar un Index Only Scan si reemplazás `SELECT *` con columnas específicas\n4. `CONCURRENTLY` previene bloquear la tabla durante la creación\n\n**Mejora esperada:** De ~30s a <10ms para este patrón de query."} ] }
Checklist de calidad de datos
Antes de entrenar, auditá tu dataset contra estos criterios:
def audit_dataset(dataset): issues = [] for i, example in enumerate(dataset): conversations = example['conversations'] # Check 1: Largo mínimo de conversación if len(conversations) < 2: issues.append(f"Ejemplo {i}: Menos de 2 turnos") # Check 2: Calidad de respuesta (proxy por largo) assistant_msgs = [c for c in conversations if c['role'] == 'assistant'] for msg in assistant_msgs: if len(msg['content']) < 50: issues.append(f"Ejemplo {i}: Respuesta muy corta ({len(msg['content'])} chars)") if len(msg['content']) > 8000: issues.append(f"Ejemplo {i}: Respuesta muy larga ({len(msg['content'])} chars)") # Check 3: Sin mensajes vacíos for c in conversations: if not c['content'].strip(): issues.append(f"Ejemplo {i}: Mensaje vacío de {c['role']}") # Check 4: Alternación correcta de roles roles = [c['role'] for c in conversations if c['role'] != 'system'] for j in range(1, len(roles)): if roles[j] == roles[j-1]: issues.append(f"Ejemplo {i}: Mensajes consecutivos de {roles[j]}") # Check 5: Sin fuga de datos for c in conversations: if any(phrase in c['content'].lower() for phrase in ['as an ai', 'i am an ai', 'language model']): issues.append(f"Ejemplo {i}: Posible fuga de identidad en mensaje de {c['role']}") return issues
¿Cuántos ejemplos necesitás?
Depende de tu tarea:
| Tipo de tarea | Mínimo | Punto óptimo | Rendimientos decrecientes |
|---|---|---|---|
| Adaptación de estilo/tono | 50–100 | 200–500 | >1,000 |
| Q&A específico del dominio | 200–500 | 1,000–3,000 | >10,000 |
| Generación de código (SDK específico) | 500–1,000 | 2,000–5,000 | >15,000 |
| Cadenas de razonamiento complejas | 1,000–2,000 | 5,000–10,000 | >20,000 |
La regla del 80/10/10:
- 80% para entrenamiento
- 10% para validación (monitoreado durante el entrenamiento para prevenir overfitting)
- 10% para evaluación final (nunca visto durante el entrenamiento)
from datasets import load_dataset, DatasetDict def prepare_splits(dataset_path): dataset = load_dataset("json", data_files=dataset_path, split="train") dataset = dataset.shuffle(seed=42) # Split 80/10/10 train_test = dataset.train_test_split(test_size=0.2, seed=42) val_test = train_test['test'].train_test_split(test_size=0.5, seed=42) return DatasetDict({ 'train': train_test['train'], 'validation': val_test['train'], 'test': val_test['test'], })
Generando datos de entrenamiento sintéticos
Si no tenés suficientes ejemplos, podés bootstrapear tu dataset usando un modelo fuerte (GPT-4, Claude) para generar datos de entrenamiento para un modelo más chico. Esta técnica se llama destilación de conocimiento vía datos sintéticos y se usa extensivamente en producción.
import openai import json SYSTEM_PROMPT = """Estás generando datos de entrenamiento para un modelo fine-tuneado que actuará como experto en optimización de PostgreSQL. Generá preguntas realistas de usuarios sobre problemas de rendimiento de PostgreSQL y proporcioná respuestas de nivel experto. Incluí: - Queries SQL específicas con nombres de tablas y tamaños realistas - Interpretación de output de EXPLAIN ANALYZE - Recomendaciones concretas de índices con sentencias CREATE INDEX - Estimaciones de mejora de rendimiento Cada respuesta debe ser de 200-500 palabras con ejemplos de código.""" async def generate_training_examples(n_examples: int = 500): client = openai.AsyncOpenAI() examples = [] topics = [ "queries JOIN lentas en tablas grandes", "problemas de N+1 queries en ORMs", "full table scans en columnas indexadas", "contención de locks en escenarios de alta escritura", "regresión de query plan después de VACUUM", "agotamiento del connection pool", "detección y remediación de index bloat", ] for i in range(n_examples): topic = topics[i % len(topics)] response = await client.chat.completions.create( model="gpt-4o", messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": f"Generá un ejemplo de entrenamiento sobre: {topic}. " f"Variá la complejidad y los esquemas de tablas."} ], temperature=0.9, response_format={"type": "json_object"}, ) example = json.loads(response.choices[0].message.content) examples.append(example) return examples
Advertencia crítica: Siempre revisá manualmente una muestra de tus datos sintéticos. Los LLMs pueden generar consejos técnicos que suenan plausibles pero son incorrectos. Presupuestá tiempo para revisión humana de al menos 10-20% de los ejemplos sintéticos.
Entrenamiento con Unsloth
Ahora viene lo interesante. Acá va el script de entrenamiento completo:
from unsloth import FastLanguageModel from trl import SFTTrainer from transformers import TrainingArguments from datasets import load_dataset # ───────────────────────────────────────── # 1. Cargar modelo con cuantización de 4 bits # ───────────────────────────────────────── model, tokenizer = FastLanguageModel.from_pretrained( model_name="unsloth/Meta-Llama-3.1-8B-Instruct", max_seq_length=4096, dtype=None, load_in_4bit=True, ) # ───────────────────────────────────────── # 2. Configurar adaptadores LoRA # ───────────────────────────────────────── model = FastLanguageModel.get_peft_model( model, r=32, lora_alpha=64, target_modules=[ "q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj", ], lora_dropout=0.05, bias="none", use_gradient_checkpointing="unsloth", random_state=42, ) model.print_trainable_parameters() # Output: trainable params: 83,886,080 || all params: 8,113,831,936 || trainable%: 1.034% # ───────────────────────────────────────── # 3. Cargar y formatear dataset # ───────────────────────────────────────── dataset = load_dataset("json", data_files="training_data.jsonl", split="train") def format_chat(example): """Formatear conversaciones al chat template de Llama 3.""" messages = example['conversations'] text = tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=False, ) return {"text": text} dataset = dataset.map(format_chat, remove_columns=dataset.column_names) # ───────────────────────────────────────── # 4. Configurar entrenamiento # ───────────────────────────────────────── training_args = TrainingArguments( output_dir="./outputs", per_device_train_batch_size=4, gradient_accumulation_steps=4, num_train_epochs=3, learning_rate=2e-4, lr_scheduler_type="cosine", warmup_ratio=0.05, weight_decay=0.01, logging_steps=10, save_strategy="steps", save_steps=100, eval_strategy="steps", eval_steps=100, fp16=not torch.cuda.is_bf16_supported(), bf16=torch.cuda.is_bf16_supported(), optim="adamw_8bit", seed=42, max_grad_norm=0.3, report_to="wandb", ) # ───────────────────────────────────────── # 5. Inicializar trainer y arrancar # ───────────────────────────────────────── trainer = SFTTrainer( model=model, tokenizer=tokenizer, train_dataset=dataset, args=training_args, max_seq_length=4096, dataset_text_field="text", packing=True, ) print("Iniciando fine-tuning...") stats = trainer.train() print(f"Entrenamiento completado en {stats.metrics['train_runtime']:.0f} segundos") print(f"Loss final: {stats.metrics['train_loss']:.4f}")
Guía de ajuste de hiperparámetros
Los valores por defecto de arriba funcionan bien para la mayoría de los casos, pero acá va cómo ajustarlos:
Rango LoRA (r):
r=8: Adaptación mínima. Bueno para cambios simples de estilo.r=16: Por defecto. Funciona para la mayoría de tareas.r=32: Mayor capacidad. Bueno para adaptación compleja de dominio.r=64+: Cerca de la capacidad de fine-tuning completo. Raramente necesario.
Regla general: empezá con r=16. Si la loss de validación se estanca temprano, subí a 32. Si hace overfitting rápido, bajá a 8.
Learning Rate:
2e-4: Estándar para QLoRA. Empezá acá.1e-4: Más conservador. Usalo si el entrenamiento es inestable.5e-5: Muy conservador. Usalo para datasets muy pequeños (<200 ejemplos).
Número de Epochs:
- 1–2: Datasets grandes (>10K ejemplos)
- 2–4: Datasets medianos (1K–10K ejemplos)
- 4–8: Datasets pequeños (<1K ejemplos)
- Cuidado con el overfitting: Si la loss de validación empieza a subir mientras la de entrenamiento sigue bajando, estás haciendo overfitting. Pará el entrenamiento.
from transformers import EarlyStoppingCallback trainer = SFTTrainer( model=model, tokenizer=tokenizer, train_dataset=train_dataset, eval_dataset=val_dataset, args=training_args, callbacks=[ EarlyStoppingCallback( early_stopping_patience=3, early_stopping_threshold=0.01 ), ], max_seq_length=4096, dataset_text_field="text", packing=True, )
Cómo debería verse la curva de loss
Un entrenamiento saludable se ve así:
Loss
4.0 |X
| X
3.0 | X
| X
2.0 | X X
| X X X
1.0 | X X X X X X ← plateau (bien, el modelo convergió)
|
0.0 +─────────────────────────────────────
0 200 400 600 800 1000
Steps
Señales de alarma:
- La loss no baja → Learning rate muy bajo o problemas con los datos
- La loss cae cerca de 0 → Overfitting severo, reducí epochs o aumentá datos
- La loss es muy ruidosa → Batch size muy pequeño o learning rate muy alto
- La loss salta repentinamente → Explosión de gradiente, reducí learning rate
Evaluando tu modelo
El entrenamiento es solo la mitad de la batalla. La evaluación te dice si tu modelo fine-tuneado realmente mejora frente al modelo base para tu tarea específica.
Evaluación automatizada
import torch from rouge_score import rouge_scorer import json def evaluate_model(model, tokenizer, test_dataset, max_samples=100): """Evaluación integral del modelo fine-tuneado.""" model.eval() scorer = rouge_scorer.RougeScorer(['rouge1', 'rougeL'], use_stemmer=True) results = { 'rouge1_scores': [], 'rougeL_scores': [], 'format_compliance': [], 'avg_response_length': [], 'examples': [], } for i, example in enumerate(test_dataset.select(range(min(max_samples, len(test_dataset))))): conversations = example['conversations'] prompt_messages = [] expected_response = "" for msg in conversations: if msg['role'] == 'assistant' and msg == conversations[-1]: expected_response = msg['content'] else: prompt_messages.append(msg) inputs = tokenizer.apply_chat_template( prompt_messages, tokenize=True, add_generation_prompt=True, return_tensors="pt" ).to(model.device) with torch.no_grad(): outputs = model.generate(inputs, max_new_tokens=1024, temperature=0.1, do_sample=False) generated = tokenizer.decode(outputs[0][inputs.shape[1]:], skip_special_tokens=True) rouge_scores = scorer.score(expected_response, generated) results['rouge1_scores'].append(rouge_scores['rouge1'].fmeasure) results['rougeL_scores'].append(rouge_scores['rougeL'].fmeasure) results['avg_response_length'].append(len(generated)) expected_has_code = '```' in expected_response generated_has_code = '```' in generated results['format_compliance'].append(expected_has_code == generated_has_code) if i < 10: results['examples'].append({ 'prompt': prompt_messages[-1]['content'][:200], 'expected': expected_response[:300], 'generated': generated[:300], 'rouge1': rouge_scores['rouge1'].fmeasure, }) summary = { 'avg_rouge1': sum(results['rouge1_scores']) / len(results['rouge1_scores']), 'avg_rougeL': sum(results['rougeL_scores']) / len(results['rougeL_scores']), 'format_compliance_rate': sum(results['format_compliance']) / len(results['format_compliance']), 'avg_response_length': sum(results['avg_response_length']) / len(results['avg_response_length']), 'examples': results['examples'], } return summary
Evaluación LLM-as-Judge
Para evaluación subjetiva de calidad, usá un modelo más fuerte como juez:
async def llm_judge_evaluation(examples, judge_model="gpt-4o"): """Usar un LLM fuerte para evaluar la calidad de las respuestas.""" client = openai.AsyncOpenAI() scores = [] for ex in examples: response = await client.chat.completions.create( model=judge_model, messages=[{ "role": "system", "content": """Estás evaluando la calidad de la respuesta de un modelo fine-tuneado. Calificá cada respuesta en estas dimensiones (escala 1-5): 1. Precisión Técnica: ¿Son correctas las afirmaciones técnicas? 2. Completitud: ¿Aborda todos los aspectos de la pregunta? 3. Cumplimiento de Formato: ¿Sigue el formato de salida esperado? 4. Accionabilidad: ¿Puede el usuario aplicar esto directamente? Respondé como JSON: {"accuracy": N, "completeness": N, "format": N, "actionability": N, "reasoning": "..."}""" }, { "role": "user", "content": f"Pregunta: {ex['prompt']}\n\nRespuesta: {ex['finetuned_response']}" }], response_format={"type": "json_object"}, ) score = json.loads(response.choices[0].message.content) scores.append(score) return scores
Guardando y deployando tu modelo
Opciones de guardado
Después del entrenamiento, tenés tres opciones de guardado:
# Opción 1: Guardar solo adaptadores LoRA (~100-300MB) model.save_pretrained("./my-lora-adapter") tokenizer.save_pretrained("./my-lora-adapter") # Opción 2: Fusionar y guardar modelo completo en 16-bit (~14GB para 7B) model.save_pretrained_merged("./my-model-merged", tokenizer, save_method="merged_16bit") # Opción 3: Exportar como GGUF para llama.cpp / Ollama model.save_pretrained_gguf("./my-model-gguf", tokenizer, quantization_method="q4_k_m") # Opción 4: Push a Hugging Face Hub model.push_to_hub("your-username/my-fine-tuned-model", token="hf_...") tokenizer.push_to_hub("your-username/my-fine-tuned-model", token="hf_...")
Deploy con vLLM (Recomendado para producción)
vLLM es el estándar para serving de LLMs en producción:
# Código del cliente — usa la API compatible con OpenAI import openai client = openai.OpenAI(base_url="http://localhost:8000/v1", api_key="dummy") response = client.chat.completions.create( model="./my-model-merged", messages=[ {"role": "system", "content": "Sos un experto en optimización de PostgreSQL."}, {"role": "user", "content": "Mi query hace un sequential scan sobre 100M de filas..."}, ], temperature=0.3, max_tokens=1024, ) print(response.choices[0].message.content)
Deploy con Ollama (Local/Edge)
Para desarrollo local o deploy en el edge, exportá a GGUF y usá Ollama:
cat > Modelfile << 'EOF' FROM ./my-model-gguf/unsloth.Q4_K_M.gguf TEMPLATE """{{ if .System }}<|start_header_id|>system<|end_header_id|> {{ .System }}<|eot_id|>{{ end }}<|start_header_id|>user<|end_header_id|> {{ .Prompt }}<|eot_id|><|start_header_id|>assistant<|end_header_id|> """ PARAMETER temperature 0.3 PARAMETER num_ctx 4096 SYSTEM "Sos un experto en optimización de PostgreSQL." EOF ollama create my-pg-expert -f Modelfile ollama run my-pg-expert "¿Cómo optimizo una query lenta con GROUP BY?"
Checklist de producción
Pre-Deploy
- Scores de evaluación superan baseline: El modelo fine-tuneado supera al modelo base en tu test set por un margen significativo
- Sin olvido catastrófico: Testeá tareas generales para asegurar que el modelo no perdió capacidades básicas
- Guardrails implementados: Testeá outputs dañinos, sesgados o fuera de alcance e implementá filtrado de contenido
- Benchmarks de latencia: Medí P50, P95 y P99 de latencia bajo carga realista
- Proyección de costos: Calculá costo por request incluyendo compute GPU y compará con alternativas basadas en API
Monitoreo post-deploy
- Trackear calidad del output a lo largo del tiempo: Configurá evaluación automatizada sobre una muestra aleatoria de requests de producción
- Monitorear distribution drift: Si las queries de usuarios empiezan a diferir de los datos de entrenamiento, la calidad del modelo se va a degradar
- Versioná tus modelos: Usá semantic versioning (v1.0.0, v1.1.0) y mantené capacidad de rollback
- Cadencia de reentrenamiento: Planificá reentrenamiento periódico a medida que acumulás más datos de producción
Errores comunes y cómo evitarlos
Error 1: Entrenar con datos malos
Síntoma: El modelo genera respuestas que suenan plausibles pero son técnicamente incorrectas.
Causa: Datos de entrenamiento sintéticos sin verificación humana.
Solución: Siempre tené expertos del dominio revisando al menos el 10% de tus datos. Un ejemplo incorrecto puede contaminar cientos de outputs relacionados.
Error 2: Overfitting en datasets chicos
Síntoma: La loss de entrenamiento se acerca a 0, pero los outputs son formulaicos y parecen memorizar ejemplos verbatim.
Causa: Demasiados epochs con muy pocos ejemplos.
Solución: Reducí epochs, aumentá LoRA dropout a 0.1, usá un rango LoRA más bajo, o aumentá tu dataset.
Error 3: Mismatch de Chat Template
Síntoma: El modelo genera basura, tokens repetidos o ignora el system prompt.
Causa: Usar un chat template diferente durante entrenamiento vs inferencia.
Solución: Usá siempre tokenizer.apply_chat_template() tanto para formatear datos de entrenamiento como para construir prompts de inferencia. Nunca construyas chat prompts manualmente.
Error 4: Olvido catastrófico
Síntoma: El modelo performa bien en tu tarea específica pero no puede con tareas básicas que hacía antes del fine-tuning.
Causa: Fine-tuning agresivo que sobreescribe conocimiento general.
Solución: Usá un learning rate más bajo (5e-5 en vez de 2e-4), menos epochs, o mezclá datos de propósito general (10-20% de tu training set debería ser ejemplos generales).
Error 5: Ignorar efectos de la cuantización
Síntoma: El modelo fine-tuneado funciona bien en FP16 pero se degrada significativamente después de cuantización GGUF.
Causa: Cuantización agresiva (Q2_K, Q3_K) en modelos que no fueron diseñados para eso.
Solución: Usá Q4_K_M o Q5_K_M para cuantización de deploy. Siempre hacé benchmark después de cuantizar para verificar que se mantiene la calidad.
Comparación de costos: Fine-Tuning vs API
Hagamos las cuentas con un escenario realista de producción:
Escenario: 100,000 requests/mes, promedio 500 tokens de entrada + 500 tokens de salida por request.
| Enfoque | Costo mensual | Latencia (P50) | Control |
|---|---|---|---|
| GPT-4o API | ~$1,500 | 800ms | Bajo |
| Claude 3.5 Sonnet API | ~$1,800 | 600ms | Bajo |
| GPT-4o-mini API | ~$30 | 400ms | Bajo |
| Self-hosted Llama 3.1 8B (A10G) | ~$350 | 120ms | Total |
| Self-hosted Fine-Tuned 8B (A10G) | ~$350 | 120ms | Total + Expertise de dominio |
El modelo fine-tuneado cuesta lo mismo de operar que el modelo base — pero produce outputs de mayor calidad específicos del dominio.
Punto de equilibrio: Para la mayoría de los equipos, self-hosting se vuelve más barato que llamadas API alrededor de las 30,000-50,000 requests/mes.
Lo que viene: técnicas emergentes en 2026
Unsloth Studio (Marzo 2026): Unsloth acaba de lanzar una interfaz open-source, local y sin código que maneja todo el ciclo de fine-tuning — preparación de datos, entrenamiento y despliegue — en una sola GUI. Promete 70% menos VRAM y 2x más velocidad. Si los scripts Python de arriba te intimidan, Studio es un game-changer.
DoRA (Weight-Decomposed Low-Rank Adaptation): Separa magnitud y dirección en las actualizaciones de pesos, superando a LoRA consistentemente por 1-3% con overhead insignificante. Ya integrado en la librería PEFT.
GaLore (Gradient Low-Rank Projection): Promete calidad de fine-tuning completo a costos de memoria de LoRA proyectando gradientes en un espacio de bajo rango. Todavía experimental pero prometedor.
LoRA Merging y Model Soups: Combinar múltiples adaptadores LoRA (cada uno entrenado en diferentes tareas) en un solo modelo mediante promediado de pesos. Permite especialización multi-tarea sin deploys separados.
GRPO (Group Relative Policy Optimization): Una técnica emergente para entrenar modelos de "IA de razonamiento" que hacen lógica multi-paso y chain-of-thought. Unsloth soporta GRPO con tan solo 5GB de VRAM. Así se están entrenando la próxima generación de modelos de razonamiento como DeepSeek-R1.
Fine-Tuning con Reward Model (RLHF/DPO): Después del SFT, una segunda pasada de entrenamiento usando Direct Preference Optimization (DPO) alinea los outputs del modelo con las preferencias humanas.
# Entrenamiento DPO después de SFT (sketch) from trl import DPOTrainer, DPOConfig dpo_config = DPOConfig( output_dir="./dpo-output", beta=0.1, learning_rate=5e-6, per_device_train_batch_size=2, num_train_epochs=1, ) dpo_trainer = DPOTrainer( model=sft_model, ref_model=None, args=dpo_config, train_dataset=preference_dataset, tokenizer=tokenizer, ) dpo_trainer.train()
Resumen final
El fine-tuning de LLMs open-source pasó de ser una actividad de laboratorio de investigación a un workflow de ingeniería estándar. Con QLoRA y Unsloth, podés fine-tunear un modelo que supere a GPT-4 en tu tarea específica — en una sola GPU de consumo, en menos de una hora.
Los principios clave:
- Calidad sobre cantidad. 500 ejemplos excelentes superan a 50,000 mediocres.
- Empezá chico. Usá Llama 3.1 8B con QLoRA en tu dataset viable más pequeño. Que el pipeline funcione antes de escalar.
- Evaluá rigurosamente. Métricas automatizadas (ROUGE, cumplimiento de formato) más LLM-as-judge más revisión manual. Las tres.
- Monitoreá en producción. El rendimiento del modelo va a driftear a medida que las queries de usuarios evolucionen. Planificá reentrenamiento periódico.
- Sabé cuándo NO hacer fine-tuning. Si RAG o mejor prompting resuelve tu problema, es más barato y rápido.
La brecha entre modelos closed-source y open-source se achica cada mes. Con las técnicas de esta guía, podés construir sistemas de IA en producción que sean más baratos, más rápidos, más privados y más especializados que cualquier cosa que una API pueda ofrecerte.
Explora herramientas relacionadas
Prueba estas herramientas gratuitas de Pockit