Enrutamiento de complejidad de LiteLLM: el modelo adecuado para cada solicitud y su costo en latencia
+
+- 1One endpoint, scoped keys
- 2Route work to the right model
- 3Mask PII at the gateway
- 4Clean traces, untouched answers
- 🏆Prove it holds under load
Parte 1 terminó en un número incómodo. La misma respuesta de tres palabras costó 3 tokens de un modelo pequeño y 200 de un modelo de razonamiento — que gastó los 200 pensando y no devolvió nada en absoluto.
Cada solicitud que envían tus aplicaciones elige un modelo, y en su mayoría esa elección se hace una vez, codificada de forma rígida, y nunca se revisita. Esta publicación pone al gateway a cargo de ello en su lugar: clasifica la solicitud, la enruta a un modelo adecuado para el trabajo. Luego mide lo que cuesta esa decisión, porque no es gratis y la mayoría de los informes omiten esa parte.
Agregando el enrutador de complejidad de LiteLLM
El gateway de la Parte 1 ya tiene tres modelos registrados. Un enrutador es solo otra entrada en model_list que asigna niveles de complejidad a ellos:
- model_name: smart-router
litellm_params:
model: auto_router/complexity_router
complexity_router_config:
tiers:
SIMPLE: qwen-small
MEDIUM: qwen-mid
COMPLEX: qwen-large
REASONING: qwen-large
return_raw_model_name: true
return_raw_model_name: true es el importante por ahora. Sin él, la respuesta informa smart-router y no tienes idea de qué modelo te sirvió. Con él, la respuesta nombra el modelo que realmente se ejecutó — así que cada prueba a continuación se verifica por sí misma.
docker compose restart litellm
Observando cómo enruta
Enviarás la misma solicitud muchas veces con solo el prompt cambiando, así que envuélvelo una vez:
ask() { curl -s http://127.0.0.1:4000/v1/chat/completions \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
-H "Content-Type: application/json" \
-d "{\"model\":\"smart-router\",\"messages\":[{\"role\":\"user\",\"content\":\"$1\"}],\"max_tokens\":10}" | jq -r .model; }
Luego cinco prompts de dificultad creciente:
ask "hi"
ask "what is a vpc"
ask "write a python function that retries an http call with backoff"
ask "our two services deadlock under load, walk me through diagnosing it"
ask "prove that the halting problem is undecidable"
Una línea por prompt, en el orden enviado:
Qwen2.5-3B-Instruct
Qwen2.5-3B-Instruct
Qwen3.5-9B
Qwen2.5-3B-Instruct
Qwen2.5-3B-Instruct
Figura 1. La solicitud de código subió un nivel. Las dos preguntas más difíciles no.
Un saludo en el modelo pequeño es correcto. Una solicitud de Python en el modelo medio es correcta. Pero un bloqueo de sistemas distribuidos y una de las preguntas más difíciles en ciencias de la computación aterrizaron en un modelo de 3 mil millones de parámetros.
Para entender por qué, debes saber lo que realmente está haciendo el enrutador.
Qué es una heurística y cómo LiteLLM puntúa una
Por defecto, este enrutador no hace llamadas a la API. Puntúa el prompt localmente con coincidencias de patrones — eso es lo que "heurística" significa aquí: una regla de oro barata que aproxima un juicio sin hacerlo.
Puntúa siete dimensiones, cada una produciendo un valor entre −1 y +1, luego multiplica cada una por un peso fijo y las suma:
| Dimensión | Peso | Se activa en |
|---|---|---|
codePresence | 0.30 | function, class, api, schema, … |
reasoningMarkers | 0.25 | "paso a paso", "pensar a través", "analizar" |
technicalTerms | 0.25 | "arquitectura", "distribuido", "cifrado" |
tokenCount | 0.10 | −1.0 bajo 15 tokens, +1.0 sobre 400 |
simpleIndicators | 0.05 | "qué es", "definir", saludos — puntúa −1.0 |
multiStepPatterns | 0.03 | "primero… luego", pasos numerados |
questionComplexity | 0.02 | más de tres signos de interrogación |
La suma ponderada se asigna a un nivel en tres límites: por debajo de 0.15 es SIMPLE, por debajo de 0.35 MEDIUM, por debajo de 0.60 COMPLEX, y por encima de eso REASONING. Todos esos valores están documentados y son configurables.
Dos dimensiones pueden empujar la puntuación hacia abajo. Un prompt corto puntúa −1.0 en tokenCount. Un prompt que contiene "qué es" puntúa −1.0 en simpleIndicators.
La aritmética, en un prompt real
El enrutador registra su propio trabajo. Cada solicitud enrutada escribe una decisión en el registro de gastos:
curl -s http://127.0.0.1:4000/spend/logs \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
| jq -r '.[].metadata.routing_decision | select(.) | "\(.tier) \(.score) -> \(.routed_model) \(.signals)"' | head -5
Más reciente primero, así que esto se lee de abajo hacia arriba en contra del orden en que se enviaron los prompts:
SIMPLE -0.1 -> qwen-small ["corto (11 tokens)"]
SIMPLE 0 -> qwen-small null
MEDIUM 0.3 -> qwen-mid ["código (función, python)"]
SIMPLE -0.15000000000000002 -> qwen-small ["corto (3 tokens)","simple (qué es)"]
SIMPLE -0.15000000000000002 -> qwen-small ["corto (0 tokens)","simple (hola)"]
Figura 2. No es una caja negra: el enrutador informa la puntuación y las señales que se activaron.
Toma el último. prueba que el problema de parada es indecidible:
tokenCount -1.0 × 0.10 = -0.10 11 tokens, bajo el umbral de 15
codePresence 0.0 × 0.30 = 0.00 sin palabras clave de código
reasoningMarkers 0.0 × 0.25 = 0.00 "prueba" no está en la lista de marcadores
technicalTerms 0.0 × 0.25 = 0.00
simpleIndicators 0.0 × 0.05 = 0.00
multiStepPatterns 0.0 × 0.03 = 0.00
questionComplexity 0.0 × 0.02 = 0.00
------
-0.10 por debajo de 0.15 → SIMPLE → modelo 3B
Cada término es cero excepto una penalización por ser corto. La pregunta se enruta hacia abajo porque es breve.
Ahora el prompt de bloqueo, que es peor: puntuó 0.00 sin señales en absoluto. No es una puntuación baja — nada coincidió. "Nuestros dos servicios se bloquean bajo carga, guíame a través del diagnóstico" no contiene ninguna palabra clave que el puntuador reconozca, por lo que cae en SIMPLE por defecto.
Puedes leer una decisión de enrutamiento — nivel, puntuación y las señales que la produjeron — y reproducir la aritmética a mano a partir de los pesos de las dimensiones.
El enrutador no está fallando. Está haciendo exactamente lo que dicen sus reglas. Las reglas simplemente no tienen forma de ver la dificultad que no esté escrita en el vocabulario que conoce.
El modo de falla es sistemático, no aleatorio: los prompts cortos que necesitan un pensamiento profundo se enrutan hacia abajo. Esos son también los prompts donde un modelo incorrecto es más obvio para el usuario.
Una palabra clave nunca coincide con su propio plural
Las palabras clave de una sola palabra se coinciden en los límites de las palabras, por lo que endpoint no coincide con endpoints. Cada palabra clave de una sola palabra en las listas predeterminadas es singular, y ninguna de ellas coincide con un plural.
Si eso cambia algo depende de cuán cerca esté la puntuación de un límite, porque las dimensiones son escalonadas en lugar de lineales — technicalTerms puntúa 0.5 en dos coincidencias y 1.0 en cuatro, por lo que perder una coincidencia a menudo no cambia nada. A veces cambia el modelo:
Revisa nuestro api endpoint para problemas de autenticación y autorización → COMPLEJO +0.425
Revisa nuestros api endpoints para problemas de autenticación y autorización → MEDIUM +0.275
Una letra, un nivel. La dirección siempre es la misma: un plural puntúa más bajo o igual, nunca más alto, por lo que la deriva es hacia el modelo más barato.
Los plurales que te importan se pueden agregar a la lista técnica con custom_technical_keywords. No hay equivalente para palabras clave de código — la única palanca es code_keywords, que reemplaza la lista incorporada en lugar de extenderla.
Preguntando a un modelo en su lugar
La alternativa es gastar una llamada de modelo en la decisión. Cuatro líneas:
classifier_type: llm
classifier_llm_config:
model: qwen-small
timeout_ms: 3000
Ahora el enrutador envía el prompt a qwen-small con un rubro y un esquema que obliga a devolver exactamente uno de SIMPLE, MEDIUM, COMPLEX, REASONING, y enruta según la respuesta. El clasificador aquí es el modelo más barato que tenemos — el mismo 3B que estaba respondiendo incorrectamente a las preguntas difíciles hace un momento. Resulta ser un mejor juez de dificultad de lo que es un respondedor de ella.
Reinicia, luego vuelve a ejecutar los cinco prompts idénticos:
Qwen2.5-3B-Instruct
Qwen3.5-9B
Qwen3.5-122B
Qwen3.5-122B
Qwen3.5-122B
Figura 3. Ambos fallos corregidos — y dos prompts subieron que, argumentablemente, no deberían haberlo hecho.
| Prompt | Heurística | Clasificador LLM |
|---|---|---|
hi | 3B | 3B |
qué es un vpc | 3B | 9B |
función de python … retroceso | 9B | 122B |
bloqueo bajo carga | 3B ✗ | 122B ✓ |
problema de parada | 3B ✗ | 122B ✓ |
Los dos casos rotos están arreglados. Pero lee de nuevo las filas 2 y 3 — todo subió. "¿Qué es un vpc?" es una búsqueda fáctica que un modelo de 3B responde perfectamente, y ahora se ejecuta en el 9B. Has dejado de subestimar los prompts difíciles y comenzado a sobreestimar los fáciles. Si esa compensación vale la pena depende de tu mezcla de tráfico, y deberías medir la tuya en lugar de confiar en esta tabla.
Lo que cuesta
Esta es la parte que se omite. Agrega un gemelo que omita el enrutador, luego mide ambos:
direct() { curl -s http://127.0.0.1:4000/v1/chat/completions \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
-H "Content-Type: application/json" \
-d "{\"model\":\"qwen-small\",\"messages\":[{\"role\":\"user\",\"content\":\"$1\"}],\"max_tokens\":10}" | jq -r .model; }
time ask "hi"
time ask "hi"
time direct "hi"
Qwen2.5-3B-Instruct real 0m2.474s ← primera llamada después del reinicio
Qwen2.5-3B-Instruct real 0m1.041s
qwen-small real 0m0.529s
Figura 4. Mismo prompt, mismo modelo lo responde, el doble del tiempo de reloj.
El mismo modelo de 3B produjo ambas respuestas. La única diferencia es que una solicitud fue clasificada primero. Ahora descompónlo: el registro de gastos mide cada llamada por separado:
| Reloj | Llamada del clasificador | Llamada de servicio |
|---|---|---|
| 2.474 s (frío) | 1449 ms | 806 ms |
| 1.041 s | 549 ms | 456 ms |
| 0.529 s (directo) | — | 495 ms |
La aritmética cierra: 549 + 456 = 1005 ms contra 1.041 s medidos en la terminal.
Tres cosas se desprenden:
El costo adicional no es constante. A través de las muestras del día, la llamada del clasificador varió de 476 ms a 1449 ms, y la primera solicitud después de un reinicio costó 1449 ms por sí sola. Es una llamada de inferencia completa y hereda lo que sea que esté haciendo el backend. Cualquier número único que cites para ello es el número que te tocó capturar.
El costo de tokens es fijo y mayor de lo que pensarías. Cada clasificación envió 281–293 tokens de entrada — el rubro — y devolvió alrededor de 10. Enrutar hi, un prompt de un token, cuesta ~281 tokens de clasificación antes de que algo responda.
La llamada de servicio también se ralentiza. 456–806 ms cuando se enruta, contra 495 ms directos para la solicitud idéntica. El clasificador parece dejar al backend ocupado para la solicitud en cola detrás de él. Nunca verías esto solo con el tiempo de reloj.
El tiempo de reloj emparejado te dice que se volvió más lento, nunca dónde. Descompón en la llamada del clasificador y la llamada de servicio antes de que cites una cifra — el primer número honesto aquí fue casi el doble del que una sola ejecución de time sugirió.
La trampa que vale la pena conocer
Si la llamada de clasificación se agota, devuelve la forma incorrecta o vuelve vacía, el enrutador vuelve a el puntuador heurístico — la cosa cuyas fallas acabas de pagar medio segundo para evitar. Silenciosamente.
La documentación oficial nombra la salida: establece classifier_fallback: default_model y un tiempo de espera enruta a un modelo que elegiste deliberadamente, en lugar de al puntuador que envía preguntas difíciles a un modelo de 3B.
También vale la pena verificar en lugar de asumir: la documentación da a timeout_ms un valor predeterminado de 2000, mientras que el paquete en nuestro contenedor tiene un valor predeterminado de 3000. Lee la versión que realmente instalaste.
Entonces, ¿cuál?
| Heurística | Clasificador LLM | |
|---|---|---|
| Latencia añadida | sub-milisegundo | 476–1449 ms |
| Llamadas API adicionales | ninguna | una por solicitud |
| Tokens de entrada adicionales | ninguno | ~280 por solicitud |
| Prompts difíciles pero cortos | enrutados hacia abajo | enrutados correctamente |
| Prompts fáciles | enrutados correctamente | enrutados hacia arriba |
| Falla por | estar confidentemente equivocado | agotarse, luego estar confidentemente equivocado |
La heurística es el valor predeterminado correcto para tráfico de alto volumen que se parece, y puedes mejorarlo mucho con custom_technical_keywords para tu propio vocabulario de dominio. El clasificador LLM justifica su costo cuando los prompts son variados, cuando equivocarse de modelo es costoso y cuando medio segundo no importa — trabajo por lotes, agentes, cualquier cosa que ya esté tomando segundos.
Lo que ninguno de ellos es, es gratis.
Solución de problemas
La respuesta dice smart-router en lugar de un nombre de modelo
return_raw_model_name no está configurado. Sin él, el enrutador repite el alias que pediste y no puedes decir qué sirvió la solicitud.
Cada prompt se enruta al mismo nivel
Verifica el campo signals en la decisión de enrutamiento. Si es null, ninguna dimensión coincidió y el prompt puntuó 0.00, lo que cae en SIMPLE. Eso es una señal de que tu tráfico no utiliza el vocabulario que las listas de palabras clave predeterminadas esperan.
Las decisiones de enrutamiento no están en el registro de gastos
Están en la solicitud enrutada, bajo metadata.routing_decision, no en la llamada del clasificador. Filtra con map(select(.)) como arriba — la mitad de las filas son las llamadas del clasificador en sí y no llevan ninguna decisión.
La primera solicitud después de un reinicio es mucho más lenta
Inicio en frío. La primera clasificación aquí tomó 1449 ms contra aproximadamente 550 ms una vez caliente. Descarta la primera muestra al medir.
¿Qué sigue?
El gateway ahora decide qué modelo se ejecuta, y sabes lo que cuesta esa decisión en latencia y tokens. Aún reenvía cada prompt textualmente al modelo que gana — incluidos los que contienen nombres de clientes, correos electrónicos y claves API.
La próxima publicación pone un filtro en ese camino.
