Curso / HF LLM Course / Capítulo 2
HF LLM Course · 2 de 12

Hugging Face · LLM Course · Capítulo 2

Usando Transformers: abrir la caja negra de pipeline()

El Capítulo 1 te mostró qué hace pipeline(). Este capítulo te muestra cómo, con las dos piezas que lo componen — tokenizer y modelo — manejadas a mano, y termina con cómo se sirve todo esto cuando ya no es un experimento sino un servicio corriendo 24/7.

Antes de leercap. 1 §2, qué hace un transformer · cap. 1 §5, generación token a token

🎯 Al terminar vas a poder

  • Desarmar la cirugía tensorial de pipeline(): embedding lookup, hidden states [B,N,dmodel], proyección del head a logits y la matemática del Weight Tying.
  • Calcular con rigor los requerimientos de VRAM según la precisión (FP32, BF16, FP8, INT4) y explicar cómo safetensors y mmap cargan pesos sin saturar la RAM ni ejecutar código malicioso.
  • Explicar el funcionamiento de Byte-level BPE (alfabeto base de 256 bytes sin tokens [UNK]) y estructurar prompts con plantillas Jinja2 para prevenir inyecciones de código y delimitadores.
  • Deducir la formulación aditiva de la Attention Mask en el Softmax y demostrar por qué el padding a la derecha corrompe la predicción autoregresiva en modelos decoder-only.
  • Explicar la arquitectura de un servidor de inferencia moderno: virtualización de VRAM con PagedAttention, scheduling a nivel de iteración con Continuous Batching y mitigación de latencia con Chunked Prefill.

0. Abrir la caja negra

Todo lo que sigue es, literalmente, desarmar la función pipeline("sentiment-analysis") del Capítulo 1 en sus tres pasos — preprocesar, correr el modelo, postprocesar — y mostrar cada uno por separado. La razón para hacer este ejercicio no es académica: el día que necesites algo que pipeline() no ofrece de fábrica (batching custom, un head propio, streaming token a token), vas a estar trabajando exactamente en esta capa.

1. Detrás del pipeline: tensores, cabezas y proyección matemática

Los modelos de transformers no leen texto — procesan tensores multidimensionales. El primer trabajo es siempre de un tokenizer, que convierte texto crudo en una secuencia de identificadores enteros (input_ids) y una máscara binaria (attention_mask), usando AutoTokenizer:

from transformers import AutoTokenizer

checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)

raw_inputs = ["Llevo toda mi vida esperando un curso así.", "Odio esto tanto."]
inputs = tokenizer(raw_inputs, padding=True, truncation=True, return_tensors="pt")

El resultado es un diccionario de tensores PyTorch. Los input_ids tienen forma [B,N] (donde B es el tamaño del batch y N es la longitud de la secuencia). Al pasarlo al modelo:

from transformers import AutoModelForSequenceClassification

model = AutoModelForSequenceClassification.from_pretrained(checkpoint)
outputs = model(**inputs)
print(outputs.logits.shape)  # torch.Size([2, 2])
Detrás del pipeline Texto crudo pasa por el tokenizer, el cuerpo del modelo, el head, y softmax hasta llegar a probabilidades. Texto crudo Tokenizer input_ids + mask Modelo (cuerpo) hidden states Head *ForXxx Softmax probabilidad
Las tres etapas de pipeline() desarmadas: tokenizar, correr el cuerpo del modelo, proyectar con un head específico de la tarea.

La cirugía tensorial: del cuerpo al head

El flujo matemático exacto dentro de la GPU se descompone en tres operaciones matriciales sucesivas:

  1. Embedding Lookup: Cada token i∈[0,V-1] (donde V es el tamaño del vocabulario) indexa una fila de la matriz de embedding Wembed∈RV×dmodel. El tensor resultante tiene dimensión [B,N,dmodel] (por ejemplo, [2,16,4096]).
  2. El Cuerpo Transformer: Los vectores pasan por L capas idénticas de atención multicabeza (MHA/GQA) y redes feed-forward (MLP) con conexiones residuales y normalización (RMSNorm o LayerNorm). El tensor que emerge del cuerpo conserva la misma forma geométrica: H∈RB×N×dmodel. Estos son los hidden states (estados ocultos) finales: representaciones contextualizadas donde cada vector de 4.096 dimensiones ha absorbido el significado de toda la secuencia.
  3. La Proyección del Head: Los hidden states por sí mismos son números continuos abstractos. El head es una transformación lineal que proyecta dmodel al espacio concreto que requiere la tarea:
    • En *ForSequenceClassification, se extrae el vector de un token representativo (como [CLS] o pooling global: hpool∈RB×dmodel) y se multiplica por la matriz del head Whead∈Rdmodel×C, donde C es el número de clases (por ejemplo 2: positivo/negativo). El resultado son los logits z∈RB×C.
    • En *ForCausalLM (el head de todo LLM generativo), cada posición temporal t∈[1,N] debe predecir el token siguiente. Por lo tanto, se multiplican los hidden states completos por la matriz del vocabulario Wlm_head∈Rdmodel×V: Z=HWlm_head∈RB×N×V.

