← Todo el material
Curso Básico 23 de agosto de 2026

Fine-tuning — Módulo 3: chat templates, tokens especiales y loss masking

Tercer módulo del Curso de Fine-tuning práctico y el más técnico del bloque: cómo se convierte un JSONL en la secuencia de tokens que el modelo realmente ve. Chat templates de ChatML, Llama 3 y Qwen, apply_chat_template, el BOS duplicado, loss masking para entrenar solo sobre la respuesta, truncación, packing y cómo verificar todo decodificando un batch antes de entrenar.

☰ Índice del contenido

En el módulo 2 terminaste con un JSONL limpio, curado, balanceado y validado. Es un archivo hermoso y el modelo no lo va a leer nunca. Lo que el modelo ve es una secuencia plana de números enteros, y entre tu JSON y esos números hay una capa de traducción — la plantilla de chat, los tokens especiales y la máscara de pérdida — donde ocurre la mayoría de los fine-tunes que “corren perfecto y no aprenden nada”. El síntoma es siempre el mismo: el entrenamiento termina sin un solo error, la curva de pérdida baja bonito, guardas el adaptador, lo cargas, le preguntas algo y el modelo responde peor que antes o repite el prompt del usuario. Este es el módulo que evita eso.

La tesis del módulo

El entrenamiento no falla con un error: falla en silencio. Un chat template equivocado, un token de fin duplicado o una pérdida calculada sobre el prompt completo no rompen nada — producen un entrenamiento que corre, converge y aprende lo que no era. La única defensa es decodificar un batch real y leer con tus ojos qué está viendo el modelo y sobre qué tokens se está calculando el gradiente.

