Capítulo 12 de 15 9 secciones 17 min

Qué está pasando cuando le escribes a un modelo de lenguaje

Tokens, el bloque completo, el bucle que escribe palabra a palabra y qué hace de verdad la temperatura.

Un modelo de lenguaje parte el texto en tokens, los pasa por unas decenas de bloques de atención, y saca una probabilidad para cada token posible como siguiente. Después elige uno, lo pega al final y repite. La temperatura solo escala esas probabilidades antes de elegir: con 0,2 la palabra favorita se lleva 0,9959 y con 3,0 baja a 0,3774.

Ya tenemos todas las piezas. Aquí las juntamos y respondemos la pregunta con la que mucha gente llega a este libro: qué está pasando exactamente cuando le escribes a ChatGPT o a Claude 🤖

Primero: el texto se parte en trozos

En el capítulo 10 quedó un error abierto: pedir el vector de "huancayo" reventaba porque esa palabra no estaba en el vocabulario. Así se arregla:

import numpy as np

CONOCIDOS = {'bo', 'de', 'ga', 'hu', 'an', 'ca', 'yo', 'li', 'ma', 'min',
             'i', 'mar', 'ket', 'a', 'o', 'e', 'u', 'n', 'r', 's', 't'}

def trozos(palabra, conocidos):
    salida, i = [], 0
    while i < len(palabra):
        for j in range(len(palabra), i, -1):        # el trozo más largo que quepa
            if palabra[i:j] in conocidos:
                salida.append(palabra[i:j])
                i = j
                break
        else:
            salida.append('?')
            i += 1
    return salida

for w in ['bodega', 'huancayo', 'minimarket', 'lima']:
    print(f'{w:12} -> {trozos(w, CONOCIDOS)}')
bodega       -> ['bo', 'de', 'ga']
huancayo     -> ['hu', 'an', 'ca', 'yo']
minimarket   -> ['min', 'i', 'mar', 'ket']
lima         -> ['li', 'ma']

Ahí está: "huancayo" se parte en hu-an-ca-yo. No hace falta que la palabra esté en el vocabulario, basta con que estén sus trozos 🧩

Eso es la tokenización por subpalabras, y es por lo que un modelo puede leer un nombre propio peruano que nunca vio, o una palabra inventada, o un error de tipeo.

También explica dos cosas que se notan usándolos. Que el español gaste más tokens que el inglés (los vocabularios se arman mirando sobre todo texto en inglés, así que nuestras palabras se parten en más trozos), y que a los modelos les cueste contar letras: ellos no ven letras, ven trozos.

Un bloque de transformer de abajo arriba: entrada con embeddings y posición, atención multicabeza, suma y normalización, red feed forward, otra suma y normalización, y salida. Dos conexiones residuales saltan por encima de los dos subbloques.
Un modelo grande es este bloque repetido decenas de veces, nada más. Las flechas punteadas de la derecha son las conexiones residuales: suman la entrada a la salida para que el gradiente tenga un camino directo hasta abajo, que es lo que permite apilar tantos.

Segundo: el bloque completo

a^=γaμσ2+ε+β

recentra y reescala las activaciones de cada ejemplo, y ese epsilon de abajo es lo que evita dividir entre cero cuando la varianza es minúscula

La atención del capítulo 11 no va sola. Un bloque de transformer le añade tres cosas, y las tres son necesarias:

def softmax(z):
    z = z - z.max(axis=-1, keepdims=True)
    e = np.exp(z)
    return e / e.sum(axis=-1, keepdims=True)

def norma(X, eps=1e-5):
    return (X - X.mean(-1, keepdims=True)) / np.sqrt(X.var(-1, keepdims=True) + eps)

rng = np.random.default_rng(7)
n, d = 6, 8
X = rng.normal(0, 1, (n, d))
Wq, Wk, Wv = [rng.normal(0, 0.5, (d, d)) for _ in range(3)]
W1 = rng.normal(0, 0.5, (d, 4 * d))
W2 = rng.normal(0, 0.5, (4 * d, d))

