Aller au contenu principal

Labellisation des données et affinage des modèles

Ce guide décrit le parcours complet, de la donnée brute au modèle affiné servi aux agents, avec deux services du catalogue : Label Studio pour la labellisation et la validation humaine, et LLaMA-Factory pour l'affinage. Décision d'architecture : ADR-0172.

  1. Label Studio : les annotateurs labellisent les données (textes, images, vidéos), comparent des réponses et valident les sorties des modèles.
  2. Export : les annotations deviennent un jeu de données au format de LLaMA-Factory : instructions (SFT), préférences (DPO), conversations avec images ou vidéos (VLM).
  3. LLaMA-Factory : l'affinage des LLM et des VLM (SFT, LoRA, QLoRA, DPO) tourne sur les GPU d'un hôte, hors ligne.
  4. Export du modèle : l'adaptateur est fusionné dans son modèle de base.
  5. Mise en service : vLLM sert le modèle exporté, LiteLLM l'enregistre et le rend disponible aux agents, qui n'appellent jamais LLaMA-Factory.

Les deux services s'installent avec la méthode podman, sur le réseau isolé de leur service, sans télémétrie (installation durcie). Leurs fiches de service donnent chaque paramètre, port, contrôle et commande.

1. Installer Label Studio​

Prérequis. Une base PostgreSQL (le service postgresql de la plateforme ou une base existante du client), un stockage S3 (SeaweedFS de la plateforme par défaut, ou un compartiment existant) et, en production, la passerelle HTTPS authentifiante de la plateforme devant l'interface. Label Studio ne garde aucun état sur ses hôtes : projets, tâches, annotations et utilisateurs sont dans la base, médias téléversés et exports dans le compartiment.

ModeComposantsUsage
standaloneune application (app, uwsgi) et une interface (nginx) sur le même hôteessai, petite équipe
replicateddeux applications ou plus, une interface ou plus, derrière HAProxy et keepalivedproduction
ParamètreRôle
public_urlAdresse qu'ouvrent les utilisateurs, par exemple https://<label-studio-host> : liens, origine CSRF, cookies sécurisés en https
admin_email, admin_passwordPremier compte, créé au premier démarrage ; le mot de passe est généré et gardé dans le coffre du Manager
secret_keyClé secrète de Django, générée et partagée par toutes les répliques
allow_signupDésactivé par défaut : seul le lien d'invitation de l'organisation inscrit un utilisateur
trusted_ca_pemAutorités internes de la base (verify-ca, verify-full) et d'un stockage S3 en https
allow_local_ml_backendsAutorise les backends d'apprentissage automatique internes (pré-annotation) ; désactivé par défaut
max_upload_mb, workersTaille maximale d'un téléversement, processus uwsgi de chaque application

L'interface écoute sur le port port (8090). Le port uwsgi app_port (8091) n'est publié que sur 127.0.0.1 en mode autonome ; en mode répliqué, il est ouvert aux seuls hôtes du cluster, pour que chaque interface répartisse sur toutes les applications.

Première connexion. Lire le mot de passe administrateur dans le coffre du Manager, se connecter, puis le changer dans Account & Settings : il ne sert qu'au premier démarrage. Inviter ensuite les annotateurs depuis Organization, Add Members (lien d'invitation, Reset Link pour l'invalider).

La commande version du service appelle /usr/bin/curl sur l'hôte : l'ajouter à allowed_commands de l'agent (Sécurité de l'agent Navin).

