Contexte : le raisonnement jamais renvoyé ne compte plus, et la compaction ne part plus trop tôt

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 <think> 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 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 00:45:55 +02:00
1 parent 18a723a671
commit d6023ef8a0
7 files changed
+514 -25

No files matched your search

+75 -3
View File
@@ -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 (<think>
// 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.