Módulo 2: Latencia y costo como arquitectura

5. Caché: muchas queries se repiten

Descripción

Al terminar esta lección vas a saber diseñar la otra gran palanca de costo de una feature de IA, complementaria al cascade: la caché. La idea es tan vieja como la computación y aquí paga más que nunca: no vuelvas a calcular lo que ya calculaste. Muchas queries de un sistema real se repiten —"iphone", "laptop", "cómo devuelvo un producto" se preguntan miles de veces al día— y volver a llamar al LLM para responder algo que ya respondiste es tirar dinero y tiempo. La caché guarda la respuesta la primera vez y la sirve al instante las siguientes, a costo cero y latencia de milisegundos. Vas a medir la métrica que gobierna todo su ahorro —el hit-rate, la fracción de queries que se sirven de lo guardado— y vas a comparar dos formas de cachear: la exacta (misma query literal) y la semántica (misma intención escrita distinto), que atrapa repeticiones que la exacta deja pasar.

Esto importa porque, a alto volumen, no repagar es el ahorro más grande de todos —más grande que el cascade—. El cascade abarata cada llamada (de caro a barato); la caché elimina la llamada por completo (de barato a cero). Si la mitad de tu tráfico son repeticiones, cachear bien recorta tu costo a la mitad de un plumazo, sin importar qué modelo uses para lo que sí llega al LLM. Y la latencia mejora en la misma proporción: una respuesta cacheada llega en 2 ms en vez de 750. La caché es la primera técnica que hay que considerar en cualquier feature de IA de alto volumen, porque el tráfico repetido es dinero que estás pagando dos veces —y muchas veces, muchas más de dos—.

Conexión con el módulo: esta lección cierra el par de palancas de costo. La lección 3 te dio el presupuesto; la 4 lo atacó abaratando cada llamada (cascade); esta lo ataca eliminando llamadas repetidas (caché). Son complementarias y se acumulan: recuerda que el cascade solo dejaba la búsqueda de Mercado en $16,241/mes, todavía sobre el presupuesto de $3,000 —la caché es lo que falta para bajar más—. La lección 6 (async/streaming) atacará la latencia de cola que ni cascade ni caché arreglan. Y la lección 7 compone las tres y demuestra —con los números— que juntas meten la feature en su presupuesto. La caché también toca una idea de las lecciones 3 y siguientes: sirve datos "viejos" (la respuesta guardada puede quedar desactualizada), lo que introduce un trade-off de frescura que veremos en la profundización.

La caja rápida del súper y el cliente que ya pagó

Piénsalo así. En el supermercado, no todos hacen la misma fila. Hay una caja rápida para el que lleva pocos artículos, y hay algo aún más rápido: el cliente que ya pagó y solo vuelve a recoger algo que dejó apartado —ese ni siquiera hace fila, se lo entregan al instante—. La caché es ese segundo cliente. La primera vez que alguien busca "iphone", el sistema hace el trabajo completo: llama al LLM, paga sus tokens, espera sus 750 ms, y —clave— guarda la respuesta. La segunda vez que alguien busca "iphone", no repite el trabajo: saca la respuesta guardada y la entrega al instante, a costo cero, como el paquete apartado. Solo las queries nuevas hacen la fila completa (la llamada al LLM); las repetidas se sirven de lo ya hecho.

La palabra que mide qué tan bien funciona esto es el hit-rate: de todas las queries que llegan, ¿qué fracción encuentra su respuesta ya guardada (un "hit") en vez de tener que calcularla (un "miss")? Un hit-rate del 50% significa que la mitad de las queries se sirven gratis y al instante; un hit-rate del 70%, que solo tres de cada diez llegan a molestar al LLM. Como el ahorro es directamente proporcional al hit-rate —cada hit es una llamada que no pagaste—, subir el hit-rate es subir el ahorro, punto por punto.

Y aquí aparece la pregunta interesante, que es donde vive el diseño: ¿cuándo dos queries son "la misma"? Si un cliente busca "iphone" y otro busca "iPhone" (con mayúscula) o "iphone " (con un espacio) o "teléfono iphone" (otro orden), ¿son la misma búsqueda o tres distintas? Una caché exacta dice "solo si el texto es idéntico letra por letra" —y deja pasar como nuevas las variantes—. Una caché semántica dice "si tienen la misma intención, aunque estén escritas distinto" —y atrapa las variantes como repeticiones—. Cuanto más laxa la definición de "la misma", más hits, más ahorro. Esta lección es aprender a montar la caja rápida y a decidir qué tan generosa es al reconocer al cliente que ya pagó.

