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.
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
Los modelos de transformers no leen texto — leen números. El primer trabajo es siempre de
un tokenizer, que convierte texto crudo en input_ids (y algunos metadatos
más), 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 con input_ids (los números) y attention_mask
(qué posiciones son texto real vs. relleno — sección 4). Ese diccionario se pasa directo al modelo:
from transformers import AutoModelForSequenceClassification
model = AutoModelForSequenceClassification.from_pretrained(checkpoint)
outputs = model(**inputs)
print(outputs.logits.shape) # torch.Size([2, 2])
pipeline() desarmadas: tokenizar, correr el cuerpo del modelo, proyectar con un head específico de la tarea.
El vector que sale del "cuerpo" del modelo (antes del head) es de alta dimensión —
[batch, secuencia, hidden_size], con hidden_size típicamente 768 en modelos
chicos y varios miles en modelos grandes. Ese vector por sí solo no significa nada legible; el
head (una o pocas capas lineales) lo proyecta a algo interpretable — en este caso, 2
números (uno por clase). Esos números son logits, no probabilidades: necesitan un softmax
para convertirse en algo que sume 1.
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.
🟢 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, tamaño oculto, cabezas de atención);
el archivo de pesos guarda los números entrenados en sí. Son dos cosas separadas a propósito: la config
te dice cómo construir el modelo vacío, los pesos te dicen qué valores ponerle adentro.
🟢 Actualización 2026
El original ya menciona .safetensors, pero vale la pena remarcar por qué importa hoy:
el formato viejo (pytorch_model.bin) usa pickle de Python, que puede
ejecutar código arbitrario al deserializarse. Un checkpoint .bin de una fuente no
confiable es, literalmente, un vector de ataque. safetensors guarda solo tensores planos
— no ejecuta nada al cargar. Herramientas de producción como vLLM hoy directamente esperan
safetensors; un repo que solo trae .bin es una señal de alerta, no un detalle
de formato.
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:
| Estrategia | Cómo corta | Problema |
|---|---|---|
| Por palabra | "Hola mundo".split() | Vocabulario enorme (500k+ palabras en inglés); "perro" y "perros" son tokens sin relación |
| Por carácter | H-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 |
En la práctica vas a usar siempre subpalabras: BPE a nivel de byte (GPT, Llama), WordPiece (BERT), o SentencePiece/Unigram (muchos modelos multilingües). El flujo de dos pasos es siempre el mismo:
tokens = tokenizer.tokenize("Using a Transformer network is simple")
# ['Using', 'a', 'transform', '##er', 'network', 'is', 'simple']
ids = tokenizer.convert_tokens_to_ids(tokens)
# [7993, 170, 11303, 1200, 2443, 1110, 3014]
Y decodificar es el camino inverso — de IDs a texto legible, reagrupando subpalabras en palabras completas.
🟢 Actualización 2026: chat templates
Lo que el original no cubre porque en 2022 casi no existía: los modelos de chat de hoy no reciben una
sola frase — reciben una conversación con roles (system,
user, assistant), y el tokenizer necesita saber cómo envolver eso en texto
plano antes de tokenizarlo. Esa receta vive en un chat template (un archivo .jinja
asociado al tokenizer), con tokens especiales propios de cada familia de modelos —
<start_of_turn> en Gemma, <|im_start|> en la convención ChatML.
Es la razón por la que nunca deberías armar el prompt de un modelo de chat concatenando strings a mano:
el chat template es parte del contrato del modelo, tanto como su vocabulario.
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 hasta igualar la más larga. Pero eso introduce un problema nuevo — la atención (Capítulo 1, §3) mira todos los tokens, incluido el relleno, y eso contamina el resultado. La solución al problema del padding es el attention mask: un tensor paralelo de 1s y 0s que le dice al modelo qué posiciones ignorar.
batched_ids = [
[200, 200, 200],
[200, 200, tokenizer.pad_token_id],
]
attention_mask = [
[1, 1, 1],
[1, 1, 0], # el modelo ignora esta posición
]
outputs = model(torch.tensor(batched_ids), attention_mask=torch.tensor(attention_mask))
Sin el attention_mask correcto, el resultado del elemento con padding sale distinto al de
procesarlo solo — un bug silencioso clásico, porque el código corre sin errores, solo da números
levemente equivocados.
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.
| Framework | Técnica clave | Mejor para |
|---|---|---|
| TGI | Flash Attention 2 + batching continuo | Producción enterprise (Kubernetes, Prometheus/Grafana de fábrica) |
| vLLM | PagedAttention | Máximo throughput, API compatible con OpenAI, control fino en Python |
| llama.cpp | Cuantización GGUF + kernels de CPU optimizados | Hardware de consumo, edge, cuando instalar un stack de Python es un problema |
La idea detrás de PagedAttention (vLLM) conecta directo con el KV cache del Capítulo 1: en vez de reservar un bloque contiguo de memoria del tamaño máximo posible para cada request (desperdicio brutal cuando la mayoría de las respuestas son más cortas que el máximo), vLLM divide el KV cache en páginas chicas de tamaño fijo — el mismo truco que usa la memoria virtual de un sistema operativo — y las reparte dinámicamente entre requests. El resultado documentado: hasta 24× más throughput que reservar memoria de forma ingenua.
# Levantar un servidor vLLM compatible con la API de OpenAI
python -m vllm.entrypoints.openai.api_server \
--model HuggingFaceTB/SmolLM2-360M-Instruct \
--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 tres 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.
6. Resumen
pipeline()es tokenizer + modelo + postprocesado — ahora sabés manejar cada pieza por separado.- 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.
- 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.
- Padding + attention mask son inseparables: sin la mask correcta, el padding contamina el resultado en silencio.
- En producción no corrés el modelo a mano — lo servís con TGI, vLLM o llama.cpp, cada uno optimizado para un escenario distinto.