def bloque(X):
    A = softmax((X @ Wq) @ (X @ Wk).T / np.sqrt(d))
    X = norma(X + A @ (X @ Wv))                       # atención + residual + norma
    X = norma(X + np.maximum(0, X @ W1) @ W2)         # feedforward + residual + norma
    return X

Y = bloque(X)
print('entra', X.shape, 'y sale', Y.shape)
print('media de cada fila al salir     :', np.round(Y.mean(axis=1), 3))
print('desviación de cada fila al salir:', np.round(Y.std(axis=1), 3))
entra (6, 8) y sale (6, 8)
media de cada fila al salir     : [ 0.  0.  0. -0. -0.  0.]
desviación de cada fila al salir: [1. 1. 1. 1. 1. 1.]

Tres añadidos y cada uno resuelve un problema concreto 🔧

  • ➕ El residual, ese X +, deja pasar la entrada sin tocar al lado de lo que calculó la capa. Así el gradiente del capítulo 6 tiene un camino directo hasta abajo sin multiplicarse por nada, y por eso se pueden apilar cien capas.
  • 📏 La norma por capa centra cada fila en media 0 y desviación 1. Mira la salida: todas las medias son 0 y todas las desviaciones 1, exacto.
  • 🧠 El feedforward es la red del capítulo 4 aplicada a cada posición por separado, y suele ser cuatro veces más ancha. Ahí está la mayoría de los pesos de un modelo de lenguaje, aunque la fama se la lleve la atención.

Por qué el residual no es opcional

𝐚(l+1)=𝐚(l)+F(𝐚(l))

la capa aprende lo que hay que sumarle a la entrada en vez de la salida entera, y por eso el gradiente encuentra siempre un camino corto de vuelta

def bloque_sin_residual(X):
    A = softmax((X @ Wq) @ (X @ Wk).T / np.sqrt(d))
    X = norma(A @ (X @ Wv))
    X = norma(np.maximum(0, X @ W1) @ W2)
    return X

def parecido(a, b):
    return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))

Z = X.copy()
for _ in range(6):
    Z = bloque_sin_residual(Z)
print('sin residual, tras 6 bloques, parecido entre dos posiciones:',
      round(parecido(Z[0], Z[1]), 4))

Z = X.copy()
for capa in range(6):
    Z = bloque(Z)
    print(f'con residual, tras {capa + 1} bloques: magnitud media {np.abs(Z).mean():.4f}')
sin residual, tras 6 bloques, parecido entre dos posiciones: 1.0
con residual, tras 1 bloques: magnitud media 0.8264
con residual, tras 2 bloques: magnitud media 0.8187
con residual, tras 3 bloques: magnitud media 0.8034
con residual, tras 4 bloques: magnitud media 0.8156
con residual, tras 5 bloques: magnitud media 0.8210
con residual, tras 6 bloques: magnitud media 0.8247

Sin residual, después de seis bloques las dos posiciones tienen un parecido de 1,0: son el mismo vector. Todas las palabras se volvieron idénticas y el modelo perdió la frase entera.

Con residual, la magnitud se queda entre 0,80 y 0,83 bloque tras bloque, sin crecer ni apagarse.

Una nota honesta: aquí los pesos son aleatorios, así que ese colapso es más brutal de lo que sería con un modelo entrenado. Lo que la medición enseña de verdad es que el residual y la norma son lo que mantiene estable una pila de bloques, y sin ellos las redes profundas de texto no se pueden entrenar 🏗️

Pérdida frente a cómputo con los dos ejes en escala logarítmica: los puntos caen sobre una recta que baja, y un punto marcado señala dónde estamos hoy en esa recta.
Los dos ejes son logarítmicos, y ahí está la trampa: que se vea como una recta significa que para bajar el error lo mismo que bajó antes hay que multiplicar la inversión otra vez. Eso explica los presupuestos de los que entrenan modelos, y explica también por qué a ti te conviene usar el suyo.

Tercero: el bucle que escribe

