LiteLLM en día 2: un worker por pod, seis mil segundos de timeout y las otras cinco cosas que hay que cambiar antes de abrir tráfico

Segundo artículo del track operativo de la capa de control. El par operativo con Langfuse trató la costura entre el gateway y la observabilidad; aquí se queda todo dentro del gateway. Las decisiones previas, qué gateway y por qué, están en elegir el gateway OSS y en el router de inferencia L7.

TL;DR

LiteLLM Proxy arranca con un config.yaml de veinte líneas y sirve tráfico esa misma tarde. El trabajo de operación consiste en corregir siete valores por defecto que están pensados para un escenario distinto del de una factoría de inferencia propia.

Un worker de uvicorn por pod. La guía de producción del proyecto pide --num_workers 1 en Kubernetes y escalado horizontal, con 1 vCPU y 4 GiB por worker puestos a la vez como requests y como limits. Los 4 GiB son un suelo y no un objetivo: el motor de consultas de Prisma marca una cota de memoria residente que crece hasta la sentencia más grande que haya ejecutado y que glibc no devuelve al sistema.

Autoescalar por CPU, nunca por memoria. Consecuencia directa de lo anterior. La memoria sube y no baja, así que un HPA por memoria escala y no desescala jamás. El objetivo recomendado es targetCPUUtilizationPercentage: 60.

La aritmética de conexiones a Postgres. database_connection_pool_limit vale 10, y el número de conexiones es instancias por workers por el pool. Los charts de Helm traen maxReplicas: 100, del orden de mil conexiones, muy por encima de lo que acepta un Postgres de serie.

Redis deja de ser opcional a partir de unas mil peticiones por segundo o diez instancias. Sin él, cada instancia aplica sus límites por su cuenta, las cachés no se comparten, y las actualizaciones de gasto contra las mismas filas producen bloqueos y agotan las conexiones con FATAL: sorry, too many clients already.

Tres endpoints de salud y solo dos valen para las sondas. /health/liveliness y /health/readiness no piden autenticación y responden rápido. /health a secas exige clave y lanza una petición real contra cada modelo del catálogo, con su coste en tokens.

Los números de resiliencia son 2, 3 y 5: dos reintentos, tres fallos permitidos y cinco segundos de enfriamiento, este último pese a que la documentación de la propia función diga que el valor por defecto es 1.

request_timeout vale 6.000 segundos. Cien minutos sosteniendo una conexión contra un motor que no responde.

Y una advertencia sobre las cifras de rendimiento: el proyecto publica 8 ms de p95 de sobrecarga en un banco de pruebas y 257,7 ms de p99 en otro. Las dos son suyas, y la diferencia está en la forma de la carga.

Estás aquí: la capa de gateway, el día después

En el stack de siete capas, este artículo vive entero en la capa de gateway. La diferencia con el artículo de elección es la que separa comprar de mantener: allí la pregunta era qué pieza poner delante de la flota, y aquí es qué pasa cuando esa pieza lleva seis meses sirviendo, el equipo ha triplicado el tráfico y alguien ha metido un agente que dispara veinte llamadas por interacción.

El gateway tiene una propiedad incómoda que hay que tener presente: está en el camino crítico de cada petición y no aporta ni un token. Todo lo que hace es coordinación, y toda coordinación que falla se convierte en una caída del servicio de inferencia entero, aunque las GPUs estén perfectamente.

La analogía: la centralita y su libreta

Una centralita de hospital hace tres trabajos a la vez. Pasa llamadas, que es lo urgente. Apunta en una libreta quién llamó, a qué extensión y cuánto duró, que es lo que sostiene la facturación. Y consulta una lista de extensiones para saber a dónde pasar cada llamada.

Los tres trabajos compiten. Si la operadora se para a escribir cada línea de la libreta antes de pasar la siguiente llamada, la cola de espera crece. Si hay tres operadoras y cada una lleva su propia libreta, los totales no cuadran. Y si la lista de extensiones se consulta en un archivador de otra planta, cada llamada tarda el viaje.

Las tres tensiones tienen su equivalente exacto en LiteLLM. La libreta son los SpendLogs en Postgres, y por eso existe el buffer de transacciones en Redis. Las tres operadoras con tres libretas son las réplicas del pod aplicando límites de forma independiente. Y el archivador de otra planta es el enrutado por uso, que añade una consulta a Redis dentro del camino de la petición.

El modelo de proceso: un worker por pod

