
Pourquoi l’ajout de plus d’agents IA a ralenti notre système
autour des LLM. Vous travaillez sur un défi auparavant impossible qu’il est désormais possible de relever à l’aide des LLM. Vous déployez les premiers agents dans votre système et tout semble bien. Les clients sont contents. Les réponses ne sont pas les plus rapides, les LLM ont tendance à être assez lents, mais c’est gérable.
Cependant, à mesure que vous commencez à déployer de plus en plus d’agents, vous commencez à remarquer d’étranges anomalies. Lentement mais sûrement, la latence commence à augmenter. Au début, vous supposez que ce sont les prestataires LLM. Peut-être que le nouveau modèle OpenAI est plus lent. Peut-être qu’Anthropic est soumis à une lourde charge. De toute façon, les LLM sont intrinsèquement lents, n’est-ce pas ?
Puis tu commences à voir erreurs de délai d’attente. Lorsque vous plongez dans les journaux et les métriques, quelque chose ne colle pas. OpenAI affirme que les demandes sont traitées très rapidement, mais votre agent met extrêmement longtemps à répondre.
« OpenAI doit nous mentir ! »
Vient ensuite la douloureuse prise de conscience.
OpenAI n’était pas le problème. Notre code l’était.
Chez Planck, nous avons vécu exactement cette transition. À mesure que notre écosystème d’agents s’est développé, nos délais de réponse ont également augmenté. C’est l’histoire de la façon dont nous avons découvert le véritable goulot d’étranglement et comment nous l’avons résolu.
Si vous commencez à utiliser des agents LLM en production et que vous vous demandez comment ils évolueront, si vous êtes un ingénieur en IA ou si vous aimez simplement résoudre les problèmes de conception de systèmes asynchrones, je vous invite à suivre.
La configuration
Chez Planck, nous avions plusieurs agents LLM en production. Ils étaient tous servis par un service uniquece qui signifie qu’une seule requête, que ce soit via HTTP ou une file d’attente de travail, déclencherait tous les agents simultanément. Chaque agent passerait alors des dizaines d’appels à ses propres sous-agents.
Voici une version simplifiée de notre code en Python :
import asyncio
import aiohttp
NUM_AGENTS = 5 # agents triggered by one request
CALLS_PER_AGENT = 30 # sub-agent LLM calls each agent makes
async def call_sub_agent(session: aiohttp.ClientSession) -> dict:
"""A single sub-agent step = one LLM call (io-bound)."""
payload = {
"model": "your-llm-model",
"messages": [{"role": "user", "content": "some fake prompt"}],
}
async with session.post("llm_url", json=payload) as resp:
return await resp.json()
async def run_agent(session: aiohttp.ClientSession, agent_id: int) -> list[dict]:
"""One agent fans out to dozens of sub-agent LLM calls at once."""
return await asyncio.gather(
*(call_sub_agent(session) for _ in range(CALLS_PER_AGENT))
)
async def handle_request() -> list[list[dict]]:
"""A single request triggers every agent simultaneously."""
async with aiohttp.ClientSession() as session:
return await asyncio.gather(
*(run_agent(session, agent_id) for agent_id in range(NUM_AGENTS))
)
À première vue, cette architecture semble idéale. Presque tout le travail est lié aux E/S, donc l’utilisation de l’écosystème asynchrone de Python permet à chaque agent d’exécuter en même temps. La latence d’une requête doit donc être dominée par l’appel LLM le plus lent, et non par le nombre total d’agents.
Du moins, c’est ce à quoi nous nous attendions.
Pour tester cela, nous avons utilisé un serveur FastAPI local. Ne vous inquiétez pas, nous avons vérifié qu’il répond rapidement et qu’il ne constitue pas un goulot d’étranglement.
Nous avons utilisé un petit aléatoireasync sleepplage dans le point de terminaison du serveur pour simuler le temps nécessaire à un LLM pour répondre. Nous avons envoyé de grosses charges utiles au serveur et reçu de grosses charges utiles en retour, ce qui est assez courant dans les workflows LLM.Les résultats peuvent varier en fonction de la taille de la charge utile, du
async sleepplage et le nombre de sous-agents par agent. Alors, ne vous concentrez pas sur les chiffres absolus eux-mêmes, concentrez-vous sur les tendance générale.

