Trois Agents, Un GPU : Multi-LoRA Dynamique avec vLLM
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 unLoRARequestqui 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-smiau démarrage de chaque processus, puis calibrerGPU_UTILen 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_lora → opnsense_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 :
- Choisir l’architecture — options GPU, multi-LoRA, budget VRAM
- Débugger le mismatch de format — pourquoi une loss correcte peut masquer un comportement catastrophique
- Valider comportementalement — injection CAP v1, scripts de vérification, EOS tokens, system prompts
- 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.