Y ahora la parte que sorprende a casi todo el mundo. Un modelo de lenguaje hace una sola cosa: dada una secuencia, poner una probabilidad a cada token posible como siguiente.

Escribir es repetir eso: eliges uno, lo pegas al final, y vuelves a preguntar.

COMENTARIOS = [
    'el pedido llego completo y a tiempo', 'el pedido llego incompleto y con retraso',
    'la bodega pidio arroz y azucar', 'el minimarket pidio aceite y azucar',
    'el mayorista pidio arroz en cantidad', 'el pedido de la bodega llego tarde',
    'el pedido del minimarket llego tarde', 'la bodega reclamo por el retraso del pedido',
    'el minimarket reclamo por el aceite roto', 'el mayorista reclamo por la factura',
    'llego todo completo sin reclamo', 'el arroz llego en buen estado',
    'el aceite llego en buen estado', 'el azucar llego roto y con retraso',
    'la bodega pago la factura a tiempo', 'el minimarket pago la factura con retraso',
    'el mayorista pago la factura a tiempo', 'el vendedor visito la bodega el lunes',
    'el vendedor visito el minimarket el martes', 'el vendedor visito al mayorista el lunes',
    'la bodega compro arroz el lunes', 'el minimarket compro aceite el martes',
    'el mayorista compro arroz y aceite', 'nadie visito la bodega esta semana',
    'el pedido de arroz llego completo', 'el pedido de aceite llego incompleto',
]

vocabulario = sorted({p for f in COMENTARIOS for p in f.split()} | {'<fin>'})
lugar = {p: i for i, p in enumerate(vocabulario)}
V = len(vocabulario)

siguiente = np.zeros((V, V))
for f in COMENTARIOS:
    ps = f.split() + ['<fin>']
    for a, b in zip(ps, ps[1:]):
        siguiente[lugar[a], lugar[b]] += 1

def escribe(inicio, temperatura=1.0, semilla=0, maximo=10):
    r = np.random.default_rng(semilla)
    salida = [inicio]
    for _ in range(maximo):
        fila = siguiente[lugar[salida[-1]]]
        if fila.sum() == 0:
            break
        puntajes = np.where(fila > 0, np.log(fila + 1e-9), -1e9) / temperatura
        p = np.exp(puntajes - puntajes.max())
        p = p / p.sum()
        elegida = vocabulario[r.choice(V, p=p)]
        if elegida == '<fin>':
            break
        salida.append(elegida)
    return ' '.join(salida)

for s in range(4):
    print(escribe('el', semilla=s))
el minimarket el aceite
el minimarket reclamo
el martes
el arroz en cantidad

Eso es un modelo de lenguaje. Uno malísimo, porque solo mira la palabra anterior en vez de toda la frase con atención, y porque aprendió de 26 comentarios en vez de billones de palabras.

Pero el bucle es exactamente el mismo: mirar, poner probabilidades, elegir, pegar, repetir. Cuando ves a Claude escribir palabra a palabra, es esto, con un transformer en el medio en vez de una tabla 🔁

Y de aquí sale algo que conviene entender: el modelo no tiene un plan de lo que va a decir. Cada token se elige con lo que hay escrito hasta ese momento. Que las respuestas salgan coherentes es consecuencia de que el contexto incluye todo lo que ya escribió.

La temperatura, que es lo único que ajustas tú

palabras = ['completo', 'tarde', 'roto', 'nunca', 'ya']
puntajes = np.array([3.2, 2.1, 0.8, -0.5, -1.4])

def con_temperatura(z, T):
    z = z / T
    e = np.exp(z - z.max())
    return e / e.sum()

print(f"{'palabra':12}" + ''.join(f'{f"T={t}":>10}' for t in [0.2, 0.5, 1.0, 1.5, 3.0]))
for i, p in enumerate(palabras):
    print(f'{p:12}' + ''.join(f'{con_temperatura(puntajes, t)[i]:10.4f}'
                              for t in [0.2, 0.5, 1.0, 1.5, 3.0]))
