Compaction plus efficace : resume borne, reduction garantie, jauge immediate

- Resume borne et dense : max_tokens 700, consigne 250 mots max, plafond dur a
  1500 caracteres, et surtout thinking DESACTIVE pour l'appel de resume
  (chat_template_kwargs enable_thinking:false) -> plus rapide, plus dense, plus
  de budget gaspille en <think> par un modele hybride.
- Garantie de reduction : la compaction n'est acceptee que si elle enleve >=20%
  du contexte estime, sinon refusee -> fini le "compactage a chaque message sans
  reduire". Queue plus agressive (tailFrac 0.35 -> 0.25) : le contexte retombe
  plus bas et met plus longtemps a re-declencher.
- Jauge de contexte : chute immediatement apres compaction (delta ctx_used emis
  aussitot) au lieu d'attendre la fin du tour.
This commit is contained in:
nathaninline committed 2026-07-23 17:07:55 +02:00
1 parent 7720877533
commit dcbfcb36f9
4 files changed
+36 -11

No files matched your search

+28 -10
View File
@@ -31,7 +31,9 @@ const (
// dépasse cette fraction de la fenêtre de contexte.
compactTriggerFrac = 0.75
// Budget de la queue : fraction de la fenêtre gardée intacte (tours récents).
compactTailFrac = 0.35
// Plus la queue est petite, plus on compacte de torse d'un coup → le contexte
// retombe bas et met longtemps à re-déclencher (au lieu de compacter souvent).
compactTailFrac = 0.25
// Un résultat d'outil du torse plus long que ça est remplacé par un marqueur
// avant le résumé (dégraissage sans IA, gratuit).
compactToolPruneLen = 200
@@ -205,8 +207,12 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
out = append(out, mid...)
out = append(out, msgs[tailStart:]...)
// Si on n'a rien gagné (résumé raté ET rien à dégraisser), signaler inchangé.
if len(out) == len(msgs) && estimateTokens(out) >= estimateTokens(msgs) {
// Garantie de réduction : on n'accepte la compaction que si elle enlève au
// moins ~20% du contexte estimé. Sinon (torse déjà maigre, résumé peu rentable)
// on la refuse — sans ça, jean « compactait » à presque chaque message sans
// vraiment réduire, puis re-déclenchait aussitôt.
before, after := estimateTokens(msgs), estimateTokens(out)
if after > before*4/5 {
return msgs, false
}
return out, true
@@ -258,13 +264,12 @@ type summarizeResp struct {
// Un seul appel NON streamé, sans outils — comme Hermes, on réutilise le modèle
// principal déjà chargé (aucune dépendance, cohérent avec la fenêtre de contexte).
func summarizeTranscript(ctx context.Context, transcript string) (string, error) {
sys := `Tu es un compacteur de contexte. On te donne la transcription d'une conversation entre un utilisateur et un assistant IA (avec ses outils). Résume-la de façon dense et FIDÈLE en français, en préservant tout ce qui est nécessaire pour continuer la conversation sans perte :
sys := `Tu es un compacteur de contexte. On te donne la transcription d'anciens tours d'une conversation entre un utilisateur et un assistant IA (avec ses outils). Résume-les de façon TRÈS DENSE et fidèle en français, en ne gardant QUE l'essentiel pour continuer sans perte :
- Objectif(s) et contraintes de l'utilisateur
- Décisions prises et faits établis
- Actions/outils exécutés et leurs résultats importants (chemins, valeurs, configuration)
- Tâches terminées, en cours, bloquées
- Prochaines étapes prévues
N'invente rien, n'ajoute pas de préambule ni de conclusion : donne directement le résumé structuré. Complet mais concis.`
- Actions/outils exécutés et leurs résultats importants (chemins, valeurs, config)
- Tâches terminées, en cours, bloquées ; prochaines étapes
Règles STRICTES : pas de préambule ni de conclusion, pas de verbatim ni de citations longues, pas de détails jetables. Utilise des puces courtes. Vise 250 mots MAXIMUM — c'est un résumé de compression, pas un compte rendu.`
payload := map[string]any{
"model": "jean",
@@ -273,7 +278,14 @@ N'invente rien, n'ajoute pas de préambule ni de conclusion : donne directement
{Role: "user", Content: transcript},
},
"stream": false,
"temperature": 0.3,
"temperature": 0.2,
// Borne dure : sans ça, un modèle bavard (surtout à reasoning) produit un
// résumé énorme et lent, donc peu de réduction → re-compaction à chaque tour.
"max_tokens": 700,
// Pas de réflexion pour un résumé : plus rapide, plus dense, et évite qu'un
// modèle hybride gaspille tout le budget en <think> (résumé vide). llama.cpp
// passe ces kwargs au gabarit Jinja (--jinja).
"chat_template_kwargs": map[string]any{"enable_thinking": false},
}
body, _ := json.Marshal(payload)
url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort())
@@ -305,5 +317,11 @@ N'invente rien, n'ajoute pas de préambule ni de conclusion : donne directement
if i := strings.LastIndex(c, thinkClose); i >= 0 {
c = c[i+len(thinkClose):]
}
return strings.TrimSpace(c), nil
c = strings.TrimSpace(c)
// Garde-fou dur : même si le modèle ignore la consigne de longueur, on tronque
// (~1500 caractères ≈ 375 tokens) pour garantir une vraie compression.
if len(c) > 1500 {
c = strings.TrimSpace(c[:1500]) + " […]"
}
return c, nil
}
+6 -1
View File
@@ -198,13 +198,16 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
}
if compacted, changed := MaybeCompact(ctx, msgs, caps, ctxUsed); changed {
msgs = compacted
est := estimateTokens(compacted)
c.mu.Lock()
if c.epoch == epoch {
c.Messages = compacted
c.CtxUsed = est // le vrai compte reviendra avec les stats du tour
}
c.mu.Unlock()
c.appendDelta(epoch, map[string]any{"compacting": false})
c.appendDelta(epoch, map[string]any{"compacted": true})
c.appendDelta(epoch, map[string]any{"ctx_used": est}) // fait chuter la jauge tout de suite
} else if willCompact {
c.appendDelta(epoch, map[string]any{"compacting": false})
}
@@ -298,13 +301,15 @@ func (c *Conversation) CompactNow() error {
c.appendDelta(epoch, map[string]any{"compact_noop": true})
return
}
est := estimateTokens(compacted)
c.mu.Lock()
if c.epoch == epoch {
c.Messages = compacted
c.CtxUsed = estimateTokens(compacted) // feedback immédiat ; le vrai compte revient au prochain tour
c.CtxUsed = est // feedback immédiat ; le vrai compte revient au prochain tour
}
c.mu.Unlock()
c.appendDelta(epoch, map[string]any{"compacted": true})
c.appendDelta(epoch, map[string]any{"ctx_used": est})
c.persist()
}()
return nil
+1
View File
@@ -1642,6 +1642,7 @@ function handleDelta(d){
if(d.compacting!==undefined){ const b=document.getElementById('compact-banner'); if(b) b.style.display = d.compacting ? 'flex' : 'none'; return; }
if(d.compacted){ const b=document.getElementById('compact-banner'); if(b) b.style.display='none'; toast('contexte compacté — les vieux tours ont été résumés'); return; }
if(d.compact_noop){ const b=document.getElementById('compact-banner'); if(b) b.style.display='none'; toast('rien à compacter (contexte déjà minimal)'); return; }
if(d.ctx_used!==undefined){ setCtxUsed(d.ctx_used); return; }
if(d.stats){ T.serverStats=d.stats;
if(d.stats.prompt_tokens_total){ setCtxUsed((d.stats.prompt_tokens_total||0)+(d.stats.gen_tokens||0)); }
if(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; }
+1
View File
@@ -63,6 +63,7 @@ function handleDelta(d){
if(d.compacting!==undefined){ const b=document.getElementById('compact-banner'); if(b) b.style.display = d.compacting ? 'flex' : 'none'; return; }
if(d.compacted){ const b=document.getElementById('compact-banner'); if(b) b.style.display='none'; toast('contexte compacté — les vieux tours ont été résumés'); return; }
if(d.compact_noop){ const b=document.getElementById('compact-banner'); if(b) b.style.display='none'; toast('rien à compacter (contexte déjà minimal)'); return; }
if(d.ctx_used!==undefined){ setCtxUsed(d.ctx_used); return; }
if(d.stats){ T.serverStats=d.stats;
if(d.stats.prompt_tokens_total){ setCtxUsed((d.stats.prompt_tokens_total||0)+(d.stats.gen_tokens||0)); }
if(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; }