Humanos y agentes en el mismo gateway: por qué `priority` no prioriza, el 429 que tumba el pool entero y las dos formas de separar el tráfico
Cuarto 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 y las claves virtuales el gobierno. Aquí llega el inquilino nuevo: los agentes, que hablan el mismo protocolo que los humanos y se comportan de otra manera. Todo lo que sigue está verificado contra LiteLLM 1.100.1 y vLLM 0.29.0, ambas de la primera decena de septiembre de 2026.
TL;DR
El día que un equipo conecta un agente al gateway corporativo, la plataforma cambia de régimen sin que nadie toque un YAML. Estas son las siete conclusiones.
El parámetro priority de LiteLLM no prioriza nada en condiciones normales. Sigue marcado como beta y su cola solo se consulta cuando todos los deployments del grupo están en enfriamiento. Con capacidad libre, lo único que produce es una cabecera de respuesta. Además, la rama de éxito no saca el elemento de la cola, así que la lista crece indefinidamente; y en /chat/completions el valor 0, que es el que usan todos los ejemplos de la documentación, es falsy en Python y desactiva el encolado.
Separar tráfico se hace con dos pools, no con prioridades. La forma no falsificable es model_group_alias a nivel de clave o de equipo: el cliente pide siempre qwen-30b y la credencial decide si eso resuelve a qwen-30b-interactivo o a qwen-30b-agentes. La forma sin tocar clientes es tag_regex contra el User-Agent, con un patrón tipo ^User-Agent: claude-code\/, que sirve para clasificar carga y no para controlar accesos, porque esa cabecera la pone el cliente.
El control de admisión útil llega en la rama 1.101, con max_in_flight_requests_per_worker y una cola por proceso que devuelve 503 con retry-after. Rechaza antes de autenticar, así que lo que descarta no aparece atribuido a ninguna clave.
max_parallel_requests solo se aplica a nivel de clave virtual. Los endpoints de equipo, usuario, cliente final y organización aceptan el campo y lo guardan en la base de datos, y no lo lee nadie. global_max_parallel_requests es un no-op salvo que se reactive el limitador antiguo, y general_settings.max_parallel_requests no lo lee ningún camino de código.
vLLM con --scheduling-policy priority no adelanta a nadie que ya esté generando. Lo único que cambia es a quién desaloja cuando falta caché KV. Si los agentes han llenado max_num_seqs, un chat con la prioridad más alta espera igual. Y los dos knobs que en 2025 permitían que los prompts cortos adelantaran a los largos, max_num_partial_prefills y max_long_partial_prefills, desaparecieron del código en la 0.27.
El producto de reintentos llega a 45 llamadas por turno de agente. Tres del SDK del cliente por tres del router por hasta cinco grupos de fallback. El proxy pone max_retries=0 en el SDK que él mismo usa, pero no puede tocar el del cliente. El timeout es por intento, no por petición, y el Timeout de LiteLLM sale como 408, que es reintentable.
Un solo 429 saca un deployment del pool durante cinco segundos, sin pasar por el umbral de allowed_fails. Con una ráfaga agéntica contra una flota de cuatro réplicas, el pool entero entra en enfriamiento y el gateway devuelve un 429 propio a todo el mundo, humanos incluidos.
Estás aquí: la capa de gateway con dos clases de tráfico
En el stack de siete capas esto sigue viviendo en la capa de gateway, con una diferencia respecto a los tres artículos anteriores. Allí la variable era la carga: más peticiones, más equipos, más presupuesto que repartir. Aquí la variable es la forma de la carga.
Un gateway dimensionado para trescientos ingenieros usando un chat funciona. El mismo gateway, con treinta de esos ingenieros ejecutando agentes de código, atiende una fracción del tráfico humano con la latencia de antes y el resto se degrada. Las GPUs siguen igual de ocupadas, las métricas de utilización siguen buenas, y el equipo de soporte recibe quejas de gente que espera nueve segundos por un párrafo.
La analogía: la ventanilla del registro y el mensajero con doscientos expedientes
Una oficina de registro atiende por orden de llegada. Los ciudadanos entran de uno en uno, presentan un papel, se van. El sistema es justo y la cola avanza.
Un martes aparece un mensajero con un carro de doscientos expedientes. Se pone en la cola como cualquiera, porque las normas son las normas, y cuando le toca ocupa la ventanilla cuarenta minutos. Detrás quedan doce personas con un papel cada una. Nadie ha incumplido nada. El mensajero tiene el mismo derecho que los demás y usa el mismo procedimiento. Lo que falla es que la oficina se diseñó suponiendo un tamaño de trámite y ha aparecido otro tres órdenes de magnitud mayor.
Las soluciones reales de una oficina son tres, y las tres tienen su equivalente exacto en la plataforma. Abrir una ventanilla para entregas masivas, que es separar pools. Poner un cartel de aforo y mandar a la gente a volver más tarde, que es el control de admisión con su 503. Y decir que los expedientes se entregan por la tarde, que es la parte que en una plataforma de inferencia casi nunca se puede aplicar, porque el agente no negocia horarios.
Lo que no funciona en la oficina es dar un número de prioridad al ciudadano cuando el mensajero ya está en la ventanilla. Ese número no interrumpe el trámite en curso. Es, literalmente, lo que hace la prioridad en las dos capas de la plataforma, y vale la pena tenerlo en la cabeza al leer la sección siguiente.
Los dos tráficos, medidos
Antes de configurar nada, la diferencia hay que poder enunciarla en números, porque de ella salen todos los ajustes posteriores.
| Rasgo | Chat humano | Bucle agéntico |
|---|---|---|
| Entrada por petición | cientos de tokens | decenas de miles, y crece con cada turno |
| Salida por petición | cientos de tokens | de dos tokens (una llamada a herramienta) a varios miles |
| Concurrencia por usuario | 1 | de 3 a 20 peticiones en vuelo |
| Métrica que duele | tiempo hasta el primer token | rendimiento total del turno |
| Tolerancia a la espera | segundos | minutos |
| Peticiones canceladas | raras | habituales, por decisión del propio agente |
| Reintentos | los del navegador | los del SDK, más los del bucle |
| Testigo cuando falla | el usuario | nadie |
Dos filas merecen comentario porque contradicen la intuición.
La primera es la de la métrica. Un agente no percibe el tiempo hasta el primer token, así que en un motor compartido es el candidato natural a ceder esa métrica. La conclusión operativa es que el pool de agentes se puede configurar para rendimiento agresivo, con lotes grandes y colas profundas, mientras el pool interactivo se configura para lo contrario.
La segunda es la de las cancelaciones. Un agente que lanza tres continuaciones especulativas y descarta dos genera dos flujos abortados por turno. Eso, en el gateway, toca la contabilidad de gasto, la liberación de los contadores de concurrencia y la conexión abierta contra el motor. LiteLLM 1.100 lo gestiona razonablemente bien, y no lo hacía hace seis meses.
Lo que no funciona: priority en LiteLLM
La primera reacción de cualquier operador es buscar un campo de prioridad. LiteLLM tiene uno, lo documenta en su página Request Prioritization, y esa página lleva marcada la etiqueta de beta y la frase de que es para pruebas. La etiqueta es correcta y hay que tomarla al pie de la letra, porque detrás hay cuatro cosas.
La cola solo se consulta cuando el grupo entero está en enfriamiento. El método poll() del planificador devuelve verdadero si hay algún deployment sano, sin tocar la cola. Con capacidad disponible, que es la situación normal, la petición pasa directamente y la prioridad no ordena nada. Lo único que cambia es que la respuesta lleva la cabecera x-litellm-request-prioritization-used.
La cola no se vacía. En esa rama de éxito no se hace heappop ni se llama a la limpieza, que solo ocurre en la rama de expiración. La lista bajo la clave scheduler:queue:{modelo} crece sin cota, se serializa entera en Redis en cada inserción, y no hay tamaño máximo ni política de descarte. Cuando el grupo sí entra en enfriamiento, la cabeza del montículo está ocupada por entradas fósiles de peticiones que terminaron hace horas, y las nuevas comparan su identificador contra esa cabeza, no coinciden nunca, y esperan hasta agotar el timeout.
En /chat/completions, la prioridad más alta desactiva la función. El código lee el valor con un kwargs.get("priority") or self.default_priority, y en Python 0 or None es None. Como en este planificador el número más bajo es la prioridad más alta, priority: 0, que es el valor de los ejemplos de la propia documentación, sale por el camino sin encolar. En /completions la comprobación se hace con is not None y funciona; las dos rutas no se comportan igual.
Y default_priority en router_settings rompe la petición. Si el cliente no manda prioridad pero el ajuste global está puesto, el valor es truthy, se entra en la rama de planificación y se invoca una función que exige el argumento posicional que nadie ha inyectado. El ajuste no tiene ni un test asociado ni aparece en la referencia de router_settings.
Existe además un /queue/chat/completions, marcado como experimental y oculto del esquema OpenAPI, que lee data["priority"] sin valor por defecto: una petición sin ese campo lanza una excepción que sale como un 400 de error de autenticación, con un mensaje que no tiene relación con la causa.
Hay una función de prioridad que sí es sólida, y es otra cosa. El limitador dinámico v3 reparte cuota de tokens y peticiones por minuto entre clases con pesos, mediante litellm.priority_reservation con valores como {"premium": 0.75, "standard": 0.25}. Reparte capacidad, no ordena una cola: por debajo del umbral de saturación presta capacidad libre y por encima aplica los pesos. Nunca hace esperar a nadie, o pasa o devuelve 429. Requiere Postgres y licencia de empresa, y sigue marcado como beta.
Lo que sí funciona: dos pools
La separación efectiva se hace antes, decidiendo a qué conjunto de réplicas va cada clase de tráfico. LiteLLM ofrece dos mecanismos con propiedades de seguridad muy distintas.
Por credencial: model_group_alias a nivel de clave o equipo
Los ajustes de router se resuelven en el orden clave, equipo, global. Entre los que admiten ese override está model_group_alias, y ahí está la pieza:
model_list:
- model_name: qwen-30b-interactivo
litellm_params:
model: hosted_vllm/Qwen3-30B
api_base: http://vllm-chat.inferencia.svc:8000/v1
model_info:
id: chat-01
max_input_tokens: 32768
- model_name: qwen-30b-agentes
litellm_params:
model: hosted_vllm/Qwen3-30B
api_base: http://vllm-agentes.inferencia.svc:8000/v1
model_info:
id: agentes-01
max_input_tokens: 131072
Y en la clave del equipo de agentes:
curl -X POST "$PROXY/key/generate" \
-H "Authorization: Bearer $MASTER_KEY" \
-d '{
"team_id": "plataforma-agentes",
"models": ["qwen-30b"],
"router_settings": {
"model_group_alias": {"qwen-30b": "qwen-30b-agentes"}
}
}'
El cliente pide qwen-30b y no se entera de nada. La resolución va atada a la credencial, de modo que el caller no puede saltársela cambiando una cabecera. La misma lista permite fijar por clave o equipo routing_strategy, fallbacks, context_window_fallbacks, retry_policy, cooldown_time y allowed_fails, lo que da políticas de fiabilidad distintas para cada clase de tráfico sobre la misma instancia de proxy.
No hay que confundirlo con los access groups (model_info.access_groups), que son permisos y no enrutado: deciden si una clave puede invocar un nombre de modelo, no a qué réplica va la petición.
Por User-Agent: tag_regex
Cuando no se pueden emitir credenciales separadas, porque el cliente agéntico usa la misma clave personal que el chat de esa persona, LiteLLM clasifica por expresión regular contra las cabeceras de la petición:
model_list:
- model_name: qwen-30b
litellm_params:
model: hosted_vllm/Qwen3-30B
api_base: http://vllm-agentes.inferencia.svc:8000/v1
tag_regex: ["^User-Agent: claude-code\\/", "^User-Agent: .*codex"]
model_info: {id: agentes-01}
- model_name: qwen-30b
litellm_params:
model: hosted_vllm/Qwen3-30B
api_base: http://vllm-chat.inferencia.svc:8000/v1
tags: ["default"]
model_info: {id: chat-01}
router_settings:
enable_tag_filtering: true
tag_filtering_match_any: true
El proxy compone cadenas del tipo User-Agent: <valor> y las prueba con re.search. El emparejamiento exacto por tags tiene precedencia sobre el regex. La propia documentación pone un aviso que hay que respetar: el User-Agent lo escribe el cliente, así que esto es clasificación de tráfico y no una frontera de seguridad. Como reparto de carga es suficiente, porque un agente que miente sobre su identidad para colarse en el pool interactivo es un problema de gobierno, no de enrutado.
El comportamiento cuando ningún deployment lleva el tag pedido tiene un matiz que sorprende en producción. Si el tag es desconocido para el grupo, la petición cae al pool marcado con tags: ["default"]. Si el tag existe en el grupo pero ningún deployment sano lo lleva en ese momento, la petición falla con el error de configuración de tags, aunque haya un pool por defecto disponible. Para tener caída determinista al pool por defecto hay que poner model_info.allow_fail_open: true.
Estrategia distinta por pool con routing_groups
router_settings:
routing_strategy: simple-shuffle
routing_groups:
- group_name: interactivo
models: [qwen-30b-interactivo]
routing_strategy: simple-shuffle
- group_name: agentes
models: [qwen-30b-agentes]
routing_strategy: simple-shuffle
La recomendación de dejar simple-shuffle en ambos no es pereza, y sostenerla exige mirar qué hacen las alternativas sobre una flota homogénea de vLLM.
least-busy mantiene un diccionario completo bajo una sola clave y lo actualiza con lectura y escritura no atómicas, sin usar INCR, así que con varias réplicas del proxy se pierden incrementos. No tiene TTL, de modo que una petición que nunca completa deja el contador subido para siempre y esa réplica queda excluida de manera permanente. Los contadores pueden ir a negativo. Y cuando el mínimo corresponde a un identificador que ya no está en la lista de sanos, cae a random.choice sin avisar. El patrón de peticiones que no completan es exactamente el de un agente que cancela.
latency-based-routing mide segundos por token de salida, no latencia de pared. Un turno de chat de veinte tokens parece lentísimo con esa métrica y una generación de agente de dos mil tokens parece rapidísima, así que mezclar ambos tráficos en el mismo grupo envenena la señal. Además siembra los deployments nuevos con latencia cero, y con el buffer por defecto a cero, una réplica recién arrancada se lleva todo el tráfico hasta su primer callback de éxito.
usage-based-routing-v2 usa ventanas de minuto que se resetean de golpe, y contabiliza los tokens al completar la petición, de modo que las generaciones largas en vuelo son invisibles para el router. Con salidas largas, subestima la carga real de forma sistemática.
cost-based-routing ordena por la suma de los precios unitarios de entrada y salida y coge el primero, sin desempate aleatorio. Con réplicas del mismo modelo al mismo precio, todo el tráfico va siempre al mismo sitio. Y un modelo que no esté en el mapa de precios recibe un coste por defecto de 5,0 por token, cifra pensada para despriorizarlo.
Control de admisión: la pieza nueva de la rama 1.101
Cuando el pool de agentes se satura, lo que se quiere es rechazar rápido y con un código que el cliente entienda, no acumular peticiones dentro del proxy hasta que se agote la memoria. Eso llega con el control de admisión por worker, incorporado en la rama 1.101, en release candidate mientras se escribe esto.
general_settings:
max_in_flight_requests_per_worker: 64
max_queued_requests_per_worker: 64
admission_queue_timeout_seconds: 1.0
Es un semáforo con cola por proceso de uvicorn, sin Redis. Al llenarse la cola o expirar la espera devuelve 503 con retry-after: 1 y un cuerpo con "type": "overloaded_error". Se observa con /health/backlog, que expone peticiones en vuelo, admitidas, encoladas y rechazadas, y con las métricas litellm_admission_* de Prometheus, cuyo contador de rechazos distingue queue_full de queue_timeout.
Dos propiedades hay que llevar al runbook. El middleware rechaza antes de autenticar, así que lo descartado no queda atribuido a ninguna clave en los registros de gasto, y el número real de peticiones rechazadas por equipo no se puede reconstruir desde la tabla de gasto. Y los límites se leen en la primera petición: cambiarlos exige reinicio.
Concurrencia por clave, y las tres configuraciones que no hacen nada
max_parallel_requests es el freno correcto para un agente concreto, y su superficie de configuración está llena de caminos muertos.
El único nivel donde se aplica es la clave virtual. El limitador v3, activo por defecto desde la 1.94, tiene descriptores para usuario, equipo, miembro de equipo, cliente final, organización, modelo por clave, tag y agente, y todos ellos llevan solo contadores de peticiones y tokens por ventana. La única referencia a max_parallel_requests es la del descriptor de clave. El limitador antiguo lo dice en un comentario: soportarlo para modelo, usuario y equipo está pendiente.
Al mismo tiempo, /team/new, /user/new, /customer/new y los endpoints de organización aceptan el campo, lo validan y lo escriben en Postgres, y la documentación lo muestra en sus ejemplos. Nadie lo lee después. Un equipo con max_parallel_requests: 20 en su fila no tiene ningún límite de concurrencia.
global_max_parallel_requests se inyecta en la metadata de la petición y solo lo consulta el limitador antiguo, que está desactivado salvo que se ponga LEGACY_MULTI_INSTANCE_RATE_LIMITING=true. La documentación de ajustes del servidor lo sigue describiendo como un límite global coordinado por Redis. Y general_settings.max_parallel_requests no aparece en ningún camino de ejecución: sus dos únicas apariciones son la copia desde la base de datos y el esquema de tipos de la interfaz.
Lo que sí funciona, con detalle:
curl -X POST "$PROXY/key/generate" -H "Authorization: Bearer $MASTER_KEY" \
-d '{"team_id":"plataforma-agentes","max_parallel_requests":8,"rpm_limit":600}'
El conteo se hace con un conjunto ordenado en Redis puntuado por el reloj del propio Redis, con scripts Lua para adquirir y liberar. Cada slot caduca a los 3.600 segundos, que es a la vez la duración máxima de petición que el medidor puede seguir y el tiempo que tarda en curarse solo un slot filtrado. La liberación está enganchada al éxito, al fallo y a la desconexión del cliente durante el streaming, que era la fuga clásica con clientes que cancelan.
Queda una fuga viva y afecta justo al caso de este artículo: cada llamada a herramienta por el gateway MCP adquiere un slot y no lo suelta. La ruta MCP invoca el hook previo, que adquiere, y ninguna de las funciones de liberación se llama desde ese módulo. Con un agente que hace veinte llamadas a herramientas por turno y una clave con ocho slots, el 429 llega en el primer turno y dura una hora.
El orden de evaluación importa para interpretar los 429. Primero se comprueban los contadores de ventana, peticiones y tokens por minuto, y solo si pasan se intenta adquirir el slot de concurrencia. El comentario del código explica por qué: al revés, cada rechazo por límite de tasa dejaría un slot huérfano. En la práctica, con rpm bajo se ve siempre el 429 de tipo requests, y el de max_parallel_requests solo aparece cuando la relación entre concurrencia y duración lo hace vinculante antes. El mensaje distingue los dos casos con el campo Limit type: y la cabecera rate_limit_type.
Detalle menor con consecuencias en el cliente: la respuesta de un rechazo por concurrencia lleva retry-after y reset_at calculados con el tamaño de la ventana, sesenta segundos por defecto, cuando un medidor de concurrencia no tiene ventana. Un cliente educado que respete esa cabecera esperará un minuto cuando quizá tenía slot libre en dos segundos.
Afinidad de sesión: el KV cache que se tira a la basura
Un turno de agente reenvía el contexto entero de la conversación, sistema y herramientas incluidos. Si esa petición aterriza en una réplica distinta a la del turno anterior, la caché de prefijos de vLLM no tiene el prefijo y el prefill se recomputa entero. Con veinte turnos y cuatro réplicas balanceadas a ciegas, se paga el prefill largo quince veces de más.
LiteLLM tiene dos mecanismos para esto y no son igual de buenos.
La comprobación prompt_caching calcula un SHA-256 del prefijo cacheable, definido como todo hasta el último bloque con cache_control, y fija el deployment con ese hash. Tiene dos problemas. El TTL está escrito a fuego en 300 segundos en dos sitios del código, sin manera de configurarlo, cosa que hay reportada y abierta; con cachés de una hora, la afinidad se pierde a los cinco minutos de inactividad. Y la clave es un hash exacto, así que un cliente que mueva el punto de corte hacia adelante en cada turno, que es justo lo que hacen los agentes para extender el prefijo cacheado, genera una clave nueva cada vez.
La comprobación session_affinity es la que hay que usar:
router_settings:
optional_pre_call_checks: ["session_affinity"]
deployment_affinity_ttl_seconds: 3600
Fija el deployment con un script Lua atómico en Redis, con TTL configurable que por defecto es una hora y que se refresca en cada petición, de modo que acota el tiempo ocioso entre turnos y no la duración de la conversación. Si el deployment fijado entra en enfriamiento, la petición sale por la estrategia normal y conserva el pin para volver cuando sane.
El identificador de sesión sale de una cadena de precedencia que empieza por x-litellm-trace-id, sigue por x-litellm-session-id y después acepta cualquier cabecera con la forma x-<algo>-session-id cuyo valor parezca un identificador. Eso incluye la cabecera de sesión que ya manda Claude Code, sin configurar nada en el cliente. Lo que case puebla a la vez el identificador de sesión y el de traza, lo que enlaza esto con la correlación del primer artículo del track.
Una interacción peligrosa al combinarlo con tags: la afinidad se evalúa antes que el enrutado por tags. Si el pin reduce la lista a un deployment que no satisface el tag de la petición, esta falla en lugar de caer al pool por defecto, salvo que se haya activado allow_fail_open. Reetiquetar un pool con sesiones vivas las rompe.
Y una aclaración que evita una búsqueda inútil: LiteLLM no hace enrutado por prefijo. No hay árbol de prefijos ni nada que dirija la petición a la réplica de vLLM que ya tiene ese prefijo caliente. Lo que hay es afinidad por sesión o por clave. Para lo otro hace falta un router específico por debajo del gateway, y esa arquitectura tiene su propio artículo pendiente.
El motor: lo que vLLM puede y no puede hacer
Todo lo anterior reparte peticiones entre motores. Dentro de un motor, la pregunta es qué ocurre cuando conviven una petición de veinte tokens y otra de sesenta mil.
La prioridad existe y hace menos de lo que parece. --scheduling-policy priority convierte la cola de espera en un montículo ordenado por prioridad, hora de llegada e identificador, con el número más bajo primero. La petición puede llevar el campo priority en el cuerpo, y también existe la cabecera X-Vllm-Priority, que tiene precedencia sobre el cuerpo y no está documentada en ningún sitio.
Lo que la prioridad no hace es adelantar a quien ya está generando. En el bucle de la cola en ejecución, cuando falta caché KV, la política solo cambia a quién se elige como víctima del desalojo: con priority se desaloja a la de peor prioridad y hora de llegada, y con fcfs a la última de la cola. En el bucle de la cola de espera, si no hay bloques disponibles se corta la iteración, sin desalojar a nadie. Y el tope max_num_seqs es un corte duro. Si los agentes han llenado ese tope, un chat con la máxima prioridad espera igual. Hay una incidencia abierta desde abril y un pull request sin integrar sobre esto.
Ese matiz de la víctima no es despreciable, porque con fcfs las víctimas son las últimas admitidas, es decir, los chats que acaban de entrar. Activar la política de prioridad ya mejora la situación aunque no adelante a nadie.
El desalojo es siempre por recomputación. El modo de intercambio a memoria del host y la enumeración que lo controlaba desaparecieron del código; solo queda liberar bloques y devolver la petición a la cola con el contador de tokens computados a cero. Una petición desalojada pierde todo su prefill, y la caché de prefijos recupera parte, pero el tiempo hasta el primer token percibido se reinicia.
Dos ajustes de admisión ayudan y no aparecen en la documentación narrativa. watermark reserva una fracción de bloques KV libres al admitir, con el propósito declarado de evitar el desalojo repetido, y viene desactivado. Y scheduler_reserve_full_isl, activo por defecto, comprueba que la longitud de entrada completa cabe en la caché antes de admitir, en lugar de mirar solo el primer trozo.
El prefill troceado está activo por defecto y prioriza decodificación. El planificador recorre entero el bucle de peticiones en ejecución antes de tocar la cola de espera, así que un prefill largo de agente no congela la generación en curso de un chat. Eso protege la latencia entre tokens de lo ya admitido, y no protege el tiempo hasta el primer token de lo que espera.
Aquí hay un cambio de ruptura que obliga a revisar los manifiestos. max_num_partial_prefills y max_long_partial_prefills fueron eliminados en la 0.27. Eran justamente el mecanismo que permitía que los prompts cortos adelantaran a los largos. Un despliegue con esos flags en el args no arranca. long_prefill_token_threshold sigue existiendo, y cambió de significado: hoy es un tope de tamaño de trozo por petición, no una clasificación de peticiones largas.
El sustituto parcial llega en la 0.29 con max_num_queued_tokens y max_num_queued_reqs, válvulas de admisión que devuelven 503 cuando el trabajo pendiente de prefill excede el objetivo. El propio código lo plantea como mecanismo de calidad de servicio para el tiempo hasta el primer token, con la fórmula de fijarlo al objetivo de latencia multiplicado por el rendimiento de prefill. Son ciegas a la clase de tráfico: rechazan igual a un chat que a un agente.
Y el valor por defecto de --max-num-batched-tokens depende del hardware y del modo de arranque. Con vllm serve sobre H100 o H200 son 8.192, sobre tarjetas de 160 GiB o más son 16.384, y en el resto 2.048. El 2.048 que aparece en la configuración del planificador solo aplica a pruebas.
Las métricas para diagnosticar convivencia, con los nombres exactos de la 0.29:
| Métrica | Para qué |
|---|---|
vllm:num_requests_waiting | profundidad de cola, la señal de autoescalado |
vllm:num_requests_waiting_by_reason | separa espera por capacidad de espera por estado bloqueado |
vllm:num_preemptions_total | desalojos, la señal de que las dos clases se pisan |
vllm:kv_cache_usage_perc | presión de caché, causa de los desalojos |
vllm:request_queue_time_seconds | lo que espera un chat con los agentes dentro |
vllm:prefix_cache_hits_total y _queries_total | medidos en tokens, no en peticiones |
Una advertencia para quien tenga alertas antiguas. El aviso de log sobre secuencias desalojadas que aparece en la documentación de optimización es de la versión anterior del motor y ya no existe en el código. Las expulsiones solo se ven como contador. Un alerta basada en buscar esa cadena en los logs lleva meses sin disparar y no porque no haya desalojos.
Sobre aislamiento entre clases dentro de un mismo motor, la respuesta corta es que no existe. Hay dos colas y ninguna es por clase de tráfico. La conclusión, que es la que sostiene la arquitectura de este artículo: el aislamiento efectivo son flotas separadas, y encima de ellas el gateway decidiendo quién va a cuál.
Reintentos: el producto que nadie calcula
Un humano que recibe un error lo ve y decide. Un bucle agéntico reintenta, y por debajo suyo hay dos capas más que también reintentan.
| Capa | Por defecto |
|---|---|
| SDK del cliente | 2 reintentos, 3 intentos |
| Router de LiteLLM | 2 reintentos, 3 intentos |
| Fallbacks | hasta 5 grupos |
El producto son unas 45 llamadas al motor por un turno del agente, antes de contar el reintento del propio bucle. LiteLLM hace lo que puede: cuando la llamada viene del router, fuerza max_retries=0 en el SDK que él mismo usa contra el motor. Lo que no puede tocar es el SDK del cliente. La única capa que rompe el producto es esa, y se rompe poniendo max_retries=0 en el cliente y delegando el reintento en el gateway.
Los errores reintentables son 408, 409, 429 y todo lo que sea 500 o superior. Y ahí está el problema con el razonamiento largo: litellm.Timeout es subclase de la excepción de timeout de OpenAI y sale con código 408, que es reintentable, de modo que la petición más cara de todas, la que expiró después de diez minutos generando, se vuelve a lanzar entera. Dos veces por el router, y otras tantas por el cliente.
Sobre los timeouts hay que ser preciso porque el comentario del código lo es: el timeout configurado es por intento, no por petición. Un request_timeout: 600 con dos reintentos y dos grupos de fallback da un peor caso de casi dos horas antes de que el cliente reciba nada. El valor por defecto sigue siendo 6.000 segundos, como ya se contó en el artículo de día 2.
La tormenta de reintentos tiene dos causas concretas y ambas son ajustes por defecto.
La primera: un solo 429 saca al deployment del pool, sin pasar por el umbral de allowed_fails. La lógica de enfriamiento tiene una rama explícita que, ante un 429 en un grupo con más de un deployment, devuelve enfriamiento inmediato. Con una ráfaga agéntica contra cuatro réplicas, los cuatro salen del pool a la vez y el gateway responde a todo el mundo con su propio 429 de “no hay deployments disponibles”. El enfriamiento por defecto es de cinco segundos, que en una ráfaga sostenida se renueva.
La segunda: el backoff vale cero mientras el pool parezca sano. La función que calcula la espera devuelve 0 si la lista de deployments sanos no está vacía. El backoff exponencial con jitter, con tope de ocho segundos, solo entra cuando ya no queda nada sano. Bajo degradación parcial, el proxy martillea sin esperar y solo frena cuando ya se ha caído todo.
A eso se suma que la cabecera Retry-After del motor solo se respeta si su valor está entre 1 y 60 segundos. Una ventana de límite más larga se ignora y se sustituye por el backoff propio.
Los ajustes que cortan el problema:
router_settings:
num_retries: 1
retry_after: 2 # suelo real; el 0 por defecto es martilleo
cooldown_time: 30
allowed_fails_policy:
RateLimitErrorAllowedFails: 3 # evita que un 429 saque el deployment
retry_policy:
TimeoutErrorRetries: 0 # no relanzar la petición cara
AuthenticationErrorRetries: 0
litellm_settings:
request_timeout: 900 # por intento
drop_params: false
Ese último ajuste merece explicación, porque es una manera silenciosa de romper un agente. Con drop_params: true, LiteLLM elimina del cuerpo cualquier parámetro que el proveedor de destino no soporte, y en esa rama no escribe aviso alguno. Los candidatos a desaparecer son tools, tool_choice, response_format y parallel_tool_calls. Un agente al que le quitan tools recibe texto libre donde esperaba una llamada a función, y lo interpreta como que el modelo ha decidido no usar herramientas.
Peor todavía es la emulación por prompt. Para un proveedor no compatible con OpenAI que no soporte herramientas, LiteLLM no falla: convierte las herramientas en texto dentro del prompt, fuerza formato JSON, descarta tool_choice, y de paso activa una variable global de proceso que hace que cualquier otro proveedor sin herramientas del mismo proceso pase a emulación en vez de dar error.
Con eso en la cabeza, el aviso sobre fallbacks es claro: LiteLLM no comprueba capacidades antes de hacer fallback. La función que sabe si un modelo soporta llamada a funciones existe y solo se usa en un endpoint informativo. Los chequeos previos filtran por ventana de contexto, límite de peticiones y región, y nada más. Una lista de fallback que lleve de un modelo con herramientas a uno sin ellas degrada el agente en silencio. Sí existen guardas para recursos con ámbito de proveedor, ficheros y lotes, así que la ausencia de la guarda de capacidades es una omisión y no un olvido de diseño.
El presupuesto de un mes en veinte minutos
Un agente descontrolado consume en minutos lo que un equipo consume en semanas, y la pregunta operativa es cuánto tarda el gateway en enterarse.
La respuesta ha cambiado, y el modelo mental antiguo, el de que el gasto se consolida cada minuto y hasta entonces no hay freno, ya no es correcto. Desde la 1.84 hay dos piezas nuevas. Una reserva optimista antes de la llamada, que estima el coste máximo de la petición y lo reserva de forma atómica en el contador antes de salir hacia el motor, reconciliándolo con el coste real al terminar. Y un incremento del contador después de la llamada que se espera de forma explícita, con el comentario de que así el contador está actualizado antes de que la siguiente petición pase por autenticación. Las comprobaciones de presupuesto leen ese contador, con Redis primero y siembra desde base de datos en frío.
La escritura por lotes a Postgres sigue existiendo, cada 10 a 15 segundos, y afecta a persistencia y a lo que se ve en la interfaz, no al corte. La recomendación de la documentación de producción de subir ese intervalo a 60 no relaja el control, y sí retrasa un minuto lo que muestra /key/info, cosa que genera pánico operativo con frecuencia. Y los diez minutos del reprogramador de presupuestos no consolidan gasto: disparan el trabajo que resetea los presupuestos vencidos.
La ventana de sobregasto queda entonces en el coste de las peticiones en vuelo, siempre que haya Redis. Sin Redis, los contadores son por pod, y con N réplicas se puede gastar hasta N veces el presupuesto. Ese es el fallo de configuración que importa y es el mismo Redis que el artículo de día 2 declaraba no opcional por otras razones.
Dos ajustes que no aparecen en la documentación pública, solo en las descripciones de los modelos de datos, y que deciden el comportamiento en el peor momento:
general_settings:
fail_closed_budget_enforcement: true # por defecto false: si falla el contador, deja pasar
# disable_budget_reservation: no ponerlo a true
Con el valor por defecto, si el backend de contadores no responde, el proxy falla abierto y sigue sirviendo. Para un agente en bucle eso es exactamente lo contrario de lo que se quiere.
Toca la advertencia honesta: esta ruta sigue inestable. Hay incidencias abiertas en las dos direcciones, una en la que el límite de presupuesto de clave y usuario deja de aplicarse mientras el gasto se registra, y otra en la que se devuelve 429 por gasto obsoleto de los contadores de reserva mientras la consulta de la clave muestra un gasto por debajo del límite. Los presupuestos de proyecto no están en la ruta de reserva atómica y los sobrepasa la concurrencia. La conclusión práctica es no fiar el corte duro solo al presupuesto.
Sobre el corte manual, hay una asimetría que hay que conocer antes de necesitarla. /key/block invalida la caché local, la de Redis y avisa por publicación a todos los workers, de modo que con Redis el efecto es casi inmediato. /customer/block hace lo mismo. /team/block solo escribe en la base de datos, sin invalidar ni avisar, así que tarda lo que tarde el TTL de la caché de objetos de gestión, sesenta segundos por defecto. Para cortar un equipo en seco hay que bloquear sus claves una a una, o bajar user_api_key_cache_ttl. Y /user/block ya no existe, aunque el docstring de la función que lo sustituyó siga mostrando esa ruta en su ejemplo.
De las alertas, dos números. Los umbrales por defecto avisan al 85 % y al 95 % del presupuesto. Y la deduplicación de esas alertas es de 24 horas por evento e identificador, así que cruzar el 85 % genera un aviso y después silencio durante un día. Para tráfico agéntico, donde el 85 % y el 100 % pueden distar veinte minutos, el segundo aviso llega cuando ya no sirve.
Atribución: quién es el usuario cuando quien llama es un agente
Con agentes, la pregunta de a quién se le imputa el gasto tiene dos respuestas y hacen falta las dos: el agente que ejecuta y el humano en cuyo nombre actúa.
La cadena que extrae el identificador de cliente final prueba primero las cabeceras estándar, después el mapeo configurable de cabeceras, y solo entonces el campo user del cuerpo, el litellm_metadata.user del formato Anthropic, el metadata.user_id y el safety_identifier. Los cuatro últimos son campos del cuerpo y por tanto los escribe el cliente. El propio código lo avisa: para llamantes no confiables hay que fijar el identificador desde cabeceras o desde un middlware en el servidor, para evitar la suplantación.
De ahí sale el patrón correcto, que es el inverso al que se monta por inercia: la clave virtual identifica al agente y es la parte no falsificable, y la cabecera o el campo user identifican al humano y sirven para atribución, no para control. Un agente que miente sobre el humano detrás sigue estando acotado por el presupuesto y los límites de su clave.
La tabla de gasto guarda user, end_user, team_id, organization_id, request_tags, session_id y agent_id, con agregación diaria por agente, así que el detalle está disponible una vez se manda. La cabecera x-litellm-agent-id es la que puebla ese último campo.
Un apunte sobre el gateway MCP, que en una plataforma con agentes acaba siendo la otra puerta de entrada. Las llamadas a herramientas sí quedan en los registros de gasto, con tipos de llamada propios y un bloque de metadata que guarda nombre de herramienta, argumentos, resultado y servidor. Lo que no hay es conteo de tokens: el coste es 0,0 por defecto y solo se puebla si el administrador configura un precio por consulta, por servidor o por herramienta. Es el mismo patrón del coste cero silencioso que ya apareció con los modelos sin precio en el primer artículo del track.
Y el ajuste de permisos que hay que revisar el primer día: el control de acceso a servidores MCP es abierto por defecto. Si ningún nivel de la jerarquía define lista, la petición accede a todos los servidores configurados. general_settings.require_key_mcp_access_defined: true invierte esa herencia, y no es el valor por defecto.
Un config.yaml de referencia
Todo lo anterior, junto, sobre una flota partida en dos pools:
model_list:
- model_name: qwen-30b-interactivo
litellm_params:
model: hosted_vllm/Qwen3-30B
api_base: http://vllm-chat.inferencia.svc:8000/v1
input_cost_per_token: 0.00000018 # sin esto, coste cero silencioso
output_cost_per_token: 0.00000072
model_info:
id: chat-01
max_input_tokens: 32768 # sin esto no hay filtro de contexto
- model_name: qwen-30b-agentes
litellm_params:
model: hosted_vllm/Qwen3-30B
api_base: http://vllm-agentes.inferencia.svc:8000/v1
input_cost_per_token: 0.00000018
output_cost_per_token: 0.00000072
tag_regex: ["^User-Agent: claude-code\\/"]
model_info:
id: agentes-01
max_input_tokens: 131072
allow_fail_open: true
router_settings:
routing_strategy: simple-shuffle
enable_pre_call_checks: true
enable_tag_filtering: true
optional_pre_call_checks: ["session_affinity"]
deployment_affinity_ttl_seconds: 3600
num_retries: 1
retry_after: 2
cooldown_time: 30
allowed_fails_policy:
RateLimitErrorAllowedFails: 3
retry_policy:
TimeoutErrorRetries: 0
AuthenticationErrorRetries: 0
general_settings:
fail_closed_budget_enforcement: true
require_key_mcp_access_defined: true
user_api_key_cache_ttl: 10
proxy_batch_write_at: 10
# rama 1.101 en adelante:
max_in_flight_requests_per_worker: 64
max_queued_requests_per_worker: 64
admission_queue_timeout_seconds: 1.0
litellm_settings:
request_timeout: 900 # por intento
drop_params: false
cache: true # Redis, no opcional
Y el arranque de los dos motores, con la asimetría deliberada:
# pool interactivo: proteger el primer token
vllm serve Qwen/Qwen3-30B \
--scheduling-policy priority \
--max-num-seqs 64 \
--max-model-len 32768 \
--max-num-batched-tokens 4096
# pool de agentes: rendimiento y colas profundas
vllm serve Qwen/Qwen3-30B \
--max-num-seqs 256 \
--max-model-len 131072 \
--max-num-batched-tokens 8192
Checklist
- Emitir claves separadas para agentes, con
model_group_aliasa nivel de clave o equipo. Es la única separación que el cliente no puede eludir. - Añadir
tag_regexsobre elUser-Agentcomo red de seguridad para los agentes que usen credenciales personales, con un pooldefaultyallow_fail_open: true. - Dejar
simple-shuffleen ambos pools. Las otras cuatro estrategias tienen defectos verificables sobre réplicas homogéneas. - Activar
session_affinitycon TTL de una hora. No usarprompt_cachingpara esto mientras su TTL siga escrito a fuego. - Declarar
max_input_tokensen cada deployment. Sin ese valor, el filtro de ventana de contexto no se aplica y falla en silencio para modelos que no estén en el mapa de precios. - Declarar precios por token en cada modelo self-hosted, o el gasto de los agentes será cero y su presupuesto no se agotará nunca.
max_parallel_requestsen la clave del agente, nunca en su equipo. En el equipo se guarda y no se aplica.- Bajar
num_retriesa 1, poner suelo aretry_after, subircooldown_timey desactivar el reintento por timeout. En el cliente,max_retries=0. fail_closed_budget_enforcement: truey Redis obligatorio. Sin Redis, N réplicas son N veces el presupuesto.- Arrancar el motor interactivo con
--scheduling-policy priorityaunque no adelante a nadie: cambia a quién desaloja. - Alertar sobre
vllm:num_preemptions_totalyvllm:num_requests_waiting_by_reason, no sobre cadenas de log que ya no se emiten. - Preparar el corte: bloqueo por clave, nunca por equipo, y verificar el tiempo real de propagación en el despliegue propio.
Trampas y cosas que no son lo que parecen
priority: 0en/chat/completionsdesactiva la priorización. Es el valor de los ejemplos de la documentación y es falsy en Python.max_parallel_requestsen un equipo se guarda y no se aplica. El campo existe en la API, en la base de datos y en la documentación; el limitador solo lo lee del descriptor de clave.global_max_parallel_requestses un no-op con el limitador por defecto, y la documentación de ajustes del servidor sigue describiéndolo como activo.- Cada llamada MCP filtra un slot de concurrencia, que solo se cura al expirar su hora. Con agentes que llaman a herramientas en bucle, el 429 aparece pronto y dura mucho.
- El TTL de la afinidad por caché de prompts son 300 segundos escritos a fuego. Para conversaciones de agente, la afinidad de sesión es la opción correcta.
- La afinidad se evalúa antes que el enrutado por tags. Un pin a un deployment que deja de satisfacer el tag hace fallar la petición en lugar de caer al pool por defecto.
- El timeout de LiteLLM es por intento. El peor caso es el timeout multiplicado por intentos y por grupos de fallback.
- Un 429 saca al deployment del pool sin pasar por
allowed_fails, y el backoff vale cero mientras quede algo sano. Retry-Afterdel motor se ignora si pide más de 60 segundos.drop_params: trueeliminatoolsyresponse_formatsin dejar aviso, y el fallback no comprueba si el modelo destino soporta llamada a funciones./team/blockno invalida caché. Tarda el TTL completo;/key/blocksí propaga por publicación en Redis.- El acceso a servidores MCP es abierto por defecto si ningún nivel define lista.
max_num_partial_prefillsymax_long_partial_prefillsya no existen en vLLM. Un manifiesto con esos flags no arranca desde la 0.27.- La prioridad de vLLM no adelanta a peticiones en ejecución, y con
max_num_seqslleno no adelanta a nadie. - El aviso de log de desalojo que sale en la documentación de vLLM ya no se emite. Solo queda el contador de Prometheus.
Cierre
La convivencia de humanos y agentes sobre la misma plataforma no se resuelve con un campo de prioridad, y esa es la conclusión que más trabajo cuesta aceptar porque el campo existe y parece hecho para esto. Ni el gateway ni el motor tienen hoy una noción utilizable de clase de servicio: el uno ordena una cola que casi nunca se consulta, el otro elige a quién desalojar. Lo que sí tienen es la capacidad de dirigir cada petición a un sitio distinto, y ahí es donde se gana.
La arquitectura que sale de todo lo anterior cabe en una frase. Dos flotas de vLLM con parámetros opuestos, una credencial que decide a cuál va cada petición, afinidad de sesión para no tirar el trabajo de prefill, límites de concurrencia en la clave del agente y no en su equipo, reintentos recortados en las tres capas donde se multiplican, y presupuestos que fallen cerrados.
El resto es medir. Si vllm:num_preemptions_total sube en el pool interactivo, las dos clases se están pisando pese a la separación. Si el tiempo en cola de los chats crece mientras el rendimiento total se mantiene, la partición está mal dimensionada. Y si el gasto de los agentes sale cero, el problema no es de convivencia sino de precios sin declarar, que es donde empezó este track.
Ver también
- Claves virtuales, presupuestos y límites en LiteLLM — la jerarquía completa y el limitador v3 sobre el que se apoyan los límites de este artículo.
- LiteLLM en día 2: alta disponibilidad — el Redis y el Postgres que aquí se dan por montados, y los reintentos vistos desde el lado del gateway.
- LiteLLM y Langfuse: el par operativo — la correlación de trazas que el identificador de sesión de este artículo alimenta, y el coste cero de los modelos sin precio.
- FinOps y multi-tenencia GPU con LiteLLM — de dónde salen las cifras que se ponen en los presupuestos que aquí se defienden.
- El contratista con la llave maestra: aislar agentes de IA — el otro lado del problema, el del agente como riesgo de seguridad y no como clase de tráfico.
- El segundo vector de coste de los agentes IA — lo que cuesta un bucle que falla a medias, y por qué la ejecución duradera cambia esa cuenta.
- Enrutado por prefijo: lo que LiteLLM no hace — la continuación de la afinidad de sesión de este artículo, y la corrección de lo que el gateway puede hacer con el KV cache.
- El gateway MCP de LiteLLM — la otra puerta de entrada de los agentes, incluida la corrección sobre el acceso por defecto que este artículo daba por abierto.
- El router de inferencia LLM — las cuatro funciones del router y el enrutado consciente de prefijo que LiteLLM no hace.
- Optimizaciones de prefill en vLLM — el trabajo que se recomputa entero cuando una petición es desalojada.
- El paso del planificador de vLLM — el bucle sobre el que operan la política de prioridad y el prefill troceado.
- Cuando MCP crece: autenticación con Keycloak — la identidad de la otra puerta de entrada, la que usan los agentes para llamar a herramientas.
Fuentes
- LiteLLM, [BETA] Request Prioritization: https://docs.litellm.ai/docs/scheduler.
- LiteLLM, Router - Load Balancing (estrategias, afinidad de sesión, comprobaciones previas): https://docs.litellm.ai/docs/routing.
- LiteLLM, Tag Based Routing (incluye
tag_regexy el ejemplo con clientes agénticos): https://docs.litellm.ai/docs/proxy/tag_routing. - LiteLLM, Router Settings for Keys and Teams (resolución clave, equipo, global): https://docs.litellm.ai/docs/proxy/keys_teams_router_settings.
- LiteLLM, Server Tuning (control de admisión por worker): https://docs.litellm.ai/docs/proxy/server_tuning.
- LiteLLM, Dynamic TPM/RPM Allocation (reserva de prioridad, beta y enterprise): https://docs.litellm.ai/docs/proxy/dynamic_rate_limit.
- LiteLLM, Budgets, Rate Limits: https://docs.litellm.ai/docs/proxy/users.
- LiteLLM, Production Best Practices: https://docs.litellm.ai/docs/proxy/prod.
- LiteLLM, Reliability (fallbacks, ventana de contexto, enfriamientos): https://docs.litellm.ai/docs/proxy/reliability.
- LiteLLM, código:
litellm/scheduler.py,litellm/router_strategy/tag_based_routing.py,litellm/router_utils/pre_call_checks/deployment_affinity_check.py,litellm/proxy/hooks/parallel_request_limiter_v3.py,litellm/proxy/middleware/admission_control_middleware.py,litellm/router_utils/cooldown_handlers.py: https://github.com/BerriAI/litellm. - LiteLLM, incidencia 28427, TTL de la afinidad por caché de prompts fijado a cinco minutos: https://github.com/BerriAI/litellm/issues/28427.
- LiteLLM, incidencia 34534, fuga de slot de concurrencia en cada llamada MCP: https://github.com/BerriAI/litellm/issues/34534.
- LiteLLM, incidencia 26672, presupuesto de clave y usuario no aplicado: https://github.com/BerriAI/litellm/issues/26672.
- LiteLLM, incidencia 872, comprobación previa de soporte de llamada a funciones, cerrada sin implementar: https://github.com/BerriAI/litellm/issues/872.
- vLLM, Optimization and Tuning (prefill troceado, prioridad de decodificación): https://docs.vllm.ai/en/v0.29.0/configuration/optimization.html.
- vLLM, Metrics design (nombres, convención
_total, autoescalado como problema abierto): https://docs.vllm.ai/en/v0.29.0/design/metrics.html. - vLLM, Data Parallel Deployment (caché KV independiente por réplica): https://docs.vllm.ai/en/v0.29.0/serving/data_parallel_deployment.html.
- vLLM, código:
vllm/v1/core/sched/scheduler.py,vllm/v1/core/sched/request_queue.py,vllm/config/scheduler.py,vllm/engine/arg_utils.py,vllm/v1/metrics/loggers.py: https://github.com/vllm-project/vllm. - vLLM, incidencia 40004, la prioridad no desaloja peticiones en ejecución: https://github.com/vllm-project/vllm/issues/40004.