palabra          T=0.2     T=0.5     T=1.0     T=1.5     T=3.0
completo        0.9959    0.8931    0.6857    0.5514    0.3774
tarde           0.0041    0.0990    0.2282    0.2648    0.2616
roto            0.0000    0.0073    0.0622    0.1113    0.1696
nunca           0.0000    0.0005    0.0170    0.0468    0.1100
ya              0.0000    0.0001    0.0069    0.0257    0.0815

Ahí está lo que hace, y es una sola división 🌡️

Con T=0,2 la favorita se lleva 0,9959 y las demás quedan en nada: el modelo va a decir siempre lo mismo. Con T=3,0 la favorita baja a 0,3774 y hasta "ya", que era la última, se lleva 0,0815.

Y fíjate en lo que la temperatura no hace: no cambia el orden ni cambia lo que el modelo sabe. Completo sigue siendo la más probable en las cinco columnas. Solo cambia cuánto se arriesga al elegir.

Por eso "bájale la temperatura para que no alucine" funciona a medias: si el modelo tiene mal el dato, con T=0 lo va a decir mal con toda seguridad 😬

Los otros dos filtros

p = con_temperatura(puntajes, 1.0)

def top_k(p, k):
    q = p.copy()
    corte = np.sort(q)[::-1][k - 1]
    q[q < corte] = 0
    return q / q.sum()

def top_p(p, umbral):
    orden = np.argsort(p)[::-1]
    acumulado = np.cumsum(p[orden])
    cuantas = int(np.searchsorted(acumulado, umbral)) + 1
    q = np.zeros_like(p)
    q[orden[:cuantas]] = p[orden[:cuantas]]
    return q / q.sum()

print('sin filtro:', np.round(p, 4))
print('top-k, k=2:', np.round(top_k(p, 2), 4))
print('top-p, 0.9:', np.round(top_p(p, 0.9), 4))
sin filtro: [0.6857 0.2282 0.0622 0.017  0.0069]
top-k, k=2: [0.7503 0.2497 0.     0.     0.    ]
top-p, 0.9: [0.7503 0.2497 0.     0.     0.    ]

El top-k se queda con las k mejores y tira el resto. El top-p se queda con las que hagan falta para juntar ese porcentaje de probabilidad, así que el corte se mueve solo: si el modelo está segurísimo se queda con una, y si duda se queda con muchas.

Aquí los dos dan lo mismo porque con estos cinco números las dos primeras ya suman el 91%. Con un vocabulario de 100.000 tokens la diferencia se nota muchísimo, y por eso el top-p es el que se usa hoy 🎚️

Recompensa a lo largo de los episodios de entrenamiento por refuerzo: una línea muy ruidosa con una media móvil encima que sube deprisa al principio, se estanca en una meseta y vuelve a subir hasta aplanarse.
Así se ve por dentro el paso que convierte un modelo bruto en uno con el que se puede conversar. La recompensa la ponen personas comparando respuestas, y la meseta del medio es normal: el modelo se atasca en una estrategia hasta que encuentra otra mejor.

Lo que este capítulo NO explica

Y esto es importante para que no salgas de aquí creyendo que ya sabes cómo se hace un ChatGPT.

  • 📚 El entrenamiento. Todo lo de este libro, pero sobre billones de palabras y durante meses, en miles de tarjetas gráficas. La retropropagación es la misma; la escala no.
  • 🎓 El ajuste con personas. Un modelo entrenado solo a predecir la palabra siguiente no contesta preguntas: completa texto. Que sea útil y educado sale de una fase posterior con evaluaciones humanas.
  • 🧯 Las barreras. Lo que decide qué no responde no está en la arquitectura.

Lo que sí te llevas es la maquinaria: tokens, atención, bloques apilados y un bucle que elige. Y con eso ya puedes leer un paper y entender de qué habla 💛

Ejercicios

1. Cuántos tokens gasta el español

Parte varias palabras y cuenta los trozos.

for w in ['lima', 'bodega', 'minimarket', 'huancayo']:
    t = trozos(w, CONOCIDOS)
    print(f'{w:12} {len(w):2d} letras -> {len(t)} trozos  {t}')
