NOPE LinkedIn

Catégories:
Blog

Trois Agents, Un GPU : Multi-LoRA Dynamique avec vLLM

Trois Agents, Un GPU : Multi-LoRA Dynamique avec vLLM image

Rubrique: Blog Tag: LoRA Tag: Qwen2.5 Tag: vLLM Tag: Multi-LoRA Tag: GPU Tag: VRAM Tag: Fine-tuning Tag: HuggingFace

Les trois agents sont validés à 100%. La question devient : comment les servir simultanément sur un GPU de 12 Go déjà occupé par le coordinateur ?


Le problème du multi-agent sur GPU contraint

L’architecture cible est simple :

Utilisateur
Coordinateur (Qwen2.5-3B, port 3001)
    │  CAP v1
    ├──→  OPNsense agent
    ├──→  WireGuard agent
    └──→  CrowdSec agent
         Tool-agent-server (port 3000)

Le coordinateur et les agents-outils tournent sur le même GPU — une RTX 4070 Ti avec 12 Go de VRAM réels (11.99 GiB mesurés).

L’approche naïve — un processus vLLM par agent — est immédiatement éliminée : trois instances de Qwen2.5-3B en 8-bit consommeraient ~7.5 Go chacune. Impossible.


La solution : multi-LoRA dynamique

vLLM supporte le chargement multi-LoRA dynamique : le backbone (modèle de base) est chargé une seule fois, et les adapters sont swappés à la volée selon l’agent appelé.

Condition nécessaire : tous les adapters partagent la même base.

C’est précisément ce que la migration Phi-3 → Qwen2.5 a établi. Les trois adapters ont été entraînés sur Qwen/Qwen2.5-3B-Instruct — ils sont compatibles multi-LoRA.

Avec un seul moteur vLLM :

  • Le backbone Qwen2.5-3B est chargé une seule fois (~3 Go en 8-bit)
  • Chaque generate() reçoit un LoRARequest qui identifie l’adapter à utiliser
  • vLLM gère le swap en interne — aucun rechargement du backbone

Budget VRAM sur RTX 4070 Ti (12 Go)

vLLM ne mesure pas le delta de VRAM qu’il consomme — il mesure la VRAM totale du GPU au moment du profiling du KV cache. Tous les processus en cours comptent.

La contrainte réelle est donc :

GPU_UTIL × VRAM_totale ≤ VRAM_libre_au_démarrage

Avec le tool-agent-server déjà chargé (TOOL_AGENT_GPU_UTIL=0.45) :

GPU total          : 11.99 GiB
Tool-agent (0.45)  :  5.40 GiB  (backbone + KV cache agents)
Coordinateur       :  2.29 GiB  (poids modèle)
Overhead CUDA      :  1.13 GiB
─────────────────────────────────
Peak estimé        :  8.82 GiB

VRAM libre au démarrage coordinateur : 10.75 GiB
GPU_UTIL max coordinateur : 10.75 / 11.99 = 0.897

La configuration retenue : COORDINATOR_GPU_UTIL=0.89, VLLM_MAX_MODEL_LEN=8192.

À 0.90, le coordinateur échoue au démarrage : 0.90 × 11.99 = 10.79 GiB > 10.75 GiB disponibles. La marge est fine — 0.01 d’écart suffit pour passer ou échouer.

Règle : ne jamais calculer le budget VRAM théoriquement. Mesurer empiriquement avec nvidia-smi au démarrage de chaque processus, puis calibrer GPU_UTIL en conséquence.


Découverte automatique des adapters

Le tool-agent-server scanne le répertoire loras/ au démarrage et charge uniquement les adapters compatibles avec le modèle de base configuré :

def _discover_lora_adapters(loras_dir: Path, base_model: str) -> Dict[str, str]:
    """
    Retourne {agent_name: adapter_path} pour les adapters dont
    base_model_name_or_path correspond au modèle chargé.
    """
    _MODEL_VARIANTS = ("_qwen25", "_qwen3", "_phi3", "_phi35")
    adapters = {}

    for lora_dir in sorted(loras_dir.iterdir()):
        adapter_path = lora_dir / "adapter"
        config_path  = adapter_path / "adapter_config.json"
        if not config_path.exists():
            continue

        cfg = json.loads(config_path.read_text())
        adapter_base = cfg.get("base_model_name_or_path", "")

        # Normaliser le nom du répertoire → nom d'agent
        # opnsense_qwen25_lora/ → "opnsense"
        raw_name   = lora_dir.name.removesuffix("_lora")
        agent_name = raw_name
        for variant in _MODEL_VARIANTS:
            agent_name = agent_name.removesuffix(variant)

        if adapter_base == base_model:
            adapters[agent_name] = str(adapter_path)

    return adapters

