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.
- Label Studio : les annotateurs labellisent les données (textes, images, vidéos), comparent des réponses et valident les sorties des modèles.
- 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).
- LLaMA-Factory : l'affinage des LLM et des VLM (SFT, LoRA, QLoRA, DPO) tourne sur les GPU d'un hôte, hors ligne.
- Export du modèle : l'adaptateur est fusionné dans son modèle de base.
- 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.
| Mode | Composants | Usage |
|---|---|---|
standalone | une application (app, uwsgi) et une interface (nginx) sur le même hôte | essai, petite équipe |
replicated | deux applications ou plus, une interface ou plus, derrière HAProxy et keepalived | production |
| Paramètre | Rôle |
|---|---|
public_url | Adresse qu'ouvrent les utilisateurs, par exemple https://<label-studio-host> : liens, origine CSRF, cookies sécurisés en https |
admin_email, admin_password | Premier compte, créé au premier démarrage ; le mot de passe est généré et gardé dans le coffre du Manager |
secret_key | Clé secrète de Django, générée et partagée par toutes les répliques |
allow_signup | Désactivé par défaut : seul le lien d'invitation de l'organisation inscrit un utilisateur |
trusted_ca_pem | Autorités internes de la base (verify-ca, verify-full) et d'un stockage S3 en https |
allow_local_ml_backends | Autorise les backends d'apprentissage automatique internes (pré-annotation) ; désactivé par défaut |
max_upload_mb, workers | Taille 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
- 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).
- 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).
- 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.
- Exporter : Export, format
JSON, ou par l'APIGET /api/projects/{id}/export?exportType=JSONavec un jeton d'accès personnel.
Configurations de labellisation des trois jeux d'affinage, reprises par les scripts de la section suivante :
| Jeu | Configuration 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.
| Mode | Usage |
|---|---|
single | une interface LLaMA Board qui entraîne sur les GPU de son hôte (plusieurs GPU par torchrun) |
multi-host | une interface par hôte GPU, chacune avec ses modèles, ses jeux de données et ses entraînements |
| Paramètre | Rôle |
|---|---|
offline | Activé 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_token | Miroir interne compatible Hugging Face et jeton des modèles à accès restreint, pour download_model |
pip_index_url | Index compatible PyPI (miroir interne) de la commande install_qlora, inutilisé quand la roue est copiée dans wheels |
gpu_devices | GPU donnés à l'interface : all, ou une liste d'index ou d'UUID (les GPU de vLLM restent à l'écart) |
shm_size | Mémoire partagée du conteneur (chargeurs de données, NCCL entre les GPU de l'hôte) |
port | Port de LLaMA Board (7860) |
Dossiers de l'hôte, sous /var/lib/navin/llama-factory :
| Dossier | Dans l'interface | Contenu |
|---|---|---|
models/hf, models/local | /models/hf, /models/local | Cache Hugging Face, poids importés |
datasets | /app/shared_data | Jeux de données et dataset_info.json |
configs | /app/configs (lecture seule) | Configurations relues des commandes train et export |
saves | /app/saves | Points de contrôle et adaptateurs, journaux des commandes dans navin-runs |
output | /app/output | Modèles exportés |
python | /opt/navin/python (lecture seule) | bitsandbytes de QLoRA, écrit par la seule commande install_qlora |
wheels | commande install_qlora seulement | Roue 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 exempleQwen/Qwen3-4B-Instruct-2507(LLM) ouQwen/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éthode | Lignes à changer |
|---|---|
QLoRA (après install_qlora) | quantization_bit: 4 (ou 8), quantization_method: bnb |
| DPO | stage: 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 :
- vLLM sur le même hôte : commande
publish_to_vllmde LLaMA-Factory (export:qwen3-4b-support,name:qwen3-4b-support), qui copie le dossier dans/var/lib/navin/vllm/models/local/qwen3-4b-supportet refuse d'écraser un dossier existant ; vLLM sur un autre hôte : y copier ce dossier, au même endroit ; - régler le service vLLM :
modelsurqwen3-4b-support,model_sourcesurlocal,served_model_namesurqwen3-4b-support; - 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) :
- Ollama sur le même hôte : commande
publish_to_ollamade 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 ; - commande
import_safetensorsdu service Ollama (model:qwen3-4b-support,folder:qwen3-4b-support), exécutée sur chaque instance ; - ajouter
qwen3-4b-supportàlitellm_modelsdu service Ollama et appliquer : LiteLLM l'enregistre par sa liaisonollama.
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
| Sujet | Label Studio | LLaMA-Factory |
|---|---|---|
| Réseau | Isolé, interface publiée, uwsgi sur 127.0.0.1 ou ouvert au seul cluster | Isolé, 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étrie | Analytics, Sentry et vérification de version coupés | Analytics Gradio, partage public, MCP et W&B coupés |
| Quantification | Sans objet | bitsandbytes épinglé (version et sha256), installé par install_qlora, monté en lecture seule |
| Comptes | Premier compte généré, inscription sur invitation | Aucun : passerelle authentifiante |
| État | Base 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 keepalived | Non : 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.