Les LLMs sont devenus des outils de travail quotidiens dans le monde de la recherche. Résumer des corpus, générer du code, structurer des idées, ça marche, c’est utile, les chercheurs s’en emparent naturellement.
La question qu’on a posée à l’UPPA, c’est : est-ce qu’on peut garder ces usages tout en maîtrisant où tournent les modèles et qui a accès aux données ?
La réponse, c’est EVARuntime. Et ce qui m’intéresse dans ce billet, ce n’est pas seulement ce qu’on a construit, mais comment ça a grandi : d’un seul GPU avec un subprocess qu’on tue quand il ne sert pas, jusqu’à une plateforme multi-modèles, multi-utilisateurs et multi-nœuds , sans jamais jeter l’architecture de départ.
Le contexte universitaire
L’usage des LLMs pour la recherche est légitime et efficace. Mais les APIs cloud soulèvent des questions concrètes que les équipes techniques doivent adresser :
- Les données transitent par des serveurs américains, soumis au Cloud Act et au FISA 702
- Il n’y a pas de garantie sur la rétention ou l’utilisation des données pour l’entraînement
- Le coût peut rapidement devenir significatif : un labo à 50M de tokens/mois sur GPT-4o, c’est ~750$ mensuels. Pour une université entière, on parle de dizaines de milliers d’euros par an
Ce n’est pas une critique de l’usage des APIs , c’est une incitation à regarder ce qu’il est possible de faire soi-même, avec les ressources qu’on a déjà.
“Alors on déploie vLLM sur Kubernetes, non ?”
C’est la réponse réflexe d’un sysadmin. Et c’est exactement là où on se plante.
Les solutions d’inférence LLM existantes (vLLM, TGI, Triton) sont conçues pour le data center. Elles partent du principe que le GPU est dédié à l’inférence, 24h/24, 7j/7, avec des dizaines de requêtes en parallèle en continu.
Le contexte universitaire, c’est l’inverse exact :
| Réalité universitaire | Conséquence |
|---|---|
| ~100 utilisateurs, trafic intermittent | Le GPU est inactif la grande majorité du temps |
| GPU partagé entre inférence et entraînement | Le moteur doit libérer la VRAM quand il ne sert pas |
| Budget serré, pas d’équipe DevOps dédiée | La solution doit être simple à opérer |
| Pas de cluster Kubernetes | On démarre avec un seul serveur, un seul GPU |
Et c’est là que vLLM devient un problème. vLLM pré-alloue 90% de la VRAM au démarrage et la conserve indéfiniment, même sans la moindre requête. Sur une L40S 48 GB, ça veut dire ~43 GB monopolisés en permanence. Impossible de lancer un job d’entraînement à côté. Impossible de partager le GPU.
J’ai creusé la question. Les issues GitHub de vLLM sont pleines de demandes de libération dynamique de VRAM (#1908, #6544, #23793, #15287). Toutes marquées stale ou non implémentées. C’est un choix de conception : vLLM est fait pour le throughput, pas pour l’efficacité énergétique.
On ne va pas déployer une artillerie lourde pour servir 100 chercheurs qui font 20 requêtes par jour.
L’idée fondatrice : tuer le processus
EVARuntime est une gateway d’inférence LLM souveraine. Le point de départ est brutalement simple :
La SEULE façon de libérer 100% de la VRAM d’un GPU, c’est de tuer le processus qui la détient. Pas de mode sleep, pas de libération partielle, pas de négociation avec le runtime CUDA. On tue. Point.
Ce n’est pas une posture : l’option --sleep-idle-seconds de llama-server laisse un contexte CUDA actif (~600 MB), donc même “endormi” le processus continue de tenir le GPU. On gère donc llama-server comme un sous-processus asyncio et on le tue (SIGTERM → SIGKILL) après la fenêtre d’inactivité. C’est le commentaire en tête de notre server_manager.py, et c’est la décision architecturale la plus importante du projet.
Concrètement : llama-server (le moteur d’inférence de llama.cpp) tourne comme un sous-processus de notre gateway FastAPI. Quand personne ne l’utilise pendant 5 minutes, on le tue. Le GPU passe de ~350W à ~30W, la VRAM est libérée à 100%, et un job d’entraînement peut prendre le relais.
Quand quelqu’un envoie une requête, le modèle se recharge en 10 à 20 secondes. Pendant ce temps, les requêtes sont mises en attente grâce à un asyncio.Event, et aucune n’est perdue.
L’architecture : un proxy intelligent devant un subprocess
L’architecture de base tient sur une serviette de restaurant :
- Nginx gère le TLS, le filtrage IP du campus, et le no-buffering pour le streaming SSE
- FastAPI authentifie les requêtes, applique le rate limiting, et gère le cycle de vie des modèles
- llama-server fait l’inférence, lancé comme sous-processus asyncio
- SQLite WAL stocke les utilisateurs, les clés API (hashées SHA-256), et les logs d’usage
Pas de Redis. Pas de RabbitMQ. Pas de Kubernetes. Pas de Docker. Un venv Python, un service systemd, un reverse proxy nginx. C’est tout.
Cette simplicité n’est pas une limite, c’est un point de départ qu’on peut faire évoluer. Moins de composants, moins de points de défaillance, moins de maintenance , et une base assez propre pour y greffer, sans tout casser, du multi-modèles, une file d’admission, une gateway étudiante et même un mode cluster. La suite du billet, c’est précisément cette trajectoire.
La machine à états qui fait que ça marche
Le cœur du système, c’est une machine à états dans ServerManager. Quatre états, des transitions verrouillées par un asyncio.Lock, et un pattern qui résout élégamment le problème des requêtes concurrentes pendant le chargement :
UNLOADED -> LOADING -> READY -> UNLOADING -> UNLOADED
Le cas critique : 10 requêtes arrivent en même temps alors que le modèle est déchargé. Sans précaution, on lancerait 10 processus llama-server.
Avec notre design, la première requête déclenche le chargement. Les 9 suivantes détectent l’état LOADING et attendent le même Event. Quand le modèle est prêt, l’Event est set, et les 10 requêtes sont traitées. Zéro requête perdue, un seul processus lancé.
C’est du pur asyncio, sans thread, sans multiprocessing, sans Redis. Et ça suffit.
Multi-modèles et gestion VRAM automatique
La gateway ne se limite pas à un seul modèle. Le ModelManager gère un pool de ServerManager, chacun pilotant un sous-processus llama-server sur un port dédié. Le budget VRAM est enforcé automatiquement :
budget_net = total_vram_gb - vram_overhead_gb - (total_vram_gb * vram_safety_margin)
# Sur L40S : 48.0 - 2.0 - (48.0 * 0.05) = 43.6 GB
# Avant chaque chargement :
if used_vram + model.vram_gb > budget_net:
evict_lru() # on décharge le modèle le moins récemment utilisé (et non épinglé)
Concrètement : si un chercheur demande Llama-3.3-70B pendant qu’un autre modèle est chargé mais idle, le système décharge automatiquement le modèle le moins récemment utilisé pour faire de la place. C’est de l’éviction LRU, comme un cache CPU, mais pour la VRAM , avec une garantie : un modèle qui traite une requête active n’est jamais évincé (pattern pin()/unpin()).
Et si le GPU n’est pas saturé, plusieurs modèles cohabitent. Le registre actuel à l’UPPA en compte plusieurs en parallèle :
| Modèle | VRAM | Particularité |
|---|---|---|
llama-3.3-70b-instruct (Q4_K_M) |
~42 GB | Modèle principal, tool calls |
minimax-m2.7 (IQ4_XS) |
~32 GB | MoE hybride, experts FFN sur CPU (--cpu-moe) |
gemma-4-26b-a4b |
~27 GB | MoE, contexte réduit pour cohabiter |
qwen3.5-9b-q5_k_m |
~7 GB | 27B total / 9B actifs, experts sur CPU |
Le truc qui rend cette cohabitation possible, c’est --cpu-moe : pour les modèles Mixture-of-Experts, on déporte les couches d’experts (FFN) en RAM CPU et on garde l’attention sur le GPU. Un Qwen3.5 27B qui prendrait 18-28 GB en VRAM tient ainsi en ~7 GB , assez pour le faire vivre à côté d’un gros modèle.
Le registre des modèles est un simple fichier YAML :
models:
- id: "llama-3.3-70b-instruct"
path: "/models/Llama-3.3-70B-Instruct-Q4_K_M.gguf"
description: "Llama 3.3 70B -- usage général"
vram_gb: 42.0
enabled: true
capabilities: [text_generation, tool_calls, streaming]
llama_params:
n_gpu_layers: 999
ctx_size: 32768
parallel: 4
flash_attn: true
cache_type_k: "q8_0"
cache_type_v: "q8_0"
Ajouter un modèle, c’est ajouter une entrée dans ce fichier. Le modifier à chaud (activer --cpu-moe, changer le contexte, l’activer/désactiver), c’est un PATCH /admin/models/{id} qui réécrit le YAML de façon atomique et relance le subprocess au prochain appel. C’est pas Kubernetes, c’est un fichier YAML et un process kill.
La file d’admission VRAM : refuser proprement plutôt que de planter
Le multi-modèles pose un problème de saturation : que se passe-t-il quand toute la VRAM est occupée par des requêtes actives et qu’un nouveau modèle doit se charger ? La réponse naïve, c’est un 503 immédiat. La réponse polie, c’est une file d’admission.
Quand la capacité (VRAM ou ports libres) manque, la requête ne part pas en erreur tout de suite : elle prend un ticket dans une deque FIFO et attend sur une asyncio.Condition. Dès qu’une requête active se termine (unpin()), la file est réveillée et le premier en attente retente sa chance. Tout est borné :
| Paramètre | Défaut | Rôle |
|---|---|---|
CAPACITY_QUEUE_TIMEOUT_SECONDS |
120 s | Délai max d’un waiter avant abandon |
CAPACITY_QUEUE_MAX_WAITERS |
100 | Taille max de la file (anti-engorgement) |
CAPACITY_QUEUE_RETRY_AFTER_SECONDS |
10 | Valeur du header Retry-After renvoyé |
Si la file est pleine ou que le timeout est atteint, on renvoie un 503 avec un Retry-After: 10 , un client OpenAI standard sait alors réessayer poliment. Et un endpoint GET /v1/capacity permet de connaître l’état de la file (idle / waiting / full) et le nombre de waiters avant même d’envoyer une requête.
L’idée : sous charge, on dégrade en douceur (attente bornée + retry guidé) au lieu de jeter brutalement les requêtes.
API OpenAI-compatible : zéro migration
Le deal-breaker pour l’adoption, c’est toujours la migration. Si vos chercheurs doivent changer leur code, personne ne le fera.
EVARuntime expose une API 100% compatible OpenAI. Vos scripts existants, vos notebooks Jupyter, vos intégrations LangChain, LiteLLM, Vercel AI SDK… tout fonctionne. Il suffit de changer deux lignes :
from openai import OpenAI
client = OpenAI(
base_url="https://llm.univ-pau.fr/v1", # <- votre serveur
api_key="llmgw-votre_cle_api", # <- votre clé
)
response = client.chat.completions.create(
model="llama-3.3-70b-instruct",
messages=[{"role": "user", "content": "Explique le théorème de Bayes."}],
stream=True,
)
for chunk in response:
print(chunk.choices[0].delta.content or "", end="", flush=True)
Le streaming SSE fonctionne token par token. Les tool calls sont supportés. Le proxy est transparent : tous les paramètres de sampling avancés de llama.cpp (mirostat, dry_multiplier, repeat_penalty, top_k, min_p…) passent directement au backend sans configuration. Et pour les modèles multimodaux, il suffit de déclarer un mmproj_path (le projecteur CLIP) dans le YAML : les images passent dans le format image_url standard d’OpenAI, relayées telles quelles à llama-server.
C’est un drop-in replacement. Changez l’URL, changez la clé, c’est fini.
La gateway étudiante : ouvrir aux étudiants sans ouvrir le GPU
Servir 100 chercheurs de confiance, c’est une chose. Ouvrir l’inférence à des milliers d’étudiants sur un VLAN moins maîtrisé, c’en est une autre. Plutôt que d’assouplir la gateway admin, on a construit une frontière de sécurité dédiée : la gateway étudiante (gateway-student/), un edge proxy durci qui ne touche jamais directement au GPU.
Ce que cette gateway garantit, par construction :
- Auth séparée : clés
llmstu-*, base SQLite distincte de la gateway admin - Normalisation allowlist-first (
policy.py) : seuls les champs explicitement autorisés sont relayés, les paramètres de sampling sont clampés dans des bornes sûres, la taille des prompts et des définitions de tools est plafonnée. Tout le reste est jeté avant de partir vers l’upstream. - Rate limiting en couches, du plus court au plus long :
| Couche | Fenêtre | Limite |
|---|---|---|
| Burst | 10 s | 3 req (global) |
| RPM | 60 s glissantes | 10 req / étudiant |
| Tokens/heure | 60 min | 20 000 / étudiant |
| Tokens/jour | minuit UTC | 100 000 / étudiant |
| Concurrence | instantané | 1 stream / étudiant |
- Audit RGPD-aware : logs JSON, aucun contenu de prompt, IP pseudonymisée par HMAC-SHA256 avec sel quotidien
- Pas de SSRF : l’URL upstream est fixée en dur, l’authentification vers la gateway admin se fait en mTLS
- Aucune route
/admin/*, aucun contrôle GPU exposés
C’est la même philosophie que le reste du projet : un composant simple, à responsabilité unique, qui s’ajoute sans complexifier le cœur.
Sécurité de la gateway admin : pas de compromis
Côté gateway admin, on ne déploie pas un LLM accessible depuis le réseau sans un minimum de sécurité , sans pour autant tomber dans la paranoïa :
- Clés API hashées SHA-256. La clé brute n’est jamais stockée. On garde un préfixe de 14 caractères pour l’identification humaine, et seulement le hash. Comme un mot de passe.
- Rate limiting par fenêtre glissante. Pas de Redis, juste une
dequede timestamps par utilisateur : sliding window log, ~30 lignes de Python, largement suffisant à l’échelle de 100 utilisateurs. - Double authentification admin. Les routes
/adminsont protégées par un secret admin ET par le filtrage IP de nginx (réseau campus uniquement). Même si le secret fuite, l’API d’admin n’est pas joignable depuis internet. - Hardening systemd. Le service tourne avec
NoNewPrivileges,PrivateTmp,ProtectSystem, sous un utilisateur dédié sans shell. llama-server n’écoute que sur127.0.0.1, il n’est jamais exposé directement. - Validation stricte du registre. Les chemins GGUF (et les projecteurs multimodaux) sont contraints à
ALLOWED_MODEL_DIRS, extension.ggufobligatoire, pas de traversée de répertoire, YAML chargé en safe-load.
Du mono-GPU au cluster multi-nœuds
L’architecture de départ tient sur un GPU. Mais rien dans le design n’oblige à y rester , et c’est précisément l’intérêt d’avoir gardé le cœur simple. EVARuntime tourne aujourd’hui aussi en mode cluster, où la gateway orchestre plusieurs nœuds GPU.
Le principe :
- Chaque nœud GPU fait tourner un
node_agent: un petit service FastAPI (~250 lignes) qui ne sait faire qu’une chose , charger/décharger unllama-serverlocal sur ordre de l’orchestrateur. - Deux plans séparés. Le control plane (HTTPS + Bearer
AGENT_SECRET) porte les ordres de load/unload et les heartbeats , faible volume. Le data plane (HTTP direct vers le llama-server du nœud) porte le streaming de tokens , fort volume, sans saut intermédiaire inutile. - Scheduler déterministe (
scheduler.py) : placement best-fit quand il y a de la capacité, simulation d’éviction LRU quand il n’y en a pas, et possibilité d’épingler un modèle sur un nœud précis. C’est une fonction pure, donc testable et prévisible. - Heartbeat & dégradation gracieuse : l’orchestrateur sonde chaque nœud toutes les 10 s ; après 3 échecs consécutifs, le nœud passe
OFFLINEet se rétablit tout seul au retour. Pas de crash en cascade.
Le point important : le mode cluster reprend exactement la même philosophie que le mode local (kill-the-process, éviction LRU, pin/unpin) et expose le même comportement public, derrière la même interface model_manager. Passer de 1 à N GPU, c’est un changement de configuration, pas une réécriture. C’est aussi ce qui ouvre la porte à des cibles matérielles comme le DGX Spark (GB10, mémoire CPU/GPU unifiée via NVLink-C2C).
L’efficacité énergétique n’est pas un détail
On en parle peu, mais c’est un argument massif. Voici les mesures réelles sur notre L40S :
| État | Puissance GPU | Description |
|---|---|---|
| GPU libre (modèle déchargé) | ~20-30W | Processus tué, VRAM 100% libre |
| Modèle chargé, inactif | ~100-150W | Modèle en VRAM, aucune requête |
| Inférence active (70B Q4_K_M) | ~300-350W | Génération de tokens |
Comparons à profil d’usage identique , disons 4h d’inférence active par jour, le reste en idle. Avec un mode “always-on” classique (vLLM, TGI), le GPU reste à ~150W pendant les 20h creuses :
vLLM : 150W × 20h + 350W × 4h = 3 000 + 1 400 = 4 400 Wh/jour ≈ 1 600 kWh/an
EVARuntime: 30W × 20h + 350W × 4h = 600 + 1 400 = 2 000 Wh/jour ≈ 730 kWh/an
Soit ~55% de consommation GPU en moins, à charge utile identique. Et c’est conservateur : dans notre contexte, le GPU est probablement actif moins de 4h par jour. Surtout, pendant les heures creuses, ce GPU libéré peut servir à de l’entraînement , avec vLLM, il est monopolisé à ne rien faire.
Ce n’est pas une économie marginale, et elle se cumule à l’échelle d’un parc : chaque GPU rendu disponible la nuit, le week-end et pendant les vacances, c’est de la capacité de calcul récupérée gratuitement.
Les paramètres qui comptent
On ne déploie pas un LLM 70B sur un GPU 48 GB sans un peu de finesse. Voici les paramètres retenus pour la L40S, et pourquoi :
| Paramètre | Valeur | Pourquoi |
|---|---|---|
-ngl 999 |
Offload complet GPU | Tout en VRAM, zéro CPU inférence |
-c 32768 |
Contexte total | 4 slots × 8K tokens par utilisateur |
--parallel 4 |
Slots concurrents | Suffisant pour un trafic académique |
-b 4096 / -ub 512 |
Batch sizes | Optimisé pour le prefill sur Ada Lovelace |
-ctk q8_0 / -ctv q8_0 |
KV cache quantisé | -50% VRAM sur le cache, qualité quasi-identique |
-fa on |
Flash Attention | Exploite l’architecture Ada Lovelace (compute 8.9) |
| VRAM totale | ~42 GB | poids + KV cache, sur 48 GB dispo |
Chaque modèle porte ses propres llama_params dans le YAML , pas de config globale qui s’impose à tous. Un gros modèle dense et un petit MoE n’ont pas les mêmes besoins, et le registre le reflète.
Pourquoi llama.cpp et pas vLLM ?
On m’a posé la question. La réponse tient en un tableau :
| Critère | llama.cpp | vLLM |
|---|---|---|
| Libération VRAM 100% | Natif, fiable | Impossible nativement |
| Redémarrage d’un 70B | 10-20 secondes | 2-5 minutes |
| Support GGUF | Natif, performant | Expérimental, ~8x plus lent |
| Débit sous forte charge | Bon | 3-6x meilleur |
| Gestion subprocess | 1 processus | Multi-workers CUDA complexes |
| Offload MoE sur CPU | --cpu-moe natif |
Limité |
vLLM est meilleur sous forte concurrence soutenue. Mais “forte concurrence soutenue”, c’est des dizaines de requêtes en parallèle en continu , pas notre cas. Avec un trafic académique intermittent, l’avantage de vLLM n’entre jamais en jeu, alors que sa pré-allocation VRAM nous coûterait notre principal atout : un GPU qu’on peut rendre.
Le jour où un GPU sera dédié exclusivement à l’inférence, avec 20-50 utilisateurs simultanés en continu, le backend pourra être reconsidéré pour ce nœud-là. L’architecture multi-nœuds le permet justement sans tout réécrire.
Ce que ça donne en vrai
EVARuntime tourne en production à l’Université de Pau et des Pays de l’Adour (UPPA). Les chiffres réels :
- ~100 utilisateurs côté recherche (doctorants, chercheurs, personnels), plus l’accès étudiant via la gateway dédiée
- Plusieurs modèles au registre : Llama 3.3 70B (Q4_K_M) comme modèle principal, plus des MoE (MiniMax, Gemma, Qwen3.5) qui cohabitent grâce à
--cpu-moe - API OpenAI-compatible : les chercheurs utilisent le client Python OpenAI standard, sans migration
- ~30W au repos, ~350W en inférence active
- 10-20 secondes de chargement à froid, zéro requête perdue pendant le chargement
- File d’admission VRAM : sous saturation, les requêtes attendent puis sont servies ou refusées proprement avec
Retry-After - Zéro donnée qui quitte l’infrastructure de l’UPPA
Le tout sur un seul serveur avec une NVIDIA L40S 48 GB , et une architecture prête à s’étendre à plusieurs nœuds.
Conclusion : commencer petit, grandir sans réécrire
Ce qu’on a appris en construisant EVARuntime, c’est que “déployer un LLM” n’oblige pas à choisir, au jour 1, entre un script bricolé et un cluster Kubernetes à six chiffres.
On a commencé avec la chose la plus simple qui marche : un GPU, une gateway FastAPI, et un subprocess qu’on tue quand il ne sert plus. Puis cette base a accueilli, une couche à la fois et sans réécriture du cœur : le multi-modèles avec éviction LRU, une file d’admission VRAM, une gateway étudiante durcie, et un mode cluster multi-nœuds.
La leçon n’est pas “il ne faut pas de cluster” , c’est qu’une architecture simple et honnête peut grandir avec le besoin, à condition de ne pas hypothéquer son point de départ. Pour un contexte comme l’UPPA, partir d’un seul GPU n’était pas un compromis : c’était le bon premier pas.
Si les ressources GPU sont déjà là, il vaut la peine de se poser la question : qu’est-ce qu’on peut faire avec, dès maintenant , et jusqu’où ça peut aller ensuite ?