~/blog / sentiment-temps-réel-200ms
⚙️ craft & architecture · analyse de sentiment en temps réel

Analyse de sentiment en moins de 200 ms

Luc Del Beato 11 juin 2026 11 min de lecture

Vous tapez un email, un message Slack, un post LinkedIn. Pendant que vos doigts courent sur le clavier, le serveur doit déjà avoir lu, compris, scoré le ton et le risque, et renvoyé des surlignages. Le tout en moins de 200 ms, sinon ça ne « sent » plus instantané, ça lague. Voici comment on conçoit une pipeline qui tient ce budget.

TL;DR

Le point de départ : un budget de latence, pas un espoir

Une extension Chrome analyse vos messages au fil de la frappe. L'enjeu produit est simple à énoncer, brutal à tenir : tant que la réponse arrive sous ~200 ms, l'utilisateur perçoit le surlignage comme une extension de son éditeur. Au-delà, il a déjà cliqué « envoyer ». La feature meurt non pas parce qu'elle est fausse, mais parce qu'elle est lente.

La première erreur serait de coder la logique d'abord et de « voir si c'est assez rapide » ensuite. On fait l'inverse : on pose le budget, 200 ms, puis on répartit ce budget entre chaque étape comme on répartit une enveloppe entre des postes de dépense. Chaque milliseconde a un propriétaire.

Un budget de latence se conçoit, il ne s'espère pas. On décide « 150 ms pour l'I/O, 50 ms pour le CPU » avant d'écrire une ligne, puis on défend chaque poste.

Étape 1, Paralléliser l'I/O, parce que c'est lui le coupable

Dans une pipeline d'analyse, ce qui coûte cher en temps n'est pas le calcul, c'est l'attente réseau. Trois appels indépendants pèsent sur notre budget : l'analyse NLP du texte (déléguée à Lettria), la vérification de sûreté/catégorisation des liens présents, et l'analyse du sujet du message. Séquentiels, ils s'additionnent et explosent le budget. Concurrents, ils ne coûtent que le plus lent des trois.

Le réflexe naïf serait Promise.all. Mais all a un défaut fatal en production : si un seul appel échoue ou traîne, il fait tomber tout le bloc. Or une analyse de liens qui timeout ne doit jamais empêcher l'analyse du ton de revenir. On veut une dégradation gracieuse, pas un échec en cascade.

D'où Promise.allSettled : il attend que tout soit retombé, réussi ou échoué, et nous rend un statut par appel. Une dépendance instable dégrade le résultat (un highlight en moins) au lieu de crasher la requête entière.

// les 3 appels I/O partent ensemble, aucun ne bloque les autres
const [nlp, links, subject] = await Promise.allSettled([
  lettria.analyze(text),        // ~100-150 ms, le plus lent
  scanLinks(extractUrls(text)), // sûreté + catégorisation
  analyzeSubject(subjectLine),  // ton / clarté de l'objet
]);

// dégradation gracieuse : on lit ce qui a réussi, on ignore le reste
const nlpResult = nlp.status === 'fulfilled' ? nlp.value : null;
if (!nlpResult) return earlyExit();   // sans NLP, pas de règles fiables
const linkFindings = links.status === 'fulfilled' ? links.value : [];
const subjectInfo  = subject.status === 'fulfilled' ? subject.value : null;
💡
Le détail qui change tout : allSettled ne « masque » pas les erreurs, il les rend explicites par appel. Chaque consommateur décide alors quoi faire d'une absence, un highlight manquant plutôt qu'un écran cassé. C'est la différence entre une feature qui survit à une panne partielle et une qui s'effondre au premier hoquet d'un tiers.

Étape 2, Un moteur de ~17 règles, in-process

Une fois le résultat NLP en main, le travail intéressant commence, et il est local. Plus aucun appel réseau : on déroule un moteur de ~17 règles pluggables, chacune consommant le même résultat NLP partagé et émettant des findings classés dangereux ou discutable.

L'intérêt de tout faire tourner in-process : ces 17 règles ne coûtent ensemble que ~30-50 ms de CPU, parce qu'elles partagent un seul parsing NLP au lieu de refaire chacune leur propre analyse. On a déjà payé le coût réseau une fois ; on l'amortit sur toutes les règles.

« Pluggable » n'est pas un mot creux : chaque règle est un module isolé qui prend en entrée le résultat NLP normalisé et retourne une liste de findings. On en ajoute, on en désactive, on en teste une unitairement sans toucher au reste. Le moteur n'est qu'un orchestrateur qui les déroule et agrège.

Étape 3, Le filtrage métier, ce qui rend l'outil crédible

