La Parte 4 puso control de acceso por
grupos sobre la interfaz de administración del gateway y después, al final, llamó a la API
desde un shell sin cuenta, sin sesión y sin grupo. Respondió con normalidad. La conclusión fue
que el SSO protege un plano de control y que el plano de datos autentica a quien llama por
máquina con claves — cosa que tiene que hacer, porque un agente que corre a las 3 de la
mañana no puede completar un login en un navegador.
Eso era cierto y era también un punto y aparte, no una respuesta. "Los clientes máquina usan
claves" te deja con una credencial que nunca expira, que ningún proveedor de identidad conoce,
y que sobrevive a la persona que la creó. La Parte 4 lo dijo sin rodeos: sacar a alguien de un
grupo no revoca sus claves, una clave filtrada no se ve afectada por la identidad en absoluto,
y las claves sobreviven a las personas.
Esta parte le da a la máquina una identidad en lugar de una clave.
authentik le emite al agente un token con el flujo de client credentials — sin navegador,
sin pantalla de consentimiento, sin humano. El token va firmado, lleva un scope y expira a los
cinco minutos. El gateway lo verifica localmente contra las claves públicas de authentik y
rechaza cualquier cosa sin el scope correcto. Al final, tres códigos de estado enseñan el
límite aguantando.
La Parte 3 puso authentik delante de Langfuse y
terminó con una confesión: el paso de vinculaciones quedó vacío, así que cualquier cuenta de
authentik podía llegar a Langfuse y nada te avisaría.
Esta parte cierra ese hueco en la
gateway LiteLLM — y el cierre salió mal de una
forma que vale más que el procedimiento. Configuramos las vinculaciones en el asistente de
authentik, las vimos aparecer en la tabla del propio asistente, enviamos, y acabamos con
cero vinculaciones guardadas. La aplicación estaba abierta a todo el mundo, la interfaz
no dijo nada, y la única razón por la que lo descubrimos es que probamos con una cuenta que
debería haber sido rechazada y no lo fue.
Después, ya con el control de acceso funcionando de verdad, llamamos a la API del gateway
desde un shell sin cuenta, sin sesión y sin grupo. Respondió con normalidad. Eso no es un
fallo — es la diferencia entre un plano de control y un plano de datos, y suponer que
el SSO cubre ambos es cómo la gente concluye que ha asegurado algo que no ha asegurado.
La Parte 4 construyó un callback
que enmascara la PII en lo que el gateway registra sin tocar lo que recibe quien llama.
También señaló que la primera versión funcional usaba un cliente HTTP bloqueante dentro
de un async def, y que eso costaba 200-350ms sobre cuatro peticiones secuenciales.
Ese post terminaba con una confesión: cuatro peticiones seguidas es la prueba equivocada.
Un bucle de eventos bloqueado apenas se nota cuando nada más está esperando. El daño
debería aparecer bajo concurrencia, y no lo habíamos medido.
Esta es esa medición, y el resultado es una forma más que un número. Con el cliente
bloqueante, algunos lotes tardan muchas veces más de lo que deberían. Con el cliente
asíncrono, ninguno. Las medianas apenas difieren, que es exactamente la razón por la
que esto llega a producción sin que nadie lo note.
Aparecieron dos cosas que no estaban en el plan. El gateway no solo se vuelve lento —
descarta peticiones. Y cuando lo hace, lo que expiró fue la llamada de enmascarado,
mientras quien llama sigue recibiendo 200 OK. Eso significa que la garantía sobre la
que se construyó la Parte 4 deja de cumplirse en silencio bajo carga.
Una clave de la API de Inferencia de WEC — créala
en el portal
httpx en tu entorno de Python
De dónde salieron estos números
Una instancia WEC: 8 vCPU AMD EPYC 7601, 15 GB de RAM, Docker 29.1.3, kernel 5.15.
LiteLLM 1.96.2 desde ghcr.io/berriai/litellm:main-stable. El analizador y el
anonimizador de Presidio como contenedores locales en el mismo host. Modelo
Qwen2.5-3B-Instruct sobre la API de Inferencia de WEC. Cinco repeticiones por celda.
Los números de latencia no se transfieren entre máquinas. Trata cada segundo de este post
como una observación de ese entorno, no como una cifra que igualar. Lo que sí debería
reproducirse es la diferencia entre las dos versiones.
Primero, averigua cuántos bucles de eventos tienes
Todo lo que sigue depende de un número, y no es el número que da la documentación.
La CLI del proxy de LiteLLM tiene una opción --num_workers. La
referencia publicada dice que su valor por
defecto es "Number of logical CPUs in the system, or 4 if that cannot be determined."
En una máquina de ocho núcleos eso significaría ocho procesos de trabajo, ocho bucles de
eventos, y una llamada bloqueante atascando una octava parte de tu tráfico.
Pregúntale al host qué se está ejecutando realmente — desde fuera del contenedor, para
que nada dentro de él pueda informar mal:
--num_workers INTEGER Number of worker processes for uvicorn /
gunicorn, or Granian worker processes
(--workers). Default is 1 (from
DEFAULT_NUM_WORKERS_LITELLM_PROXY). With
--run_granian, use --granian_threads for
runtime threads per worker.
Figura 3. El propio texto de ayuda de la herramienta: "Default is 1."
Eso es una cadena de ayuda. Como estamos a punto de contradecir la documentación
publicada, tómalo también del código fuente — ajusta la versión de python en la ruta para
que coincida con tu imagen:
La referencia de la CLI indica que el valor por
defecto de --num_workers es "Number of logical CPUs in the system, or 4 if that cannot
be determined." Tres fuentes dicen lo contrario: el contenedor en marcha, el código
instalado de 1.96.2, y el main actual del proyecto, donde tanto la constante como el
texto de ayuda siguen diciendo 1.
No es un desajuste de versiones, y no es algo de este despliegue. Comprueba el tuyo:
dockerexec llm-gateway env|grep-i worker
Una salida vacía significa que estás en el valor por defecto, que es un worker.
Así que: un proceso, un bucle de eventos, compartido por cada petición en vuelo. Esa es la
condición que convierte una llamada bloqueante de un impuesto en una caída.
Lanza N peticiones a la vez, cronometra cada una, informa el tiempo total del lote. El
mensaje lleva PII para que el callback de enmascarado tenga trabajo real. Una petición
fallida se cuenta en vez de dejar que abandone la ejecución — vas a necesitar eso.
~/llm-gateway/loadtest.py
"""Fire N chat completions at the gateway at once and report how long they take.
The message carries PII so the Presidio masking callback has real work to do.
A failed request is counted and reported, not allowed to abandon the batch.
Figura 4. Ocho a la vez, ninguna falló. Por sí solo este número todavía no significa
nada.
Necesitas un control, o tus números no significan nada
Aquí está la trampa. El gateway llama a un modelo por la red. Ese modelo tiene su propia
latencia, su propia carga y sus propias malas tardes. Cuando un lote tarda doce segundos
no puedes saber si tu gateway se atascó o si el upstream estaba ocupado — y si adivinas,
vas a publicar una tontería.
Así que mide el upstream directamente, con el gateway fuera del camino. Copia el archivo y
cambia las tres constantes de arriba:
Tres detalles de ahí no son decoración, y cada uno costó una ejecución perdida para
aprenderlo:
Espera al gateway. Un docker compose restart puede tardar más de diez segundos, y
lanzar peticiones a un puerto que todavía no escucha produce fallos instantáneos —
gateway=0.03s(8 failed) — que parecen un resultado catastrófico y no significan nada.
Se niega a aceptar una etiqueta tuya. Lee el callback cargado del registro del
gateway. Pasa blocking por la línea de comandos mientras está cargado el callback
asíncrono y vas a medir el mismo código dos veces, no ver diferencia, y concluir que aquí
no hay nada. Eso pasó dos veces escribiendo este post.
Descarta una pasada de calentamiento. El primer lote tras un reinicio paga las
importaciones y los pools de conexión, y llega de tres a seis veces más lento que el
siguiente, sin importar qué callback esté cargado.
Estás a punto de comparar dos versiones de un archivo, y necesitas certeza sobre cuál está
activa. El registro de arranque de LiteLLM lista los callbacks de éxito y de fallo pero no
litellm_settings.callbacks, y no hay un endpoint que los informe —
/get/config/callbacks responde 200 con {"detail":"Not Found"}.
Así que haz que cada versión diga su propio nombre al importarse, donde no cuesta nada por
petición:
Figura 7. El cliente bloqueante. Mismo script, misma carga, una palabra distinta en el
código. La columna de control se mantiene entre 1,19 y 2,00s en todo momento.
Versión
N
Mediana del gateway
Peor del gateway
Mediana del control
asíncrono AsyncClient
8
0,90s
0,98s
0,85s
asíncrono AsyncClient
16
1,61s
1,77s
1,42s
bloqueante Client
8
0,96s
1,70s
0,90s
bloqueante Client
16
2,88s
70,18s *
1,70s
Figura 8. Cada lote de las Figuras 6 y 7 en un eje logarítmico. El control se mantiene
en una banda estrecha en los cuatro grupos. Las ejecuciones asíncronas se quedan con él.
Dos bloqueantes lo abandonan.
Un número de esa tabla necesita un asterisco
El lote de 70,18s coincidió con otra prueba de carga no relacionada contra el mismo
gateway, así que parte de ese tiempo no es atribuible al callback. Se informa porque
ocurrió, no porque esté limpio. Lee el lote de 5,14s como el atasco representativo —
todavía más de cuatro veces su propio control de 1,19s.
El efecto en sí se reprodujo en cuatro ejecuciones distintas en este host, con atascos en
N=16 de 11,22s, 12,44s, 21,22s, 31,14s y 5,14s. En todas las ejecuciones asíncronas
limpias: ninguno.
Lee primero las filas asíncronas. En N=8 la mediana del gateway es 0,90s y la del control
0,85s. El callback, con todas sus idas y vueltas a Presidio, desaparece dentro de la
latencia del propio modelo.
Ahora la fila bloqueante en N=8. Mediana de 0,96s contra un control de 0,90s. Eso es
0,06s. Si midieras medianas y desplegaras, dirías que está bien — y el peor lote de esas
mismas cinco tardó 1,70s mientras su propio control tardó 0,90s.
En N=16 deja de esconderse. La mediana sube a 2,88s contra 1,70s, y la cola se sale del
gráfico.
Habilidad desbloqueada 🏅
Sabes distinguir si un gateway lento es tu propio código o el modelo al que llama —
ejecuta la misma carga contra los dos, intercalada, y lee la cola en vez de la mediana.
Antes de añadir el conteo de fallos al script de carga, una ejecución bloqueante murió del
todo:
httpx.RemoteProtocolError: Server disconnected without sending a response.
Dos de diez lotes de esa ejecución perdieron peticiones así. Cero lotes asíncronos lo
hicieron nunca. El gateway no estuvo lento para esos clientes. Les colgó.
INFO: 172.22.0.1:47566 - "POST /v1/chat/completions HTTP/1.1" 200 OK
--
httpx.ReadTimeout: timed out
INFO: 172.22.0.1:52870 - "POST /v1/chat/completions HTTP/1.1" 200 OK
Figura 9.httpcore/_sync aparece 77 veces. El cliente asíncrono produciría _async.
Y el timeout está entre dos líneas 200 OK.
Ese _sync es la huella. Prueba que la llamada que falla es la llamada bloqueante a
Presidio y no otra cosa de la pila. El error completo nombra a quien llama:
LiteLLM:ERROR: logging_worker.py:103 - LoggingWorker error: timed out
File ".../httpcore/_sync/connection_pool.py", line 236, in handle_request
httpcore.ReadTimeout: timed out
Ahora mira lo que lo rodea.200 OK. Las peticiones tuvieron éxito. Lo que falló fue
el enmascarado.
Esa es la parte en la que vale la pena detenerse, porque toda la promesa de la Parte 4 era
que la PII llega a tus rastros ya enmascarada. Bajo carga, con el cliente bloqueante, la
llamada de enmascarado expira contra su propio límite de diez segundos mientras quien
llama recibe un éxito normal. Nada en la respuesta te dice que la garantía dejó de
cumplirse. Tendrías que estar leyendo el stderr del gateway para saberlo.
httpx.Client dentro de un async def no cede el control. Cuando el callback llama a
Presidio, el hilo que ejecuta el bucle de eventos se queda en una lectura de socket hasta
que Presidio responde, y durante ese tiempo el bucle no atiende nada — ni la llamada al
modelo de otra petición, ni una respuesta que vuelve. Es un solo bucle, como confirmaste
al principio.
Cada petición dispara varias de estas llamadas: los mensajes, la copia en el objeto de
registro estándar, y la respuesta. Así que bajo concurrencia las peticiones no se
solapan. Se ponen en fila, y la espera de cada una es la suma del tiempo de Presidio de
todas las anteriores. Por eso el efecto no es un impuesto constante — depende de cuántas
peticiones lleguen mientras el bucle está retenido, que es también la razón por la que los
números son irregulares en vez de uniformemente peores.
Pasada cierta profundidad de cola, las esperas superan el propio timeout=10.0 del
scrubber y el enmascarado se rinde, mientras las conexiones de cliente que esperan sobre
un bucle congelado se caen. La mediana lo esconde todo porque a la mayoría de los lotes
les toca suerte. La cola es la verdad.
El arreglo es el que adoptó la Parte 4, y esta es la evidencia a su favor:
httpx.AsyncClient con await, que cede el bucle mientras Presidio trabaja.
diff scrubber.py scrubber_blocking.py
Output
26,27c26,27
< async with httpx.AsyncClient(timeout=10.0) as client:
< r = await client.post(f"{ANALYZER}/analyze", json={"text": text, "language": "en"})
---
> with httpx.Client(timeout=10.0) as client:
> r = client.post(f"{ANALYZER}/analyze", json={"text": text, "language": "en"})
Dos palabras y un await. Esa es toda la diferencia entre la Figura 6 y la Figura 7.
Más workers no es el arreglo, y la documentación explica por qué mejor de lo que podríamos
nosotros. Sobre --timeout_worker_healthcheck, describiendo --num_workers > 1:
"the supervisor process pings each worker; a worker that does not respond within this
window (for example because its event loop is blocked by synchronous work) is killed
with SIGKILL and replaced."
Con un worker no hay supervisor, así que un bucle bloqueado se atasca. Con varios, un
worker bloqueado es eliminado y todo lo que tenía en vuelo muere en vez de esperar.
Ninguna de las dos cosas arregla el trabajo sincrónico sobre un bucle de eventos. Arregla
la llamada.
🎯
Finished this tutorial?
Mark it complete to earn Prove it holds under load on your skill path.
Cinco repeticiones por celda bastan para mostrar que existe un atasco al lado de un
control estable. No bastan para caracterizar la distribución — con qué frecuencia, con qué
gravedad, o cómo escala más allá de N=16. Si ejecutas esto en producción, ejecútalo más
tiempo y mira los percentiles.
No probamos --num_workers > 1, respuestas en streaming, ni un Presidio lento o remoto en
lugar de un contenedor local sano.
La preocupación obvia, cuando la llamada de enmascarado expira en una petición que
devolvió 200 OK, es que la carga sin enmascarar acabe igualmente en el rastro. No es
así.
Carga sostenida — seis rondas de veinticuatro peticiones concurrentes, cada una con un
nombre y un número de teléfono — produjo 77 timeouts de enmascarado en el registro del
gateway. De las 144 peticiones, 62 rastros llegaron a Langfuse y 82 no llegaron
nunca. Los 62 estaban correctamente enmascarados. Ninguno llevaba un nombre en claro.
Así que el fallo no es un fallo de privacidad. Es un fallo de observabilidad, y silencioso:
bajo carga sostenida más de la mitad del tráfico simplemente no se registra, mientras cada
cliente recibe un éxito normal. Si estás leyendo el volumen de rastros como aproximación
del tráfico, o contando con los rastros para una pista de auditoría, esa brecha es lo que
hay que vigilar — y es invisible desde el lado de la respuesta.
Mide la detección antes de medir cualquier otra cosa
Construyendo esta prueba, las peticiones se etiquetaron con un marcador corto para poder
encontrar cada rastro — [TAG-07] Priya Raghunathan called from …. Nueve rastros
volvieron entonces con el teléfono enmascarado y el nombre en claro, que se lee exactamente
como una fuga inducida por la carga.
No lo era. De forma secuencial, sin carga alguna, esa frase enviada directamente a Presidio
devuelve solo PHONE_NUMBER; la misma frase sin el prefijo entre corchetes devuelve
PERSON y PHONE_NUMBER a la vez. El marcador suprimió la detección del nombre, y el
callback enmascaró fielmente todo lo que Presidio informó.
Comprueba qué detecta tu analizador en tus cadenas exactas antes de concluir nada sobre el
enmascarado bajo carga. Un instrumento que cambia lo que mide te va a entregar un hallazgo
que no está ahí.
La Parte 3 configuró Presidio para enmascarar
los prompts en el momento del registro y deliberadamente se detuvo antes de probarlo. Aún no se había
conectado a un destino de registro, así que no había rastro que inspeccionar.
Esta publicación lo conecta, encuentra <PERSON> donde debería estar — y luego encuentra la
dirección de correo electrónico real del cliente sentada unas líneas más abajo, en la respuesta del modelo.
La Parte 2 terminó con la puerta de enlace decidiendo
qué modelo responde a cada solicitud — y aún así reenvía cada solicitud tal cual,
incluyendo las que llevan nombres de clientes, correos electrónicos y números de teléfono.
Esta publicación coloca un filtro de PII en ese camino. No delante del modelo, sin embargo:
delante de los registros. El modelo que lee
tu solicitud está haciendo su trabajo; el riesgo es lo que se almacena. Así que el texto sin procesar
va al modelo y una copia enmascarada va a tus registros.
Eso es lo que la puerta de enlace publicita. Seguir su configuración documentada me dio
lo opuesto: un modelo que respondió correctamente y una respuesta que regresó como
<LOCATION>. Así es como encontrar eso y cómo solucionarlo.
Parte 1 terminó en un número incómodo. La misma respuesta de tres palabras costó 3 tokens de un modelo pequeño y 200 de un modelo de razonamiento — que gastó los 200 pensando y no devolvió nada en absoluto.
Cada solicitud que envían tus aplicaciones elige un modelo, y en su mayoría esa elección se hace una vez, codificada de forma rígida, y nunca se revisita. Esta publicación pone al gateway a cargo de ello en su lugar: clasifica la solicitud, la enruta a un modelo adecuado para el trabajo. Luego mide lo que cuesta esa decisión, porque no es gratis y la mayoría de los informes omiten esa parte.
Así es como suele ir. Una aplicación necesita un modelo, así que pegas la clave API en
su .env. Luego una segunda aplicación necesita una. Luego un script. Seis meses después, la misma
clave está en cinco lugares, nadie recuerda cuál de ellas sigue funcionando, y no
puedes rotarla sin romper algo que solo descubrirás cuando se rompa.
Un gateway es la solución aburrida. Un endpoint frente a cada modelo, un lugar que
mantiene la credencial real, y una clave específica por aplicación que puedes revocar por su cuenta.
Esta publicación despliega uno en una instancia WEC y lo apunta a la API de Inferencia WEC.