lima          4 letras -> 2 trozos  ['li', 'ma']
bodega        6 letras -> 3 trozos  ['bo', 'de', 'ga']
minimarket   10 letras -> 4 trozos  ['min', 'i', 'mar', 'ket']
huancayo      8 letras -> 4 trozos  ['hu', 'an', 'ca', 'yo']

Cuatro letras dan dos trozos y diez letras dan cuatro. Con un vocabulario de verdad la proporción es mejor, pero la idea es esa.

Y tiene consecuencia de plata: los modelos se cobran por token. Si tu texto está en español y el vocabulario se armó mirando inglés, pagas más por decir lo mismo 💸

2. La palabra que no se puede partir

Prueba con una palabra cuyos trozos no estén todos.

print(trozos('zapallo', CONOCIDOS))
print(trozos('xyz', CONOCIDOS))
['?', 'a', '?', 'a', '?', '?', 'o']
['?', '?', '?']

Aparecen interrogantes donde no encontró nada. En un tokenizador de verdad eso no pasa nunca, porque el vocabulario incluye todas las letras sueltas: siempre hay una salida, aunque sea letra a letra.

Ese es el diseño que hace que nunca falle, a diferencia de los embeddings del capítulo 10, que reventaban con una palabra nueva 🧩

3. La temperatura sobre el generador

Escribe con temperaturas distintas y compara.

for T in [0.2, 1.0, 3.0]:
    print(f'T={T}:')
    for s in range(3):
        print('   ', escribe('el', temperatura=T, semilla=s))
T=0.2:
    el pedido de aceite
    el pedido llego completo y azucar
    el minimarket el pedido de arroz y azucar
T=1.0:
    el minimarket el aceite
    el minimarket reclamo
    el martes
T=3.0:
    el minimarket el aceite
    el mayorista reclamo
    el lunes

Las frases cambian, sí, pero no como esperabas: con T=0,2 salen las más largas, y con T=3,0 las más cortas y sosas. Justo al revés de la intuición de "temperatura alta, más creativo".

Y tiene explicación. Con una tabla de 26 comentarios, la mayoría de las palabras tienen una o dos continuaciones posibles, así que la temperatura no cambia cuánto se arriesga: cambia por qué rama se va. Y unas ramas llevan a frases largas y otras a un punto final.

La temperatura hace lo que dice el apartado anterior cuando hay muchas continuaciones plausibles, que es lo que pasa con un vocabulario de 100.000 tokens. Con cinco opciones, lo que se ve es otra cosa 🌡️

4. La misma semilla siempre da lo mismo

Comprueba que no hay nada mágico ni impredecible.

print(escribe('la', semilla=3))
print(escribe('la', semilla=3))
print('¿idénticas?', escribe('la', semilla=3) == escribe('la', semilla=3))
la bodega el pedido del minimarket llego incompleto
la bodega el pedido del minimarket llego incompleto
¿idénticas? True

Idénticas, siempre. La misma entrada y la misma semilla dan la misma salida.

Que un modelo te conteste distinto cada vez no es porque "piense diferente": es porque el azar de la elección cambia. Con temperatura 0 y sin muestreo, un modelo de lenguaje es una función determinista 🎲

5. Cuántos pesos hay en cada sitio

Reparte los parámetros de un bloque entre atención y feedforward.

for dim in [8, 768, 4096]:
    atencion = 4 * dim * dim              # Wq, Wk, Wv y la de salida
    feed = 8 * dim * dim                  # una de d a 4d y otra de 4d a d
    print(f'd={dim:5d}: atención {atencion:12,}  feedforward {feed:12,}  '
          f'({feed / (atencion + feed):.0%} en el feedforward)')
d=    8: atención          256  feedforward          512  (67% en el feedforward)
d=  768: atención    2,359,296  feedforward    4,718,592  (67% en el feedforward)
d= 4096: atención   67,108,864  feedforward  134,217,728  (67% en el feedforward)

Dos tercios de los pesos de un bloque están en el feedforward, no en la atención.