Ejemplo trabajado: hit-rate y ahorro, exacta contra semántica

Vamos a montar una caché sobre un workload realista y a medir su hit-rate y su ahorro, comparando tres formas de decidir "la misma query". El escenario es el de la vida real de una búsqueda: los usuarios buscan lo mismo escrito de mil formas distintas. Generamos 3000 búsquedas sobre un catálogo de 256 intenciones (combinaciones de adjetivo + sustantivo, tipo "wireless earbuds") con popularidad Zipf —unas pocas concentran casi todo el tráfico— más un 20% de "cola larga": búsquedas raras que nunca se repiten. Y cada búsqueda se renderiza con una variante de superficie de su intención: distinto orden de palabras, distinto uso de mayúsculas, distinto espaciado. Así, la misma intención rara vez aparece dos veces idéntica —justo el caso que separa la caché exacta de la semántica—.

Un miss cuesta una llamada al modelo (usamos el strong con una query típica: 750 ms, $0.0084). Un hit cuesta casi nada: leer del caché, ~2 ms y $0. Las tres claves de caché, de más estricta a más laxa:

  • raw: la query tal cual se escribió (solo repeticiones literales cuentan).
  • exact: normaliza mayúsculas y espacios (" ".join(q.lower().split())) —atrapa las variantes de case y spacing—.
  • semantic: además ordena las palabras (tuple(sorted(...))) —atrapa también el distinto orden de palabras, near-duplicates de la misma intención—.
# Leccion 05 — cache de respuestas (muchas queries se repiten)
# STUB del LLM: todo SIMULADO. Cero red, cero API, cero claves. Semilla fija.
import random

MODELS = {
    "cheap":  dict(usd_in=0.0008, usd_out=0.004, base_ms=90,  ms_per_tok=0.4),
    "strong": dict(usd_in=0.008,  usd_out=0.040, base_ms=300, ms_per_tok=3.0),
}
def call_llm(model, in_tokens, out_tokens):
    m = MODELS[model]
    return m["base_ms"] + out_tokens * m["ms_per_tok"], \
           (in_tokens/1000)*m["usd_in"] + (out_tokens/1000)*m["usd_out"]

# Un MISS cuesta una llamada al modelo (strong, query tipica). Un HIT es casi gratis.
MISS_MS, MISS_COST = call_llm("strong", 300, 150)   # (750.0, 0.0084)
HIT_MS, HIT_COST = 2.0, 0.0                          # leer del cache: ~2 ms, $0

# --- El workload: 3000 busquedas. Los usuarios buscan lo MISMO escrito distinto. ---
# 256 intenciones (adjetivo x sustantivo) con popularidad Zipf, + 20% de cola larga unica.
ADJ = ["wireless","gaming","leather","stainless","portable","waterproof","ergonomic","compact",
       "premium","budget","vintage","smart","foldable","insulated","rechargeable","adjustable"]
NOUN = ["earbuds","laptop","case","bottle","chair","lamp","backpack","speaker","keyboard","desk",
        "jacket","kettle","boots","watch","monitor","scale"]
INTENTS = [f"{a} {n}" for a in ADJ for n in NOUN]
WEIGHTS = [1.0/(i+1) for i in range(len(INTENTS))]

random.seed(11)
N, P_UNIQUE = 3000, 0.20
_uid = [0]
def render(intent):
    """La misma intencion, escrita cada vez con distinto orden, spacing y case."""
    words = intent.split()
    words = random.choice([words, words[::-1]])          # orden de palabras
    sep   = random.choice([" ", "   "])                  # 1 o 3 espacios
    case  = random.choice([str.lower, str.upper, str.title])
    return case(sep.join(words))
def one_query():
    if random.random() < P_UNIQUE:                       # cola larga: nunca se repite
        _uid[0] += 1
        return f"rare oneoff {_uid[0]}"
    return render(random.choices(INTENTS, weights=WEIGHTS, k=1)[0])
queries = [one_query() for _ in range(N)]

