ruta ai/agentes / módulo 0.5

A0.5 — KV cache y el costo del contexto

Outcome del módulo: entender el mecanismo que hace que el contexto largo sea caro (no solo cuánto contexto entra, sino cuánto cuesta procesarlo por token), y usar prompt caching desde el backend para cortar costo y latencia en cargas que repiten un prefijo.

Este módulo profundiza el “por qué” detrás de A0 Concepto 1 (costo por tokens) y Concepto 2 (context window). Ahí se dijo “más tokens de input = más costo y más latencia, aunque el modelo aguante”. Acá se explica de dónde sale ese costo.


Concepto 1 — Qué es el KV cache y por qué existe

Idea núcleo: para generar cada token nuevo, el modelo compara ese token contra todos los anteriores; los vectores (key y value) de los tokens anteriores no cambian, así que se calculan una vez y se guardan en memoria de la GPU en vez de recalcularse en cada paso.

Entender (capa 1)

Texto: en el paso de atención, el token más nuevo compara contra cada token previo usando dos vectores por token anterior: un key y un value (resúmenes numéricos que el modelo computa para ese token en cada capa). Recalcular esos vectores en cada paso sería trabajo puro desperdiciado, porque una vez procesado un token sus key/value quedan fijos. El KV cache los guarda la primera vez y en el paso siguiente el modelo solo computa el key/value del token nuevo y lee el resto del cache. El cache guarda vectores, no el texto original — por eso podés tener un out-of-memory aunque el modelo mismo entre con espacio de sobra: lo que crece es el cache, no los pesos.

Visual:

flowchart LR
    N[token nuevo] --> AT[paso de atencion]
    KV[KV cache<br/>keys y values de tokens previos] --> AT
    AT --> OUT[siguiente token]
    N -->|se computa 1 vez y se guarda| KV

Ejemplo (mal → bien conceptual):

# mal: recomputar keys/values de TODOS los tokens en cada paso
# trabajo por token crece con el largo del input -> desperdicio cuadratico

# bien: KV cache
# paso 1: computa y guarda key/value de cada token del prompt (una vez)
# paso N: computa solo el token nuevo, lee el resto del cache

Fijar (capa 2)

Nota atómica:

Feynman: “es una libreta de apuntes. La primera vez que leés cada palabra anotás su resumen. Para escribir la siguiente palabra no volvés a leer todo el libro: mirás tus apuntes.”

Aplicar (capa 3)

Estimá el cache de un request tuyo: contá los tokens de input (system prompt + historial) con count_tokens del proveedor, y notá que el cache crece linealmente con ese número. Duplicar el contexto duplica el cache.

Límites


Concepto 2 — Prefill vs decoding: por qué el contexto largo es caro

Idea núcleo: generar tiene dos fases con cuellos distintos; la fase que domina el costo del contexto largo (decoding) está limitada por ancho de banda de memoria, no por cómputo — por eso achicar el cache no solo ahorra memoria, también acelera.

Entender (capa 1)

Texto: la generación corre en dos fases. En prefill el modelo lee todo el input de una, en paralelo, y construye los key/value de cada token en el cache en una sola pasada — mantiene ocupadas las unidades de cálculo, es compute-bound. En decoding el modelo produce la salida de a un token: cada token nuevo corre un paso de atención contra todo el cache, o sea lee cada key/value guardado desde la memoria de la GPU antes de emitir el siguiente token, y repite esa lectura por cada token que produce. El límite acá es qué tan rápido se mueve el cache desde la memoria hacia las unidades de cálculo — es memory-bound. Un cache más grande = más datos cruzando el bus de memoria por token = generación más lenta y más cara, aunque quepa cómodo en memoria.

Visual:

flowchart TD
    IN[input completo] -->|prefill: 1 pasada, compute-bound| CACHE[KV cache lleno]
    CACHE -->|decoding: relee TODO el cache por token| T1[token 1]
    CACHE --> T2[token 2]
    CACHE --> T3[token N]
    T1 -.memory-bound.-> BUS[ancho de banda de memoria = cuello]
    T2 -.-> BUS
    T3 -.-> BUS

Fijar (capa 2)

Nota atómica:

Feynman: “prefill es leer el libro entero una vez de corrido. Decoding es que, para escribir cada palabra nueva, tenés que hojear otra vez todo lo que llevás. Cuanto más grueso el fajo, más tardás en cada palabra.”

Aplicar (capa 3)

