Hugging Face · LLM Course · Capítulo 11
Fine-tuning de LLMs: lo que el Capítulo 3 adelantó, ahora en serio
Este es el capítulo donde el curso se pone al día con 2026 de verdad: SFT (Supervised Fine-Tuning) reemplazó al fine-tuning de tarea única del Capítulo 3 como el camino estándar para adaptar un LLM — y es exactamente lo que ya usaste en tus propios proyectos.
Antes de leercap. 3 §5, LoRA · cap. 6, tokens especiales · cap. 5, Datasets
🎯 Al terminar vas a poder
- Explicar qué hace un chat template, ver con tus ojos el texto exacto que produce para un modelo concreto, y reconocer el síntoma de haberlo usado mal.
- Armar un dataset en formato conversacional, entrenar con
SFTTrainer+ LoRA, elegir el learning rate correcto y decidir si entrenar sobre toda la conversación o solo sobre los turnos del asistente. - Fusionar un adapter LoRA en el modelo base y explicar cuándo eso no es posible.
- Correr una evaluación con
lighteval, leer una tabla de benchmarks sabiendo qué mide cada uno y qué trampa tiene, y decidir cuándo hace falta un set de evaluación propio.
1. Chat templates, en serio
Ya los mencionamos en los Capítulos 2 y 6 como la pieza que reemplaza a token_type_ids
para modelos de chat. Acá se formaliza: un modelo base (recién preentrenado) solo sabe
completar texto; un modelo instruct fue afinado para seguir el formato de conversación
de su chat template específico — y ese formato varía por familia de modelo (Llama, Mistral, Gemma,
cada uno con sus propios tokens de rol y delimitadores).
El template no es cosmético: define dónde empieza y termina cada turno, qué texto es del sistema y cuál del usuario, y en los modelos más nuevos, cómo se representan llamadas a herramientas o inputs multimodales. Usar el template equivocado para un modelo — o concatenar strings a mano en vez de usar el que trae el tokenizer — es una de las formas más comunes de que un modelo instruct responda peor de lo que debería, sin ningún error visible.
Lo mejor que podés hacer para dejar de tratarlo como magia es mirar el string. El tokenizer
guarda el template (una plantilla Jinja) y lo aplica sobre una lista de mensajes con
role/content:
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("google/gemma-4-E2B-it")
mensajes = [
{"role": "system", "content": "Respondés en español, en una frase."},
{"role": "user", "content": "¿Qué es un adapter LoRA?"},
]
print(tok.apply_chat_template(mensajes, tokenize=False, add_generation_prompt=True))
<bos><|turn>system\nRespondés en español, en una frase.<turn|>\n<|turn>user\n¿Qué es un adapter LoRA?<turn|>\n<|turn>model\n
Tres cosas que se ven ahí y que importan para entrenar. Primero, los delimitadores
(<|turn> y <turn|> en Gemma 4, que además escribe el rol model
donde vos pusiste assistant y antepone <bos>; SmolLM2 y Qwen usan el formato "ChatML",
<|im_start|> / <|im_end|>; Llama 3, <|start_header_id|>;
Gemma 3, <start_of_turn>) son tokens especiales, uno solo cada uno en el
vocabulario. El tokenizer los reconoce aunque los escribas vos como texto — el riesgo de concatenar a
mano no es ese, sino reproducir el formato exacto: los saltos de línea, el orden, el mensaje
de sistema por defecto que algunos templates insertan solos. Un espacio de diferencia y el modelo ve
una secuencia que nunca vio en entrenamiento.
Segundo, add_generation_prompt=True agrega el encabezado del turno del asistente vacío
— es lo que se usa en inferencia para que el modelo empiece a responder; en
entrenamiento no se usa, porque el turno del asistente ya viene completo en el dataset.
Tercero, el turno del asistente en un ejemplo de entrenamiento tiene que terminar con el token de
fin de turno (<|im_end|>): es lo que le enseña al modelo a parar. Un modelo
que después de fine-tuning "no se calla" casi siempre fue entrenado con ejemplos sin ese token.
⚠️ Cuidado
Un modelo base no trae chat template, o trae uno genérico que nunca vio en entrenamiento. Si le hacés SFT a un modelo base, el template que uses en el dataset es el que el modelo aprende — elegilo una vez (el de la versión instruct de esa misma familia es la opción obvia), fijalo en el tokenizer que guardás junto al modelo, y usá exactamente ese en inferencia.
2. Fine-tuning supervisado con SFTTrainer
SFT es el sucesor natural del fine-tuning de tarea única del Capítulo 3: en vez de entrenar para una sola tarea (clasificar, traducir), entrenás sobre ejemplos de conversación completa — pares instrucción/respuesta — para que el modelo aprenda a seguir instrucciones en general. Es, literalmente, el paso que convierte un modelo base en algo parecido a ChatGPT o Claude.
Tiene sentido cuando necesitás controlar el formato exacto de salida, o adaptar el modelo a un dominio específico que un modelo instruct genérico no cubre bien — los mismos dos motivos del Capítulo 7, aplicados a instrucciones en vez de a una tarea puntual.
El formato del dataset
SFTTrainer (de la librería trl) espera uno de dos formatos. El
conversacional es el que vas a usar casi siempre: una columna messages
con la lista de turnos, cada uno un diccionario {"role": ..., "content": ...} — el mismo
formato que le pasaste a apply_chat_template arriba. El otro es el
estándar: columnas prompt y completion de texto plano (o
una sola columna text ya formateada). Con el conversacional, el trainer aplica el
chat template del tokenizer por vos — no tenés que formatear nada a mano, y esa es justamente
la gracia: el formato que ve el modelo en entrenamiento es idéntico al de inferencia porque lo
produce la misma función.
{"messages": [
{"role": "system", "content": "Sos un asistente que explica finanzas en español rioplatense."},
{"role": "user", "content": "¿Qué es el EBITDA?"},
{"role": "assistant", "content": "Es la ganancia operativa antes de intereses, impuestos, depreciación y amortización..."}
]}
Y el entrenamiento, con la configuración mínima:
from trl import SFTTrainer, SFTConfig
trainer = SFTTrainer(
model="HuggingFaceTB/SmolLM2-1.7B",
train_dataset=dataset, # columna "messages" — el trainer aplica el chat template
args=SFTConfig(
max_length=2048, # largo máximo en tokens; lo que sobra se trunca
packing=True,
learning_rate=2e-5, # fine-tuning completo; con LoRA, ~10× más (§3)
num_train_epochs=1,
output_dir="sft-smollm2",
),
)
trainer.train()
🟢 Actualización 2026
El parámetro se llama max_length. El nombre viejo, max_seq_length, que
todavía aparece en el curso original y en muchos tutoriales, fue renombrado en versiones recientes
de TRL; si lo usás, según la versión vas a recibir un aviso de deprecación o un error de argumento
desconocido.
packing=True concatena varios ejemplos cortos en una sola secuencia de longitud máxima —
evita desperdiciar cómputo en padding cuando tus ejemplos son mucho más cortos que el límite del
modelo. Todo lo del Capítulo 3 sobre leer curvas de aprendizaje aplica igual acá: loss que baja y se
estabiliza es sano, un gap creciente entre train y validación es overfitting, sin importar que ahora
el objetivo sea "seguir instrucciones" en vez de "clasificar una reseña".
¿Loss sobre toda la conversación o solo sobre el asistente?
Por defecto el modelo se entrena a predecir todos los tokens de la secuencia — incluido el
prompt del sistema y lo que escribió el usuario. Eso tiene un costo: parte del gradiente se gasta
en aprender a "escribir preguntas de usuario", que no es lo que querés, y si tus prompts son largos
y tus respuestas cortas, la mayoría de la loss ni siquiera mide lo que te importa. La alternativa es
enmascarar los tokens del prompt (ponerles etiqueta -100, que la loss ignora) y
computar la loss solo sobre los turnos del asistente. En versiones recientes de TRL eso se pide
con assistant_only_loss=True en SFTConfig, y requiere que el chat
template del modelo marque dónde empiezan y terminan los turnos del asistente (los templates
más nuevos lo traen; con uno que no, el trainer te lo va a decir). Para el formato
prompt/completion, el equivalente es entrenar solo sobre la
completion, que es el comportamiento por defecto para ese formato. Como regla: con
respuestas largas y prompts cortos la diferencia es chica; con prompts largos (RAG, documentos
pegados en el contexto) es grande y conviene enmascarar.
📐 Formulación matemática de SFT condicional y empaquetado por bloques (Packing)
Sea una secuencia tokenizada descompuesta en el prefijo de instrucción y la respuesta del asistente . La función de pérdida supervisada condicional es:
Asignando para todo , se garantiza . De este modo, la capacidad de los adaptadores LoRA se concentra exclusivamente en modelar la variedad condicional , sin consumir gradientes en aprender a autogenerar las preguntas del usuario.
Empaquetado (Multipack) y Máscara Diagonal por Bloques: Al concatenar múltiples ejemplos independientes en una única secuencia de longitud , una atención causal estándar provocaría que el inicio de atienda al final de . FlashAttention-2 resuelve esto mediante tensores de longitud acumulada cu_seqlens:
Esto elimina por completo el costo en memoria y FLOPs del padding sin fuga de atención entre documentos.
-100 el sistema, el usuario y el encabezado del asistente hace que el
gradiente vaya solo a lo que el asistente dijo, incluido el token de fin de turno.
3. LoRA, con código real
El Capítulo 3 introdujo la idea; acá está la implementación real, vía peft integrado
directo en SFTTrainer:
from peft import LoraConfig
peft_config = LoraConfig(
r=16, lora_alpha=32, lora_dropout=0.05,
target_modules=["q_proj", "v_proj"], # o "all-linear" para cubrir todas las proyecciones
task_type="CAUSAL_LM",
)
trainer = SFTTrainer(
model=model, train_dataset=dataset, peft_config=peft_config,
args=SFTConfig(learning_rate=2e-4, max_length=2048, output_dir="sft-lora"),
)
trainer.train()
trainer.save_model("sft-lora/adapter") # guarda solo el adapter, unos pocos MB
🟢 Con Gemma 4, target_modules necesita un regex
["q_proj", "v_proj"] funciona con SmolLM2 y falla con google/gemma-4-E2B-it: el encoder
de visión también tiene módulos llamados q_proj, envueltos en una clase (Gemma4ClippableLinear)
que peft no sabe adaptar, y el error es Target module … is not supported. La solución es apuntar al modelo de
texto por nombre completo: target_modules=r".*language_model.*\.(q_proj|k_proj|v_proj|o_proj|gate_proj|up_proj|down_proj)$".
"all-linear" también anda, pero pone adapters en las torres de visión y audio. Probado con peft 0.20 y
trl 1.12; el ejercicio 2 de esta página trae la corrida completa.
Tres decisiones que la config deja en tus manos. r y lora_alpha:
r es el rango de las matrices A/B (más rango, más capacidad y más
parámetros), y la corrección se escala por lora_alpha / r — la convención de
alpha = 2r del ejemplo es un punto de partida razonable, no una ley.
target_modules: el ejemplo del curso toca solo las proyecciones de query y
value de la atención; aplicar LoRA a todas las capas lineales (atención y MLP) suele rendir mejor a
cambio de un adapter más grande — hoy es lo habitual. learning_rate: con
LoRA se usa uno del orden de 1e-4 a 2e-4, unas diez veces más alto que en
fine-tuning completo, porque las matrices del adapter arrancan en cero (B) y necesitan
moverse rápido; usar el 2e-5 de un fine-tuning completo es la causa más común de un LoRA
que "no aprendió nada". Y task_type="CAUSAL_LM" no es decorativo: le dice a
peft qué tipo de modelo está envolviendo, y sin él el forward puede no propagar bien los
argumentos (labels, cache) al modelo base.
El resultado de entrenar así no son pesos nuevos del modelo completo — es un adapter, un
puñado de archivos chicos con las matrices A/B entrenadas. Podés servirlo
separado (cargando el adapter sobre el modelo base en runtime) o fusionarlo — sumar
A·B directamente a los pesos originales — para quedarte con un único checkpoint normal,
sin dependencias extra de peft en producción.
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
base = AutoModelForCausalLM.from_pretrained("HuggingFaceTB/SmolLM2-1.7B")
model = PeftModel.from_pretrained(base, "sft-lora/adapter") # base + adapter, separados
model = model.merge_and_unload() # W ← W + (alpha/r)·B·A ; devuelve el modelo "plano"
model.save_pretrained("sft-fusionado")
AutoTokenizer.from_pretrained("HuggingFaceTB/SmolLM2-1.7B").save_pretrained("sft-fusionado") # ¡con el chat template!
Fusionar es reversible solo si guardaste el adapter aparte — el modelo fusionado ya no distingue qué
parte de cada peso vino de dónde. Lo cargás después con AutoModelForCausalLM.from_pretrained("sft-fusionado")
como cualquier otro checkpoint, y lo servís con vLLM sin flags especiales.
🔧 El límite de "fusionar" que ya conocés
Fusionar A·B en los pesos solo tiene sentido cuando esos pesos son números de punto
flotante normales. En la página de
NVFP4 en difusión viste el caso límite: si el modelo base ya está empaquetado en NVFP4 (4 bits
por valor, en bloques de 16), no existe un "peso intermedio" al que sumarle la corrección — por eso
nunchaku tiene que fusionar el cómputo de LoRA en el kernel, en cada forward, en vez de
fusionarlo en los pesos como acá. Mismo concepto de LoRA, weights float vs. weights cuantizados.
🟢 El paso siguiente a SFT: preferencias
SFT enseña cómo responder a partir de ejemplos buenos; no enseña a distinguir una respuesta
buena de una mediocre. Para eso está la alineación por preferencias: un dataset de
pares chosen/rejected para el mismo prompt (juicio humano del tipo que
Argilla captura con una RankingQuestion, Capítulo 10) y un trainer que empuja la
probabilidad de la respuesta elegida por encima de la rechazada. En trl es
DPOTrainer (Direct Preference Optimization: sin reward model separado, optimiza
directamente sobre los pares), y variantes como ORPO que fusionan SFT y preferencias en un solo paso.
No entramos en detalle acá; el Capítulo 12 cubre el otro
camino — RL con GRPO, donde no hay pares fijos sino una función de reward.
4. Evaluación: benchmarks y sus límites
Un LLM instruction-tuned se evalúa contra baterías de benchmarks estandarizados — útiles para comparar modelos entre sí, con la salvedad de que un buen score no garantiza que sirva para tu caso de uso específico. Cada uno mide algo distinto y cada uno tiene una forma conocida de mentir:
| Benchmark | Qué mide | Cómo se puntúa | La trampa |
|---|---|---|---|
| MMLU | Conocimiento general en 57 materias (derecho, medicina, matemática…), preguntas de opción múltiple | Exactitud sobre la letra elegida, típicamente 5-shot | Contaminación (las preguntas circulan por todo internet) y sensibilidad al formato del prompt: cambiar el orden de las opciones mueve puntos. |
| GSM8K | Problemas de matemática de escuela primaria con varios pasos | Coincidencia exacta de la respuesta numérica final | El split de train se usa muchísimo para SFT y GRPO (Capítulo 12): si el test se filtró al entrenamiento, el score mide memoria. Y la extracción de la respuesta final depende de un regex. |
| BBH | Razonamiento en tareas donde los modelos rendían peor que humanos (lógica, seguimiento de objetos, fechas) | Exactitud con chain-of-thought de pocos ejemplos | El resultado depende tanto del prompt de razonamiento como del modelo. |
| TruthfulQA | Resistencia a reproducir conceptos erróneos comunes ("¿qué pasa si tragás un chicle?") | Opción múltiple o juicio de un modelo | Mide falsedades imitativas, no veracidad general; la variante de opción múltiple se puede aprender. |
| HumanEval | Generación de código funcional: 164 problemas de Python con tests | pass@k: fracción de problemas donde alguna de k generaciones pasa los tests | Son pocos problemas, todos en GitHub desde hace años; un modelo entrenado con código público casi seguro los vio. |
| IFEval | Seguir instrucciones verificables ("respondé en menos de 50 palabras", "sin comas") | Chequeo automático de cada restricción | Mide obediencia de formato, no calidad del contenido. |
La herramienta del curso para correr esto es lighteval, de Hugging Face.
Una tarea se nombra con cuatro partes separadas por |: la suite, la tarea, la cantidad de
ejemplos few-shot, y si se permite truncar esos ejemplos cuando no entran en el contexto. Un
comando típico, evaluando un modelo local con accelerate:
pip install lighteval
lighteval accelerate \
"model_name=./sft-fusionado" \
"lighteval|gsm8k|0|0" \
--output-dir resultados
El resultado es un JSON por corrida con la métrica de cada tarea (para GSM8K, la exactitud de la
respuesta final) más los detalles por ejemplo si los pedís — que son los que te interesan: leer
veinte fallos concretos enseña más que el número agregado. En versiones anteriores de
lighteval el argumento del modelo se escribía pretrained=... y las tareas
del leaderboard iban con el prefijo leaderboard|; si un comando copiado de un tutorial
falla, es casi seguro eso. La misma herramienta permite definir tareas propias — un archivo de Python
con tu dataset y tu métrica — que es la forma de meter tu set de evaluación custom en el mismo
pipeline que los benchmarks públicos.
Cuando el benchmark genérico no alcanza, dos alternativas: LLM-as-judge (otro LLM evalúa la respuesta contra un criterio) o arenas (comparación humana entre respuestas de distintos modelos) — y para lo más específico, un set de evaluación propio, hecho a medida. Las dos primeras tienen sus propias trampas: un juez LLM prefiere respuestas largas y con formato, y tiende a preferir las de su propia familia; una arena mide qué respuesta gusta, no cuál es correcta.
🟢 Ya construiste las dos alternativas que este capítulo sugiere
El claim_verifier.py de tu
gate anti-alucinación es
exactamente un LLM-as-judge — un modelo evaluando la salida de otro contra un criterio preciso (¿esta
cita respalda el reclamo?) — con el agregado de que el veredicto del juez se verifica en código, no
se toma como palabra final. Y model_acceptance.py es tu set de evaluación custom: no un
benchmark genérico, sino un puñado de casos reales (el incidente Micron, dos casos limpios) que
importan específicamente para tu producto. El capítulo lo presenta como la mejor práctica; vos ya la
aplicás.
5. Errores comunes
Casi ninguno tira una excepción. El síntoma es un modelo que entrena "bien" según la loss y responde mal.
| Síntoma | Causa probable | Qué hacer |
|---|---|---|
| El modelo fine-tuneado responde peor que el instruct original, o "no se calla" y sigue generando turnos de usuario. | Se entrenó sin chat template (texto concatenado a mano) o con un template distinto al de inferencia; o los ejemplos no terminan con el token de fin de turno. | Dataset en formato messages y dejar que SFTTrainer aplique el template; verificar con apply_chat_template(tokenize=False) que el texto de entrenamiento y el de inferencia son idénticos (§1, §2). |
| La loss baja pero las respuestas no mejoran; el modelo aprendió a imitar el estilo de las preguntas. | Loss computada sobre toda la secuencia con prompts largos: la mayor parte del gradiente va al prompt. | assistant_only_loss=True (o el formato prompt/completion, que enmascara el prompt por defecto) (§2). |
Con packing=True el modelo mezcla información entre ejemplos o "continúa" una conversación con contenido de otra. | Los ejemplos no son independientes — partes de una misma conversación larga, o documentos troceados que dependen del contexto anterior — y packing los concatena y ordena arbitrariamente. | Packing solo con ejemplos autocontenidos. Si dependen entre sí, unirlos en un solo ejemplo (y subir max_length) o desactivar packing (§2). |
| El adapter LoRA entrena sin errores, la loss casi no se mueve, y el modelo fusionado es indistinguible del base. | Learning rate de fine-tuning completo (2e-5) aplicado a LoRA. | Subir a 1e-4–2e-4; comprobar que la loss cae claramente en los primeros cientos de pasos (§3). |
Error extraño en el forward al entrenar con peft (argumentos inesperados, labels ignorados), o un adapter que se guarda sin metadatos de tarea. | Falta task_type="CAUSAL_LM" en LoraConfig. | Ponerlo siempre; es lo que le dice a peft cómo envolver el modelo (§3). |
| Ejemplos truncados: el modelo aprende a cortar respuestas a mitad de frase. | max_length más corto que muchos ejemplos del dataset; la truncación corta el final, que es la respuesta. | Medir longitudes en tokens antes de entrenar (Capítulo 10, §6) y filtrar o subir max_length (§2). |
| Score altísimo en GSM8K después de SFT, malo en problemas nuevos. | Contaminación: el test set (o paráfrasis) estaba en el dataset de entrenamiento. | Chequeo de solapamiento de n-gramas contra el test antes de entrenar; mirar los detalles por ejemplo de lighteval (§4). |
6. Resumen
- Un chat template no es cosmético — define el contrato de formato entre vos y el modelo, distinto por familia. Mirá el string con
apply_chat_template(tokenize=False); los delimitadores son tokens especiales y el turno del asistente tiene que cerrar con el token de fin. - SFT es fine-tuning para seguir instrucciones en general, no para una sola tarea — el paso que convierte un modelo base en algo usable como asistente.
SFTTrainertoma una columnamessages, aplica el template solo, y conassistant_only_lossentrena solo sobre lo que dijo el asistente. El largo se fija conmax_length(antesmax_seq_length). - LoRA con
peft+SFTTrainerentrena un adapter chico — con learning rate de1e-4–2e-4, no el de fine-tuning completo — que podés servir separado o fusionar conmerge_and_unload(); fusionar solo funciona sobre pesos en punto flotante, no sobre pesos ya cuantizados. - Después de SFT viene la alineación por preferencias (DPO sobre pares chosen/rejected) o RL con reward functions (GRPO, Capítulo 12).
- Los benchmarks estandarizados sirven para comparar modelos entre sí, cada uno mide una cosa y cada uno tiene una trampa (casi siempre contaminación);
lightevallos corre con un comando. LLM-as-judge y evaluación custom son lo que realmente te dice si el modelo sirve para tu caso — y ya construiste ambos.
7. Ponete a prueba
Respondé antes de abrir cada respuesta. Si fallás una, volvé a la sección indicada.
1. ¿Qué diferencia hay entre llamar a apply_chat_template con add_generation_prompt=True y sin él, y en cuál de los dos casos lo usás para entrenar?
Con True agrega el encabezado vacío del turno del asistente al final, para que el modelo empiece a responder: es para inferencia. Para entrenar no se usa, porque el turno del asistente ya viene completo en el ejemplo y tiene que cerrar con el token de fin de turno. (§1)
2. Tu dataset tiene columnas pregunta y respuesta en texto plano. ¿Qué tenés que hacer antes de pasárselo a SFTTrainer?
Convertirlo a uno de los dos formatos que entiende: conversacional (una columna messages con la lista de {"role", "content"}) o estándar (prompt/completion). Con el conversacional el trainer aplica el chat template por vos — no formatees a mano. (§2)
3. ¿Qué pasa con la loss si tus prompts tienen 1.500 tokens de documento pegado y las respuestas 50, y no enmascarás el prompt?
El 97 % de los tokens sobre los que se calcula la loss son del prompt: el modelo gasta el gradiente en aprender a reproducir documentos, y la loss que ves casi no mide la calidad de las respuestas. assistant_only_loss=True (o prompt/completion) pone -100 en las etiquetas del prompt y computa solo sobre el asistente. (§2)
4. Copiás una config de fine-tuning completo (learning_rate=2e-5) y le agregás peft_config. La loss baja apenas. ¿Por qué?
LoRA necesita un learning rate unas diez veces mayor (1e-4–2e-4): las matrices del adapter arrancan de cero y con 2e-5 se mueven tan poco que el modelo fusionado es casi el base. (§3)
5. Después de merge_and_unload() y save_pretrained(), ¿podés recuperar el adapter desde el modelo fusionado? ¿Y qué archivo te olvidaste de guardar si el modelo fusionado responde raro en vLLM?
No: la fusión suma B·A a los pesos y el resultado no distingue qué vino de dónde; guardá el adapter aparte antes. Y el tokenizer con su chat template: sin él, vLLM aplica otro formato (o ninguno) y el modelo ve un contrato distinto del que aprendió. (§1, §3)
6. Un modelo pasa de 45 % a 80 % en GSM8K después de SFT con un dataset público de matemática. ¿Qué chequeás antes de festejar?
Contaminación: solapamiento de n-gramas entre tu dataset y el test de GSM8K (los datasets públicos de matemática suelen incluirlo o parafrasearlo). Y mirar los detalles por ejemplo de lighteval: si acierta problemas del test palabra por palabra y falla en problemas nuevos del mismo estilo, es memoria. (§4, Capítulo 10 §6)
8. Para practicar
✏️ Ejercicio 1 — Ver el chat template y sus tokens especiales
Sin GPU y sin bajar pesos (solo el tokenizer): compará el string que produce el template con y sin add_generation_prompt, y comprobá que los delimitadores son un solo token cada uno. Después probá con el tokenizer de otra familia (por ejemplo Qwen/Qwen2.5-0.5B-Instruct) y mirá qué cambia.
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("HuggingFaceTB/SmolLM2-135M-Instruct")
msgs = [{"role": "user", "content": "Hola"},
{"role": "assistant", "content": "Hola, ¿en qué te ayudo?"}]
entrenamiento = tok.apply_chat_template(msgs, tokenize=False)
inferencia = tok.apply_chat_template(msgs[:1], tokenize=False, add_generation_prompt=True)
print(repr(entrenamiento)); print(repr(inferencia))
ids = tok(entrenamiento, add_special_tokens=False)["input_ids"]
print([tok.decode([i]) for i in ids][:12]) # ¿"<|im_start|>" es UN token?
print(len(tok("<|im_start|>", add_special_tokens=False)["input_ids"])) # 1: lo reconoce dentro del texto
print(len(tok("<|im_start|", add_special_tokens=False)["input_ids"])) # un carácter menos: ya no es especial
Pista / qué esperar
El string de entrenamiento termina con <|im_end|>\n después de la respuesta; el de inferencia termina con <|im_start|>assistant\n y sin respuesta. Los delimitadores decodifican como un token único porque están registrados como tokens especiales, y el tokenizer los reconoce incluso dentro de un string; la variante con un carácter menos se parte en varios ids normales — es lo que le pasa al modelo cuando el formato concatenado a mano no coincide exactamente con el template.
✏️ Ejercicio 2 — SFT + LoRA en CPU con un modelo de 135M
Un fine-tuning mínimo, de verdad, sin GPU: diez ejemplos que enseñan un estilo (responder siempre con "Dato:" adelante), 30 pasos, LoRA sobre SmolLM2-135M-Instruct. Tarda pocos minutos en CPU. pip install trl peft datasets. Observá la loss caer y luego generá con el adapter.
from datasets import Dataset
from peft import LoraConfig
from trl import SFTTrainer, SFTConfig
pares = [("¿Capital de Francia?", "Dato: París."), ("¿Cuánto es 2+2?", "Dato: 4."),
("¿Color del cielo?", "Dato: azul."), ("¿Autor del Quijote?", "Dato: Cervantes."),
("¿Planeta más grande?", "Dato: Júpiter."), ("¿Idioma de Brasil?", "Dato: portugués."),
("¿Símbolo del oro?", "Dato: Au."), ("¿Año de la Revolución de Mayo?", "Dato: 1810."),
("¿Océano más grande?", "Dato: Pacífico."), ("¿Moneda de Japón?", "Dato: yen.")]
ds = Dataset.from_list([{"messages": [{"role": "user", "content": q}, {"role": "assistant", "content": a}]}
for q, a in pares])
trainer = SFTTrainer(
model="HuggingFaceTB/SmolLM2-135M-Instruct",
train_dataset=ds,
peft_config=LoraConfig(r=8, lora_alpha=16, target_modules="all-linear", task_type="CAUSAL_LM"),
args=SFTConfig(output_dir="mini-sft", max_length=128, learning_rate=2e-4,
max_steps=30, per_device_train_batch_size=2, logging_steps=5,
use_cpu=True, report_to="none"),
)
trainer.train()
trainer.save_model("mini-sft/adapter")
tok = trainer.processing_class
prompt = tok.apply_chat_template([{"role": "user", "content": "¿Capital de Italia?"}],
tokenize=False, add_generation_prompt=True)
out = trainer.model.generate(**tok(prompt, return_tensors="pt"), max_new_tokens=12, do_sample=False)
print(tok.decode(out[0], skip_special_tokens=True))
Pista / qué esperar
La loss tiene que caer de forma visible entre el paso 5 y el 30. La respuesta debería arrancar con "Dato:" — el modelo aprendió el formato con diez ejemplos (LIMA en miniatura); si además acierta "Roma", el conocimiento ya lo tenía. Variante con Gemma 4, corrida en la 5090: model="google/gemma-4-E2B-it", el regex de target_modules del callout de §3, bf16=True y sin use_cpu. 12,1M de parámetros entrenables; la loss baja de 4,26 a 0,27 en 30 pasos, que tardan 7 s (unos 20 s con la carga) y llegan a un pico de 20 GB de VRAM. Con el adapter responde Dato: Roma.; sin él (with model.disable_adapter():), La capital de Italia es **Roma**. El formato es lo único que cambió. Repetí con learning_rate=2e-5 y compará: es el error de §5 en vivo. Si use_cpu o processing_class no existen en tu versión de TRL, son no_cuda y tokenizer en versiones anteriores.
✏️ Ejercicio 3 — Fusionar el adapter y comprobar que no cambió nada
Sobre el adapter del ejercicio anterior: cargá base + adapter, fusioná, guardá, y verificá que el modelo fusionado (cargado como un checkpoint normal, sin peft) produce exactamente los mismos logits que base + adapter.
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
tok = AutoTokenizer.from_pretrained("HuggingFaceTB/SmolLM2-135M-Instruct")
base = AutoModelForCausalLM.from_pretrained("HuggingFaceTB/SmolLM2-135M-Instruct")
con_adapter = PeftModel.from_pretrained(base, "mini-sft/adapter")
x = tok("¿Capital de Italia?", return_tensors="pt")
with torch.no_grad():
logits_adapter = con_adapter(**x).logits.clone()
fusionado = con_adapter.merge_and_unload()
fusionado.save_pretrained("mini-sft/fusionado"); tok.save_pretrained("mini-sft/fusionado")
plano = AutoModelForCausalLM.from_pretrained("mini-sft/fusionado") # sin peft
with torch.no_grad():
logits_plano = plano(**x).logits
print("diferencia máxima:", (logits_adapter - logits_plano).abs().max().item())
print("parámetros:", sum(p.numel() for p in plano.parameters()))
Pista / qué esperar
La diferencia máxima tiene que ser del orden de 1e-5 o menor (ruido de punto flotante), y el conteo de parámetros del fusionado es el del modelo base: el adapter desapareció dentro de los pesos. Mirá el tamaño de mini-sft/adapter contra mini-sft/fusionado en disco.
9. Fuentes y para seguir
- LLM Course, Chapter 11 — Fine-tuning large language models — el capítulo original: chat templates, SFT, LoRA y evaluación con lighteval, con el ejemplo de SmolLM2.
- Transformers — Chat templates — cómo se escriben y aplican los templates,
add_generation_prompt, y cómo fijar uno en un modelo base. - TRL — SFTTrainer — los formatos de dataset aceptados,
max_length, packing,assistant_only_lossy la integración conpeft; es la referencia contra la que verificar cualquier nombre de parámetro. - LoRA: Low-Rank Adaptation of Large Language Models (arXiv 2106.09685) — el paper original: por qué una corrección de rango bajo alcanza, y de dónde sale el escalado por
alpha/r. - Direct Preference Optimization (arXiv 2305.18290) — el paso siguiente a SFT del callout de §3: optimizar preferencias sin reward model.
- lighteval — el evaluador de §4: cómo se nombran las tareas, qué backends soporta y cómo definir una tarea propia.