# --- Tres claves de cache, de mas estricta a mas laxa ---
def key_raw(q):      return q                                    # tal cual se escribio
def key_exact(q):    return " ".join(q.lower().split())          # normaliza case + spacing
def key_semantic(q): return tuple(sorted(key_exact(q).split()))  # + invariante al orden

def run_cache(key_fn):
    cache, hits, cost, ms = set(), 0, 0.0, 0.0
    for q in queries:
        k = key_fn(q)
        if k in cache:
            hits += 1; cost += HIT_COST; ms += HIT_MS
        else:
            cache.add(k); cost += MISS_COST; ms += MISS_MS
    return hits/N, cost, ms

base_cost, base_ms = N*MISS_COST, N*MISS_MS
print(f"{'strategy':<16}{'hit_rate':>10}{'total_cost':>12}{'cost_saved':>12}{'avg_lat_ms':>12}")
print(f"{'no_cache':<16}{'0.0%':>10}{base_cost:>12.4f}{'0.0%':>12}{base_ms/N:>12.1f}")
for name, fn in (("cache_raw", key_raw), ("cache_exact", key_exact), ("cache_semantic", key_semantic)):
    hr, cost, ms = run_cache(fn)
    print(f"{name:<16}{hr*100:>9.1f}%{cost:>12.4f}{(1-cost/base_cost)*100:>11.1f}%{ms/N:>12.1f}")

Qué esperar. Al correrlo:

strategy          hit_rate  total_cost  cost_saved  avg_lat_ms
no_cache              0.0%     25.2000        0.0%       750.0
cache_raw            47.7%     13.1796       47.7%       393.2
cache_exact          66.9%      8.3328       66.9%       249.3
cache_semantic       72.5%      6.9300       72.5%       207.7

Aquí está la caja rápida funcionando, y aquí está la historia de cómo "qué cuenta como la misma query" mueve el ahorro. Léela renglón por renglón.

Cachear, a secas, ya es enorme. Pasar de no-caché (0% hit, $25.20, 750 ms de promedio) a la caché más tonta, raw (que solo atrapa repeticiones literales), ya te da 47.7% de hit-rate: casi la mitad de las búsquedas se sirven gratis. El costo se parte a la mitad ($13.18) y la latencia promedio también (393 ms, porque la mitad ahora llega en 2 ms). Y fíjate: el ahorro de costo es exactamente igual al hit-rate —47.7% de hits = 47.7% de ahorro—, porque cada hit es una llamada que no pagaste. El hit-rate es el ahorro. Esa es la métrica que gobierna todo.

Normalizar (exacta) sube el hit-rate un montón. La caché exact —que colapsa mayúsculas y espacios, tratando "iphone", "IPHONE" e "iphone " como la misma— salta a 66.9% de hit-rate: casi 20 puntos porcentuales más que raw. ¿Por qué tanto? Porque en el mundo real la gente escribe la misma cosa con casing y espaciado distintos todo el tiempo, y la caché raw los contaba como búsquedas nuevas —un miss innecesario cada vez—. Normalizar es barato (una transformación de string) y recupera todo ese tráfico: el costo baja de $13.18 a $8.33. Es la mejora de mayor relación beneficio/esfuerzo de la lección.

Semántica (near-duplicates) sube un poco más. La caché semantic —que además ignora el orden de las palabras, tratando "wireless earbuds" y "earbuds wireless" como la misma— llega a 72.5% de hit-rate: casi 6 puntos más que exact. Atrapa las repeticiones que difieren solo en el orden, que la exacta dejaba pasar. El costo baja a $6.93 y la latencia promedio a 208 ms. La ganancia sobre la exacta es menor que la de la exacta sobre la raw —porque las variantes de orden son menos comunes que las de case/spacing— pero es real, y en features de mucho volumen esos 6 puntos son dinero.

Junta los tres renglones y tienes la lección: la caché es la palanca de costo más grande a alto volumen —del 0% al 72.5% de ahorro solo por no repagar— y qué tan laxa sea la definición de "la misma query" decide cuánto ahorras. La gran ganancia es cachear a secas (0 → 47.7%); normalizar la agranda mucho (→ 66.9%); la semántica la remata (→ 72.5%). Y fíjate en lo que no cachea: la cola larga, ese 20% de búsquedas raras que nunca se repiten, siempre es un miss, sin importar la técnica —por eso ninguna caché llega al 100%—. El techo del hit-rate lo pone cuánto se repite tu tráfico, no lo lista que sea tu caché.

