La Parte 3 le dio a un agente cinco
herramientas por MCP — un calendario, una lista de clientes, facturas y un reembolso — y puso a
un humano delante del reembolso. Terminó admitiendo lo evidente: un solo agente tenía las
cinco. Nada impedía que el modelo echara mano de issue_refund cuando se le había pedido
reservar una cita. La pausa humana era lo único que se interponía, y una pausa solo se dispara
si el modelo llega a llamar la herramienta.
Esta parte quita la herramienta en su lugar. El de agenda recibe tres y el de facturación tres,
compartiendo una. Pídele al de agenda que reembolse una factura y no puede — no porque se le
haya dicho que no, no porque una política lo bloquee, sino porque issue_refund nunca estuvo en
la lista que se le dio.
Luego el supervisor de la Parte 2 vuelve
encima, y sale a la luz la propiedad interesante: el enrutado decide quién trabaja, el alcance
decide qué es posible. Un reembolso mal enrutado sigue sin poder reembolsar.
La Parte 1 le dio a un agente un estado que
sobrevive a un reinicio, y una pausa donde un humano aprueba antes de que continúe. La
Parte 2 puso un supervisor delante de un
agente de agenda y uno de facturación, y repartió el trabajo entre ellos.
Ninguno de esos agentes agendó ni facturó nada. Lo comentaron. Cada herramienta que tenían era
una función que tú escribiste en el mismo proceso, y las interesantes — buscar un cliente,
ocupar un hueco, mover dinero — no existían.
Esta parte construye la caja de herramientas que esos dos roles necesitan, por MCP: un
calendario, una lista de clientes, facturas y un reembolso. Para reducir piezas móviles
apuntamos un agente a las cinco herramientas en lugar de reconstruir el supervisor de la
Parte 2 — el agente las descubre en tiempo de ejecución en vez de estar cableado a ellas, las
encadena para responder una pregunta, y escribe filas que puedes ir a comprobar en la base de
datos después. Luego el reembolso lo detiene en seco y hace que un humano teclee el importe.
Repartir estas herramientas de vuelta entre el agente de agenda y el de facturación — de modo
que el de agenda no pueda verissue_refund en absoluto — es el siguiente paso natural, y la
última sección explica cómo.
En julio la especificación de MCP eliminó las sesiones por completo.
El día que ejecutamos esto, LangChain publicó el soporte para esa revisión en el paquete
principal. Así que esta es una primera ejecución contra un protocolo de cinco semanas y un cliente
del mismo día — que es justo por lo que tres cosas de aquí no están todavía en la documentación
de nadie.
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í.
Tienes dos agentes. Uno reserva citas. Otro gestiona facturación.
Llega un ticket que necesita los dos: cambia la fecha de mi instalación, y mi factura
parece incorrecta. Así que lo envías a los dos.
El primero reserva el martes. El segundo ve que la cuenta está en mora y la congela. Los
dos terminan casi en el mismo momento, y los dos guardan lo que decidieron.
Solo se guarda uno de ellos. ¿Cuál? El que terminó primero — que depende de lo lenta que
estuviera una llamada de API ese día. Así que reservas una cita en una cuenta congelada, o
congelas una cuenta a la que acabas de prometerle un ingeniero. Después parece que se tomó
una única decisión limpia.
La Parte 1 construyó un agente que
ejecuta sus pasos en un orden fijo. Este post tiene varios: un supervisor que elige quién
trabaja en qué, un agente que pasa el trabajo a otro a mitad de camino, y dos agentes
puestos en el camino del otro a propósito — para descubrir qué hace LangGraph cuando no
están de acuerdo.
Tu agente lleva cuarenta segundos trabajando en la petición de un cliente. Ha leído el
ticket, ha consultado la cuenta y ha revisado los calendarios de tres ingenieros. Está a
punto de reservar la cita.
Entonces el proceso muere. Un despliegue que sale, el host que se queda sin memoria,
alguien que reinicia el contenedor — da igual cuál de ellos.
El agente no continúa donde lo dejó, porque no hay nada desde donde continuar. Todo lo que
aprendió vivía en variables dentro de un proceso que ya no existe. El cliente sigue
esperando. Ejecútalo de nuevo y pagas todo ese trabajo por segunda vez. Y la parte que más
debería preocuparte: nadie puede decir si la cita se reservó en el último segundo antes de
morir.
Un agente es un modelo dentro de un bucle. Como script de Python normal, ese bucle es
exactamente igual de frágil que el proceso que lo contiene.
Aquí construimos uno que guarda su estado en Postgres después de cada paso, para que una
caída no cueste nada.
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.