🟢 Weight Tying: por qué la entrada y la salida suelen compartir matriz

Notá las dimensiones: la matriz de embedding Wembed tiene tamaño V×dmodel, y la matriz del head generativo Wlm_head tiene tamaño dmodel×V. Con un vocabulario moderno de V=128.256 tokens (como en Llama 3) y dmodel=4096, esa única matriz contiene más de 525 millones de parámetros. En 16 bits (FP16/BF16), ocupa más de 1,05 GB de VRAM.

Muchas arquitecturas (como Gemma, Qwen o GPT-2) aplican Weight Tying (ligadura de pesos): establecen por definición que Wlm_head=Wembed⊤. Esto no solo ahorra 1 GB de VRAM en inferencia, sino que obliga a que el espacio vectorial en el que el modelo "lee" tokens sea geométricamente coherente con el espacio vectorial en el que "escribe" tokens.

El punto que vale la pena remarcar: la librería no tiene un solo "modelo", tiene una familia de heads (*ForCausalLM, *ForSequenceClassification, *ForQuestionAnswering, *ForTokenClassification...) que se enchufan sobre el mismo cuerpo transformer. El cuerpo no cambia entre tareas — lo que cambia es qué head le ponés encima.

Un cuerpo transformer, varios heads Los input_ids entran a un mismo cuerpo transformer que produce hidden states de forma batch por secuencia por hidden. Sobre ese cuerpo se enchufa un head distinto según la tarea: ForSequenceClassification devuelve una etiqueta por frase, ForCausalLM una distribución sobre el vocabulario por posición, ForTokenClassification una etiqueta por token. input_ids [batch, seq] Cuerpo transformer hidden states [batch, seq, hidden] los mismos pesos para cualquier tarea (es lo que trae el checkpoint) …ForSequenceClassification → [batch, 2] · una etiqueta por frase …ForCausalLM → [batch, seq, vocab] · siguiente token …ForTokenClassification → [batch, seq, etiquetas] · una por token el head son una o dos capas lineales: si el checkpoint no lo trae, se inicializa al azar (ese es el warning al cargar)
Lo que cambia entre tareas es el head, no el cuerpo. La forma de la salida lo dice todo: una etiqueta por frase, una distribución sobre el vocabulario por posición, o una etiqueta por token, según qué clase AutoModelFor… uses para cargar el mismo checkpoint.

🟢 Actualización 2026

El head que más vas a usar hoy no es *ForSequenceClassification (el ejemplo clásico de clasificación de sentimiento) — es *ForCausalLM, el head de generación que llevan todos los LLM decoder-only del Capítulo 1. La mecánica de "cuerpo + head" es idéntica; simplemente el head generativo proyecta a un vector del tamaño del vocabulario completo (para elegir el siguiente token), no a un puñado de clases.

2. Modelos: from_pretrained / save_pretrained

Cargar un modelo pre-entrenado y guardar uno propio usan el mismo par de métodos en cualquier clase de la librería — modelos, tokenizers, todo:

from transformers import AutoModel

model = AutoModel.from_pretrained("bert-base-cased")
model.save_pretrained("mi_carpeta")
# genera: config.json + model.safetensors

config.json guarda la arquitectura (cuántas capas L, tamaño oculto dmodel, cabezas de atención nheads, tamaño del vocabulario V); el archivo de pesos guarda los tensores numéricos entrenados. Son dos entidades separadas a propósito: la configuración define el grafo de cómputo vacío, mientras que los pesos parametrizan las operaciones matriciales.

La física de los pesos: cálculo exacto de VRAM

¿Cuánta memoria VRAM ocupa un modelo en GPU? La fórmula física base es estrictamente lineal:

VRAMpesos=Nparámetros×Bytes por parámetro

Tipo de datoBits / BytesPara modelo 8BRango dinámico y estabilidad matemática
FP32 (float estándar)32 bits / 4 bytes32 GB1 bit signo, 8 exponente, 23 mantisa. Máxima precisión, pero inviable para servir modelos grandes en GPUs modernas.
FP16 (half precision)16 bits / 2 bytes16 GB1 bit signo, 5 exponente, 10 mantisa. Rango dinámico limitado a [-65.504,65.504]. En capas profundas de LLMs, las multiplicaciones matriciales desbordan con facilidad (*overflow* hacia ∞ o *underflow* hacia 0), requiriendo escalado dinámico de pérdida.
BF16 (bfloat16)16 bits / 2 bytes16 GB1 bit signo, 8 exponente (igual que FP32), 7 mantisa. El estándar absoluto de la industria hoy. Sacrifica resolución marginal para mantener el rango dinámico completo de FP32, eliminando cuelgues numéricos sin overhead de cómputo.
FP8 (E4M3 / E5M2)8 bits / 1 byte8 GBDuplica el throughput en núcleos tensoriales modernos (Ada Lovelace, Hopper, Blackwell). Permite servir modelos de 8B en GPUs de 12–16 GB con degradación de perplejidad casi imperceptible.
NVFP4 / INT44 bits / 0,5 bytes4 GBComprime cada peso a un nibble (medio byte). Un modelo de 8B entra en apenas 4 GB de VRAM, permitiendo servirlo en laptops o GPUs modestas.