2. Labelliser et valider​

  1. Créer un projet et choisir son gabarit de labellisation (classification, entités nommées, réponses libres, comparaison de deux réponses, description d'images et questions sur une image, sons, vidéos).
  2. Importer les tâches : téléversement depuis l'interface, ou stockage S3 du projet. Pour le compartiment de la plateforme, laisser le point d'accès S3 du projet vide : Label Studio utilise celui de l'installation. Un point d'accès interne saisi dans un projet est refusé (protection contre la falsification de requêtes côté serveur).
  3. Labelliser, puis valider : plusieurs annotations par tâche, filtres et tri du Data Manager. L'édition Community n'a pas de rôles ni de circuit de relecture : tous les membres de l'organisation voient tous les projets.
  4. Exporter : Export, format JSON, ou par l'API GET /api/projects/{id}/export?exportType=JSON avec un jeton d'accès personnel.

Configurations de labellisation des trois jeux d'affinage, reprises par les scripts de la section suivante :

JeuConfiguration de labellisation (dans <View>)
Instructions (SFT)<Text name="prompt" value="$prompt"/>, <TextArea name="answer" toName="prompt"/>
Préférences (DPO)<Text name="prompt" value="$prompt"/>, <Text name="answer1" value="$answer1"/>, <Text name="answer2" value="$answer2"/>, <Pairwise name="preference" toName="answer1,answer2"/>
Images (VLM)<Image name="image" value="$image"/>, <Text name="prompt" value="$prompt"/>, <TextArea name="answer" toName="image"/>

3. Préparer le jeu de données​

LLaMA-Factory lit les formats alpaca et sharegpt (json, jsonl, csv, parquet), décrits dans dataset_info.json du dossier des jeux de données. Les scripts ci-dessous convertissent l'export JSON de Label Studio et ignorent les annotations annulées.

Instructions (SFT)​

Les tâches portent un champ prompt ; la réponse validée est saisie dans la zone de texte answer :

import json
import sys

tasks = json.load(open(sys.argv[1], encoding="utf-8"))
rows = []
for task in tasks:
for annotation in task.get("annotations", []):
if annotation.get("was_cancelled"):
continue
answers = [r["value"]["text"][0] for r in annotation["result"]
if r.get("from_name") == "answer" and r.get("type") == "textarea"]
if answers:
rows.append({"instruction": task["data"]["prompt"], "input": "", "output": answers[0]})
json.dump(rows, open(sys.argv[2], "w", encoding="utf-8"), ensure_ascii=False, indent=2)

Préférences (DPO)​

Les tâches portent une question prompt et deux réponses answer1 et answer2 ; l'annotateur choisit la meilleure avec Pairwise (left pour answer1, right pour answer2) :

import json
import sys

tasks = json.load(open(sys.argv[1], encoding="utf-8"))
rows = []
for task in tasks:
data = task["data"]
for annotation in task.get("annotations", []):
if annotation.get("was_cancelled"):
continue
choices = [r["value"]["selected"] for r in annotation["result"] if r.get("type") == "pairwise"]
if choices and choices[0] in ("left", "right"):
chosen, rejected = ("answer1", "answer2") if choices[0] == "left" else ("answer2", "answer1")
rows.append({"instruction": data["prompt"], "input": "", "chosen": data[chosen],
"rejected": data[rejected]})
json.dump(rows, open(sys.argv[2], "w", encoding="utf-8"), ensure_ascii=False, indent=2)

Images (VLM)​

Les tâches portent une image image (téléversée, ou objet du stockage S3 du projet) et une question prompt ; la réponse validée est saisie dans answer. Le script télécharge chaque image par l'API de Label Studio (adresse dans LS_URL, jeton d'accès personnel dans LS_TOKEN), puis écrit support_images.json et le dossier support_images de ses images :

import base64
import json
import os
import sys
import urllib.request
from pathlib import PurePosixPath

base, token = os.environ["LS_URL"].rstrip("/"), os.environ["LS_TOKEN"]


def call(path, access=None, body=None):
headers = {"Authorization": f"Bearer {access}"} if access else {"Content-Type": "application/json"}
with urllib.request.urlopen(urllib.request.Request(base + path, data=body, headers=headers)) as response:
return response.read()


def source(task):
uri = task["data"]["image"]
if uri.startswith("/"): # fichier téléversé dans Label Studio
return uri
# objet du stockage S3 du projet, lu par Label Studio
return f"/tasks/{task['id']}/resolve/?fileuri={base64.urlsafe_b64encode(uri.encode()).decode()}"


