Módulo 01 · Fundamentos

Tokenizadores y chat templates

El modelo nunca ve texto: ve enteros. Entre tu string y los token_ids hay dos piezas que deciden más de lo que parece — el tokenizador, que segmenta el texto en unidades del vocabulario, y el chat template, una función determinista que ensambla los mensajes en una única cadena antes de tokenizar. Si cualquiera de las dos no coincide con la que usó el modelo original, el fine-tuning aprende el formato equivocado y la inferencia se rompe de formas sutiles. Este módulo fija cómo funcionan y cómo inspeccionar exactamente qué recibe el modelo.

Al terminar sabrás

  • Explicar el byte-level BPE y por qué con él nunca hay OOV (out-of-vocabulary).
  • Leer la tokenización como un mapa $T:\text{string}\to(\mathrm{id}_1,\dots,\mathrm{id}_n)$ y medir su fertilidad.
  • Distinguir los tokens especiales (BOS/EOS/PAD, <|im_start|>/<|im_end|>) del texto real.
  • Aplicar un chat template con apply_chat_template y saber qué hace add_generation_prompt.
  • Relacionar el attention_mask con el padding y con qué posiciones cuentan en la loss.
  • Inspeccionar el texto final exacto que recibe el modelo antes de entrenar.

1Qué es tokenizar: del texto a los ids

Tokenizar es partir una cadena en un número finito de unidades y asignar a cada una un entero. Formalmente es un mapa determinista de texto a una secuencia de ids, cada uno dentro del vocabulario de tamaño $V$:

$$ T:\text{string}\to(\mathrm{id}_1,\dots,\mathrm{id}_n),\ \mathrm{id}_i\in\{0,\dots,V-1\} $$

Los modelos modernos (Qwen, Llama, GPT) usan byte-level BPE. La clave está en byte-level: antes de mirar caracteres, el texto se codifica en UTF-8, es decir en una secuencia de bytes, y el alfabeto base del tokenizador son los 256 bytes posibles. Como cualquier texto imaginable — un emoji, un ideograma, un símbolo matemático, un idioma que el modelo nunca vio — se reduce a esos 256 bytes, nunca hay OOV (out-of-vocabulary): en el peor caso una palabra rara se rompe en muchos bytes sueltos, pero siempre es representable. No existe el token "desconocido" que descarta información.

Nota · sin OOV

Los tokenizadores antiguos basados en palabras tenían un token especial <unk> para lo que no estaba en el vocabulario, y ahí se perdía información irreversiblemente. Byte-level BPE elimina ese problema de raíz: el vocabulario está cerrado bajo cualquier entrada. El costo es que lo raro consume más tokens (lo veremos en §3).

2BPE: tokenizar como segmentar

BPE (Byte-Pair Encoding) aprende un vocabulario por frecuencia. Se parte del alfabeto de 256 bytes y, sobre un corpus grande, se busca el par de símbolos adyacentes más frecuente y se fusiona en un nuevo símbolo. La regla de merge es simplemente:

$$ \arg\max_{(a,b)} \mathrm{count}(a,b) $$

Ese par ganador se añade al vocabulario como un token nuevo y se registra la fusión. El proceso se repite — recontando frecuencias tras cada fusión — hasta alcanzar el tamaño de vocabulario objetivo (por ejemplo $V \approx 150\,000$ en Qwen3). El resultado es una tabla de merges ordenada: fragmentos frecuentes como ▁the o ción acaban siendo un solo token, mientras lo raro queda en piezas pequeñas.

En inferencia el tokenizador no reaprende nada: aplica esa tabla de merges de forma determinista, pero no fusiona de izquierda a derecha, sino por prioridad (rango de merge): en cada paso busca, entre todos los pares adyacentes presentes, el que tiene el menor rango en la tabla (el que se aprendió antes, de mayor prioridad) y lo fusiona sin importar su posición en la cadena; repite hasta que no quede ningún par aplicable. Por eso tokenizar es, en la práctica, un problema de segmentación: dado el texto, ¿cuál es la partición en unidades del vocabulario que la tabla de merges produce? La segmentación es única y reproducible, lo que garantiza que el mismo string siempre dé los mismos ids.