Voilà la partie qu'on sous-estime toujours. Un modèle qui détecte « colère » dans une phrase, c'est facile. Un outil qui ne nag pas l'utilisateur pour rien, c'est dur. Et c'est précisément ce filtrage métier qui sépare un assistant utile d'un détecteur de fumée qui hurle à chaque cuisson.

Deux exemples concrets de nuance qu'on a dû coder à la main, par-dessus le NLP brut :

Ces règles paraissent triviales prises isolément. Mises bout à bout, elles font toute la différence entre un outil qu'on garde activé et un outil qu'on désactive au bout d'une heure parce qu'il crie au loup. La qualité perçue d'une « analyse IA » ne vient pas du modèle, elle vient de cette couche de bon sens qui filtre ses excès.

🎯
La vraie leçon « IA » : le difficile n'est jamais l'appel au modèle. C'est le filtrage métier autour, celui qui transforme une sortie brute statistiquement correcte en une sortie qu'un humain juge pertinente. La confiance se gagne sur les faux positifs qu'on a tués, pas sur les vrais positifs qu'on a trouvés.

Étape 4, Le contexte, via une requête DB bon marché

La règle de politesse illustre bien pourquoi le contexte est indispensable. Flaguer « pas de salutation » sur le premier email d'un échange, oui. Le flaguer sur le quinzième message d'une conversation en cours dans la journée, c'est agaçant et faux : on ne se redit pas « Bonjour » toutes les trois minutes.

La parade tient en une requête indexée : la règle vérifie s'il existe des messages envoyés au même destinataire dans les dernières 24 h. Si oui, on est en pleine conversation, et l'absence de salutation devient normale, on ne la signale pas. L'index sur (channel, recipient, created) rend ce lookup quasi gratuit dans notre budget.

// la règle de politesse ne nag pas au milieu d'un échange
SELECT 1
FROM messages
WHERE channel   = :channel
  AND recipient = :recipient
  AND created  >= now() - interval '24 hours'
LIMIT 1;
-- index: (channel, recipient, created)  → lookup ~quelques ms

Étape 5, Un stockage idempotent par instanceId

Dernier piège : l'utilisateur tape, corrige, retape. Chaque frappe peut relancer une analyse du même message. Si on insérait naïvement, on accumulerait dix analyses obsolètes pour un seul brouillon, et l'historique deviendrait illisible.

La solution : le client envoie un instanceId stable pour un brouillon donné. Côté serveur, avant d'insérer la nouvelle analyse, on marque les précédentes du même instanceId comme isLast: false. Le stockage devient idempotent, une ré-analyse remplace logiquement la précédente plutôt que de la dupliquer, et une seule ligne porte toujours l'état courant.

Le budget, poste par poste

Mis bout à bout, voici comment les 200 ms se répartissent. L'astuce est visible d'un coup d'œil : les trois appels I/O ne s'additionnent pas, ils se chevauchent (∥). On ne paie que le plus lent.

┌──────────────────────────────────────────────────────────┐
│  I/O EN PARALLÈLE  (Promise.allSettled)                    │
│    Lettria NLP   ~100-150 ms  ████████████████             │
│    liens         ~ 40- 80 ms  ████████         ∥           │
│    sujet         ~ 30- 60 ms  ██████           ∥           │
│  → coût réel = le plus lent ≈ 100-150 ms                   │
├──────────────────────────────────────────────────────────┤
│  MOTEUR DE RÈGLES (17, in-process)  ~30-50 ms  ██████      │
├──────────────────────────────────────────────────────────┤
│  INSERT idempotent                  ~10-20 ms  ███         │
└──────────────────────────────────────────────────────────┘
   TOTAL  ≈  150 - 200 ms   ✅  perçu comme instantané

Tout le design découle de cette image. Le NLP domine, donc on le lance en premier et en parallèle de tout le reste. Les règles sont gratuites en réseau, donc on les garde in-process. L'insert est le poste le plus compressible, donc on l'optimise en dernier. Chaque décision d'archi répond à une question : « ce poste rentre-t-il dans son enveloppe ? »

Ce que je retiens

Cette pipeline n'est qu'une moitié de l'histoire : côté navigateur, il faut capter le texte au fil de la frappe sur des dizaines de plateformes hétérogènes. J'en parle dans l'article sur l'architecture de l'extension Chrome multi-plateformes, l'autre bout du même fil temps réel.

// craft & architecture

Une pipeline temps réel à concevoir ?

Budgets de latence, I/O parallèle, dégradation gracieuse, filtrage métier qui rend une sortie IA crédible, c'est exactement le genre de problème que j'aime tenir en production. Parlons-en.

Parlons-en
L
Luc Del Beato

Senior Lead Engineer, ~20 ans de web. Do-er passionné de résolution de problèmes, de belle architecture et d'automatisation ; les agents IA, c'est ma direction. Mon parcours →