Loss Correcte, Vérification à 0% : Le Bug Silencieux du Format de Prompt
C’est le type de bug qu’on ne voit pas venir.
L’entraînement se termine normalement. La loss finale est bonne — 0.2532 pour OPNsense, comparable aux runs précédents. Pas d’anomalie dans les courbes. Le modèle a convergé.
Puis on lance la vérification fonctionnelle. Et le score tombe à zéro.
Score : 0/102 (0%)
❌ ADAPTATEUR NON VALIDÉ
L’investigation
La première réaction est de chercher un bug dans le script de vérification. On inspecte le chargement du modèle, l’application de l’adapter, le décodage. Tout semble correct.
On génère manuellement une réponse et on l’affiche brute :
raw = tokenizer.decode(outputs[0], skip_special_tokens=False)
print(repr(raw))
Sortie :
'<|im_start|>system\nTu es un agent OPNsense...<|im_end|>\n
<|im_start|>user\n{"directive": "block_ip"...}<|im_end|>\n
<|im_start|>assistant\n<|system|>\nTu es un agent OPNsense...<|end|>\n
<|user|>\n{"directive": "block_ip"...}<|end|>\n<|assistant|>\n'
Le modèle recopie le prompt en format Phi-3 au lieu de générer un tool_call. Il n’a pas appris à répondre — il a appris à répéter dans un autre format.
La cause racine
Le script d’entraînement contenait une fonction format_single_example_to_text() écrite
lors de la phase Phi-3. Elle construisait le format de prompt manuellement :
def format_single_example_to_text(messages: list[dict]) -> str:
parts = []
for msg in messages:
role = msg["role"]
content = msg.get("content", "")
if role == "system":
parts.append(f"<|system|>\n{content}<|end|>\n")
elif role == "user":
parts.append(f"<|user|>\n{content}<|end|>\n")
elif role == "assistant":
parts.append(f"<|assistant|>\n{content}<|end|>\n")
return "".join(parts)
Tokens Phi-3 : <|system|>, <|user|>, <|assistant|>, <|end|>.
Le script de vérification, lui, utilisait tokenizer.apply_chat_template() avec le
tokenizer Qwen2.5. Tokens Qwen2.5 : <|im_start|>system, <|im_end|>, <|im_start|>user…
Le modèle a été entraîné dans un format, interrogé dans un autre.
Visualiser le mismatch
Format entraînement (Phi-3 hardcodé) :
<|system|>
Tu es un agent OPNsense...
<|end|>
<|user|>
{"directive": "block_ip", "entities": {...}}
<|end|>
<|assistant|>
tool_call : block_ip({"ip": "10.0.0.1"})
<|end|>
Format inférence (Qwen2.5 via apply_chat_template) :
<|im_start|>system
Tu es un agent OPNsense...<|im_end|>
<|im_start|>user
{"directive": "block_ip", "entities": {...}}<|im_end|>
<|im_start|>assistant
Le modèle reçoit un contexte qu’il n’a jamais vu pendant l’entraînement. Il ne sait pas quoi faire — alors il régurgite sa mémoire d’entraînement.
Pourquoi la loss ne détecte pas ça ?
C’est la question centrale. La loss mesure la capacité du modèle à prédire le token suivant dans le format d’entraînement. Dans ce format, la prédiction est bonne — d’où la loss correcte.
La loss ne mesure pas le comportement en inférence avec un format différent.
C’est un cas d’école de la différence entre :
- Métriques d’entraînement : loss, perplexité — mesurent la qualité de l’optimisation
- Métriques comportementales : taux de réussite sur tâches réelles — mesurent ce qu’on veut vraiment
Une loss de 0.25 avec un mismatch de format vaut exactement 0 en production.
Le fix
Cinq lignes dans lora_trainer.py :
# Avant — format Phi-3 hardcodé
text = format_single_example_to_text(example["messages"])
# Après — délégué au tokenizer du modèle courant
def format_example(tokenizer, messages):
converted = []
for msg in messages:
m = {"role": msg["role"], "content": msg.get("content", "") or ""}
if msg.get("tool_calls"):
tc_json = json.dumps(msg["tool_calls"], ensure_ascii=False)
m["content"] = (m["content"] + "\n" + tc_json).strip()
converted.append(m)
return tokenizer.apply_chat_template(
converted, tokenize=False, add_generation_prompt=False
)
apply_chat_template() lit le format directement dans la configuration du tokenizer.
Qwen2.5 produit du ChatML, Phi-3 produit du Phi-3, Llama produit du Llama. Le code
d’entraînement devient model-agnostic.
Validation du fix
Avant de relancer l’entraînement (~3h42 pour OPNsense), on vérifie que les deux formats sont maintenant identiques :
# Script de vérification rapide
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-3B-Instruct")
messages = [
{"role": "system", "content": "Tu es un agent OPNsense."},
{"role": "user", "content": '{"directive": "block_ip"}'},
]
# Format entraînement (après fix)
train_fmt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
# Format inférence
infer_fmt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
print(train_fmt)
# <|im_start|>system
# Tu es un agent OPNsense.<|im_end|>
# <|im_start|>user
# {"directive": "block_ip"}<|im_end|>
# <|im_start|>assistant
Les formats sont cohérents. On relance.
Résultat après correction
Score : 102/102 (100%)
✅ ADAPTATEUR VALIDÉ (CAP v1)
La même loss (~0.25), mais cette fois le modèle a été entraîné et interrogé dans le même référentiel. Il répond correctement à 100% des directives de vérification.
La leçon
Un changement de modèle de base implique de vérifier chaque point de contact entre le code d’entraînement et le format de prompt. La loss ne le fera pas pour vous.
Checklist lors d’une migration de base model :
-
format_single_example_to_text()ou équivalent — utilise-t-ilapply_chat_template? - Les tokens EOS du script de vérification correspondent-ils au nouveau modèle ?
- Le system prompt du script de vérification correspond-il au dataset d’entraînement ?
- Un test rapide côte-à-côte des deux formats avant de lancer un entraînement long
Ce dernier point évite de perdre plusieurs heures de GPU pour découvrir le problème après.
Prochain article : comment valider un agent LoRA de façon comportementale — la vérification fonctionnelle par injection de paquets CAP v1.