La documentación de producción es explícita: en Kubernetes, --num_workers 1 y escalar con réplicas. En una máquina virtual sin orquestador, NUM_WORKERS igual al número de vCPU.

La razón no es ideológica. Un worker adicional dentro del mismo pod comparte el límite de memoria del contenedor con los demás, multiplica las conexiones a la base de datos por el mismo factor, y registra una copia de cada trabajo periódico. Con réplicas separadas, cada una tiene su presupuesto de recursos, su cuota de conexiones y un sitio donde el planificador puede colocarla.

El dimensionado recomendado es 1 vCPU y 4 GiB por worker, y la parte que se pasa por alto es que van como requests y como limits a la vez. Poner menos de 4 GiB produce un bucle de reinicios por OOM que aparece tarde, cuando alguna petición grande ha hecho crecer la huella del motor de consultas.

resources:
  requests: { cpu: "1", memory: "4Gi" }
  limits:   { cpu: "1", memory: "4Gi" }

Sobre esa huella hay que ser preciso, porque determina la política de autoescalado. El motor de consultas de Prisma mantiene una memoria residente que actúa como marca de agua: crece hasta la sentencia más grande que haya ejecutado el proceso y no baja, porque glibc no devuelve al sistema esa memoria liberada. Un pod que atendió un pico ayer sigue mostrando la huella de aquel pico hoy.

De ahí salen dos reglas. La primera: autoescalar por CPU y nunca por memoria. Un HPA por memoria ve una métrica que solo sube, escala en el pico y no desescala nunca. El objetivo recomendado es targetCPUUtilizationPercentage: 60, más bajo que el 80 que traen los charts por defecto, porque la sonda de arranque admite hasta 300 segundos antes de pasar la primera comprobación de readiness y hace falta margen para que el pod nuevo esté listo antes de que el que está saturado se caiga.

La segunda: acotar la vida del proceso. --max_requests_before_restart 10000 recicla el worker antes de que la marca de agua importe.

Postgres: la aritmética que revienta al escalar

database_connection_pool_limit vale 10 por defecto. El número total de conexiones que abre el despliegue es réplicas por workers por pool, y hay que compararlo con max_connections del Postgres.

La fórmula que da la documentación es la inversa, y es la que hay que aplicar al dimensionar:

database_connection_pool_limit = MAX_DB_CONNECTIONS / (instancias × workers)

El detalle que hace daño en producción está en los charts de Helm, que traen autoscaling.maxReplicas y keda.maxReplicas a 100. Con el pool por defecto, eso son del orden de mil conexiones en pleno escalado, muy por encima de lo que acepta un Postgres sin tocar. Y el momento en que el HPA llega a esas réplicas es justo el momento de más carga, así que el agotamiento de conexiones llega cuando menos se puede permitir.

Hay dos válvulas más en el mismo camino. Los errores de los proveedores se escriben en la base de datos por defecto, y bajo un fallo sostenido del motor eso infla la tabla de gasto sin aportar nada que no esté en las métricas. Se apagan así:

general_settings:
  disable_error_logs: true      # deja de escribir los errores de proveedor
  proxy_batch_write_at: 60      # agrupa las escrituras de gasto

disable_spend_logs: true es la versión radical, que quita el detalle por petición de la interfaz y deja el coste en Prometheus y en el backend de trazas. Antes de llegar a eso hay que decidir si esa tabla es el registro de auditoría del sistema, que es la discusión del artículo anterior: si lo es, no se puede apagar, y la salida es el buffer de Redis.

Redis: cuándo deja de ser opcional

Sin Redis, LiteLLM funciona. Cada instancia mantiene su propia caché en memoria y aplica sus propios contadores. Las consecuencias son tres, y todas se notan al crecer.

Los límites de peticiones por minuto se aplican por instancia, así que un límite de 100 con cinco réplicas es en la práctica un límite de 500. Los aciertos de caché son locales, de modo que la misma pregunta repetida acierta una vez de cada cinco. Y no hay elección de líder, así que los trabajos periódicos corren en todos los procesos a la vez.

El umbral que da la documentación para activar el buffer de transacciones es mil peticiones por segundo o diez instancias. Por debajo, cada instancia actualiza directamente las filas de clave, usuario y equipo; por encima, todas escriben sobre las mismas filas, aparecen bloqueos y Postgres empieza a rechazar con FATAL: sorry, too many clients already.

general_settings:
  use_redis_transaction_buffer: true