La atención se lleva toda la atención (perdón) porque es la idea nueva, y el grueso de la memoria del modelo está en esas dos matrices anchas que procesan cada posición por separado 🧠

6. Qué tan grande es la norma por capa

Comprueba que deja cada fila en media 0 y desviación 1.

raro = np.array([[1000., 1001, 999, 1002],
                 [0.001, 0.002, 0.0015, 0.0011]])
print('antes, medias      :', np.round(raro.mean(axis=1), 4))
print('antes, desviaciones:', np.round(raro.std(axis=1), 4))
print('después, medias      :', np.round(norma(raro).mean(axis=1), 4))
print('después, desviaciones:', np.round(norma(raro).std(axis=1), 4))
antes, medias      : [1.0005e+03 1.4000e-03]
antes, desviaciones: [1.118e+00 4.000e-04]
después, medias      : [ 0. -0.]
después, desviaciones: [1.     0.1235]

Las dos filas salen en media 0, y ahí funcionó. Pero mira las desviaciones: la primera sale 1 y la segunda sale 0,1235.

El culpable es el eps de la fórmula. La segunda fila tiene una varianza de 1,6e-07, o sea muchísimo más chica que el 1e-05 que le sumamos para no dividir entre cero, así que ese epsilon se come el cálculo y la fila sale aplastada.

No es un fallo: es el precio de protegerse de la división entre cero, y solo se nota cuando los números son ridículamente pequeños. Me pareció que valía la pena enseñarlo porque estas protecciones nunca son gratis, y esta en concreto está en todas las redes que existen 🧯

Por lo demás, la norma por capa es el StandardScaler del capítulo 5 aplicado dentro de la red y a cada fila en vez de a las columnas antes de empezar. Y por la misma razón: para que el gradiente no tenga que lidiar con escalas distintas ⚖️

7. El error del token que no existe

Pídele al generador que arranque con una palabra que no está en el corpus.

escribe('hola')
KeyError: 'hola'

Mismo error que cerraba el capítulo 10, y por eso lo repito aquí: el generador trabaja con una tabla de palabras conocidas y "hola" no está.

La diferencia es que ahora ya sabes la solución. Si en vez de palabras enteras usara los trozos del principio del capítulo, "hola" se partiría en pedacitos conocidos y no habría error.

La tokenización por subpalabras no es un detalle de implementación: es lo que hace que el sistema no se rompa con el mundo real 🧩

Comprueba que lo tienes

¿Por qué las conexiones residuales no son opcionales en un transformer profundo?

  • Porque le dan al gradiente un camino corto de vuelta a las primeras capas
  • Porque hacen el modelo más rápido
  • Porque reducen el número de parámetros
  • Porque evitan el sobreajuste

Lo que te llevas

  • 🧩 El texto se parte en trozos, así que "huancayo" se lee como hu-an-ca-yo sin estar en el vocabulario.
  • ➕ Un bloque es atención más residual más norma más feedforward. Sin residual, seis bloques dejan todas las posiciones con parecido 1,0.
  • 🧠 Dos tercios de los pesos están en el feedforward, no en la atención.
  • 🔁 Escribir es un bucle: poner probabilidades, elegir, pegar, repetir. No hay plan previo.
  • 🌡️ La temperatura es una división: con 0,2 la favorita se lleva 0,9959 y con 3,0 baja a 0,3774. No cambia el orden ni lo que el modelo sabe.
  • 🎚️ Top-k corta por cantidad y top-p por probabilidad acumulada, así que el corte del top-p se mueve según lo seguro que esté.
  • 🎲 Misma entrada y misma semilla dan la misma salida. Nada de esto es impredecible.
  • 📚 Y lo que aquí no está: el entrenamiento a escala, el ajuste con personas y las barreras.

En el capítulo 13 pasamos al lado práctico: cuándo conviene un prompt, cuándo RAG y cuándo ajustar un modelo, que es la pregunta que llega a las empresas.

Que tengas lindo día! 🌸

¿Tienes alguna duda o consulta?