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 ?

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 89% 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 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 simple qui change tout : tuer (kill) le processus

On a construit EVARuntime, une gateway d’inférence LLM souveraine. L’idée 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.

La SEULE façon de libérer 100% la VRAM GPU est de tuer le processus. L’option –sleep-idle-seconds de llama-server laisse un contexte CUDA actif (~600MB). 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. C’est la thèse de tout le projet. Et c’est la décision architecturale la plus importante qu’on ait prise.

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%. 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

graph TD A[Internet] -->|HTTPS| B[Nginx - TLS, SSE no-buffer, IP filtering] B --> C[FastAPI Gateway - port 8000] C --> D[Auth + Rate Limiting] D --> E[Model Manager] E -->|subprocess| F[llama-server - port 8081+] F --> G[NVIDIA L40S 48GB] E -->|idle 5 min| H[SIGTERM -> SIGKILL] H -->|GPU libre ~30W| G style H fill:#c0392b,color:#fff style G fill:#27ae60,color:#fff

L’architecture tient sur une serviette de restaurant :

  1. Nginx gère le TLS, le filtrage IP du campus, et le no-buffering pour le streaming SSE
  2. FastAPI authentifie les requêtes, applique le rate limiting, et gère le cycle de vie du modèle
  3. llama-server fait l’inférence, lancé comme sous-processus asyncio
  4. 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.

La simplicité de l’architecture est un atout majeur. Moins de composants, moins de points de défaillance, moins de maintenance. Et surtout, ça correspond à la réalité des infrastructures universitaires : un seul serveur, un seul GPU, une équipe technique réduite.

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 (modele) lancé.

C’est du pur asyncio, sans thread, sans multiprocessing, sans Redis. Et ça suffit.

API OpenAI-compatible : zéro migration

Le deal-breaker pour l’adoption, c’est toujours la migration. Si vos chercheurs doivent changer leur code, c’est compliqué. 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.

C’est un drop-in replacement. Changez l’URL, changez la clé, c’est fini.

Sécurité : pas de compromis

On ne déploie pas un LLM accessible depuis internet sans un minimum de sécurité. Voici ce qu’on a mis en place, sans pour autant tomber dans la paranoïa :

Clés API hashées SHA-256. La clé brute n’est jamais stockée. On génère un préfixe de 14 caractères pour l’identification humaine, et on ne garde que le hash. Comme un mot de passe.

Rate limiting par fenêtre glissante. Pas de Redis, juste une deque de timestamps par utilisateur. On purge les entrées hors de la fenêtre d'1 minute, on refuse si le compteur dépasse la limite. C’est l’algorithme sliding window log, implémentable en 30 lignes de Python. À l’échelle de 100 utilisateurs, c’est largement suffisant.

Double authentification admin. Les routes /admin sont 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 accessible depuis internet.

Hardening systemd. Le service tourne avec NoNewPrivileges, PrivateTmp, ProtectSystem, sous un utilisateur dédié sans shell. llama-server n’écoute que sur 127.0.0.1 – il n’est jamais exposé directement.

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

Un GPU qui tourne pour rien la nuit, le week-end, pendant les vacances… ça consomme. Avec un mode “always-on” classique (vLLM, TGI), votre GPU idle consomme ~150W en permanence. Sur un an :

150W x 24h x 365j = 1 314 kWh

Avec EVARuntime et son timeout de 5 minutes :

30W x 20h + 350W x 4h = 600 + 1400 = 2 000 Wh/jour
(en estimant 4h d'utilisation active par jour)

Soit ~730 kWh/an contre ~1 314 kWh. Et c’est une estimation conservatrice – en réalité, le GPU est probablement actif moins de 4h par jour dans notre contexte.

C’est pas une économie de bout de chandelle. C’est ~45% de consommation en moins sur le GPU. Et surtout, pendant les heures creuses, ce GPU peut servir à de l’entraînement. Avec vLLM, il est monopolisé à ne rien faire.

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é

Concrètement : si un chercheur demande Llama-3.3-70B pendant qu’un Qwen2.5-9B est chargé mais idle, le système décharge automatiquement le Qwen pour faire de la place. C’est de l’éviction LRU, comme un cache CPU, mais pour la VRAM.

Ou meme si jamais le modele est actif, mais que le GPU n’est pas saturé a 100%, on peut charger plusieurs modèles en parallèle. Par exemple, un Qwen2.5-9B (10 GB VRAM) peut coexister avec un Qwen2.5-3B (4 GB VRAM) et un Llama-3.3-13B Q4_K_M (8 GB VRAM) sur la même L40S, tant que le total ne dépasse pas le budget net de ~43.6 GB qu’on s’est fixé.

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 recharger, c’est un appel API. C’est pas Kubernetes, c’est un fichier YAML et un process kill.

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 qu’on a 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 x 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é identique
-fa on Flash Attention 2 Exploite l’architecture Ada Lovelace (compute 8.9)
VRAM totale ~40.5 GB 38 GB poids + 2.5 GB KV cache, sur 48 GB dispo

Il reste ~7.5 GB de marge. Assez pour un petit modèle en parallèle, ou pour absorber des pics de contexte.

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

vLLM est meilleur sous forte concurrence soutenue. Mais “forte concurrence soutenue”, c’est 50 requêtes en parallèle en continu. Pas notre cas. On a 4 slots pour 100 utilisateurs avec un trafic intermittent. L’avantage de vLLM n’entre jamais en jeu.

Le seul scénario où on migrerait vers vLLM : un GPU dédié exclusivement à l’inférence, avec 20-50 utilisateurs simultanés en continu. Ce jour-là, on réécrit le ServerManager. Pas avant.

Ce que ça donne en vrai

EVARuntime tourne en production à l’Université de Pau et des Pays de l’Adour (UPPA). Voici les chiffres réels :

  • ~100 utilisateurs (doctorants, chercheurs, personnels)
  • Llama 3.3 70B en quantification Q4_K_M
  • 4 slots parallèles, contexte 8K par slot
  • API OpenAI-compatible : les chercheurs utilisent le client Python OpenAI standard
  • ~30W au repos, ~350W en inférence active
  • 10-20 secondes de chargement à froid
  • Zéro requête perdue pendant le chargement
  • Zéro donnée qui quitte l’infrastructure de l’UPPA

Le tout sur un seul serveur avec une NVIDIA L40S 48 GB.

Conclusion : on n’a pas besoin d’un cluster

Ce qu’on a appris en construisant EVARuntime, c’est que “déployer un LLM” n’implique pas nécessairement un cluster Kubernetes, une équipe DevOps, ou un budget cloud à six chiffres.

Un seul GPU. Un script d’installation. Une API compatible OpenAI. Un subprocess qu’on tue quand on n’en a plus besoin.

Pour un contexte comme l’UPPA — 100 utilisateurs, trafic intermittent, GPU partagé — cette architecture est non seulement suffisante, elle est optimale. Et elle tourne en production pour le prouver.

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 ?