Contexte : corrections de relecture — la garde de marge ne compacte plus en boucle, et le preset externe compte comme avant

La garde de marge de génération réclamait 1,2 × la plus longue génération
récente, sans plafond. Une étape de 40 k sur une fenêtre de 65 k (ou de 20 k
sur 32 k, sans budget de raisonnement) demandait plus de place que
l'historique fraîchement compacté n'en laisse : la garde repartait à chaque
étape, et chaque fois résumait le résumé précédent.

- Garde : jamais avant la moitié de la fenêtre. Au-delà, le rejeu sur
  « length » reste le filet.
- Preset externe : le complément « messages arrivés depuis la mesure » ne
  s'applique plus. Son CtxUsed garde le raisonnement, qui couvrait déjà ce
  complément ; l'ajouter le faisait compacter plus tôt qu'avant.
- Journal [ctx] : l'estimation d'avant compaction (début de tour, en tour)
  n'est plus comparée au compte d'une requête compactée, ce qui donnait de
  faux écarts.
- Tests : génération énorme juste après compaction, seuil de 50 % pour toutes
  les fenêtres, comptage externe contre local.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 00:53:38 +02:00
1 parent d6023ef8a0
commit 26fc348506
4 files changed
+59 -11

No files matched your search

+7 -1
View File
@@ -234,12 +234,18 @@ func compactNeeded(msgs []Message, used, peakGen int) bool {
// fenêtre pour les petites), plus une petite marge. Fonction pure, testable.
// Plancher et marge sont choisis pour ne JAMAIS devancer le seuil de 75 % à eux
// seuls : seule une génération longue avance la compaction.
//
// Et jamais avant la MOITIÉ de la fenêtre, si longue que soit la génération :
// une étape de 40 k sur 65 k (ou de 20 k sur 32 k) réclamait sinon une place
// que l'historique compacté lui-même — prompt système, outils, résumé, queue —
// ne laisse pas. La garde repartait alors à CHAQUE étape, résumé de résumé, le
// fil perdu pour rien. Au-delà, c'est le rejeu sur « length » qui rattrape.
func genHeadroomShort(used, peakGen, window int) bool {
if used <= 0 || peakGen <= 0 || window <= 0 {
return false
}
need := max(peakGen*6/5, min(8192, window/8)) + min(1024, window/32)
return used+need > window
return used+min(need, window/2) > window
}
// logCtx trace un fait du comptage de contexte (fenêtre pleine, relance après
+28 -10
View File
@@ -752,27 +752,35 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// sous-agents, vérification) est rattaché à la discussion active.
ctx = withPerf(ctx, perfMain, getStr(bkChat, ckActive))
// llama-server local seulement : le preset externe garde le seul seuil, et
// ses complétions ne nourrissent pas la garde de marge (compactNeeded).
local := !externalActive()
// Snapshot de la vue modèle.
c.mu.Lock()
msgs := append([]Message(nil), c.Messages...)
// Dernier compte du moteur + ce qu'il n'a pas encore vu (ce message-ci, la
// file). Sans ce complément, seul le raisonnement compté à tort masquait le
// manque ; maintenant qu'il est retiré, le nouveau message doit compter.
ctxUsed := ctxPending(c.CtxUsed, c.ctxUsedLen, msgs)
ctxUsed := c.ctxNowLocked(msgs, local)
peak := c.genPeak
c.genPeak = 0 // recompté pendant ce tour
c.mu.Unlock()
// llama-server local seulement : le preset externe garde le seul seuil, et
// ses complétions ne nourrissent pas la garde de marge (compactNeeded).
local := !externalActive()
// Compaction proactive (façon Hermes) sur la vue MODÈLE uniquement ; le journal
// d'affichage garde le fil complet. Le résumé est un appel modèle non streamé :
// il bloque plusieurs secondes AVANT que la vraie réponse commence, d'où la
// bannière de progression émise par compactAndPublish.
// Estimation de début de tour, comparée au premier compte réel du moteur
// (journal [ctx], seulement au-delà de 2 % d'écart).
startEst := ctxUsed
if compactNeeded(msgs, ctxUsed, peak) {
if out, changed := c.compactAndPublish(ctx, epoch, "début-tour", msgs, ctxUsed, caps); changed {
msgs = out
// Le chiffre d'avant compaction ne dit plus rien de la requête qui
// part : on compare celui que la compaction vient de poser.
c.mu.Lock()
startEst = c.CtxUsed
c.mu.Unlock()
}
}
@@ -809,9 +817,6 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// l'usage est arrivé ; sinon on retombe sur l'estimation (celle qui pilote
// déjà la compaction), approximative mais jamais absente.
sawUsage := false
// Estimation de début de tour, comparée au premier compte réel du moteur
// (journal [ctx], seulement au-delà de 2 % d'écart).
startEst := ctxUsed
turnPeak := 0
// asked : le tour s'est terminé sur une question à l'utilisateur (outil ask) —
// pas de vérification du mode Code avant sa réponse.
@@ -923,7 +928,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
c.genPeak = max(c.genPeak, turnPeak)
}
msgs = append([]Message(nil), c.Messages...)
ctxUsed = ctxPending(c.CtxUsed, c.ctxUsedLen, msgs)
ctxUsed = c.ctxNowLocked(msgs, local)
peak = c.genPeak
stale := c.epoch != epoch
c.mu.Unlock()
@@ -950,7 +955,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
c.codeVerifyLoop(ctx, caps, temperature, epoch, asked)
c.mu.Lock()
msgs = append([]Message(nil), c.Messages...)
ctxUsed = ctxPending(c.CtxUsed, c.ctxUsedLen, msgs)
ctxUsed = c.ctxNowLocked(msgs, local)
peak = c.genPeak
stale = c.epoch != epoch
c.mu.Unlock()
@@ -973,6 +978,18 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
c.compactAndPublish(context.Background(), epoch, "fin-tour", msgs, ctxUsed, caps)
}
// ctxNowLocked : contexte à juger pour compacter, c.mu tenu. llama-server
// local : le dernier compte plus les messages arrivés depuis (ctxPending). Preset
// externe : le dernier compte seul, comme avant — son CtxUsed garde encore le
// raisonnement (ctxAfter n'y retire rien), qui couvre déjà ce complément ; l'y
// ajouter le ferait compacter plus tôt qu'avant.
func (c *Conversation) ctxNowLocked(msgs []Message, local bool) int {
if !local {
return c.CtxUsed
}
return ctxPending(c.CtxUsed, c.ctxUsedLen, msgs)
}
// CompactNow force une compaction du contexte MAINTENANT, sans attendre le seuil
// (bouton « compacter » de l'UI). Détaché comme la génération : émet la bannière
// de progression, résume les anciens tours, remplace le torse et persiste. Les
@@ -982,6 +999,7 @@ func (c *Conversation) CompactNow() error {
if !healthCheck() {
return errModelLoading
}
local := !externalActive()
c.mu.Lock()
if c.Generating {
c.mu.Unlock()
@@ -991,7 +1009,7 @@ func (c *Conversation) CompactNow() error {
ctx, cancel := context.WithCancel(context.Background())
c.cancel = cancel
msgs := append([]Message(nil), c.Messages...)
lastReal := ctxPending(c.CtxUsed, c.ctxUsedLen, msgs) // dernier contexte réel mesuré, pour estimer le surcoût fixe
lastReal := c.ctxNowLocked(msgs, local) // dernier contexte réel mesuré, pour estimer le surcoût fixe
epoch := c.epoch
c.mu.Unlock()
+23
View File
@@ -47,6 +47,8 @@ func TestGenHeadroomShort(t *testing.T) {
{"65k, courte : plancher 8k + marge, sous 84 %", 55000, 2000, 65536, false, true},
{"65k, longue : 1,2×15k ne tient plus", 47000, 15000, 65536, true, false},
{"65k, longue mais la place y est", 40000, 15000, 65536, false, false},
{"65k, énorme (45k) mais historique tout juste compacté", 20000, 45000, 65536, false, false},
{"65k, énorme (45k) au-delà de la moitié", 33000, 45000, 65536, true, false},
{"131k, courte : rien avant le seuil", 98000, 4000, 131072, false, false},
{"131k, très longue génération", 95000, 30000, 131072, true, false},
{"8k, plancher ramené à 1/8", 6000, 0, 8192, false, false},
@@ -67,6 +69,14 @@ func TestGenHeadroomShort(t *testing.T) {
if genHeadroomShort(below, 1, w) {
t.Errorf("fenêtre %d : la garde devance le seuil sans génération longue", w)
}
// Génération aussi longue que la fenêtre : jamais avant la moitié, sinon
// l'historique fraîchement compacté la redéclenche à chaque étape.
if genHeadroomShort(w/2, w, w) {
t.Errorf("fenêtre %d : la garde part à 50 %% (compaction en boucle)", w)
}
if !genHeadroomShort(w/2+1, w, w) {
t.Errorf("fenêtre %d : la garde ne part pas au-delà de 50 %% avec une génération énorme", w)
}
}
}
@@ -292,3 +302,16 @@ func TestPasseIsoleeNeTouchePasLeContexte(t *testing.T) {
t.Fatalf("passe de correction : genPeak = %d, attendu 800", c.genPeak)
}
}
// Preset externe : le compte mesuré seul, comme avant ; local : complété des
// messages arrivés depuis la mesure.
func TestCtxNowLockedExternal(t *testing.T) {
msgs := []Message{um("bonjour"), am("salut"), um(strings.Repeat("x", 4000))}
c := &Conversation{CtxUsed: 30000, ctxUsedLen: 2}
if got := c.ctxNowLocked(msgs, false); got != 30000 {
t.Errorf("externe : %d, attendu le compte mesuré seul (30000)", got)
}
if got, want := c.ctxNowLocked(msgs, true), 30000+estimateTokens(msgs[2:]); got != want {
t.Errorf("local : %d, attendu %d", got, want)
}
}
+1
View File
@@ -2038,6 +2038,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
cb(StreamEvent{Compacting: &no})
logCompact("en-tour", used, messages, c, changed)
if changed {
lastEst = 0 // estimation d'avant compaction : plus rien à comparer
messages = c
// La nouvelle base contient déjà tout ce tour : on la publie et on
// repart d'un `extra` vide, sinon l'appelant la ré-empilerait avec