Utiliser une IA locale dans Django

5 juin 2023 12:38 dans data-pipelines / publications

Ceci est une introduction l'IA prend de plus en plus de place, de plus en plus vite. Depuis quelques temps a émergé un mouvement open source autour notamment de Llama.cpp.

Slider Image

Phénomène incontournable, l'IA prend de plus en plus de place, de plus en plus vite. Depuis quelques temps a émergé un mouvement open source autour notamment de Llama.cpp. Cette évolution permet aujourd'hui de faire tourner des language models en local sur un simple cpu. Voyons comment utiliser cela en Python, pour ensuite l'intégrer dans une app Django.

Note : le code présenté dans cet article est disponible dans le repository django-local-ai.

Les bindings Python Llama.cpp

Installons le package pip. Noter que cela va compiler Llama.cpp en local pour builder le wheel du package.


        pip install llama-cpp-python
      

Une fois la base installée, nous devons nous procurer un language model open source et le télécharger en local.

Le language model

Téléchargeons un modèle open source au format Ggml 3 compatible Llama. Nous allons utiliser pour cet exemple le modèle GPT4All-13B-snoozy disponible dans ce repository. Créer d'abord un dossier pour stocker les modèles locaux.


        mkdir mon/dir/models
        cd mon/dir/models
        wget https://huggingface.co/TheBloke/GPT4All-13B-snoozy-GGML/resolve/main/GPT4All-13B-snoozy.ggmlv3.q5_0.bin
      

Créons maintenant un simple script pour vérifier que tout fonctionne comme prévu. Le script va charger le modèle et l'interroger de manière basique. Dans un fichier testlm.py :

from llama_cpp import Llama

        llm = Llama(
            # le path doit être absolu
            model_path="/home/moi/mon/dir/models/GPT4All-13B-snoozy.ggmlv3.q5_0.bin"
        )
        output = llm(
            "Name the planets in the solar system?",
            max_tokens=256,
            echo=True,
        )
        print(output)
      

Lancer :


        python testlm.py
      

Une fois vérifié que le modèle se charge correctement et donne une réponse, nous avons un setup IA locale fonctionnel. Commençons donc à voir comment intégrer cela dans Django.

Endpoint http response simple

Créons un endpoint chargé de lancer l'inférence à l'aide d'un stack Django Ninja (voir la série sur Django Ninja pour le détail du setup). Créons d'abord un schéma Pydantic pour spécifier le format de la réponse :

classInferUsageContract(Schema):
            prompt_tokens: int
            completion_tokens: int
            total_tokens: intclassInferResponseContract(Schema):
            text: str
            finish_reason: str
            usage: InferUsageContract

        classInferContract(Schema):
            prompt: str

Maintenant le endpoint post :

from llama_cpp import Llama
          from myapp.schemas import InferContract, InferResponseContract
          
          @router.post("/inferhttp",
              response={
                  200: InferResponseContract,
              },
          )definferhttp(request: HttpRequest, data: InferContract):
              LLM = Llama(model_path=settings.MODEL_PATH)
              prompt: str = data.dict()["prompt"]
              print("Prompt:")
              print(prompt)
              output = LLM.create_completion(prompt, max_tokens=1024)
              print("Response:")
              print(output)
              text: str = output["choices"][0]["text"]
              resp = InferResponseContract(
                  text=text,
                  finish_reason=output["choices"][0]["finish_reason"],
                  usage=output["usage"],
              )
              return200, resp
        

Nous utilisons les éléments Llama pour charger le modèle et create_completion pour l'infèrence. Les données sont ensuite renvoyées au format spécifié par le schéma à partir du payload retourné par la fonction d'inférence. Voir l'api llama-cpp-python pour les détails.

Interrogeons maintenant le modèle via un request frontend. Nous utiliserons ici un exemple Vuejs :

const prompt = ref();
        const isRunning = ref(false);
        const result = ref("");

        asyncfunctioninfer() {
          isRunning.value = true;
          const res = await api.post("/api/llm/inferhttp", { "prompt": prompt.value });
          isRunning.value = false;
          console.log(res.data);
          result.value = res.data.text;
        }
      

Nous envoyons simplement un request avec le prompt en attendant une réponse. Les variables réactives sont connectées à un input text pour le prompt, le résultat est affiché dans la page une fois disponible. Le code complet de l'exemple ci-dessus peut être trouvé ici.

Endpoint streaming

Pour une expérience optimale il conviendrait d'ajouter la possibilité d'une réponse en streaming. L'utilisateur verrait ainsi la réponse apparaitre au fur et à mesure au lieu d'avoir à attendre la réponse complète du serveur http. Idéalement le process de réponse devrait être séparé de la réponse du serveur de manière à ne pas le bloquer. Mettons tout d'abord en place des websockets.

Websockets

Nous utiliserons Django Instant pour gérer les websockets dans Django.


        pip install django-instant
      

Ajouter "instant" dans le setting INSTALLED_APPS ainsi que les urls correspondantes :


        urlpatterns = [
            # ...
            path("instant/", include("instant.urls")),
        ]
      

La config étant en place, il ne nous reste plus qu'à télécharger le serveur websockets :


        python manage.py installws
      

