DEV Community

Cover image for Cómo construir un LLM desde cero: guía y aprendizaje
Roobia
Roobia

Posted on • Originally published at apidog.com

Cómo construir un LLM desde cero: guía y aprendizaje

TL;DR

Construir un modelo de lenguaje mínimo desde cero requiere menos de 300 líneas de Python. El proceso revela exactamente cómo funcionan la tokenización, la atención y la inferencia, lo que te convierte en un consumidor de API mucho mejor cuando integras LLMs de producción en tus aplicaciones.

Prueba Apidog hoy

Introducción

La mayoría de los desarrolladores tratan los modelos de lenguaje como cajas negras: envías texto, salen tokens y, en algún punto intermedio, ocurre la magia. Este enfoque es suficiente hasta que necesitas depurar una integración de API defectuosa, ajustar parámetros de muestreo o entender por qué tu modelo sigue alucinando datos estructurados.

GuppyLM, un proyecto que destacó en HackerNews, hace visibles los internos de los LLM. Es un transformador de 8.7M de parámetros escrito desde cero en Python, entrenable en menos de una hora en una GPU de consumo, y cuyo código cabe en un solo archivo. Su objetivo no es competir con GPT-4, sino desmitificar cómo funcionan realmente los LLM.

En este artículo verás cómo construir un pequeño LLM, qué hace cada componente y qué te aporta comprender los detalles internos cuando trabajas con APIs de IA.

💡 Si estás probando integraciones de API de IA, los Escenarios de prueba de Apidog te permiten verificar respuestas en streaming, afirmar la estructura de los tokens y simular finalizaciones de casos extremos sin consumir créditos de producción. Más sobre esto más adelante.

¿Qué hace que un modelo de lenguaje sea "pequeño"?

Un LLM de producción como GPT-4 tiene cientos de miles de millones de parámetros. Un LLM "pequeño" trabaja en el rango de 1M a 25M de parámetros. Ejemplos: GuppyLM (8.7M), nanoGPT de Karpathy (124M), MicroLM (1-2M).

Ventajas de los LLM pequeños:

  • Se entrenan en un portátil o Google Colab.
  • Caben en la memoria de la CPU.
  • Se pueden inspeccionar, modificar y depurar a nivel de pesos.

Limitaciones:

  • No manejan razonamientos complejos.
  • No generan texto coherente de formato largo de manera fiable.
  • No igualan la profundidad factual de los modelos de producción.

El valor está en comprender su funcionamiento, no en el resultado final.

Componentes principales: cómo funciona realmente un LLM

Antes de escribir código, debes conocer estos cuatro componentes principales:

Tokenizador

Convierte texto sin procesar a IDs de enteros. Por ejemplo, "Hello, world!" se convierte en [15496, 11, 995, 0]. Cada entero representa una subpalabra de un vocabulario fijo.

Relevancia en APIs: el conteo de tokens afecta latencia y costo. Entender cómo los tokenizadores dividen el texto ayuda a escribir prompts que se ajusten a la ventana de contexto y evitar truncamientos.

GuppyLM usa un tokenizador a nivel de caracteres. Modelos de producción usan BPE (Byte Pair Encoding) con vocabularios de 50K-100K tokens.

Capa de incrustación (Embedding layer)

Convierte IDs de tokens en vectores densos aprendidos (por ejemplo, 384 dimensiones en GuppyLM). Los tokens similares terminan agrupados en el espacio vectorial. Se añaden incrustaciones de posición para que el modelo conozca el orden de los tokens.

Bloques de transformador

Cálculo central del modelo. Cada bloque contiene:

  • Autoatención (Self-attention): cada token atiende a todos los demás en la secuencia para decidir cuáles son importantes para predecir el siguiente token. GuppyLM usa 6 cabezas de atención en 6 capas.
  • Red de avance (Feed-forward network): una MLP de dos capas con activación ReLU.

Cabezal de salida

Tras el bloque final, una capa lineal proyecta la representación de cada token al tamaño del vocabulario. Softmax convierte a probabilidades, se elige (o muestrea) el siguiente token y se repite el proceso.

Construyendo un LLM mínimo en Python

A continuación, un LLM mínimo funcional siguiendo el enfoque de GuppyLM, usando PyTorch.

import torch
import torch.nn as nn
import torch.nn.functional as F