"tokenización" → bytes UTF-8 → [ merges BPE ] → ▁token | ización  (2 tokens)
"漢字"          → bytes UTF-8 → [ merges BPE ] →  |   (o varios bytes: nunca OOV)
Lo frecuente colapsa en pocos tokens; lo raro se fragmenta, pero siempre es representable.

3Fertilidad y presupuesto de contexto

La fertilidad mide cuántos tokens consume el tokenizador por palabra de texto:

$$ \text{fertilidad} = \text{tokens}/\text{palabra} $$

Un tokenizador entrenado sobre todo en inglés suele tener fertilidad cercana a $1.3$ en inglés, pero puede dispararse a $2$–$3$ en otros idiomas, en código o en texto con muchos símbolos, porque las secuencias de bytes menos frecuentes no se fusionaron en tokens grandes. Esto tiene consecuencias directas y medibles:

  • Ventana de contexto: la longitud se cuenta en tokens, no en palabras. Con fertilidad $2.5$, un documento de 4000 palabras ocupa 10 000 tokens y puede no caber donde un texto en inglés equivalente sí cabría.
  • Costo y latencia: más tokens por el mismo contenido significa más cómputo por secuencia, tanto al entrenar como al servir en producción.
  • Presupuesto del dataset: antes de fine-tunear conviene medir la fertilidad real de tu corpus objetivo para estimar cuánto contexto vas a necesitar y cuántos ejemplos caben por batch.
Cuidado

Comparar "número de ejemplos" entre dos idiomas sin mirar la fertilidad engaña. Mide siempre tokens, no filas ni palabras: es la unidad que consume memoria, contexto y presupuesto de entrenamiento.

4Tokens especiales: la estructura invisible

Además de los tokens de texto, el vocabulario reserva unos pocos tokens especiales que no representan contenido sino estructura. Son parte del vocabulario (tienen su id) pero el usuario nunca los escribe: los inserta el tokenizador o la plantilla.

TokenQué marcaDónde aparece
BOS (begin of sequence)Inicio de la secuenciaAl principio, si el modelo lo usa
EOS (end of sequence)Fin de la generaciónSeñala al muestreo que pare
PAD (padding)Relleno para igualar longitudesEn batches, ignorado por la máscara
<|im_start|> / <|im_end|>Apertura y cierre de un turno de chatEnvuelven cada mensaje (formato ChatML)

Los marcadores de turno como <|im_start|> y <|im_end|> (convención ChatML) son los que permiten al modelo distinguir quién habla: system, user o assistant. El rol se escribe como texto justo después del <|im_start|>. Estos tokens se aprendieron durante el post-entrenamiento del modelo; usar otros distintos, o ninguno, es pedirle que interprete una estructura que no reconoce.

5El chat template: una función determinista

Un modelo de chat no recibe una lista de mensajes: recibe una única cadena de texto. El chat template es la función determinista que convierte los mensajes en esa cadena, antes de tokenizar:

$$ \mathrm{render}:\ \text{messages}\ \longrightarrow\ \text{string} \qquad\text{(luego)}\qquad T(\text{string})\to(\mathrm{id}_1,\dots,\mathrm{id}_n) $$

En la práctica esa función render está escrita como una plantilla Jinja empaquetada junto al tokenizador (campo chat_template). Recorre la lista de mensajes e inserta los marcadores de rol y turno correspondientes. Como es determinista, el mismo messages siempre produce exactamente el mismo string — condición indispensable para que entrenamiento e inferencia coincidan.

messages = [
  {"role": "system",    "content": "Eres un asistente conciso."},
  {"role": "user",      "content": "¿Qué es la fertilidad de un tokenizador?"},
  {"role": "assistant", "content": "Es el número de tokens por palabra."}
]

ids = tokenizer.apply_chat_template(messages, add_generation_prompt=True)
# apply_chat_template = render(messages) → string → tokenizar
# produce UNA secuencia de ids con marcadores especiales, p. ej.:
# <|im_start|>system\nEres un asistente conciso.<|im_end|>
# <|im_start|>user\n¿Qué es...?<|im_end|>
# <|im_start|>assistant\n            ← header abierto por add_generation_prompt

