From d6023ef8a095e9a947c84e080822b4de9a8750fe Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 4 Oct 2026 00:45:55 +0200 Subject: [PATCH] =?UTF-8?q?Contexte=20:=20le=20raisonnement=20jamais=20ren?= =?UTF-8?q?voy=C3=A9=20ne=20compte=20plus,=20et=20la=20compaction=20ne=20p?= =?UTF-8?q?art=20plus=20trop=20t=C3=B4t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CtxUsed valait usage.prompt_tokens + généré, raisonnement compris. Or Loki ne renvoie jamais ce raisonnement au modèle (Message n'a pas de champ pour lui) : la requête suivante pesait 1 à 8 k jetons de moins que le compte, et la compaction, qui perd de l'information, partait vers 44-47 k au lieu de 49 k sur une fenêtre de 65 k. Rien ne change dans les requêtes : seul le compte qui décide de compacter. - Comptage : seulement les morceaux reasoning_content séparés par le llama-server local, remis à zéro à chaque tentative. Le découpé chez nous reste dans le message renvoyé pendant le tour, il n'est pas retiré ; l'API externe garde l'ancien calcul. Un morceau vaut au plus un jeton : on ne peut que sous-estimer, le sens sans danger. - Début de tour : le message utilisateur (et la file) arrivé depuis la dernière mesure s'ajoute en estimation, comme en cours de tour. - Vérificateur : sa trace isolée écrasait CtxUsed avec sa petite taille, et la fin de tour décidait sur ce chiffre-là. Plus maintenant ; la jauge de l'UI ne bouge pas non plus et suit le même calcul que le serveur. - Marge de génération : le compte gonflé offrait une marge par accident. Elle est remplacée par une vraie garde — compacter si 1,2 × la plus longue génération récente ne tient plus dans la fenêtre. Plancher et marge seuls ne devancent jamais le seuil de 75 %. - Fenêtre pleine en plein raisonnement (finish « length » au-delà du seuil) : compaction puis étape rejouée, au lieu du nudge « arrête de raisonner ». - Journal [ctx] : estimation contre compte réel au-delà de 2 % d'écart, chaque « length » et chaque nudge, pour vérifier qu'ils n'augmentent pas. - Le seuil reste à 75 % : la réserve fixe proposée (toolResultMax/3 + 6 k) laissait trop peu de place sans budget de raisonnement ni max_tokens. Co-Authored-By: Claude Opus 5.5 --- internal/loki/chat_compact.go | 57 ++++++ internal/loki/chat_conversation.go | 57 +++++- internal/loki/code_verify.go | 39 +++- internal/loki/ctx_accounting_test.go | 294 +++++++++++++++++++++++++++ internal/loki/llm_client.go | 78 ++++++- internal/loki/ui/index.html | 7 +- internal/loki/ui/src/js/09-stream.js | 7 +- 7 files changed, 514 insertions(+), 25 deletions(-) create mode 100644 internal/loki/ctx_accounting_test.go diff --git a/internal/loki/chat_compact.go b/internal/loki/chat_compact.go index d395c8d..fe59a3a 100644 --- a/internal/loki/chat_compact.go +++ b/internal/loki/chat_compact.go @@ -215,6 +215,63 @@ func compactWouldTrigger(msgs []Message, knownTokens int) bool { return used >= int(float64(ctxWindow())*compactTriggerFrac) } +// compactNeeded = compactWouldTrigger, plus une garde de MARGE DE GÉNÉRATION. +// Tant que le raisonnement de la dernière étape était compté dans le contexte +// (à tort : il n'est jamais renvoyé), il offrait par accident une marge à peu +// près proportionnelle à la longueur des étapes. Ce compte est désormais juste, +// et sans max_tokens ni budget de raisonnement, une étape aussi longue que la +// plus longue récente doit encore tenir : sinon le moteur coupe en plein +// raisonnement (« length »). peakGen = 0 (inconnu, API externe) : seuil seul. +func compactNeeded(msgs []Message, used, peakGen int) bool { + if compactWouldTrigger(msgs, used) { + return true + } + return compactEnabled() && genHeadroomShort(used, peakGen, ctxWindow()) +} + +// genHeadroomShort : la fenêtre ne laisse plus la place d'une génération de +// 1,2 × la plus longue récente (plancher de 8 k jetons, ramené à 1/8 de la +// 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. +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 +} + +// logCtx trace un fait du comptage de contexte (fenêtre pleine, relance après +// un raisonnement sans action) : de quoi vérifier, journal en main, qu'ils ne +// deviennent pas plus fréquents. +func logCtx(format string, args ...any) { + fmt.Fprintf(os.Stderr, "[ctx] "+format+"\n", args...) +} + +// logCtxEstimate compare le contexte ESTIMÉ avant une requête au compte réel +// du moteur (usage.prompt_tokens), et ne parle qu'au-delà de 2 % d'écart. +func logCtxEstimate(phase string, est, actual int) { + if actual <= 0 { + return + } + if d := est - actual; d*50 > actual || -d*50 > actual { + logCtx("%s estimé=%d réel=%d écart=%+d", phase, est, actual, d) + } +} + +// ctxPending ajoute au dernier contexte mesuré (used, valable pour les +// measured premiers messages) l'estimation des messages arrivés depuis : le +// nouveau message utilisateur, ceux de la file. Le compte du moteur ne les a +// pas vus. measured = 0 (inconnu, discussion rechargée) ou plus grand que +// l'historique (réécrit entre-temps) : on s'en tient au compte mesuré. +func ctxPending(used, measured int, msgs []Message) int { + if used <= 0 || measured <= 0 || measured > len(msgs) { + return used + } + return used + estimateTokens(msgs[measured:]) +} + // logCompact trace UNE ligne par décision de compaction sur la sortie d'erreur // (donc dans `journalctl -u loki-ui`). Sans ça, une compaction qui ne se // déclenche pas — ou qui se déclenche et n'enlève rien — est invisible : côté diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index be4c705..bda8d69 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -59,6 +59,14 @@ type Conversation struct { Log []LogEvent `json:"log"` // vue « UI » rejouable Seq int `json:"seq"` CtxUsed int `json:"ctx_used"` // taille réelle du contexte au dernier tour + // ctxUsedLen : nombre de messages de Messages que CtxUsed couvre déjà (0 = + // inconnu). Ce qui arrive ensuite — le message utilisateur du tour suivant, + // la file — est estimé en plus par ctxPending : le moteur ne l'a pas encore + // compté. genPeak : génération la plus longue du dernier tour (llama-server + // local), pour la garde de marge de compactNeeded. Ni l'un ni l'autre n'est + // persisté : un rechargement repart du seul CtxUsed, comme avant. + ctxUsedLen int + genPeak int Generating bool `json:"-"` cancel context.CancelFunc // annule la génération en cours (/stop) @@ -189,6 +197,7 @@ func (c *Conversation) loadFrom(b []byte) { c.Stop() c.mu.Lock() c.Messages, c.Log, c.Seq, c.CtxUsed = nil, nil, 0, 0 + c.ctxUsedLen, c.genPeak = 0, 0 c.queued = nil // file de l'ancienne discussion : elle ne suit pas la bascule if len(b) > 0 { _ = json.Unmarshal(b, c) @@ -401,6 +410,7 @@ func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase s if c.epoch == epoch { c.Messages = compacted c.CtxUsed = est // le vrai compte reviendra avec les stats du prochain tour + c.ctxUsedLen = len(compacted) } c.mu.Unlock() c.appendDelta(epoch, map[string]any{"compacted": true}) @@ -745,14 +755,22 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa // Snapshot de la vue modèle. c.mu.Lock() msgs := append([]Message(nil), c.Messages...) - ctxUsed := c.CtxUsed + // 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) + 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. - if compactWouldTrigger(msgs, ctxUsed) { + if compactNeeded(msgs, ctxUsed, peak) { if out, changed := c.compactAndPublish(ctx, epoch, "début-tour", msgs, ctxUsed, caps); changed { msgs = out } @@ -791,6 +809,10 @@ 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. asked := false @@ -847,16 +869,23 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa // compaction de début de tour. c.appendDelta(epoch, map[string]any{"compacting": *ev.Compacting}) case ev.Stats != nil: - // Taille réelle du contexte (usage.prompt_tokens + généré) pour le compteur - // et la décision de compactage au tour suivant. + // Taille réelle du contexte (usage.prompt_tokens + généré, raisonnement + // non renvoyé retiré) pour le compteur et la décision de compactage au + // tour suivant. if ev.Stats.PromptTokensTotal > 0 { + if !sawUsage && local && startEst > 0 { + logCtxEstimate("début-tour", startEst, ev.Stats.PromptTokensTotal) + } sawUsage = true c.mu.Lock() if c.epoch == epoch { - c.CtxUsed = ev.Stats.PromptTokensTotal + ev.Stats.GenTokens + c.CtxUsed = ev.Stats.ctxAfter() } c.mu.Unlock() } + if local { + turnPeak = max(turnPeak, ev.Stats.GenTokens) + } c.appendDelta(epoch, map[string]any{"stats": ev.Stats}) case ev.Ask != nil: // Question structurée (outil ask) : carte à boutons dans l'UI. @@ -886,9 +915,16 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa if s := content.String(); strings.TrimSpace(s) != "" { c.Messages = append(c.Messages, Message{Role: "assistant", Content: s}) } + // Le dernier compte couvre tout ce qui vient d'être rangé : la dernière + // requête portait le tour entier, sa génération en est la réponse. + if sawUsage { + c.ctxUsedLen = len(c.Messages) + } + c.genPeak = max(c.genPeak, turnPeak) } msgs = append([]Message(nil), c.Messages...) - ctxUsed = c.CtxUsed + ctxUsed = ctxPending(c.CtxUsed, c.ctxUsedLen, msgs) + peak = c.genPeak stale := c.epoch != epoch c.mu.Unlock() @@ -900,6 +936,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa c.mu.Lock() if c.epoch == epoch { c.CtxUsed = est + c.ctxUsedLen = len(msgs) } c.mu.Unlock() c.appendDelta(epoch, map[string]any{"ctx_used": est}) @@ -913,7 +950,8 @@ 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 = c.CtxUsed + ctxUsed = ctxPending(c.CtxUsed, c.ctxUsedLen, msgs) + peak = c.genPeak stale = c.epoch != epoch c.mu.Unlock() } @@ -927,7 +965,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa // retombe tout de suite et le tour suivant démarre avec de la marge. // Sauf si un Reset est passé (rien à compacter) ou si le tour a été annulé // (bouton stop) : on n'enchaîne pas plusieurs secondes de résumé sur un stop. - if stale || ctx.Err() != nil || !compactWouldTrigger(msgs, ctxUsed) { + if stale || ctx.Err() != nil || !compactNeeded(msgs, ctxUsed, peak) { return } // context.Background() et non ctx : le tour est terminé, son contexte peut @@ -953,7 +991,7 @@ func (c *Conversation) CompactNow() error { ctx, cancel := context.WithCancel(context.Background()) c.cancel = cancel msgs := append([]Message(nil), c.Messages...) - lastReal := c.CtxUsed // dernier contexte réel mesuré, pour estimer le surcoût fixe + lastReal := ctxPending(c.CtxUsed, c.ctxUsedLen, msgs) // dernier contexte réel mesuré, pour estimer le surcoût fixe epoch := c.epoch c.mu.Unlock() @@ -1009,6 +1047,7 @@ func (c *Conversation) Reset() { c.Log = nil c.Seq = 0 c.CtxUsed = 0 + c.ctxUsedLen, c.genPeak = 0, 0 c.epoch++ c.Generating = false c.cancel = nil diff --git a/internal/loki/code_verify.go b/internal/loki/code_verify.go index bbb4344..71c1d1b 100644 --- a/internal/loki/code_verify.go +++ b/internal/loki/code_verify.go @@ -116,7 +116,7 @@ func (c *Conversation) runVerifyPass(ctx context.Context, caps Caps, temperature // dans le moteur aussi : effacé à la fin de la passe (llm_slots.go). defer engineSideJob()() _, _ = runChat(withPerfKind(ctx, perfVerify), msgs, temperature, vcaps, func(ev StreamEvent) bool { - c.forwardStream(ev, epoch) + c.forwardStream(ev, epoch, true) return true }) } @@ -165,7 +165,11 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur final = append([]Message{{Role: "system", Content: sp}}, final...) } var content strings.Builder + sawUsage := false extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool { + if ev.Stats != nil && ev.Stats.PromptTokensTotal > 0 { + sawUsage = true + } if ev.Content != "" { content.WriteString(ev.Content) } @@ -175,7 +179,7 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur if ev.Ask != nil { asked = true } - c.forwardStream(ev, epoch) + c.forwardStream(ev, epoch, false) return true }) c.mu.Lock() @@ -184,6 +188,9 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur if s := content.String(); strings.TrimSpace(s) != "" { c.Messages = append(c.Messages, Message{Role: "assistant", Content: s}) } + if sawUsage { + c.ctxUsedLen = len(c.Messages) // même règle que generate + } } c.mu.Unlock() c.persist() @@ -193,7 +200,13 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur // forwardStream relaie les événements d'un runChat secondaire (vérification, // correction) vers le journal d'affichage — même mapping que generate(), sans // la gestion de compaction (ces passes n'en déclenchent pas : contexte court). -func (c *Conversation) forwardStream(ev StreamEvent, epoch int) { +// +// isolated : la passe tourne sur sa propre trace (vérificateur), PAS sur +// l'historique de la discussion. Ses comptes ne disent rien du contexte de +// celle-ci : ils écrasaient CtxUsed avec la petite taille du vérificateur, et +// le contrôle de fin de tour — puis celui du tour suivant — décidaient sur ce +// chiffre-là. La jauge de l'UI ne doit pas bouger non plus (ctx_isolated). +func (c *Conversation) forwardStream(ev StreamEvent, epoch int, isolated bool) { switch { case ev.Err != nil: c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()}) @@ -223,13 +236,21 @@ func (c *Conversation) forwardStream(ev StreamEvent, epoch int) { case ev.Ask != nil: c.appendDelta(epoch, map[string]any{"ask": ev.Ask}) case ev.Stats != nil: - if ev.Stats.PromptTokensTotal > 0 { - c.mu.Lock() - if c.epoch == epoch { - c.CtxUsed = ev.Stats.PromptTokensTotal + ev.Stats.GenTokens - } - c.mu.Unlock() + if isolated { + c.appendDelta(epoch, map[string]any{"stats": ev.Stats, "ctx_isolated": true}) + break } + local := !externalActive() + c.mu.Lock() + if c.epoch == epoch { + if ev.Stats.PromptTokensTotal > 0 { + c.CtxUsed = ev.Stats.ctxAfter() + } + if local { + c.genPeak = max(c.genPeak, ev.Stats.GenTokens) + } + } + c.mu.Unlock() c.appendDelta(epoch, map[string]any{"stats": ev.Stats}) case ev.DropReasoning: c.appendDelta(epoch, map[string]any{"drop_reasoning": true}) diff --git a/internal/loki/ctx_accounting_test.go b/internal/loki/ctx_accounting_test.go new file mode 100644 index 0000000..1be73c9 --- /dev/null +++ b/internal/loki/ctx_accounting_test.go @@ -0,0 +1,294 @@ +package loki + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" +) + +// Le contexte de la requête suivante = prompt + généré − raisonnement (jamais +// renvoyé), sans jamais retirer plus que ce qui a été généré. +func TestStatsCtxAfter(t *testing.T) { + cases := []struct { + name string + prompt, gen, reasonN int + want int + }{ + {"sans raisonnement", 40000, 500, 0, 40500}, + {"raisonnement retiré", 40000, 6000, 5000, 41000}, + {"tout est raisonnement", 40000, 3000, 3000, 40000}, + {"compte de morceaux au-delà du généré : borné", 40000, 2000, 2500, 40000}, + {"pas d'usage", 0, 300, 100, 200}, + } + for _, c := range cases { + s := StatsEvent{PromptTokensTotal: c.prompt, GenTokens: c.gen, ReasoningTokens: c.reasonN} + if got := s.ctxAfter(); got != c.want { + t.Errorf("%s : ctxAfter = %d, attendu %d", c.name, got, c.want) + } + } +} + +// Garde de marge : à 65 k comme à 131 k, plancher et marge seuls ne devancent +// jamais le seuil de 75 % ; seule une génération récente longue l'avance. +func TestGenHeadroomShort(t *testing.T) { + cases := []struct { + name string + used, peak, window int + want bool + ratioAlreadyTriggers bool + }{ + {"65k, inconnu", 60000, 0, 65536, false, true}, + {"65k, courte : sous le seuil, pas d'avance", 49000, 2000, 65536, false, false}, + {"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}, + {"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}, + {"contexte inconnu", 0, 30000, 65536, false, false}, + } + for _, c := range cases { + if got := genHeadroomShort(c.used, c.peak, c.window); got != c.want { + t.Errorf("%s : genHeadroomShort = %v, attendu %v", c.name, got, c.want) + } + ratio := c.used >= int(float64(c.window)*compactTriggerFrac) + if ratio != c.ratioAlreadyTriggers { + t.Errorf("%s : cas mal posé, seuil de 75 %% = %v", c.name, ratio) + } + } + // Sans génération longue, la garde ne part jamais avant le seuil de 75 %. + for _, w := range []int{4096, 8192, 16384, 32768, 65536, 131072, 262144} { + below := int(float64(w)*compactTriggerFrac) - 1 + if genHeadroomShort(below, 1, w) { + t.Errorf("fenêtre %d : la garde devance le seuil sans génération longue", w) + } + } +} + +// Le message arrivé depuis la dernière mesure s'ajoute ; une mesure inconnue +// ou qui ne correspond plus à l'historique n'ajoute rien. +func TestCtxPending(t *testing.T) { + msgs := []Message{um("bonjour"), am("salut"), um(strings.Repeat("x", 4000))} + added := estimateTokens(msgs[2:]) + cases := []struct { + name string + used, measured int + want int + }{ + {"nouveau message estimé", 30000, 2, 30000 + added}, + {"rien de nouveau", 30000, 3, 30000}, + {"mesure inconnue (rechargement)", 30000, 0, 30000}, + {"historique raccourci depuis", 30000, 5, 30000}, + {"aucun compte : repli sur l'estimation complète, ailleurs", 0, 2, 0}, + } + for _, c := range cases { + if got := ctxPending(c.used, c.measured, msgs); got != c.want { + t.Errorf("%s : ctxPending = %d, attendu %d", c.name, got, c.want) + } + } +} + +// fauxMoteurScript : llama-server de comédie qui sert ses réponses streamées +// dans l'ordre (la dernière se répète), et un résumé JSON aux appels non +// streamés (compaction). Garde le dernier message de chaque requête streamée. +func fauxMoteurScript(t *testing.T, streams ...string) (port string, lastMsgs func() []string) { + t.Helper() + var mu sync.Mutex + var last []string + n := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Stream bool `json:"stream"` + Messages []Message `json:"messages"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + if !body.Stream { + sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- résumé des étapes précédentes"}}}}) + return + } + mu.Lock() + i := min(n, len(streams)-1) + n++ + if len(body.Messages) > 0 { + last = append(last, msgText(body.Messages[len(body.Messages)-1])) + } + mu.Unlock() + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(200) + _, _ = w.Write([]byte(streams[i] + "data: [DONE]\n\n")) + })) + t.Cleanup(srv.Close) + u, _ := url.Parse(srv.URL) + return u.Port(), func() []string { + mu.Lock() + defer mu.Unlock() + return append([]string(nil), last...) + } +} + +func sseReasoning(text string) string { + return `data: {"choices":[{"delta":{"reasoning_content":"` + text + `"}}]}` + "\n\n" +} + +func sseFinal(finish string, prompt, gen int) string { + return `data: {"choices":[{"delta":{},"finish_reason":"` + finish + `"}]}` + "\n\n" + + `data: {"choices":[],"timings":{"prompt_n":10,"predicted_n":` + itoa(gen) + `},"usage":{"prompt_tokens":` + itoa(prompt) + `,"completion_tokens":` + itoa(gen) + `}}` + "\n\n" +} + +// runForStats lance runChat et rend le dernier StatsEvent reçu. +func runForStats(t *testing.T, msgs []Message, caps Caps) (StatsEvent, string) { + t.Helper() + var st StatsEvent + var content strings.Builder + if _, err := runChat(context.Background(), msgs, 0.7, caps, func(ev StreamEvent) bool { + if ev.Stats != nil { + st = *ev.Stats + } + content.WriteString(ev.Content) + return true + }); err != nil { + t.Fatal(err) + } + return st, content.String() +} + +// Raisonnement séparé par le moteur : compté, et retiré du contexte suivant. +func TestRaisonnementSepareCompte(t *testing.T) { + testHome(t) + port, _ := fauxMoteurScript(t, sseReasoning("je ")+sseReasoning("réfléchis ")+sseReasoning("ici")+ + sseChunk("Bon")+sseChunk("jour")+sseFinal("stop", 100, 7)) + if err := SetConfigKey("PORT", port); err != nil { + t.Fatal(err) + } + st, _ := runForStats(t, []Message{um("salut")}, Caps{}) + if st.ReasoningTokens != 3 { + t.Fatalf("ReasoningTokens = %d, attendu 3", st.ReasoningTokens) + } + if got := st.ctxAfter(); got != 104 { + t.Fatalf("ctxAfter = %d, attendu 104 (100 + 7 − 3)", got) + } +} + +// en ligne découpé chez nous : ce texte reste dans le message renvoyé +// au modèle pendant le tour, il ne doit donc PAS être retiré du compte. +func TestRaisonnementEnLigneNonCompte(t *testing.T) { + testHome(t) + port, _ := fauxMoteurScript(t, sseChunk("je pense")+sseChunk("")+sseChunk("Réponse")+sseFinal("stop", 100, 6)) + if err := SetConfigKey("PORT", port); err != nil { + t.Fatal(err) + } + if err := SetConfigKey("REASONING", "on"); err != nil { + t.Fatal(err) + } + var sawReasoning bool + var st StatsEvent + if _, err := runChat(context.Background(), []Message{um("salut")}, 0.7, Caps{}, func(ev StreamEvent) bool { + if ev.Reasoning != "" { + sawReasoning = true + } + if ev.Stats != nil { + st = *ev.Stats + } + return true + }); err != nil { + t.Fatal(err) + } + if !sawReasoning { + t.Fatal("le en ligne n'a pas été découpé : test mal posé") + } + if st.ReasoningTokens != 0 || st.ctxAfter() != 106 { + t.Fatalf("raisonnement en ligne compté : ReasoningTokens=%d ctxAfter=%d", st.ReasoningTokens, st.ctxAfter()) + } +} + +// Une relance (ici le nudge « pensé sans agir ») repart d'un compte vierge : +// le raisonnement d'une tentative jetée n'est pas retiré deux fois. +func TestRaisonnementRemisAZeroALaRelance(t *testing.T) { + withWorkspace(t) + port, _ := fauxMoteurScript(t, + sseReasoning("a")+sseReasoning("b")+sseReasoning("c")+sseFinal("stop", 100, 3), + sseReasoning("d")+sseChunk("Fait.")+sseFinal("stop", 120, 4)) + if err := SetConfigKey("PORT", port); err != nil { + t.Fatal(err) + } + st, content := runForStats(t, []Message{um("fais-le")}, Caps{Agent: true}) + if content != "Fait." { + t.Fatalf("réponse = %q, la relance n'a pas eu lieu", content) + } + if st.ReasoningTokens != 1 || st.ctxAfter() != 123 { + t.Fatalf("après relance : ReasoningTokens=%d ctxAfter=%d, attendu 1 et 123", st.ReasoningTokens, st.ctxAfter()) + } +} + +// Fenêtre pleine en plein raisonnement (« length ») : on compacte et on rejoue +// l'étape, sans le nudge qui demanderait au modèle d'arrêter de raisonner. +func TestFenetrePleineCompacteEtRejoue(t *testing.T) { + withWorkspace(t) + if err := SetConfigKey("CTX", "4096"); err != nil { + t.Fatal(err) + } + port, lastMsgs := fauxMoteurScript(t, + sseReasoning("long")+sseReasoning(" raisonnement")+sseFinal("length", 3900, 196), + sseChunk("Réponse.")+sseFinal("stop", 1500, 3)) + if err := SetConfigKey("PORT", port); err != nil { + t.Fatal(err) + } + msgs := []Message{um("corrige le build"), am("ok"), um("continue")} + for i := 0; i < 10; i++ { + msgs = append(msgs, atc("bash"), tm("sortie "+strings.Repeat("x", 400))) + } + msgs = append(msgs, um("et maintenant ?")) + var compacted, dropped bool + var content strings.Builder + if _, err := runChat(context.Background(), msgs, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool { + if ev.NewHistory != nil { + compacted = true + } + if ev.DropReasoning { + dropped = true + } + content.WriteString(ev.Content) + return true + }); err != nil { + t.Fatal(err) + } + if !compacted || !dropped { + t.Fatalf("fenêtre pleine : compacté=%v raisonnement retiré=%v", compacted, dropped) + } + if content.String() != "Réponse." { + t.Fatalf("réponse = %q", content.String()) + } + for _, m := range lastMsgs() { + if strings.Contains(m, "You reasoned but did not") || strings.Contains(m, "Stop reasoning") { + t.Fatalf("nudge envoyé malgré la fenêtre pleine : %q", m) + } + } +} + +// La passe du vérificateur tourne sur sa propre trace : ses comptes ne +// touchent ni CtxUsed ni la jauge, contrairement à une passe de correction. +func TestPasseIsoleeNeTouchePasLeContexte(t *testing.T) { + testHome(t) + c := newTestConv() + c.CtxUsed = 45000 + st := &StatsEvent{PromptTokensTotal: 3000, GenTokens: 800, ReasoningTokens: 500} + c.forwardStream(StreamEvent{Stats: st}, c.epoch, true) + if c.CtxUsed != 45000 { + t.Fatalf("passe isolée : CtxUsed = %d, attendu 45000 inchangé", c.CtxUsed) + } + if last := c.Log[len(c.Log)-1].Delta; last["ctx_isolated"] != true { + t.Fatalf("passe isolée : la jauge n'est pas prévenue (%v)", last) + } + c.forwardStream(StreamEvent{Stats: st}, c.epoch, false) + if c.CtxUsed != 3300 { + t.Fatalf("passe de correction : CtxUsed = %d, attendu 3300 (3000 + 800 − 500)", c.CtxUsed) + } + if c.genPeak != 800 { + t.Fatalf("passe de correction : genPeak = %d, attendu 800", c.genPeak) + } +} diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 30fd139..114c94f 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -728,6 +728,13 @@ type StatsEvent struct { // Taille TOTALE du prompt traité ce tour (préfixe caché compris), issue de // `usage.prompt_tokens`. 0 si le backend ne renvoie pas d'usage. PromptTokensTotal int `json:"prompt_tokens_total,omitempty"` + // Morceaux de raisonnement reçus SÉPARÉS par le moteur (reasoning_content), + // llama-server local seulement. Loki ne renvoie jamais ce raisonnement au + // modèle (Message n'a pas de champ pour lui) : ces jetons font partie de + // GenTokens mais pas de la requête suivante. Un morceau vaut au plus un + // jeton, donc ce compte ne peut que sous-estimer — le sens sans danger : on + // compacte au pire comme avant, jamais trop tard. Voir ctxAfter. + ReasoningTokens int `json:"reasoning_tokens,omitempty"` // Conseil ponctuel quand le cache de prompts n'a pas tenu après un travail // annexe (voir noteEnginePrompt). Vide la plupart du temps. CacheHint string `json:"cache_hint,omitempty"` @@ -747,6 +754,15 @@ type StatsEvent struct { Kind string `json:"kind,omitempty"` } +// ctxAfter : taille du contexte que la requête SUIVANTE renverra réellement au +// moteur — le prompt de cette complétion plus ce qu'elle a généré, MOINS son +// raisonnement, jamais renvoyé. Compter ce raisonnement gonflait le contexte de +// 1 à 8 k jetons par étape, et la compaction (avec perte) partait trop tôt. +// Borné par GenTokens : le compte de morceaux ne retire jamais plus que généré. +func (s StatsEvent) ctxAfter() int { + return s.PromptTokensTotal + s.GenTokens - min(s.GenTokens, s.ReasoningTokens) +} + // ChatCallback receives stream events. Return false to abort the stream. type ChatCallback func(StreamEvent) bool @@ -972,6 +988,18 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // par rendre la main plutôt que faire attendre. const maxNudges = 2 nudgeCount := 0 + // Génération la plus longue de ce tour (llama-server local), pour la garde + // de marge de compactNeeded : le raisonnement n'étant plus compté dans le + // contexte, il faut s'assurer AUTREMENT qu'une étape aussi longue tient + // encore dans la fenêtre. + peakGen := 0 + // Complétion coupée par la fenêtre pleine (finish_reason « length ») : + // compactée puis rejouée une fois, au lieu du nudge « arrête de raisonner » + // qui raccourcirait la réflexion du modèle. + lengthReplays := 0 + // Contexte estimé pour la requête suivante (en-tour), comparé au compte + // réel du moteur quand il arrive — journalisé s'il s'en écarte. + lastEst := 0 // Garde-fou « appel d'outil écrit en texte » (code_retry.go) : relances // consécutives bornées, compteur remis à zéro après chaque outil exécuté. patternRetries := 0 @@ -1427,6 +1455,15 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // Backend already separates reasoning — trust it, disable our split. sawReasoningField = true thinkOpen = false + // Compté ICI seulement : le raisonnement découpé chez nous ( + // en ligne, plus bas) reste dans assistantContent et repart dans + // l'étape suivante — le retirer sous-estimerait le contexte. Pas + // pour une API externe : son découpage en morceaux ne dit rien du + // nombre de jetons, et son preset doit rester tel quel. stats est + // propre à cette tentative : une relance repart de zéro. + if !ep.External { + stats.ReasoningTokens++ + } if !scb(StreamEvent{Reasoning: ch.Delta.ReasoningContent}) { aborted = true break @@ -1560,6 +1597,16 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps s.LostAlert = perfAlert(rec, perfLostAlertAt(chatCfg)) } cb(StreamEvent{Stats: &s}) + if !ep.External { + peakGen = max(peakGen, stats.GenTokens) + if lastEst > 0 && stats.PromptTokensTotal > 0 { + logCtxEstimate("en-tour", lastEst, stats.PromptTokensTotal) + } + } + lastEst = 0 + } + if finishReason == "length" { + logCtx("finish=length prompt=%d généré=%d raisonnement=%d fenêtre=%d", stats.PromptTokensTotal, stats.GenTokens, stats.ReasoningTokens, ctxWindow()) } if aborted { return extra, nil @@ -1977,12 +2024,14 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // comptés. Sans eux, une étape qui lit plusieurs gros fichiers d'un coup // passait de 60 % à plus de 130 % de la fenêtre sans jamais compacter. // Serveur sans usage (base 0) : on laisse 0, compactWouldTrigger estime - // alors TOUT l'historique — étape comprise. + // alors TOUT l'historique — étape comprise. Le raisonnement de l'étape + // est retiré (ctxAfter) : il ne repart pas dans la requête suivante. used := 0 - if base := stats.PromptTokensTotal + stats.GenTokens; base > 0 { + if base := stats.ctxAfter(); base > 0 { used = base + estimateTokens(messages[stepStart:]) + lastEst = used } - if compactWouldTrigger(messages, used) { + if compactNeeded(messages, used, peakGen) { yes, no := true, false cb(StreamEvent{Compacting: &yes}) c, changed := compactMessages(ctx, messages, caps) @@ -2020,8 +2069,31 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // Filet de sécurité (le vrai fix est le prompt court, voir baseSystemPrompt) : // si un modèle « pense sans agir » malgré tout, on le relance avec une // consigne impérative au lieu d'afficher « pas de réponse ». + // + // Sauf si la complétion a été COUPÉE par une fenêtre pleine + // (« length » au-delà du seuil de compaction) : le modèle n'a pas + // « pensé sans agir », il n'a plus eu de place. Lui dire d'arrêter de + // raisonner raccourcirait sa réflexion et alourdirait encore le + // contexte ; on compacte et on rejoue l'étape, une fois. + if finishReason == "length" && !ep.External && lengthReplays < 1 && + compactWouldTrigger(messages, stats.PromptTokensTotal+stats.GenTokens) { + lengthReplays++ + yes, no := true, false + cb(StreamEvent{Compacting: &yes}) + c, changed := compactMessages(ctx, messages, caps) + cb(StreamEvent{Compacting: &no}) + logCompact("fenêtre-pleine", stats.PromptTokensTotal+stats.GenTokens, messages, c, changed) + if changed { + cb(StreamEvent{DropReasoning: true}) + messages = c + extra = nil + cb(StreamEvent{NewHistory: append([]Message(nil), messages...)}) + continue + } + } if len(tools) > 0 && !disableTools && nudgeCount < maxNudges { nudgeCount++ + logCtx("relance « pensé sans agir » n°%d (finish=%s)", nudgeCount, finishReason) // Le raisonnement de ce tour avorté ne mène à rien : on demande à // l'UI de l'effacer avant de relancer, pour ne pas afficher deux // blocs de réflexion successifs. diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 116c9e7..d31611b 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -7726,8 +7726,11 @@ function handleDelta(d){ // prompt_tokens_total = comptage exact du moteur (include_usage). Absent sur // certains llama-server récents : le serveur publie alors un `ctx_used` // estimé en fin de tour, traité plus bas — on ne remet donc PAS la jauge à - // zéro ici, on la laisse simplement inchangée. - if(d.stats.prompt_tokens_total){ setCtxUsed((d.stats.prompt_tokens_total||0)+(d.stats.gen_tokens||0)); } + // zéro ici, on la laisse simplement inchangée. Même calcul que le serveur + // (StatsEvent.ctxAfter) : le raisonnement, jamais renvoyé au modèle, ne + // compte pas. ctx_isolated = passe du vérificateur, sur sa propre trace : + // ses chiffres ne disent rien de la discussion, la jauge ne bouge pas. + if(d.stats.prompt_tokens_total && !d.ctx_isolated){ const g=d.stats.gen_tokens||0; setCtxUsed(d.stats.prompt_tokens_total+g-Math.min(g, d.stats.reasoning_tokens||0)); } if(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; } if(d.tool_used){ settleBlocks(); // le bloc texte précédent (raisonnement/contenu) est terminé diff --git a/internal/loki/ui/src/js/09-stream.js b/internal/loki/ui/src/js/09-stream.js index 3afcd7a..99d82bd 100644 --- a/internal/loki/ui/src/js/09-stream.js +++ b/internal/loki/ui/src/js/09-stream.js @@ -561,8 +561,11 @@ function handleDelta(d){ // prompt_tokens_total = comptage exact du moteur (include_usage). Absent sur // certains llama-server récents : le serveur publie alors un `ctx_used` // estimé en fin de tour, traité plus bas — on ne remet donc PAS la jauge à - // zéro ici, on la laisse simplement inchangée. - if(d.stats.prompt_tokens_total){ setCtxUsed((d.stats.prompt_tokens_total||0)+(d.stats.gen_tokens||0)); } + // zéro ici, on la laisse simplement inchangée. Même calcul que le serveur + // (StatsEvent.ctxAfter) : le raisonnement, jamais renvoyé au modèle, ne + // compte pas. ctx_isolated = passe du vérificateur, sur sa propre trace : + // ses chiffres ne disent rien de la discussion, la jauge ne bouge pas. + if(d.stats.prompt_tokens_total && !d.ctx_isolated){ const g=d.stats.gen_tokens||0; setCtxUsed(d.stats.prompt_tokens_total+g-Math.min(g, d.stats.reasoning_tokens||0)); } if(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; } if(d.tool_used){ settleBlocks(); // le bloc texte précédent (raisonnement/contenu) est terminé