# Hiperparámetros
VOCAB_SIZE = 256     # nivel de caracteres ASCII
D_MODEL = 128        # dimensión de embedding
N_HEADS = 4          # cabezas de atención
N_LAYERS = 3         # bloques transformador
SEQ_LEN = 64         # ventana de contexto
DROPOUT = 0.1

class SelfAttention(nn.Module):
    def __init__(self, d_model, n_heads):
        super().__init__()
        self.n_heads = n_heads
        self.head_dim = d_model // n_heads
        self.qkv = nn.Linear(d_model, 3 * d_model, bias=False)
        self.proj = nn.Linear(d_model, d_model, bias=False)
        self.dropout = nn.Dropout(DROPOUT)

    def forward(self, x):
        B, T, C = x.shape
        qkv = self.qkv(x).reshape(B, T, 3, self.n_heads, self.head_dim)
        q, k, v = qkv.unbind(dim=2)
        q = q.transpose(1, 2)
        k = k.transpose(1, 2)
        v = v.transpose(1, 2)
        # Máscara causal: cada token solo atiende a tokens previos
        scale = self.head_dim ** -0.5
        attn = (q @ k.transpose(-2, -1)) * scale
        mask = torch.triu(torch.ones(T, T, device=x.device), diagonal=1).bool()
        attn = attn.masked_fill(mask, float('-inf'))
        attn = F.softmax(attn, dim=-1)
        attn = self.dropout(attn)
        out = (attn @ v).transpose(1, 2).reshape(B, T, C)
        return self.proj(out)

class TransformerBlock(nn.Module):
    def __init__(self, d_model, n_heads):
        super().__init__()
        self.attn = SelfAttention(d_model, n_heads)
        self.ff = nn.Sequential(
            nn.Linear(d_model, 4 * d_model),
            nn.ReLU(),
            nn.Linear(4 * d_model, d_model),
            nn.Dropout(DROPOUT),
        )
        self.ln1 = nn.LayerNorm(d_model)
        self.ln2 = nn.LayerNorm(d_model)

    def forward(self, x):
        x = x + self.attn(self.ln1(x))
        x = x + self.ff(self.ln2(x))
        return x

class TinyLLM(nn.Module):
    def __init__(self):
        super().__init__()
        self.embed = nn.Embedding(VOCAB_SIZE, D_MODEL)
        self.pos_embed = nn.Embedding(SEQ_LEN, D_MODEL)
        self.blocks = nn.ModuleList([
            TransformerBlock(D_MODEL, N_HEADS) for _ in range(N_LAYERS)
        ])
        self.ln_f = nn.LayerNorm(D_MODEL)
        self.head = nn.Linear(D_MODEL, VOCAB_SIZE, bias=False)

    def forward(self, idx):
        B, T = idx.shape
        tok_emb = self.embed(idx)
        pos = torch.arange(T, device=idx.device)
        pos_emb = self.pos_embed(pos)
        x = tok_emb + pos_emb
        for block in self.blocks:
            x = block(x)
        x = self.ln_f(x)
        logits = self.head(x)
        return logits

# Inicializa y cuenta parámetros
model = TinyLLM()
total_params = sum(p.numel() for p in model.parameters())
print(f"Model size: {total_params:,} parameters")  # ~1.2M
Enter fullscreen mode Exit fullscreen mode

Bucle de entrenamiento

import torch.optim as optim

def train(model, data, epochs=100, lr=3e-4):
    optimizer = optim.AdamW(model.parameters(), lr=lr)
    model.train()
    for epoch in range(epochs):
        # data: tensor de IDs de tokens, shape [batch, seq_len+1]
        x = data[:, :-1]   # entrada: todos menos el último
        y = data[:, 1:]    # objetivo: desplazado por 1
        logits = model(x)
        loss = F.cross_entropy(logits.reshape(-1, VOCAB_SIZE), y.reshape(-1))
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        if epoch % 10 == 0:
            print(f"Epoch {epoch}, loss: {loss.item():.4f}")
Enter fullscreen mode Exit fullscreen mode

Inferencia (generación de texto)

