Comment héberger un LLM (partie 1)

Il y a un an maintenant, nous avons eu la chance d’accueillir au sein d’Ippon, un serveur équipé d’un GPU (Graphics Processing Unit).

Le but derrière cela était de pouvoir appliquer ce que nous faisions tous déjà chacun à l’époque à titre personnel sur nos propres machines : héberger des modèles de LLM en local, mais cette fois-ci à plus grande échelle, pour le contexte professionnel.

Suite à cela, l’équipe a mis en pratique et appris de nombreuses choses concernant les aspects techniques des grands modèles de langage (Large Language Models, LLM).

Cette suite d’articles a pour but de vulgariser les principes théoriques des réseaux de neurones artificiels mais également de faire un point sur l’aspect pratique : l'hébergement et la mise à disposition d’un modèle de LLM, la présentation de l'écosystème qui l’entoure, ie. les différents software et librairies, mais également des composants hardwares et leurs interactions dans ce contexte.

Ces articles n’ont pas été écrits par un expert en machine learning, mais par un ingénieur en systèmes informatiques qui a cherché à comprendre les principes du machine learning, des réseaux de neurones artificiels, ainsi que l’écosystème qui les accompagne, notamment autour des LLM. Il s’agit donc avant tout d’un retour d’expérience et d’un partage de connaissances.

Pour pouvoir héberger votre propre LLM, nous allons devoir comprendre un minimum les mathématiques derrière tout ça, mais j’ai deux bonnes nouvelles : 

  • Premièrement, pour simplifier, nous n’aborderons pas tout ce qui concerne l’entraînement du modèle : dérivées partielles, rétropropagation du gradient (“backpropagation”) ou encore fonctions de coûts (“cost functions”) ne seront pas de la partie.
  • Deuxièmement, au final, étonnamment, afin de comprendre l’idée générale du fonctionnement des LLM, pas besoin d’un niveau expert. Ce sont essentiellement des vecteurs et des matrices (bon peut-être un peu plus...).

1. Qu’est-ce qu’un LLM ?

Partons d’un exemple : Quand on télécharge un modèle sur Hugging Face par exemple : “Ministral-3-3B-Instruct-2512-BF16” (on reviendra en partie sur le nom plus tard) on peut voir déjà que le repo pèse 15 GB et est composé de plein de fichiers, certains plus volumineux que d'autres. Ceux qui nous intéressent sont “model-0000X-of-0000X.safetensors”, ils composent quasiment la totalité du ‘poids’ du répertoire.

Interface d'un dépôt de modèle sur la plateforme Hugging Face

Et si on clique sur l’un d’entre eux on a le détail de ce qui le compose:

Détail du contenu et de la structure d'un fichier Safetensors

Layer, MLP, weight, attention… Quels sont tous ces mots étranges ?

1.1 Aspect mathématique

Il faut comprendre qu'un réseau de neurones est une énorme fonction, il reçoit des valeurs en entrée et renvoie des valeurs en sortie. Au milieu se placent les connexions neuronales, où chaque connexion est configurée par un (des) paramètre(s).

Schéma de l'architecture d'un réseau neuronal simple

La différence avec une fonction classique, c’est que dans le cas d’un réseau de neurones dit “profond” (deep learning), le calcul de cette réponse repose aujourd’hui sur des dizaines de milliards de paramètres. Ces paramètres sont majoritairement des “poids”(weight) et des “biais” (bias) qui sont disponibles dans ces gros fichiers vus juste avant.

Chaque réseau est divisé en couches qui contiennent un certain nombre de ‘neurones’. Le neurone de la couche précédente, qui est en fait un nombre qu’on appelle activation, va influencer les neurones de la couche suivante en fonction de l’importance qu’il lui donnera (grâce au weight, on y revient encore et toujours).

Pour illustrer cela, nous allons prendre l'exemple de la reconnaissance digitale en Machine Learning, qui est un cas d'école, une sorte d’équivalent du "Hello World" dans le monde de la programmation.

