Accueil / Articles / Exécuter un affinage LoRA localement : vérifier, fusionner et éviter les échecs silencieux

Exécuter un affinage LoRA localement : vérifier, fusionner et éviter les échecs silencieux

Prouvez qu’un adaptateur LoRA a réellement amélioré un petit modèle, fusionnez-le et servez-le derrière une API locale compatible OpenAI, puis détectez les échecs qui génèrent des résultats erronés mais présentés comme fiables.

2570 mots

Lorsque l’entraînement d’un LoRA est terminé, on obtient un petit fichier d’adaptateur, d’environ 11 MB dans ce cas, et rien de plus. Un fichier n’est pas un résultat : tant que le modèle affiné n’a pas été évalué par rapport à la même base de référence et mis à disposition là où une application peut l’appeler, on ne dispose que d’espoir. Ce guide prend un adaptateur de tri des tickets de support pour un modèle à 2 milliards de paramètres, le mesure, le fusionne en poids autonomes, le met à disposition derrière un point de terminaison compatible OpenAI local, et examine les modes d’échec qui génèrent des réponses plausibles, bien formées mais fausses, sans aucun message d’erreur.

Tout ce qui est présenté s’exécute depuis le répertoire finetune-demo, qui inclut l’adaptateur entraîné, ce qui vous permet de suivre le processus sans avoir à entraîner quoi que ce soit vous-même. En résumé : pour cette tâche, le modèle est passé de zéro réponse entièrement valide sur 40 à 40 réponses correctes sur 40.

Mesurer l’adaptateur par rapport à la base de référence

La seule modification de mesure équitable consiste à changer une variable unique. L’évaluation utilise le même script, les mêmes 40 tickets réservés et la même température que l’exécution de référence sur le modèle non entraîné ; la seule addition est le flag --adapter qui fait référence aux poids entraînés. Le paramètre --no-think désactive le mode de raisonnement du modèle afin qu’il donne directement une réponse.

python evaluate.py - model mlx-community/Qwen3.5–2B-MLX-4bit \
--adapter adapters/triage-2b --limit 40 --no-think

===== mlx-community/Qwen3.5–2B-MLX-4bit (adapter: adapters/triage-2b) =====
examples : 40
usable : 40/40 (100%) returned parseable JSON
fully valid : 40/40 (100%) <- the headline
median latency: 0.33s
errors by rule:

La section errors by rule est vide, ce qui correspond exactement à l’objectif : chacune des 40 réponses a satisfait toutes les règles de validation. La latence médiane était de 0,33 seconde par ticket.

Comparé à la version de référence, le changement est frappant. Le modèle non entraîné produisait déjà à chaque fois du JSON lisible, mais il n’utilisait jamais le vocabulaire requis pour la catégorie, la priorité ou les étiquettes :

|                         |  Before   | After     |
|-------------------------|-----------|-----------|
| Returned parseable JSON |   40/40   | 40/40     |
| **Fully valid**         |  **0/40** | **40/40** |
| `category` errors       |     40    | 0         |
| `priority` errors       |     40    | 0         |
| `tags` errors           |     40    | 0         |
| `needs_human` errors    |      8    | 0         |

Le même ticket utilisé pour démontrer la référence montre pourquoi. Avant l’entraînement, le modèle inventait des étiquettes telles que "IT Support" ainsi que des tags en majuscules ; par la suite, il a utilisé les valeurs en minuscules du schéma de base :

TICKET : The password reset email never arrives, I have checked spam.

BEFORE : {"category": "IT Support", "priority": "High", "needs_human": true,
          "tags": ["Password Reset","Email Delivery","Account Access","Spam Filter"]}

AFTER  : {"category": "account", "priority": "medium", "needs_human": true,
          "tags": ["password", "email_change"]}

Il y a un deuxième avantage dans cet exemple. Le modèle de base utilisait 63 tokens de complétion pour sa réponse, tandis que le modèle affiné n’en utilisait que 29, soit moins de la moitié. Les tokens de sortie influencent à la fois le temps de réponse et le coût des inférences sur un endpoint chargé, donc en les réduisant de moitié on réalise une économie significative, et non une erreur d’arrondi.