Las métricas que hay que vigilar cuando eso está activo son litellm_in_memory_spend_update_queue_size, litellm_redis_spend_update_queue_size y litellm_pod_lock_manager_size. Si la primera crece sin que baje la segunda, el volcado a Postgres no está siguiendo el ritmo.

Un apunte sobre la estrategia de enrutado, que interactúa con esto. simple-shuffle no consulta nada externo. El enrutado por uso sí, y añade una ida y vuelta a Redis dentro del camino de la petición. En una flota homogénea de vLLM detrás del mismo modelo, la mejora que aporta rara vez compensa esa latencia; en una flota heterogénea, la conversación es otra y la trató el artículo del router.

Los trabajos de fondo se registran por worker

Este detalle no aparece en ninguna guía de arranque y produce un desconcierto notable cuando se descubre. Los trabajos periódicos del proxy se registran por worker de uvicorn, no por pod. Con --num_workers 4 y diez réplicas salen cuarenta copias de cada trabajo, todas ejecutándose sin coordinación si no hay Redis para la elección de líder.

La variable que lo separa es LITELLM_JOB_ROLE: valor serving en los pods que atienden tráfico y una réplica aparte con valor worker para los trabajos. Con la recomendación de un worker por pod, el problema se reduce, pero no desaparece mientras haya varias réplicas.

Sondas: tres endpoints y solo dos valen

Los tres endpoints existen y hacen cosas distintas:

EndpointAutenticaciónQué hacePara qué sirve
/health/livelinessNoDevuelve I'm alive!, o 503 durante el apagadolivenessProbe
/health/readinessNoEstado del proceso y de la base de datos. 503 si el Postgres configurado no respondereadinessProbe
/healthLanza una petición real contra cada modelo del catálogoDiagnóstico manual, nunca una sonda

Hay que insistir en la última fila. /health gasta tokens en cada llamada, uno por modelo configurado. Puesto como sonda, con el intervalo por defecto de Kubernetes y una flota de ocho modelos, son miles de peticiones de inferencia al día que no sirven a nadie.

Hay más endpoints útiles para diagnóstico: /health/readiness/details, /health/services?service=langfuse para comprobar un callback concreto, /health/history y /health/latest para el histórico, y /health/backlog, que devuelve las peticiones en vuelo y aparece en la sección de latencia. /health/drain existe pero devuelve 404 salvo que se active con enable_drain_endpoint: true, y está protegido con X-Drain-Token.

Las comprobaciones periódicas de los modelos se configuran aparte y no en la sonda:

general_settings:
  background_health_checks: true
  health_check_interval: 300          # segundos
  background_health_check_model_groups: ["llama-70b", "qwen-30b"]

Y un ajuste que trae cola en despliegues sin salida a Internet: allow_requests_on_db_unavailable: true permite servir tráfico con la base de datos caída, pero deja /health/readiness devolviendo 200 siempre. Con eso puesto, la sonda de readiness deja de detectar el fallo que fue diseñada para detectar, y los errores de presupuesto y de modelo siguen bloqueando igual.

Reintentos, enfriamientos y fallbacks: los números reales

Los valores por defecto, leídos del código del proyecto y no de la documentación:

ConstanteValorQué gobierna
DEFAULT_MAX_RETRIES2Reintentos antes de pasar a fallbacks
DEFAULT_ALLOWED_FAILS3Fallos antes de enfriar un despliegue
DEFAULT_COOLDOWN_TIME_SECONDS5Segundos que un despliegue queda fuera
SINGLE_DEPLOYMENT_TRAFFIC_FAILURE_THRESHOLD1000Peticiones mínimas antes de aplicar la lógica de enfriamiento con un solo despliegue
DEFAULT_REQUEST_TIMEOUT_SECONDS6000.0Timeout de la petición

Además del contador de fallos, hay un criterio proporcional: un despliegue entra en enfriamiento si falla el 50% de sus peticiones en un minuto dado.

Sobre el enfriamiento hay una discrepancia que merece conocerse antes de depurar a ciegas. La cadena de documentación de la función del router dice que cooldown_time vale 1 por defecto, y el código usa la constante de 5. Manda el código.

Los fallbacks son tres listas separadas y se aplican después de agotar los reintentos:

router_settings:
  num_retries: 2
  timeout: 120
  fallbacks: [{"llama-70b": ["qwen-30b"]}]
  context_window_fallbacks: [{"llama-70b": ["llama-70b-128k"]}]
  content_policy_fallbacks: [{"llama-70b": ["llama-70b-sin-guardrail"]}]