Dans ce cas, le but de notre modèle est de reconnaître des chiffres écrits à la main au sein d’une image. On envoie donc au modèle en entrée des pixels et lui nous indique en retour s'il s’agit d’un 3 ou plutôt d’un 8, magique.

De nombreuses images de chiffres manuscrits disponibles en ligne, notamment celles du célèbre jeu de données MNIST, sont représentées sous la forme d'images de 28 × 28 pixels. Ce jeu de données, largement utilisé pour l'entraînement des réseaux de neurones, contient plusieurs dizaines de milliers d'images de cette taille. Chaque image est donc décrite par 28 × 28 = 784 pixels. Dans notre réseau de neurones, chacun de ces pixels est associé à un neurone d'entrée, ce qui conduit à une couche d'entrée composée de 784 neurones. La sortie contient l’ensemble des chiffres possibles détectables, de 0 à 9, soit 10 sorties.

rchitecture d'un réseau de neurones avec 784 entrées, une couche cachée de 15 neurones et 10 sorties pour la reconnaissance d'images
Réseau de neurones pour la reconnaissance d’image, avec 784 neurones d’entrées et 10 sorties. La couche intermédiaire comprend 15 neurones.

La première couche représente les pixels de l’image du chiffre manuscrit envoyée au modèle. Chaque neurone correspond à un pixel et le nombre qu’il contient est la valeur en niveaux de gris, 0 représente le blanc et 1 le noir, avec toutes les nuances possibles entre les deux couleurs.

Comme vous pouvez le voir, chaque neurone d’une couche est connecté à la couche suivante. Il reçoit l'activation de chaque neurone (a) auquel il attribue une importance appelée poids (weight ou w). Ce qui donne pour lien : w₁a₁ (le poids est multiplié par l’activation du neurone)

En additionnant chaque apport de tous les neurones de la couche précédente on obtient l’activation du neurone de destination.

regression lineaire

On illustre cela avec un vecteur qui est composé de l’activation de tous les neurones de la couche précédente et d’une matrice qui est composée de tous les poids que les neurones de la couche suivante attribuent à ces activations. Le produit donne l’activation des neurones de la couche suivante :

Schéma du produit matrice-vecteur pour les projections de couches dans un LLM

Bon en réalité, il nous reste deux étapes avant d’avoir l’activation du prochain neurone.

  • Premièrement, on ajoute toujours à la fin du calcul, un biais, noté b. Cela permet de pouvoir arbitrairement ajuster l’activation du neurone. Un biais négatif rend l’activation plus difficile, à l'inverse d’un biais positif qui facilite son activation.
regression lineaire avec biais
  • Deuxièmement, on applique une fonction d’activation au résultat obtenu. Cette fonction transforme la sortie du neurone en une valeur qui pourra être utilisée par les couches suivantes. Il en existe une centaine, mais en pratique une dizaine seulement sont utilisées. 
    Historiquement, la fonction sigmoïde : A(x)  =1/(1+e−x)
    était largement utilisée, notamment parce qu'elle limite la sortie entre 0 et 1. Aujourd'hui, la fonction d'activation la plus répandue dans les réseaux de
    neurones profonds est l'Unité Linéaire Rectifiée (ReLU) :
Formule mathématique de la fonction d'activation ReLU f(x) = max(0, x)

Voilà, l’activation du prochain neurone a été calculée: au final, une simple régression linéaire suivie d’une fonction d’activation, rien de sorcier! Dans les derniers modèles, SwiGLU est souvent utilisé, l'idée reste la même donc on va rester sur ReLU pour faciliter l’explication.

1.2 Pourquoi ça pose problème

