mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-13 18:28:03 +02:00
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:
1 parent
3565b594ea
commit
c72467ce4c
7 files changed
+141
-31
No files matched your search
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"},
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user