Moteur : corrections de relecture de l'isolation des travaux annexes — un slot que le travail n'a jamais pris n'est plus effacé

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 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-03 23:44:33 +02:00
1 parent 3565b594ea
commit c72467ce4c
7 files changed
+141 -31

No files matched your search

+3
View File
@@ -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"`
+3
View File
@@ -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 := ""
+72 -25
View File
@@ -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))
}
+48
View File
@@ -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)
}
+3
View File
@@ -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"},
+6 -3
View File
@@ -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');
+6 -3
View File
@@ -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');