Profundización: caché semántica de verdad, TTL y el peligro del dato viejo

Cómo funciona una caché semántica real. En el ejemplo, la caché "semántica" es una aproximación honesta pero simple: normaliza y ordena palabras. Una caché semántica de producción va más allá: convierte cada query en un embedding (un vector que representa su significado) y considera "la misma" a dos queries cuyos vectores están muy cerca —así atrapa no solo "earbuds wireless" sino "audífonos inalámbricos", "wireless headphones", sinónimos y parafraseos—. Cómo se calculan esos embeddings y cómo se busca el más cercano es mecánica de AI Engineering (vector stores, similitud coseno) y queda fuera de este módulo. Lo que sí es de aquí es la decisión de arquitectura: una caché semántica sube el hit-rate (atrapa más repeticiones) pero introduce un riesgo —dos queries "cercanas" pueden no ser realmente la misma, y servir la respuesta de una para la otra sería un error—. Cuanto más laxa la caché, más hits pero más riesgo de servir una respuesta que no corresponde. Ese es el trade-off que decides al elegir el umbral de similitud.

El TTL y el peligro del dato viejo. Una respuesta cacheada es, por definición, vieja: se calculó en el pasado, y el mundo pudo cambiar desde entonces. Si cacheas la búsqueda "iphone" y el precio del iphone cambia, la caché sigue sirviendo el precio viejo hasta que la invalides. Por eso toda caché tiene un TTL (time-to-live): cuánto tiempo una entrada se considera fresca antes de recalcularla. Un TTL largo maximiza el hit-rate (menos recálculos) pero sirve datos más viejos; un TTL corto mantiene la frescura pero baja el hit-rate. Es el mismo trade-off performance↔frescura que el cache clásico —cachear compra velocidad y costo a cambio de frescura—, y la decisión depende de qué tan rápido cambia el dato: la descripción de un producto tolera un TTL largo (rara vez cambia); el precio o el stock, uno corto (cambian seguido). Servir un dato viejo sin querer es el error clásico de la caché, y el TTL es la perilla para controlarlo.

Qué cachear y qué no. No todo se cachea igual. Las queries de solo lectura y estables (búsquedas de catálogo, FAQs de soporte) son ideales: se repiten mucho y su respuesta cambia poco. Las queries personalizadas (que dependen del usuario específico, su historial, su carrito) cachean mal —la respuesta a "recomiéndame algo" para Ana no sirve para Beto—, así que o no se cachean, o se cachea por usuario (menos hits). Y las queries que disparan una acción (no solo leen, sino que hacen algo: crear un pedido, mandar un correo) no se cachean nunca —servir una respuesta cacheada para una acción sería ejecutar la acción con datos viejos, o no ejecutarla creyendo que ya se hizo—. La caché es para leer, no para actuar; la frontera entre proponer y disponer (que el módulo 6 de la guía trata a fondo) también aplica aquí.

Errores comunes

No cachear queries repetidas (de omisión). Qué pasa: la feature llama al LLM en cada query, incluso para las que se repiten mil veces al día. Se paga (y se espera) el trabajo completo por respuestas que ya se calcularon. A alto volumen, esto es tirar la mitad del presupuesto o más. Por qué pasa: cachear se ve como "optimización prematura" y se pospone, o no se nota cuánto tráfico es repetido hasta que se mide. Cómo detectarlo: si no sabes el hit-rate potencial de tu feature (qué fracción del tráfico se repite), no sabes cuánto estás pagando de más. Cómo corregirlo: mide la repetición de tu tráfico y mete una caché —empezar por la más simple (raw o exact) ya recupera la mayor parte del ahorro—.

Cachear con la clave demasiado estricta (de hit-rate bajo). Qué pasa: se mete una caché raw (solo repeticiones literales) y el hit-rate sale bajo, porque la gente escribe la misma query con distinto case, spacing y orden, y cada variante es un miss. El equipo concluye "la caché no ayudó mucho" cuando el problema era la clave. Por qué pasa: la clave raw es la implementación de un renglón, la obvia, y no se piensa en las variantes de superficie. Cómo detectarlo: si tu hit-rate es sospechosamente bajo para un tráfico que sabes que se repite, la clave está siendo demasiado literal. Cómo corregirlo: normaliza la clave (case + spacing) para saltar de raw a exact —en el ejemplo, casi 20 puntos de hit-rate gratis—; y considera la semántica si el idioma/orden varía mucho.

