<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Alta-Disponibilidad on lo0 — Blog Técnico</title><link>https://blog.lo0.es/tags/alta-disponibilidad/</link><description>Recent content in Alta-Disponibilidad on lo0 — Blog Técnico</description><generator>Hugo -- gohugo.io</generator><language>es</language><lastBuildDate>Tue, 08 Sep 2026 07:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/tags/alta-disponibilidad/index.xml" rel="self" type="application/rss+xml"/><item><title>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</title><link>https://blog.lo0.es/posts/litellm-proxy-dia-2-alta-disponibilidad/</link><pubDate>Tue, 08 Sep 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/posts/litellm-proxy-dia-2-alta-disponibilidad/</guid><description>&lt;blockquote>
&lt;p>Segundo artículo del track operativo de la capa de control. El &lt;a href="https://blog.lo0.es/posts/litellm-langfuse-par-operativo/">par operativo con Langfuse&lt;/a> 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 &lt;a href="https://blog.lo0.es/posts/elegir-gateway-oss-inferencia-llm/">elegir el gateway OSS&lt;/a> y en &lt;a href="https://blog.lo0.es/posts/router-inferencia-llm-gateway-l7/">el router de inferencia L7&lt;/a>.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>LiteLLM Proxy arranca con un &lt;code>config.yaml&lt;/code> 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.&lt;/p>
&lt;p>&lt;strong>Un worker de uvicorn por pod.&lt;/strong> La guía de producción del proyecto pide &lt;code>--num_workers 1&lt;/code> en Kubernetes y escalado horizontal, con &lt;strong>1 vCPU y 4 GiB por worker puestos a la vez como &lt;code>requests&lt;/code> y como &lt;code>limits&lt;/code>&lt;/strong>. 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.&lt;/p>
&lt;p>&lt;strong>Autoescalar por CPU, nunca por memoria.&lt;/strong> 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 &lt;code>targetCPUUtilizationPercentage: 60&lt;/code>.&lt;/p>
&lt;p>&lt;strong>La aritmética de conexiones a Postgres.&lt;/strong> &lt;code>database_connection_pool_limit&lt;/code> vale 10, y el número de conexiones es instancias por workers por el pool. Los charts de Helm traen &lt;code>maxReplicas: 100&lt;/code>, del orden de mil conexiones, muy por encima de lo que acepta un Postgres de serie.&lt;/p>
&lt;p>&lt;strong>Redis deja de ser opcional&lt;/strong> 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 &lt;code>FATAL: sorry, too many clients already&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Tres endpoints de salud y solo dos valen para las sondas.&lt;/strong> &lt;code>/health/liveliness&lt;/code> y &lt;code>/health/readiness&lt;/code> no piden autenticación y responden rápido. &lt;code>/health&lt;/code> a secas exige clave y lanza una petición real contra cada modelo del catálogo, con su coste en tokens.&lt;/p>
&lt;p>&lt;strong>Los números de resiliencia son 2, 3 y 5&lt;/strong>: 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.&lt;/p>
&lt;p>&lt;strong>&lt;code>request_timeout&lt;/code> vale 6.000 segundos.&lt;/strong> Cien minutos sosteniendo una conexión contra un motor que no responde.&lt;/p>
&lt;p>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.&lt;/p>
&lt;h2 id="estás-aquí-la-capa-de-gateway-el-día-después">Estás aquí: la capa de gateway, el día después&lt;/h2>
&lt;p>En el &lt;a href="https://blog.lo0.es/posts/siete-capas-stack-inferencia-llm-on-premise/">stack de siete capas&lt;/a>, 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.&lt;/p>
&lt;p>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.&lt;/p>
&lt;h2 id="la-analogía-la-centralita-y-su-libreta">La analogía: la centralita y su libreta&lt;/h2>
&lt;p>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.&lt;/p>
&lt;p>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.&lt;/p>
&lt;p>Las tres tensiones tienen su equivalente exacto en LiteLLM. La libreta son los &lt;code>SpendLogs&lt;/code> 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.&lt;/p>
&lt;h2 id="el-modelo-de-proceso-un-worker-por-pod">El modelo de proceso: un worker por pod&lt;/h2>
&lt;p>La documentación de producción es explícita: en Kubernetes, &lt;code>--num_workers 1&lt;/code> y escalar con réplicas. En una máquina virtual sin orquestador, &lt;code>NUM_WORKERS&lt;/code> igual al número de vCPU.&lt;/p>
&lt;p>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.&lt;/p>
&lt;p>El dimensionado recomendado es &lt;strong>1 vCPU y 4 GiB por worker&lt;/strong>, y la parte que se pasa por alto es que van como &lt;code>requests&lt;/code> y como &lt;code>limits&lt;/code> 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.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">, memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4Gi&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">, memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4Gi&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>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.&lt;/p>
&lt;p>De ahí salen dos reglas. La primera: &lt;strong>autoescalar por CPU y nunca por memoria&lt;/strong>. Un HPA por memoria ve una métrica que solo sube, escala en el pico y no desescala nunca. El objetivo recomendado es &lt;code>targetCPUUtilizationPercentage: 60&lt;/code>, 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.&lt;/p>
&lt;p>La segunda: acotar la vida del proceso. &lt;code>--max_requests_before_restart 10000&lt;/code> recicla el worker antes de que la marca de agua importe.&lt;/p>
&lt;h2 id="postgres-la-aritmética-que-revienta-al-escalar">Postgres: la aritmética que revienta al escalar&lt;/h2>
&lt;p>&lt;code>database_connection_pool_limit&lt;/code> vale &lt;strong>10&lt;/strong> por defecto. El número total de conexiones que abre el despliegue es réplicas por workers por pool, y hay que compararlo con &lt;code>max_connections&lt;/code> del Postgres.&lt;/p>
&lt;p>La fórmula que da la documentación es la inversa, y es la que hay que aplicar al dimensionar:&lt;/p>
&lt;pre tabindex="0">&lt;code>database_connection_pool_limit = MAX_DB_CONNECTIONS / (instancias × workers)
&lt;/code>&lt;/pre>&lt;p>El detalle que hace daño en producción está en los charts de Helm, que traen &lt;code>autoscaling.maxReplicas&lt;/code> y &lt;code>keda.maxReplicas&lt;/code> a &lt;strong>100&lt;/strong>. 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.&lt;/p>
&lt;p>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í:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">disable_error_logs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># deja de escribir los errores de proveedor&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">proxy_batch_write_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># agrupa las escrituras de gasto&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>disable_spend_logs: true&lt;/code> 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 &lt;a href="https://blog.lo0.es/posts/litellm-langfuse-par-operativo/">artículo anterior&lt;/a>: si lo es, no se puede apagar, y la salida es el buffer de Redis.&lt;/p>
&lt;h2 id="redis-cuándo-deja-de-ser-opcional">Redis: cuándo deja de ser opcional&lt;/h2>
&lt;p>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.&lt;/p>
&lt;p>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.&lt;/p>
&lt;p>El umbral que da la documentación para activar el buffer de transacciones es &lt;strong>mil peticiones por segundo o diez instancias&lt;/strong>. 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 &lt;code>FATAL: sorry, too many clients already&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">use_redis_transaction_buffer&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Las métricas que hay que vigilar cuando eso está activo son &lt;code>litellm_in_memory_spend_update_queue_size&lt;/code>, &lt;code>litellm_redis_spend_update_queue_size&lt;/code> y &lt;code>litellm_pod_lock_manager_size&lt;/code>. Si la primera crece sin que baje la segunda, el volcado a Postgres no está siguiendo el ritmo.&lt;/p>
&lt;p>Un apunte sobre la estrategia de enrutado, que interactúa con esto. &lt;code>simple-shuffle&lt;/code> 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ó &lt;a href="https://blog.lo0.es/posts/router-inferencia-llm-gateway-l7/">el artículo del router&lt;/a>.&lt;/p>
&lt;h2 id="los-trabajos-de-fondo-se-registran-por-worker">Los trabajos de fondo se registran por worker&lt;/h2>
&lt;p>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 &lt;strong>por worker de uvicorn, no por pod&lt;/strong>. Con &lt;code>--num_workers 4&lt;/code> 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.&lt;/p>
&lt;p>La variable que lo separa es &lt;code>LITELLM_JOB_ROLE&lt;/code>: valor &lt;code>serving&lt;/code> en los pods que atienden tráfico y una réplica aparte con valor &lt;code>worker&lt;/code> para los trabajos. Con la recomendación de un worker por pod, el problema se reduce, pero no desaparece mientras haya varias réplicas.&lt;/p>
&lt;h2 id="sondas-tres-endpoints-y-solo-dos-valen">Sondas: tres endpoints y solo dos valen&lt;/h2>
&lt;p>Los tres endpoints existen y hacen cosas distintas:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Endpoint&lt;/th>
&lt;th>Autenticación&lt;/th>
&lt;th>Qué hace&lt;/th>
&lt;th>Para qué sirve&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>/health/liveliness&lt;/code>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Devuelve &lt;code>I'm alive!&lt;/code>, o 503 durante el apagado&lt;/td>
&lt;td>&lt;code>livenessProbe&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/health/readiness&lt;/code>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Estado del proceso y de la base de datos. &lt;strong>503 si el Postgres configurado no responde&lt;/strong>&lt;/td>
&lt;td>&lt;code>readinessProbe&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/health&lt;/code>&lt;/td>
&lt;td>Sí&lt;/td>
&lt;td>Lanza una petición real contra &lt;strong>cada modelo&lt;/strong> del catálogo&lt;/td>
&lt;td>Diagnóstico manual, nunca una sonda&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Hay que insistir en la última fila. &lt;code>/health&lt;/code> 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.&lt;/p>
&lt;p>Hay más endpoints útiles para diagnóstico: &lt;code>/health/readiness/details&lt;/code>, &lt;code>/health/services?service=langfuse&lt;/code> para comprobar un callback concreto, &lt;code>/health/history&lt;/code> y &lt;code>/health/latest&lt;/code> para el histórico, y &lt;code>/health/backlog&lt;/code>, que devuelve las peticiones en vuelo y aparece en la sección de latencia. &lt;code>/health/drain&lt;/code> existe pero devuelve 404 salvo que se active con &lt;code>enable_drain_endpoint: true&lt;/code>, y está protegido con &lt;code>X-Drain-Token&lt;/code>.&lt;/p>
&lt;p>Las comprobaciones periódicas de los modelos se configuran aparte y no en la sonda:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">background_health_checks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">health_check_interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">300&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># segundos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">background_health_check_model_groups&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;llama-70b&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;qwen-30b&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Y un ajuste que trae cola en despliegues sin salida a Internet: &lt;code>allow_requests_on_db_unavailable: true&lt;/code> permite servir tráfico con la base de datos caída, pero deja &lt;code>/health/readiness&lt;/code> 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.&lt;/p>
&lt;h2 id="reintentos-enfriamientos-y-fallbacks-los-números-reales">Reintentos, enfriamientos y fallbacks: los números reales&lt;/h2>
&lt;p>Los valores por defecto, leídos del código del proyecto y no de la documentación:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Constante&lt;/th>
&lt;th>Valor&lt;/th>
&lt;th>Qué gobierna&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>DEFAULT_MAX_RETRIES&lt;/code>&lt;/td>
&lt;td>2&lt;/td>
&lt;td>Reintentos antes de pasar a fallbacks&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>DEFAULT_ALLOWED_FAILS&lt;/code>&lt;/td>
&lt;td>3&lt;/td>
&lt;td>Fallos antes de enfriar un despliegue&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>DEFAULT_COOLDOWN_TIME_SECONDS&lt;/code>&lt;/td>
&lt;td>5&lt;/td>
&lt;td>Segundos que un despliegue queda fuera&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>SINGLE_DEPLOYMENT_TRAFFIC_FAILURE_THRESHOLD&lt;/code>&lt;/td>
&lt;td>1000&lt;/td>
&lt;td>Peticiones mínimas antes de aplicar la lógica de enfriamiento con un solo despliegue&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>DEFAULT_REQUEST_TIMEOUT_SECONDS&lt;/code>&lt;/td>
&lt;td>6000.0&lt;/td>
&lt;td>Timeout de la petición&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>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.&lt;/p>
&lt;p>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 &lt;code>cooldown_time&lt;/code> vale 1 por defecto, y el código usa la constante de 5. Manda el código.&lt;/p>
&lt;p>Los fallbacks son tres listas separadas y se aplican &lt;strong>después&lt;/strong> de agotar los reintentos:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">num_retries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">120&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">fallbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="nt">&amp;#34;llama-70b&amp;#34;: &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;qwen-30b&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>}&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">context_window_fallbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="nt">&amp;#34;llama-70b&amp;#34;: &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;llama-70b-128k&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>}&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">content_policy_fallbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="nt">&amp;#34;llama-70b&amp;#34;: &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;llama-70b-sin-guardrail&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>}&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Una nota sobre el comportamiento cuando todo está en enfriamiento: si no queda ningún despliegue disponible en el grupo, LiteLLM cae sobre un &lt;code>model_info.id&lt;/code> 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.&lt;/p>
&lt;p>Y una capacidad que se perdió por el camino: desde la versión 1.85.0, los parámetros &lt;code>mock_testing_fallbacks&lt;/code> 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 &lt;code>litellm.Router&lt;/code> directamente, en un test. Quien tuviera una prueba de humo montada sobre eso, la tiene rota sin avisar.&lt;/p>
&lt;h3 id="el-timeout-de-seis-mil-segundos">El timeout de seis mil segundos&lt;/h3>
&lt;p>Merece su propio apartado porque es el ajuste con peor relación entre daño y esfuerzo de todo el sistema. &lt;code>request_timeout&lt;/code> 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.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">request_timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Diez minutos siguen siendo generosos para una generación larga, y acotan el daño.&lt;/p>
&lt;h2 id="overhead-las-dos-cifras-del-proyecto">Overhead: las dos cifras del proyecto&lt;/h2>
&lt;p>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.&lt;/p>
&lt;p>La página de benchmarks, con Locust, mil usuarios con tiempo de reflexión y un endpoint falso, da para cuatro instancias una &lt;code>x-litellm-overhead-duration-ms&lt;/code> de &lt;strong>mediana 2 ms, p95 8 ms y p99 13 ms&lt;/strong> 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.&lt;/p>
&lt;p>El banco AIGatewayBench, de julio de 2026, mide el proxy en Python con &lt;strong>257,7 ms de p99 añadido&lt;/strong> 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.&lt;/p>
&lt;p>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.&lt;/p>
&lt;h3 id="el-hueco-de-latencia-que-las-métricas-no-ven">El hueco de latencia que las métricas no ven&lt;/h3>
&lt;p>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.&lt;/p>
&lt;p>Se detecta comparando dos fuentes: &lt;code>GET /health/backlog&lt;/code> o el indicador &lt;code>litellm_in_flight_requests&lt;/code> 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.&lt;/p>
&lt;p>Dos cabeceras están siempre activas y sirven para vigilarlo sin instrumentar nada: &lt;code>x-litellm-overhead-duration-ms&lt;/code> y &lt;code>x-litellm-callback-duration-ms&lt;/code>. 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.&lt;/p>
&lt;p>Y la primera causa de latencia que menciona esa guía no es ninguna de las anteriores: &lt;code>LITELLM_LOG=DEBUG&lt;/code> serializa el payload con &lt;code>json.dumps(indent=4)&lt;/code> 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.&lt;/p>
&lt;h2 id="el-modo-de-fallo-que-hay-que-llevar-al-runbook">El modo de fallo que hay que llevar al runbook&lt;/h2>
&lt;p>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.&lt;/p>
&lt;p>La forma del fallo es la que interesa más que el caso concreto: &lt;strong>la saturación del motor se convierte en un bucle de reinicios del gateway&lt;/strong>, y desde fuera parece que el problema es el gateway. Las defensas son las que ya están en este artículo, aplicadas juntas: &lt;code>startupProbe&lt;/code> con margen amplio para que la readiness no compita con el arranque, &lt;code>request_timeout&lt;/code> 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 &lt;a href="https://blog.lo0.es/posts/runbooks-incident-response-llm-keep-kafka/">runbooks de respuesta a incidentes&lt;/a>.&lt;/p>
&lt;h2 id="actualizar-sin-cortar">Actualizar sin cortar&lt;/h2>
&lt;p>El despliegue es una &lt;code>Deployment&lt;/code> normal con &lt;code>RollingUpdate&lt;/code>, &lt;code>maxUnavailable: 0&lt;/code> y &lt;code>maxSurge: 1&lt;/code>, y no tiene más misterio salvo por un punto: &lt;strong>las migraciones de esquema&lt;/strong>. 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 &lt;code>LITELLM_JOB_ROLE: worker&lt;/code>.&lt;/p>
&lt;p>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 &lt;code>--detailed_debug&lt;/code> en un pod aparte antes de aplicarla a la flota.&lt;/p>
&lt;h2 id="checklist-de-puesta-en-producción">Checklist de puesta en producción&lt;/h2>
&lt;ol>
&lt;li>&lt;code>--num_workers 1&lt;/code> y escalado por réplicas.&lt;/li>
&lt;li>&lt;code>requests&lt;/code> iguales a &lt;code>limits&lt;/code>, 1 vCPU y 4 GiB por worker.&lt;/li>
&lt;li>HPA por CPU al 60%. Nunca por memoria.&lt;/li>
&lt;li>&lt;code>database_connection_pool_limit&lt;/code> calculado desde &lt;code>max_connections&lt;/code>, y &lt;code>maxReplicas&lt;/code> del chart bajado a algo que la base de datos aguante.&lt;/li>
&lt;li>Redis en cuanto haya más de una réplica, y &lt;code>use_redis_transaction_buffer&lt;/code> a partir de diez o de mil peticiones por segundo.&lt;/li>
&lt;li>&lt;code>livenessProbe&lt;/code> a &lt;code>/health/liveliness&lt;/code>, &lt;code>readinessProbe&lt;/code> a &lt;code>/health/readiness&lt;/code>, y &lt;code>/health&lt;/code> fuera de las sondas.&lt;/li>
&lt;li>&lt;code>startupProbe&lt;/code> con margen de hasta 300 segundos.&lt;/li>
&lt;li>&lt;code>request_timeout: 600&lt;/code>.&lt;/li>
&lt;li>&lt;code>LITELLM_LOG&lt;/code> fuera de &lt;code>DEBUG&lt;/code>, y &lt;code>disable_error_logs: true&lt;/code>.&lt;/li>
&lt;li>Un panel con &lt;code>x-litellm-overhead-duration-ms&lt;/code>, &lt;code>x-litellm-callback-duration-ms&lt;/code>, &lt;code>litellm_in_flight_requests&lt;/code> y el tiempo de respuesta del balanceador de delante.&lt;/li>
&lt;/ol>
&lt;h2 id="trampas-y-cosas-que-no-son-lo-que-parecen">Trampas y cosas que no son lo que parecen&lt;/h2>
&lt;p>&lt;strong>El HPA escala y no desescala.&lt;/strong> Está midiendo memoria, que en este proceso solo sube.&lt;/p>
&lt;p>&lt;strong>Los pods mueren en bucle nada más desplegar.&lt;/strong> Menos de 4 GiB de límite. La huella aparece con la primera petición grande, no al arrancar.&lt;/p>
&lt;p>&lt;strong>Postgres empieza a rechazar conexiones en el pico.&lt;/strong> El chart tiene &lt;code>maxReplicas: 100&lt;/code> y el pool por defecto en 10.&lt;/p>
&lt;p>&lt;strong>Los límites por minuto se cumplen mal.&lt;/strong> No hay Redis, y cada réplica lleva su propia cuenta.&lt;/p>
&lt;p>&lt;strong>El healthcheck consume tokens.&lt;/strong> Alguien puso &lt;code>/health&lt;/code> como sonda de readiness.&lt;/p>
&lt;p>&lt;strong>La sonda de readiness devuelve 200 con la base de datos caída.&lt;/strong> Está &lt;code>allow_requests_on_db_unavailable&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Un despliegue sigue recibiendo tráfico después de fallar.&lt;/strong> 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.&lt;/p>
&lt;p>&lt;strong>La prueba de fallbacks ya no prueba nada.&lt;/strong> Los parámetros de simulación se eliminan de las peticiones que entran por el proxy desde la 1.85.0.&lt;/p>
&lt;p>&lt;strong>El p99 es diez veces peor que el benchmark publicado.&lt;/strong> El benchmark tiene tiempo de reflexión y la carga real es agéntica.&lt;/p>
&lt;h2 id="cierre">Cierre&lt;/h2>
&lt;p>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.&lt;/p>
&lt;p>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.&lt;/p>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/posts/litellm-langfuse-par-operativo/">LiteLLM y Langfuse: el par operativo&lt;/a> — la otra mitad del día 2: coste por token en modelos propios, correlación de trazas y las colas de la telemetría que este post da por montadas.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/router-inferencia-llm-gateway-l7/">El router de inferencia LLM: la centralita L7&lt;/a> — las cuatro funciones del router y las estrategias de enrutado cuya latencia se discute aquí.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/elegir-gateway-oss-inferencia-llm/">Elegir la centralita: qué gateway OSS poner por delante&lt;/a> — la decisión previa, con licencias verificadas.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/runbooks-incident-response-llm-keep-kafka/">Runbooks de respuesta a incidentes&lt;/a> — dónde encaja la cascada de 429 y sondas fallidas descrita aquí.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/anatomia-request-llm-mayo-2026/">Anatomía de una petición LLM en producción&lt;/a> — el recorrido completo, del que este post detalla el tramo del gateway.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/guidellm-validacion-slo-bajo-carga/">GuideLLM y la validación de SLO bajo carga&lt;/a> — cómo medir el punto de saturación real antes de fijar los límites de este artículo.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/siete-capas-stack-inferencia-llm-on-premise/">El stack de inferencia LLM on-premise en siete capas&lt;/a> — dónde vive el gateway en el edificio completo.&lt;/li>
&lt;/ul>
&lt;h2 id="fuentes">Fuentes&lt;/h2>
&lt;ul>
&lt;li>LiteLLM, &lt;em>Production best practices&lt;/em> (topología, workers, memoria, pool de conexiones, &lt;code>LITELLM_JOB_ROLE&lt;/code>, buffer de transacciones en Redis, &lt;code>request_timeout&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/proxy/prod">https://docs.litellm.ai/docs/proxy/prod&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Health checks&lt;/em> (los tres endpoints, comprobaciones de fondo, &lt;code>enable_drain_endpoint&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/proxy/health">https://docs.litellm.ai/docs/proxy/health&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Reliability&lt;/em> (fallbacks, reintentos, deprecación de los parámetros de simulación en la 1.85.0): &lt;a href="https://docs.litellm.ai/docs/proxy/reliability">https://docs.litellm.ai/docs/proxy/reliability&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Benchmarks&lt;/em> y &lt;em>Load test advanced&lt;/em> (las cifras de sobrecarga por número de instancias y la advertencia de la ley de Little): &lt;a href="https://docs.litellm.ai/docs/benchmarks">https://docs.litellm.ai/docs/benchmarks&lt;/a> · &lt;a href="https://docs.litellm.ai/docs/load_test_advanced">https://docs.litellm.ai/docs/load_test_advanced&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Rust AI Gateway benchmarks&lt;/em> (los 257,7 ms de p99 del proxy en Python): &lt;a href="https://docs.litellm.ai/blog/rust-ai-gateway-benchmarks">https://docs.litellm.ai/blog/rust-ai-gateway-benchmarks&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Latency overhead&lt;/em> (el hueco de la cola de uvicorn, las cabeceras de diagnóstico, el coste de &lt;code>LITELLM_LOG=DEBUG&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/troubleshoot/latency_overhead">https://docs.litellm.ai/docs/troubleshoot/latency_overhead&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>DB info&lt;/em> (&lt;code>disable_spend_logs&lt;/code>, &lt;code>disable_error_logs&lt;/code> y qué se pierde con cada uno): &lt;a href="https://docs.litellm.ai/docs/proxy/db_info">https://docs.litellm.ai/docs/proxy/db_info&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/constants.py&lt;/code> (los valores por defecto de reintentos, fallos permitidos, enfriamiento y timeout): &lt;a href="https://github.com/BerriAI/litellm/blob/main/litellm/constants.py">https://github.com/BerriAI/litellm/blob/main/litellm/constants.py&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/router.py&lt;/code> (la discrepancia entre el docstring de &lt;code>cooldown_time&lt;/code> y la constante que usa el código): &lt;a href="https://github.com/BerriAI/litellm/blob/main/litellm/router.py">https://github.com/BerriAI/litellm/blob/main/litellm/router.py&lt;/a>.&lt;/li>
&lt;li>LiteLLM, issue #15526 — cascada de 429 sostenidos, sondas de readiness fallidas y bucle de reinicios: &lt;a href="https://github.com/BerriAI/litellm/issues/15526">https://github.com/BerriAI/litellm/issues/15526&lt;/a>.&lt;/li>
&lt;li>LiteLLM, issue #21046 — degradación de throughput contra un motor propio compatible con OpenAI: &lt;a href="https://github.com/BerriAI/litellm/issues/21046">https://github.com/BerriAI/litellm/issues/21046&lt;/a>.&lt;/li>
&lt;/ul></description></item></channel></rss>