Zoom avant
Pourquoi est-ce possible ? Nous utilisions async exactement comme nous le devrions. Tous les agents et sous-agents étaient censés fonctionner simultanément, donc en théorie, il n’y aurait pas eu grand-chose à faire. aucune différence entre exécuter un agent et exécuter 10 000 agents.
Lorsque nous exécutons le code en mode débogage, nous voyons avertissements de décalage de boucle d’événementce qui signifie que la tâche est terminée, c’est-à-dire que le LLM renvoie un résultat, mais la boucle d’événements ne peut pas demander à un travailleur de l’examiner car elle est occupée à faire autre chose, et dans des cas extrêmes, cela peut conduire aux erreurs de délai d’attente que nous voyions en production.
WARNING:root: event-loop lag: 230 ms (loop was busy, not waiting)
WARNING:root: event-loop lag: 137 ms (loop was busy, not waiting)
WARNING:root: event-loop lag: 146 ms (loop was busy, not waiting)
WARNING:root: event-loop lag: 177 ms (loop was busy, not waiting)
WARNING:root: event-loop lag: 283 ms (loop was busy, not waiting)
...
Mais pourquoi la boucle d’événements ne peut-elle pas acquérir un travailleur pour gérer la réponse LLM ? En Python, à cause de GILun seul thread peut exécuter du code à la fois. Cela signifie que la boucle d’événements doit être occupée à exécuter ailleurs des travaux liés au processeur.
Mais qu’est-ce que ça pourrait être ? Pour comprendre cela, nous devrions utiliser un profileur.
Les victoires faciles
Après avoir examiné le profileur, nous pouvons identifier quelques éléments. Premièrement, nous perdons beaucoup de temps à sérialiser et désérialiser le corps. Par défaut, aiohttp utilise le json bibliothèque, mais nous pouvons considérablement accélérer cela en utilisant orjson.

Deuxièmement, nous ne sommes pas réellement en mesure d’exécuter toutes les requêtes d’E/S simultanément. Il s’avère que le aiohttp session limite le nombre de connexions HTTP que nous pouvons ouvrir, et par défaut, cette limite est de 100.
Nous avons donc essayé d’augmenter et d’optimiser ce nombre, mais pas indéfiniment. À un moment donné, continuer à augmenter cette valeur nuira aux performances.
Et si on refait les tests, on voit les résultats sont bien meilleurs.
Cependant, même aujourd’hui, à mesure que le nombre d’agents augmente, la latence augmente également.

aiohttp limites de connexion (image de l’auteur)Le véritable goulot d’étranglement
Certains d’entre vous pourraient blâmer aiohttpdisant que ce n’est pas assez rapide ou que, parce que cela limite le nombre de connexions, nous ne pouvons toujours pas vraiment paralléliser les E/S.
Pour cela, et pour des réflexions similaires, je souhaite que nous créions un exemple de code encore plus simple. Au lieu de nous appuyer sur autant de bibliothèques et d’exécuter de vrais appels HTTP, utilisons la veille asynchrone pour simuler les tâches d’E/S et la veille pour simuler les tâches liées au processeur.
import asyncio
import random
import time
NUM_AGENTS = 50
CALLS_PER_AGENT = 30
IO_RANGE = (0.45, 0.55) # simulated LLM latency: I/O wait
CPU_RANGE = (0.001, 0.002) # simulated (de)serialization: CPU work
async def call_sub_agent() -> None:
await asyncio.sleep(random.uniform(*IO_RANGE))
time.sleep(random.uniform(*CPU_RANGE))
async def run_agent() -> None:
await asyncio.gather(*(call_sub_agent() for _ in range(CALLS_PER_AGENT)))
async def handle_request(num_agents: int) -> None:
await asyncio.gather(*(run_agent() for _ in range(num_agents)))
Le résultat est assez similaire à ce que nous avons vu précédemment. Bien sûr, j’ai triché un peu et ajusté les temps de sommeil pour créer ce beau graphique, mais même avec des chiffres différents, le point important reste le même : comme le le nombre d’agents augmente, la latence augmente. Nous ne pouvons pas parvenir à un véritable parallélisme.
Bien sûr, nous pouvons abaisser la courbe en optimisant les tâches liées au processeur, mais seulement dans une certaine mesure.

À ce stade, il est tentant de blâmer le runtime asynchrone de Python. Se pourrait-il que la boucle d’événements ne puisse tout simplement pas gérer autant de tâches simultanées ?
La boucle d’événements elle-même est incroyablement efficace. Si nous comparons une application qui planifie des centaines de milliers de coroutines n’exécutant rien d’autre que asyncio.sleep()il évolue remarquablement bien. Tant que les tâches sont réellement liées aux E/S, asyncio n’a aucune difficulté à gérer des quantités massives de concurrence.