Cachear datos que cambian, sin TTL (de frescura). Qué pasa: se cachea todo con un TTL largo (o sin invalidación), incluidos datos que cambian —precios, stock, estados de pedido—, y la feature empieza a servir información vieja: precios desactualizados, "en stock" cuando ya se agotó. El bug es invisible hasta que un cliente reclama. Por qué pasa: la caché se monta pensando en el ahorro (hit-rate alto = TTL largo) y se olvida que la respuesta envejece. Cómo detectarlo: si cacheas un dato que cambia y no puedes decir cuánto tiempo puede quedar viejo, no controlaste la frescura. Cómo corregirlo: pon un TTL acorde a qué tan rápido cambia el dato (corto para precio/stock, largo para descripciones), o invalida la entrada cuando el dato subyacente cambia. Y nunca caches una query que dispara una acción.

Ejercicios

Ejercicio 1 — Traduce el hit-rate a dinero. La búsqueda semántica de Mercado, sin caché, cuesta $25,200/mes (100k búsquedas/día al strong model). Si metes una caché con 66.9% de hit-rate (la exact del ejemplo), ¿cuánto cuesta al mes? ¿Y si subes a 72.5% (la semantic)? ¿Vale la pena el salto de exacta a semántica?

Ver solución

Como el ahorro de costo es igual al hit-rate (cada hit es una llamada no pagada), el costo mensual con caché es costo_sin_cache × (1 − hit_rate):

  • Con exact (66.9%): $25,200 × (1 − 0.669) = $25,200 × 0.331 = $8,341/mes.
  • Con semantic (72.5%): $25,200 × (1 − 0.725) = $25,200 × 0.275 = $6,930/mes.

El salto de exacta a semántica ahorra $8,341 − $6,930 = $1,411/mes adicionales. ¿Vale la pena? Depende del costo de montar y operar la caché semántica —que es más compleja: necesita embeddings, un vector store, y afinar un umbral de similitud, con el riesgo de servir una respuesta "cercana" que no corresponda—. $1,411/mes es real y a mayor volumen crece, así que en una feature de alto tráfico suele justificarse; pero la mayor ganancia ya la capturó la caché exacta (de $25,200 a $8,341), que es mucho más barata de montar. La regla: empieza por la exacta (gran ahorro, poco esfuerzo) y sube a semántica solo si el ahorro incremental justifica su complejidad y su riesgo. No saltes directo a lo complejo.

Ejercicio 2 — Caché más cascade, juntos. Hasta ahora la caché sirve los misses con el strong model. Pero ¿y si combinas caché con cascade? Los hits salen gratis (0%), y los misses van al cascade (que abarata según dificultad). Razona por qué la ganancia incremental del cascade se achica cuando ya tienes una caché con hit-rate alto, y qué implica eso para el orden en que aplicas las técnicas.

Ver solución

Con una caché de, digamos, 70% de hit-rate, solo el 30% del tráfico (los misses) llega al LLM. El cascade opera únicamente sobre ese 30% —los hits ya no tocan ningún modelo, salen gratis—. Así que el ahorro del cascade, que sobre el 100% del tráfico recortaba ~44% del costo, ahora recorta ~44% pero solo del 30% que quedó: su impacto absoluto sobre el total es mucho menor, porque la caché ya se comió el 70% barato.

Dicho de otro modo: la caché y el cascade atacan el mismo tráfico fácil/repetido, así que se solapan —no se suman de forma independiente—. La caché elimina las repeticiones (que suelen ser fáciles); el cascade abarata lo que queda (que tiende a ser más único, más difícil, más orientado al strong model). Por eso la ganancia del cascade encima de una buena caché es modesta.

Implicación para el orden: conviene cachear primero (elimina el grueso del tráfico gratis) y luego aplicar el cascade sobre los misses (abarata lo que sí llega al LLM). Es el orden que la lección 7 va a montar: caché → cascade. Y la moraleja general: las técnicas se componen pero no suman, porque se solapan sobre el tráfico barato —hay que medir la combinación, no sumar los ahorros por separado—.

