Enrutado por prefijo: lo que LiteLLM no hace, quién sí lo hace y cuánto mejora de verdad
Quinto artículo del track operativo de la capa de control. El par con Langfuse cubrió la observabilidad, el día 2 del proxy la disponibilidad, las claves virtuales el gobierno y humanos y agentes la convivencia de dos clases de tráfico. Aquí se desmonta una afirmación mía de junio. Todo lo que sigue está verificado contra LiteLLM 1.102.0 (commit del 10 de septiembre de 2026), vLLM 0.29.0 y las ramas principales de production-stack, llm-d y Dynamo de la segunda semana de septiembre de 2026.
TL;DR
LiteLLM no hace enrutado consciente del prefijo, ni en la versión abierta ni en la de pago. No es una cuestión de licencia. Una búsqueda sobre el árbol completo de kv_aware, cache_aware, radix, prefix_cache y block_hash devuelve cero coincidencias en el router. Tampoco hay ninguna llamada del router hacia el /metrics ni hacia ningún endpoint de vLLM. En junio escribí lo contrario en el artículo del router de inferencia y estaba equivocado.
Lo que LiteLLM tiene es afinidad, que es otra cosa. Cuatro comprobaciones previas pegan una petición a una réplica por identificador de sesión, por hash de la clave, por identificador de respuesta anterior o por hash exacto de un prefijo marcado. Ninguna está activa por defecto. La afinidad reparte conversaciones; el enrutado por prefijo reparte prefijos compartidos entre conversaciones distintas, que es el caso que multiplica el rendimiento.
La única que mira el prompt es frágil de tres maneras. Si el cliente no envía cache_control, la clave sale nula y la comprobación no hace nada. Si el punto de corte va en el último turno, que es lo que hace la inyección automática por defecto, la clave cambia en cada turno y nunca acierta. Y el TTL del pin son 300 segundos escritos a fuego en dos líneas, mientras el proveedor ofrece cachés de una hora.
El motor no responde preguntas, publica eventos. No existe ningún endpoint de vLLM al que preguntar si tiene un prefijo. Lo que hay es un socket ZMQ que emite bloques almacenados y desalojados en msgpack, apagado por defecto, con descarte silencioso cuando el consumidor no sigue el ritmo. Un router que quiera ser exacto tiene que replicar el hash del motor entero: SHA256 encadenado, semilla determinista, granularidad del bloque y las claves extra en su orden.
Y por defecto no publica los aciertos, solo las altas. El índice de un router que solo escuche se desincroniza del LRU real del motor sin enterarse. Para ver los aciertos hay que pedirlo petición a petición con kv_cache_report_mode: full.
Los tres routers que sí lo hacen son muy distintos entre sí. El de vLLM production-stack indexa trozos de 128 caracteres crudos en un trie que nunca se purga, no modela desalojo y muere en cada reinicio. El de llm-d ofrece un modo aproximado y otro exacto por eventos, todos sus plugins están en alfa o beta, y su tokenizador por defecto no produce tokens reales. El de NVIDIA Dynamo mantiene un árbol radix por eventos con una función de coste ponderada, y trae apagado el parámetro que evita matar de hambre a una réplica recién escalada.
Las mejoras grandes solo aparecen en el mejor caso posible. Los números de dos y tres cifras los publican los propios proyectos, con prefijos sintéticos compartidos y un balanceador redondo como referencia. La medida independiente más completa, de Meta, obtiene 2,3 veces más rendimiento y también identifica regímenes de carga donde la afinidad deja la capacidad entre la mitad y dos tercios de la referencia. Otra, en despliegue multirregión con 89 % de reuso, mide mejoras de un dígito.
La arquitectura correcta tiene dos capas y no una. LiteLLM arriba para identidad, presupuesto y auditoría, apuntando a un solo destino por grupo de modelo. El router consciente del KV debajo, dentro del dominio del motor. Mezclarlo en una sola pieza no es una opción que hoy exista.
Estás aquí: entre el gateway y el motor
Este artículo ocupa el hueco entre dos capas que ya tienen artículo propio. Por arriba, la centralita L7 y el gateway que se opera desde el primer artículo de este track. Por abajo, los fundamentos del KV cache y la ingeniería del hit rate.
El hueco es la pieza que decide a qué réplica va cada petición sabiendo qué tiene cada réplica en memoria. Es la única función del stack que no se puede resolver ni en el gateway ni en el motor por separado.
La analogía: la biblioteca con cuatro mostradores
Una biblioteca con cuatro mostradores. Cada bibliotecario tiene delante una pila con los libros que ha consultado últimamente. Cuando llega un lector pidiendo el volumen tercero de una enciclopedia, si ese volumen está en la pila del mostrador dos, atenderle allí cuesta segundos. En cualquier otro mostrador hay que bajar al depósito.
Un balanceador normal manda al lector al mostrador con menos cola. Es la decisión correcta si todos los mostradores son intercambiables, y es la decisión equivocada aquí, porque no lo son: lo que los diferencia no es la cola, es la pila.
La afinidad de sesión resuelve un caso concreto de esto. Al lector que vuelve se le manda siempre al mismo mostrador, porque probablemente pida el volumen cuarto de la misma enciclopedia. Funciona, y no resuelve el caso general: cuando cien lectores distintos piden el mismo volumen tercero, la afinidad de sesión los reparte por los cuatro mostradores y cuatro bibliotecarios bajan al depósito a por el mismo libro.
El enrutado por prefijo es mirar lo que pide el lector antes de asignarle mostrador. Tiene dos costes que la analogía deja ver enseguida. El primero es que alguien tiene que llevar la cuenta de qué hay en cada pila, y esa contabilidad cuesta trabajo. El segundo es que si todo el mundo pide el volumen tercero, el mostrador dos se convierte en la única ventanilla de la biblioteca mientras las otras tres miran.
La corrección: lo que escribí en junio
En el artículo del router de inferencia, el 2 de junio de 2026, cerré el apartado de enrutado por prefijo con esta frase: “vLLM Production Stack router lo implementa nativamente. NVIDIA Dynamo también. LiteLLM en su versión enterprise tiene un beta.”
Las dos primeras se sostienen. La tercera es falsa y no lo era menos en junio. Al preparar el artículo de humanos y agentes apareció el primer indicio, y la verificación completa sobre la 1.102.0 lo confirma.
Búsqueda insensible a mayúsculas sobre litellm/, enterprise/ y gateway/, de los términos kv_aware, kv-aware, cache_aware, cache-aware, radix, prefix_cache, prefix-cache, kv_cache, block_hash, prefix_hash, prefix_score y longest_prefix: cero coincidencias. Búsqueda de vllm dentro de litellm/router.py, litellm/router_strategy/ y litellm/router_utils/: cero. Búsqueda de num_requests_waiting, gpu_cache_usage o cualquier scrapeo de /metrics de los backends desde el router: cero. El router de LiteLLM nunca le pregunta nada al motor.
Búsqueda de premium_user en los ficheros donde vivirían esas comprobaciones: un solo resultado, y es de otra cosa. No hay una versión de pago con esta función. La única pieza de enrutado que sí está tras licencia son los presupuestos por tag, en litellm/router_strategy/budget_limiter.py.
De dónde salió el error. La documentación de LiteLLM tiene una entrada de asignación dinámica de TPM y RPM marcada como beta y enterprise, y una comprobación previa que se llama prompt_caching. Leídas por encima y sumadas, dan la impresión de un enrutado por prefijo en beta. Leído el código, prompt_caching es otra cosa, y merece un apartado propio porque se sigue recomendando para este problema.
Lo que LiteLLM sí tiene: cuatro afinidades
El campo optional_pre_call_checks admite ocho valores. Ninguno se activa por defecto: apply_default_settings pasa una lista vacía. Uno de los ocho, forward_client_headers_by_model_group, está declarado en el tipo y en el esquema de la interfaz y no lo consume ninguna rama del código. Es un valor muerto.
Los cuatro que pegan una petición a una réplica son estos.
| Comprobación | Qué pega | Dónde guarda | TTL |
|---|---|---|---|
session_affinity | Identificador de sesión | Redis con script Lua, espejo en memoria | deployment_affinity_ttl_seconds, 3600 por defecto, se refresca |
deployment_affinity | Hash de la clave de API | Igual | Igual |
responses_api_deployment_check | previous_response_id | Igual | Igual |
prompt_caching | Hash exacto de un prefijo marcado | DualCache | 300 s, escritos a fuego |
Las otras cuatro son router_budget_limiting, encrypted_content_affinity, enforce_model_rate_limits y el valor muerto.
prompt_caching: por qué no sirve para esto
La clave es un SHA256 sobre la serialización del prefijo cacheable, y “prefijo cacheable” significa todo hasta el último bloque marcado con cache_control: {"type": "ephemeral"} inclusive. Tres consecuencias, comprobadas ejecutando las funciones aisladas sobre el código del repositorio.
Si la petición no lleva ningún cache_control, la extracción devuelve una lista vacía, la clave sale nula y la comprobación no pinea nada. La inyección automática de puntos de corte solo se activa con litellm.enable_anthropic_prompt_caching, que por defecto es falso, y solo para proveedores Anthropic y Bedrock.
Si el punto de corte está en el system prompt, la clave no cambia al añadir turnos y el pin funciona. Este es el caso bueno y exige que el cliente lo marque a propósito.
Si el punto de corte está en el último mensaje, la clave cambia en cada turno y el pin nunca acierta. Y este es justo el caso por defecto de la inyección automática, que coloca un punto en el system y otro en el índice -1. Al ser el segundo el último, el prefijo cacheable pasa a ser la conversación entera.
Encima está el TTL. Dos líneas con ttl=300, una de ellas con el comentario # store for 5 minutes. No hay parámetro que lo cambie. La incidencia 28427 lo tiene abierto desde hace meses sin respuesta de los mantenedores, y apunta al desajuste con las cachés efímeras de una hora que el propio LiteLLM sabe pedir vía cache_control.ttl: "1h".
Hay un matiz que la incidencia no recoge y que empeora el diagnóstico: en el caso por defecto el pin ni siquiera llega a durar cinco minutos, porque la clave cambia antes.
session_affinity: la que sí hay que usar
Para conversaciones, esta es la pieza correcta y está razonablemente construida. El identificador de sesión se busca en este orden exacto de cabeceras:
x-litellm-trace-idx-litellm-session-id- Cualquier
x-<vendor>-session-id, con expresión regular^x-.+-session-id$y valor de al menos ocho caracteres alfanuméricos - Solo si el
User-Agentes de Codex:session-id,session_id,thread-id,conversation_id x-session-ida secas, para opencode
Y si nada casa, dos respaldos: un metadata.user_id con formato Anthropic, y el baggage de W3C con session.id=. El tercer punto es el que captura a Claude Code sin configurar nada.
El almacenamiento es un script Lua en Redis que hace GET, SET NX EX y EXPIRE, de modo que gana el primero que escribe y el TTL se refresca en cada petición que confirma el pin. Es un TTL de inactividad, no de duración total de sesión. Sin Redis degrada a una reserva local del pod, que es atómica pero no compartida.
Un detalle del orden del pipeline que ya salió en el artículo anterior y sigue vigente: las comprobaciones de afinidad corren dentro de async_get_healthy_deployments antes del filtrado por tags. Si el pin devuelve un único deployment y ese deployment no satisface el tag de la petición, el filtro posterior lo elimina y la petición falla. La afinidad no tiene voto sobre los tags.
Los routers que sí leen el prompt, y por qué no valen
LiteLLM tiene cuatro piezas que leen el contenido de los mensajes: AutoRouter con encaje semántico, RequestComplexityRouter, AdaptiveRouter y QualityRouter. Todas viven en async_pre_routing_hook, que se ejecuta antes de buscar deployments sanos.
La razón por la que no resuelven este problema está en qué eligen. Las cuatro eligen grupo de modelo, no réplica. Deciden si una pregunta fácil va al modelo de treinta mil millones de parámetros o al pequeño. Ninguna tiene acceso a la lista de réplicas ni sabe qué hay en la memoria de cada una.
Lo que sí aterrizó: el control de admisión
Una corrección menor al artículo anterior, que lo situaba en la rama 1.101 como novedad próxima. En la 1.102.0 ya está, en litellm/proxy/middleware/admission_control_middleware.py, con los tres ajustes en general_settings: max_in_flight_requests_per_worker, max_queued_requests_per_worker y admission_queue_timeout_seconds, este último con un valor por defecto de 1,0 segundos.
Sigue inactivo mientras no se declare el primero. Expone /health/backlog autenticado y las tres métricas litellm_admission_admitted_requests, litellm_admission_queued_requests y litellm_admission_rejected_requests_total, esta con etiqueta de motivo. Es un semáforo por proceso, no coordinado entre pods.
Lo que el motor expone hacia arriba
Antes de mirar a los routers que sí hacen esto, hay que entender con qué materia prima trabajan. La pregunta natural sería si vLLM tiene un endpoint al que preguntar por un prefijo. No lo tiene.
El único router de caché en los puntos de entrada es vllm/entrypoints/serve/dev/cache/api_router.py, y solo expone escrituras destructivas: POST /reset_prefix_cache, /reset_mm_cache y /reset_encoder_cache. Las tres están detrás de VLLM_SERVER_DEV_MODE=1 y no existen en un despliegue normal. No hay lectura.
Lo que sí hay es un flujo de eventos.
El flujo ZMQ
Se activa con --kv-events-config, y está apagado por defecto. La configuración acepta publicador (null o zmq), endpoint (por defecto tcp://*:5557, que hace bind), un endpoint de repetición opcional, el número de lotes retenidos para esa repetición (10.000), y un límite de marca alta de 100.000.
Ese límite es el que importa operativamente: por encima, ZMQ descarta eventos si el consumidor no sigue el ritmo. Silenciosamente.
El transporte es un socket PUB con serialización msgpack y tramas de tres partes: tópico, número de secuencia de ocho bytes y carga. El número de secuencia permite a un suscriptor pedir la repetición desde donde se quedó a través del socket ROUTER opcional. Con paralelismo de datos el puerto se desplaza por rango, así que hay que suscribirse a 5557+rank.
Tres tipos de evento. BlockStored lleva los hashes, el hash del padre, los identificadores de token, el tamaño de bloque, el nombre del adaptador LoRA, las claves extra, el medio (GPU, CPU o almacenamiento) y un identificador de sesión. BlockRemoved lleva solo hashes. AllBlocksCleared no lleva carga y se emite al resetear el prefix cache.
Se publican una vez por paso del planificador, solo si hay eventos. Con la función apagada el coste es cero. Con ella encendida, los identificadores de token completos de cada bloque viajan por el cable, así que el ancho de banda escala con tokens por segundo y no con peticiones por segundo.
Un ejemplo del propio repositorio de vLLM se declara a sí mismo experimental en su octava línea.
El hash que hay que replicar
Un router exacto no recibe el estado del motor, lo reconstruye. Para eso tiene que calcular los mismos hashes, y el motor los calcula así:
# vllm/v1/core/kv_cache_utils.py
def hash_block_tokens(hash_function, parent_block_hash, curr_block_token_ids, extra_keys):
if not parent_block_hash:
parent_block_hash = NONE_HASH
return hash_function((parent_block_hash, tuple(curr_block_token_ids), extra_keys))
Es encadenado: cada hash identifica el prefijo completo hasta ese límite, no el bloque aislado. El algoritmo por defecto es SHA256, configurable con --prefix-caching-hash-algo entre cuatro valores.
Cinco cosas que un router tiene que acertar para que sus hashes casen con los del motor:
La semilla. Desde la 0.29, NONE_HASH es determinista a partir de una semilla fija, así que dos procesos distintos producen los mismos hashes sin configurar nada. Es un cambio de semántica silencioso respecto a versiones anteriores, que exigían fijar PYTHONHASHSEED. Con los algoritmos xxhash sigue haciendo falta.
El tamaño de bloque. Por defecto 16 tokens, pero el backend de atención puede sobrescribirlo si el usuario no pasó --block-size: 256 con atención dispersa por ventana deslizante, 64 con AITER de ROCm, 64 o más con FlashAttention en XPU. Un router no debe asumir 16, tiene que leerlo del evento.
La granularidad de hashing, que no es necesariamente el tamaño de bloque físico. Existe --prefix-match-unit, que con varios grupos de caché se resuelve al máximo común divisor de los tamaños de los grupos cacheables.
Las claves extra, en su orden: nombre del adaptador LoRA (por nombre, no por identificador), identificadores multimodales con su desplazamiento relativo dentro del bloque, cache_salt solo en el bloque cero, y hash de los embeddings del prompt. Los parámetros de muestreo no entran, que es lo correcto: el KV del prefijo no depende de la temperatura.
Y el truncado. Por defecto los hashes viajan por el cable como enteros de 64 bits truncados de un SHA256, no como los bytes completos. Se puede desactivar con VLLM_KV_EVENTS_USE_INT_BLOCK_HASHES=0. Con el valor por defecto, un router tiene un riesgo teórico de colisión que el motor no tiene.
Los aciertos son invisibles por defecto
Este es el detalle que más trabajo cuesta descubrir y más consecuencias tiene. Los eventos son incrementales: solo se anuncia lo que se cachea nuevo. Un bloque reutilizado no genera evento.
Un router que solo escuche ve las altas y las bajas, pero no ve los aciertos. Su noción de qué es reciente y qué está frío se desvía del LRU real del motor sin que nada avise.
Existe un modo completo, y es por petición. sampling_params.extra_args["kv_cache_report_mode"] = "full" hace que se emitan eventos BlockStored también para los bloques que fueron acierto. El docstring de la función lo dice sin rodeos: genera eventos para que consumidores externos, “por ejemplo un gateway”, conozcan los bloques reutilizados. Es decir: para que esto funcione, el router tiene que inyectar un parámetro en cada petición que reenvía.
Las métricas, y lo que se rompió
Para medición agregada quedan dos contadores: vllm:prefix_cache_queries_total y vllm:prefix_cache_hits_total, ambos en tokens y no en peticiones, con etiquetas de modelo y motor. Hay dos equivalentes para el conector externo, que permiten distinguir un acierto en HBM de uno traído de CPU o NVMe.
Y hay dos bajas que rompen configuraciones existentes. vllm:gpu_prefix_cache_hit_rate ya no existe: cero apariciones en el repositorio. Se deprecó en la 0.8, se ocultó en la 0.9 y se eliminó en la 0.10. Y vllm:gpu_cache_usage_perc se renombró a vllm:kv_cache_usage_perc, lo que rompe paneles de Grafana y autoescaladores que dependieran del nombre viejo.
El hit rate hay que derivarlo en PromQL con rate() sobre los dos contadores. El que aparece en el log de texto es otra cosa: usa una ventana de las últimas mil peticiones.
Y tiene un sesgo que hay que conocer antes de tomar decisiones con él. Cuando una petición es desalojada, sus bloques vuelven a la cola libre conservando el hash, así que el reprefill posterior los reencuentra y cuenta como acierto. Un hit rate alto puede significar reutilización útil entre usuarios o puede significar que el motor está desalojando y recomputando en bucle. Las estadísticas internas separan las dos cosas con un campo preempted, que no se expone como métrica de Prometheus.
El último token
Un detalle pequeño con efecto en conversaciones largas: la longitud máxima de acierto es el número de tokens de la petición menos uno. Siempre hay que recomputar algo para obtener logits. Como la reserva exige alineación al tamaño de bloque, en la práctica eso puede forzar recomputar el bloque final entero. Con bloques de 16 es ruido. Con un backend que prefiere 256, son hasta 256 tokens recomputados por turno.
Los que sí lo hacen
vLLM production-stack
Ocho lógicas de enrutado en un solo selector --routing-logic: roundrobin, session, kvaware, loadaware, prefixaware, disaggregated_prefill, disaggregated_prefill_orchestrated y priority. Dos de ellas, loadaware y priority, no están en el enum del chart de Helm y solo se activan por extraArgs.
La trampa de nomenclatura es que prefixaware y kvaware son cosas radicalmente distintas.
prefixaware es un trie de hashes en el proceso del router. Trocea la cadena del prompt cada 128 caracteres, no tokens, y hashea cada trozo con xxhash de 64 bits. El propio docstring lo dice: el tamaño de trozo está “en número de caracteres”. Ese 128 está escrito a fuego, no hay flag ni valor de Helm para cambiarlo.
De ahí salen cuatro consecuencias. --prefix-min-match-length se mide en caracteres y está cuantizado a múltiplos de 128. Un prefijo compartido que difiera en un carácter dentro del primer trozo rompe el encaje completo, porque no hay encaje parcial dentro de un trozo. Para chat, concatena el contenido de todos los mensajes con saltos de línea y sin aplicar la plantilla de chat, así que la cadena indexada no es lo que el motor tokeniza. Y el desempate entre réplicas empatadas es random.choice, sin mirar carga.
El trie no tiene noción de saturación. Un prefijo popular concentra todo el tráfico en la misma réplica indefinidamente. El docstring asume el modelo simplificado y lo declara: “asumimos que no hay desalojo del prefix cache”.
Tampoco tiene poda. HashTrie solo tiene inserción y búsqueda del prefijo más largo; no existe método de borrado. Cuando una réplica desaparece, sus URLs quedan para siempre en los conjuntos de todos los nodos, y el filtrado ocurre en tiempo de consulta. Funciona, y el trie crece de forma monótona: cada prompt distinto añade su longitud partida por 128 nodos permanentes, sin cota ni TTL ni LRU. El límite de memoria del router en el chart son 1000 MiB. Por contraste, el router redondo del mismo fichero sí acota sus cachés a 1024 entradas.
Y muere en cada reinicio: es estado en memoria de proceso, sin instantánea ni carga inicial.
kvaware y loadaware son mejores y tienen otro precio. No indexan ellos: preguntan al controlador de LMCache, que sabe de tokens reales. Eso exige lmcache==0.3.11 y vllm==0.13.0 pineados exactos, y la imagen oficial del router arrastra vLLM entero. La limitación que hay que leer antes de planificar nada: solo miran request_json.get("prompt", ""), con un TODO abierto para chat completions. Con /v1/chat/completions, ambas tokenizan una cadena vacía.
loadaware es la única de las tres que rompe afinidad por saturación, con la fórmula score = encaje_relativo - beta * carga_relativa y beta = 1.0 por defecto. La lectura de los propios autores: una réplica al doble de la carga media pierde el equivalente a un acierto de caché completo. La guía sugiere 0,25 para favorecer caché y 2,0 para favorecer balanceo.
Sobre resultados publicados, el fichero de benchmarking de la documentación dice literalmente que la plataforma de medición llegará pronto. Los dos posts del blog del proyecto afirman entre tres y diez veces menos latencia de respuesta, y solo uno declara configuración parcial: Llama 3.1 de 70B en cuatro nodos con paralelismo de tensor 2 sobre A100 de 80 GB, entradas de 9K tokens y salidas de 10, contra AIBrix 0.2.0 y un despliegue plano. Gráficas sin puntos etiquetados, sin cifras absolutas y sin declarar qué lógica de enrutado se usó en cada una.
llm-d
Aquí ha habido una reorganización que invalida buena parte de la documentación de terceros. El código del Endpoint Picker salió del repositorio de Gateway API Inference Extension y se fusionó en el de llm-d, que se ha renombrado a llm-d Router. El proyecto de Kubernetes conserva la API InferencePool y un EPP ligero de referencia que no hace nada de prefijo. Si se busca el enrutado consciente del KV en el repositorio de la extensión, hoy no está.
La InferencePool está en v1 y es GA. Las otras dos APIs se movieron al grupo llm-d.ai en v1alpha2, y cuando coexisten con las antiguas el EPP prefiere las nuevas e ignora las viejas.
El scoring es una suma ponderada. Los pesos por defecto, cuando no se pasa configuración: cola 2, utilización de KV 2, prefijo 3. Cualquier scorer referenciado sin peso vale 1,0. No hay configuración por variables de entorno: las antiguas ENABLE_*_SCORER ya no existen, todo va en un EndpointPickerConfig en YAML.
El scorer de prefijo consume un atributo que publica un productor, y hay dos productores.
El aproximado calcula los hashes en el propio EPP sin hablar con el motor, y mantiene un LRU local por par de pod y bloque que se puebla después de la decisión. Es una aproximación por construcción. Tiene un suelo duro: el tamaño de bloque configurado por debajo de 64 tokens se sube a 64 en tiempo de petición, porque el índice guarda una entrada por pod y bloque y bajar a 16 multiplicaría la memoria por cuatro.
El exacto construye el índice real con los eventos ZMQ del motor, con un suscriptor por pod que se instala y se derriba con el alta y la baja del endpoint. Tiene adaptadores para vLLM y SGLang, deduplica desalojos por conteo de referencias, y el buffer de repetición que permite reconstruir el índice tras reiniciar el EPP exige vLLM 0.26.0 o superior.
La trampa está en el tokenizador. El EPP tokeniza mediante un plugin, y el backend por defecto es estimate: empaquetado de bytes sin tokenizador, aproximadamente 4 bytes por token, con identificadores que no corresponden a tokens reales del motor. El scorer exacto necesita tokens reales, y para eso hay que configurar explícitamente el backend vllm, que hace HTTP contra los endpoints /v1/completions/render del motor. Si se omite, el estimate autocreado satisface la dependencia y el sistema degrada en silencio.
Sobre madurez, el propio fichero lo declara: todos los plugins en árbol están en alfa o beta, y se promoverán a estable cuando el proyecto se acerque a la 1.0. Un plugin alfa configurado sin el flag correspondiente hace fallar el arranque del EPP.
Y sobre réplicas, el aviso es explícito: el modo activo-activo debe evitarse con enrutado aproximado por prefijo, porque las réplicas del EPP no comparten estado y cada una solo ve el tráfico que ella misma ha atendido, lo que degrada de forma significativa el hit rate. El índice exacto sí es seguro en alta disponibilidad, y aun así las guías de referencia fijan una sola réplica porque la contabilidad de peticiones en vuelo es local al proceso.
El coste es el mejor documentado de todo el campo, y merece una tabla. Simulador con Qwen3 de 8B, 100 pods, 100K tokens de entrada:
| Peticiones/s | Tope de tokens a encajar | CPU pico | Memoria pico |
|---|---|---|---|
| 5,0 | 4.096 | 1,19 cores | 0,26 GiB |
| 5,0 | 100.000 | 3,82 cores | 0,65 GiB |
| 98,7 | 4.096 | 35,17 cores | 2,46 GiB |
| 98,8 | 100.000 | 46,50 cores | 3,41 GiB |
Con salidas de 10K tokens a 50 peticiones por segundo, 32,53 cores y 12,54 GiB. La regla de dimensionamiento de los mantenedores es de medio core a un core por petición por segundo en cargas agénticas grandes. Y el consumo en reposo escala con el número de pods por el scrapeo continuo: con 100 pods, unos 7,5 cores sin tráfico.
NVIDIA Dynamo
Índice preciso por eventos, con árbol radix global en el proceso de cada frontend. Como todas las réplicas consumen el mismo plano de eventos, no hace falta sincronizar routers entre sí para el estado de prefijo.
La función de coste es explícita y ponderada. Descuenta del prefill los bloques solapados en GPU, host y disco con pesos distintos (1,0 para el crédito de solape, 0,75 para host, 0,25 para disco), y suma los bloques de decodificación potenciales y las peticiones activas.
Hay un parámetro que hay que mirar antes de desplegar: --router-kv-overlap-score-credit-decay está a 0 por defecto, es decir, desactivado. Su descripción dice para qué sirve: evitar que réplicas cargadas y ricas en caché ganen una y otra vez mientras las recién autoescaladas reciben demasiado poco tráfico. Con el valor por defecto, un pod nuevo no tiene caché, por lo tanto nunca gana el scoring, por lo tanto nunca genera caché.
El modo aproximado existe (--no-router-kv-events, con TTL de 120 segundos) y la documentación dice que no es la vía recomendada en producción.
Dynamo es el proyecto más honesto en su metodología de medición: su guía de comparación recomienda contrastar --router-mode random contra kv sobre la traza pública de Mooncake de FAST'25, con un ratio de caché declarado del 59 %, y genera la tabla localmente en vez de publicar un número.
El resto del campo
SGLang ha renombrado su router a sgl-model-gateway, versión 0.3.2 dentro de SGLang 0.5.19. Su política cache_aware es un árbol radix aproximado sobre caracteres crudos, con conmutación a cola más corta cuando el sistema está desbalanceado según dos umbrales combinados. Un detalle que puede costar una tarde: los valores por defecto del Rust y los del CLI de Python no coinciden, y en el tamaño máximo del árbol difieren en cuatro órdenes de magnitud (10.000 nodos contra 2^26).
AIBrix 0.7.0 ofrece modo estándar con tabla hash local y modo de sincronización por eventos KV tras un flag y una etiqueta de compilación, con vLLM 0.7.0 o superior.
Ray Serve LLM tiene PrefixCacheAffinityRouter con aviso explícito de API en alfa, árbol de prefijos por caracteres en un actor desacoplado, y su umbral de desbalanceo a infinito por defecto, es decir, de fábrica nunca rompe la afinidad por carga.
Envoy AI Gateway 1.1.0 no implementa nada de esto y delega en InferencePool y el EPP. KServe igual, con su LLMInferenceService en v1alpha1. Mooncake Conductor sigue siendo una propuesta sin implementar.
Tabla comparativa
| Proyecto | Versión y fecha | Índice | Dónde vive | Kubernetes | Madurez declarada |
|---|---|---|---|---|---|
production-stack prefixaware | rama principal, 09-sep-2026 | Aproximado, trie de caracteres, sin desalojo | Proceso del router | Opcional | WIP en el README |
production-stack kvaware/loadaware | igual | Exacto vía controlador LMCache | Controlador LMCache | Opcional | Sin chat completions |
| llm-d Router aproximado | v0.10.0, 17-ago-2026 | Aproximado, LRU por pod | Proceso del EPP | Sí | Beta, activo-activo desaconsejado |
| llm-d Router exacto | igual | Exacto por eventos ZMQ | Proceso del EPP | Sí | Beta |
| NVIDIA Dynamo | v1.4.2, 27-ago-2026 | Exacto, árbol radix por eventos | Proceso del frontend | No | GA con subsistemas experimentales |
SGLang sgl-model-gateway | 0.3.2, 03-sep-2026 | Aproximado, radix de caracteres | Proceso del gateway | No | Política por defecto |
| AIBrix | v0.7.0, 16-jun-2026 | Ambos | Plugin del gateway | Sí | Sincronización tras flag |
| Ray Serve LLM | Ray 2.58 | Aproximado, caracteres | Actor de Ray | No | Alfa declarada |
| Envoy AI Gateway | v1.1.0, 21-ago-2026 | Ninguno propio | Delega en EPP | Sí | API 1.x estable |
Cuánto mejora de verdad
Aquí hay que separar dos tipos de fuente.
Los números del propio proyecto. Red Hat publicó en mayo el caso de llm-d: Qwen3 de 32B, ocho pods de vLLM sobre 16 H100 con paralelismo de tensor 2, carga sintética de prefijo compartido con 150 grupos de cinco prompts, 6.000 tokens de system, 1.200 de pregunta y 1.000 de salida. Resultado: hasta 109 % más rendimiento y hasta 99 % menos TTFT, unos 200 usuarios concurrentes dentro de SLO frente a unos 20. Los ficheros de resultados del repositorio lo detallan más: rendimiento pico de 6.986 a 14.892 tokens de salida por segundo, TTFT p90 de 135,5 segundos a 0,26.
Ese mismo fichero publica la regresión, y por eso vale la pena leerlo: la latencia entre tokens p50 sube un 22,4 % con vLLM y un 45,4 % con SGLang. El compromiso está declarado: enrutar por afinidad concentra más trabajo concurrente en los pods con caché caliente.
Es el mejor caso posible. Prefijo sintético compartido y una referencia que es un Service de Kubernetes plano.
Las medidas independientes. Son tres y dicen cosas distintas.
CacheRoute, de Meta, agosto de 2026, es la más completa. Llama 3.3 de 70B en fp8, 30 destinos con paralelismo de tensor 2 sobre 60 H100, traza semisintética de telemetría de producción con 128.824 claves de negocio, y todas las alternativas reimplementadas en un mismo arnés en lugar de comparar contra un balanceador tonto. Obtiene 176 peticiones por segundo con SLO p99 de 3,5 segundos frente a 76 del mejor competidor, y un hit rate de 93,2 % frente a 72,0 %.
Y publica dos hallazgos negativos que nadie más publica. Existen regímenes de carga donde la afinidad reduce la capacidad a entre la mitad y dos tercios de la referencia, hasta el punto de que los autores hacen obligatorio un ensayo con tráfico espejo antes de desplegar. Y aumentar la flota puede enfriar un prefijo aunque la capacidad total de caché crezca, porque el tiempo entre revisitas se estira más allá de la ventana de desalojo. Escalar horizontalmente puede empeorar el hit rate.
GORGO, junio de 2026, mide en despliegue multirregión con un dataset donde el reuso de prefijo intra-usuario es del 89,4 % y el prompt medio son casi 18.000 tokens. Mejora de 6,9 a 15,5 % en TTFT p95. Con un reuso altísimo, la mejora es de un dígito, porque la latencia de red y el encolado dominan.
LAAR, de IBM Research Tokyo, abril de 2026, es el contrapunto incómodo: en su comparación, el enrutado por afinidad de sesión fue el peor de los baselines, y a contextos de 64K un enrutado por carga da menor latencia absoluta a costa de menos aciertos.
La lectura conjunta cabe en tres condiciones. Las mejoras grandes requieren reuso de prefijo alto y concentrado, prompts largos, y una referencia débil. Quitando cualquiera de las tres, los números se desinflan.
Los cinco modos de fallo
Punto caliente por afinidad. El bucket más ocupado hereda el sesgo de la carga, y la latencia de cola pasa a seguir al destino más caliente en lugar de a la media de la flota. Las mitigaciones existen en los cuatro productos principales y en tres de los cuatro vienen apagadas de fábrica: el decaimiento de crédito de Dynamo a 0, el umbral de desbalanceo de Ray a infinito, y prefixaware de production-stack sin ninguna.
Inanición del pod recién escalado. Un pod nuevo no tiene caché, por lo que nunca gana el scoring, por lo que nunca calienta. Dynamo lo documenta como el motivo del parámetro de decaimiento. No encontré documentación primaria de ninguno de los proyectos sobre la interacción concreta con KEDA.
Coste del índice. Los números de llm-d de la tabla anterior son la mejor referencia pública que existe. Subir el tope de tokens a encajar de 16.384 a 400.000 puede más que duplicar la CPU del EPP con poco tráfico.
Tráfico de prefijos únicos. Cada proyecto degrada a algo distinto. SGLang enruta al árbol más pequeño, que no es lo mismo que al menos cargado. Ray cae a potencia de dos elecciones. Dynamo tiene un preset explícito, --load-aware, para decir “quiero el modelo de carga sin reuso”. Y prefixaware de production-stack sigue insertando en el trie, es decir, sigue pagando el coste sin recibir nada.
Ráfagas de hermanos. Muestreo paralelo, mejor de N, o un agente que abre cinco ramas a la vez. Con enrutado guiado por eventos, ningún motor ha emitido todavía el evento de bloque almacenado, todos los hermanos puntúan solape cero, y el prefijo se precomputa en todas las réplicas. Dynamo tiene un TTL de predicción para esto; los demás, no.
La arquitectura que sale de todo lo anterior
Dos capas, con las responsabilidades separadas.
Arriba, LiteLLM, con lo que sí hace bien: identidad no falsificable por clave virtual, presupuestos, registro de gasto, límites de concurrencia, fallback entre proveedores y unificación de la API. Para el gateway, cada grupo de modelo tiene un solo destino: la URL del router de abajo.
model_list:
- model_name: qwen-30b
litellm_params:
model: hosted_vllm/Qwen3-30B
api_base: http://kv-router.inferencia.svc:8000/v1
api_key: os.environ/VLLM_KEY
router_settings:
routing_strategy: simple-shuffle
num_retries: 1
optional_pre_call_checks: ["session_affinity"]
deployment_affinity_ttl_seconds: 3600
general_settings:
max_in_flight_requests_per_worker: 64
max_queued_requests_per_worker: 64
admission_queue_timeout_seconds: 1.0
Abajo, el router consciente del KV, dentro del dominio del motor, hablando con las réplicas de vLLM y consumiendo sus eventos.
Cuatro consecuencias de esta separación que hay que aceptar antes de montarla.
Se pierde la visibilidad por réplica en LiteLLM. Para el gateway hay un solo deployment, así que sus enfriamientos, sus contadores por deployment y sus estrategias dejan de significar nada. La observabilidad de qué réplica atendió qué se va abajo.
Los reintentos se apilan. El SDK del cliente reintenta, LiteLLM reintenta, y el router de abajo puede tener su propio failover. En el artículo anterior salieron 45 llamadas por turno con tres capas; con cuatro es peor. Bajar num_retries en el gateway a 1, o a 0, es la decisión sensata cuando hay un router debajo que ya reintenta.
La afinidad de sesión de LiteLLM deja de tener sentido para el KV, porque ya no elige réplica. Se mantiene si se usa para otra cosa, como fijar una conversación a un proveedor concreto.
Y la elección del router de abajo condiciona el resto del cluster mucho más que la del gateway. llm-d exige Kubernetes 1.32 o superior, Gateway API, la extensión de inferencia y un Gateway conforme. Dynamo no exige Kubernetes. production-stack tiene chart de Helm y funciona fuera. Esa decisión es la cara, no la del gateway.
Cuándo no montar esto
Tres casos en los que la respuesta correcta es no hacerlo, al menos todavía.
Una sola réplica por modelo. Con una réplica, el prefix cache del motor ya hace todo el trabajo y no hay nada que enrutar. Es el caso de buena parte de los despliegues de cuatro H100 con un modelo grande y paralelismo de tensor.
Reuso de prefijo bajo o disperso. Antes de montar nada, la medida es rate(vllm:prefix_cache_hits_total[5m]) / rate(vllm:prefix_cache_queries_total[5m]) por réplica, con la corrección mental por el sesgo de los reprefills tras desalojo. Si ya está alto con reparto ciego, es que el reuso está concentrado dentro de cada conversación, y ahí la afinidad de sesión de LiteLLM cuesta una línea de configuración y resuelve el caso.
Tráfico donde la red domina. El resultado de GORGO es el aviso: con prompts enormes y despliegue distribuido, la mejora se come en encolado y latencia de red.
El caso donde sí compensa es concreto: varias réplicas del mismo modelo, un system prompt grande compartido entre usuarios distintos, o un RAG con un corpus de documentos que se repiten entre peticiones. Ahí el prefill que se ahorra es real y es la mayor parte del trabajo.
Checklist
- Medir el hit rate actual por réplica con los dos contadores de vLLM antes de tocar nada, y comprobar si el motor está desalojando con
vllm:num_preemptions_total. - Comprobar que el prefix caching está encendido. Ya no es
--enable-prefix-cachinglo que lo enciende: el valor por defecto se deriva del modelo y el log de la decisión es de nivel DEBUG. Lo accionable hoy es--no-enable-prefix-cachingpara apagarlo. - Auditar los prompts antes de enrutar: un timestamp o un identificador de sesión al principio del system prompt rompe el encaje desde el bloque cero y ninguna capa de enrutado lo arregla.
- Si se activa el flujo de eventos KV, dimensionar el ancho de banda por tokens por segundo y vigilar el descarte por marca alta. Con paralelismo de datos, suscribirse a un puerto por rango.
- Si se elige un router exacto, verificar la versión de vLLM contra la que exige el buffer de repetición.
- Si se elige llm-d, configurar explícitamente el backend de tokenización
vllm. Elestimatepor defecto no falla, degrada. - Si se elige production-stack con
kvawareoloadaware, verificar antes si el tráfico va por/v1/chat/completions, porque hoy ahí no funciona. - Revisar el parámetro de ruptura de afinidad por carga, porque en tres de los cuatro proyectos principales viene desactivado.
- Bajar
num_retriesen LiteLLM cuando hay un router debajo que ya hace failover. - Ensayar con tráfico espejo antes de cambiar producción. Es la recomendación explícita de la única medida independiente que buscó regímenes malos, y los encontró.
Trampas y cosas que no son lo que parecen
- LiteLLM no tiene enrutado por prefijo en ninguna versión. Ni la abierta ni la de pago. Lo dije mal en junio.
prompt_cachingno hace nada si el cliente no envíacache_control, y con la inyección automática por defecto falla en cada turno porque marca el último mensaje.- El TTL de ese pin son 300 segundos escritos a fuego, incompatibles con las cachés de una hora que el propio LiteLLM sabe solicitar.
prefixawarede production-stack indexa caracteres, no tokens, en trozos de 128 no configurables, y su trie no se purga nunca ni modela desalojo.kvawareyloadawarede production-stack no soportan chat completions. Tokenizan una cadena vacía.- El tokenizador por defecto del EPP de llm-d es una estimación por bytes, y con él el scorer exacto degrada en silencio.
- El modo activo-activo de llm-d está desaconsejado con prefijo aproximado, y las guías de referencia fijan una réplica incluso con el exacto.
- El decaimiento de crédito de solape de Dynamo viene a cero, lo que puede dejar sin tráfico a una réplica recién autoescalada.
- Los valores por defecto del router de SGLang difieren entre el Rust y el CLI de Python, hasta en cuatro órdenes de magnitud.
vllm:gpu_prefix_cache_hit_rateya no existe yvllm:gpu_cache_usage_percse llama ahoravllm:kv_cache_usage_perc. Los paneles y autoescaladores viejos están midiendo la nada.- El hit rate cuenta como acierto el reprefill tras desalojo. Un número alto puede ser reutilización o puede ser thrashing.
- Los eventos KV no publican los aciertos por defecto, solo las altas, salvo que se pida el modo completo petición a petición.
- Los hashes viajan truncados a 64 bits salvo que se desactive con una variable de entorno.
- El tamaño de bloque por defecto es 16, y el backend de atención puede cambiarlo sin que nadie lo pida.
- El Endpoint Picker salió del repositorio de Gateway API Inference Extension. Buscarlo allí hoy no lleva a nada.
Cierre
El error de junio tiene una lectura que va más allá de la errata. Vino de leer la documentación de un producto y no su código, y de que la documentación de LiteLLM usa la palabra “caching” en una comprobación previa que hace otra cosa. La distancia entre lo que un nombre sugiere y lo que una función hace es, a estas alturas del track, el patrón más repetido de toda la serie.
La conclusión de arquitectura es que el enrutado consciente del KV no es una función del gateway. No lo es hoy en LiteLLM y probablemente no deba serlo: exige mantener un índice sincronizado con la memoria de cada motor, replicar su función de hash, consumir un flujo de eventos y dimensionar CPU por petición por segundo. Eso pertenece al dominio del motor, no al de la identidad y el presupuesto.
Lo que queda es una decisión de dos partes. La primera es si el tráfico la merece, y esa se contesta con dos contadores de Prometheus y sin instalar nada. La segunda es cuál de los tres routers encaja con el cluster que ya hay, y ahí la variable que decide no es el hit rate sino si se está dispuesto a meter Gateway API y la extensión de inferencia, o a pinear vLLM 0.13.0, o a operar un frontend de Dynamo.
Y la honestidad que exige el material: la única medida independiente que buscó específicamente regímenes donde esto empeora las cosas los encontró, y no son marginales. Medio a dos tercios de la capacidad de referencia es una regresión seria. Que ningún proyecto publique ese lado de la curva no significa que no exista en el suyo.
Ver también
- Humanos y agentes en el mismo gateway — la afinidad de sesión y el orden del pipeline que aquí se dan por leídos, y el resto de lo que el gateway sí puede hacer con dos clases de tráfico.
- El router de inferencia LLM — el artículo de junio que este corrige, y las otras cuatro funciones del router que siguen siendo válidas.
- Prefix cache: ingeniería del hit rate — la auditoría de plantillas que hay que hacer antes de plantearse enrutar nada.
- KV cache: fundamentos — qué es exactamente lo que se está intentando no recomputar.
- Contexto largo y KV offloading — LMCache y la jerarquía de memoria de la que dependen
kvawarey los pesos por tier de Dynamo. - Optimizaciones de prefill en vLLM — el trabajo concreto que se ahorra cuando el enrutado acierta.
- El paso del planificador de vLLM — el bucle donde se publican los eventos KV, una vez por paso.
- Instrumentación OTel en vLLM — las métricas del motor, incluidas las que han cambiado de nombre.
- Elegir la centralita — por qué el gateway se elige por licencia y encaje antes que por features, que es exactamente lo que este artículo vuelve a ilustrar.
- vLLM en Kubernetes — el despliegue de réplicas sobre el que opera todo lo anterior.
Fuentes
- LiteLLM, Router - Load Balancing (estrategias, comprobaciones previas, afinidad): https://docs.litellm.ai/docs/routing.
- LiteLLM, código:
litellm/router.py,litellm/router_utils/prompt_caching_cache.py,litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py,litellm/router_utils/pre_call_checks/deployment_affinity_check.py,litellm/proxy/litellm_pre_call_utils.py,litellm/proxy/middleware/admission_control_middleware.py: https://github.com/BerriAI/litellm. - LiteLLM, incidencia 28427, TTL de afinidad por caché de prompts fijado a cinco minutos: https://github.com/BerriAI/litellm/issues/28427.
- vLLM, código:
vllm/v1/core/kv_cache_utils.py,vllm/v1/core/block_pool.py,vllm/v1/core/kv_cache_manager.py,vllm/distributed/kv_events.py,vllm/config/kv_events.py,vllm/v1/metrics/loggers.py,vllm/v1/metrics/stats.py: https://github.com/vllm-project/vllm. - vLLM, KV offloading usage (niveles CPU, filesystem y objeto; compartición entre procesos): https://docs.vllm.ai/en/latest/features/kv_offloading_usage.html.
- vLLM, notas de la versión 0.29.0 (semilla determinista, seguimiento de propiedad en eventos, flags de admisión): https://github.com/vllm-project/vllm/releases/tag/v0.29.0.
- vLLM, RFC 16669, publicación de bloques KV y métricas: https://github.com/vllm-project/vllm/issues/16669.
- vLLM production-stack, código:
src/vllm_router/routers/routing_logic.py,src/vllm_router/prefix/hashtrie.py,src/vllm_router/parsers/parser.py: https://github.com/vllm-project/production-stack. - LMCache Lab, resultados de production-stack frente a AIBrix (parte interesada): https://blog.lmcache.ai/en/2025/03/06/open-source-llm-inference-cluster-performing-10x-faster-than-sota-oss-solution/.
- llm-d Router, código y documentación de operaciones (pesos por defecto, dimensionamiento del EPP, aviso de activo-activo): https://github.com/llm-d/llm-d-router.
- llm-d, guía de enrutado preciso por prefijo y resultados publicados (parte interesada): https://github.com/llm-d/llm-d.
- llm-d Router, incidencia 1290, estado de prefijo no compartido entre réplicas del EPP: https://github.com/llm-d/llm-d-router/issues/1290.
- Red Hat, Same 16 GPUs, twice the users (parte interesada): https://www.redhat.com/en/blog/same-16-gpus-twice-users-inference-aware-routing-llm-clusters.
- Kubernetes SIG Network, Gateway API Inference Extension (traslado del EPP y del BBR): https://github.com/kubernetes-sigs/gateway-api-inference-extension.
- NVIDIA Dynamo, diseño y ajuste del router KV: https://github.com/ai-dynamo/dynamo.
- SGLang,
sgl-model-gateway: https://github.com/sgl-project/sglang/tree/main/sgl-model-gateway. - AIBrix, enrutado por caché de prefijo: https://github.com/vllm-project/aibrix/blob/main/pkg/plugins/gateway/algorithms/prefix_cache_readme.md.
- Ray Serve LLM, enrutado consciente de prefijo (API en alfa): https://docs.ray.io/en/latest/serve/llm/user-guides/prefix-aware-routing.html.
- Huang Cheng (Meta), CacheRoute, agosto de 2026: https://arxiv.org/html/2608.19677.
- Yuan et al., DualMap, febrero de 2026: https://arxiv.org/abs/2602.06502.
- Ricci Toniolo et al., GORGO, junio de 2026: https://arxiv.org/html/2602.11688.
- Yoshimura, Chiba y van de Beek, Accuracy Is Speed (EuroMLSys ‘26), abril de 2026: https://arxiv.org/html/2604.15732v1.