Una nota sobre el comportamiento cuando todo está en enfriamiento: si no queda ningún despliegue disponible en el grupo, LiteLLM cae sobre un model_info.id concreto saltándose la comprobación de enfriamiento. Es una degradación deliberada, servir algo antes que devolver 503, y hay que saber que existe porque enmascara el estado real de la flota.

Y una capacidad que se perdió por el camino: desde la versión 1.85.0, los parámetros mock_testing_fallbacks y sus dos hermanos se eliminan de las peticiones que entran por el proxy y no tienen efecto. Ya no se pueden probar los fallbacks contra el proxy con una petición trucada; hay que hacerlo contra litellm.Router directamente, en un test. Quien tuviera una prueba de humo montada sobre eso, la tiene rota sin avisar.

El timeout de seis mil segundos

Merece su propio apartado porque es el ajuste con peor relación entre daño y esfuerzo de todo el sistema. request_timeout vale 6.000 segundos. Cien minutos. Un motor de inferencia que deja de responder sin cerrar la conexión retiene un worker durante ese tiempo, y con un worker por pod eso es un pod entero fuera de servicio por cada petición atrapada.

general_settings:
  request_timeout: 600

Diez minutos siguen siendo generosos para una generación larga, y acotan el daño.

Overhead: las dos cifras del proyecto

LiteLLM publica dos medidas de su propia sobrecarga que se diferencian en un factor de treinta, y las dos son legítimas porque miden cargas distintas.

La página de benchmarks, con Locust, mil usuarios con tiempo de reflexión y un endpoint falso, da para cuatro instancias una x-litellm-overhead-duration-ms de mediana 2 ms, p95 8 ms y p99 13 ms a 1.170 peticiones por segundo. Con dos instancias, mediana 12 ms, p95 29 ms y p99 43 ms. La propia página advierte que esas cifras corresponden a unas 130 peticiones en vuelo, y que un cliente en bucle cerrado sin tiempo de reflexión mantiene mil en vuelo y ve del orden de ocho veces esa latencia a igual tasa, por la ley de Little.

El banco AIGatewayBench, de julio de 2026, mide el proxy en Python con 257,7 ms de p99 añadido y 329,5 MB de memoria de pico, sin callbacks, sin seguimiento de gasto y sin persistencia. La variante en Rust, en beta, da 0,7 ms.

La lectura para una plataforma propia: la primera cifra es el mejor caso con clientes humanos, y la segunda es el orden de magnitud a planificar para carga agéntica, que llega en bucle cerrado contra respuestas rápidas. Hay un caso reportado que se parece mucho a este escenario: un motor propio compatible con OpenAI servía unas 16 peticiones por segundo directo y unas 9 a través de LiteLLM, con la degradación creciendo con la concurrencia, en un pod de 4 vCPU y 8 GB.

El hueco de latencia que las métricas no ven

Los temporizadores de LiteLLM empiezan cuando su manejador empieza. El tiempo que la petición pasa encolada en el bucle de eventos de uvicorn, antes de llegar ahí, no aparece en ninguna de sus métricas. El ejemplo de la propia guía de diagnóstico es un caso en el que LiteLLM registra 10 segundos y el usuario experimenta 20.

Se detecta comparando dos fuentes: GET /health/backlog o el indicador litellm_in_flight_requests frente al tiempo de respuesta que mide el balanceador de delante. Si divergen, el hueco está en la cola de entrada y la respuesta es más réplicas, no más ajustes.

Dos cabeceras están siempre activas y sirven para vigilarlo sin instrumentar nada: x-litellm-overhead-duration-ms y x-litellm-callback-duration-ms. Para la segunda, la documentación fija el umbral de sospecha en 100 ms, por encima del cual el diagnóstico es que los payloads son demasiado grandes.

Y la primera causa de latencia que menciona esa guía no es ninguna de las anteriores: LITELLM_LOG=DEBUG serializa el payload con json.dumps(indent=4) de forma síncrona, y con payloads de más de 2 MB eso solo puede costar entre 2 y 5 segundos por petición. Un nivel de log puesto para depurar un problema y olvidado ahí produce exactamente el problema que se quería depurar.

El modo de fallo que hay que llevar al runbook

