Fine-tuning de un chat.
Un modelo de lenguaje chico que aprendió a escribir como yo, a partir de un chat de WhatsApp. Entrenado en la nube por un par de dólares y ejecutado entero en el navegador.
La idea
Un modelo de lenguaje genérico contesta como un asistente: formal, largo, con signos de apertura y ganas de ayudar. Yo en un chat contesto corto, sin mayúsculas y con mis propias muletillas. El fine-tuning consiste en seguir entrenando un modelo que ya existe con ejemplos propios, para que aprenda ese estilo.
El resultado es un modelo que, dado un mensaje, responde como lo haría yo. La demo es privada: aprendió de conversaciones personales, así que no está publicada.
Cómo se hizo
- Exportar el chat. Desde WhatsApp, sin archivos adjuntos: queda un
.txtcon fecha, hora, autor y mensaje. - Limpiar. Fotos, audios y stickers se reemplazan por marcadores (
[foto],[audio]) en vez de borrarse, para que el modelo sepa que hubo algo. Se sacan llamadas, mensajes borrados y avisos del sistema. - Anonimizar. Links, emails, teléfonos, DNI, CUIT y CBU se reemplazan por etiquetas como
<numero>antes de entrenar. - Armar ejemplos. Se juntan los mensajes seguidos de una misma persona en un turno y se corta la charla cuando pasan más de 3 horas. Cada respuesta mía es un ejemplo: la entrada son hasta 8 turnos previos y la salida es lo que respondí.
- Filtrar. Solo quedan respuestas que llegaron en menos de 15 minutos. Las más lentas suelen reaccionar a algo que pasó fuera del chat, y el modelo aprendía a inventar.
- Entrenar. LoRA sobre Qwen2.5-Instruct en un training job de SageMaker con una GPU de 24 GB, que se apaga sola al terminar.
- Medir. Perplejidad sobre ejemplos que el modelo nunca vio, y un set fijo de 30 charlas para comparar versiones con los mismos parámetros.
- Exportar y servir. Se une LoRA al modelo, se convierte a GGUF, se cuantiza a 4 bits y se parte en pedazos de 256 MB. La página lo carga con llama.cpp compilado a WebAssembly.
Un ejemplo de entrenamiento
Cada ejemplo es una charla corta con la respuesta que el modelo tiene que aprender. Solo se calcula la pérdida sobre la respuesta, no sobre el contexto.
ya saliste?
te espero en la esquina
todavía no
en 10 salgo
[foto]
mirá lo que encontré
jajaja dónde estaba eso
ahí voy
Ejemplo inventado para ilustrar el formato. No se publica ningún mensaje real.
{"prompt": [
{"role": "system", "content": "Respondé como lo harías vos: mismo tono, largo y forma de escribir."},
{"role": "user", "content": "ya saliste?\nte espero en la esquina"},
{"role": "assistant", "content": "todavía no\nen 10 salgo"},
{"role": "user", "content": "[foto]\nmirá lo que encontré"}],
"completion": [
{"role": "assistant", "content": "jajaja dónde estaba eso\nahí voy"}]}
Entrenamiento
| Parámetro | Valor |
|---|---|
| Modelos base | Qwen2.5-0.5B-Instruct y Qwen2.5-1.5B-Instruct |
| Técnica | LoRA (r = 16, alpha = 32) en todas las proyecciones de atención y MLP |
| Épocas / learning rate | 2 / 2e-4 con cosine y warmup |
| Batch efectivo / largo máximo | 16 / 1024 tokens |
| Pérdida | Solo sobre la respuesta (completion_only_loss) |
| Hardware | SageMaker ml.g5.xlarge (A10G) y ml.g6.xlarge (L4) |
lora = LoraConfig(r=16, lora_alpha=32, lora_dropout=0.05, task_type="CAUSAL_LM",
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"])
cfg = SFTConfig(num_train_epochs=2, learning_rate=2e-4, lr_scheduler_type="cosine",
per_device_train_batch_size=8, gradient_accumulation_steps=2,
max_length=1024, bf16=True, completion_only_loss=True)
trainer = SFTTrainer(model=model, args=cfg, train_dataset=train, eval_dataset=eval,
processing_class=tokenizer, peft_config=lora)
with trainer.model.disable_adapter(): # misma métrica con el modelo base
base = trainer.evaluate()
trainer.train()
tuned = trainer.evaluate()
Resultados
La perplejidad mide qué tan sorprendido está el modelo con mis respuestas reales: más baja es mejor. Se calcula sobre 220 ejemplos que no se usaron para entrenar.
| Modelo | Base | Ajustado | Largo de respuesta | Tamaño |
|---|---|---|---|---|
| Qwen2.5-0.5B | 61,9 | 17,8 | 106 → 61 | ~400 MB |
| Qwen2.5-1.5B | 47,4 | 14,4 | 147 → 45 | ~1 GB |
Largo promedio en caracteres en el set de prueba. Mis respuestas reales promedian 51.
El cambio más visible es el tono: el modelo base contesta "¡Qué bueno! Espero que te vaya genial…", el ajustado contesta "oka / vamo". El de 1,5B además sigue el hilo de la charla. El de 0,5B copia bien el estilo, pero se va por las ramas.
Corre en el navegador
No hay ningún servidor de inferencia. El modelo cuantizado se descarga una vez, queda en la caché del navegador y se ejecuta en el dispositivo con wllama, que es llama.cpp compilado a WebAssembly. Los mensajes no salen del teléfono o la computadora, y el hosting es un sitio estático en S3 y CloudFront.
const wllama = new Wllama({ default: "./wllama/wasm/wllama.wasm" });
await wllama.loadModelFromUrl("model-00001-of-00002.gguf", { n_ctx: 1024, useCache: true });
await wllama.createChatCompletion({
messages: [{ role: "system", content: SYSTEM }, ...history.slice(-8)],
max_tokens: 120, temperature: 0.5, top_k: 40, penalty_repeat: 1.15,
stream: true, onData: chunk => render(chunk.choices[0].delta.content),
});
Para que use varios hilos, el sitio envía los headers Cross-Origin-Opener-Policy y Cross-Origin-Embedder-Policy.
Lo que aprendí
- Los datos pesan más que el modelo. Filtrar las respuestas lentas y conservar los marcadores de fotos mejoró más que cualquier ajuste del entrenamiento.
- Revisar ejemplos a mano. Los registros de llamadas ("Llamada. 16 min") se colaban como mensajes en el 10% de los ejemplos; solo se notó mirándolos.
- La temperatura importa. Con 0,8 un modelo de 0,5B divaga; con 0,5 y penalización de repetición se vuelve mucho más coherente sin reentrenar.
- El iPhone tiene poca memoria por pestaña. El modelo de 1 GB cerraba la pestaña al cargar. Partirlo en pedazos y ofrecer una versión de 0,5B para celular lo resolvió.
- Medir siempre igual. Un set fijo de 30 charlas con los mismos parámetros que la demo sirve más que juzgar por sensación.
Costos
Cada entrenamiento cuesta entre USD 0,40 y 1 de GPU, que se apaga sola al terminar. Los recursos de entrenamiento (bucket, rol y datos) se borran después de cada corrida. Lo único que queda es el sitio estático: centavos por mes.
Stack
Python · Hugging Face transformers, peft y trl · Amazon SageMaker · llama.cpp (GGUF, Q4_K_M) · wllama · S3 · CloudFront · Route 53