Aller au contenu
Sylvain Joffraud l4w
Retour

mlx_lm.server : les commentaires SSE cassent le client

mlx_lm.server envoie des lignes de keepalive SSE, : keepalive 19/33, pendant qu’il lit le prompt, avant le premier morceau de réponse. Mon client de flux OpenAI passait chaque ligne à JSON.parse : la première de ces lignes faisait échouer la réponse entière, et le banc d’essai du 12 octobre 2026 a compté 151 cas bloquants sur 151. Une ligne qui commence par deux-points est un commentaire SSE, que la spécification demande d’ignorer. Le correctif tient en une ligne ; la version de production, écrite ensuite, ne lit que les lignes data:.

Le symptôme : 151 cas bloquants sur 151

L’assistant du Journal des Nuisances, une application de suivi de nuisances que j’ai co-fondée, tourne sur un modèle local. Pour tester des candidats plus légers, je les sers sur mac-train, un Mac mini M2 Pro 16 Go, avec mlx_lm.server (mlx-lm 0.32.0), qui expose /v1/chat/completions au format OpenAI.

Le banc d’essai, en TypeScript, appelle ce serveur en flux (stream: true). Il avait été écrit contre llama-server (llama.cpp), sur pc-llm, et il y marchait. Le 12 octobre, premier banc d’un modèle affiné par LoRA sur le Mac : chaque réponse en erreur, chaque cas bloquant. Un modèle affiné qui ne sait plus rien dire, c’est plausible ; c’était faux.

Ce que mlx_lm.server envoie avant le premier mot

Le flux d’un serveur compatible OpenAI est du SSE (Server-Sent Events, un flux HTTP texte où chaque événement tient en lignes champ: valeur, séparées par une ligne vide). Les morceaux de réponse arrivent dans des lignes data: ; le flux se termine par data: [DONE].

Pendant la lecture du prompt, mlx_lm.server ajoute des lignes de commentaire dont le compteur avance :

: keepalive 19/33

: keepalive 33/33

data: {"choices":[{"delta":{"content":"Vous pouvez"}}]}

data: {"choices":[{"delta":{"content":" noter les faits."}}]}

data: {"choices":[],"usage":{"prompt_tokens":3000,"completion_tokens":5}}

data: [DONE]

llama-server n’en envoyait pas, d’où un client qui avait l’air correct.

Pourquoi JSON.parse échouait sur : keepalive

Le client retirait le préfixe data: , puis parsait ce qui restait :

const data = ligne.trim().replace(/^data: ?/, "");
if (!data || data === "[DONE]") return;
const o = JSON.parse(data); // ": keepalive 19/33" → SyntaxError

Une ligne : keepalive 19/33 ne commence pas par data: : elle passait intacte à JSON.parse, qui levait une SyntaxError. L’exception remontait jusqu’au catch de la requête, qui rangeait le message dans la mesure du cas. Le banc compte toute erreur de requête comme un cas bloquant, d’où le 151 sur 151.

Le comptage a eu le bon réflexe : le rapport affichait l’erreur à la place de la réponse, et non une note sur le modèle. J’ai décrit le même mécanisme avec l’erreur System message must be at the beginning de Qwen 3.5 et Ministral 3.

Le correctif : ignorer les lignes qui commencent par deux-points

La spécification HTML des Server-Sent Events est explicite : une ligne qui commence par : est un commentaire, à ignorer. Dans le banc :

// Une ligne qui commence par « : » est un commentaire SSE :
// mlx_lm.server en envoie (« : keepalive 19/33 ») pendant qu'il lit le prompt.
if (ligne.trimStart().startsWith(":")) return;
const data = ligne.trim().replace(/^data: ?/, "");

Un client SSE qui parse toute ligne non vide comme du JSON marche avec un serveur, jusqu’au premier qui envoie un commentaire.

Un test Vitest rejoue le flux ci-dessus avec un fetch simulé et vérifie trois choses : pas d’erreur, le texte recollé (« Vous pouvez noter les faits. »), et les 3 000 jetons du prompt lus dans le morceau d’usage.

Côté production : ne lire que les lignes data:

Le 17 octobre, l’Edge Function assistant-repondre a appris à parler à mlx_lm.server : une adresse qui finit par /v1 bascule l’appel au format OpenAI. Son lecteur de flux part de l’autre bout. Au lieu d’exclure les commentaires, il ne garde que les lignes data:, et un JSON illisible devient un événement d’erreur au lieu d’une exception :

for (const ligne of lignes) {
  const l = ligne.trim();
  if (!l.startsWith("data:")) continue; // commentaires, event:, id:, lignes vides
  const data = l.slice(5).trim();
  if (data === "[DONE]") { evenements.push({ type: "fin", fin }); continue; }
  try {
    objet = JSON.parse(data);
  } catch {
    evenements.push({ type: "erreur", message: "ligne_illisible" });
    continue;
  }
  // …
}

Le lecteur garde aussi la ligne coupée entre deux morceaux réseau, et les jetons du morceau d’usage jusqu’au [DONE]. C’est le même flux SSE que la fonction relaie déjà au navigateur, adopté à cause du proxy qui coupait les réponses longues, comme expliqué dans l’article sur le préchauffage du prompt au déploiement.

LecteurRègleLigne : keepalive
Banc, avant le 12/10tout parser sauf [DONE]SyntaxError, réponse perdue
Banc, aprèsignorer les lignes :ignorée
Edge Functionne lire que data:ignorée

Lessons learned


Partager cet article :

Article précédent
Date métier en JS : `toISOString()` recule d’un jour
Article suivant
RLS Postgres : `INSERT … RETURNING` refusé en 403