Ejercicio 3 — ¿Qué cachear en el agente de soporte? El agente de soporte de Mercado responde muchos tipos de mensaje. Para cada uno, di si lo cachearías, con qué TTL (corto/largo/nunca) y por qué: (a) "¿cuál es su política de devoluciones?"; (b) "¿dónde está mi pedido #48213?"; (c) "quiero cancelar mi pedido #48213".

Ver solución
  • (a) Política de devoluciones → cachear, TTL largo. Es una pregunta frecuentísima (mucho hit-rate potencial) y la respuesta cambia rara vez (la política es estable). Cachearla es ideal: se repite mucho, envejece lento. Un TTL largo (horas o días) maximiza el ahorro con riesgo mínimo de servir algo viejo; y cuando la política cambie de verdad, se invalida la entrada.
  • (b) "¿Dónde está mi pedido #48213?" → cachear con cuidado, TTL corto (o cachear el patrón, no la respuesta). El estado de un pedido cambia (en tránsito → entregado), así que una respuesta cacheada envejece rápido. Si la cacheas, el TTL debe ser corto (minutos) para no decir "en tránsito" cuando ya se entregó. Mejor aún: aquí la respuesta depende de un dato en vivo (el estado del pedido), así que en vez de cachear la respuesta del LLM, se consulta el estado actual cada vez —la caché sirve para respuestas estables, no para datos que cambian por segundo—.
  • (c) "Quiero cancelar mi pedido #48213" → NUNCA cachear. Esto no es una consulta de lectura: es una acción (cancelar un pedido). Servir una respuesta cacheada aquí sería desastroso —o creería que ya se canceló cuando no, o dispararía la cancelación con datos viejos—. Las queries que actúan sobre el estado del sistema no se cachean jamás; la caché es para leer, no para hacer.

La regla que emerge: cachea lo estable y de solo lectura (a), maneja con cuidado y TTL corto lo que cambia (b), y nunca caches una acción (c). La frescura del dato y la distinción leer-vs-actuar deciden qué entra a la caja rápida.

Resumen y siguiente paso

En esta lección montaste la otra gran palanca de costo: la caché, que no vuelve a calcular lo ya calculado. Con la caja rápida del súper viste que la primera búsqueda hace el trabajo completo y lo guarda, y las siguientes se sirven al instante a costo cero. Mediste la métrica que gobierna el ahorro —el hit-rate, igual al porcentaje de ahorro porque cada hit es una llamada no pagada— y comparaste tres claves sobre 3000 búsquedas donde los usuarios escriben lo mismo de formas distintas: raw 47.7% → exact 66.9% → semantic 72.5%, con el costo cayendo de $25.20 a $6.93 y la latencia promedio de 750 a 208 ms. Aprendiste que cachear a secas ya es enorme, que normalizar (exacta) es la mejora de mayor relación beneficio/esfuerzo, y que la semántica remata atrapando near-duplicates. Y viste los límites: la cola larga nunca cachea (el techo del hit-rate lo pone tu tráfico), el dato cacheado envejece (TTL, trade-off frescura), y las acciones nunca se cachean (la caché lee, no actúa).

Antes de avanzar deberías poder: diseñar una caché para una feature y elegir su clave (raw/exact/semántica) según cómo varía el tráfico; medir su hit-rate y traducirlo a ahorro de costo y latencia; explicar por qué la caché y el cascade se componen pero no suman (se solapan sobre el tráfico barato); y decidir qué cachear, con qué TTL, y qué no cachear nunca (datos que cambian, acciones).

Lo que sigue ataca el problema que ni el cascade ni la caché resuelven: la latencia percibida en las queries que sí llegan al LLM y tardan segundos. En la lección 6 vas a ver dos técnicas: el streaming, que muestra la respuesta token a token para que el usuario vea texto casi de inmediato en vez de esperar el bloque completo; y el async, que saca el trabajo pesado del camino crítico por completo, para las operaciones donde el usuario ni siquiera debería esperar. Vas a medir cómo el streaming baja la latencia percibida aunque el trabajo real siga tardando lo mismo, y cuándo cada técnica corresponde. Es la palanca de latencia que completa el juego.

Recursos