Télémétrie : chaque complétion dit enfin ce qu'elle a repris du cache, recalculé et accepté du brouillon

Jusqu'ici, impossible de savoir si une ligne volatile s'était glissée dans
le prompt, si une mise à jour du moteur avait cassé les points de reprise
ou si l'acceptation du MTP s'était effondrée : le chunk final de llama.cpp
porte ces chiffres (cache_n, draft_n, cached_tokens), on les jetait.

Lecture seule : aucun champ ajouté aux requêtes, et le comptage du
contexte (usage.prompt_tokens → CtxUsed → compaction) ne change pas.

- perf_log.go : décodage à part et tolérant (perfWire) — un compteur mal
  typé ne jette plus le chunk final ni ne fait échouer une compaction ;
  cached_tokens, strictement entier dans streamChunk, y déménage aussi.
- Inconnu n'est pas 0 : pointeurs, et l'UI garde « prefill N tok » quand
  le moteur ne dit rien de son cache.
- Nature (main, subagent, verify, task, compact, bench, foreign) portée
  par le contexte, pas par Caps ; TTFT au premier delta de tout type.
- Perte de cache = total précédent − cache_n, seulement quand les messages
  prolongent strictement ceux de la complétion précédente de la même
  discussion et de la même nature (empreintes cumulées) ; jamais sur un
  flux coupé. Ce qui s'est intercalé (sous-agent, client /v1, bench) est
  nommé.
- Anneau de 5000 entrées en mémoire, sans texte ; GET /api/perf/summary
  (derrière la clé) : taux de cache, recalcul par tour et par nature,
  médianes pp/tg par profondeur, acceptation du brouillon.
- Ligne [perf] sur stderr seulement avec LOKI_PERF_LOG.
- UI : « prefill X nouveaux / Y en cache », ambre au-delà d'un seuil
  relevé sur un modèle hybride (espacement des points de reprise).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 00:29:17 +02:00
1 parent 3ff56e01db
commit 80b7793773
14 files changed
+1032 -25

No files matched your search

