« System message must be at the beginning » : c’est la réponse, en 404, que Qwen 3.5 et Ministral 3 ont renvoyée à toutes les requêtes de mon banc d’essai le 13 octobre 2026. Le banc envoyait deux messages system à la suite (les règles, puis le contexte de la personne), ce que Gemma accepte et que le gabarit de conversation de ces deux familles refuse. Le criblage a noté 22 cas bloquants sans qu’aucune requête n’atteigne le modèle. Le correctif tient en une fonction : réunir les messages système de tête en un seul, derrière une option --systeme-unique.
Le banc : 22 cas pilotes, deux messages système
L’assistant du Journal des Nuisances, une application de suivi de nuisances que j’ai co-fondée, tourne sur un modèle local. Je cherchais un modèle plus léger que la référence, assistant-gemma4, à servir sur mac-train, un Mac mini M2 Pro 16 Go, avec mlx_lm.server (mlx-lm 0.32.0).
Le banc d’essai, écrit en TypeScript, rejoue 22 cas pilotes contre un candidat, passe des contrôles déterministes puis un LLM-juge. Un cas bloquant est un cas qui ne passe pas : contrôle en échec, échec critique relevé par le juge, cas non jugé… ou erreur de la requête.
La requête suit l’ordre du prompt de production. La fonction composerMessages produit deux messages system, puis la conversation :
export function composerMessages(systeme: string, contexte: string | null, conversation: MessageConversation[]) {
const tete: { role: "system"; content: string }[] = [{ role: "system", content: systeme }];
if (contexte) tete.push({ role: "system", content: contexte });
return [...tete, ...conversation];
}
Le premier est le socle (les règles de l’assistant, identiques pour tout le monde), le second le contexte de la personne. Les séparer garde le socle en tête du prompt, là où le cache de préfixe peut le réutiliser.
« System message must be at the beginning » : qui refuse quoi ?
Le gabarit de conversation (chat template) est le petit programme Jinja, livré avec le modèle, qui transforme la liste de messages en texte. Celui de Qwen 3.5 et celui de Ministral 3 n’acceptent un message system qu’en première position : le second déclenche l’erreur, et le serveur répond 404 « System message must be at the beginning » avant toute génération.
| Famille (MLX 4 bits) | Deux messages system |
|---|---|
| Gemma 4 | accepté |
| Qwen 3.5 | refusé |
| Ministral 3 | refusé |
| Apertus 8B (criblé le 17-18/10) | refusé |
Rien d’exotique dans la requête : l’API OpenAI accepte plusieurs messages system, c’est le gabarit du modèle qui fixe la règle.
Une erreur HTTP comptée comme bloquante
Le banc attrape l’erreur de la requête et la range dans la mesure du cas :
if (!reponse.ok) {
throw new Error(`HTTP ${reponse.status} ${await reponse.text()}`);
}
// …
} catch (err) {
m.erreur = (err as Error).message;
}
Depuis le 7 octobre, la fonction bloquant() compte un cas en erreur comme bloquant, au même titre qu’un contrôle raté :
export function bloquant(r: ResultatCas): boolean {
return (
r.controles.some((c) => !c.ok && !c.informatif) ||
(r.jugement?.echecs ?? []).some((e) => e.gravite === "critique") ||
r.nonJuge === true ||
r.mesures.some((m) => m.erreur)
);
}
Le criblage du 13 octobre a donc affiché 22 bloquants sur 22. Le rapport imprime (erreur : …) à la place de la réponse quand une mesure a échoué : le rouge venait avec sa cause, et cette cause n’était pas le modèle, qui n’avait rien lu.
Un banc de LLM doit distinguer « le modèle a mal répondu » de « la requête n’est jamais arrivée » : un 404 n’est pas une note.
Le correctif : --systeme-unique
fusionnerSysteme réunit les messages system de tête en un seul, dans le même ordre, séparés par une ligne vide :
export function fusionnerSysteme(messages: Messages): Messages {
const n = messages.findIndex((m) => m.role !== "system");
const tete = n < 0 ? messages : messages.slice(0, n);
if (tete.length < 2) return messages;
return [
{ role: "system", content: tete.map((m) => m.content).join("\n\n") },
...messages.slice(tete.length),
];
}
L’ordre socle → contexte est conservé : le socle reste le début du prompt, comme dans l’article sur l’ordre stable → variable du cache de préfixe. L’option --systeme-unique l’active pour une cible compatible OpenAI ; Gemma, qui accepte deux messages, garde la composition de production. Un test vérifie que la requête n’envoie plus que ["system", "user"], avec le contexte dans le premier message.
Rejoué le soir même, le criblage a enfin mesuré des modèles : Ministral 3 8B à 61,6 % (8 bloquants), Qwen 3.5 9B à 54,3 % (5 bloquants), en un passage.
Le même piège à l’entraînement
La règle vaut partout où ces modèles lisent une conversation. Le lendemain, la même option est passée au générateur du jeu d’entraînement, qui fait répondre un candidat servi par le Mac, puis à preparer.py, qui écrit les fichiers d’affinage pour mlx-lm : les 112 conversations du jeu ouvraient sur deux messages système. La fusion se fait avant l’entraînement, sinon le modèle apprendrait sur un format que son propre gabarit refuse.
def systeme_unique(messages: list[dict]) -> list[dict]:
n = next((i for i, m in enumerate(messages) if m["role"] != "system"), len(messages))
if n < 2:
return messages
fusion = "\n\n".join(m["content"] for m in messages[:n])
return [{"role": "system", "content": fusion}, *messages[n:]]
Lessons learned
- Qwen 3.5 et Ministral 3 refusent un second message
system. Leur gabarit de conversation lève « System message must be at the beginning », et le serveur répond404sans générer. - Fusionner les messages système garde l’ordre du prompt. Socle, ligne vide, contexte : le début du prompt reste identique, le cache de préfixe aussi.
- Une erreur de requête doit compter comme un cas bloquant. Elle rend le criblage rouge, et le message d’erreur dans le rapport dit tout de suite que le modèle n’a rien lu.
- La composition des messages appartient au banc, au générateur et à l’entraînement. Corrigée à un seul endroit, elle laisse un jeu d’entraînement dans un format que le modèle refuse.