volver al blog

Sumideros de atención: por qué tu LLM se rompe al deslizar la ventana

llmatencionkv-cache

El experimento que no sale

Tienes un asistente con una conversación larga y el KV cache creciendo sin parar. La solución parece evidente: ventana deslizante. Te quedas con los últimos N tokens y descartas los antiguos. Memoria constante, latencia constante, problema resuelto.

Lo implementas y el modelo se desmorona. No degrada suavemente: colapsa. La perplejidad se dispara varios órdenes de magnitud en cuanto el borde de la ventana pasa por encima de los primeros tokens de la secuencia. El texto pasa de coherente a ensalada de palabras en cuestión de un par de pasos de decode.

Lo raro es que esos primeros tokens ya no importaban. Eran el saludo inicial o un fragmento de instrucción que quedó 20.000 tokens atrás. Y sin embargo, quitarlos rompe el modelo entero.

Los primeros tokens no son texto: son un desagüe

Si visualizas los mapas de atención de un transformer decoder ya entrenado, aparece un patrón que no encaja con ninguna intuición semántica. Una fracción enorme de la atención, en casi todas las capas y casi todas las cabezas, apunta a los primeros tokens de la secuencia. Da igual lo que digan. Suelen ser un token de inicio o una palabra funcional sin contenido.

Esos tokens reciben atención no por lo que significan, sino por lo que hacen: absorben masa de atención sobrante. De ahí el nombre: attention sink, sumidero de atención.

La causa está en el softmax. La distribución de atención de cada cabeza tiene que sumar exactamente 1. El modelo no tiene una salida de “aquí no hay nada relevante”: está obligado a repartir toda esa probabilidad entre los tokens disponibles. Cuando una cabeza no encuentra nada útil que mirar en el contexto, necesita un sitio donde verter el excedente sin contaminar el resultado.

Durante el entrenamiento, los primeros tokens son los candidatos naturales para ese papel. Son los únicos visibles para todas las posiciones de la secuencia por la máscara causal. El modelo aprende a usarlos como vertedero y calibra sus pesos asumiendo que siempre estarán ahí.

Cuando la ventana deslizante los descarta, la masa de atención sobrante tiene que ir a otra parte. Y va a tokens con contenido real, que quedan sobreponderados de forma absurda. El modelo no ha perdido información: ha perdido el sitio donde no mirar.

La receta: unos pocos sinks más la ventana

La corrección es casi ofensiva de lo barata que es. Mantén de forma permanente los primeros tokens en el cache y desliza la ventana solo sobre el resto. Con cuatro tokens fijos basta para recuperar la perplejidad de referencia; es el número que reporta el trabajo original de StreamingLLM y el que suele funcionar en la práctica.

# Ventana deslizante con sumideros de atención.
# El cache es una lista de pares (k, v) por posición.

N_SINKS = 4        # tokens iniciales que no se descartan nunca
WINDOW  = 2048     # tokens recientes que sí rotan

def recortar_cache(cache):
    if len(cache) <= N_SINKS + WINDOW:
        return cache
    # Los sinks van al principio; en medio se tira todo lo que sobra.
    return cache[:N_SINKS] + cache[-WINDOW:]

def decode(cache, token):
    k, v = proyectar_kv(token)
    cache.append((k, v))
    cache = recortar_cache(cache)
    # Detalle crítico: la posición que se usa en la codificación posicional
    # es el índice dentro del cache recortado, no el índice absoluto
    # del token en la conversación.
    logits = atender(query(token), cache, posiciones=range(len(cache)))
    return muestrear(logits), cache

El comentario del final no es un adorno. Con codificación posicional rotatoria (RoPE), la posición se aplica en el momento de calcular la atención, así que hay que reindexar respecto al cache recortado. Si conservas los índices absolutos, el modelo ve un salto enorme entre el token 3 y el token 40.000. Vuelve a degradarse, esta vez por otra razón. Este fallo es silencioso: no lanza ninguna excepción, solo empeora la salida.

Cuándo compensa cada estrategia

EstrategiaMemoria del cacheCalidad en secuencias largasCuándo usarla
Atención densa completaCrece linealmente sin techoMáxima, hasta agotar la GPUContextos que caben de sobra en memoria
Ventana deslizante simpleConstanteColapsa al pasar los primeros tokensPrácticamente nunca
Ventana con sumiderosConstanteEstable en secuencias de millones de tokensStreaming continuo, sesiones sin fin
Recomputar el contextoConstante, coste de cómputo altoMáxima dentro del recorteCuando puedes pagar el prefill repetido

La cuarta fila merece un matiz. Recortar el histórico y volver a hacer prefill del contexto recortado también funciona, y de hecho es lo que hacen muchas aplicaciones de chat. Pero pagas un prefill completo cada vez que recortas, y ese coste crece con el tamaño del contexto que decidas conservar.

Lo que esto no te da

Aquí está la parte que se malinterpreta con frecuencia. Los sumideros de atención permiten seguir generando sin que la perplejidad se dispare. No amplían la memoria del modelo.

El contenido que sale de la ventana se ha ido. Si en el token 5.000 el usuario dijo su nombre y la ventana cubre 2.048 tokens, en el token 30.000 ese nombre no existe para el modelo. Los cuatro sinks que conservas no guardan información útil: son un desagüe, no un resumen.

Dicho de otro modo: esto resuelve un problema de estabilidad numérica, no uno de recuperación de información. Si necesitas que el modelo recuerde algo de hace 50.000 tokens, sigues necesitando RAG, un resumen incremental o una memoria externa explícita. Los sumideros solo garantizan que lo que sí está en la ventana se procese bien.

Cómo aterrizarlo

Si sirves modelos con vLLM, SGLang o TensorRT-LLM, es probable que ya lo tengas: las implementaciones de ventana deslizante de los motores de inferencia maduros llevan sinks incorporados. Búscalo antes de escribir nada. Varias familias de modelos recientes van más allá y entrenan un sumidero explícito —un valor aprendido que se suma al denominador del softmax— en lugar de dejar que el comportamiento emerja sobre tokens reales.

Si mantienes tu propio bucle de decode, la lista es corta:

  • Fija los primeros tokens del cache y no los toques nunca. Cuatro es un punto de partida razonable.
  • Reindexa las posiciones respecto al cache recortado, no respecto a la secuencia original.
  • Mide perplejidad sobre un documento largo antes y después. Si el arreglo funciona, la curva se aplana; si no, verás el pico exactamente donde la ventana pasa por encima de los sinks.
  • No vendas esto como memoria infinita. Es generación estable, sin límite práctico de longitud, que es otra cosa.

La lección de fondo va más allá de este truco concreto. El modelo no aprendió solo a representar tu texto: aprendió a apoyarse en propiedades estructurales de su propio contexto. Optimizar la inferencia sin conocer esas dependencias es una forma segura de romper algo que funcionaba, sin un solo error en los logs.

Fuentes