@torch.no_grad()
def generate(model, prompt_ids, max_new_tokens=50, temperature=1.0, top_k=10):
    model.eval()
    ids = torch.tensor([prompt_ids])
    for _ in range(max_new_tokens):
        idx_cond = ids[:, -SEQ_LEN:]  # recorta a ventana de contexto
        logits = model(idx_cond)
        logits = logits[:, -1, :] / temperature  # solo último token
        # top-k sampling
        v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
        logits[logits < v[:, [-1]]] = float('-inf')
        probs = F.softmax(logits, dim=-1)
        next_id = torch.multinomial(probs, num_samples=1)
        ids = torch.cat([ids, next_id], dim=1)
    return ids[0].tolist()
Enter fullscreen mode Exit fullscreen mode

Lo que esto te enseña sobre el comportamiento de las APIs de IA

Construir un LLM mínimo da claridad sobre varios aspectos de consumo de APIs:

La temperatura y el muestreo son mecánicos, no mágicos

La temperatura divide los logits antes del softmax. Temperatura alta = salida más aleatoria; temperatura baja = salida más determinista. Si una API devuelve resultados inconsistentes con temperature=0.0, no es un bug: la temperatura cero real es un argmax codicioso, pero muchas APIs la redondean para evitar salidas degeneradas.

Las ventanas de contexto son límites estrictos

La línea idx_cond = ids[:, -SEQ_LEN:] muestra que al alcanzar el límite, el modelo descarta silenciosamente los tokens más antiguos. No asumas que la API recuerda todo el historial de conversación. Para más detalles, consulta [interno: cómo-funciona-la-memoria-del-agente-de-IA].

Los tokens de streaming son solo pasos de inferencia expuestos

Las APIs de streaming simplemente ejecutan el bucle de inferencia y envían cada token al flujo conforme se genera. Si el flujo se interrumpe, no se puede reanudar: hay que reiniciar.

Los logits explican por qué la salida estructurada es difícil

El modelo asigna probabilidad a cada token del vocabulario en cada paso. Generar JSON válido implica que cada token correcto gane en cada posición. Herramientas como Outlines y Guidance restringen la distribución de logits para imponer gramática. Cuando una API de IA ofrece "salida estructurada", internamente está haciendo esto.

Cómo probar integraciones de API de IA con Apidog

Comprender la inferencia de LLM permite escribir mejores pruebas de API. Los Escenarios de prueba de Apidog te permiten encadenar llamadas y afirmar la estructura de las respuestas.

Ejemplo para una API de chat en streaming:

  1. Crea un Escenario de prueba en Apidog con el endpoint /v1/chat/completions.
  2. Añade aserciones para la estructura de la respuesta:
    • response.choices[0].finish_reason == "stop"
    • response.usage.total_tokens < 4096
  3. Agrega un paso que envíe la respuesta como contexto al siguiente turno, simulando conversación multi-turno.
  4. Usa Smart Mock de Apidog para simular el endpoint de IA y probar el manejo de errores:
    • simula finish_reason: "length" (truncamiento),
    • finish_reason: "content_filter",
    • y un timeout de red a mitad de transmisión.

De esta forma, puedes probar integraciones de IA sin consumir créditos de API en cada ejecución de CI. Consulta [interno: tutorial-de-pruebas-de-api] para más detalles sobre pruebas de API.

Probando aserciones de recuento de tokens