tasks = json.load(open(sys.argv[1], encoding="utf-8"))
name = sys.argv[2] # par exemple support_images
os.makedirs(f"{name}/images", exist_ok=True)
rows = []
for task in tasks:
answers = [r["value"]["text"][0] for a in task.get("annotations", []) if not a.get("was_cancelled")
for r in a["result"] if r.get("from_name") == "answer" and r.get("type") == "textarea"]
if not answers:
continue
access = json.loads(call("/api/token/refresh/", body=json.dumps({"refresh": token}).encode()))["access"]
image = f"{name}/images/{task['id']}-{PurePosixPath(task['data']['image'].split('?')[0]).name}"
with open(image, "wb") as out:
out.write(call(source(task), access=access))
rows.append({"messages": [{"role": "user", "content": "<image>" + task["data"]["prompt"]},
{"role": "assistant", "content": answers[0]}],
"images": [image]})
json.dump(rows, open(f"{name}.json", "w", encoding="utf-8"), ensure_ascii=False, indent=2)

Le jeton d'accès personnel donne un jeton d'accès valable environ 5 minutes : le script en demande un pour chaque image. Pour un stockage S3 du projet, décocher Use pre-signed URLs : Label Studio lit alors lui-même les objets, et le jeton ne quitte pas Label Studio. Une vidéo se décrit de la même façon, avec la colonne videos et la balise <video> dans le message.

Copier les fichiers obtenus, et le dossier support_images, dans /var/lib/navin/llama-factory/datasets de l'hôte GPU, avec leur description dans dataset_info.json du même dossier. Les chemins des images sont relatifs à ce dossier (media_dir, par défaut le dossier des jeux de données) :

{
"support_answers": {
"file_name": "support_answers.json",
"formatting": "alpaca",
"columns": {"prompt": "instruction", "query": "input", "response": "output"}
},
"support_preferences": {
"file_name": "support_preferences.json",
"ranking": true,
"columns": {"prompt": "instruction", "query": "input", "chosen": "chosen", "rejected": "rejected"}
},
"support_images": {
"file_name": "support_images.json",
"formatting": "sharegpt",
"columns": {"messages": "messages", "images": "images"},
"tags": {"role_tag": "role", "content_tag": "content", "user_tag": "user", "assistant_tag": "assistant"}
}
}

4. Installer LLaMA-Factory​

Prérequis. Un hôte GPU NVIDIA avec son pilote, le NVIDIA Container Toolkit et la spécification CDI (nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml), 16 Go de mémoire au moins et de l'espace disque pour les modèles (100 Go conseillés). Les contrôles préalables refusent un hôte sans GPU de 8 Go ou plus.