Mettre à jour les settings donnés par le retour de la commande et tout est prêt. Pour plus détails voir la doc de Django Instant.

Task queue

Pour un confort optimal  d'utilisation, nous allons mettre en place une task queue qui sera chargée d'exécuter la commande d'inférence. Pour cet exemple nous utiliserons Huey, une task queue légère :


        pip install huey
      

Ajouter la config Huey dans les settings :


        HUEY = SqliteHuey(filename=str(BASE_DIR / "tasks.sqlite"))
      

Noter que nous utilisons ici Sqlite avec Huey pour éviter d'installer un broker lourd type Redis. L'environnement adéquat étant en place, élaborons maintenant la task d'inférence et le endpoint.

Création d'une task

Localisons la fonction d'infèrence du language model dans une task spécifique. Créer un fichier tasks.py :

from llama_cpp import Llama
        from django.conf import settings
        from instant.producers import publish
        
        huey = settings.HUEY

        @huey.task()definfer(prompt: str):
            LLM = Llama(model_path=settings.MODEL_PATH)
            stream = LLM.create_completion(prompt, max_tokens=1024, stream=True)
            running = True
            i = 0while running:
                for output in stream:
                    if i == 0:
                        publish("$llm", "#STARTSTREAM#")
                    if output["choices"][0]["finish_reason"] == "stop":
                        publish("$llm", "#ENDSTREAM#")
                        running = Falseelse:
                        publish("$llm", output["choices"][0]["text"])
                    i += 1return

Nous utilisons ici le mode streaming output qui permet de traiter chaque token au fur et à mesure. Concrètement cela revient à un stream lettre par lettre. Chaque token que le language model produit est ici envoyé directement sur un channel websockets via la fonction publish.

Il convient de créer le channel $llm dans l'admin Django dans l'app Instant. C'est le canal websockets que nous utiliserons pour communiquer l'output au frontend.

Websockets frontend

Pour connecter les websockets côté frontend, la librairie de Django Instant fournit des facilités :

import { reactive, ref } from"vue";
        import { useInstant, Message } from"djangoinstant";              

        const instant = useInstant();
        const stream = ref("");
        const lmState = reactive({
          isRunning: false,
          isStreaming: false,
        })

        asyncfunctioninitWs() {
          await instant.init(
            "http://localhost:8000",
            "ws://localhost:8427",
            true)
        }

        asyncfunctionconnectWs() {
          await instant.onReady;
          instant.onMessage((msg: Message) => {
            console.log(msg);
            if (msg.channelName == "$llm") {
              if (msg.msg == "#STARTSTREAM#") {
                lmState.isStreaming = true
              } elseif (msg.msg == "#ENDSTREAM#") {
                lmState.isStreaming = false;
                lmState.isRunning = false;
              } else {
                stream.value = stream.value + msg.msg
              }
            }
          });
          await instant.connect();
        }
      

Nous redirigeons ici les messages websockets reçus dans le channel $llm vers une variable réactive stream qui sera utilisable directement dans l'ui et mise à jour automatiquement.

Le endpoint streaming

Tous les éléments nécessaires étant en place, il ne nous reste plus qu'à les assembler dans un endpoint dédié. Celui-ci sera chargé de lancer la task à partir d'un prompt communiqué par le frontend. Le résultat de l'inférence sera alors produit par la task et transmis au frontend via websockets.

from myapp.schemas import InferContract
        from myapp.tasks import infer
        
        @router.post("/inferstream",
            response={ 200: None },
        )definferstream(request: HttpRequest, data: InferContract):
            prompt: str = data.dict()["prompt"]
            print("Calling LLama.cpp infer task with,", prompt)
            infer(prompt)
            return200, None

Noter que le endpoint se contente de lancer la task et retourne immédiatement une réponse, préservant ainsi la fluidité du serveur http. La task sera lancée par le process Huey et traitée à part. Dans ce scénario, le frontend poste son prompt de la même manière que précedemment et reçoit un status code 200 instantanément. Il écoute ensuite le channel websockets pour obtenir la réponse en streaming du language model.

Lancement

Pour lancer le tout, nous avons besoin de trois process indépendants: un pour le serveur http Django normal, un pour le serveur websockets et un pour la task queue. Nous allons les lancer dans trois terminaux différents. Lancer d'abord le serveur http normalement, puis le serveur websockets :

cd centrifugo
        ./centrifugo
      

Et enfin la task queue :


        python manage.py run_huey
      

Conclusion

Mettre en place une configuration pour faire tourner une IA locale de manière réaliste dans Django n'est pas trivial et implique des fonctionnalités spécifiques comme les websockets et une task queue. Cependant cela ouvre la voie au développement d'applications basées sur l'IA dans Django.

Cet article présente seulement une introduction et des solutions pour mettre en place une IA locale dans un environnement Django classique. Une fois les bases maitrisées, il apparait possible de développer des applications plus complexes comme des chatbots basés sur des documents spécifiques ou des agents chargés de tâches particulières. Les possibilités semblent nombreuses et la technologie évolue vite. Dans un prochain article nous introduirons des interfaces plus haut niveau pour travailler avec les language models, en présentant Langchain.

Note: le code présenté dans cet article est disponible dans le repository django-local-ai.

Articles associés

Django et IA locale : connecter Langchain