Compará latencia de una misma tarea con contexto corto vs contexto largo (mismo modelo, mismo output esperado). El tiempo por token de salida sube con el contexto de entrada aunque el output sea idéntico — ese delta es el barrido del cache.

Límites


Concepto 3 — La ecuación del tamaño del cache

Idea núcleo: el tamaño del cache es un producto de números fijos del modelo por dos que vos controlás — tokens de contexto y requests simultáneos — y crece lineal con ambos.

Entender (capa 1)

Texto: el tamaño del cache es:

2 × capas × kv_heads × head_dim × bytes_por_numero × tokens × batch

Los primeros cinco los fija la arquitectura del modelo. Los dos últimos son tuyos: el cache crece lineal con los tokens (duplicar contexto duplica el cache) y lineal con el batch (servir más usuarios a la vez lo escala igual de rápido). Ejemplo de referencia [verificar contra specs del modelo]: un Llama 3 70B (80 capas, 8 kv_heads, head_dim 128, 2 bytes) a 128.000 tokens de contexto para un solo request da ~40 GB — casi una tarjeta de 80 GB entera por un request largo.

Visual:

flowchart LR
    FIJO[fijos del modelo<br/>capas · kv_heads · head_dim · bytes] --> SIZE[tamano del cache]
    TOK[tokens de contexto<br/>lo controlas vos] --> SIZE
    BATCH[batch / usuarios simultaneos<br/>lo controlas vos] --> SIZE

Fijar (capa 2)

Nota atómica:

Feynman: “el modelo trae un costo fijo por token, como el peso de un ladrillo. Vos decidís cuántos ladrillos (tokens de contexto) y cuántas paredes a la vez (usuarios). El montón crece derecho con las dos cosas.”

Aplicar (capa 3)

Tomá un modelo que uses y buscá sus specs (capas, kv_heads, head_dim). Calculá el cache por token, después multiplicá por un contexto realista de tu app. Compará cache de 8K vs 128K tokens: es exactamente 16×.

Límites


Concepto 4 — Técnicas para achicar el cache

Idea núcleo: cada optimización ataca un término de la ecuación o el desperdicio alrededor; se diferencian en cuánto piden a cambio, desde casi gratis hasta un verdadero riesgo de perder información.

Entender (capa 1)

Texto: las técnicas caen en dos grupos. Unas cambian la arquitectura (se deciden al entrenar el modelo): reducen la huella de cada token. Otras operan sobre un modelo ya servido: cómo se guarda o se administra el cache.

TécnicaAtacaCosto / tradeoff
Grouped-query attention (GQA)kv_headscasi gratis, default hoy (varios query heads comparten un kv_head)
Multi-query attention (MQA)kv_headsahorra más, pero baja calidad y desestabiliza entrenamiento
Multi-head latent attention (DeepSeek)bytes/tokencomprime keys/values a un espacio latente; +trabajo en cada lectura
Quantization 8/4 bitbytes/número8bit ~gratis (<1% accuracy); 4bit pierde en retrieval multi-dato
Eviction (tirar tokens)tokensgamble real: el token que tirás hoy puede ser el que necesitás después
Paged attention (vLLM)fragmentación~gratis, sube throughput 2-3×; corta desperdicio de 60-80% a <4%
Prefix / prompt cachingrecomputo del prefijo~gratis en calidad; comparte el cache de un prefijo repetido

Detalle de dos que importan por su tradeoff:

Visual:

flowchart TD
    ARCH[cambian la arquitectura<br/>se entrena con ellas] --> GQA[GQA / MQA: menos kv_heads]
    ARCH --> LAT[latent attention: comprime keys/values]
    SERV[operan sobre modelo servido] --> QUANT[quantization: menos bytes/numero]
    SERV --> EVICT[eviction: menos tokens<br/>riesgo de perder info]
    SERV --> PAGED[paged + prefix caching: administran mejor el cache]

Fijar (capa 2)

Nota atómica:

Feynman: “quantization es escribir tus apuntes en letra más chica: entra más, se lee casi igual. Eviction es arrancar hojas viejas para hacer lugar: rápido, hasta que necesitás justo la hoja que arrancaste.”

Aplicar (capa 3)

Para un modelo que uses, averiguá si trae GQA (casi todos los modernos sí) y cuántos kv_heads tiene vs query heads. La razón entre ambos es el factor por el que GQA ya te está achicando el cache.

Límites


Concepto 5 — Prompt caching: la palanca que sí controlás

