From c72467ce4c1a6305d72881eaf8b04940e9eb456b Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sat, 3 Oct 2026 23:44:33 +0200 Subject: [PATCH] =?UTF-8?q?Moteur=20:=20corrections=20de=20relecture=20de?= =?UTF-8?q?=20l'isolation=20des=20travaux=20annexes=20=E2=80=94=20un=20slo?= =?UTF-8?q?t=20que=20le=20travail=20n'a=20jamais=20pris=20n'est=20plus=20e?= =?UTF-8?q?ffac=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un sous-agent, une vérification, une tâche ou un bench qui échoue avant que le moteur ne le serve (erreur avant l'envoi, refus 4xx, arrêt immédiat) laissait le slot 0 tel quel : il portait encore la conversation, que l'effacement jetait alors sans qu'elle soit dans le cache RAM — recalcul complet garanti, soit exactement ce que l'isolation devait éviter. - engineServed() : runChat (réponse 200 du moteur local) et le bench le notent ; un travail annexe n'efface que si le moteur a servi au moins une requête de Loki depuis son début (finishSideJob, testable sans moteur). - Les prompts d'un travail annexe ne sont plus retenus comme « la conversation » par noteEnginePrompt : deux vérifications de suite comparaient sinon la conversation au prompt du vérificateur, et le conseil pouvait se tromper. - CACHE_RAM et CACHE_ISOLATE figurent dans le modèle de configuration commenté (loki config). - Éditeur : une valeur fixée par EXTRA_ARGS ou LLAMA_ARG_CACHE_RAM (-1, 0) ne s'affiche plus comme « défaut du moteur ». Co-Authored-By: Claude Opus 5.5 --- internal/loki/llm_bench.go | 3 + internal/loki/llm_client.go | 3 + internal/loki/llm_slots.go | 97 +++++++++++++++++++++------- internal/loki/llm_slots_test.go | 48 ++++++++++++++ internal/loki/sys_service.go | 3 + internal/loki/ui/index.html | 9 ++- internal/loki/ui/src/js/07-models.js | 9 ++- 7 files changed, 141 insertions(+), 31 deletions(-) diff --git a/internal/loki/llm_bench.go b/internal/loki/llm_bench.go index 0d187a2..827b1b8 100644 --- a/internal/loki/llm_bench.go +++ b/internal/loki/llm_bench.go @@ -99,6 +99,9 @@ func runBench(nPrompt, nPredict int) (*benchResult, error) { return nil, err } defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + engineServed() // le slot porte désormais le bench : l'effacer a un sens + } var parsed struct { Timings struct { PromptN int `json:"prompt_n"` diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index f51af5b..698b490 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -1177,6 +1177,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // Une réponse est arrivée : le budget de reprise réseau repart à neuf pour // la suite de la boucle d'outils. netRetries = 0 + if !ep.External { + engineServed() // le slot porte désormais cette requête (llm_slots.go) + } toolCalls := map[int]*ToolCall{} assistantContent := strings.Builder{} finishReason := "" diff --git a/internal/loki/llm_slots.go b/internal/loki/llm_slots.go index ea48da8..7cd43f4 100644 --- a/internal/loki/llm_slots.go +++ b/internal/loki/llm_slots.go @@ -39,6 +39,8 @@ import ( // inconnu ou fork : on s'abstient ; // - un seul slot (/props total_slots, pas la clé PARALLEL qu'EXTRA_ARGS peut // contredire) ; +// - le travail annexe a bien été servi par le moteur : sinon le slot porte +// encore la conversation (erreur avant l'envoi, refus, arrêt immédiat) ; // - aucune requête de Loki en vol vers le moteur (compteur ci-dessous, tenu // par le chat, les résumés, le bench et les proxys /v1), et le slot 0 au // repos d'après /slots ; @@ -55,8 +57,29 @@ const slotEraseMinBuild = 8660 var engineGate struct { mu sync.Mutex inflight int + served uint64 // réponses 200 du moteur obtenues par runChat ou le bench } +// engineServed note qu'une requête de Loki a été acceptée par le moteur local : +// le slot porte désormais SON état. Un travail annexe qui n'en a obtenu aucune +// (erreur avant l'envoi, moteur qui refuse, arrêt immédiat) n'a pas pris le +// slot — il porte encore la conversation, et l'effacer la ferait recalculer. +func engineServed() { + engineGate.mu.Lock() + engineGate.served++ + engineGate.mu.Unlock() +} + +func engineServedCount() uint64 { + engineGate.mu.Lock() + defer engineGate.mu.Unlock() + return engineGate.served +} + +// sideJobs : travaux annexes en cours. Leurs prompts ne sont pas ceux de la +// conversation : noteEnginePrompt ne les retient ni ne les compare. +var sideJobs atomic.Int32 + // engineRequestStart marque une requête vers le moteur local ; la fonction // rendue la clôt (une seule fois, quel que soit le nombre d'appels), une fois // le corps de la réponse lu. @@ -170,35 +193,54 @@ var slotEraseLogOnce sync.Once // « defer engineSideJob()() ». Elle efface le slot si c'est sans risque, et // arme la vérification du cache sur la requête suivante (noteEnginePrompt). func engineSideJob() func() { - before := lastEnginePrompt.Load() + before, servedAt := lastEnginePrompt.Load(), engineServedCount() + sideJobs.Add(1) return func() { - ep := resolveChatEndpoint() - if ep.External { - return - } - cfg := ReadConfig() - argEnv := map[string]string{} - for _, k := range cacheArgEnv { - argEnv[k] = os.Getenv(k) - } - if !cacheIsolationOn(cfg) || cacheRAMOff(cfg, splitArgs(cfg["EXTRA_ARGS"]), argEnv) { - return - } - iso := slotIsolator{base: fmt.Sprintf("http://localhost:%d", LLMPort()), auth: ep.auth, client: http.DefaultClient} - erased, err := iso.eraseIfIdle(context.Background()) - if err != nil { - // Moteur sans --slot-save-path (501), ou /slots coupé : l'erreur est - // sans conséquence et se répéterait à chaque travail annexe. - slotEraseLogOnce.Do(func() { - fmt.Fprintf(os.Stderr, "[cache] isolation des travaux annexes indisponible : %v\n", err) - }) - } - if erased && before > 0 { - slotHintPending.Store(before) - } + sideJobs.Add(-1) + finishSideJob(before, servedAt, eraseLocalSlot) } } +// finishSideJob : la fin d'un travail annexe, séparée de ses appels au moteur +// pour les tests. Rien n'est effacé si le moteur n'a servi aucune requête de +// Loki depuis le début du travail : le slot porte alors toujours la +// conversation (ou l'état d'un client qu'on ne connaît pas). +func finishSideJob(before int64, servedAt uint64, erase func() (bool, error)) { + if engineServedCount() == servedAt { + return + } + erased, err := erase() + if err != nil { + // Moteur sans --slot-save-path (501), ou /slots coupé : l'erreur est + // sans conséquence et se répéterait à chaque travail annexe. + slotEraseLogOnce.Do(func() { + fmt.Fprintf(os.Stderr, "[cache] isolation des travaux annexes indisponible : %v\n", err) + }) + } + if erased && before > 0 { + slotHintPending.Store(before) + } +} + +// eraseLocalSlot efface le slot 0 du moteur local si le preset actif s'y prête +// et que tous les garde-fous d'eraseIfIdle passent. +func eraseLocalSlot() (bool, error) { + ep := resolveChatEndpoint() + if ep.External { + return false, nil + } + cfg := ReadConfig() + argEnv := map[string]string{} + for _, k := range cacheArgEnv { + argEnv[k] = os.Getenv(k) + } + if !cacheIsolationOn(cfg) || cacheRAMOff(cfg, splitArgs(cfg["EXTRA_ARGS"]), argEnv) { + return false, nil + } + iso := slotIsolator{base: fmt.Sprintf("http://localhost:%d", LLMPort()), auth: ep.auth, client: http.DefaultClient} + return iso.eraseIfIdle(context.Background()) +} + // Vérification du cache après un effacement. Le moteur dit, dans l'usage de // chaque réponse, combien de jetons du prompt il a repris du cache // (prompt_tokens_details.cached_tokens). Si la conversation revient après un @@ -217,6 +259,11 @@ const slotHintMinTokens = 4096 // conseil à afficher, s'il y a lieu (une seule fois par lancement ; le journal, // lui, le note à chaque fois). func noteEnginePrompt(total, cached int, hasCached bool) string { + // Prompt d'un travail annexe : ni la conversation à comparer, ni la taille + // à retenir pour le prochain (deux vérifications de suite, par exemple). + if sideJobs.Load() > 0 { + return "" + } if total > 0 { lastEnginePrompt.Store(int64(total)) } diff --git a/internal/loki/llm_slots_test.go b/internal/loki/llm_slots_test.go index e642d73..de06404 100644 --- a/internal/loki/llm_slots_test.go +++ b/internal/loki/llm_slots_test.go @@ -174,3 +174,51 @@ func TestProxysRefusentLesActionsSlots(t *testing.T) { } } } + +// Un travail annexe que le moteur n'a jamais servi (erreur avant l'envoi, +// refus, arrêt immédiat) n'a pas pris le slot : il porte encore la +// conversation, on n'efface rien. Servi, on efface et on arme la vérification. +func TestFinishSideJobSeulementApresUneRequeteServie(t *testing.T) { + slotHintPending.Store(0) + calls := 0 + erase := func() (bool, error) { calls++; return true, nil } + + finishSideJob(30000, engineServedCount(), erase) + if calls != 0 || slotHintPending.Load() != 0 { + t.Fatalf("sans requête servie : %d effacements, vérification %d", calls, slotHintPending.Load()) + } + + at := engineServedCount() + engineServed() + finishSideJob(30000, at, erase) + if calls != 1 || slotHintPending.Load() != 30000 { + t.Fatalf("après une requête servie : %d effacements, vérification %d", calls, slotHintPending.Load()) + } + slotHintPending.Store(0) + + // Abstention ou échec : rien d'armé. + at = engineServedCount() + engineServed() + finishSideJob(30000, at, func() (bool, error) { return false, nil }) + if slotHintPending.Load() != 0 { + t.Fatal("vérification armée sans effacement") + } +} + +// Pendant un travail annexe, ses propres prompts ne comptent pas : ni retenus +// comme « la conversation », ni comparés à elle. +func TestNoteEnginePromptIgnoreLesTravauxAnnexes(t *testing.T) { + slotHintShown.Store(false) + lastEnginePrompt.Store(30000) + slotHintPending.Store(30000) + sideJobs.Add(1) // ce que fait engineSideJob en entrant + if h := noteEnginePrompt(40000, 10, true); h != "" { + t.Fatalf("prompt d'un travail annexe comparé : %q", h) + } + if lastEnginePrompt.Load() != 30000 || slotHintPending.Load() != 30000 { + t.Fatalf("prompt annexe retenu : dernier %d, en attente %d", lastEnginePrompt.Load(), slotHintPending.Load()) + } + sideJobs.Add(-1) + slotHintPending.Store(0) + slotHintShown.Store(false) +} diff --git a/internal/loki/sys_service.go b/internal/loki/sys_service.go index 929dda0..6203118 100644 --- a/internal/loki/sys_service.go +++ b/internal/loki/sys_service.go @@ -80,6 +80,9 @@ var configTemplate = []struct{ key, help string }{ "sortie identique, gain de 0 à quelques %) ; off puis redémarrage s'il plante"}, {"KV_TYPE", "quantization du cache KV ; vide = f16, sorties de référence ; q8_0 les modifie légèrement, " + "q4_0 avec perte mesurable ; KV_TYPE_K / KV_TYPE_V pour les séparer"}, + {"CACHE_RAM", "cache de prompts en RAM hôte (--cache-ram, Mio), copie exacte des conversations quittées, jamais la VRAM ; " + + "vide = auto (agrandi seulement pour un modèle tout-GPU), -1 = sans limite, 0 = coupé"}, + {"CACHE_ISOLATE", "off = ne plus effacer le slot après un sous-agent, une vérification, une tâche ou un bench"}, {"REASONING", "passthrough du mode raisonnement (on/auto/deepseek)"}, {"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"}, {"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high / xhigh"}, diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index d94dcab..22c4419 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -5894,10 +5894,13 @@ async function refreshCacheRAM(){ try{ r = await jpost('/api/preset/cacheram', {content: document.getElementById('m-content').value}); } catch(_){ r = {}; } if(seq !== cramSeq || !r.ok) return; - const auto = r.mib > 0 ? r.mib : r.default; - el.placeholder = 'auto · ' + auto; + // Valeur fixée (CACHE_RAM, -cram, LLAMA_ARG_CACHE_RAM) : -1 et 0 ne sont pas + // « le défaut du moteur », la raison seule suffit. + const fixed = /^fixé/.test(r.why || ''); + el.placeholder = fixed ? (r.mib < 0 ? 'sans limite' : r.mib === 0 ? 'coupé' : String(r.mib)) + : 'auto · ' + (r.mib > 0 ? r.mib : r.default); if(sub) sub.textContent = 'RAM hôte, copie exacte du contexte · jamais la VRAM' + - (r.why ? ' · ' + r.why : '') + (r.mib > 0 ? '' : ' · défaut du moteur'); + (r.why ? ' · ' + r.why : '') + (r.mib > 0 || fixed ? '' : ' · défaut du moteur'); } function syncKVSub(){ const el = document.getElementById('s-kv-sub'); diff --git a/internal/loki/ui/src/js/07-models.js b/internal/loki/ui/src/js/07-models.js index 9f6335e..2d6d7f7 100644 --- a/internal/loki/ui/src/js/07-models.js +++ b/internal/loki/ui/src/js/07-models.js @@ -970,10 +970,13 @@ async function refreshCacheRAM(){ try{ r = await jpost('/api/preset/cacheram', {content: document.getElementById('m-content').value}); } catch(_){ r = {}; } if(seq !== cramSeq || !r.ok) return; - const auto = r.mib > 0 ? r.mib : r.default; - el.placeholder = 'auto · ' + auto; + // Valeur fixée (CACHE_RAM, -cram, LLAMA_ARG_CACHE_RAM) : -1 et 0 ne sont pas + // « le défaut du moteur », la raison seule suffit. + const fixed = /^fixé/.test(r.why || ''); + el.placeholder = fixed ? (r.mib < 0 ? 'sans limite' : r.mib === 0 ? 'coupé' : String(r.mib)) + : 'auto · ' + (r.mib > 0 ? r.mib : r.default); if(sub) sub.textContent = 'RAM hôte, copie exacte du contexte · jamais la VRAM' + - (r.why ? ' · ' + r.why : '') + (r.mib > 0 ? '' : ' · défaut du moteur'); + (r.why ? ' · ' + r.why : '') + (r.mib > 0 || fixed ? '' : ' · défaut du moteur'); } function syncKVSub(){ const el = document.getElementById('s-kv-sub');