+65 -14
View File
@@ -731,6 +731,20 @@ type StatsEvent struct {
// 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"`
// Télémétrie d'affichage (perf_log.go), jamais relue pour décider quoi que ce
// soit. Pointeurs : nil = le moteur ne l'a pas dit, ce qui n'est pas 0 — un
// cache_n à 0 (tout recalculé) est précisément ce qu'il faut pouvoir montrer.
CacheTokens *int `json:"cache_tokens,omitempty"`
DraftN *int `json:"draft_n,omitempty"`
DraftAccepted *int `json:"draft_accepted,omitempty"`
TTFTms *int64 `json:"ttft_ms,omitempty"`
// Posés sur le dernier événement de la complétion seulement, une fois
// celle-ci rangée : jetons du tour précédent qu'il a fallu recalculer, et
// s'il faut le signaler (seuil relevé sur un modèle hybride).
Lost *int `json:"lost,omitempty"`
LostAfter string `json:"lost_after,omitempty"`
LostAlert bool `json:"lost_alert,omitempty"`
Kind string `json:"kind,omitempty"`
}
// ChatCallback receives stream events. Return false to abort the stream.
@@ -758,13 +772,14 @@ type streamChunk struct {
PredictedPerSec float64 `json:"predicted_per_second"`
} `json:"timings"`
// Chunk final (include_usage) : taille totale du prompt, hors choices.
// ⚠️ Rien de plus ici : un champ typé de travers fait échouer le décodage
// du chunk ENTIER, qui est sauté — texte final, finish_reason et usage
// compris. Les compteurs de télémétrie (cache_n, draft_n, cached_tokens) se
// lisent à part, sans jamais échouer (perfWire, perf_log.go).
Usage *struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
PromptTokensDetails *struct {
CachedTokens int `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
@@ -908,6 +923,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// bubble works regardless of backend. The ik_llama.cpp fork already sends
// reasoning_content, in which case we leave content untouched.
chatCfg := ReadConfig()
// Nature et conversation de ces complétions, pour la télémétrie seulement
// (perf_log.go) : rien de tout ça ne part dans la requête.
ptag, perfIter := perfTagOf(ctx), 0
reasoningOn := reasoningActive(chatCfg["REASONING"])
// Intensité du raisonnement, passée telle quelle au gabarit du modèle. Vide
// = on n'envoie rien. Réglage par preset, donc de fait par modèle.
@@ -1005,13 +1023,16 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
logBudget(toolRuns, budget, budgetNudges)
messages = append(messages, Message{Role: "user", Content: msg})
}
// Normalisé juste avant l'envoi : un seul système, en tête. Les gabarits
// stricts (Qwen3.x) refusent un système ailleurs qu'en position 0.
// Gardé à part : la télémétrie compare ces messages-là d'une requête à
// l'autre (perfPrefix).
sent := normalizeSystemMessages(messages)
payload := map[string]any{
"model": ep.Model,
// Normalisé juste avant l'envoi : un seul système, en tête. Les gabarits
// stricts (Qwen3.x) refusent un système ailleurs qu'en position 0.
// Les images de l'historique y sont rangées par référence
// (chat_images.go) : on remet leurs octets juste avant l'envoi.
"messages": expandImageRefs(normalizeSystemMessages(messages)),
"messages": expandImageRefs(sent),
"stream": true,
"temperature": temperature,
// include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE
@@ -1250,6 +1271,13 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// build llama.cpp (MTP/spéculatif), a `choices:[]` — on les traite AVANT le
// garde de choices, sinon `gen_tokens`/`gen_per_second` (decode) sont jetés
// et l'UI retombe à « 0 tok/s » à la fin de la génération.
// Deuxième lecture, tolérante, pour la télémétrie : seulement sur les
// chunks qui portent timings ou usage, pas sur chaque jeton.
var pw perfWire
if chunk.Timings != nil || chunk.Usage != nil {
pw = decodePerfWire([]byte(data))
stats.applyPerf(pw)
}
if chunk.Timings != nil {
sawTimings = true
stats.PromptTokens = chunk.Timings.PromptN
@@ -1268,11 +1296,8 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
stats.PromptTokensTotal = chunk.Usage.PromptTokens
s := stats
if !ep.External {
cached, has := 0, chunk.Usage.PromptTokensDetails != nil
if has {
cached = chunk.Usage.PromptTokensDetails.CachedTokens
}
s.CacheHint = noteEnginePrompt(chunk.Usage.PromptTokens, cached, has)
cached := pw.cachedTokens()
s.CacheHint = noteEnginePrompt(chunk.Usage.PromptTokens, cached.int(), cached.ok)
}
scb(StreamEvent{Stats: &s})
}
@@ -1490,6 +1515,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
scanErr := sc.Err()
resp.Body.Close()
endReq()
// Premier jeton, de quelque nature qu'il soit (raisonnement, texte ou
// appel d'outil). tReq est repris à chaque tentative.
if !tFirst.IsZero() {
ms := tFirst.Sub(tReq).Milliseconds()
stats.TTFTms = &ms
}
if !sawTimings && !tFirst.IsZero() {
// Décodage mesuré ici ; le 1er token tombe dans la lecture du prompt.
// Pas de débit de lecture : le serveur a pu réutiliser une partie du
@@ -1507,6 +1538,26 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
s := stats
cb(StreamEvent{Stats: &s})
}
// Télémétrie (perf_log.go). Une complétion coupée avant son chunk final
// (arrêt, appel écrit en texte, refus anticipé, flux cassé) n'a ni cache
// ni total fiables : rangée comme incomplète, sans calcul de perte.
complete := !aborted && (scanErr == nil || finishReason != "") && (sawTimings || stats.PromptTokensTotal > 0)
var prefix []uint64
if complete {
prefix = perfPrefix(sent)
}
rec := perfRecord(perfRecFromStats(ptag, perfIter, complete, stats), prefix)
perfIter++
if complete {
// Dernier événement de la complétion, copie COMPLÈTE des stats : rejoué
// depuis le journal, il ne fait que repeindre la même ligne.
s := stats
s.Kind, s.Lost, s.LostAfter = rec.Kind, rec.Lost, rec.LostAfter
if (rec.Lost != nil && *rec.Lost > 0) || (rec.Cached != nil && *rec.Cached == 0) {
s.LostAlert = perfAlert(rec, perfLostAlertAt(chatCfg))
}
cb(StreamEvent{Stats: &s})
}
if aborted {
return extra, nil
}