De JSON a tokens: las tres capas

        LO QUE ESCRIBISTE Y LO QUE EL MODELO VE

 ┌───────────────────────────────────────────┐
 │ 1. JSONL   {"messages":[{"role":"user"... │
 └────────────────────┬──────────────────────┘
                      │ apply_chat_template
                      ▼
 ┌───────────────────────────────────────────┐
 │ 2. TEXTO   <|im_start|>user\nHola...      │
 │            <|im_end|>\n<|im_start|>...    │
 └────────────────────┬──────────────────────┘
                      │ tokenizer
                      ▼
 ┌───────────────────────────────────────────┐
 │ 3. IDS     [151644, 872, 198, 39814, ...] │
 │    LABELS  [  -100, -100, -100,  39814...]│
 └────────────────────┬──────────────────────┘
                      │
                      ▼
                 gradiente

  El bug vive en las capas 2 y 3, y ninguna lanza error.

Las tres capas se pueden inspeccionar y hay que inspeccionarlas. La capa 2 se lee como texto; la capa 3 se lee comparando input_ids con labels. Diez minutos ahí ahorran un día de entrenamiento perdido.

Chat templates: cada familia tiene el suyo

Un chat template (glosar: la plantilla que convierte una lista de mensajes con roles en el string exacto de texto plano que ese modelo específico vio durante su instruction tuning, con sus tokens especiales de apertura y cierre de turno) no es un detalle cosmético. El modelo aprendió durante su entrenamiento que después de cierta secuencia exacta de tokens le toca hablar a él. Si usas otra, estás pidiéndole que generalice a un formato que nunca vio.

Tres familias que vas a encontrar en la práctica:

ChatML (Qwen, y muchos modelos derivados). Usa <|im_start|> y <|im_end|> — im por instant message:

<|im_start|>system
Eres un asistente conciso.<|im_end|>
<|im_start|>user
¿Cuál es la capital de Chile?<|im_end|>
<|im_start|>assistant
Santiago.<|im_end|>

Llama 3. Usa cabeceras de rol con <|start_header_id|> y cierra cada turno con <|eot_id|> (end of turn):

<|begin_of_text|><|start_header_id|>system<|end_header_id|>

Eres un asistente conciso.<|eot_id|><|start_header_id|>user<|end_header_id|>

¿Cuál es la capital de Chile?<|eot_id|><|start_header_id|>assistant<|end_header_id|>

Santiago.<|eot_id|>

Mistral / Mixtral (formato clásico). Usa [INST] y [/INST] sin token de rol para el asistente:

<s>[INST] ¿Cuál es la capital de Chile? [/INST] Santiago.</s>

Tres cosas que saltan a la vista comparándolos: los tokens especiales son distintos, los saltos de línea son distintos (Llama 3 mete dos después de la cabecera, y eso importa), y el manejo del rol system es distinto — Mistral clásico ni siquiera tiene uno.

Ojo: el template correcto no lo eliges tú, viene con el modelo. Está en el archivo tokenizer_config.json del repositorio en Hugging Face, en el campo chat_template, escrito en sintaxis Jinja. Nunca lo armes a mano por más simple que se vea: un salto de línea de diferencia es un token de diferencia, y el modelo lo nota.

apply_chat_template: la única forma correcta

"""Aplicar el chat template correcto. Esta es la única forma segura.

Regla: NUNCA concatenes strings a mano para armar la conversación.
El template vive en el tokenizer del modelo y lo aplica él.
"""
from transformers import AutoTokenizer

MODELO = "Qwen/Qwen2.5-7B-Instruct"
tok = AutoTokenizer.from_pretrained(MODELO)

mensajes = [
    {"role": "system", "content": "Eres un asistente conciso."},
    {"role": "user", "content": "¿Cuál es la capital de Chile?"},
    {"role": "assistant", "content": "Santiago."},
]

# --- Para ENTRENAR: la conversación COMPLETA, con la respuesta,
#     y sin prompt de generación (el turno ya está cerrado).
texto_entrenamiento = tok.apply_chat_template(
    mensajes,
    tokenize=False,              # queremos ver el string, no los ids
    add_generation_prompt=False, # False en entrenamiento: crítico
)
print(repr(texto_entrenamiento))

# --- Para INFERIR: solo hasta el user, y SÍ el prompt de generación,
#     que abre el turno del assistant para que el modelo lo continúe.
texto_inferencia = tok.apply_chat_template(
    mensajes[:-1],               # sin la respuesta del assistant
    tokenize=False,
    add_generation_prompt=True,  # True en inferencia: crítico
)
print(repr(texto_inferencia))

El parámetro add_generation_prompt es la fuente de un bug clásico y sutil. En inferencia debe ser True: agrega la apertura del turno del asistente (<|im_start|>assistant\n en ChatML) para que el modelo sepa que le toca. En entrenamiento debe ser False, porque la respuesta del asistente ya viene incluida en los mensajes y el template ya abre y cierra ese turno solo. Ponerlo en True durante el entrenamiento agrega una apertura de turno vacía al final, y el modelo aprende a producir un turno vacío después de responder.

Cuidado: algunos modelos base (no instruct) no traen chat_template en su tokenizer. Si aplicas apply_chat_template sobre uno de ellos, las versiones antiguas de transformers caían silenciosamente a una plantilla por defecto; las modernas lanzan un error. Verifica siempre con tok.chat_template is not None antes de asumir. Si vas a afinar un modelo base sin plantilla, tienes que definir una y usarla consistentemente en entrenamiento y en inferencia.

Tokens especiales y el BOS duplicado

Dos tokens que hay que tener claros:

  • BOS (glosar: Beginning Of Sequence; el token que marca el inicio de la secuencia — en Llama es <|begin_of_text|>, en muchos otros es <s>).
  • EOS (glosar: End Of Sequence; el token que marca el fin — es la señal con la que el modelo detiene la generación en inferencia).

El BOS duplicado es el bug de formato más frecuente y funciona así: apply_chat_template ya incluye el BOS en el string que devuelve. Si después tokenizas ese string con tokenizer(texto) usando el default add_special_tokens=True, el tokenizer agrega otro BOS al principio. El modelo entrena con dos BOS al inicio de cada secuencia, en inferencia recibe uno solo, y la distribución de entrada es distinta a la de entrenamiento.

"""El BOS duplicado: cómo detectarlo en treinta segundos."""
from transformers import AutoTokenizer

tok = AutoTokenizer.from_pretrained("meta-llama/Llama-3.1-8B-Instruct")
mensajes = [{"role": "user", "content": "hola"}]

texto = tok.apply_chat_template(mensajes, tokenize=False)

# MAL: el template ya trajo BOS y el tokenizer agrega otro.
ids_mal = tok(texto)["input_ids"]

# BIEN: el string ya viene con sus tokens especiales.
ids_bien = tok(texto, add_special_tokens=False)["input_ids"]

print("bos_token_id:", tok.bos_token_id)
print("mal :", ids_mal[:4], "->", tok.convert_ids_to_tokens(ids_mal[:4]))
print("bien:", ids_bien[:4], "->", tok.convert_ids_to_tokens(ids_bien[:4]))
# mal : [128000, 128000, 128006, 882]  <- BOS repetido
# bien: [128000, 128006, 882, 128007]

# Chequeo defensivo que vale la pena dejar en el código:
assert ids_bien[:2] != [tok.bos_token_id, tok.bos_token_id], "BOS duplicado"

La regla corta: si el texto salió de apply_chat_template, tokenízalo con add_special_tokens=False. Si en cambio le pasas tokenize=True a apply_chat_template, él se encarga y no hay problema.

El otro lado del mismo tema es el EOS faltante. Si tu secuencia de entrenamiento no termina con el token de fin de turno correcto, el modelo nunca aprende a parar. En producción el síntoma es inconfundible: responde bien y después sigue escribiendo, se inventa un turno del usuario, se responde solo, y sigue hasta agotar max_new_tokens. Los templates modernos cierran el turno solos, pero si armaste el formato a mano — o si el dataset trae respuestas con espacios en blanco al final que el template no limpia — revísalo.

Síntoma en inferenciaCausa probable en el formato
El modelo no para de escribirFalta EOS / EOT al final de la respuesta
Se inventa el turno del usuarioFalta EOS y entrenó sobre el prompt
Responde con un turno vacío al finaladd_generation_prompt=True al entrenar
Repite el prompt antes de responderSin loss masking: aprendió a generar el prompt
Ignora el system promptEntrenó sin system, o con uno distinto
Respuestas degeneradas o incoherentesChat template de otra familia
Cortes a mitad de respuestaTruncación por la derecha en el dataset

Loss masking: el corazón del módulo

Aquí está lo que más rinde de este curso completo.

Un modelo de lenguaje se entrena prediciendo el siguiente token: para cada posición, el modelo produce una distribución sobre el vocabulario, se compara con el token que efectivamente venía, y de esa comparación sale la pérdida (loss, glosar: el número que mide qué tan equivocada estuvo la predicción; el entrenamiento ajusta los pesos para bajarlo). Por defecto eso se hace sobre todas las posiciones de la secuencia.

Y ahí está el problema: en tu secuencia de entrenamiento, buena parte de los tokens son el system prompt y la pregunta del usuario. Si calculas la pérdida sobre todo, le estás enseñando al modelo a generar preguntas de usuario, que es exactamente lo que no quieres. El loss masking (glosar: “enmascarar la pérdida”; marcar las posiciones que no deben contribuir al gradiente, típicamente todo lo que no sea la respuesta del asistente) resuelve eso.

El mecanismo concreto en PyTorch y Hugging Face: la función de pérdida de clasificación (CrossEntropyLoss) tiene un parámetro ignore_index cuyo valor por defecto es -100. Cualquier posición del tensor labels que valga -100 se salta al calcular la pérdida. Enmascarar es, literalmente, poner -100 en las etiquetas del prompt.

   LOSS MASKING SOBRE LA SECUENCIA REAL (ChatML)

 tokens   <|im_start|> user \n ¿capital? <|im_end|> \n
 labels      -100      -100 -100  -100      -100   -100
                    ─────── PROMPT: sin gradiente ──────

 tokens   <|im_start|> assistant \n
 labels      -100        -100    -100
                  ─── apertura: sin gradiente ───

 tokens   Santiago . <|im_end|>
 labels   Santiago . <|im_end|>
          ─── RESPUESTA: aquí sí hay gradiente ───

  Solo los tokens de la respuesta empujan los pesos.
  El <|im_end|> final SÍ se entrena: así aprende a parar.

Fíjate en el último detalle del diagrama, que se pasa por alto seguido: el token de fin de turno tiene que estar dentro de la máscara entrenable. Si lo excluyes junto con el resto del andamiaje, el modelo nunca recibe gradiente sobre “aquí se termina” y en producción no para de escribir.

¿Cuánto importa en la práctica?

Depende de una sola razón: el ratio entre tokens de respuesta y tokens totales, el mismo ratio_respuesta que calculamos en el script de validación del módulo 2.

  • Si tus respuestas son el 60% de la secuencia (conversación equilibrada), entrenar sin máscara degrada, pero el modelo igual aprende algo porque la mayoría del gradiente viene de la respuesta.
  • Si tus respuestas son el 5% de la secuencia (system prompt largo + documento de contexto + una etiqueta de una palabra), entrenar sin máscara significa que el 95% del gradiente está enseñándole al modelo a generar system prompts y documentos. El fine-tune es casi puro ruido para tu objetivo.

Ese segundo caso es justamente el más común en producción — clasificación, extracción, respuestas cortas sobre contexto largo — y es la razón de que la falta de loss masking sea una de las causas más frecuentes de fine-tunes que no aprenden.

Ojo: hay un caso legítimo donde no enmascaras: cuando quieres que el modelo aprenda también la distribución de las entradas, por ejemplo en continued pretraining o cuando entrenas un modelo para completar documentos. Pero si tu objetivo es “responde así a esto”, enmascara. Si dudas, enmascara.

En código: assistant_only_loss

TRL resuelve el loss masking con dos flags de SFTConfig, según el formato del dataset: completion_only_loss (activo por defecto para datasets prompt/completion) y assistant_only_loss (para datasets conversacionales con messages). Hasta TRL 0.19 esto se hacía con un collator aparte (glosar: la función que toma varios ejemplos sueltos y arma el batch — es el punto donde se aplica el padding y donde se construyen las etiquetas), DataCollatorForCompletionOnlyLM, al que le indicabas el string que marca el inicio de la respuesta. Ese collator fue eliminado en TRL 0.20 (mediados de 2025) y todavía aparece en muchos tutoriales: si lo ves, es código viejo.

"""SFT con loss masking explícito usando TRL (>= 0.20).

La pieza clave es `assistant_only_loss=True`: TRL pone -100 en todo lo
que no sea un turno del assistant, de modo que la pérdida se calcule
solo sobre las respuestas — incluidos los <|im_end|> que las cierran.
"""
from datasets import load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer
from trl import SFTConfig, SFTTrainer

MODELO = "Qwen/Qwen2.5-7B-Instruct"

tok = AutoTokenizer.from_pretrained(MODELO)
modelo = AutoModelForCausalLM.from_pretrained(MODELO, device_map="auto")

# Formato conversacional: TRL aplica el chat template del tokenizer por
# su cuenta (con add_generation_prompt=False, como corresponde al entrenar).
datos = load_dataset("json", data_files="data/train.jsonl")["train"]

entrenador = SFTTrainer(
    model=modelo,
    train_dataset=datos,
    processing_class=tok,
    args=SFTConfig(
        output_dir="./salida",
        max_length=2048,             # ver la sección de truncación
        assistant_only_loss=True,    # aquí se activa el loss masking
        packing=False,               # empieza sin packing (ver abajo)
        num_train_epochs=2,
        per_device_train_batch_size=2,
        learning_rate=2e-4,
    ),
)
entrenador.train()

Dos advertencias sobre ese bloque, y las dos han costado entrenamientos completos:

Cuidado: assistant_only_loss=True exige que el chat template marque dónde empieza y termina cada turno del assistant con {% generation %} / {% endgeneration %}. TRL parcha automáticamente el template de las familias conocidas (Qwen3, entre otras); para el resto, si el template no trae esos marcadores, la máscara queda vacía y no hay ningún token entrenable. El camino robusto en ese caso es convertir el dataset al formato prompt/completion, donde completion_only_loss funciona sin depender del template.

Cuidado: en el collator histórico, el response_template se comparaba a nivel de tokens, y un espacio de más lo dejaba sin encontrar: el ejemplo entero quedaba enmascarado y la pérdida no se movía. Con assistant_only_loss ese bug desaparece, pero la verificación de la sección siguiente sigue siendo obligatoria: si “lo que entrena” sale vacío o incluye el prompt, algo del template no calzó.

Para conversaciones de varios turnos no hay que configurar nada más: assistant_only_loss enmascara todos los turnos del usuario y entrena todas las respuestas del assistant, de modo que en un diálogo de cinco turnos se entrenan las cinco respuestas. Con el collator histórico eso exigía además un instruction_template; sin él, todo lo posterior al primer marcador se entrenaba, turnos del usuario incluidos.

La verificación que no te puedes saltar

Todo lo anterior se comprueba en un minuto. Este es el bloque que hay que correr antes de cada train():

"""Inspección de un ejemplo real ANTES de entrenar.

Responde tres preguntas con evidencia, no con fe:
 1. ¿El texto que ve el modelo tiene el formato correcto?
 2. ¿Sobre qué tokens se está calculando la pérdida?
 3. ¿Hay algún ejemplo completamente enmascarado?

TRL deja el dataset tokenizado en `entrenador.train_dataset`, con la
columna `labels` ya construida (-100 donde no hay gradiente).
"""
import torch

ejemplo = entrenador.train_dataset[0]
ids = torch.tensor(ejemplo["input_ids"])
labels = torch.tensor(ejemplo["labels"])

# 1) La secuencia completa, tal cual la ve el modelo.
print("=== SECUENCIA COMPLETA ===")
print(tok.decode(ids, skip_special_tokens=False))

# 2) Solo lo entrenable: los labels distintos de -100.
entrenables = ids[labels != -100]
print("\n=== SOLO LO QUE ENTRENA (debe ser la respuesta) ===")
print(tok.decode(entrenables, skip_special_tokens=False))

# 3) Proporción y sanidad. Un 0% significa que la máscara quedó vacía:
#    el template no trae los marcadores y el entrenamiento sería inútil.
n_entrenables = int((labels != -100).sum())
print(f"\nentrenables: {n_entrenables}/{len(ids)} "
      f"({n_entrenables / len(ids):.1%})")
assert n_entrenables > 0, "Ningún token entrenable: revisa el chat template"

# 4) Ningún ejemplo puede quedar 100% enmascarado (muestra los primeros 200).
for i in range(min(len(entrenador.train_dataset), 200)):
    fila = entrenador.train_dataset[i]["labels"]
    assert any(x != -100 for x in fila), f"Ejemplo {i} completamente enmascarado"

# 5) ¿La respuesta termina con el token de fin de turno?
#    Si no, el modelo no aprende a parar.
print("\núltimos tokens entrenables:",
      tok.convert_ids_to_tokens(entrenables[-3:].tolist()))

El punto 2 es el que se lee con los ojos y el que descubre casi todo. Si en “SOLO LO QUE ENTRENA” aparece el system prompt, la máscara no está funcionando. Si aparece la pregunta del usuario, tampoco. Si aparece vacío, el template no trae los marcadores {% generation %}. Y si aparece exactamente tu respuesta terminada en <|im_end|>, puedes entrenar tranquilo.

Truncación: dónde se corta y por qué importa

max_seq_length define el largo máximo de la secuencia; lo que sobra se corta. La decisión relevante es de qué lado.

La truncación por defecto es por la derecha, y en un dataset de fine-tuning eso es casi siempre lo peor posible: corta el final de la respuesta del asistente, que es justo lo único que estás entrenando. El resultado es un modelo entrenado con miles de respuestas incompletas, que en producción corta a mitad de frase.

Tres formas de manejarlo, en orden de preferencia:

  1. Sube max_seq_length hasta cubrir el p99 de tus longitudes (el número que calculó el script del módulo 2). Es la solución correcta si la VRAM alcanza. El costo en memoria de las activaciones crece con la longitud, así que esto tiene un techo — tema del módulo 4.
  2. Filtra los ejemplos que exceden el límite. Perder el 1% de los ejemplos es mucho mejor que entrenar con el 1% truncado, porque el ejemplo truncado enseña activamente a cortar.
  3. Recorta el contexto por la izquierda en el preprocesamiento, si el largo viene de la entrada y no de la respuesta. TRL solo conserva el inicio de la secuencia (truncation_mode="keep_start"; la opción "keep_end" está deprecada), así que el recorte por la izquierda lo haces tú sobre el dataset — acortando el documento de contexto — antes de entregárselo al trainer. Sacrificas el principio del contexto, que suele ser lo menos crítico, y conservas la respuesta completa.

Lo que nunca corresponde es dejar la truncación por defecto sin haber mirado el percentil 99 de tus longitudes.

Packing: eficiencia con letra chica

El packing (glosar: concatenar varios ejemplos cortos en una sola secuencia del largo máximo, para no desperdiciar cómputo en tokens de relleno) resuelve un problema real: si tu max_seq_length es 2048 y tus ejemplos promedian 300 tokens, el 85% de cada batch es padding y estás pagando GPU por procesar relleno.

Sin packingCon packing
Eficiencia de cómputoBaja si los ejemplos son cortosAlta, casi sin padding
Loss masking por respuestaDirecto con assistant_only_lossVerifica que la máscara sobreviva al packing en tu versión
Contaminación entre ejemplosImposiblePosible sin máscara de límites
Cuándo convieneDatasets chicos, ejemplos largosDatasets grandes, ejemplos cortos
Recomendación del bloqueEmpieza aquíCuando el costo lo justifique

El riesgo específico del packing es la contaminación entre ejemplos: si dos conversaciones distintas quedan en la misma secuencia y no hay una máscara de atención que las separe, los tokens de la segunda pueden atender a los de la primera. El modelo aprende correlaciones entre ejemplos que no tienen ninguna relación. Las implementaciones modernas ofrecen máscaras que respetan los límites, pero es una capacidad que hay que verificar en la versión que estás usando, no asumir.

Para un primer fine-tuning: packing=False. Es más lento y es correcto. La optimización viene después de que el modelo funcione.

Errores comunes

  • Armar el formato de chat concatenando strings a mano. Un salto de línea de diferencia con el template oficial es un token de diferencia, y el modelo entrena sobre una distribución que no es la suya. Usa apply_chat_template siempre.
  • Entrenar sin loss masking. Con respuestas cortas sobre contexto largo, el 90% o más del gradiente está enseñándole al modelo a generar prompts de usuario. Es la causa número uno de fine-tunes que corren perfecto y no sirven.
  • BOS duplicado. apply_chat_template ya incluye los tokens especiales; volver a tokenizar con add_special_tokens=True agrega otro. Entrenas con dos e infieres con uno.
  • add_generation_prompt=True en entrenamiento. Agrega una apertura de turno vacía al final de cada ejemplo y el modelo aprende a producirla. Es True solo en inferencia.
  • Chat template sin los marcadores {% generation %}. assistant_only_loss no tiene dónde apoyarse, la máscara queda vacía y no hay nada que entrenar. Verifica decodificando el ejemplo, o pasa el dataset a prompt/completion.
  • Copiar código con DataCollatorForCompletionOnlyLM o max_seq_length. Es la API de TRL anterior a la 0.20: el collator ya no existe y el campo se llama max_length. Si el tutorial los usa, es código viejo y va a fallar al importar.
  • Dejar la truncación por la derecha sin mirar las longitudes. Corta el final de la respuesta, que es exactamente lo que estás entrenando, y el modelo aprende a truncarse.
  • Entrenar sin decodificar un batch primero. Es un minuto de trabajo que verifica las tres capas de una vez. Saltárselo es apostar horas de GPU a que todos los defaults calzaron.
  • Usar el chat template de otro modelo. Un template de Llama sobre un Qwen produce respuestas degeneradas, y como el entrenamiento no falla, el diagnóstico aparece recién al probar el modelo terminado.

La frase para llevarse

“Si no decodificaste un batch, no sabes qué estás entrenando. La loss baja igual cuando el modelo está aprendiendo a escribir tus propias preguntas.”

Qué viene

Módulo 4: elegir el modelo base y el hardware, el cierre del bloque básico. Base o instruct y cuándo cada uno, qué tamaño pide realmente tu tarea, y el asunto de las licencias, que decide si puedes usar el resultado comercialmente. Después, la cuenta de VRAM completa y explícita — pesos, gradientes, estados del optimizador y activaciones — con la fórmula y un ejemplo numérico paso a paso, más la tabla de qué cabe en qué GPU para full fine-tuning, LoRA y QLoRA. Y dónde entrenar en la práctica: tu máquina, Colab, RunPod o Vast, Vertex AI o SageMaker, con órdenes de magnitud de costo por hora.

Material relacionado

Fine-tuningChat templateLoss maskingTokenizaciónTRL

Preguntas sobre este curso

Regístrate gratis para hacer preguntas y guardar tu progreso.

✉ Entérate de lo nuevo

Recibe un aviso cuando publique nuevos cursos, workshops o investigaciones. Sin spam.