El parámetro add_generation_prompt=True añade al final el header del turno del assistant (<|im_start|>assistant\n) pero sin contenido ni <|im_end|>: deja la cadena "abierta" justo donde el modelo debe empezar a escribir. Es lo que quieres en inferencia, para que el modelo continúe en el rol correcto. En cambio, para construir un ejemplo de entrenamiento completo (donde la respuesta del assistant ya existe) normalmente lo pones en False, porque el turno ya está cerrado.

Nota · Qwen vs Gemma

Qwen usa ChatML: <|im_start|>role\n…<|im_end|> y no antepone BOS. Gemma usa otra convención: <start_of_turn>user\n…<end_of_turn>, roles distintos (model en vez de assistant) y sí antepone un token BOS. Los marcadores, los nombres de rol y el uso de BOS cambian entre familias: por eso jamás se escribe la plantilla a mano, se usa la que viene con el tokenizador.

6attention_mask, plantillas mal aplicadas y cómo inspeccionar

Al armar batches, las secuencias se rellenan con PAD hasta igualar longitudes. El attention_mask es un vector binario que marca qué posiciones son reales y cuáles relleno:

$$ a_i\in\{0,1\} $$

Las posiciones con padding ($a_i=0$) se ponen a $-\infty$ en los logits pre-softmax de la atención — de modo que ninguna posición real "atiende" al relleno. Es exactamente la misma idea de máscara que la causal del Módulo 0, aplicada aquí a las posiciones de relleno.

Nota · dos máscaras distintas

No las confundas: son dos tensores diferentes. El attention_mask (valores 0/1) gobierna la atención — le dice al modelo que no atienda al padding. El -100 (el ignore_index de la loss) va en las labels, no en el attention_mask, y gobierna la loss: marca qué posiciones no cuentan en el gradiente. Una vive en la atención; la otra, en el cálculo de la pérdida (lo verás en el Módulo 3). Juntas evitan que el padding contamine ni las activaciones ni el gradiente.

Por qué importa la plantilla correcta. Durante el fine-tuning el modelo no solo aprende qué responder, sino el formato en el que las respuestas aparecen: los marcadores de turno, los saltos de línea, dónde empieza el assistant. Si entrenas con una plantilla distinta de la que usarás en inferencia — o peor, concatenando los mensajes a mano sin marcadores — el modelo aprende una estructura que luego no se le presenta. El síntoma clásico: en producción el modelo alucina turnos, no para de generar (no emite EOS donde debía) o ignora el rol system. La causa casi nunca son los datos: es el desajuste de formato.

Cuidado

Un error frecuente es tokenizar dos veces los tokens especiales: si tu string ya contiene <|im_start|> como texto y además pides que el tokenizador añada especiales, terminas con marcadores duplicados o partidos. Regla práctica: deja que apply_chat_template haga todo el trabajo y no vuelvas a insertar especiales por tu cuenta.

Cómo inspeccionar lo que realmente recibe el modelo. Nunca confíes en tu modelo mental: imprime el texto final. Llama a apply_chat_template(..., tokenize=False) para ver la cadena renderizada carácter por carácter, y luego decodifica los ids con tokenizer.decode (o token a token con convert_ids_to_tokens) para confirmar dónde caen exactamente los marcadores. Verifica el round-trip: si decode(encode(x)) reproduce tu texto (salvo los tokens especiales añadidos), la tubería está sana. Esta inspección de cinco minutos evita horas de fine-tuning tirado.

En el teléfono

El tokenizador no vive solo en el entrenamiento: corre en el dispositivo justo antes de cada inferencia, porque el modelo on-device sigue viendo únicamente enteros. En apps on-device ese trabajo suele hacerlo la librería Rust tokenizers compilada para ARM, la misma que usas desde Python. El punto delicado es el chat template: si en el teléfono se aplica mal — otros marcadores, otro orden, un salto de línea de más — el modelo recibe un formato distinto al de entrenamiento y responde peor. Es un bug silencioso frecuente: nada falla en pantalla, solo baja la calidad.

Cómo practicar
  1. Instala el entorno: pip install transformers.
  2. Carga el tokenizer de Qwen3-0.6B: AutoTokenizer.from_pretrained("Qwen/Qwen3-0.6B").
  3. Renderiza la plantilla: aplica apply_chat_template a una conversación con y sin add_generation_prompt e imprime el string renderizado (tokenize=False) para verlos lado a lado.
  4. Verifica el round-trip: confirma que decode(encode(x)) == x salvo los tokens especiales añadidos.
  5. Mide la fertilidad (tokens/palabra) de tu texto objetivo frente a un baseline en inglés y compáralas.
