index() pour garder une trace du lien entre les documents et leurs chunks (calcule de hash du contenu, associé à l’identifiant du vecteur dans la base de données). Ainsi, seuls les documents ayant été modifiés sont rafraîchis dans la base vectorielle. Tous les chunks associés à un document sont supprimés, puis les nouveaux chunks sont injectés.
Question : Est-ce que l’association entre les données de la base vecteurs et la traçabilité des différents chunks reste stable, s’il y a un crash, pendant l’import ? Que faire si un crash arrive ? Quelle est la garantie transactionnelle de l’import des données entre la base vecteur et la base SQL qui maintient le lien entre les documents et les vecteurs ?
À notre connaissance, il n’existe pas de base vectorielle compatible avec le commit à deux phases, permettant de garantir qu’en cas de crash, et la base vectorielle et la base SQL seront rollbackées.
Ceux que nous souhaitons, c’est qu’en cas d’import, tous les imports soient validés simultanément, ou qu’aucun ne le soit.
Un autre problème similaire est présent, lors de la sauvegarde de l’historique de la conversation. La classe SQLChatMessageHistory
ne garantie pas que l’ensemble de l’échange sera sauvegardé dans la même transaction SQL. En crash entre la sauvegarde de la question et de la réponse, et vous avez une session instable (sans compter l’implémentation asynchrone qui laisse à désirer)
Pgvector est une bonne piste d’intégration, mais il présente d’autres problèmes que nous résolverons via des PR et un prochain article. Par exemple, il n’implémente pas d’api asynchrone.
Les projets LLM exploitent la GPU pour prédire les mots suivants. Une GPU ne se partage pas aussi facilement qu’une CPU. Surtout avec les modèles de langages. Ils sont initialement développés pour ne gérer qu’une seule inférence à la fois.
Si vous utilisez les instances proposées par le fournisseur de cloud, comme Bedrocks ou Vertex, les API OpenAI, Claude, Mistral, etc. vous n’avez pas à vous soucier de cela. Toutefois, si vous souhaitez maitriser l’hébergement du modèle, que cela soit “on premise” ou sur le cloud, avec des GPU dédiés, vous devez vous interroger sur la qualité de votre exposition du modèle.
Un développeur naïf peut simplement utiliser un LLM localement, pour développer. Lorsqu’il désire proposer une API (généralement via FastAPI) pour le LLM, il prend conscience qu’une seule invocation est possible simultanément. S’il y a plusieurs utilisateurs désirant utiliser le LLM, il faut maintenir une file d’attente. Si la génération représente beaucoup de tokens, il faudra attendre que chaque invocation soit terminée avant de recevoir le premier token du job suivant.
Pour améliorer cela, la première idée consiste à créer des micro-batchs avec quelques requêtes d’inférence, pouvant être combinées sans dépasser la limite du nombre de tokens. Dans ce scénario, le traitement le plus long devra être terminé avant le lancement du prochain micro-batch.
Une amélioration est alors proposée pour permettre d’injecter une nouvelle requête dès qu’un des traitements est terminé dans le micro-batch. Une sorte de micro-batch en flux. Ainsi, l’exploitation du modèle est plus efficiente.
Ces stratégies sont complexes, car elles demandent une gestion, voire une modification fine du modèle, et un suivi de la mémoire entre la CPU et la GPU, pour être mise en œuvre. Heureusement, des projets Open Source comme vLLM ou d’autres s’occupent de tout. Il faut alors déployer un modèle de LLM à côté de l’application. Vertex de Google s’occupe d’exposer votre modèle via vLLM (ancienne version de l’API vLLM pour le moment).
Question : Est-ce que le LLM sait gérer plusieurs requêtes simultanément ?
Si ce n’est pas le cas, partez à la recherche de solutions élastiques, capable d’optimiser l’utilisation des GPU pour ce type d’usage. Ne vous lancez pas sur un développement à la main.
Il est important de comprendre que Python n’est pas un langage multitâche. Il est incapable d’exécuter plusieurs flux simultanément. Une seule instruction python est exécutée à la fois, quel que soit le nombre de cœurs ou de thread que vous utilisez. Cela est contrôlé par un verrou, le GIL (Global Interpreter Lock). Pourquoi cette contrainte ? Entre autres, parce que python utilise des compteurs de références pour gérer la mémoire. Il est impossible d’utiliser cette stratégie avec un processeur multicœur, à cause des caches de chacun.
Lorsque l’on demande à python de gérer plusieurs threads, en fait, il utilise deux stratégies. Toutes les API utilisant des I/O débloquent le GIL. Cela permet à d'autres threads de reprendre leurs exécutions. L’interpréteur de byte code de python change alors le flux de traitement, mais il continue à n’exécuter qu’une instruction à la fois. La seconde stratégie consiste à chronométrer le temps accordé à un flux de traitement python. S’il dépasse un seuil, entre deux instructions Python, l’interpréteur peut partir s’occuper d’un autre thread. Il est à noter que chaque thread consomme de la mémoire afin de maintenir une pile pour les appels. Le nombre de threads actifs est alors limité par la taille de la mémoire nécessaire pour chaque pile d’exécution.
Pour améliorer cela, et être capable de gérer plusieurs milliers d’utilisateurs, Python propose le framework async/await. C’est un framework intégré dans le compilateur Python, pour découper chaque méthode ou function async
, en tranche. Chaque tranche est délimitée par les mots clés async
et/ou await
. Le compilateur se charge de convertir votre méthode ou votre fonction en un ensemble de coroutine (des fonctions asynchrones), et à organiser l'enchaînement des appels. Un seul thread est à l'écoute d’une file de coroutine, avec chaque fragment de code à exécuter. Cette file évolue suivant si les résultats attendus arrivent ou non. À la résolution d’un future (à la réception de l’objet attendu par le await
), le bloc de code suivant est ajouté à la file de coroutine. Il sera traité par l’unique thread, lorsque cela sera son tour. Ce thread est alors capable de gérer des milliers d’utilisateurs (comme le fait nodejs par exemple). Il n’est plus nécessaire d’avoir un pool de thread, forcément limité en taille.
Ce framework impose une collaboration fine du développeur pour permettre la gestion de nombreux utilisateurs. C'est-à-dire que le développeur doit respecter sa part du contrat, pour que cela fonctionne. Il faut que dans toutes les tranches de codes async
, il n’y ait aucun appel réseau, aucun appel disque ni aucun appel bloquant à la GPU. Il faut également rendre la CPU au plus vite.
La plupart des serveurs WEB sous Python sont compatibles ASGI (Asynchronous Server Gateway Interface). Il s’agit d’un lien normalisé entre un protocole HTTP et une application Python. C’est le cas de FastAPI, Flask et de bien d’autres frameworks. Le terme important est “Asynchronous”.
Ce qu’il faut faire lors du développement si votre code à vocation à être exposé via ASGI :
async
async
. Je recherche des alternativesGénéralement, les développeurs construisent leurs chaînes de traitement dans un notebook, puis, en quelques lignes, publient le code dans une API. Avec cette approche naïve, un seul traitement sera exécuté, quel que soit le nombre d'utilisateurs.
Que se passe-t-il si on ne respecte pas le framework ? Et bien, il n’utilise qu’un seul flux de traitement pour exécuter chaque bloc de code, l’un après l’autre. Si l’un d’eux garde la main, tout est bloqué. Imaginez qu’un problème réseau temporaire entraîne un délai de 10 secondes lors de l’invocation de votre LLM. Plus aucun utilisateur ne progresse. Tout est bloqué. Peu importe le nombre d’instances de LLM que vous avez activé. C'est identique si le disque présente un souci ou pour toute autre dégradation de l’infrastructure.
Notre conseil pour bien saisir l’impact d’une erreur : choisissez une fonction de votre API, et ajoutez un time.sleep(120)
(attention pas un await asyncio.sleep(120)
) et essayez d’utiliser votre code avec plusieurs utilisateurs dont l’un est sur l’API patché. Assurez-vous d’utiliser un seul worker, pour que le test soit plus instructif (--workers 1) Vous risquez d’avoir quelques sueurs froides.
Dans les faits, lancer FastAPI avec les paramètres par défaut, va lancer autant d'instance Python qu’il y a de vCore du processeur (à chacun son GIL et son GC, pas de cache partagé entre les instances Python). Donc, cela cache les limitations évoquées (2 ou 4 traitements simultanés dans autant d'instances Python). Mais elles apparaissent dès que le nombre d’utilisateurs simultanés dépasse le nombre de vCore.
La question que vous devez vous poser est la suivante : Est-ce que le code utilise uniquement les API async
de tous les frameworks ? Cela concerne les drivers de bases de données, les API des bases vecteurs, l’invocation d’API, l’invocation des calculs d’embedding ou de LLM, etc.
Langchain propose deux jeux d’API dans le même framework. Ce n’est pas pour faire joli, mais bien parce que c’est nécessaire. Pratiquement toutes les méthodes sont doublées. L’une est synchrone, l’autre est asynchrone et doit être utilisée systématiquement si votre code doit être exposé via un serveur ASGI.
D’expérience, on croise rarement un code propre vis-à-vis de cela et c’est même parfois difficile. Par exemple, le driver pgvector de Langchain ne propose pas d’API asynchrone… (Nous avons proposé un patch pour rendre pgvector asynchrone)
La plupart des démos de type Chat, utilisent des frameworks ou composants permettant de proposer rapidement une interface utilisateur. On peut citer : streamlit, gradio, chainlit.
Problème : ces frameworks ne sont généralement pas compatibles avec le framework async/await. Et on retombe sur le problème précédent. (Notez qu’ils présentent également d’autres problèmes de sécurités, de résiliences, etc)
Question : Est-ce que l’interface utilisateur utilisée est compatible avec async ?
Si ce n’est pas le cas, partez à la chasse à d'autres solutions, ou implémentez vous-même l’interface web, qui ne fait qu’invoquer une API.
Comme vous pouvez le voir, il existe de nombreuses mauvaises pratiques que l’on retrouve trop souvent dans ces applications. Vous vous reconnaissez peut-être dans certaines de ces situations. Mais alors, pourquoi peu de développeurs réagissent ?
Les projets de LLM sont nouveaux et les coûts d’exécution sont élevés. Pour le moment, ils sont ouverts à un petit nombre de personnes. Cela cache les difficultés. C’est plus tard, avec le succès, qu’il faudra sûrement revoir de fond en comble le code du projet.
Si je devais être MLOps, étant incapable de garantir leur résilience, je refuserais probablement quelques applications à passer en production. La loi de murphy étant ce qu'elle est, le pire scénario est certain d’arriver, un moment ou à un autre.
Il est temps de prendre en charge la résilience des applications.
Pour vos projets d’IA générative, posez-vous ces différentes questions, avant une mise en production :
async
de tous les frameworks ?Il faut s’intéresser à tous les bugs qui n’arrivent pas lors du développement. C’est peut-être comme cela qu’on devient un “expert”.
Un prochain article proposera des solutions pour le framework langchain. Elles ne sont pas simples, puisque langchain en l’état (version 0.2.0) ne permet pas de proposer une application résiliente ! Néanmoins, nous vous proposerons des solutions.
Nous espérons que vous n’êtes pas dans ces situations. Si cela était le cas, n'hésitez pas à nous contacter, nous pourrions vous aider 😉