ModeUsage
singleune interface LLaMA Board qui entraîne sur les GPU de son hôte (plusieurs GPU par torchrun)
multi-hostune interface par hôte GPU, chacune avec ses modèles, ses jeux de données et ses entraînements
ParamètreRôle
offlineActivé par défaut : aucun téléchargement depuis l'interface, modèles et jeux de données déjà sur l'hôte
hf_endpoint, hf_tokenMiroir interne compatible Hugging Face et jeton des modèles à accès restreint, pour download_model
pip_index_urlIndex compatible PyPI (miroir interne) de la commande install_qlora, inutilisé quand la roue est copiée dans wheels
gpu_devicesGPU donnés à l'interface : all, ou une liste d'index ou d'UUID (les GPU de vLLM restent à l'écart)
shm_sizeMémoire partagée du conteneur (chargeurs de données, NCCL entre les GPU de l'hôte)
portPort de LLaMA Board (7860)

Dossiers de l'hôte, sous /var/lib/navin/llama-factory :

DossierDans l'interfaceContenu
models/hf, models/local/models/hf, /models/localCache Hugging Face, poids importés
datasets/app/shared_dataJeux de données et dataset_info.json
configs/app/configs (lecture seule)Configurations relues des commandes train et export
saves/app/savesPoints de contrôle et adaptateurs, journaux des commandes dans navin-runs
output/app/outputModèles exportés
python/opt/navin/python (lecture seule)bitsandbytes de QLoRA, écrit par la seule commande install_qlora
wheelscommande install_qlora seulementRoue de bitsandbytes copiée pour un hôte sans accès à un index

QLoRA. L'image officielle de LLaMA-Factory n'embarque pas bitsandbytes, nécessaire à la quantification 4 et 8 bits de QLoRA. La commande install_qlora l'installe une fois sur chaque hôte du service : bitsandbytes 0.50.2 (MIT), roue vérifiée par son sha256 épinglé, dans le dossier python monté en lecture seule dans l'interface, sans redémarrage ; elle affiche ensuite la version installée. Elle prend la roue dans le dossier wheels de l'hôte si elle s'y trouve, sinon dans l'index pip_index_url. Hôte sans accès à un index : copier bitsandbytes-0.50.2-py3-none-manylinux_2_24_x86_64.whl (sha256 55348a9a4a21bfd99cf8c7b32fe67b4030ae5c2a05738e03c1747f65fa6ec283) dans /var/lib/navin/llama-factory/wheels avant de lancer la commande.

LLaMA Board n'a pas d'authentification propre : en production, sa console ne s'ouvre qu'à travers la passerelle authentifiante de la plateforme. Gradio charge ses polices depuis Google : sans accès à Internet, l'interface utilise les polices du poste, sans perte de fonction.

Les commandes du service lancent /usr/bin/podman (téléchargement, entraînement, export, journaux), /usr/bin/nvidia-smi (état des GPU) et /bin/sh (publication vers vLLM et Ollama) sur l'hôte : les ajouter à allowed_commands de l'agent (Sécurité de l'agent Navin).

5. Modèle de base​

Deux sources, hors ligne ensuite :

  • commande download_model (dépôt, révision) : les poids vont dans le cache Hugging Face de chaque hôte du service, depuis le miroir interne ; l'interface les trouve par leur identifiant, par exemple Qwen/Qwen3-4B-Instruct-2507 (LLM) ou Qwen/Qwen3-VL-4B-Instruct (VLM) ;
  • poids validés copiés dans /var/lib/navin/llama-factory/models/local/<nom> : chemin /models/local/<nom>.

6. Entraîner​

Depuis LLaMA Board : régler le dossier de données (Data dir) sur /app/shared_data, choisir le modèle, la méthode (LoRA, QLoRA, entière), l'étape (SFT, DPO, KTO...) et le jeu de données, puis lancer.

Depuis le Manager : déposer une configuration relue dans /var/lib/navin/llama-factory/configs, par exemple qwen3-lora-support.yaml :

model_name_or_path: Qwen/Qwen3-4B-Instruct-2507
trust_remote_code: true
stage: sft
do_train: true
finetuning_type: lora
lora_rank: 8
lora_target: all
dataset_dir: /app/shared_data
dataset: support_answers
template: qwen3_nothink
cutoff_len: 2048
preprocessing_num_workers: 16
dataloader_num_workers: 4
output_dir: saves/qwen3-4b/lora/support
logging_steps: 10
save_steps: 500
plot_loss: true
overwrite_output_dir: true
report_to: none
per_device_train_batch_size: 1
gradient_accumulation_steps: 8
learning_rate: 1.0e-4
num_train_epochs: 3.0
lr_scheduler_type: cosine
warmup_ratio: 0.1
bf16: true
ddp_timeout: 180000000
resume_from_checkpoint: null

Lancer la commande train du service avec le nom du fichier, puis suivre l'entraînement avec runs (journaux du Manager et trainer_log.jsonl) et run_log. Le nom d'une configuration n'accepte que lettres, chiffres, points, tirets et soulignés : il est passé en argument, jamais dans le texte du script. Un redémarrage de l'interface arrête les entraînements en cours : relancer avec resume_from_checkpoint reprend depuis le dernier point de contrôle.

QLoRA, DPO et VLM. Les autres méthodes reprennent la même configuration, avec ces lignes changées :

MéthodeLignes à changer
QLoRA (après install_qlora)quantization_bit: 4 (ou 8), quantization_method: bnb
DPOstage: dpo, pref_beta: 0.1, pref_loss: sigmoid, dataset: support_preferences
VLM (images, vidéos)model_name_or_path: Qwen/Qwen3-VL-4B-Instruct, template: qwen3_vl_nothink, dataset: support_images, image_max_pixels: 262144, video_max_pixels: 16384, freeze_vision_tower: true

Les méthodes se combinent : un VLM s'affine aussi en QLoRA ou en DPO (jeu sharegpt avec les colonnes chosen, rejected et images). Le DPO part en général du modèle affiné en SFT : l'exporter d'abord, puis le donner en model_name_or_path.

7. Exporter et servir avec vLLM ou Ollama​

Configuration d'export, par exemple qwen3-merge-support.yaml (ne pas fusionner un modèle quantifié) :

model_name_or_path: Qwen/Qwen3-4B-Instruct-2507
adapter_name_or_path: saves/qwen3-4b/lora/support
template: qwen3_nothink
trust_remote_code: true
export_dir: output/qwen3-4b-support
export_size: 5
export_device: cpu
export_legacy_format: false

La commande export écrit le modèle fusionné dans /var/lib/navin/llama-factory/output/qwen3-4b-support. Pour le servir :

  1. vLLM sur le même hôte : commande publish_to_vllm de LLaMA-Factory (export : qwen3-4b-support, name : qwen3-4b-support), qui copie le dossier dans /var/lib/navin/vllm/models/local/qwen3-4b-support et refuse d'écraser un dossier existant ; vLLM sur un autre hôte : y copier ce dossier, au même endroit ;
  2. régler le service vLLM : model sur qwen3-4b-support, model_source sur local, served_model_name sur qwen3-4b-support ;
  3. LiteLLM enregistre le modèle par sa liaison vllm ; l'évaluer (Opik, promptfoo) avant de l'ouvrir aux agents.

Pour le servir avec Ollama (architectures qu'Ollama importe depuis Safetensors : Llama, Mistral, Gemma, Qwen) :

  1. Ollama sur le même hôte : commande publish_to_ollama de LLaMA-Factory, qui copie le dossier dans /var/lib/navin/ollama/import/qwen3-4b-support ; Ollama sur d'autres hôtes : y copier ce dossier, au même endroit, sur chaque instance ;
  2. commande import_safetensors du service Ollama (model : qwen3-4b-support, folder : qwen3-4b-support), exécutée sur chaque instance ;
  3. ajouter qwen3-4b-support à litellm_models du service Ollama et appliquer : LiteLLM l'enregistre par sa liaison ollama.

Un modèle déjà converti en GGUF passe par la commande import_model d'Ollama (fichier .gguf du même dossier d'import).

Un adaptateur QLoRA se fusionne dans le modèle de base non quantifié : la configuration d'export garde model_name_or_path sans quantization_bit. Un VLM s'exporte de la même façon, avec son gabarit (template: qwen3_vl_nothink). Pour le servir, régler aussi vision du service vLLM sur true, pour que LiteLLM le déclare avec supports_vision, et au besoin le nombre d'images par requête dans extra_args : --limit-mm-per-prompt puis {"image": 4, "video": 1}.

Les poids affinés gardent la licence de leur modèle de base (LEX-012) : enregistrer licence et empreinte sha256 de chaque modèle dans le registre des modèles.

8. Sécurité et exploitation​

SujetLabel StudioLLaMA-Factory
RéseauIsolé, interface publiée, uwsgi sur 127.0.0.1 ou ouvert au seul clusterIsolé, LLaMA Board publié derrière la passerelle
Privilèges--cap-drop ALL, utilisateur 1001 de l'image--cap-drop ALL, GPU par CDI
TélémétrieAnalytics, Sentry et vérification de version coupésAnalytics Gradio, partage public, MCP et W&B coupés
QuantificationSans objetbitsandbytes épinglé (version et sha256), installé par install_qlora, monté en lecture seule
ComptesPremier compte généré, inscription sur invitationAucun : passerelle authentifiante
ÉtatBase PostgreSQL et compartiment S3, sauvegardés par leurs services/var/lib/navin/llama-factory, sauvegarde de l'hôte
Haute disponibilitéMode répliqué derrière HAProxy et keepalivedNon : un entraînement reprend depuis son dernier point de contrôle

L'entraînement multi nœud (torchrun entre hôtes) n'est pas déployé par le catalogue : NCCL demanderait le réseau de l'hôte.