Los artículos sobre ajustar un modelo pequeño suelen terminar en el mismo punto: la curva de pérdida se estabiliza, una generación de muestra se ve bien y el artículo concluye.
Ahí empieza el trabajo.
Ajusté un adaptador LoRA sobre Qwen/Qwen2.5-3B-Instruct para escribir reacciones breves y concretas a fotos en VIOFY. El entrenamiento fue la parte corta. Los tres fallos que más tiempo me costaron llegaron después y compartían algo que los hizo caros:
Ninguno lanzó una excepción. Cada uno producía una salida que parecía válida. Un prompt con formato incorrecto sigue generando texto. Una ruta de caché equivocada sigue resolviendo a un directorio. Un contenedor que no puede arrancar sigue informando de un estado.
Cómo está organizado el despliegue
Esta estructura explica por qué costaba ver los fallos.
image
→ general vision model (rented, general-purpose)
→ structured vision JSON (validated + normalized)
→ LoRA writer on serverless GPU
→ one short reaction
El adaptador nunca ve una foto. Recibe un resumen estructurado con la categoría, el foco emocional, los temas que debe evitar, una descripción de visión y un estado de ánimo. Devuelve una o dos frases. El modelo base y el adaptador se ejecutan en un contenedor con GPU bajo demanda. Una función en el servidor conserva las credenciales y hace la llamada.
El adaptador se entrena para una tarea muy concreta. Esa especialización es lo que busco y también lo que hizo invisible el primer error.
Fallo 1: el adaptador incluía un tokenizador sin plantilla de chat
El repositorio del adaptador contiene su propio tokenizer_config.json. No tiene la clave chat_template.
El worker cargaba el tokenizador desde ese repositorio, no encontraba una plantilla y recurría a un formato de texto plano:
System:
...
User:
...
Assistant:
Qwen2.5 se entrenó con ChatML:
<|im_start|>system
...<|im_end|>
<|im_start|>user
...<|im_end|>
<|im_start|>assistant
Cada petición llegaba al modelo con un formato que no había visto ni durante el preentrenamiento ni durante el ajuste. El adaptador recibía entradas fuera de distribución en todas las llamadas.
Aun así producía frases en inglés. Por eso tardé en encontrarlo.
Cuando supe qué buscar, los síntomas eran:
- reacciones que agotaban todo el presupuesto de
max_new_tokensen vez de detenerse, porque el modelo no emitía un token de parada que el sistema reconociera; - fragmentos del prompt del sistema en la salida del usuario, como
You need to write in natural American Englishdentro de una reacción; - marcadores de rol como
Assistant:filtrados en el texto del producto.
Yo los interpretaba como problemas de calidad del prompt. El problema estaba en el formato.
La solución y el criterio detrás
Hice dos cambios.
Primero, cargar siempre el tokenizador desde el modelo base y nunca desde el repositorio del adaptador:
tokenizer = AutoTokenizer.from_pretrained(
BASE_MODEL_ID, # Qwen/Qwen2.5-3B-Instruct
token=hf_token(),
cache_dir=MODEL_CACHE_DIR,
trust_remote_code=True,
)
ensure_chat_template(tokenizer)
El adaptador cambia los pesos. No cambia los delimitadores entre turnos. El modelo base define esos delimitadores y debe ser la fuente.
Segundo, impedir la alternativa que usaba texto plano:
def ensure_chat_template(tokenizer) -> None:
"""Fail fast if the tokenizer cannot produce Qwen ChatML."""
if not getattr(tokenizer, "chat_template", None):
raise RuntimeError(
f"Tokenizer for {BASE_MODEL_ID} has no chat_template. "
"Refusing to start: the LoRA requires Qwen ChatML formatting."
)
Un worker que no puede dar el formato correcto a los prompts no debería aceptar tareas. Negarse a arrancar es un fallo visible que cuesta un despliegue. Degradar el formato en silencio es un fallo invisible que cuesta semanas y llega a los usuarios.
Este error me dejó una regla:
Una alternativa solo es segura si el modo degradado sigue siendo correcto. Si produce un comportamiento incorrecto que parece válido, es un error con una política de reintentos.
El mismo criterio se aplica a los tokens de parada. La generación termina con un conjunto de identificadores EOS. El worker los resuelve de forma explícita y se niega a arrancar si el conjunto está vacío:
if not EOS_IDS:
raise RuntimeError(
"No usable EOS token ids resolved. Generation would never terminate."
)
Para este tokenizador, los identificadores resueltos son [151645, 151643]: <|im_end|> y el EOS del propio tokenizador. Un conjunto vacío haría que todas las peticiones llegaran al límite de tokens. Es mejor no arrancar.
Fallo 2: una dependencia sin versión fija eliminó el soporte de CUDA
La imagen base es pytorch/pytorch:2.5.1-cuda12.4-cudnn9-runtime. Ya incluye torch 2.5.1 compilado para CUDA 12.4.
requirements.txt enumeraba torch sin fijar una versión.
pip install -r requirements.txt hizo lo que le pedí: encontró el requisito de torch, resolvió la versión más reciente de PyPI y sustituyó la compilación enlazada con CUDA por un paquete genérico. Al mismo tiempo, transformers tampoco tenía una versión fija y pasó a una versión principal nueva. huggingface_hub avanzó más allá del intervalo que admite transformers.
La construcción de la imagen no falló. La imagen se generó correctamente. Después:
- los workers se caían al arrancar;
- el endpoint los marcaba como
unhealthy; - las peticiones quedaban en la cola sin un error atribuible a una petición concreta.
Las peticiones estaban bien. No había ningún proceso vivo que pudiera atenderlas.
La solución es tan corta que da vergüenza: fijar las dependencias y omitir torch a propósito.
# torch is intentionally ABSENT: the base image already provides torch 2.5.1
# built against CUDA 12.4. Listing it here makes pip replace that with a
# generic PyPI wheel and breaks GPU support.
runpod>=1.7,<2
transformers==4.57.1
peft==0.17.1
accelerate==1.10.1
bitsandbytes==0.48.1
# transformers 4.57 requires huggingface-hub >=0.34,<1.0
huggingface_hub>=0.34,<1.0
sentencepiece==0.2.2
protobuf>=4,<6
El comentario importa tanto como la versión. Si no explicas por qué está fijada, la siguiente persona que toque el archivo puede «limpiarla», incluido mi yo del futuro.
Como el fallo solo aparece al arrancar el contenedor en una GPU, conviene comprobar la resolución antes de reconstruir la imagen:
echo "torch==2.5.1" > /tmp/constraints.txt
uv pip compile requirements.txt --constraint /tmp/constraints.txt \
--python-version 3.11 --python-platform x86_64-unknown-linux-gnu \
-o /tmp/resolved.txt
grep -E '^(torch|transformers|huggingface-hub)==' /tmp/resolved.txt
En un contenedor con GPU, la imagen base forma parte del grafo de dependencias. Tratar requirements.txt como si contuviera todo es una forma de romper el enlace con CUDA sin verlo.
Fallo 3: dos fuentes de verdad para una carpeta de caché
El Dockerfile establecía HF_HOME, TRANSFORMERS_CACHE y HF_HUB_CACHE en rutas bajo /runpod-volume/....
El manejador tenía su propio valor predeterminado escrito en código.
Ambos definían la ubicación y no coincidían. Ganó el valor del manejador, así que las variables de entorno de la imagen se ignoraban en silencio. Además, el endpoint no tenía conectado ningún volumen de red, de modo que /runpod-volume nunca fue un volumen real. Había tres valores apuntando a una ruta inexistente, reemplazados por un cuarto que nadie había documentado.
Este fallo no provoca una caída. Descarga varios gigabytes de pesos en un directorio que no querías, en cada worker que arranca en frío, hasta llenar el disco.
Lo resolví eliminando configuración. Una función, un orden de prioridad y el código como único responsable:
def resolve_cache_dir() -> str:
"""Single source of truth for the Hugging Face cache location."""
return os.getenv("MODEL_CACHE_DIR", os.getenv("HF_HOME", DEFAULT_CACHE_DIR))
MODEL_CACHE_DIR → HF_HOME → /tmp/huggingface
El Dockerfile ya no establece ninguna de esas variables y explica por qué:
# Cache location is resolved at runtime by prompting.resolve_cache_dir():
# MODEL_CACHE_DIR -> HF_HOME -> /tmp/huggingface
# Not hardcoded here so the image works with or without a network volume.
Cuando dos capas pueden configurar el mismo valor, a menudo conviene quitarle esa capacidad a una de ellas en lugar de intentar que ambas coincidan.
La suposición más cara: cuantizar no siempre acelera
Publiqué primero NF4 de 4 bits porque un modelo de 3B en 4 bits parecía la opción eficiente.
En una GPU de 24 GB, bf16 era más rápido.
La cuantización cambia cómputo por memoria. Si el modelo ya cabe con comodidad en la VRAM, no ganas capacidad útil y pagas el coste de descuantizar en cada pasada. NF4 compensa cuando la memoria es el límite. Para un modelo de 3B en 24 GB, no lo era.
Por eso bf16 pasó a ser la opción predeterminada y los 4 bits quedaron detrás de USE_4BIT, desactivados salvo que alguna restricción los exija:
def use_4bit() -> bool:
return env_flag("USE_4BIT", False)
Este cambio y la reparación de la plantilla de chat modificaron la generación de forma apreciable. Antes, la ejecución rondaba los 8,4 segundos por petición, en gran parte porque llegaba al límite de tokens en vez de detenerse en <|im_end|>. El arranque en frío rondaba los 47 segundos antes de activar la opción de inicio rápido de la plataforma.
Mide la configuración predeterminada antes de optimizarla. Adopté «cuantizar modelos pequeños» como una creencia y no como una medición. Me costó latencia y un día de trabajo.
Haz que el worker registre lo que resolvió
Los tres fallos compartían otro detalle: el worker conocía la respuesta, pero no la mostraba. Sabía qué tokenizador había cargado, qué directorio de caché había elegido y con qué precisión estaba ejecutándose.
Ahora registra su configuración resuelta antes de aceptar una sola tarea:
HF token present: True
model cache dir: /tmp/huggingface
precision: bfloat16
emojis allowed: True
loading tokenizer from Qwen/Qwen2.5-3B-Instruct
stop token ids: [151645, 151643]
Seis líneas. Cada una habría permitido detectar alguno de esos errores en segundos en lugar de días.
Dos detalles:
- Registra los valores resueltos.
model cache dirse imprime desde la función que decide la ruta, después de aplicar el orden de prioridad. Repetir una variable de entorno en un registro solo dice qué pediste. Necesitas saber qué obtuviste. - Registra la presencia de un secreto, nunca el secreto.
HF token present: Trueresponde la pregunta necesaria al arrancar. El token no aparece en los registros, ni siquiera truncado.
Después del worker, la función que lo llama registra el proveedor, el modelo, la espera en cola y el tiempo de ejecución de cada llamada. Así puedo distinguir algo lento de algo roto: si aumenta la espera en cola, necesito más capacidad; si aumenta la ejecución, ha cambiado la generación.
Probar todo esto sin una GPU
Estos errores sobrevivieron tanto tiempo porque no tenía una forma barata de comprobarlos. Todo estaba en un archivo que importaba torch, así que el único entorno de prueba era un endpoint con GPU.
Separar el módulo lo resolvió. La construcción de prompts, la normalización de entradas y la limpieza de salidas viven en un archivo que no importa torch, peft, transformers ni el SDK de la plataforma. El archivo pesado solo conecta las piezas.
Ahora hay una prueba para la regresión que causó el P0:
def test_build_chat_prompt_refuses_plaintext_fallback(self):
"""The old code silently degraded here. It must now fail loudly."""
with self.assertRaises(RuntimeError):
build_chat_prompt(FakeTokenizer(chat_template=""), "anything")
def test_rendered_prompt_uses_qwen_chatml_markers(self):
prompt = build_chat_prompt(_BASE_TOKENIZER, "Task: react.")
self.assertIn("<|im_start|>system", prompt)
self.assertIn("<|im_end|>", prompt)
self.assertTrue(prompt.rstrip().endswith("<|im_start|>assistant"))
Unas pocas pruebas cargan el tokenizador base público real para verificar que existe la plantilla y que <|im_end|> corresponde a un identificador válido. Se omiten automáticamente si falta transformers o la máquina no tiene conexión. Las demás solo necesitan Python.
Muchas de las partes que fallan en un servicio de ML no necesitan una GPU. El formato del prompt, la validación de entradas, los límites numéricos, la limpieza de salidas y la resolución de configuración son software corriente. Se pueden probar en milisegundos si impides que las importaciones pesadas se filtren a esos módulos.
La lista que uso ahora
Antes de que un adaptador atienda una petición real:
- Carga el tokenizador desde el modelo base, no desde el repositorio del adaptador.
- Comprueba al arrancar que existe la plantilla de chat y rechaza el arranque si falta.
- Comprueba al arrancar que el conjunto de tokens de parada no está vacío.
- Genera un prompt y léelo. Confirma que los tokens especiales coinciden con los usados al entrenar el modelo.
- Fija las dependencias y documenta cuáles proporciona ya la imagen base.
- Resuelve las dependencias localmente antes de reconstruir la imagen.
- Asigna un único responsable a cada valor de configuración.
- Mide el rendimiento con precisión completa antes de activar la cuantización.
- Mantén la lógica de prompts y salidas en módulos sin importaciones pesadas y pruébalos.
- Registra qué proveedor, modelo y precisión atendieron cada petición; sin eso, no puedes observar lo anterior en producción.
El modelo fue la parte fácil. Se comportó tal como se había entrenado en cada petición, incluidas aquellas en las que yo le entregaba un formato que nunca había visto. No tenía cómo avisarme. Lo que hacía incorrectas esas peticiones estaba en el contenedor, en la resolución de dependencias y en las variables de entorno. Esa capa es donde estaban los fallos.
Un pequeño voto de confianza
¿Valió la pena leer esta nota?