Pourquoi la frappe ralentit à mesure qu'une session dure — et comment Hermes Desktop a réduit le coût des deux tiers


Il est 15 h et vous faites défiler une session ouverte depuis la veille, à la recherche des conclusions auxquelles vous êtes arrivé ce matin. Vous remontez en haut, revenez dans le champ de saisie et commencez à taper — puis vous vous arrêtez. Les caractères arrivent un par un. Effacer un caractère prend une demi-seconde. Le curseur a un temps de retard sur vos doigts. Vous essayez une autre machine : même latence. Vous en déduisez que le modèle travaille en arrière-plan, sauf qu’il attend simplement que vous finissiez votre phrase.

Le problème venait de la zone de saisie elle-même. Un changement de performance fusionné dans Hermes Desktop le 26 octobre (PR #144903) rend le mécanisme explicite : plus l’historique chargé est volumineux, plus la frappe ralentit, parce que chaque touche relançait le rendu de toute la transcription. Après le correctif, la même session de 300 tours coûte 9,6 ms de temps sur le thread principal par touche, contre 33,9 ms.

Une touche, un rendu complet

La surface de chat du Desktop repose sur assistant-ui, une bibliothèque de composants pour interfaces de conversation. Le texte de votre champ de saisie ne reste pas sagement dans ce champ : c’est une valeur dans un store partagé, et un nombre surprenant d’éléments s’y abonnent —

  • l’activation ou le grisage du bouton d’envoi ;
  • le fait qu’un ? seul doive faire apparaître l’indice d’aide ;
  • le fait que la ligne soit une commande slash (/model et compagnie) ou un message ordinaire ;
  • l’affichage ou non du texte de substitution vide ;
  • la façon dont les retours à la ligne sont mis en page.

La subtilité, c’est que la vérification de ces abonnements ne se limite pas au champ de saisie. Une écriture dans le store relance le rendu des ressources clientes du fil et revérifie les sélecteurs de chaque message monté. Plus la transcription est longue, plus il y a de tours montés, plus la facture est élevée. Le commentaire du nouveau code chiffre l’ordre de grandeur : environ 0,5 ms par touche dans une session courte, jusqu’à 19 ms dès qu’un historique long est à l’écran — et ce n’est qu’une partie du coût.

Ce qui explique la sensation contre-intuitive : la frappe ne se dégrade pas tant avec l’âge de la session qu’avec le défilement vers le haut. C’est le fait de remonter qui monte réellement l’historique.

Le correctif : n’écrire dans le store que lorsque quelque chose de visible change

Le changement n’est pas un debounce vissé sur le champ de saisie. Il transforme la question « quand cette touche doit-elle atteindre le store ? » en logique explicite. Un nouveau module, composer-store-writer.ts, dérive une signature du texte avec composerTextEdges(text) — exactement les faits dont les abonnés ont besoin :

text.length === 0                          // empty vs not
trimmed.length > 0                         // has real content
text === '?'                               // a lone question mark
trimmed.length > 0 && !SLASH_COMMAND_RE    // ordinary text, not a slash command
text.trimEnd().includes('\n')              // multi-line layout

Si une touche laisse cette signature intacte, tous les abonnés observeraient le même état : l’écriture peut donc attendre. Combien de temps ? TYPING_STORE_IDLE_MS = 180 — une pause de frappe de 180 ms et le texte final arrive dans le store, où la persistance du brouillon et le miroir du runtime le lisent.

Différer les écritures exige de couvrir les cas limites, sinon vous échangez une latence contre un bug :

  • flush() — déposer le texte en attente avant toute lecture qui a besoin d’un store à jour ;
  • cancel() — abandonner le texte en attente au démontage plutôt que de laisser un état périmé ;
  • observed() — adopter comme nouvelle référence un changement venu d’ailleurs (l’abonnement du runtime), pour qu’une ancienne valeur ne puisse pas écraser une nouvelle.

La bande de prompt collante (le champ de saisie reflété, épinglé au-dessus d’une longue transcription) a également cessé de recalculer les styles de chaque tour visible à chaque image ; elle enregistre désormais une variable CSS une seule fois.

Les mesures

La PR fournit un protocole reproductible : une session de 300 tours et 1 800 messages, parcourue avec 2 500 événements de molette pour que tout l’historique soit monté (environ 42 000 éléments DOM), puis 60 caractères tapés.

Mesure Avant Après
Temps sur le thread principal par touche, historique remonté 33,9 ms 9,6 ms
Temps sur le thread principal par touche, ouverture fraîche 12,1 ms 6,0 ms
Vidage du store par touche 18,0 ms 1,3 ms

L’auteur reste prudent sur ce que ces chiffres signifient, et cette honnêteté mérite d’être reprise : des exécutions uniques sur une machine partagée, où une seconde exécution avant correctif a mesuré 49,3 ms par touche. Sur cette machine à 20 cœurs, le délai entre la frappe et l’affichage restait proche d’une image dans les deux cas — la différence entre 33,9 et 9,6 se ressent donc surtout sur du matériel plus lent, là où le temps du thread principal est la ressource rare. Il ne s’agit pas de « tout est devenu trois fois plus rapide » ; il s’agit de « votre portable cesse de payer une taxe par touche sur un historique qu’il a déjà rendu ».

Ce qu’il faut en faire

Le changement est pour l’instant uniquement sur main. La v0.22.5 est sortie le 23 octobre ; cette PR a été fusionnée le 26 octobre, donc même les tags canary de ce jour-là ne le contiennent pas. Trois voies :

  1. attendre la prochaine version et lancer hermes update — le moins d’effort ;
  2. suivre main si vous le voulez aujourd’hui (le changement de canal est décrit dans le guide des canaux de mise à jour) ;
  3. l’atténuer tout de suite en gardant des sessions plus courtes — le coût du champ de saisie évolue avec l’historique monté, donc ouvrir une nouvelle session ou faire le ménage dans les frontières de session aide davantage que n’importe quel réglage.

Si votre reproche portait en réalité sur un Desktop qui démarre lentement, c’est un problème distinct, traité dans l’analyse sur la latence au démarrage. Ce changement n’y fait rien.

Pourquoi celui-ci mérite d’être retenu

Nous avons spontanément tendance à accuser le modèle quand un agent semble lent : trop de tokens, trop de contexte, un fournisseur qui met les requêtes en file. Ce bug rappelle que la couche d’interface accumule sa propre dette de performance, et que cette dette est particulièrement invisible — elle n’apparaît jamais dans un log ni dans une trace, seulement entre vos doigts et l’écran.

Alors la prochaine fois que quelque chose semble lent, demandez-vous quelle moitié est lente. Une frappe lente alors que les réponses arrivent normalement, c’est un problème de rendu. Les deux lents, c’est le moment d’aller regarder le modèle et le réseau. Séparer cette question en deux épargne l’essentiel du débogage.