Flux : l'écriture d'un gros fichier ne coûte plus un temps quadratique côté Go

Pendant qu'un modèle tapait un write de 120 Ko, chaque morceau faisait relire
et redécoder tout le JSON des arguments, puis republiait le corps ENTIER à
chaque ligne : 250 Mo de flux SSE, autant de tas vivant dans le journal, et
près de 8 s de CPU volées au décodage quand des experts tournent sur le
processeur. Même travers pour la détection des appels d'outils écrits en
texte, qui repassait toutes les regex sur toute la réponse à chaque jeton.
Rien de ce que voit le modèle ne change : les arguments exécutés restent
entiers, la passe complète de fin de tour aussi.

- argPreview : lecture incrémentale de l'argument affiché et du corps, même
  règle que previewArgDone (barre oblique coupée gardée en attente) ;
  arguments accumulés dans un strings.Builder, cur.Function.Arguments recalé
  sur lui à chaque morceau.
- Événement de frappe : seules les 40 dernières lignes (4 Kio au plus) du
  corps, avec body_lines (vrai « +N ») et body_tail ; l'UI affiche « … » et
  garde le repli sur le décompte local.
- textualToolCallFrom : ne relit que la fin, depuis un vrai début de ligne
  avant la fin du balayage précédent, en reculant sur les suites que les
  motifs embarqués peuvent traverser. Surcharge retry_patterns : balayage
  complet, comme avant.
- Direct SSE : les événements trouvés à un réveil partent en une écriture
  (lots de 128 Kio / 256 événements au plus), un sceau par événement en E2E.
- Tests : fuzz d'argPreview contre previewArgDone, équivalence fenêtre /
  balayage complet au morceau près, corps borné de bout en bout, ordre des
  lots ; bancs d'essai.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 01:48:49 +02:00
1 parent eaf5e313d9
commit f1f6cbe4ea
9 files changed
+901 -54

No files matched your search

+33 -1
View File
@@ -840,6 +840,10 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// ne l'ajoute que quand il est là pour ne pas gonfler chaque événement.
if ev.ToolUsed.Body != "" {
tu["body"] = ev.ToolUsed.Body
tu["body_lines"] = ev.ToolUsed.BodyLines
if ev.ToolUsed.BodyTail {
tu["body_tail"] = true
}
}
// Lignes +/- d'une écriture (edit / mémoire) : persistées avec le reste
// pour que le diff soit encore là après un rafraîchissement.
@@ -1289,6 +1293,31 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
// Bloque jusqu'à ce que ctx (la connexion HTTP) soit annulé — la génération, elle,
// continue indépendamment. emit renvoie false si l'écriture échoue (client parti).
func (c *Conversation) SubscribeTail(ctx context.Context, from int, tail int, convID string, emit func(map[string]any) bool) {
c.subscribeSink(ctx, from, tail, convID, tailSink{emit: emit})
}
// tailSink : sortie d'un abonné. emit écrit et pousse aussitôt ; queue met en
// tampon (en écrivant d'office un tampon plein) et flush pousse ce qui attend.
// Sans queue/flush, tout passe par emit, un événement à la fois.
type tailSink struct {
emit func(map[string]any) bool
queue func(map[string]any) bool
flush func() bool
}
// subscribeSink : SubscribeTail, en regroupant le direct. Au réveil, plusieurs
// événements peuvent attendre (un jeton chacun) : une écriture et un flush par
// événement multipliaient les appels système et les trames d'un client en
// retard, ou d'un relais chiffré. Ils partent ensemble, dans l'ordre.
func (c *Conversation) subscribeSink(ctx context.Context, from int, tail int, convID string, sink tailSink) {
emit := sink.emit
queue, flush := sink.queue, sink.flush
if queue == nil {
queue = emit
}
if flush == nil {
flush = func() bool { return true }
}
// Réveille les attentes de cond quand la connexion se ferme.
go func() {
<-ctx.Done()
@@ -1448,10 +1477,13 @@ func (c *Conversation) SubscribeTail(ctx context.Context, from int, tail int, co
// `lastEmitted` (avant mise à jour) sert de repère : si la compaction de
// fin de tour vient de fusionner un bloc que le client suivait en direct,
// l'événement fusionné arrive avec un Seq supérieur au sien → `replace`.
if !emit(decorateEvent(ev, lastEmitted)) {
if !queue(decorateEvent(ev, lastEmitted)) {
return
}
}
if !flush() {
return
}
c.mu.Lock()
}
}