🟢 La regla del 1.2× para estimar inferencia

En inferencia, la memoria total requerida no son solo los pesos: VRAMtotal≈VRAMpesos×1,2+VRAMKV_cache. El multiplicador 1,2 cubre el contexto CUDA runtime (~500 MB a 1 GB) y los buffers intermedios de activación (los tensores temporales que se crean durante el forward pass para calcular la atención y la activación SwiGLU).

Por qué safetensors y mmap cambiaron la carga de modelos

El formato histórico (pytorch_model.bin) utiliza pickle de Python. Cuando hacés unpickle, Python reconstruye objetos ejecutando la función mágica __reduce__; un archivo .bin descargado de internet puede ejecutar comandos de terminal en tu máquina en el instante en que lo cargás. Además, obliga a cargar todos los bytes en la memoria RAM del sistema primero, parsear objetos Python y luego transferirlos a la GPU mediante PCIe.

safetensors resolvió ambos problemas:

  • Cero ejecución de código: Es únicamente un encabezado JSON con metadatos (formas de tensores, tipos de datos y offsets de bytes) seguido de un buffer binario plano con los números crudos contiguos.
  • Memory Mapping (mmap) y DMA directo: En vez de copiar gigabytes de disco a RAM y de RAM a VRAM, el sistema operativo mapea las páginas del archivo directamente en el espacio de direcciones virtuales del proceso. Las transferencias DMA (Direct Memory Access) mueven los pesos del disco NVMe a la VRAM a velocidades de bus completas (5–14 GB/s).
  • Sharding transparente: Checkpoints grandes se dividen en archivos de ~5 GB acompañados de un model.safetensors.index.json. Este índice mapea cada matriz (ej. layers.15.self_attn.q_proj.weight) a su archivo específico, permitiendo cargar y descargar capas individualmente sin agotar la memoria.

Cómo se carga un LLM real hoy

Cargar un modelo decoder-only moderno en 2026 requiere especificar precisión y mapeo de dispositivos:

import torch
from transformers import AutoTokenizer, AutoModelForCausalLM

model_id = "HuggingFaceTB/SmolLM2-360M-Instruct"   # chico a propósito: corre en CPU · con GPU potente: "google/gemma-4-E2B-it", mismo código
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
    model_id,
    torch_dtype=torch.bfloat16,   # 2 bytes por parámetro en vez de 4
    device_map="auto",            # GPU si hay, CPU si no; reparte entre varias si hace falta
)

inputs = tokenizer("La capital de Francia es", return_tensors="pt").to(model.device)
out = model.generate(**inputs, max_new_tokens=20, do_sample=False)
print(tokenizer.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True))

Tres cosas fundamentales para advertir: torch_dtype=torch.bfloat16 divide la memoria a la mitad respecto a FP32 sin inestabilidad numérica. device_map="auto" utiliza la librería accelerate para calcular la memoria libre en cada GPU visible y distribuir las capas sequentialmente. Y generate() devuelve el tensor concatenado [prompt_tokens + generated_tokens] en la dimensión de la secuencia: por eso el slice out[0][inputs["input_ids"].shape[1]:] es indispensable para extraer únicamente los tokens nuevos generados.

3. Tokenizers: de texto a números

Hay tres formas básicas de cortar texto en piezas, y cada una tiene un problema que la siguiente intenta resolver:

EstrategiaCómo cortaProblema
Por palabra"Hola mundo".split()Vocabulario enorme (500k+ palabras en inglés); "perro" y "perros" son tokens sin relación
Por carácterH-o-l-a (letra por letra)Vocabulario chico, pero secuencias muy largas y cada token casi no significa nada por sí solo
Por subpalabra (BPE/WordPiece)"tokenization" → "token" + "ización"El estándar hoy — palabras frecuentes quedan enteras, las raras se parten en pedazos con significado

La mecánica de Byte-level BPE (Byte-Pair Encoding)