Avec TOOL_AGENT_BASE_MODEL=Qwen/Qwen2.5-3B-Instruct, le serveur trouve et charge automatiquement :

loras/opnsense_qwen25_lora/adapter   →  "opnsense"
loras/wireguard_qwen25_lora/adapter  →  "wireguard"
loras/crowdsec_qwen25_lora/adapter   →  "crowdsec"

Les adapters entraînés sur Phi-3.5 sont ignorés avec un warning — ils coexistent sans interférence dans le même répertoire.


Le moteur vLLM en mode multi-LoRA

L’initialisation du moteur vLLM active le multi-LoRA avec un paramètre :

self.llm = LLM(
    model=model_path,
    enable_lora=True,
    max_lora_rank=64,
    gpu_memory_utilization=gpu_utilization,
    max_model_len=max_model_len,
    quantization="bitsandbytes",
)

max_lora_rank=64 correspond au rang maximum des adapters entraînés (OPNsense r=64, WireGuard r=32, CrowdSec r=16). La valeur doit couvrir le rang le plus élevé.

Pour chaque inférence, un LoRARequest identifie l’adapter à utiliser :

from vllm.lora.request import LoRARequest

adapter_id   = abs(hash(adapter_name)) % 10000
lora_request = LoRARequest(
    lora_name=adapter_name,
    lora_int_id=adapter_id,
    lora_path=adapter_path,
)
outputs = self.llm.generate(prompt, sampling_params, lora_request=lora_request)

vLLM maintient un cache des adapters chargés. Si l’adapter demandé est déjà en mémoire GPU (requête précédente pour le même agent), il est réutilisé directement. Si la capacité du cache est dépassée, l’adapter le moins récemment utilisé est évincé et rechargé à la prochaine requête.


Convention de nommage des adapters

Un détail pratique qui a nécessité une correction : les noms de répertoires LoRA incluaient le suffixe de variante (opnsense_qwen25_lora), mais le coordinateur routait vers des noms d’agents simples (opnsense, wireguard, crowdsec).

La fonction _discover_lora_adapters normalise le nom en retirant _lora puis les suffixes de variante (_qwen25, _phi35, etc.). Le mapping final est toujours un nom d’agent court, indépendant du modèle de base.

Quand on migre vers un nouveau modèle de base, seuls les répertoires changent de nom (opnsense_qwen25_loraopnsense_qwen3_lora par exemple). Le code du coordinateur et des agents reste inchangé.


Publication sur HuggingFace

Les trois adapters sont publiés sur HuggingFace pour permettre leur réutilisation indépendante du pipeline d’entraînement local.

La publication est gérée par factory/scripts/publish_qwen25_loras.py. Le point critique est la génération d’un README.md correct pour chaque adapter :

def generate_model_card(cfg: dict) -> str:
    return f"""---
base_model: Qwen/Qwen2.5-3B-Instruct
library_name: peft
license: apache-2.0
---

# {cfg['repo_name']}
...
"""

Le frontmatter YAML avec base_model: Qwen/Qwen2.5-3B-Instruct est obligatoire. HuggingFace valide ce champ et rejette les valeurs qui ne correspondent pas à un modèle existant sur le Hub — notamment les chemins locaux comme /home/user/.cache/... que génère parfois Unsloth dans son propre README.

Pour éviter ce conflit, le script exclut explicitement le README.md généré par Unsloth lors de la copie des fichiers d’adapter :

for f in cfg["lora_path"].iterdir():
    if f.name == "README.md":
        continue  # Remplacé par notre model card
    shutil.copy2(f, adapters_dst / f.name)

Résultat — trois adapters publiés, tous sur la même base, prêts pour le multi-LoRA :

Adapter Fonctions Score HuggingFace
opnsense-qwen25-lora 102 102/102 — 100% patlegu/opnsense-qwen25-lora
wireguard-qwen25-lora 11 11/11 — 100% patlegu/wireguard-qwen25-lora
crowdsec-qwen25-lora 15 15/15 — 100% patlegu/crowdsec-qwen25-lora

Ce que la série a documenté

En quatre articles, cette série couvre le cycle complet d’une migration de modèle de base en production :

  1. Choisir l’architecture — options GPU, multi-LoRA, budget VRAM
  2. Débugger le mismatch de format — pourquoi une loss correcte peut masquer un comportement catastrophique
  3. Valider comportementalement — injection CAP v1, scripts de vérification, EOS tokens, system prompts
  4. Déployer — multi-LoRA vLLM, découverte automatique, publication HuggingFace

La prochaine étape naturelle est l’observabilité en production : métriques par agent, traces bout-en-bout, détection des régressions comportementales sans relancer une vérification complète.