Je voudrais qu’on s’attarde sur ce point deux minutes, si on reprend l'exemple précédent de notre réseau de neurones de détection de chiffres manuscrit, qui comprend 784 neurones d'entrée, eux-mêmes connectés à 15 neurones dans la couche cachée (“hidden layer”).

  1. L’interconnexion entre la couche d’entrée (784 neurones) et la couche cachée (15 neurones) : Il existe une valeur de poids pour chaque connexion neuronale entre l’entrée et la couche cachée:  784 x 15  = 11 760 poids. De plus, en ajoutant une valeur de biais à la fin de chaque calcul, soit 15 valeurs de biais, nous avons 11 760 + 15  =  11 775 paramètres au total.
  2. On ajoute de la même manière le branchement entre les 15 neurones de la couche cachée et les 10 neurones de sortie, nous avons : 15 (poids) × 10 + 10 (biais) = 160 paramètres pour cette partie la

Ainsi, pour connecter ces 3 couches (Entrée, Couche cachée, Sortie), il est nécessaire d’avoir : 11 775 + 160 = 11 925 paramètres

Pour un si petit modèle qui ne serait pas capable de résoudre grand chose, nous avons déjà un nombre conséquent de paramètres et cela permet de comprendre à quel point ce nombre peut grandir de façon exponentielle.

Si on reprend le nom du modèle d'exemple : “Ministral-3-3B-Instruct-2512-BF16” le “3B” c’est le nombre de poids et biais que possède ce modèle. 3 Milliards de paramètres …

La partie du réseau présentée précédemment est appelée “Multilayer perceptron” (MLP), les poids et biais sont comme des milliards de petits boutons qui ont été ajustés petit à petit pendant l'entraînement pour être parfaitement configurés. C’est dans ces poids que résident les  “connaissances” du modèle, qu’il sait que Paris est la capitale de la France, que 2 + 2 = 4 …

Dans le schéma plus haut, la dernière couche “output layer” est le résultat. Elle contient 10 neurones, un pour chaque chiffre possible. L’activation indique la confiance du modèle “Le système pense que les pixels reçus représentent le chiffre…”

1.3 Gestion de la sortie

Mais par défaut, chaque neurone possède un nombre. Ces nombres peuvent être très proches les uns des autres, comment le modèle sait quoi faire de tout ça? Pour transformer ces chiffres d’activation bruts en probabilités on applique Softmax qui va ramener l’ensemble des valeurs à une distribution de probabilité entre 0 et 1.

Formule mathématique de la fonction d'activation Softmax pour réseaux de neurones

C’est exactement le même principe qu’un LLM qui prédit le prochain mot de la phrase. A la différence que l'on attend un résultat unique et identique à chaque essai. Si on souhaite, comme dans le cas d’un “chatbot”, rajouter un peu d'imprévu (un mot de vocabulaire moins utilisé par exemple) on peut rajouter un paramètre, c’est la fameuse “Température

Formule mathématique de la fonction d'activation Softmax pour réseaux de neurones avec la temperature

Une température basse rend le modèle plus déterministe, une température haute lui donne de la "folie" en rendant les choix moins probables plus accessibles.

Et c’est comme ca que cette image, composé de pixels qui vont être transformée en 784 nombres indiquant si le pixel est plus ou moins noir, va être reconnu par notre modèle en un chiffre.

Vectorisation d'une image de chiffre en 784 pixels pour le modèle MNIST

Au final, comme nous l’avons vu, tout modèle de réseau de neurones profond n’est au final qu’un ensemble de paramètres (poids et biais) qui sont des nombres flottants. Que ce soit un réseau de neurones pour de la reconnaissance d’images ou un LLM (type Mistral), lorsque celui-ci est hébergé en local sur nos GPU, ce ne sont que ces paramètres flottants qui sont stockés dans le GPU, pas de risque de backdoor ou de fonction malveillante . 

Maintenant qu'on a une idée plus claire de ce qu'on charge dans notre serveur, il est temps de passer à la pratique et de voir comment lancer un modèle.

2. L’écosystème LLM.

2.1 Le moteur d'inférence