Hay una incidencia abierta que describe una cascada con una forma característica. Bajo 429 sostenidos del motor de arriba, los pods dejaron de responder a las sondas de readiness durante el arranque, Kubernetes los mató por sonda fallida, y el reinicio metió más presión sobre un upstream que ya estaba saturado. Picos de 500 peticiones por segundo, media de 60, entre dos y cinco réplicas con 1,3 CPU y 4 GB.

La forma del fallo es la que interesa más que el caso concreto: la saturación del motor se convierte en un bucle de reinicios del gateway, y desde fuera parece que el problema es el gateway. Las defensas son las que ya están en este artículo, aplicadas juntas: startupProbe con margen amplio para que la readiness no compita con el arranque, request_timeout acotado para que las peticiones atrapadas se suelten, enfriamientos que saquen de rotación el despliegue que devuelve 429, y autoescalado por CPU al 60% para que haya capacidad antes de que la haga falta. Encaja con lo tratado en runbooks de respuesta a incidentes.

Actualizar sin cortar

El despliegue es una Deployment normal con RollingUpdate, maxUnavailable: 0 y maxSurge: 1, y no tiene más misterio salvo por un punto: las migraciones de esquema. LiteLLM usa Prisma y aplica migraciones al arrancar. Con varias réplicas subiendo a la vez tras un cambio de versión, la migración debe ejecutarla un solo proceso, que es otra razón para la réplica dedicada con LITELLM_JOB_ROLE: worker.

Antes de una subida de versión, dos comprobaciones que se pagan solas: leer las notas de la versión buscando cambios en la metadata emitida a los callbacks, porque un cambio así rompe paneles guardados sin tocar el servicio, y verificar que la configuración nueva arranca con --detailed_debug en un pod aparte antes de aplicarla a la flota.

Checklist de puesta en producción

  1. --num_workers 1 y escalado por réplicas.
  2. requests iguales a limits, 1 vCPU y 4 GiB por worker.
  3. HPA por CPU al 60%. Nunca por memoria.
  4. database_connection_pool_limit calculado desde max_connections, y maxReplicas del chart bajado a algo que la base de datos aguante.
  5. Redis en cuanto haya más de una réplica, y use_redis_transaction_buffer a partir de diez o de mil peticiones por segundo.
  6. livenessProbe a /health/liveliness, readinessProbe a /health/readiness, y /health fuera de las sondas.
  7. startupProbe con margen de hasta 300 segundos.
  8. request_timeout: 600.
  9. LITELLM_LOG fuera de DEBUG, y disable_error_logs: true.
  10. Un panel con x-litellm-overhead-duration-ms, x-litellm-callback-duration-ms, litellm_in_flight_requests y el tiempo de respuesta del balanceador de delante.

Trampas y cosas que no son lo que parecen

El HPA escala y no desescala. Está midiendo memoria, que en este proceso solo sube.

Los pods mueren en bucle nada más desplegar. Menos de 4 GiB de límite. La huella aparece con la primera petición grande, no al arrancar.

Postgres empieza a rechazar conexiones en el pico. El chart tiene maxReplicas: 100 y el pool por defecto en 10.

Los límites por minuto se cumplen mal. No hay Redis, y cada réplica lleva su propia cuenta.

El healthcheck consume tokens. Alguien puso /health como sonda de readiness.

La sonda de readiness devuelve 200 con la base de datos caída. Está allow_requests_on_db_unavailable.

Un despliegue sigue recibiendo tráfico después de fallar. El enfriamiento es de 5 segundos, no de minutos, y con un solo despliegue en el grupo hace falta superar las mil peticiones para que la lógica se aplique.

La prueba de fallbacks ya no prueba nada. Los parámetros de simulación se eliminan de las peticiones que entran por el proxy desde la 1.85.0.

El p99 es diez veces peor que el benchmark publicado. El benchmark tiene tiempo de reflexión y la carga real es agéntica.

Cierre

Ninguno de estos ajustes es complicado y todos son de una línea. Lo que los une es que sus valores por defecto describen un despliegue pequeño, con una instancia, un cliente humano al otro lado y un proveedor comercial que se encarga de la capacidad. Una factoría de inferencia propia es lo contrario en los cuatro ejes: varias réplicas, clientes automáticos, y un motor cuya capacidad es finita y conocida.

De los diez puntos del checklist, tres deciden casi todo en un despliegue on-premise: el modelo de proceso con su dimensionado, la aritmética de conexiones a la base de datos, y el timeout de petición. Los otros siete son los que evitan la llamada del sábado.

Ver también

Fuentes