Essayer avec votre propre texte

Puisque l’adaptateur est fourni avec le répertoire, le script try_it.py fonctionne immédiatement après clonage. En passant l’option --compare, on charge à la fois le modèle de base et celui adapté, ce qui vous permet de voir la différence sur un ticket que vous avez écrit :

.venv/bin/python try_it.py \
--compare "I was charged twice for my Pro plan and nobody has replied in a week"

TICKET       "I was charged twice for my Pro plan and nobody has replied in a week"

before       { "category": "Billing & Support", "priority": "High", "needs_human": true,
                 "tags": ["Duplicate Charge","Account Inquiry","Support Ticket","Pro Plan"] }
               INVALID -> category, priority, tags   (0.42s)

after        {"category":"billing","priority":"medium","needs_human":true,
                "tags":["double_charge","email_change"]}
               VALID   (0.24s)

La réponse de base échoue à la validation pour trois champs ; la réponse adaptée réussit et est également plus rapide. Supprimez --compare pour obtenir uniquement la réponse affinée, ou omettez le texte du ticket pour obtenir une invite interactive.

Trois façons d’exécuter le modèle

Fusion de l’adaptateur

LoRA représente la mise à jour des poids sous forme d’un produit à rang faible BA qui est ajouté aux poids figés W à chaque passe en avant. La fusion effectue l’addition W + BA une seule fois et enregistre des poids ordinaires, vous donnant ainsi un seul répertoire de modèle autonome :

python -m mlx_lm fuse \
  --model mlx-community/Qwen3.5-2B-MLX-4bit \
  --adapter-path adapters/triage-2b \
  --save-path fused/triage-2b

Cela a pris 3,6 secondes et a généré 1,0 GB de résultats. L’étape suivante n’est pas optionnelle : il faut évaluer le modèle fusionné avant de lui faire confiance.

fused/triage-2b   fully valid: 40/40 (100%)   median latency 0.26s
adapter           fully valid: 40/40 (100%)   median latency 0.33s

La qualité est identique, et le modèle fusionné est nettement plus rapide, car les multiplications matricielles supplémentaires par couche ont disparu. La raison de réévaluer est que la fusion repose sur des opérations arithmétiques, et des erreurs arithmétiques se produisent silencieusement. Un modèle endommagé génère néanmoins un répertoire de fichiers ayant l’air plausibles, qui produisent ensuite des résultats erronés mais convaincants. Seule une évaluation permet de distinguer les deux.

L’exécution du modèle

mlx_lm server expose le modèle fusionné via HTTP. L’option --chat-template-args désactive la capacité de réflexion au niveau du serveur, ce qui est important pour les raisons expliquées ci-dessous :

python -m mlx_lm server --model fused/triage-2b --port 8082 \
       --chat-template-args '{"enable_thinking":false}'

Une requête simple curl vers l’endpoint des complétions de chat confirme que le modèle répond dans le format d’entraînement. La température est nulle pour un résultat déterministe, et le prompt système est celui utilisé pendant l’entraînement :

curl -s -X POST http://127.0.0.1:8082/v1/chat/completions \
 -H 'Content-Type: application/json' -d '{
 "messages":[
  {"role":"system","content":"You are a support triage engine. Reply with one JSON object and nothing else, with keys: category, priority, needs_human, tags."},
  {"role":"user","content":"Production is down for all our users. The app crashes every time I open the dashboard screen."}],
 "max_tokens":120,"temperature":0}'


{"category": "bug", "priority": "urgent", "needs_human": false,
 "tags": ["crash", "desktop"]}

La réponse a utilisé 29 tokens de complétion. Comme l’endpoint est compatible OpenAI, le code existant écrit pour l’API OpenAI peut l’utiliser en modifiant uniquement l’URL de base.

Appel de l’endpoint depuis du code d’application