Faire en sorte que ces fichiers deviennent un LLM, capable de nous répondre n’est pas une tâche facile. Heureusement il existe des logiciels créés pour ça. Certains des plus connus aujourd’hui sont ollama et vLLM, ils font partie de la famille des moteurs d'inférence (inference engine). Mais qu'est-ce que fait un moteur d'inférence ? Il fait tout ce dont tu as besoin c’est-à-dire :

    • Charge les poids dans la mémoire
    • Transforme le texte en tokens (le tokenizer) puis en vecteurs (embedding)
    • Fait les calculs matriciels de chaque couche du transformer, pour chaque token (attention + feed-forward).
    • Choisit le prochain token à partir des probabilités, puis le convertit en texte lisible
    • Et expose une API pour pouvoir interagir avec le modèle. 

Quand je dis “il fait” c’est pas totalement vrai, les moteurs d'inférences s'appuient sur des librairies bien connues pour certaines de ces tâches.
Pour vLLM, la transformation de tokens est faite grâce à Tokenizers (bibliothèque Hugging Face) et surtout pour la partie ‘calcul’ c’est souvent basé sur PyTorch qui exécute les multiplications matricielles du transformer.
Mais grâce au moteur d'inférence la plupart de ces tâches sont transparentes

Aujourd’hui, beaucoup de ces logiciels proposent une image docker, et cela nous arrange bien car on compte bien utiliser la puissance de Kubernetes pour manager l'inférence.
Ok c'est l'heure de rendre notre modèle disponible, en créant un pod ou un deployment :

   containers:
     - name: mistral
      image: vllm/vllm-openai:nightly
      command: ["/bin/sh", "-c"]
      args: |
       vllm serve mistralai/Ministral-3-3B-Instruct-2512-BF16 \
         --max-model-len 16384 \
         --tensor-parallel-size 1 \
         --gpu-memory-utilization 0.90 \
         --served_model_name ministral \
         --tokenizer-mode mistral \
         --config_format mistral \
         --load_format mistral \
         --enable-auto-tool-choice \
         --tool-call-parser mistral
      resources:
        requests:
          cpu: "1"
          memory: 64G
          nvidia.com/gpu: "1"
      env:
      - name: HF_TOKEN
        valueFrom:
          secretKeyRef:
            name: hf-token-secret
            key: token

Attention le runtime du container peut ne pas contenir par défaut le driver, il faut parfois spécifié à kubernetes que le pod en question a besoin de ces drivers, grâce à l’option "runtimeClassName: nvidia”

Plusieurs arguments méritent notre attention. Les paramètres --tokenizer-mode, --config_format et --load_format spécifient à vLLM que les poids et la configuration du modèle doivent être chargés et lus selon les principes indiqués par mistral. Côté tokenizer, on passe par la lib officielle (mistral-common). Ça garantit qu'il découpe le texte pile comme Mistral l'a prévu.

Et il y a surtout deux éléments importants dans le yaml, premièrement il faut mettre à disposition un token Hugging Face pour que vLLM puisse “pull” le modèle. Et deuxièmement la requests “nvidia.com/gpu”.

2.2 GPU, driver et Kubernetes

Pour pouvoir utiliser un GPU, il faut installer le driver afin que son code soit chargé dans le kernel et que CUDA, qui s'appuie sur ce driver pour traduire les calculs en instructions exécutables sur le GPU, puisse fonctionner. Sans CUDA, PyTorch ne peut pas se servir du GPU.

Et pour pouvoir réclamer un GPU dans un cluster Kubernetes, il faut que Kubernetes soit au courant qu’il a cette ressource à sa disposition. Heureusement, un opérateur est capable de faire les deux pour les GPU Nvidia: GPU Operator. GPU operator a deux modes de fonctionnement, soit il installe le driver et le charge dans le kernel lui-même, soit le driver est déjà installé et il indique juste à kubernetes qu’il est disponible. Pour s’assurer que tous tes nodes aient une version identique du driver, on va garder la première option.

