Back

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 UsoDónde falla el PromptingVentaja del Fine-Tuning
Generación de código para APIs internasEl modelo no conoce tu SDKAprende tus patrones y convenciones específicos
Análisis de documentos médicos/legalesLos modelos genéricos son demasiado cautelososSalidas seguras y específicas del dominio
Extracción de datos estructuradosEl formateo basado en prompts es frágilAdherencia consistente al esquema
Matching de tono de soporte al clienteLos system prompts derivan en conversaciones largasVoz y personalidad incorporadas
Generación SQL para esquemas customEl esquema en el contexto consume tokensConocimiento 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:

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

  2. 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).

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

GPUVRAMTamaño máximo de modeloVelocidad de entrenamiento
T4 (Colab Free)16GB7B (justo)~1.5 horas/epoch en 10K muestras
RTX 3090/409024GB7B (cómodo), 13B (justo)~45 min/epoch en 10K muestras
A100 40GB40GB13B (cómodo), 34B (justo)~20 min/epoch en 10K muestras
A100 80GB80GB70B 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:

ModeloParámetrosContextoIdeal paraLicencia
Llama 4 Scout109B total / 17B activos (MoE)10MFlagship de Meta, contexto masivo, necesita H100Llama 4 Community
Llama 3.1 8B8B128KMejor ratio calidad/tamaño para principiantesLlama 3.1 Community
Mistral Small 432B128KMultilingüe, razonamiento fuerte, Apache licensedApache 2.0
Gemma 3 27B27B128KMultimodal, coding fuerte, ecosistema GoogleGemma License
Qwen 2.5 7B7B128KMejor para bilingüe chino + inglésApache 2.0
Phi-4 14B14B16KCompacto, razonamiento fuerteMIT

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 tareaMínimoPunto óptimoRendimientos decrecientes
Adaptación de estilo/tono50–100200–500>1,000
Q&A específico del dominio200–5001,000–3,000>10,000
Generación de código (SDK específico)500–1,0002,000–5,000>15,000
Cadenas de razonamiento complejas1,000–2,0005,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.

EnfoqueCosto mensualLatencia (P50)Control
GPT-4o API~$1,500800msBajo
Claude 3.5 Sonnet API~$1,800600msBajo
GPT-4o-mini API~$30400msBajo
Self-hosted Llama 3.1 8B (A10G)~$350120msTotal
Self-hosted Fine-Tuned 8B (A10G)~$350120msTotal + 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:

  1. Calidad sobre cantidad. 500 ejemplos excelentes superan a 50,000 mediocres.
  2. Empezá chico. Usá Llama 3.1 8B con QLoRA en tu dataset viable más pequeño. Que el pipeline funcione antes de escalar.
  3. Evaluá rigurosamente. Métricas automatizadas (ROUGE, cumplimiento de formato) más LLM-as-judge más revisión manual. Las tres.
  4. Monitoreá en producción. El rendimiento del modelo va a driftear a medida que las queries de usuarios evolucionen. Planificá reentrenamiento periódico.
  5. 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.

AILLMfine-tuningQLoRALoRAUnslothLlamaMistralGemmamachine-learningopen-sourceproduction

Explora herramientas relacionadas

Prueba estas herramientas gratuitas de Pockit