
Je suis Jules Ginhac, Co-Founder & ingénieur IA chez Apogée Consult à Lyon. Je conçois et déploie des architectures IA génératives (RAG, agents, LLMOps) pour des PME, startups et organisations publiques.
Streaming de réponses LLM : les 5 pièges UX qu'on n'a vus qu'en prod
- ia-produit
Le streaming SSE d'un LLM semble simple à implémenter. Cinq problèmes UX nous ont frappé uniquement en production, sur des utilisateurs réels. Voici ce qu'on a appris.
Streaming de réponses LLM : les 5 pièges UX qu'on n'a vus qu'en prod
En développement, le streaming est satisfaisant à regarder. Les tokens arrivent, le texte s'affiche, tout semble fluide. En production, avec des connexions dégradées, des utilisateurs qui cliquent pendant que la réponse arrive, et du markdown rendu en temps réel, les problèmes apparaissent. Voici les cinq qu'on n'avait pas anticipés.
Piège 1 : le markdown qui clignote pendant le rendu
Le streaming renvoie du texte brut que vous rendez progressivement en markdown. Le problème : un token * seul est un astérisque. Deux tokens ** sont le début d'un gras. Trois tokens **text sont du gras incomplet. Le parser markdown voit des états intermédiaires invalides et les rend de façon chaotique.
En pratique : les titres apparaissent et disparaissent, les blocs de code s'ouvrent puis se referment, les listes clignotent.
La solution est d'utiliser un parser markdown incrémental, ou, plus simplement, de ne rendre le markdown complet qu'une fois le streaming terminé, et d'afficher du texte brut pendant le stream.
function StreamingMessage({ content, isComplete }: {
content: string;
isComplete: boolean;
}) {
if (isComplete) {
return <ReactMarkdown>{content}</ReactMarkdown>;
}
// Pendant le stream : texte brut avec saut de ligne préservé
return (
<pre className="whitespace-pre-wrap font-sans">
{content}
</pre>
);
}Le compromis visuel est acceptable : le markdown s'active d'un coup à la fin. Les utilisateurs remarquent rarement cette transition. Ils remarquent beaucoup le clignotement.
Piège 2 : le scroll automatique qui lutte contre l'utilisateur
Beaucoup d'implémentations scrollent automatiquement vers le bas à chaque nouveau token. L'intention est de suivre la réponse en cours. Le problème : si l'utilisateur a scrollé vers le haut pour relire le début de la réponse, l'auto-scroll le ramène en bas contre sa volonté.
La règle : l'auto-scroll ne s'applique que si l'utilisateur est déjà en bas. S'il a scrollé vers le haut, on suspend l'auto-scroll jusqu'à ce qu'il revienne en bas volontairement.
function useAutoScroll(ref: React.RefObject<HTMLElement>, content: string) {
const isAtBottomRef = useRef(true);
useEffect(() => {
const el = ref.current;
if (!el) return;
const handleScroll = () => {
const threshold = 50; // px de tolérance
isAtBottomRef.current =
el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
};
el.addEventListener('scroll', handleScroll, { passive: true });
return () => el.removeEventListener('scroll', handleScroll);
}, [ref]);
useEffect(() => {
const el = ref.current;
if (!el || !isAtBottomRef.current) return;
el.scrollTop = el.scrollHeight;
}, [content, ref]);
}Piège 3 : l'interruption silencieuse
Une connexion coupée pendant le streaming ne lève pas toujours une erreur explicite. La connexion se ferme, l'event source se tait, et l'interface reste dans un état "streaming en cours" sans jamais passer à "terminé".
En développement, les connexions ne se coupent pas. En production, avec des utilisateurs sur mobile ou derrière un VPN instable, ça arrive plusieurs fois par heure.
Il faut un timeout explicite sur l'absence de nouveaux tokens :
const STREAM_TIMEOUT_MS = 8_000;
async function streamWithTimeout(
stream: AsyncIterable<StreamChunk>,
onChunk: (text: string) => void,
onDone: () => void,
onError: (e: Error) => void
) {
let timeoutId: ReturnType<typeof setTimeout>;
const resetTimeout = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
onError(new Error('Stream interrompu : aucun token reçu depuis 8 secondes'));
}, STREAM_TIMEOUT_MS);
};
try {
resetTimeout();
for await (const chunk of stream) {
resetTimeout();
onChunk(chunk.delta.text);
}
clearTimeout(timeoutId);
onDone();
} catch (e) {
clearTimeout(timeoutId);
onError(e instanceof Error ? e : new Error(String(e)));
}
}L'erreur doit être visible : un message "La réponse a été interrompue. Réessayer ?" vaut mieux qu'une interface bloquée.
Piège 4 : le bouton "Envoyer" reste accessible pendant le stream
Si l'utilisateur peut soumettre un nouveau message pendant que le précédent est en cours de streaming, vous avez deux streams concurrents qui écrivent dans le même état. Le résultat est un mélange de tokens illisible.
La correction évidente est de désactiver le bouton. La correction correcte est de désactiver le champ et de proposer un bouton "Arrêter" qui annule le stream en cours :
<div>
<textarea
disabled={isStreaming}
value={input}
onChange={e => setInput(e.target.value)}
/>
{isStreaming ? (
<button onClick={abortStream} type="button">
Arrêter
</button>
) : (
<button type="submit">
Envoyer
</button>
)}
</div>L'annulation du stream côté client doit aussi signaler l'annulation au serveur si votre implémentation maintient une connexion active. Sinon, le modèle continue de générer des tokens que personne ne lit, et vous payez pour rien.
Piège 5 : l'état "en cours" qui persiste après une erreur serveur
Un 500 pendant le streaming arrive après que les headers HTTP ont déjà été envoyés (code 200 inclus). L'EventSource ne voit pas un code d'erreur HTTP, il voit une connexion qui se ferme. L'interface ne sait pas distinguer une fin normale d'une fin anormale.
La solution : envoyer un event SSE explicite pour les erreurs, avant de fermer la connexion.
// Côté serveur (Node.js / Next.js route handler)
async function POST(req: Request) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
try {
for await (const chunk of llmStream) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'chunk', text: chunk })}\n\n`)
);
}
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'done' })}\n\n`)
);
} catch (error) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'error', message: 'Erreur serveur' })}\n\n`)
);
} finally {
controller.close();
}
}
});
return new Response(stream, {
headers: { 'Content-Type': 'text/event-stream' }
});
}Côté client, vous traitez l'event error comme une fin anormale et affichez le message approprié.
Ce qu'il reste à résoudre
Ces cinq pièges sont les plus fréquents que nous ayons rencontrés. Il en reste d'autres que nous n'avons pas encore résolus proprement : la gestion du streaming sur des connexions à haute latence (>500ms RTT) où les tokens arrivent par paquets plutôt qu'un par un, et le comportement correct quand l'utilisateur ferme et rouvre l'onglet pendant un stream actif.
Si vous avez des solutions établies sur ces deux cas, nous sommes preneurs.