Installons notre opérateur grâce a Helm

helm repo add nvidia https://helm.ngc.nvidia.com/nvidia \
    && helm repo update
helm install gpu-operator nvidia/gpu-operator --wait --generate-name -n gpu-operator --create-namespace 
pod gpu operateur

On peut vérifier que le node a bien récupéré les bonnes annotations :

annotation nvidia sur kubernetes node
(y en a bien plus…)

Et s'assurer que le GPU est disponible dans le cluster en exécutant la commande nvidia “nvidia-smi” dans un des pods:

resultat d'une commande nvidia-smi

Parfait, on peut donc appliquer le manifeste vu plus haut.

vllm information

Pendant le chargement, on peut voir plusieurs informations importantes :

(EngineCore pid=259) [default_loader.py:430] Loading weights took 2.69 seconds
(EngineCore pid=259) [model_runner.py:302] Model loading took 7.26 GiB and 28.404294 seconds

Mais aussi

(EngineCore pid=259) [gpu_worker.py:544] Available KV cache memory: 20.77 GiB
(EngineCore pid=259) [kv_cache_utils.py:2154] GPU KV cache size: 209,456 tokens
(EngineCore pid=259) [kv_cache_utils.py:2155] Maximum concurrency for 16,384 tokens per request: 12.78x

Et surtout l’annonce que l’api est disponible avec la liste des endpoints :

information vllm

En branchant n'importe quel outil ou avec un simple curl (avec un peu de jq), on peut interroger l’api de vLLM. On demande la liste des modèles d’abord  :

curl http://ministral.ia.ippon-hosting.net/v1/models

{
  "id": "ministral",
  "model": "mistralai/Ministral-3-3B-Instruct-2512-BF16",
  "permissions": "modelperm-a8f72850e6341ec0"
}

Et puis la complétion, autrement dit, le chatbot:

curl -X POST http://ministral.ia.ippon-hosting.net/v1/chat/completions -H "Content-Type: application/json" -d '{"model": "ministral", "messages": [{"role": "user", "content": "Bonjour, peux-tu te présenter ?"}], "temperature": 0.0}'   

{
  "id": "chatcmpl-738b4b5c-fa90-4eac-b7e8-44057b116711",
  "model": "ministral",
  "message": "Bien sûr ! 😊\n\nJe suis **Mistral**, un modèle de mistralai conçu pour répondre à tes questions, t'aider dans tes projets, discuter de tout sujet, et même t'aider à apprendre ou créer des idées. Mon but ? T'aider de manière claire, utile et adaptée à tes besoins !\n\nSi tu veux, je peux aussi :\n- **Répondre à des questions** (science, histoire, tech, etc.)\n- **Écrire des textes** (essais, emails, poèmes, scripts…)\n- **Résoudre des problèmes** (maths, programmation, logique)\n- **Te proposer des idées** (récréatives, professionnelles)\n- **T’expliquer des concepts** simplement\n\nEt bien plus encore ! 🚀\n\n*À toi de jouer : quelle est ta question ou ton projet aujourd’hui ?*\n\n*(Petit détail : je suis un modèle d’IA, pas une personne, donc je n’ai pas de sentiments ou de conscience !)* 😄"
}

Dans notre pod on voit bien les deux hits.

Youhou tout fonctionne… mais attends une seconde.
“Available KV cache memory: 20.77 GiB , GPU KV cache size: 209,456 tokens”                                                                                                  
Qu’est-ce que ça veut dire ? C’est quoi KV ? Y a-t-il d'autres choses à mettre dans notre VRAM que les poids du modèle ? 

Pour y répondre, il va falloir ouvrir le capot de l'architecture “transformer” et regarder ce qui se passe vraiment à l'intérieur d’un modèle quand il génère du texte. Rendez-vous dans la partie 2 pour comprendre pourquoi cette ligne de log a autant d'importance.