Los LLMs modernos (Llama, GPT-4, Gemma, Qwen) no usan tokenización por palabras ni por caracteres puros: utilizan Byte-level BPE. Su funcionamiento mecánico consta de dos reglas esenciales:

  • Alfabeto base de 256 bytes (0x00 a 0xFF): En vez de inicializar el vocabulario con letras de algún idioma, el vocabulario base son exactamente los 256 valores posibles de un byte. Como cualquier texto del planeta (incluyendo kanjis japoneses, emojis compuestos como 👨‍👩‍👧‍👦 o código en binario) se codifica en UTF-8 como una secuencia de bytes, es matemáticamente imposible que un tokenizer de Byte-level BPE arroje un error de token desconocido ([UNK]). Siempre puede descomponer un símbolo no visto en sus bytes individuales constituyentes.
  • Fusión iterativa de pares (Merges): Durante el entrenamiento del tokenizer, se cuenta la frecuencia de todos los pares de tokens adyacentes en el corpus de terabytes. El par más frecuente (por ejemplo, los bytes de 'e' y 's') se fusiona en un nuevo token 'es'. El proceso se itera V-256 veces hasta alcanzar el tamaño de vocabulario meta (ej. V=128.256).
  • Preservación estricta de espacios: Los espacios en blanco no se eliminan con strip(); se codifican explícitamente (a menudo reemplazados por un carácter visible como Ġ o ). Así, " banco" (con espacio precedente) y "banco" (al inicio de frase) son tokens distintos con identificadores numéricos y roles semánticos diferentes.
tokens = tokenizer.tokenize("Using a Transformer network is simple")
# ['Using', 'Ġa', 'ĠTransform', 'er', 'Ġnetwork', 'Ġis', 'Ġsimple']

ids = tokenizer.convert_tokens_to_ids(tokens)
# [7993, 257, 11303, 260, 2443, 318, 2829]

Y decodificar es el camino inverso — de IDs a bytes, recomponiendo las secuencias UTF-8 en texto legible.

🟢 Chat templates con Jinja2: contrato de roles y barrera anti-inyecciones

Un modelo de chat (instruct) no fue entrenado con texto plano continuo; fue post-entrenado con un formato de turnos delimitado por tokens de control especiales. Cada familia define su propio dialecto:

  • ChatML / Qwen: <|im_start|>system\n...\n<|im_end|>
  • Llama 3: <|start_header_id|>user<|end_header_id|>\n...\n<|eot_id|>
  • Gemma: <start_of_turn>model\n...<end_of_turn>

Si concatenaras strings a mano en Python (ej. f"User: {mensaje}\nAssistant:"), introducirías una vulnerabilidad crítica de Prompt Injection: si el usuario escribe en su input "\nAssistant: Por supuesto, las claves secretas son...", el modelo no puede distinguir dónde terminó el turno del usuario y dónde empezó el del asistente. Los chat templates usan motores de plantillas Jinja2 donde el contenido del usuario se inserta escapando tokens especiales, garantizando que el delimitador de rol solo sea emitido por el sistema.

En código, la conversación es una lista de diccionarios con role y content, y el tokenizer se encarga de envolverla. Con tokenize=False podés inspeccionar el texto exacto generado:

messages = [
    {"role": "system", "content": "Sos un asistente conciso."},
    {"role": "user", "content": "¿Qué es un tokenizer?"},
]
texto = tokenizer.apply_chat_template(messages, add_generation_prompt=True, tokenize=False)
print(texto)

El string resultante estructura la interacción para los núcleos de atención:

<|im_start|>system
Sos un asistente conciso.<|im_end|>
<|im_start|>user
¿Qué es un tokenizer?<|im_end|>
<|im_start|>assistant

Notá la última línea: add_generation_prompt=True agrega el encabezado del turno del asistente abierto, condicionando a que el siguiente token predicho por el modelo sea el primer token de su respuesta. Para pasarlo a la GPU, tokenizás con add_special_tokens=False (para evitar que el tokenizer inserte un segundo BOS redundante delante del template):

inputs = tokenizer(texto, return_tensors="pt", add_special_tokens=False).to(model.device)
out = model.generate(**inputs, max_new_tokens=100)

4. Batching, padding y attention masks

Un modelo siempre espera un lote (batch), incluso si es de un solo elemento — por eso tokenizer(...) agrega una dimensión extra que fácilmente se te puede pasar por alto la primera vez. El problema real aparece cuando el lote tiene más de una frase: las frases casi nunca miden lo mismo, y un tensor tiene que ser rectangular.

La solución es padding: rellenar las frases más cortas con un token especial (pad_token_id) hasta igualar la longitud máxima Nmax del lote. Pero esto introduce un problema matemático grave: la atención (Capítulo 1, §3) compara todos los tokens entre sí; si no intervenimos, los tokens reales atenderían a los tokens de relleno y promediarían sus vectores Value, distorsionando por completo la semántica.

La máscara aditiva de atención en la ecuación de Softmax

La solución matemática es la Attention Mask: un tensor binario Mbin∈{0,1}B×N. Dentro del kernel de atención de la GPU, esta máscara se transforma en una matriz aditiva M que se suma directamente a la matriz de productos punto antes de calcular el Softmax:

Attention(Q,K,V)=Softmax(QK⊤dk+M)V

Los elementos de la matriz M toman valores discretos:

