Prefix cache: ingeniería del hit rate para pasar del 15% al 75%
TL;DR
El prefix cache de vLLM almacena los bloques de KV cache de prefijos compartidos y los reutiliza en requests posteriores. Un hit evita recalcular ese prefijo: el TTFT cae al coste del sufijo variable únicamente. En workloads enterprise con system prompts fijos —RAG, chatbots de dominio, asistentes con instrucciones largas— el hit rate debería ser >70%. En la práctica es 10-20% por razones completamente evitables. Este artículo las identifica, las corrige y da las queries OTel para confirmar el resultado.
La analogía
Un intérprete de conferencias simultáneas que tiene que traducir los discursos de veinte ponentes. Todos empiezan con el mismo preámbulo protocolar de dos páginas: la declaración de la conferencia, las reglas de conducta, el programa del día. Un intérprete sin memoria relee las dos páginas para cada ponente antes de empezar a traducir su discurso específico. Un intérprete con notas buenas las lee una vez, las archiva, y cuando empieza el segundo ponente pasa directamente al discurso.
El prefix cache es ese archivo. El hash del prefijo es la referencia que permite saltar a la parte nueva. Pero si el preámbulo cambia aunque sea en una palabra — porque alguien pone la fecha del día — el intérprete tiene que releer todo desde el principio.
Cómo funciona el hash de prefix cache
vLLM divide el KV cache en bloques de 16 tokens. Cada bloque tiene un hash calculado sobre su contenido exacto. Cuando llega un nuevo request, vLLM comprueba si algún bloque inicial del prompt ya está en cache comparando hashes.
El hash se calcula sobre el contenido byte a byte de los tokens. Cualquier diferencia — un espacio, un carácter diferente, un token de más — produce un hash completamente distinto. No hay matching parcial dentro de un bloque.
Consecuencia directa: si tu system prompt tiene 512 tokens y el token número 3 cambia entre requests (porque interpolas una fecha, un ID, un número de versión), ningún bloque hace hit aunque el 99% del texto sea idéntico.
Bloque 0 (tokens 0-15): hash = a3f7... ← ¿en cache?
Bloque 1 (tokens 16-31): hash = 9d2c... ← ¿en cache?
...
Bloque 31 (tokens 496-511): hash = 7e1a... ← ¿en cache?
Si el bloque 0 no hace hit (porque su contenido cambió), los bloques 1-31 tampoco se comprueban aunque sean idénticos — el prefix cache es secuencial.
Auditoría: por qué tu hit rate real es bajo
Antes de cambiar nada, hay que saber qué está rompiendo el hash. El método más directo: extraer los últimos 1000 prompts de producción y calcular qué fracción del prefix varía.
# audit_prefix_cache.py
import langfuse, hashlib, collections
from transformers import AutoTokenizer
client = langfuse.Langfuse()
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-14B-Instruct")
traces = client.fetch_traces(limit=1000).data
prompts = [t.input for t in traces if t.input]
# Tokenizar y extraer los primeros 512 tokens (el system prompt típico)
prefixes = []
for prompt in prompts:
tokens = tokenizer.encode(prompt, add_special_tokens=False)
prefix_tokens = tuple(tokens[:512])
prefixes.append(prefix_tokens)
# ¿Cuántos prefixes únicos hay?
unique = len(set(prefixes))
total = len(prefixes)
print(f"Prefixes únicos: {unique}/{total} ({unique/total*100:.1f}%)")
print(f"Hit rate teórico si todos fueran iguales: {(1 - unique/total)*100:.1f}%")
# Encontrar qué token difiere entre el prefix más común y los demás
from collections import Counter
most_common_prefix = Counter(prefixes).most_common(1)[0][0]
divergence_positions = []
for prefix in prefixes:
if prefix == most_common_prefix:
continue
for i, (a, b) in enumerate(zip(most_common_prefix, prefix)):
if a != b:
divergence_positions.append(i)
break
if divergence_positions:
pos = Counter(divergence_positions).most_common(1)[0][0]
token_text = tokenizer.decode([most_common_prefix[pos]])
print(f"\nDivergencia más frecuente en posición {pos}: '{token_text}'")
print("→ El token en esa posición varía entre requests")
Los culpables más comunes, en orden de frecuencia:
1. Timestamps y fechas:
# ❌ Rompe el hash en cada request
system = f"Fecha actual: {datetime.now().strftime('%Y-%m-%d %H:%M')}. Eres un asistente..."
# ✅ Sacar la fecha del system prompt
system = "Eres un asistente especializado en infraestructura cloud."
# Pasar la fecha como parte del mensaje del usuario si es necesaria
2. IDs de sesión y usuarios:
# ❌
system = f"Usuario ID: {user_id}. Preferencias: {user_prefs}. Eres un asistente..."
# ✅ Separar lo estático de lo contextual
system = "Eres un asistente especializado." # siempre igual
# Agregar contexto de usuario como primer mensaje del historial
3. Versiones de prompt interpoladas:
# ❌
system = f"[v{PROMPT_VERSION}] Eres un asistente..." # cambia con cada deploy
# ✅ No versionar en el texto, versionar en el nombre del prompt en Langfuse
system = "Eres un asistente..."
4. Few-shots dinámicos:
# ❌ Ejemplos recuperados aleatoriamente de un pool
examples = random.sample(example_pool, k=3)
system = f"Ejemplos:\n{format_examples(examples)}\n\nEres un asistente..."
# ✅ Few-shots fijos ordenados siempre igual
FIXED_EXAMPLES = [example_pool[0], example_pool[1], example_pool[2]]
system = f"Ejemplos:\n{format_examples(FIXED_EXAMPLES)}\n\nEres un asistente..."
Ingeniería de templates: la estructura que maximiza hits
El principio es simple: todo lo estático va antes, todo lo dinámico va después. El prefix cache es secuencial — una vez que un bloque no hace hit, el resto tampoco se busca.
ESTRUCTURA ÓPTIMA para maximizar prefix cache:
┌──────────────────────────────────────────────┐
│ BLOQUE ESTÁTICO (tokens 0-511) │ ← hit rate ~100%
│ System prompt invariante │
│ Instrucciones fijas │
│ Few-shots ordenados siempre igual │
├──────────────────────────────────────────────┤
│ BLOQUE SEMI-ESTÁTICO (tokens 512-1023) │ ← hit rate ~60-80%
│ Documentos RAG para esta sesión │
│ Historial de conversación hasta ahora │
├──────────────────────────────────────────────┤
│ BLOQUE DINÁMICO (tokens 1024+) │ ← hit rate ~0% (esperado)
│ Mensaje actual del usuario │
│ Contexto específico de este request │
└──────────────────────────────────────────────┘
Para RAG específicamente: si los documentos recuperados son los mismos para un conjunto de queries similares (muy frecuente en RAG sobre documentos corporativos fijos), ordenarlos siempre en el mismo orden (por ID, por score fijo, no por score variable) multiplica el hit rate del bloque semi-estático.
Routing prefix-aware: el siguiente nivel
Con una sola instancia de vLLM, el prefix cache funciona automáticamente. El problema aparece con múltiples réplicas: el load balancer distribuye requests round-robin, y el prefix cacheado en la réplica A no sirve de nada cuando el request llega a la réplica B.
La solución es prefix-aware routing: enviar requests con el mismo prefix al mismo nodo.
Con Ray Serve (integración nativa):
# ray_serve_prefix_router.py
from ray import serve
from ray.serve.llm import LLMConfig, build_llm_deployment
@serve.deployment
class PrefixAwareRouter:
def __init__(self, replicas):
self.replicas = replicas # lista de handles de vLLM
async def __call__(self, request):
body = await request.json()
messages = body.get("messages", [])
# Calcular hash del system prompt (prefix estático)
system_content = ""
for msg in messages:
if msg["role"] == "system":
system_content = msg["content"]
break
prefix_hash = hash(system_content)
# Routing determinístico: mismo hash → mismo nodo
replica_idx = prefix_hash % len(self.replicas)
return await self.replicas[replica_idx].remote(request)
Con un gateway L7 (Nginx/Traefik):
# nginx.conf — routing por header X-Prefix-Hash
upstream vllm_backends {
hash $http_x_prefix_hash consistent;
server vllm-0:8000;
server vllm-1:8000;
server vllm-2:8000;
server vllm-3:8000;
}
El cliente calcula el hash del prefix estático y lo incluye como header:
import hashlib, requests
def llm_request(messages, base_url):
system_msg = next((m["content"] for m in messages if m["role"] == "system"), "")
prefix_hash = hashlib.sha256(system_msg.encode()).hexdigest()[:16]
return requests.post(
f"{base_url}/v1/chat/completions",
json={"messages": messages, "model": "mi-modelo"},
headers={"X-Prefix-Hash": prefix_hash}
)
Medir el impacto con OTel
# Hit rate actual (0.0 a 1.0) — objetivo > 0.70 con workloads enterprise
vllm:gpu_prefix_cache_hit_rate
# TTFT por percentil — debe caer cuando el hit rate sube
histogram_quantile(0.50, rate(vllm:time_to_first_token_seconds_bucket[5m]))
histogram_quantile(0.95, rate(vllm:time_to_first_token_seconds_bucket[5m]))
La correlación inversa entre hit rate y TTFT es la prueba de que el cache está funcionando. Si el hit rate sube del 15% al 70% y el TTFT p50 no cambia, hay un problema de configuración: el cache puede estar desactivado o el routing no está enviando los requests al nodo correcto.
Query de correlación en Grafana (panel de dos ejes):
# Eje Y izquierdo: hit rate
vllm:gpu_prefix_cache_hit_rate
# Eje Y derecho: TTFT p50 (invertido)
histogram_quantile(0.50, rate(vllm:time_to_first_token_seconds_bucket[5m]))
La pendiente inversa debe ser visible: cuando el hit rate baja (pico de requests con prompts nuevos), el TTFT sube. Cuando el hit rate se estabiliza (usuarios repitiendo el mismo flujo), el TTFT baja.
El impacto en números
Para un sistema con 100 req/min, system prompt de 512 tokens y hit rate antes/después:
| Métrica | Hit rate 15% | Hit rate 75% | Diferencia |
|---|---|---|---|
| Tokens de prefill por minuto | 5.100 | 12.800 — 50% cacheados → 6.400 efectivos | −37% carga |
| TTFT p50 (prompt 512 + sufijo 100) | ~820 ms | ~180 ms (sólo sufijo) | −78% |
| Capacidad de prefill liberada | — | +1.200 tok/min | disponible para más requests |
El 75% de hit rate en este ejemplo equivale a poder atender un 37% más de requests con el mismo hardware, porque el trabajo de prefill de 3 de cada 4 requests ya está hecho.
Cuándo el prefix cache no ayuda
El prefix cache es ineficaz en workloads donde cada request tiene un prompt completamente único: traducciones de documentos distintos cada vez, análisis de código con contexto siempre diferente, generación creativa sin sistema. En estos casos, el hit rate estructuralmente no puede superar el 5-10% y el esfuerzo de ingeniería de templates no compensa.
La señal: si tu p99 de longitud de input es mayor que el p50, tienes alta varianza de prompts y el prefix cache aporta poco. Si el p50 y el p99 son similares (prompts consistentes), el prefix cache es la palanca más barata disponible.
Ver también
- https://blog.lo0.es/posts/prefill-optimizaciones-vllm/ —
--enable-prefix-cachingy la interacción con chunked prefill: sólo el primer chunk se beneficia del cache, lo que afecta al presupuesto óptimo demax-num-batched-tokens - https://blog.lo0.es/posts/kv-cache-fundamentos/ — la estructura de bloques sobre la que opera el prefix cache: por qué la granularidad de 16 tokens importa para el diseño de templates
- https://blog.lo0.es/posts/batch-sizing-vllm-grid-search/ — el grid search que determina el
max-num-seqsóptimo, que interactúa con el número de bloques disponibles para el cache - https://blog.lo0.es/posts/router-inferencia-llm-gateway-l7/ — el gateway L7 donde se implementa el routing prefix-aware via header
- https://blog.lo0.es/posts/vllm-otel-instrumentacion-optimizaciones/ — cómo configurar
gpu_prefix_cache_hit_rateen el dashboard de Grafana y la alerta cuando cae por debajo del umbral objetivo