Idea núcleo: prefix caching (lo que las APIs venden como prompt caching) reusa el cache de un prefijo repetido entre requests; para aprovecharlo, ordenás el prompt de estable a variable y ponés el input que cambia al final.

Entender (capa 1)

Texto: como el cache vive en bloques compartibles (Concepto 4, paged attention), dos requests que empiezan con el mismo texto pueden apuntar a los mismos bloques físicos mientras cada uno tiene su continuación privada. Esa es la base de prefix caching. El ahorro es grande en cualquier carga que repita un prefijo — un agente que manda el mismo system prompt de miles de tokens en cada llamada. Los proveedores cobran los tokens cacheados a una fracción de los frescos (lecturas ~0.1× del precio de input; escrituras ~1.25× con TTL corto [verificar contra pricing vigente]). OpenAI y Anthropic reportan reducciones de costo y latencia de 50-90% en cache hit.

Regla dura: el cache es un match de prefijo. Cualquier byte que cambie en el prefijo invalida todo lo que viene después. El orden de render es tools → system → messages. Entonces: contenido estable primero (system prompt congelado, lista de tools determinística), contenido volátil (timestamps, IDs por request, la pregunta que varía) después del último punto de cache.

Visual:

flowchart LR
    PREFIJO[prefijo estable<br/>system + tools + ejemplos] -->|cache_control aca| CACHE[bloques cacheados<br/>lectura ~0.1x]
    CACHE --> VAR[input variable<br/>la pregunta de este request]
    VAR --> RESP[respuesta]

Ejemplo (mal → bien):

# mal: dato volatil dentro del system prompt -> invalida el cache en cada request
system = f"Sos un asistente. Fecha actual: {datetime.now()}. {instrucciones_largas}"
# el timestamp cambia el prefijo cada vez -> cache_read_input_tokens siempre 0

# bien: prefijo congelado, dato volatil despues, punto de cache al final del prefijo estable
resp = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=1024,
    system=[
        {"type": "text", "text": instrucciones_largas,
         "cache_control": {"type": "ephemeral"}},   # TTL 5 min por defecto
    ],
    messages=[{"role": "user", "content": pregunta_de_este_request}],
)
# verificar el hit:
print(resp.usage.cache_read_input_tokens)   # >0 = lo leyo del cache

Fijar (capa 2)

Nota atómica:

Feynman: “el cache reconoce el arranque del prompt como una huella. Si el arranque es idéntico, entra por la puerta rápida. Cambiale una coma al principio y ya es una huella nueva: vuelve a pagar la entrada completa.”

Aplicar (capa 3)

Tomá un prompt tuyo con prefijo grande y repetido. Mandalo dos veces seguidas (dentro del TTL) con cache_control en el último bloque del prefijo estable. Mirá cache_read_input_tokens en el segundo request: si es 0, buscá el invalidador silencioso (timestamp, UUID, JSON sin ordenar, set de tools que varía).

Límites


Build step

Instrumentá una llamada tuya que repita un prefijo grande:

  1. Estructurá el prompt prefijo-estable → variable: system prompt e instrucciones fijas primero, input que cambia al final.
  2. Poné cache_control en el último bloque del prefijo estable.
  3. Mandá el request dos veces dentro del TTL y logueá cache_creation_input_tokens (primera) y cache_read_input_tokens (segunda).
  4. Confirmá que la segunda llamada leyó del cache. Si no, diffeá los bytes del prefijo entre ambos requests para cazar el invalidador.
import anthropic

client = anthropic.Anthropic()

def llamar(pregunta: str):
    r = client.messages.create(
        model="claude-opus-4-8",
        max_tokens=512,
        system=[
            {"type": "text", "text": INSTRUCCIONES_FIJAS,   # prefijo estable
             "cache_control": {"type": "ephemeral"}},
        ],
        messages=[{"role": "user", "content": pregunta}],   # variable, al final
    )
    print("write:", r.usage.cache_creation_input_tokens,
          "read:", r.usage.cache_read_input_tokens)
    return r

llamar("primera pregunta")    # write > 0, read == 0
llamar("segunda pregunta")    # write == 0, read > 0  <- cache hit

Checklist de dominio A0.5

Marcás cuando podés explicar (Feynman) + aplicar cada uno:

Salida verificable del módulo: una llamada instrumentada que muestra cache_read_input_tokens > 0 en la segunda pasada, y la capacidad de explicar por qué mover un dato volátil al system prompt lo llevaría a 0.