alrededor de los LLM. Estás trabajando en un desafío que antes era imposible y que ahora es posible abordar mediante LLM. Implementa los primeros agentes en su sistema y todo parece estar bien. Los clientes están contentos. Las respuestas no son las más rápidas, los LLM tienden a ser bastante lentos, pero es manejable.
Sin embargo, a medida que empiezas a desplegar más y más agentes, empiezas a notar anomalías extrañas. Lenta pero constantemente, la latencia comienza a aumentar. Al principio, asume que son los proveedores de LLM. Quizás el nuevo modelo OpenAI sea más lento. Quizás Anthropic esté bajo una gran carga. Los LLM son intrínsecamente lentos de todos modos, ¿verdad?
Luego empiezas a ver errores de tiempo de espera. Cuando profundizas en los registros y las métricas, algo no cuadra. OpenAI afirma que las solicitudes se completan muy rápidamente, pero su agente está tardando mucho en responder.
“¡OpenAI debe estar mintiéndonos!”
Luego viene la dolorosa comprensión.
OpenAI no fue el problema. Nuestro código era.
En Planck pasamos exactamente por esa transición. A medida que nuestro ecosistema de agentes crecía, también crecían nuestros tiempos de respuesta. Esta es la historia de cómo descubrimos el verdadero cuello de botella y cómo lo solucionamos.
Si está comenzando a utilizar agentes LLM en producción y se pregunta cómo escalarán, si es ingeniero de inteligencia artificial o si simplemente disfruta desafiando problemas de diseño de sistemas asincrónicos, lo invito a seguirnos.
La configuración
En Planck, teníamos varios agentes de LLM en producción. Todos eran atendidos por un único servicio, lo que significa que una sola solicitud, ya sea a través de HTTP o una cola de trabajadores, activaría todos los agentes simultáneamente. Luego, cada agente haría docenas de llamadas a sus propios subagentes.
Aquí hay una versión simplificada de nuestro código en Python:
import asyncio import aiohttp NUM_AGENTS = 5 # agentes activados por una solicitud CALLS_PER_AGENT = 30 # llamadas de subagente LLM cada agente realiza async def call_sub_agent(sesión: aiohttp.ClientSession) -> dict: “””Un solo paso de subagente = una llamada de LLM (io-bound).””” payload = { “model”: “your-llm-model”, “mensajes”: [{“role”: “user”, “content”: “some fake prompt”}]} async con session.post(“llm_url”, json=payload) como resp: return await resp.json() async def run_agent(session: aiohttp.ClientSession, agent_id: int) -> list[dict]: “””Un agente se distribuye en docenas de llamadas de LLM de subagente a la vez.””” return await asyncio.gather( *(call_sub_agent(session) for _ in range(CALLS_PER_AGENT)) ) async def handle_request() -> list[list[dict]]: “””Una sola solicitud activa todos los agentes simultáneamente.””” async con aiohttp.ClientSession() como sesión: return await asyncio.gather( *(run_agent(session, agent_id) for agent_id in range(NUM_AGENTS)))
A primera vista, esta arquitectura parece ideal. Casi todo el trabajo está vinculado a E/S, por lo que el uso del ecosistema asíncrono de Python permite que todos los agentes se ejecuten simultáneamente. Por lo tanto, la latencia de una solicitud debe estar dominada por la llamada LLM más lenta, no por el número total de agentes.
Al menos eso es lo que esperábamos.
Para probar eso, utilizamos un servidor FastAPI local. No te preocupes, verificamos que responde rápidamente y no es el cuello de botella.
Utilizamos un pequeño rango de suspensión asíncrono aleatorio en el punto final del servidor para simular el tiempo que tarda un LLM en responder. Enviamos grandes cargas útiles al servidor y recibimos grandes cargas útiles, lo cual es bastante común en los flujos de trabajo de LLM.Los resultados pueden variar según el tamaño de la carga útil, el rango de suspensión asíncrona y la cantidad de subagentes por agente. Por lo tanto, no se centre en las cifras absolutas, sino en la tendencia general.
Acercándose
¿Por qué podría ser eso? Estábamos usando async exactamente como deberíamos. Se suponía que todos los agentes y subagentes se ejecutarían simultáneamente, por lo que, en teoría, debería haber poca o ninguna diferencia entre ejecutar un agente y ejecutar 10.000 agentes.
Cuando ejecutamos el código en modo de depuración, vemos advertencias de retraso del bucle de eventos, lo que significa que la tarea ha finalizado, es decir, el LLM devuelve un resultado, pero el bucle de eventos no puede conseguir que un trabajador lo mire porque está ocupado haciendo otra cosa y, en casos extremos, esto puede provocar errores de tiempo de espera que estábamos viendo en producción.
ADVERTENCIA: raíz: retraso del bucle de eventos: 230 ms (el bucle estaba ocupado, no esperando) ADVERTENCIA: raíz: retraso del bucle de eventos: 137 ms (el bucle estaba ocupado, no esperando) ADVERTENCIA: raíz: retraso del bucle de eventos: 146 ms (el bucle estaba ocupado, no esperando) ADVERTENCIA: raíz: retraso del bucle de eventos: 177 ms (el bucle estaba ocupado, no esperando) ADVERTENCIA: raíz: Retraso del bucle de eventos: 283 ms (el bucle estaba ocupado, no esperando) …
Pero, ¿por qué el bucle de eventos no puede adquirir un trabajador para manejar la respuesta LLM? En Python, debido a GIL, solo un hilo puede ejecutar código a la vez. Eso significa que el bucle de eventos debe estar ocupado ejecutando algún trabajo vinculado a la CPU en otro lugar.
¿Pero qué podría ser? Para resolver eso, deberíamos usar un generador de perfiles.
Lo fácil gana
Después de revisar el generador de perfiles, podemos identificar algunas cosas. Primero, perdemos mucho tiempo serializando y deserializando el cuerpo. De forma predeterminada, aiohttp usa la biblioteca json incorporada, pero podemos acelerarla significativamente usando orjson.
En segundo lugar, en realidad no podemos ejecutar todas las solicitudes de E/S simultáneamente. Resulta que la sesión aiohttp limita la cantidad de conexiones HTTP que podemos abrir y, por defecto, ese límite es 100.
Entonces, intentamos aumentar y optimizar este número, pero no indefinidamente. En algún momento, seguir aumentando este valor afectará el rendimiento.
Y si volvemos a realizar las pruebas, vemos que los resultados son mucho mejores.
Sin embargo, incluso ahora, a medida que aumenta el número de agentes, la latencia también aumenta.
El verdadero cuello de botella
Algunos de ustedes podrían culpar a aiohttp, diciendo que no es lo suficientemente rápido o que, debido a que limita el número de conexiones, todavía no podemos paralelizar realmente la E/S.
Para eso, y para líneas de pensamiento similares, quiero que creemos un ejemplo de código aún más simple. En lugar de depender de tantas bibliotecas y ejecutar llamadas HTTP reales, usemos la suspensión asíncrona para simular tareas de E/S y la suspensión para simular tareas vinculadas a la CPU.
importar asyncio importar tiempo de importación aleatorio NUM_AGENTS = 50 CALLS_PER_AGENT = 30 IO_RANGE = (0.45, 0.55) # latencia LLM simulada: espera de E/S CPU_RANGE = (0.001, 0.002) # (des)serialización simulada: CPU funciona asíncrono def call_sub_agent() -> Ninguno: espera asyncio.sleep(random.uniform(*IO_RANGE)) time.sleep(random.uniform(*CPU_RANGE)) async def run_agent() -> Ninguno: esperar asyncio.gather(*(call_sub_agent() for _ in range(CALLS_PER_AGENT))) async def handle_request(num_agents: int) -> Ninguno: esperar asyncio.gather(*(run_agent() para _ en el rango(num_agents)))
El resultado es bastante similar a lo que vimos antes. Por supuesto, hice un poco de trampa y ajusté los tiempos de suspensión para crear este hermoso gráfico, pero incluso con números diferentes, el punto importante sigue siendo el mismo: a medida que crece la cantidad de agentes, aumenta la latencia. No podemos lograr un verdadero paralelismo.
Por supuesto, podemos reducir la curva optimizando las tareas vinculadas a la CPU, pero sólo hasta cierto punto.
En este punto, resulta tentador culpar al tiempo de ejecución asíncrono de Python. ¿Podría ser que el bucle de eventos simplemente no pueda manejar tantas tareas simultáneas?
El bucle de eventos en sí es increíblemente eficiente. Si comparamos una aplicación que programa cientos de miles de rutinas que no realizan nada más que asyncio.sleep(), escala notablemente bien. Siempre que las tareas estén realmente vinculadas a E/S, asyncio no tiene problemas para administrar cantidades masivas de concurrencia.
Entonces, ¿por qué nuestro código se comporta de manera tan diferente?
Porque nuestras tareas no están puramente vinculadas a E/S.
Cada vez que llega una respuesta LLM, el bucle de eventos tiene que ejecutar una pequeña cantidad de trabajo de la CPU antes de poder pasar a la siguiente rutina; individualmente, estas operaciones son casi gratuitas. En conjunto, a través de cientos o miles de llamadas simultáneas, se convierten en el cuello de botella.
Ahora algunos de ustedes pueden empezar a gritar: “Bueno, eso es lo que pasa por usar Python en producción. Por supuesto que no escalará”.
Pero eso no es el punto. Claro, GIL empeora el problema, pero incluso con un lenguaje diferente como Java o Go, todavía hay un límite en la cantidad de procesos que puede abrir para paralelizar tareas vinculadas a la CPU, porque hay un límite en la cantidad de núcleos que tiene su máquina.
Alejar
Para encontrar la solución adecuada, debemos dar un paso atrás por un momento y dejar de centrarnos en qué tareas consumen más tiempo de CPU y cómo podemos optimizarlas. Más bien, debemos reconocer que existe un problema fundamental con el diseño de nuestro sistema.
Lo importante es darse cuenta de que el trabajo de la CPU no escala de la misma manera que lo hace la E/S. Cada operación de E/S completada eventualmente requiere algo de trabajo de la CPU antes de que la aplicación pueda continuar. Deserializar una respuesta, contar tokens, validar datos o ejecutar lógica personalizada son operaciones individualmente económicas. Pero cuando cientos o miles de llamadas de agentes finalizan aproximadamente al mismo tiempo, esas pequeñas tareas de CPU compiten por los mismos recursos de CPU y eventualmente se convierten en un cuello de botella.
Una vez que entendimos esto, la solución se volvió mucho más clara. En lugar de intentar concentrar más trabajo en un único bucle de eventos, cambiamos la arquitectura para que ningún proceso fuera responsable de todos los agentes.
Exactamente cómo lo hagas depende de tu arquitectura. Puede utilizar colas de trabajadores, servicios múltiples, trabajos separados u otro mecanismo de distribución. Los detalles de implementación varían, pero el principio subyacente es siempre el mismo: distribuir el trabajo de la CPU en lugar de pedirle a un solo proceso que lo haga todo.
Esto no eliminó el trabajo de la CPU. Aún era necesario deserializar, validar y procesar cada respuesta. Pero en lugar de que todo ese trabajo se acumulara en un solo proceso, se distribuyó entre múltiples procesos, núcleos de CPU y máquinas. Esa distinción es importante. No eliminamos el cuello de botella, lo dividimos en unidades más pequeñas que podían escalarse horizontalmente.
En nuestro caso, introdujimos un enrutador que distribuía cada solicitud entre varios trabajadores. Cada trabajador era responsable únicamente de un subconjunto de agentes y tenía su propio bucle de eventos y grupo de conexiones.
La distribución de la carga de trabajo resolvió varios problemas a la vez. Impidió que la latencia aumentara continuamente a medida que añadíamos más agentes. Permitió a los equipos seguir creando nuevos agentes sin preocuparse por ralentizar a los existentes. Y permitió a los ingenieros centrarse en resolver problemas empresariales en lugar de dedicar tiempo a microoptimizar cada operación vinculada a la CPU.
Pensamientos finales
Es fácil pensar que el código asincrónico es infinitamente escalable. Después de todo, agregar otra rutina parece casi gratis. Pero la E/S asíncrona solo elimina la espera, no elimina el cálculo. Al final, cada respuesta todavía tiene que ejecutar tareas de la CPU, y ahí es donde la escalabilidad comienza a fallar.
Este no es realmente un problema de agentes de IA. Es un problema de distribución. Ya sea que una sola solicitud desencadene cientos de agentes o una evaluación por lotes desencadene miles de llamadas de LLM en un conjunto de datos, el problema subyacente es el mismo: eventualmente, el trabajo de CPU requerido después de cada respuesta se convierte en el cuello de botella. Dividir la carga de trabajo entre múltiples procesos o trabajos suele ser una solución mucho más escalable que intentar pasar todo a través de un único bucle de eventos.
La lección más amplia se extiende mucho más allá de los agentes Python o AI. La buena ingeniería no se trata sólo de escribir código eficiente, sino de diseñar sistemas que continúen escalando a medida que crecen. Un código bien escrito no puede compensar un sistema mal diseñado. Puede ejecutar perfiladores y optimizar cada línea de código, pero hay un límite en el impacto que eso puede tener.
A veces, hay que dar un paso atrás y preguntarse: ¿este enfoque realmente escala o deberíamos considerar rediseñar nuestro sistema?
La historia de cómo dividimos realmente la carga de trabajo es interesante en sí misma. Estoy pensando en convertir esto en una serie y escribir más sobre eso. Déjame saber si estarías interesado.
De hecho, parte de ese rediseño requirió mover nuestra canalización RAG de un almacén en memoria a uno compartido. Si tienes curiosidad sobre esa parte de la historia, puedes leerla aquí:
https://towardsdatascience.com/when-not-to-use-vector-db/