asyncio surcharge de la boucle d’événements à mesure que le nombre de tâches augmente (image de l’auteur)Alors pourquoi notre code se comporte-t-il si différemment ?
Parce que nos tâches ne sont pas uniquement liées aux E/S.
Chaque fois qu’une réponse LLM arrive, la boucle d’événements doit exécuter une petite quantité de travail CPU avant de pouvoir passer à la coroutine suivante, individuellement, ces opérations sont presque gratuites. Collectivement, à travers des centaines ou des milliers d’appels simultanés, ils deviennent les goulot.
Maintenant, certains d’entre vous pourraient commencer à crier : « Eh bien, c’est ce que vous obtenez en utilisant Python en production. Bien sûr, il ne sera pas évolutif. »
Mais cela passe à côté de l’essentiel. Bien sûr, le GIL aggrave le problème, mais même avec un langage différent comme Java ou Go, il existe toujours un problème. limiter le nombre de processus que vous pouvez ouvrir pour paralléliser les tâches liées au processeurcar il y a une limite au nombre de cœurs dont dispose votre machine.
Zoom arrière
Pour trouver la bonne solution, nous devons prendre un instant de recul et cesser de nous concentrer sur les tâches qui consomment le plus de temps CPU et sur la manière dont nous pouvons les optimiser. Nous devons plutôt reconnaître qu’il existe un problème fondamental dans la conception de notre système.
La réalisation importante est que Le travail du processeur n’évolue pas de la même manière que les E/S. Chaque opération d’E/S terminée nécessite finalement un peu de travail sur le processeur avant que l’application puisse continuer. Désérialiser une réponse, compter des jetons, valider des données ou exécuter une logique personnalisée sont toutes des opérations individuellement peu coûteuses. Mais lorsque des centaines ou des milliers d’appels d’agent se terminent à peu près au même moment, ces minuscules tâches CPU se disputent les mêmes ressources CPU et finissent par devenir un goulot d’étranglement.
Une fois que nous avons compris cela, la solution est devenue beaucoup plus claire. Au lieu d’essayer de regrouper davantage de travail dans une seule boucle d’événements, nous avons modifié l’architecture afin que aucun processus n’était responsable de tous les agents.
La manière exacte dont vous procédez dépend de votre architecture. Vous pouvez utiliser des files d’attente de tâches, plusieurs services, des tâches distinctes ou un autre mécanisme de distribution. Les détails d’implémentation varient, mais le principe sous-jacent est toujours le même : répartir le travail du processeur plutôt que de demander à un seul processus de tout faire.
Cela n’a pas éliminé le travail du processeur. Chaque réponse devait encore être désérialisée, validée et traitée. Mais au lieu que tout ce travail soit accumulé dans un seul processus, il a été réparti sur plusieurs processus, cœurs de processeur et machines. Cette distinction est importante. Nous n’avons pas éliminé le goulot d’étranglement, nous l’avons divisé en unités plus petites pouvant être mises à l’échelle horizontalement.
Dans notre cas, nous avons introduit un routeur qui répartit chaque requête sur plusieurs nœuds de calcul. Chaque travailleur n’était responsable que d’un sous-ensemble d’agents et disposait de sa propre boucle d’événements et de son propre pool de connexions.
La répartition de la charge de travail a résolu plusieurs problèmes à la fois. Cela a empêché la latence d’augmenter continuellement à mesure que nous ajoutions plus d’agents. Cela a permis aux équipes de continuer à créer de nouveaux agents sans se soucier de ralentir ceux existants. Et cela permet aux ingénieurs de se concentrer sur la résolution des problèmes commerciaux plutôt que de passer du temps à micro-optimiser chaque opération liée au processeur.
Pensées finales
Il est facile de considérer le code asynchrone comme infiniment évolutif. Après tout, ajouter une autre coroutine semble presque gratuit. Mais les E/S asynchrones suppriment uniquement l’attente, elles n’éliminent pas le calcul. Finalement, chaque réponse doit encore exécuter des tâches CPU, et c’est là que l’évolutivité commence à s’effondrer.
Ce n’est pas vraiment un problème d’agent IA. C’est un problème de diffusion. Qu’une seule requête déclenche des centaines d’agents ou qu’une évaluation par lots déclenche des milliers d’appels LLM sur un ensemble de données, le problème sous-jacent est le même : en fin de compte, le Le travail du processeur requis après chaque réponse devient le goulot d’étranglement. Répartir la charge de travail entre plusieurs processus ou tâches est souvent une solution beaucoup plus évolutive que d’essayer de tout faire passer par une seule boucle d’événements.
La leçon plus large s’étend bien au-delà de Python ou des agents IA. Une bonne ingénierie ne consiste pas seulement à écrire du code efficace, il s’agit également de concevoir des systèmes qui continuent d’évoluer au fur et à mesure de leur croissance. Un code bien écrit ne peut pas compenser un système mal conçu. Vous pouvez exécuter des profileurs et optimiser chaque ligne de code, mais il y a une limite à l’impact que cela peut avoir.
Parfois, il faut prendre du recul et se demander : cette approche est-elle vraiment évolutive, ou devrions-nous envisager de repenser notre système ?
L’histoire de la façon dont nous répartissons réellement la charge de travail est intéressante en soi. Je pense en faire une série et écrire davantage à ce sujet. Faites-moi savoir si vous seriez intéressé.
En fait, une partie de cette refonte a nécessité de déplacer notre pipeline RAG d’un magasin en mémoire vers un magasin partagé. Si vous êtes curieux de connaître cette partie de l’histoire, vous pouvez la lire ici :
https://towardsdatascience.com/when-not-to-use-vector-db/



