diff --git a/internal/loki/chat_compact.go b/internal/loki/chat_compact.go index 65b2c6c..d395c8d 100644 --- a/internal/loki/chat_compact.go +++ b/internal/loki/chat_compact.go @@ -680,8 +680,11 @@ Write the summary in the SAME language as the conversation.` if err != nil { return "", err } + // Decoder et non Unmarshal : comme avant, seule la première valeur JSON + // compte — un octet parasite après elle (proxy, passerelle) ne doit pas + // faire échouer une compaction qui passait. var out summarizeResp - if err := json.Unmarshal(raw, &out); err != nil { + if err := json.NewDecoder(bytes.NewReader(raw)).Decode(&out); err != nil { return "", err } // Télémétrie de la compaction : réponse non streamée, timings et usage sont diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index ca2141d..30fd139 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -1553,7 +1553,10 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // 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) { + // Pas d'alerte pour une API externe : son cache (s'il existe) suit ses + // propres règles, sans slot ni points de reprise à surveiller ici. Le + // relevé reste dans l'anneau, l'affichage ne change pas. + if !ep.External && ((rec.Lost != nil && *rec.Lost > 0) || (rec.Cached != nil && *rec.Cached == 0)) { s.LostAlert = perfAlert(rec, perfLostAlertAt(chatCfg)) } cb(StreamEvent{Stats: &s}) diff --git a/internal/loki/perf_log.go b/internal/loki/perf_log.go index b0b701c..c29a9b1 100644 --- a/internal/loki/perf_log.go +++ b/internal/loki/perf_log.go @@ -1,6 +1,7 @@ package loki import ( + "bytes" "context" "encoding/json" "fmt" @@ -115,8 +116,10 @@ type perfWire struct { } func decodePerfWire(data []byte) perfWire { + // Decoder plutôt qu'Unmarshal : un octet parasite après la réponse ne doit + // pas tout rendre inconnu. Ce qui a pu être lu l'est ; le reste reste inconnu. var w perfWire - _ = json.Unmarshal(data, &w) // ce qui a pu être lu l'est ; le reste reste inconnu + _ = json.NewDecoder(bytes.NewReader(data)).Decode(&w) return w } @@ -390,12 +393,24 @@ func perfLine(r perfRec) string { // reprise : perdre jusqu'à un espacement de points (--checkpoint-min-step, // 2048 posé par Loki) plus un micro-batch est l'état NORMAL, pas un incident. // Le signaler à chaque étape noierait les vraies pertes. +// +// Même ordre de priorité que le lancement (ckptArgs) : un -cms d'EXTRA_ARGS ou +// LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT l'emporte sur CKPT_MIN_STEP, un -ub +// d'EXTRA_ARGS sur UBATCH. func perfLostAlertAt(cfg map[string]string) int { const base = 1000 - step := 0 - if n, err := strconv.Atoi(strings.TrimSpace(cfg["CKPT_MIN_STEP"])); err == nil && n > 0 { - step = n - } else if m := strings.TrimSpace(cfg["MODEL"]); m != "" { + extra := splitArgs(cfg["EXTRA_ARGS"]) + num := func(vals ...string) int { + for _, v := range vals { + if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 { + return n + } + } + return 0 + } + step := num(flagValue(extra, "-cms", "--checkpoint-min-step"), + os.Getenv("LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT"), cfg["CKPT_MIN_STEP"]) + if m := strings.TrimSpace(cfg["MODEL"]); step == 0 && m != "" { if p, err := resolveServeModelPath(m); err == nil { if g, err := ggufMeta(p); err == nil && ggufHybrid(&g) { step = 2048 @@ -405,9 +420,9 @@ func perfLostAlertAt(cfg map[string]string) int { if step == 0 { return base } - ub := 512 - if n, err := strconv.Atoi(strings.TrimSpace(cfg["UBATCH"])); err == nil && n > 0 { - ub = n + ub := num(flagValue(extra, "-ub", "--ubatch-size"), cfg["UBATCH"]) + if ub == 0 { + ub = 512 } return max(base, step+ub) } diff --git a/internal/loki/perf_log_test.go b/internal/loki/perf_log_test.go index 30848e6..b7c7d24 100644 --- a/internal/loki/perf_log_test.go +++ b/internal/loki/perf_log_test.go @@ -43,6 +43,8 @@ func TestPerfWireDecode(t *testing.T) { {"moteur muet", `{"usage":{"prompt_tokens":900}}`, nil, nil, nil}, // Types de travers (fork, passerelle) : lu si c'est un nombre, sinon inconnu. {"types de travers", `{"timings":{"cache_n":"abc","draft_n":"12","draft_n_accepted":7.0}}`, nil, iptr(12), iptr(7)}, + // Octets parasites après la réponse (proxy) : la première valeur compte. + {"octets parasites", `{"timings":{"cache_n":12}}` + "\nxyz", iptr(12), nil, nil}, {"structure de travers", `{"timings":"nope","usage":{"prompt_tokens_details":{"cached_tokens":5.9}}}`, iptr(5), nil, nil}, } eq := func(a, b *int) bool { return (a == nil) == (b == nil) && (a == nil || *a == *b) } @@ -268,12 +270,20 @@ func TestPerfLostAlert(t *testing.T) { {"espacement posé", map[string]string{"CKPT_MIN_STEP": "2048"}, 2560}, {"espacement et micro-batch", map[string]string{"CKPT_MIN_STEP": "4096", "UBATCH": "1024"}, 5120}, {"espacement illisible", map[string]string{"CKPT_MIN_STEP": "beaucoup"}, 1000}, + // Comme au lancement : EXTRA_ARGS l'emporte sur les clés du preset. + {"-cms d'EXTRA_ARGS", map[string]string{"CKPT_MIN_STEP": "2048", "EXTRA_ARGS": "--checkpoint-min-step 8192 -ub 256"}, 8448}, + {"-ub d'EXTRA_ARGS seul", map[string]string{"CKPT_MIN_STEP": "2048", "UBATCH": "1024", "EXTRA_ARGS": "-ub=2048"}, 4096}, } + t.Setenv("LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT", "") for _, c := range cases { if got := perfLostAlertAt(c.cfg); got != c.want { t.Errorf("%s : seuil %d, attendu %d", c.name, got, c.want) } } + t.Setenv("LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT", "3000") + if got := perfLostAlertAt(map[string]string{"CKPT_MIN_STEP": "2048"}); got != 3512 { + t.Errorf("LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT ignorée : seuil %d", got) + } if perfAlert(perfRec{Lost: iptr(900)}, 1000) || !perfAlert(perfRec{Lost: iptr(1500)}, 1000) { t.Fatal("seuil de perte") } diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 4a12130..116c9e7 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -7414,7 +7414,7 @@ function renderStats(el, s){ if(s.gen_tokens) parts.push('decode '+s.gen_tokens+' tok · '+(s.gen_per_second||0).toFixed(1)+' tok/s'); // Perte de cache signalée par le serveur (seuil relevé sur un modèle hybride, // dont les points de reprise font perdre un peu à chaque étape). - if(s.lost_alert && s.lost) parts.push(nfmt(s.lost)+' recalculés'+(s.lost_after ? ' après '+s.lost_after : '')); + if(s.lost_alert && s.lost) parts.push(nfmt(s.lost)+' recalculés'+(s.lost_after ? ' après '+s.lost_after.split(',').map(k=>PERF_KIND[k]||k).join(', ') : '')); if(!parts.length) return; // Réponse de l'assistant : ligne de mesures dédiée sous le texte (son étiquette // est masquée dans cette mise en page). Bulle repliable : l'étiquette EST le @@ -7426,6 +7426,9 @@ function renderStats(el, s){ if(sl) sl.classList.toggle('cache-miss', !!s.lost_alert); } } +// Ce qui s'est intercalé avant une perte de cache (lost_after, perf_log.go), +// dit en clair : les identifiants internes n'ont rien à faire dans l'interface. +const PERF_KIND={main:'tour', subagent:'sous-agent', verify:'vérification', task:'tâche', compact:'compaction', bench:'bench', foreign:'client /v1'}; // Milliers séparés par une espace fine insécable : « 41 230 », pas « 41230 ». function nfmt(n){ return String(Math.round(n||0)).replace(/\B(?=(\d{3})+(?!\d))/g,'\u202f'); } // --- Compteurs de bulle ----------------------------------------------------- diff --git a/internal/loki/ui/src/js/09-stream.js b/internal/loki/ui/src/js/09-stream.js index ed115c4..3afcd7a 100644 --- a/internal/loki/ui/src/js/09-stream.js +++ b/internal/loki/ui/src/js/09-stream.js @@ -249,7 +249,7 @@ function renderStats(el, s){ if(s.gen_tokens) parts.push('decode '+s.gen_tokens+' tok · '+(s.gen_per_second||0).toFixed(1)+' tok/s'); // Perte de cache signalée par le serveur (seuil relevé sur un modèle hybride, // dont les points de reprise font perdre un peu à chaque étape). - if(s.lost_alert && s.lost) parts.push(nfmt(s.lost)+' recalculés'+(s.lost_after ? ' après '+s.lost_after : '')); + if(s.lost_alert && s.lost) parts.push(nfmt(s.lost)+' recalculés'+(s.lost_after ? ' après '+s.lost_after.split(',').map(k=>PERF_KIND[k]||k).join(', ') : '')); if(!parts.length) return; // Réponse de l'assistant : ligne de mesures dédiée sous le texte (son étiquette // est masquée dans cette mise en page). Bulle repliable : l'étiquette EST le @@ -261,6 +261,9 @@ function renderStats(el, s){ if(sl) sl.classList.toggle('cache-miss', !!s.lost_alert); } } +// Ce qui s'est intercalé avant une perte de cache (lost_after, perf_log.go), +// dit en clair : les identifiants internes n'ont rien à faire dans l'interface. +const PERF_KIND={main:'tour', subagent:'sous-agent', verify:'vérification', task:'tâche', compact:'compaction', bench:'bench', foreign:'client /v1'}; // Milliers séparés par une espace fine insécable : « 41 230 », pas « 41230 ». function nfmt(n){ return String(Math.round(n||0)).replace(/\B(?=(\d{3})+(?!\d))/g,'\u202f'); } // --- Compteurs de bulle -----------------------------------------------------