{
  "assertions": [
    {
      "field": "response.usage.completion_tokens",
      "operator": "less_than",
      "value": 512
    },
    {
      "field": "response.choices[0].finish_reason",
      "operator": "equals",
      "value": "stop"
    },
    {
      "field": "response.choices[0].message.content",
      "operator": "not_empty"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Ejecuta esto en varios modelos (GPT-4o, Claude 3.5 Sonnet, Gemini 1.5 Pro) en un solo escenario de prueba para detectar diferencias de esquema antes de pasar a producción.

Avanzado: cuantificación y optimización de inferencia

Al tener un LLM pequeño en marcha, es útil entender dos técnicas usadas en producción.

Cuantificación

Por defecto, los pesos del modelo son float de 32 bits. La cuantificación los reduce a INT8 o INT4, disminuyendo el uso de memoria 4-8x con poca pérdida de precisión.

# Ejemplo: cuantificación dinámica INT8 en PyTorch
import torch.quantization
quantized_model = torch.quantization.quantize_dynamic(
    model, {nn.Linear}, dtype=torch.qint8
)
Enter fullscreen mode Exit fullscreen mode

Las APIs de producción ejecutan modelos cuantificados. Cambios en la calidad de salida entre "versiones" de un mismo modelo suelen deberse a esto.

Caché KV

En el bucle de inferencia, recalculamos la atención para toda la secuencia. Los sistemas de producción almacenan en caché los pares clave-valor (KV) de los tokens previos para que cada token nuevo solo calcule atención sobre el último. Por eso el primer token de una respuesta streaming tarda más.

LLM pequeño vs. API de producción: ¿cuándo usar cada uno?

Caso de uso LLM pequeño API de producción
Aprendizaje de los internos Lo mejor para Excesivo
Prototipado de aplicación Calidad insuficiente Lo mejor para
Datos privados/sensibles Buena opción Depende del proveedor
Despliegue offline/en el borde Viable No posible
Sensible al costo, alto volumen Posible con tradeoffs Caro a escala
Tareas de alto razonamiento No viable Requerido

En la práctica: usa la API de producción para tu app, pero ejecuta un modelo pequeño para entender lo que sucede bajo el capó. No compiten. Consulta [interno: asistentes-de-codificación-de-código-abierto-2026] para herramientas que permiten "traer tu propio modelo".

Conclusión

Construir un LLM pequeño desde cero es cosa de un fin de semana. No es para producción, pero te da un modelo mental funcional sobre cómo opera cada modelo de lenguaje, desde GuppyLM hasta GPT-4o. Esa comprensión te ayuda a depurar integraciones de streaming, ajustar parámetros de muestreo y diseñar aserciones para tus pruebas de API de IA.

GuppyLM es un buen inicio: clónalo, entrénalo con cualquier dataset de texto y revisa el bucle de inferencia. Luego vuelve a tus integraciones de API de producción y las verás de otra manera.

Prueba los Escenarios de prueba de Apidog para llevar el mismo rigor a tus pruebas de API de IA que a cualquier backend.

Preguntas frecuentes

¿Cuántos parámetros necesita un LLM "pequeño" para generar texto coherente?

Alrededor de 10M-50M de parámetros con buen dataset pueden producir oraciones localmente coherentes. Por debajo de 1M, suele generar galimatías. GuppyLM (8.7M) funciona para conversaciones cortas en su dominio de entrenamiento (60 temas).

¿Puedo ejecutar un LLM pequeño sin GPU?

Sí. Modelos con menos de 100M de parámetros funcionan bien en CPU, aunque la inferencia es más lenta. El ejemplo anterior (1.2M) genera tokens en milisegundos en una laptop.

¿En qué dataset debo entrenar?

Modelos a nivel de caracteres funcionan bien con textos de Proyecto Gutenberg, Wikipedia o cualquier corpus plano. GuppyLM usa un dataset conversacional de 60K entradas en HuggingFace (arman-bd/guppylm-60k-generic). Para código, usa The Stack o CodeParrot.

¿Diferencia entre temperatura y muestreo top-k?

La temperatura escala la distribución de logits (aleatoriedad global). Top-k restringe el muestreo a los k tokens más probables antes de aplicar la temperatura. Se usan juntos: primero top-k filtra candidatos, luego la temperatura ajusta probabilidades.

¿Por qué mi LLM a veces se repite?

La repetición ocurre cuando el modelo asigna alta probabilidad a los tokens recién generados porque aparecen en el contexto. Las APIs suelen usar penalizaciones por repetición (repetition_penalty=1.1) para reducir esto.

¿Cuánto tiempo lleva entrenar un LLM pequeño?

El ejemplo anterior entrena para salida coherente en menos de 2 horas en una sola GPU (RTX 3060). GuppyLM entrena en Colab en similar tiempo. Modelos mayores (100M+) requieren multi-GPU y días de entrenamiento.

¿Cómo pasar de LLM pequeño a endpoint de API real rápido?

Exporta a formato GGUF con el script de llama.cpp y sírvelo con llama-server. Obtendrás un endpoint compatible con OpenAI localmente; apunta Apidog para pruebas. Consulta [interno: mejores-prácticas-de-rest-api].

¿Cómo manejan los LLM de producción el contexto más largo que su ventana de entrenamiento?

Técnicas como RoPE escalado, atención de ventana y generación aumentada por recuperación extienden el contexto efectivo. La arquitectura central no cambia; se modifican la codificación posicional y la ventana de atención.

Top comments (0)