L’intégration se fait en une seule fonction utilisant uniquement la bibliothèque standard Python. La version présente dans client_example.py du répertoire importe le prompt système et les outils de validation depuis un module schema partagé, envoie la requête, et refuse de renvoyer quoi que ce soit qu’il ne peut pas valider :

import json, urllib.request
from schema import SYSTEM_PROMPT, validate, extract_json

ENDPOINT = "http://127.0.0.1:8082/v1/chat/completions"

def triage(ticket_text, timeout=60):
    payload = {
        "messages": [
            {"role": "system", "content": SYSTEM_PROMPT},   # MUST match training
            {"role": "user",   "content": ticket_text},
        ],
        "max_tokens": 160, "temperature": 0,
    }
    req = urllib.request.Request(ENDPOINT, data=json.dumps(payload).encode(),
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        body = json.load(r)

    msg = body["choices"][0]["message"]
    content = msg.get("content")
    if not content:                                  # thinking left no answer
        raise RuntimeError(f"no content; finish_reason={body['choices'][0]['finish_reason']}")

    record = extract_json(content)
    errs = validate(record) if record is not None else ["unparseable"]
    if errs:                                         # never trust it blindly
        raise ValueError(f"invalid record: {errs} -> {content!r}")
    return record

Lorsqu’on l’exécute sur deux tickets, il renvoie des dictionnaires propres :

I was charged twice for my Pro subscription this month.
  -> {'category': 'billing', 'priority': 'medium', 'needs_human': True,
      'tags': ['double_charge', 'invoice']}

Production is down for all our users, the dashboard crashes on load.
  -> {'category': 'bug', 'priority': 'urgent', 'needs_human': False,
      'tags': ['crash', 'desktop']}

Trois éléments dans cette fonction sont présents intentionnellement, et chacun permet de prévenir une défaillance décrite dans la section suivante :

  • SYSTEM_PROMPT provient d’une importation plutôt que d’une copie. Même une différence d’un seul caractère par rapport aux données d’entraînement fait sortir le modèle de sa distribution.
  • Vérification du content vide. Si le modèle utilise toute sa capacité de raisonnement, il n’y a pas de réponse à analyser.
  • validate() est exécuté pour chaque réponse. Un modèle affiné a une forte tendance à réussir, mais ce n’est pas une garantie. Un score parfait sur un ensemble de test ne dit rien de certain au sujet de la demande suivante ; il faut donc définir dans le code ce qui se passe en cas d’échec.

Trois défaillances qui ne génèrent jamais d’erreur

Aucune des solutions suivantes ne lance d’exception. Chacune retourne une réponse fausse, fiable et bien structurée.

Le drapeau de l’adaptateur qui est ignoré en silence

La solution évidente consiste à omettre le processus de fusion et à transmettre directement l’adaptateur au serveur :

python -m mlx_lm server --model <base> --adapter-path adapters/triage-2b

Avec mlx-lm 0.31.3, la version utilisée ici, c’est le modèle de base qui a été servi. Il n’y a eu ni avertissement, ni ligne dans les journaux, ni erreur. Le point de terminaison a démarré normalement et a répondu par "category": "Production", "priority": "Critical" ainsi qu’un ensemble de quatre étiquettes en majuscules : le comportement du modèle non entraîné est resté inchangé. Sans chiffre de référence pour comparaison, la conclusion naturelle aurait été que l’ajustement fin n’avait pas fonctionné. Les versions ultérieures peuvent se comporter différemment, il est donc préférable de vérifier plutôt que d’affirmer.

Une façon rapide de détecter cela ne prend que quelques secondes : envoyez une requête dont vous connaissez déjà la bonne réponse. Une réponse utilisant votre propre vocabulaire signifie que l’adaptateur est actif ; une réponse similaire au modèle de base indique qu’il ne l’est pas. Le chemin fusionné, mentionné précédemment, évite complètement cette question.

Raisonnement qui épuise tout le budget

De nombreux petits modèles récents raisonnent avant de répondre. Demandez du JSON avec une limite de 120 tokens lorsque le raisonnement est activé, et la réponse peut ressembler à ceci :

{
   "choices":
   [
    {
      "finish_reason":"length",
      "message":{
        "role": "assistant",
        "reasoning":"Thinking Process:\n\n1. **Analyze the Request:** ..."
      }
    }
   ]
}

Il n’y a AUCUN champ content. Chaque token a été utilisé pour le raisonnement, la génération s’est arrêtée avec finish_reason: "length" au milieu du raisonnement, et un client qui lit response.choices[0].message.content rencontre soit une erreur KeyError, soit, pire encore, une chaîne vide qu’il interprète comme une réponse valide.

Désactivez la fonction de réflexion sur le serveur avec --chat-template-args '{"enable_thinking":false}', ou par demande spécifique avec "chat_template_kwargs": {"enable_thinking": false}. Sans cette fonction, la même demande est traitée en 29 tokens.

Un prompt système différent de celui utilisé pendant l’entraînement

L’entraînement a appris à l’adaptateur à répondre sous **un seul et même prompt système**. Si ce prompt est modifié, la demande sort du cadre de ce à quoi l’adaptateur a été exposé, et la plupart des comportements appris disparaissent. Voici le même modèle affiné à qui est donné un prompt générique demandant à un assistant utile de classer le ticket :

This is a **Critical Production Incident** (or a **Major Service Level Incident**).

Here is the breakdown of why this categorization applies:

*   **Severity Level: Critical / P0**
    *   **Impact:** Total system outage affecting all users.

Le résultat est un texte Markdown sans aucun JSON. Le modèle n’est pas défectueux ; on lui a posé une question pour laquelle il n’a jamais été entraîné. Conservez une seule définition du prompt, partagée par le générateur de données et le client, et importez-la partout.

Deux autres pièges : la liste des modèles et votre propre framework

GET /v1/models liste tous les modèles dans le cache local, et non celui qui est actuellement chargé. Considérez-le plutôt comme une liste de cache que comme un test de disponibilité : il peut vous indiquer que le serveur est opérationnel, mais pas quels poids sont utilisés.

Vérifiez également l’outil d’évaluation avant de blâmer les poids. Dans ce projet, l’outil d’évaluation décidait s’il fallait désactiver la capacité de réflexion en cherchant "qwen" dans le nom du modèle. Cela a fonctionné pour mlx-community/Qwen3.5-2B-MLX-4bit, mais la version fusionnée se trouve à fused/triage-2b ; par conséquent, la capacité de réflexion est restée active sans que personne ne s’en aperçoive, et le modèle fusionné a obtenu 82 % au lieu de 100 %. Les poids étaient en ordre ; c’est l’outil d’évaluation qui était à l’origine du problème. Lorsqu’un score diminue de manière inattendue, suspectez d’abord l’outil d’évaluation, et ne basez jamais le comportement sur le nom d’un fichier.

Ce que le score 40/40 ne prouve pas

Le score parfait existe bel et bien, mais soyez précis quant à son champ d’application : il concerne les exemples non utilisés créés par le même générateur qui a produit l’ensemble d’entraînement. Le modèle généralise, mais uniquement à de nouveaux exemples synthétiques de ce type.

Quelques tickets réalistes et complexes racontent une histoire différente. Six ont été examinés. Quatre ont passé la validation structurelle, mais plusieurs d’entre eux étaient encore erronés avec une certitude absolue :

  • Une plainte en majuscules indiquant que les commandes ne pouvaient pas être expédiées et que tout était défectueux a atterri dans la catégorie account au lieu de bug.
  • Un message de remerciement louant une correction apportée au tableau de bord a été classé dans feature_request, car le schéma ne propose pas d’option « ce n’est pas un ticket » et le modèle doit en choisir une.
  • Une demande de suppression conformément au RGPD est devenue une entrée de type how_to avec needs_human: false, ce qui éloigne l’échéance légale d’une intervention humaine.

Le dernier cas concerne un défaut de données, et non un défaut du modèle. Dans l’ensemble de données généré, la valeur de needs_human est entièrement déterminée par la category:

account {True: 125}   billing {True: 137}
bug {False: 153}      how_to {False: 115}      feature_request {False: 110}

Le modèle a donc appris une table de correspondance à cinq lignes plutôt que de prendre une décision, et aucune quantité d’entraînement ne peut corriger une étiquette qui n’a jamais été indépendante. On ne découvre cela qu’en effectuant des tests hors distribution, il convient donc de considérer la note réservée comme le minimum que l’on peut revendiquer et non comme le maximum. Pour une utilisation en production, étiquetez quelques centaines de tickets réels, laissez needs_human varier indépendamment de la catégorie, et ajoutez une étiquette « aucune action ».

Au-delà des tickets de support

Rien dans cette chaîne de traitement n’est spécifique aux tickets. Elle convient partout où l’on dispose de texte non structuré et d’un ensemble d’étiquettes fixe :

  • CV en fonction du niveau hiérarchique, des années d’expérience et des tags de compétences.
  • Factures en fonction du fournisseur, de la monnaie et des catégories de postes.
  • Lignes de journal en fonction du service, du niveau de gravité et du type d’incident.
  • Avis en termes d’émotion ressentie, de fonctionnalité mentionnée et de type de défaut.
  • Seuls deux fichiers doivent être modifiés : schema.py, qui contient les valeurs autorisées, la demande ainsi que la fonction validate(), et make_data.py, qui génère les exemples. Toutes les commandes présentées ici fonctionneront donc sans modification.

    Au préalable de l’ajustement fin du prochain modèle

    Essayez d’abord le décodage contraint. Les grammaires GBNF dans llama.cpp ou des bibliothèques comme xgrammar obligent la sortie générée à respecter un schéma, ce qui rend impossible toute sortie structurellement incorrecte, que le modèle ait été affiné ou non. L’application d’une grammaire seule aurait permis d’atteindre une validité du schéma de 100 % ici, sans aucune formation. L’affinage reste néanmoins utile : une grammaire peut imposer la forme mais pas le sens, et c’est la formation qui a appris au modèle la bonne catégorie, tout en réduisant le nombre de tokens de moitié. Mais si le seul problème est du JSON mal formaté, optez pour une grammaire avant de lancer un entraînement.

    Comptez le coût par requête, et non par exécution d’entraînement. L’exécution d’entraînement dure environ cinq minutes, une seule fois. La consommation en tokens se reproduit à chaque appel tant que le service est actif, donc la réduction de 63 à 29 tokens représente l’économie qui continue d’augmenter. Si vous comparez cela à une API hébergée, l’analyse présentée dans fine-tune or call the API examine les chiffres pour un pipeline similaire.

    Considérez GGUF comme la troisième option fragile. La conversion en GGUF permet de rendre le modèle compatible avec llama.cpp ou Ollama, mais les outils peuvent terminer sans problème tout en vous laissant des poids qui génèrent du contenu inutilisable. Générez une complétion d’échantillon après chaque étape de conversion ; la présence d’un fichier GGUF ne prouve rien quant au bon fonctionnement du modèle.

    Points clés

    • Le nombre qui donne un sens à chaque résultat ultérieur est la ligne de référence. Mesurez avant l’entraînement, puis mesurez à nouveau après chaque transformation telle que la fusion ou la conversion.
    • Les poids fusionnés étaient aussi précis que l’adaptateur et plus rapides à charger ; la route --adapter-path non fusionnée affichait silencieusement le modèle de base de la version testée.
    • Sécurisez chaque réponse dans le code : importez exactement la demande d’entraînement, vérifiez l’absence de content, et validez le enregistrement.
    • Un score parfait obtenu sur des données réservées ne couvre que des données similaires à l’ensemble d’entraînement. Testez avec des entrées réelles complexes, et corrigez les fuites d’étiquettes dans les données plutôt que d’espérer que l’entraînement y remédiera.