Herramientas: Python · transformers · tokenizers
Rust · on-device

La librería tokenizers de Hugging Face está escrita en Rust: puedes cargar el tokenizer de Qwen y tokenizar sin tocar Python. Para el chat template en Rust se resuelve la misma plantilla Jinja con el crate minijinja, aplicándola a los mensajes antes de tokenizar. Esto es exactamente lo que corre dentro de una app: tokenización y render de plantilla nativos, sin intérprete de Python.

// tokenizers (Rust): cargar el tokenizer de Qwen y tokenizar
let tokenizer = Tokenizer::from_pretrained("Qwen/Qwen3-0.6B", None)?;
let enc = tokenizer.encode("Hola, mundo", true)?;   // ids del texto
let ids = enc.get_ids();                              // &[u32]
// el chat template se renderiza aparte con minijinja (plantilla Jinja)

Lecturas y recursos

Ejercicios

De menor a mayor complejidad. El último es el que hace un practicante de verdad.

Ejercicio 1 · calentamiento

Round-trip básico

Carga el tokenizer de Qwen3 y tokeniza tres frases (una con emoji o acentos). Decodifica los ids de vuelta e imprime el original y el reconstruido lado a lado.

Entrega: tabla frase → ids → texto decodificado.   Pista: tokenizer.encode(x) y tokenizer.decode(ids).

Ejercicio 2

No hay OOV

Tokeniza texto "imposible": un idioma exótico, símbolos matemáticos y bytes raros. Confirma que ninguna entrada produce un token desconocido y que todo se reconstruye tras decodificar.

Entrega: assert de que decode(encode(x)) == x para cada caso.   Pista: byte-level BPE parte de 256 bytes; observa cuántos tokens usa lo raro.

Ejercicio 3

Mide la fertilidad

Toma un párrafo en español y su traducción al inglés. Cuenta tokens y palabras en cada uno y calcula la fertilidad ($\text{tokens}/\text{palabra}$) de ambos.

Entrega: tabla idioma → palabras, tokens, fertilidad.   Pista: palabras por split(), tokens por len(encode(x)).

Ejercicio 4

Renderiza la plantilla

Con una conversación system/user/assistant, llama a apply_chat_template(messages, tokenize=False) con add_generation_prompt en True y en False. Imprime ambas cadenas y señala la diferencia exacta al final.

Entrega: las dos cadenas renderizadas con la diferencia marcada.   Pista: mira si termina en <|im_end|> o en <|im_start|>assistant\n.

Ejercicio 5 · ejercicio top

Volcado token-por-token y presupuesto de contexto

Toma una conversación multi-turno cruda, ejecuta tokenizer.apply_chat_template para Qwen3 con y sin add_generation_prompt, y produce un volcado token-por-token coloreado que muestre exactamente dónde caen <|im_start|>, los strings de rol, <|im_end|> y cualquier BOS/EOS; luego verifica el round-trip (decode(encode(x)) == x salvo tokens especiales) y calcula la fertilidad de tu dataset objetivo frente a un baseline en inglés para presupuestar la longitud de contexto.

Entrega: tabla de tokens anotada probando los límites de la plantilla + un test de round-trip por aserciones.   Pista: usa convert_ids_to_tokens para el volcado y tokenizer.all_special_tokens para filtrar especiales antes de comparar el round-trip.

Puntos clave
  • Byte-level BPE parte de 256 bytes: nunca hay OOV, lo raro solo cuesta más tokens.
  • Tokenizar es segmentar: el par más frecuente se fusiona ($\arg\max\,\mathrm{count}(a,b)$) hasta llenar el vocabulario.
  • La fertilidad ($\text{tokens}/\text{palabra}$) decide tu presupuesto de contexto — mide tokens, no palabras.
  • El chat template es render(messages)→string, determinista y en Jinja, aplicado antes de tokenizar.
  • add_generation_prompt abre el turno del assistant para inferencia; el attention_mask ($a_i\in\{0,1\}$) descarta el padding en atención y loss.
  • Una plantilla incorrecta enseña el formato equivocado: inspecciona siempre el texto final antes de entrenar.