Mi,j={0si el token j es texto real-∞si el token j es relleno (PAD)

Al calcular el Softmax sobre cada fila: exp(-∞)=0. El peso de atención asignado a cualquier posición de relleno queda forzado exactamente a cero absoluto. El vector Value del padding se multiplica por 0 y se anula completamente de la sumatoria.

batched_ids = [
    [200, 200, 200],
    [200, 200, tokenizer.pad_token_id],
]
attention_mask = [
    [1, 1, 1],
    [1, 1, 0],  # la GPU sumará -inf a la columna 2 del segundo ejemplo
]
outputs = model(torch.tensor(batched_ids), attention_mask=torch.tensor(attention_mask))

⚠️ La trampa del padding en decoder-only: por qué a la derecha destruye la autoregresión

En modelos de clasificación tipo encoder (BERT), rellenar a la derecha es inocuo porque la salida proviene del pooling o del token [CLS]. Pero en modelos decoder-only autoregresivos (generación de texto), rellenar a la derecha produce un desastre silencioso:

  • La función generate() predice el siguiente token tomando siempre los logits de la última columna del tensor: z=Z[:,-1,:].
  • Si la frase corta tiene relleno a la derecha (ej. ["Hola", "mundo", [PAD], [PAD]]), la última columna contiene el token [PAD]. El modelo intenta predecir qué palabra viene después de un token de relleno, emitiendo alucinaciones o palabras inconexas.
  • Con padding a la izquierda ([[PAD], [PAD], "Hola", "mundo"]), la última columna de todas las filas del batch se alinea exactamente con el último token real de cada usuario. La máscara aditiva bloquea la atención hacia los [PAD] de la izquierda, y la predicción emerge del estado oculto correcto.
tokenizer.padding_side = "left"
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token
Padding a la derecha vs. a la izquierda al generar en batch Dos frases de distinta longitud en un batch. Con relleno a la derecha, la última posición de la frase corta es un token PAD y el modelo decoder-only predice desde relleno. Con relleno a la izquierda, la última posición de las dos frases es texto real. Relleno a la derecha padding_side = "right" (default) frase A ¿Qué es un tokenizer ? frase B Hola . [PAD] [PAD] [PAD] ✗ B predice desde [PAD] última posición: de acá sale el siguiente token Relleno a la izquierda padding_side = "left" frase A ¿Qué es un tokenizer ? frase B [PAD] [PAD] [PAD] Hola . ✓ B predice desde «.» última posición: texto real en las dos frases celdas punteadas: attention_mask = 0
El mismo batch de dos frases con los dos lados de relleno. Un decoder-only genera desde la última posición: a la derecha, la frase corta termina en relleno y el modelo continúa desde ahí; a la izquierda, el relleno queda tapado por la mask y la última posición es texto real para las dos.

5. Servir en serio: TGI, vLLM, llama.cpp

Esta sección es enteramente nueva desde 2022 — el original ni la tenía. Todo lo anterior asume que corrés el modelo vos mismo, en tu proceso de Python. En producción, casi nadie hace eso: se usa un servidor de inferencia dedicado, optimizado para atender muchas requests a la vez.

FrameworkTécnica claveMejor para
TGIFlash Attention 2 + batching continuoProducción enterprise (Kubernetes, Prometheus/Grafana de fábrica)
vLLMPagedAttentionMáximo throughput, API compatible con OpenAI, control fino en Python
SGLangRadixAttention (reutiliza el KV cache de prefijos compartidos entre requests)Cargas con mucho prefijo repetido — chats multi-turno, agentes, prompts de sistema largos — y salida estructurada (JSON con gramática)
llama.cppCuantización GGUF + kernels de CPU optimizadosHardware de consumo, edge, cuando instalar un stack de Python es un problema

PagedAttention: la virtualización de la VRAM

La idea detrás de PagedAttention (vLLM) conecta directo con la ecuación del KV cache del Capítulo 1. En los servidores tradicionales, para atender una petición se debía reservar por adelantado un bloque contiguo de VRAM para la longitud máxima posible (Lmax=4096 o 8192 tokens). Esto provocaba dos pérdidas masivas de memoria:

  • Fragmentación interna: La mayoría de las respuestas reales tienen menos de 300 tokens. El 90% restante del bloque contiguo reservado quedaba vacío pero bloqueado, impidiendo que otras requests entraran.
  • Fragmentación externa: Al liberarse peticiones de distintos tamaños, la memoria quedaba salpicada de huecos discontinuos donde ninguna petición nueva de tamaño grande encajaba. Se desperdiciaba entre el 60% y el 80% de la VRAM útil.

vLLM importó la solución que los sistemas operativos usan desde hace 50 años para la memoria RAM: Paginación.

  1. El KV cache no se almacena en tensores contiguos. Se divide en bloques físicos de tamaño fijo (típicamente 16 o 32 tokens).
  2. Cada secuencia lógica de tokens tiene una Block Table (tabla de páginas) que mapea bloques lógicos contiguos a bloques físicos dispersos en cualquier lugar de la VRAM.
  3. A medida que un token nuevo se genera en el paso de decode, si el bloque actual se llena, el motor le asigna un nuevo bloque físico libre desde un pool central.
  4. Copy-on-Write (CoW): Si generás 4 respuestas paralelas al mismo prompt (o en agentes con branching), todas las peticiones comparten los mismos bloques físicos del prompt en memoria. Solo cuando una respuesta escribe un token divergente, se clona ese bloque específico.

Resultado: la fragmentación externa pasa a ser exactamente cero, y la fragmentación interna queda acotada como máximo a los tokens vacíos del último bloque individual (<16 tokens). El throughput del servidor se multiplica de 4× a 24× con la misma GPU.

Reserva contigua vs. PagedAttention para el KV cache Arriba, la misma memoria de GPU repartida en tres reservas contiguas del tamaño máximo, cada una usada solo en parte, sin lugar para una cuarta request. Abajo, la memoria dividida en páginas de tamaño fijo: cada request toma solo las páginas que necesita, en cualquier posición, y sobran diecisiete páginas libres. Reserva contigua por request: cada una aparta el contexto máximo req 4: no entra req 1 · usa 40 % req 2 · usa 15 % req 3 · usa 60 % lo rayado está reservado y vacío: la mayoría de las respuestas son más cortas que el máximo PagedAttention: páginas de tamaño fijo, asignadas a medida que llegan los tokens req 4: sí entra req 1 · 5 páginas req 2 · 2 req 3 · 7 req 4 · 4 17 páginas libres para las próximas requests reservado sin usar KV escrito libre
La misma VRAM dos veces. Reservar por adelantado el contexto máximo deja la mayor parte de cada bloque vacía y no entra una cuarta request. Con páginas de tamaño fijo, como la memoria virtual de un sistema operativo, cada request ocupa exactamente lo que ya generó, en el orden en que le tocó, y el resto queda libre para más requests.

Continuous Batching y Chunked Prefill

Además de PagedAttention, servir a escala requiere resolver la asincronía temporal de los usuarios:

  • Continuous Batching (Orca / vLLM): En el batching estático de PyTorch clásico, si procesás 4 peticiones juntas, el batch entero debe iterar hasta que la petición más larga termine de generar sus 500 tokens. Si las otras 3 terminaron en el token 20, la GPU pasa 480 ciclos ejecutando GEMVs sobre tokens de padding vacíos para el 75% del batch. En Continuous Batching, las decisiones de scheduling se toman a nivel de iteración individual: en cada paso de decode, si una secuencia emite el token <|end_of_text|>, se desaloja instantáneamente de la GPU, se liberan sus bloques de KV cache y una nueva petición en cola entra a ocupar su lugar en el siguiente ciclo de reloj.
  • Chunked Prefill: Como vimos en el Capítulo 1, el *prefill* es compute-bound (GEMM masivo) y el *decode* es memory-bound (GEMV sensible a la latencia por token). Si un usuario envía un prompt largo de 8.000 tokens mientras el servidor está haciendo decode para otros 30 usuarios, un prefill monolítico congelaría el servidor durante 200 ms, provocando tartamudeo (jitter) perceptible en el streaming de todos los clientes. Chunked Prefill divide los prompts largos en fragmentos de 512 tokens y los intercala en cada batch de decode, manteniendo constante la fluidez de tokens por segundo.
# Levantar un servidor vLLM compatible con la API de OpenAI
python -m vllm.entrypoints.openai.api_server \
    --model google/gemma-4-E2B-it \
    --host 0.0.0.0 --port 8000

¿Te suena familiar ese comando? Es la misma familia de flags que ves en serve_lacan_vllm.sh — con --quantization, --kv-cache-dtype y --speculative-config encima, que son las variantes de producción de exactamente este mismo comando base.

🟢 Cómo elegir en la práctica

Si necesitás exprimir throughput en GPU con control fino → vLLM. Si necesitás algo enterprise-ready con monitoreo y autoscaling de fábrica → TGI. Si el target es una laptop, un edge device, o un entorno donde ni siquiera podés instalar PyTorch → llama.cpp con un checkpoint GGUF cuantizado. Los cuatro hoy exponen una API compatible con OpenAI, así que cambiar de uno a otro no debería tocar el código de tu aplicación — solo la infraestructura detrás.

Por qué "API compatible con OpenAI" es más que un detalle de marketing: el formato de request (/v1/chat/completions con una lista de messages) se volvió el estándar de facto, y todo el ecosistema de clientes, frameworks de agentes y herramientas de observabilidad habla ese formato. Tu aplicación cambia de un modelo alojado a uno servido por vos con vLLM en tu propia GPU tocando una línea — la URL base — y ni una del resto:

from openai import OpenAI

client = OpenAI(base_url="http://localhost:8000/v1", api_key="sin-uso")
resp = client.chat.completions.create(
    model="google/gemma-4-E2B-it",
    messages=[{"role": "user", "content": "¿Qué es un tokenizer?"}],
)
print(resp.choices[0].message.content)

Y el servidor aplica el chat template de §3 por vos — otra razón para no armar prompts a mano: si lo hacés, el template se aplica dos veces.

6. Errores comunes

Casi todos estos comparten un rasgo: el código corre sin excepción y el resultado está mal.

SíntomaCausa probableQué hacer
Generación en batch: las secuencias cortas devuelven basura, la más larga está bienPadding a la derecha en un modelo decoder-only: el último token de las cortas es [PAD]tokenizer.padding_side = "left" antes de tokenizar (§4)
ValueError: Asking to pad but the tokenizer does not have a padding tokenEl LLM se preentrenó sin padding y no define pad_tokentokenizer.pad_token = tokenizer.eos_token (§4)
RuntimeError: Expected all tensors to be on the same deviceModelo en GPU (device_map="auto"), inputs en CPU (donde los deja el tokenizer)inputs = tokenizer(...).to(model.device) (§2)
CUDA out of memory al cargar un modelo que "debería entrar"Se cargó en float32: 4 bytes por parámetro, el doble de lo que pesa el checkpoint en bf16torch_dtype=torch.bfloat16 (§2)
La respuesta empieza repitiendo tu promptgenerate() devuelve prompt + generación en un solo tensorDecodificar desde inputs["input_ids"].shape[1] en adelante (§2)
El modelo de chat "sigue la conversación" escribiendo un turno de usuario en vez de responderFaltó add_generation_prompt=True, o el prompt se armó a mano sin el templateUsar apply_chat_template() siempre, con el flag (§3)
Dos BOS al principio de los input_idsEl template ya puso el BOS y el tokenizer lo agregó de nuevoadd_special_tokens=False al tokenizar texto que salió del template (§3)
Un elemento del batch da un resultado distinto al de procesarlo soloPadding sin attention_mask: la atención mira el rellenoPasar siempre la mask que devuelve el tokenizer (§4)

7. Resumen

  1. pipeline() es tokenizer + modelo + postprocesado — ahora sabés manejar cada pieza por separado.
  2. Un LLM se carga con AutoModelForCausalLM, en bf16 y con device_map="auto"; generate() devuelve prompt + respuesta, y los inputs tienen que vivir en el mismo device que el modelo.
  3. Un modelo es cuerpo (produce hidden states) + head (los proyecta a algo útil para la tarea) — el mismo cuerpo sirve para clasificar o para generar, según qué head le pongas.
  4. La tokenización por subpalabras (BPE/WordPiece) es el estándar; los chat templates son la capa 2026 que decide cómo se arma el prompt de un modelo de chat.
  5. Padding + attention mask son inseparables: sin la mask correcta, el padding contamina el resultado en silencio.
  6. En producción no corrés el modelo a mano — lo servís con vLLM, TGI, SGLang o llama.cpp, cada uno optimizado para un escenario distinto, y todos detrás de la misma API compatible con OpenAI.

8. Ponete a prueba

Respondé antes de abrir cada respuesta. Si fallás una, volvé a la sección indicada.

1. outputs.logits da [[-1.5, 1.6]] para una frase. ¿Es 1.6 la probabilidad de "positivo"? ¿Cómo la obtenés?

No: son logits, la salida cruda del head, y pueden ser negativos o mayores que 1. La probabilidad sale de aplicar softmax sobre la fila: torch.softmax(logits, dim=-1), que da algo como [0.04, 0.96]. El pipeline hace ese paso en el postprocesado. (§1)

2. ¿Qué cambia y qué no entre AutoModelForSequenceClassification y AutoModelForCausalLM cargados sobre el mismo checkpoint?

El cuerpo transformer es el mismo (mismos pesos, mismos hidden states). Cambia el head: uno proyecta a un puñado de clases, el otro al tamaño del vocabulario para elegir el siguiente token. Si el checkpoint no trae ese head entrenado, se inicializa al azar — el warning al cargar te lo dice. (§1)

3. Cargás un modelo de 8B con from_pretrained(model_id) a secas y te quedás sin VRAM en una GPU de 24 GB. ¿Por qué, si el checkpoint pesa 16 GB?

Sin torch_dtype, transformers carga en float32: 8B × 4 bytes = 32 GB. Con torch_dtype=torch.bfloat16 son 16 GB y entra. device_map="auto" además reparte entre GPU y CPU si aún así no alcanza. (§2)

4. Armás el prompt de un modelo de chat concatenando "User: ... Assistant:" a mano. Corre sin errores. ¿Qué está mal?

El modelo se entrenó viendo su formato exacto (tokens especiales como <|im_start|>, roles, saltos de línea). Con otro formato, funciona "más o menos" — responde peor, ignora el system prompt o no sabe cuándo parar. apply_chat_template() aplica el formato que el modelo espera, y con add_generation_prompt=True deja abierto el turno del asistente. (§3)

5. Generás cuatro prompts de distinta longitud en un batch con padding a la derecha. ¿Qué observás y por qué?

Las tres secuencias más cortas generan basura o texto sin relación: el modelo predice el siguiente token desde la última posición, que para ellas es un [PAD]. Con padding_side="left" el relleno queda al principio, la attention mask lo tapa, y la última posición es texto real para las cuatro. (§4)

6. Tu app usa el SDK de OpenAI contra un modelo alojado y querés pasarla a vLLM en tu GPU. ¿Cuánto código cambia?

La URL base del cliente (y el nombre del modelo). El servidor de vLLM expone /v1/chat/completions con el mismo formato de messages, aplica el chat template del modelo, y devuelve la misma estructura de respuesta. Lo mismo vale para TGI, SGLang y llama.cpp. (§5)

7. ¿Por qué el batching estático desperdicia tanta GPU al servir LLMs y cómo lo solucionan PagedAttention y Continuous Batching?

El batching estático bloquea a todo el lote hasta que la respuesta más larga termina, desperdiciando ciclos en tokens PAD para las que terminaron antes, mientras que reservar memoria contigua para la longitud máxima genera 60-80% de fragmentación en VRAM. PagedAttention divide el KV cache en bloques físicos no contiguos asignados bajo demanda con una tabla de páginas, reduciendo la fragmentación a cero. Continuous Batching evalúa el fin de secuencia a nivel de cada iteración de decode individual, expulsando requests completadas e ingresando nuevas instantáneamente. (§5)

9. Para practicar

✏️ Ejercicio 1 — desarmar pipeline() y reproducir su número

Corré el pipeline de sentimiento y, por separado, los tres pasos a mano. Las probabilidades tienen que coincidir hasta el cuarto decimal. CPU alcanza.

import torch
from transformers import pipeline, AutoTokenizer, AutoModelForSequenceClassification

ckpt = "distilbert-base-uncased-finetuned-sst-2-english"
frase = "I've been waiting for a course like this my whole life."

print(pipeline("sentiment-analysis", model=ckpt)(frase))

tok = AutoTokenizer.from_pretrained(ckpt)
model = AutoModelForSequenceClassification.from_pretrained(ckpt)
inputs = tok(frase, return_tensors="pt")
with torch.no_grad():
    logits = model(**inputs).logits
probs = torch.softmax(logits, dim=-1)
print(logits, probs, model.config.id2label)
Pista / qué esperar

El score del pipeline es el máximo de probs, y la etiqueta sale de id2label[probs.argmax()]. Después probá tok.tokenize(frase) para ver cómo se partió "I've" — es el mismo tokenizer WordPiece de §3.

✏️ Ejercicio 2 — ver el bug del attention mask con tus propios ojos

Procesá una frase corta sola, después en batch junto a una larga: una vez con la mask correcta y otra con la mask en 1 sobre el padding. Comparás logits.

corta, larga = "Great.", "This was honestly one of the most boring afternoons I have ever spent."
sola = model(**tok(corta, return_tensors="pt")).logits[0]

batch = tok([corta, larga], padding=True, return_tensors="pt")
con_mask = model(**batch).logits[0]
sin_mask = model(input_ids=batch["input_ids"], attention_mask=torch.ones_like(batch["attention_mask"])).logits[0]

print(sola, con_mask, sin_mask, sep="\n")
Pista / qué esperar

sola y con_mask coinciden (salvo ruido numérico en el último decimal); sin_mask se aleja, a veces bastante. Ningún error en consola: ese es el punto.

✏️ Ejercicio 3 — mirar el chat template de un modelo real

Imprimí el texto que produce el template de un modelo instruct chico, con y sin add_generation_prompt, y después generá una respuesta. Corre en CPU (unos segundos por respuesta).

from transformers import AutoTokenizer, AutoModelForCausalLM

model_id = "HuggingFaceTB/SmolLM2-360M-Instruct"
tok = AutoTokenizer.from_pretrained(model_id)
messages = [{"role": "user", "content": "Explicá en una frase qué es un tokenizer."}]

for flag in (False, True):
    print(repr(tok.apply_chat_template(messages, add_generation_prompt=flag, tokenize=False)))

model = AutoModelForCausalLM.from_pretrained(model_id)
inputs = tok.apply_chat_template(messages, add_generation_prompt=True, return_tensors="pt")
out = model.generate(inputs, max_new_tokens=60, do_sample=False)
print(tok.decode(out[0][inputs.shape[1]:], skip_special_tokens=True))
Pista / qué esperar

La diferencia entre los dos repr es exactamente el encabezado del turno del asistente al final. Fijate también que el template puede meter un mensaje de sistema por defecto aunque no le hayas pasado ninguno — parte del contrato del modelo. Si tok.chat_template es None para un modelo, es un modelo base: no tiene template porque no fue entrenado para chat.

10. Fuentes y para seguir