diff --git a/README.md b/README.md index 708158e..f0ff323 100644 --- a/README.md +++ b/README.md @@ -400,7 +400,8 @@ Ajoutées par ce fork : autre requête de Loki l'annule aussitôt, sauf un message dont la requête prolonge exactement le préfixe préparé (même historique, mêmes outils, mêmes réglages du gabarit). Jamais avec plus d'un slot (`PARALLEL` ou `-np` dans - `EXTRA_ARGS`), pendant un tour, une tâche, un bench, ni vers un preset externe. + `EXTRA_ARGS`) — sauf les deux de `SIDE_SLOT`, où il prépare le slot de la + discussion —, pendant un tour, une tâche, un bench, ni vers un preset externe. `full` prépare aussi la discussion qu'on ouvre, si on y reste 3 s. Visible dans la télémétrie sous `prewarm`. Ce que voit le modèle ne change pas : au pire, le préchauffage ne sert à rien (moteur sans points de reprise aux messages @@ -470,6 +471,33 @@ Ajoutées par ce fork : place si : un modèle entraîné à se méfier des consignes lues dans une sortie d'outil peut moins bien le suivre. À comparer (tours qui concluent après le rappel) avant de l'adopter. +- **Second slot pour les travaux annexes** (clé `SIDE_SLOT`, **off** par + défaut, `loki config set SIDE_SLOT on`, moteur relancé) : avec un seul slot, + une vérification du mode Code, un sous-agent, une tâche planifiée ou un bench + prennent la place de la discussion ; son état part dans le cache RAM et en + revient — ou est recalculé s'il n'y tient plus. Avec `on`, le moteur ouvre + deux slots de `CTX` jetons **chacun** (`-c` 2×`CTX`, `--parallel 2`, + `--no-kv-unified`) : deux flux de cache KV séparés, si bien qu'un slot ne + peut jamais manquer de place à cause de l'autre, et que les travaux annexes + gardent toute la fenêtre. Chaque requête de Loki porte alors son `id_slot` : + le tour, ses étapes, le préchauffage et le résumé en continuation sur le + slot 0 ; vérification, sous-agents, tâches, bench, résumé sur transcription + et clients `/v1` (forcés, quel que soit l'`id_slot` demandé) sur le slot 1. + L'état de la discussion ne bouge plus ; l'effacement de slot après un + travail annexe (`CACHE_ISOLATE`) n'a plus lieu d'être et s'abstient. Le prix : + le cache KV et l'état récurrent d'un hybride en double en VRAM (le + brouillon MTP et les points de reprise en RAM hôte aussi) — l'éditeur de + preset affiche ce surcoût sous le cache de prompts, et le lancement refuse la + clé (un slot, comme sans elle, avec une note dans le journal) si modèle et + deux états dépassent 90 % de la VRAM, si la VRAM est inconnue (macOS, AMD), + si des poids sont sur CPU (`--n-cpu-moe`, `-ot …=CPU`, `NGL` partiel), si le + moteur ne connaît pas `--no-kv-unified`, ou si `PARALLEL`≠2, `-c`, `-np`, + `-kvu`, `--kv-unified-per-slot`, `--cache-idle-slots` (ou leurs + `LLAMA_ARG_*`) sont réglés à la main. Le routage ne s'active que si le moteur + annonce bien deux slots d'au moins `CTX` jetons (`/props`) ; sinon les + requêtes partent sans `id_slot`, comme avant. Ce que voit le modèle ne + change pas : deux slots qui calculent en même temps donnent des logits égaux + au bruit de virgule flottante près, comme un découpage de lots différent. - **Discussions multiples** : historique complet dans la barre latérale, titre repris du premier message (renommable), suppression. **Chaque discussion a son dossier de fichiers** (`workspace/discussions//`) : les pièces jointes diff --git a/internal/loki/backend_serve.go b/internal/loki/backend_serve.go index accd5d6..69f9406 100644 --- a/internal/loki/backend_serve.go +++ b/internal/loki/backend_serve.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" "strings" "sync" "time" @@ -344,6 +345,7 @@ func cmdServe(args []string) error { probeFidelityEnv(&si) probeCacheRAM(cfg, extra, &si) probeCkptEnv(&si) + probeSideSlotEnv(cfg, &si) spec := specMode(cfg) if spec == "auto" || spec == "mtp" { probeSpec(cfg, &si) @@ -521,9 +523,16 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS // physiques au lieu de tous les threads logiques (voir threadArgs). threads, threadNotes := threadArgs(cfg["THREADS"], cfg["THREADS_BATCH"], extra, si.CPU) notes = append(notes, threadNotes...) + // Second slot (SIDE_SLOT, voir backend_serve_side.go) : sans la clé, sideCtx + // vaut 0 et rien de ce qui suit ne change. + sideCtx, sideMiB, sideWhy := sideSlotPlan(cfg, extra, si) + ctxArg := get("CTX", "32768") + if sideCtx > 0 { + ctxArg = strconv.Itoa(2 * sideCtx) + } args = []string{bin, "-m", si.Model, - "-c", get("CTX", "32768"), + "-c", ctxArg, } args = append(args, threads...) args = append(args, @@ -540,9 +549,16 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS // uniquement à cause de ce nouveau défaut. PARALLEL=n dans le preset pour qui // veut vraiment servir plusieurs requêtes à la fois — ou --parallel dans // EXTRA_ARGS, auquel cas on ne redouble pas le drapeau. - if !hasAnyFlag(extra, "--parallel", "-np") { + if sideCtx > 0 { + // Deux flux KV séparés de sideCtx jetons : aucun slot ne peut manger la + // place de l'autre (voir backend_serve_side.go). + args = append(args, "--parallel", "2", "--no-kv-unified") + } else if !hasAnyFlag(extra, "--parallel", "-np") { args = append(args, "--parallel", get("PARALLEL", "1")) } + if n := sideSlotNote(sideCtx, sideMiB, sideWhy, nglForced(cfg, extra)); n != "" { + notes = append(notes, n) + } // Couches GPU. Quatre cas, dans cet ordre : // // NGL=auto → rien du tout (le moteur décide seul) diff --git a/internal/loki/backend_serve_side.go b/internal/loki/backend_serve_side.go new file mode 100644 index 0000000..a070680 --- /dev/null +++ b/internal/loki/backend_serve_side.go @@ -0,0 +1,211 @@ +package loki + +import ( + "fmt" + "math" + "os" + "strconv" + "strings" +) + +// Second slot pour les travaux annexes (SIDE_SLOT, off par défaut). +// +// En --parallel 1, un vérificateur, un sous-agent, une tâche ou un bench +// prennent LE slot de la conversation : son état part dans le cache RAM et +// revient après — quand il y tient encore. Avec SIDE_SLOT=on, le moteur ouvre +// deux slots, la conversation garde le slot 0 et tout le reste passe par le +// slot 1 (id_slot, voir llm_sideslot.go) : l'état principal ne bouge plus du +// tout. +// +// Le dimensionnement est le cœur de l'affaire. Un cache KV UNIFIÉ partagé entre +// deux slots laisse chacun grandir jusqu'à la taille du pool : quand les deux +// travaillent et que le pool est plein, llama.cpp renvoie « Context size has +// been exceeded. » aux DEUX, et Loki prendrait ça pour un débordement de la +// conversation — compaction, voire réduction, d'un fil qui tenait très bien +// dans sa fenêtre. Et --kv-unified-per-slot plafonne tous les slots à la même +// valeur, sans rien garantir sur la somme. On prend donc le cache NON unifié +// (--no-kv-unified) avec -c 2×CTX : llama.cpp découpe alors le pool en deux +// flux de CTX jetons chacun, séparés. Un débordement concurrent est impossible +// par construction, chaque slot a exactement la fenêtre de la conversation (les +// travaux annexes ne sont pas raccourcis), et --cache-idle-slots ne vide plus +// les slots au repos (il ne le fait que sous cache unifié) : rien d'autre à +// régler. +// +// Le prix : le cache KV (et l'état récurrent d'un hybride) en double, en VRAM. +// On refuse donc l'option quand le compte ne tient pas, quand des poids vivent +// en RAM (le KV en plus pousserait le placement automatique à sortir des +// couches du GPU), et dès que l'utilisateur a réglé lui-même l'un des leviers +// en jeu : on ne mélange pas ses réglages et les nôtres. + +// sideSlotOn : la clé SIDE_SLOT est-elle posée ? +func sideSlotOn(cfg map[string]string) bool { + switch strings.ToLower(strings.TrimSpace(cfg["SIDE_SLOT"])) { + case "on", "1", "true", "yes", "oui": + return true + } + return false +} + +// sideSlotConflictFlags : les drapeaux d'EXTRA_ARGS qui décident déjà de ce que +// SIDE_SLOT règle. Un seul suffit à refuser. +var sideSlotConflictFlags = []string{ + "-c", "--ctx-size", + "-np", "--parallel", + "-kvu", "--kv-unified", "-no-kvu", "--no-kv-unified", + "--kv-unified-per-slot", + "--cache-idle-slots", "--no-cache-idle-slots", +} + +// sideSlotArgEnv : les mêmes leviers posés par variable. La ligne de commande +// les écraserait sans rien dire : un choix de l'utilisateur, on s'abstient. +var sideSlotArgEnv = []string{"LLAMA_ARG_N_PARALLEL", "LLAMA_ARG_KV_UNIFIED", "LLAMA_ARG_KV_UNIFIED_PER_SLOT", + "LLAMA_ARG_CACHE_IDLE_SLOTS", "LLAMA_ARG_CTX_SIZE"} + +// probeSideSlotEnv complète ArgEnv avec sideSlotArgEnv, seulement si la clé est +// posée : sans elle, rien n'est lu. +func probeSideSlotEnv(cfg map[string]string, si *serveSysInfo) { + if !sideSlotOn(cfg) { + return + } + if si.ArgEnv == nil { + si.ArgEnv = map[string]string{} + } + for _, k := range sideSlotArgEnv { + if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" { + si.ArgEnv[k] = v + } + } +} + +// sideSlotPlan décide de SIDE_SLOT pour ce lancement. ctx > 0 : accepté, chaque +// slot aura ctx jetons (le pool en fait le double) ; extraMiB est la VRAM de +// plus, estimée. ctx == 0 : refusé (why dit pourquoi), ou clé absente (why vide) +// — la ligne de commande est alors celle d'avant, à l'octet près. +func sideSlotPlan(cfg map[string]string, extra []string, si serveSysInfo) (ctx int, extraMiB int64, why string) { + if !sideSlotOn(cfg) { + return 0, 0, "" + } + if isExternalConfig(cfg) { + return 0, 0, "preset externe" + } + if p := strings.TrimSpace(cfg["PARALLEL"]); p != "" && p != "2" { + return 0, 0, "PARALLEL=" + p + " (SIDE_SLOT ouvre exactement 2 slots)" + } + for _, f := range sideSlotConflictFlags { + if hasAnyFlag(extra, f) { + return 0, 0, f + " est réglé dans EXTRA_ARGS" + } + } + for _, k := range sideSlotArgEnv { + if si.ArgEnv[k] != "" { + return 0, 0, k + " est posée" + } + } + if !strings.Contains(si.Help, "--no-kv-unified") { + return 0, 0, "ce moteur ne connaît pas --no-kv-unified" + } + n, err := strconv.Atoi(strings.TrimSpace(cfg["CTX"])) + if strings.TrimSpace(cfg["CTX"]) == "" { + n, err = 32768, nil + } + if err != nil || n <= 0 { + return 0, 0, "CTX doit être un nombre de jetons explicite" + } + extraMiB, why = sideSlotVRAM(cfg, extra, si, n) + if why != "" { + return 0, 0, why + } + return n, extraMiB, "" +} + +// sideSlotVRAM estime la VRAM du second slot (cache KV de ctx jetons et état +// récurrent) et vérifie que modèle + deux états tiennent dans 90 % de la VRAM, +// la même marge que cacheRAMAuto. Inconnu = refus : on ne double pas le cache +// KV à l'aveugle, c'est exactement la panne de mémoire qu'on a déjà vécue. +func sideSlotVRAM(cfg map[string]string, extra []string, si serveSysInfo, ctx int) (extraMiB int64, why string) { + switch { + case si.VRAMMiB <= 0: + return 0, "VRAM NVIDIA inconnue (macOS, AMD, --device…)" + case si.GGUF == nil: + return 0, "métadonnées GGUF illisibles" + case si.ModelBytes <= 0: + return 0, "taille du modèle inconnue" + } + if r := cpuWeights(cfg, extra, si.ArgEnv, si.GGUF.BlockCount); r != "" { + return 0, r + } + kt, vt, _ := effectiveKVTypes(cfg, extra, si.ArgEnv) + perTok, rec := ggufStateBytes(*si.GGUF, kt, vt) + if perTok <= 0 { + return 0, "taille d'état inconnue" + } + state := perTok*float64(ctx) + float64(rec) + if float64(si.ModelBytes)+2*state > 0.9*float64(si.VRAMMiB)*(1<<20) { + return 0, fmt.Sprintf("VRAM insuffisante : modèle + 2 × %d Mio d'état dépassent 90 %% de %d Mio", + int64(math.Ceil(state/(1<<20))), si.VRAMMiB) + } + return int64(math.Ceil(state / (1 << 20))), "" +} + +// sideSlotNote : ce que le lancement dit de SIDE_SLOT, accepté ou refusé. +func sideSlotNote(ctx int, extraMiB int64, why string, nglForced bool) string { + if ctx <= 0 { + if why == "" { + return "" + } + return "SIDE_SLOT refusé (" + why + ") : un seul slot, comme sans la clé" + } + n := fmt.Sprintf("SIDE_SLOT : 2 slots de %d jetons (-c %d, --no-kv-unified), ~%d Mio de VRAM en plus "+ + "pour le second (cache KV et état récurrent ; le brouillon MTP éventuel et les points de reprise en RAM hôte "+ + "doublent aussi)", ctx, 2*ctx, extraMiB) + if nglForced { + return n + " ; NGL est imposé : surveille la VRAM au premier long prompt" + } + return n + " ; le placement automatique sortira des couches du GPU s'il manque de place" +} + +// sideSlotPreview : l'avis de SIDE_SLOT pour l'éditeur de preset, calculé comme +// au lancement, à l'aide du moteur près (supposée connaître --no-kv-unified : +// un moteur trop ancien sera refusé au lancement, et le journal le dira). +func sideSlotPreview(content string) (on bool, extraMiB int64, why string) { + cfg := parseEnv(content) + if !sideSlotOn(cfg) { + return false, 0, "" + } + if isExternalConfig(cfg) { + return false, 0, "preset externe" + } + extra := splitArgs(cfg["EXTRA_ARGS"]) + si := serveSysInfo{Help: "--no-kv-unified", ArgEnv: map[string]string{}} + for _, k := range append(append(append(append([]string{}, serveArgEnv...), fidelityArgEnv...), cacheArgEnv...), sideSlotArgEnv...) { + if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" { + si.ArgEnv[k] = v + } + } + if m := strings.TrimSpace(cfg["MODEL"]); m != "" { + if p, err := resolveServeModelPath(m); err == nil { + si.Model = p + } + } + if si.Model == "" { + return false, 0, "modèle introuvable" + } + probeCacheRAM(cfg, extra, &si) + ctx, mib, why := sideSlotPlan(cfg, extra, si) + return ctx > 0, mib, why +} + +// nglForced : le preset impose-t-il un nombre de couches GPU ? Alors le +// placement automatique ne tourne pas, et un second état de trop finit en +// panne de mémoire plutôt qu'en couches sorties du GPU. 999 est la sentinelle +// « toutes », traduite en auto sur un moteur récent (voir nglArgs). +func nglForced(cfg map[string]string, extra []string) bool { + v := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") + if v == "" { + v = strings.TrimSpace(cfg["NGL"]) + if v == "999" { + return false + } + } + return v != "" && !strings.EqualFold(v, "auto") +} diff --git a/internal/loki/backend_serve_side_test.go b/internal/loki/backend_serve_side_test.go new file mode 100644 index 0000000..b14aa64 --- /dev/null +++ b/internal/loki/backend_serve_side_test.go @@ -0,0 +1,147 @@ +package loki + +import ( + "reflect" + "strings" + "testing" +) + +// helpSide : un moteur qui connaît le cache KV non unifié. +const helpSide = helpCacheRAM + `-kvu, --kv-unified, -no-kvu, --no-kv-unified + use single unified KV buffer shared across all sequences +` + +// sideCfg : le preset de denseGPU (0,25 Mio d'état par jeton) à 32k, avec ou +// sans SIDE_SLOT. +func sideCfg(kv ...string) map[string]string { + cfg := map[string]string{"CTX": "32768", "NGL": "999"} + for i := 0; i+1 < len(kv); i += 2 { + cfg[kv[i]] = kv[i+1] + } + return cfg +} + +func sideSI() serveSysInfo { + si := denseGPU() + si.Help = helpSide + return si +} + +// Sans la clé (ou à off), la ligne de commande est celle d'avant, à l'octet +// près, notes comprises. +func TestSideSlotDefautIdentique(t *testing.T) { + for _, v := range []string{"", "off", "0"} { + cfg := sideCfg() + if v != "" { + cfg["SIDE_SLOT"] = v + } + got, _, notes := buildServeArgs(cfg, nil, "/bin/llama-server", sideSI()) + want, _, wantNotes := buildServeArgs(sideCfg(), nil, "/bin/llama-server", sideSI()) + if !reflect.DeepEqual(got, want) || !reflect.DeepEqual(notes, wantNotes) { + t.Fatalf("SIDE_SLOT=%q change la ligne :\n%v\n%v", v, got, want) + } + if argValue(got, "--parallel") != "1" || argValue(got, "-c") != "32768" { + t.Fatalf("défaut : %v", got) + } + for _, a := range got { + if a == "--no-kv-unified" { + t.Fatal("--no-kv-unified sans la clé") + } + } + } +} + +// Accepté : deux slots de CTX jetons chacun, flux KV séparés — le pool fait +// le double, aucun slot ne peut déborder sur l'autre. +func TestSideSlotAccepte(t *testing.T) { + for _, cfg := range []map[string]string{sideCfg("SIDE_SLOT", "on"), sideCfg("SIDE_SLOT", "on", "PARALLEL", "2")} { + args, _, notes := buildServeArgs(cfg, nil, "/bin/llama-server", sideSI()) + if argValue(args, "-c") != "65536" || argValue(args, "--parallel") != "2" { + t.Fatalf("args = %v", args) + } + n := 0 + for i, a := range args { + if a == "--no-kv-unified" { + n++ + if args[i-1] != "2" || args[i-2] != "--parallel" { + t.Fatalf("--no-kv-unified mal placé : %v", args) + } + } + if a == "--kv-unified-per-slot" || a == "-kvu" || a == "--kv-unified" { + t.Fatalf("drapeau inattendu %s", a) + } + } + if n != 1 { + t.Fatalf("--no-kv-unified × %d", n) + } + found := false + for _, s := range notes { + if strings.HasPrefix(s, "SIDE_SLOT : 2 slots de 32768 jetons") && strings.Contains(s, "~8192 Mio") { + found = true + } + } + if !found { + t.Fatalf("note de VRAM absente : %q", notes) + } + } +} + +// Refusé : la ligne est exactement celle sans la clé, plus une note qui dit +// pourquoi. +func TestSideSlotRefus(t *testing.T) { + cases := []struct { + name string + cfg map[string]string + extra []string + si func(*serveSysInfo) + why string + }{ + {name: "PARALLEL=3", cfg: sideCfg("SIDE_SLOT", "on", "PARALLEL", "3"), why: "PARALLEL=3"}, + {name: "-c dans EXTRA_ARGS", extra: []string{"-c", "16384"}, why: "-c "}, + {name: "--ctx-size=", extra: []string{"--ctx-size=16384"}, why: "--ctx-size"}, + {name: "-np", extra: []string{"-np", "4"}, why: "-np"}, + {name: "-kvu", extra: []string{"-kvu"}, why: "-kvu"}, + {name: "--no-kv-unified", extra: []string{"--no-kv-unified"}, why: "--no-kv-unified"}, + {name: "--kv-unified-per-slot", extra: []string{"--kv-unified-per-slot", "8192"}, why: "--kv-unified-per-slot"}, + {name: "--cache-idle-slots", extra: []string{"--cache-idle-slots"}, why: "--cache-idle-slots"}, + {name: "--no-cache-idle-slots", extra: []string{"--no-cache-idle-slots"}, why: "--no-cache-idle-slots"}, + {name: "variable LLAMA_ARG_N_PARALLEL", si: func(si *serveSysInfo) { si.ArgEnv["LLAMA_ARG_N_PARALLEL"] = "4" }, why: "LLAMA_ARG_N_PARALLEL"}, + {name: "variable LLAMA_ARG_KV_UNIFIED", si: func(si *serveSysInfo) { si.ArgEnv["LLAMA_ARG_KV_UNIFIED"] = "1" }, why: "LLAMA_ARG_KV_UNIFIED"}, + {name: "moteur sans --no-kv-unified", si: func(si *serveSysInfo) { si.Help = helpCacheRAM }, why: "--no-kv-unified"}, + {name: "CTX=0", cfg: sideCfg("SIDE_SLOT", "on", "CTX", "0"), why: "CTX"}, + {name: "VRAM trop juste à 64k", cfg: sideCfg("SIDE_SLOT", "on", "CTX", "65536"), why: "VRAM insuffisante"}, + {name: "VRAM inconnue", si: func(si *serveSysInfo) { si.VRAMMiB = 0 }, why: "VRAM"}, + {name: "GGUF illisible", si: func(si *serveSysInfo) { si.GGUF = nil }, why: "GGUF"}, + {name: "experts sur CPU", extra: []string{"--n-cpu-moe", "20"}, why: "CPU"}, + {name: "-ot sur CPU", extra: []string{"-ot", "exps=CPU"}, why: "CPU"}, + {name: "NGL partiel", cfg: sideCfg("SIDE_SLOT", "on", "NGL", "30"), why: "couches GPU"}, + } + for _, c := range cases { + cfg := c.cfg + if cfg == nil { + cfg = sideCfg("SIDE_SLOT", "on") + } + si := sideSI() + if c.si != nil { + c.si(&si) + } + _, _, why := sideSlotPlan(cfg, c.extra, si) + if !strings.Contains(why, c.why) { + t.Errorf("%s : why = %q, attendu %q", c.name, why, c.why) + } + got, _, notes := buildServeArgs(cfg, c.extra, "/bin/llama-server", si) + off := map[string]string{} + for k, v := range cfg { + if k != "SIDE_SLOT" { + off[k] = v + } + } + want, _, wantNotes := buildServeArgs(off, c.extra, "/bin/llama-server", si) + if !reflect.DeepEqual(got, want) { + t.Errorf("%s : refus qui change la ligne\n%v\n%v", c.name, got, want) + } + if len(notes) != len(wantNotes)+1 { + t.Errorf("%s : notes %q", c.name, notes) + } + } +} diff --git a/internal/loki/chat_compact.go b/internal/loki/chat_compact.go index b02cf98..9b55a86 100644 --- a/internal/loki/chat_compact.go +++ b/internal/loki/chat_compact.go @@ -72,6 +72,9 @@ func compactEnabled() bool { // ctxWindow renvoie la fenêtre de contexte configurée (config.env CTX), 32768 // par défaut — la même valeur que celle passée à llama-server au lancement. +// Avec SIDE_SLOT, le moteur reçoit -c 2×CTX, mais chacun de ses deux slots +// fait CTX jetons : la fenêtre d'une conversation, comme d'un travail annexe, +// reste CTX (backend_serve_side.go). func ctxWindow() int { if v := ReadConfig()["CTX"]; v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { @@ -839,6 +842,10 @@ func summarizeTranscriptFor(ctx context.Context, transcript string, code bool) ( // argument inconnu par un 400, et la compaction échouait à chaque fois. delete(payload, "chat_template_kwargs") } + // SIDE_SLOT : la transcription est un prompt neuf, calculé à froid — sur le + // second slot, qui fait CTX jetons comme le premier, elle laisse intact le + // slot de la discussion. Sans second slot en service, aucun id_slot. + setEngineSlot(payload, engineSlotFor(ep, false)) ch, err := postSummary(ctx, ep, payload, perfTag{kind: perfCompact, conv: perfTagOf(ctx).conv}) if err != nil { return "", err @@ -882,7 +889,8 @@ func postSummary(ctx context.Context, ep chatEndpoint, payload map[string]any, t req.Header.Set("Content-Type", "application/json") ep.auth(req.Header.Set) if !ep.External { - defer engineRequestStart()() // voir llm_slots.go + end, _ := engineRequestBeginSide(nil, payloadOnSideSlot(payload)) // voir llm_slots.go + defer end() } resp, err := http.DefaultClient.Do(req) if err != nil { diff --git a/internal/loki/chat_compact_cont.go b/internal/loki/chat_compact_cont.go index 6ea32eb..50cbd65 100644 --- a/internal/loki/chat_compact_cont.go +++ b/internal/loki/chat_compact_cont.go @@ -441,6 +441,8 @@ func summarizeContinuation(ctx context.Context, msgs []Message, tailStart int, v payload["stream"] = false delete(payload, "stream_options") payload["max_tokens"] = compactSummaryBudget() + // SIDE_SLOT : elle prolonge le prompt du slot de la discussion, elle y va. + setEngineSlot(payload, engineSlotFor(ep, true)) ch, err := postSummary(ctx, ep, payload, perfTag{kind: perfCompact, conv: view.conv}) if err != nil { // Refus de la requête elle-même (gabarit à alternance stricte, champ diff --git a/internal/loki/chat_prewarm.go b/internal/loki/chat_prewarm.go index e154d2d..7ab0320 100644 --- a/internal/loki/chat_prewarm.go +++ b/internal/loki/chat_prewarm.go @@ -26,7 +26,9 @@ package loki // // Il ne passe jamais devant un vrai travail : // - moteur local seulement, un seul slot (/props total_slots = 1 : ni -// PARALLEL ni -np d'EXTRA_ARGS ne peuvent le contredire) ; +// PARALLEL ni -np d'EXTRA_ARGS ne peuvent le contredire) — ou les deux de +// SIDE_SLOT en service, et il vise alors le slot de la discussion +// (id_slot 0, llm_sideslot.go) ; // - jamais pendant un tour, une tâche, une compaction, un bench, un travail // annexe, ni s'il reste une requête de Loki en vol (compteur de // llm_slots.go, qu'il tient lui-même : l'isolation des slots n'efface rien @@ -379,13 +381,19 @@ func (c *Conversation) prewarm(why string) { if !healthCheck() { return } - if prewarmSlots(ep) != 1 { - return + // Un seul slot, ou les deux de SIDE_SLOT : le préchauffage vise alors le + // slot de la discussion (id_slot 0), celui où partira le vrai tour. + slot := -1 + if n := prewarmSlots(ep); n != 1 { + if slot = engineSlotFor(ep, true); n != 2 || slot != 0 { + return + } } payload, run, skip := c.prewarmBodyFor(ep, cfg) if skip != "" { return } + setEngineSlot(payload, slot) body, err := json.Marshal(payload) if err != nil { return diff --git a/internal/loki/llm_bench.go b/internal/loki/llm_bench.go index 763d438..0a5ee06 100644 --- a/internal/loki/llm_bench.go +++ b/internal/loki/llm_bench.go @@ -228,7 +228,14 @@ func (e benchEngine) do(ctx context.Context, method, path string, payload any) ( } // Requête en vol jusqu'à la lecture complète du corps : l'isolation des // travaux annexes n'efface jamais le slot pendant ce temps (llm_slots.go). - defer engineRequestStart()() + // Une requête du second slot (SIDE_SLOT) ne touche pas au slot de la + // discussion. + side := false + if m, ok := payload.(map[string]any); ok { + side = payloadOnSideSlot(m) + } + end, _ := engineRequestBeginSide(nil, side) + defer end() resp, err := e.client.Do(req) if err != nil { return nil, err @@ -422,6 +429,9 @@ type benchSetup struct { kv string kvQuant bool gpus string + // sideSlot : SIDE_SLOT en service, le bench passe par le second slot + // (id_slot 1) et laisse la conversation en place (llm_sideslot.go). + sideSlot bool } func benchSetupFrom(cfg map[string]string, argEnv map[string]string) benchSetup { @@ -492,6 +502,9 @@ func (s benchSetup) payload(msgs []Message, maxTokens int, cache bool) map[strin if s.kwargs != nil { p["chat_template_kwargs"] = s.kwargs } + if s.sideSlot { + setEngineSlot(p, 1) + } return p } @@ -825,6 +838,7 @@ func runBench(ctx context.Context, opts benchOpts, progress benchProgress) (*ben effortRemember(want, got) logEffortFallback(want, got) } + setup.sideSlot = engineSlotFor(resolveChatEndpoint(), false) == 1 eng := benchEngine{base: fmt.Sprintf("http://localhost:%d", port), auth: resolveChatEndpoint().auth, client: http.DefaultClient} // Le bench prend le slot de la conversation : une fois fini (erreur et // annulation comprises), on l'efface si c'est sans risque, pour que son état diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index c0deb24..aa11bf9 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -1312,6 +1312,8 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools compactedRetry := false // Réductions forcées (shrinkToFit) quand le compactage n'a pas suffi. shrinkRetries := 0 + // Rejeux à l'identique après un cache KV plein en plein calcul (SIDE_SLOT). + poolRetries := 0 // Repli d'intensité de raisonnement (llm_effort.go) : une seule tentative par // tour, comme les autres filets. effortRetried := false @@ -1430,6 +1432,9 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools tools: tools, disableTools: disableTools, toolChoiceNone: toolChoiceNone, effort: reasoningEffort, kwargs: reasoningKwargs, }) + // SIDE_SLOT (llm_sideslot.go) : le fil de la discussion sur le slot 0, + // le reste sur le 1. Sans second slot en service, aucun id_slot. + setEngineSlot(payload, engineSlotFor(ep, slotIsMain(ptag.kind))) body, _ := json.Marshal(payload) req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body)) if err != nil { @@ -1450,7 +1455,7 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools endReq := func() {} var reqSeq uint64 if !ep.External { - endReq, reqSeq = engineRequestBegin(prewarmKeeper(ptag.kind, sent, payload)) + endReq, reqSeq = engineRequestBeginSide(prewarmKeeper(ptag.kind, sent, payload), payloadOnSideSlot(payload)) } resp, err := http.DefaultClient.Do(req) if err != nil { @@ -1501,6 +1506,24 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools continue } } + // SIDE_SLOT : un cache KV à court de place en plein calcul n'est pas un + // prompt trop long (llm_sideslot.go). Rejouée telle quelle, sans + // compaction ni réduction — deux fois au plus, le temps que l'autre + // slot finisse, puis l'erreur telle quelle : la conversation tenait + // dans sa fenêtre, rien ne justifie d'en perdre un morceau. Sans + // second slot en service, ce filet ne joue pas. + if !ep.External && sideSlotPoolError(msg) && sideSlotLive(ep) { + if poolRetries < 2 { + poolRetries++ + logCtx("cache KV plein pendant le calcul (deux slots) : requête rejouée telle quelle, sans compaction") + if werr := llmNetBackoff(ctx, poolRetries-1); werr == nil { + continue + } + } + err := fmt.Errorf("llama-server a renvoyé %d : %s", resp.StatusCode, msg) + cb(StreamEvent{Err: err}) + return extra, err + } // Gabarit qui refuse le raisonnement renvoyé (gpt-oss : « Cannot pass // both content and thinking », message assistant jugé invalide…) : // rejoué UNE fois sans lui, AVANT la coupure des outils et la consigne diff --git a/internal/loki/llm_oai.go b/internal/loki/llm_oai.go index fb354d0..dd8f2e2 100644 --- a/internal/loki/llm_oai.go +++ b/internal/loki/llm_oai.go @@ -112,10 +112,19 @@ func oaiHandler() http.Handler { return } if strings.HasPrefix(p, "/v1") || p == "/health" || p == "/props" || p == "/metrics" || strings.HasPrefix(p, "/slots") { + // SIDE_SLOT : la complétion d'un client passe par le second slot, + // jamais par celui de la discussion (llm_sideslot.go). Sans second + // slot en service, le corps n'est pas lu. + side, status, msg := sideSlotProxyRewrite(r) + if status != 0 { + sendOAIError(w, status, msg, "invalid_request_error", "side_slot") + return + } // Requête en vol vers le moteur : l'isolation des travaux annexes // n'efface pas le slot pendant ce temps. Différé : ReverseProxy panique // (ErrAbortHandler) quand le client coupe en plein flux. - defer engineRequestStart()() + end, _ := engineRequestBeginSide(nil, side) + defer end() // Complétion d'un client externe : elle prend le slot sous le nez de // la conversation. Notée pour nommer la perte de cache qui suit. if r.Method == http.MethodPost && strings.HasPrefix(p, "/v1/") { diff --git a/internal/loki/llm_sideslot.go b/internal/loki/llm_sideslot.go new file mode 100644 index 0000000..a97ad79 --- /dev/null +++ b/internal/loki/llm_sideslot.go @@ -0,0 +1,175 @@ +package loki + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +// Routage des requêtes entre les deux slots de SIDE_SLOT (backend_serve_side.go). +// +// Sans id_slot, llama-server choisit le slot par similarité de prompt ou, à +// défaut, le moins récemment servi : un vérificateur peut alors tomber sur le +// slot 0 et écraser la conversation — exactement ce que la clé doit empêcher. +// Tant que le second slot est en service, CHAQUE requête de Loki porte donc son +// id_slot : 0 pour le fil de la discussion (tour, étapes d'outils, +// préchauffage, résumé en continuation, qui prolonge le prompt du slot 0), 1 +// pour tout le reste (vérification, sous-agent, tâche, bench, résumé sur +// transcription, clients /v1). +// +// « En service » se lit sur le moteur, pas dans la configuration : /props doit +// annoncer 2 slots d'au moins CTX jetons chacun, ce que seul un lancement +// SIDE_SLOT accepté produit. Un moteur relancé sans la clé, refusé ou pas encore +// prêt : aucun id_slot, la requête est celle d'avant. Et sur un seul slot, un +// id_slot explicite ferait même sauter le rechargement depuis le cache RAM +// (f_keep indéfini sur un slot vide) : il ne doit jamais y partir. + +// sideSlotProbeTTL : durée pendant laquelle la lecture de /props vaut. Un +// moteur relancé entre-temps sur un seul slot recevrait au pire un id_slot 1, +// que llama.cpp ramène au slot 0 : le comportement d'avant la clé. +const sideSlotProbeTTL = 10 * time.Second + +var sideSlotProbe struct { + mu sync.Mutex + key string + at time.Time + live bool +} + +// sideSlotProps lit /props du moteur local : nombre de slots et fenêtre d'un +// slot. Remplaçable par les tests. +var sideSlotProps = func(ep chatEndpoint) (slots, nCtx int, ok bool) { + var props struct { + TotalSlots int `json:"total_slots"` + Default struct { + NCtx int `json:"n_ctx"` + } `json:"default_generation_settings"` + } + iso := slotIsolator{base: fmt.Sprintf("http://localhost:%d", LLMPort()), auth: ep.auth, client: http.DefaultClient} + if err := iso.get(context.Background(), "/props", &props); err != nil { + return 0, 0, false + } + return props.TotalSlots, props.Default.NCtx, true +} + +// sideSlotLive : le second slot est-il en service sur le moteur local ? Sans la +// clé, ni lecture ni requête : faux tout de suite. +func sideSlotLive(ep chatEndpoint) bool { + if ep.External { + return false + } + cfg := ReadConfig() + if !sideSlotOn(cfg) || isExternalConfig(cfg) { + return false + } + win := ctxWindow() + key := fmt.Sprintf("%d|%d", LLMPort(), win) + sideSlotProbe.mu.Lock() + defer sideSlotProbe.mu.Unlock() + if sideSlotProbe.key == key && time.Since(sideSlotProbe.at) < sideSlotProbeTTL { + return sideSlotProbe.live + } + slots, nCtx, ok := sideSlotProps(ep) + if !ok { + // Moteur injoignable (relance en cours) : rien de retenu, la prochaine + // requête redemande. + return false + } + // llama.cpp arrondit la fenêtre d'un slot au multiple de 256 supérieur. + live := slots == 2 && nCtx >= win + sideSlotProbe.key, sideSlotProbe.at, sideSlotProbe.live = key, time.Now(), live + return live +} + +// engineSlotFor : l'id_slot d'une requête vers ep, -1 = aucun (la requête part +// telle qu'avant la clé). main : fil de la discussion. +func engineSlotFor(ep chatEndpoint, main bool) int { + if !sideSlotLive(ep) { + return -1 + } + if main { + return 0 + } + return 1 +} + +// slotIsMain : les complétions de cette nature restent sur le slot de la +// discussion. Toutes les autres (vérification, sous-agent, tâche, bench, +// résumé) passent par le second. +func slotIsMain(kind string) bool { + return kind == perfMain || kind == perfPrewarm +} + +// setEngineSlot pose id_slot dans payload ; slot < 0 : rien. +func setEngineSlot(payload map[string]any, slot int) { + if slot >= 0 { + payload["id_slot"] = slot + } +} + +// payloadOnSideSlot : la requête part-elle sur le second slot ? Elle ne touche +// alors pas au slot de la discussion (engineRequestBeginSide). +func payloadOnSideSlot(payload map[string]any) bool { + s, ok := payload["id_slot"].(int) + return ok && s == 1 +} + +// sideSlotProxyMaxBody : au-delà, un corps de client /v1 n'est pas réécrit mais +// refusé — le laisser passer sans id_slot, c'est risquer le slot 0. +const sideSlotProxyMaxBody = 128 << 20 + +// sideSlotProxyRewrite pose id_slot 1 dans le corps JSON d'une requête POST +// /v1/* d'un client externe, quel que soit l'id_slot qu'il demandait : seul +// Loki parle au slot de la discussion. Sans second slot en service, rien n'est +// lu ni touché (side=false). Un corps qui n'est pas un objet JSON passe tel +// quel : le moteur le refusera de lui-même. status != 0 : refuser la requête +// avec ce code et ce message. +func sideSlotProxyRewrite(r *http.Request) (side bool, status int, msg string) { + if r.Method != http.MethodPost || !strings.HasPrefix(r.URL.Path, "/v1/") || r.Body == nil { + return false, 0, "" + } + if !sideSlotLive(resolveChatEndpoint()) { + return false, 0, "" + } + raw, err := io.ReadAll(io.LimitReader(r.Body, sideSlotProxyMaxBody+1)) + r.Body.Close() + if err != nil { + return false, http.StatusBadRequest, "corps illisible : " + err.Error() + } + if len(raw) > sideSlotProxyMaxBody { + return false, http.StatusRequestEntityTooLarge, "corps trop gros pour être routé vers le slot des clients" + } + var obj map[string]json.RawMessage + if json.Unmarshal(raw, &obj) == nil && obj != nil { + obj["id_slot"] = json.RawMessage("1") + if b, err := json.Marshal(obj); err == nil { + raw, side = b, true + } + } + r.Body = io.NopCloser(bytes.NewReader(raw)) + r.ContentLength = int64(len(raw)) + r.Header.Set("Content-Length", strconv.Itoa(len(raw))) + return side, 0, "" +} + +// reqExceedsCtx : le libellé de llama.cpp pour un prompt plus long que la +// fenêtre d'un slot. +var reqExceedsCtx = regexp.MustCompile(`(?i)exceeds the available context size|request \(\d+ tokens\)`) + +// sideSlotPoolError : « Context size has been exceeded. » sans le nombre de +// jetons de la requête n'est pas un prompt trop long, c'est le cache KV qui a +// manqué de place pendant le calcul. Avec deux slots, ce serait la faute du +// voisin, pas de la conversation : la compacter ou la tronquer perdrait de +// l'information pour rien. Avec des flux séparés (--no-kv-unified) ça ne doit +// pas arriver ; le filet ne joue que si le second slot est en service. +func sideSlotPoolError(msg string) bool { + return strings.Contains(strings.ToLower(msg), "context size has been exceeded") && !reqExceedsCtx.MatchString(msg) +} diff --git a/internal/loki/llm_sideslot_test.go b/internal/loki/llm_sideslot_test.go new file mode 100644 index 0000000..89dfc09 --- /dev/null +++ b/internal/loki/llm_sideslot_test.go @@ -0,0 +1,230 @@ +package loki + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +// stubSideProps remplace la lecture de /props : slots, fenêtre d'un slot. +func stubSideProps(t *testing.T, slots, nCtx int) { + t.Helper() + prev := sideSlotProps + sideSlotProps = func(chatEndpoint) (int, int, bool) { return slots, nCtx, true } + reset := func() { + sideSlotProbe.mu.Lock() + sideSlotProbe.key, sideSlotProbe.live = "", false + sideSlotProbe.mu.Unlock() + } + reset() + t.Cleanup(func() { sideSlotProps = prev; reset() }) +} + +// slotOf : l'id_slot d'un corps de requête ; -1 = absent. +func slotOf(t *testing.T, body string) int { + t.Helper() + var p map[string]any + if err := json.Unmarshal([]byte(body), &p); err != nil { + t.Fatalf("corps illisible : %v", err) + } + v, ok := p["id_slot"] + if !ok { + return -1 + } + return int(v.(float64)) +} + +// Le tour, une vérification puis un résumé sur transcription : un corps par +// requête, dans l'ordre. +func sideSlotScenario(t *testing.T, m *moteurCont) []string { + t.Helper() + ctx := withPerf(context.Background(), perfMain, convActiveID()) + if _, err := runChat(ctx, []Message{um("bonjour")}, 0.2, Caps{}, func(StreamEvent) bool { return true }); err != nil { + t.Fatal(err) + } + if _, err := runChat(withPerfKind(ctx, perfVerify), []Message{um("vérifie")}, 0.2, Caps{}, func(StreamEvent) bool { return true }); err != nil { + t.Fatal(err) + } + if _, err := summarizeTranscriptFor(ctx, "user: a\nassistant: b", false); err != nil { + t.Fatal(err) + } + b := m.all() + if len(b) != 3 { + t.Fatalf("%d requêtes, attendu 3", len(b)) + } + return b +} + +// Sans la clé : aucun id_slot, et /props n'est même pas lu. +func TestSideSlotRoutageDefaut(t *testing.T) { + m := contSetup(t, nil) + prev := sideSlotProps + sideSlotProps = func(chatEndpoint) (int, int, bool) { t.Fatal("/props lu sans la clé"); return 0, 0, false } + t.Cleanup(func() { sideSlotProps = prev }) + for _, b := range sideSlotScenario(t, m) { + if strings.Contains(b, "id_slot") { + t.Fatalf("id_slot sans la clé : %s", b) + } + } +} + +// Avec deux slots en service : le tour sur le 0, la vérification et la +// transcription sur le 1 — et le slot 0 porte toujours le tour après elles. +func TestSideSlotRoutage(t *testing.T) { + m := contSetup(t, map[string]string{"SIDE_SLOT": "on", "COMPACT_CONTINUATION": "on"}) + stubSideProps(t, 2, 8192) + b := sideSlotScenario(t, m) + for i, want := range []int{0, 1, 1} { + if got := slotOf(t, b[i]); got != want { + t.Errorf("requête %d : id_slot %d, attendu %d", i, got, want) + } + } + if !engineSlotHolds(convActiveID()) { + t.Fatal("le slot 0 n'a pas bougé : le tampon du tour doit tenir après le second slot") + } +} + +// Un seul slot, une fenêtre trop petite (lancement refusé, PARALLEL=2 seul) ou +// un preset externe : aucun id_slot. +func TestSideSlotPasEnService(t *testing.T) { + testHome(t) + if err := SetConfigKey("SIDE_SLOT", "on"); err != nil { + t.Fatal(err) + } + if err := SetConfigKey("CTX", "8192"); err != nil { + t.Fatal(err) + } + for _, c := range []struct{ slots, nCtx int }{{1, 8192}, {2, 4096}, {4, 8192}} { + stubSideProps(t, c.slots, c.nCtx) + if s := engineSlotFor(chatEndpoint{}, false); s != -1 { + t.Errorf("%d slots de %d : id_slot %d", c.slots, c.nCtx, s) + } + } + stubSideProps(t, 2, 8448) // arrondi de llama.cpp au multiple de 256 + if engineSlotFor(chatEndpoint{}, true) != 0 || engineSlotFor(chatEndpoint{}, false) != 1 { + t.Fatal("deux slots de CTX jetons : routage attendu") + } + if s := engineSlotFor(chatEndpoint{External: true}, false); s != -1 { + t.Fatalf("preset externe : id_slot %d", s) + } +} + +func TestSideSlotPoolError(t *testing.T) { + for msg, want := range map[string]bool{ + `{"error":{"code":500,"message":"Context size has been exceeded.","type":"server_error"}}`: true, + `{"error":{"message":"request (40000 tokens) exceeds the available context size (32768 tokens), try increasing it","type":"exceed_context_size_error"}}`: false, + `the request exceeds the available context size`: false, + `failed to parse tool call`: false, + } { + if got := sideSlotPoolError(msg); got != want { + t.Errorf("%q : %v", msg, got) + } + } +} + +// Cache KV plein en plein calcul, deux slots en service : la requête est +// rejouée telle quelle, sans compaction ni réduction. +func TestSideSlotPoolErrorRejoue(t *testing.T) { + testHome(t) + var mu sync.Mutex + var bodies []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + mu.Lock() + bodies = append(bodies, string(b)) + n := len(bodies) + mu.Unlock() + if n == 1 { + sendJSON(w, 500, map[string]any{"error": map[string]any{"message": "Context size has been exceeded."}}) + return + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte(sseChunk("ok") + sseFinal("stop", 50, 1) + "data: [DONE]\n\n")) + })) + t.Cleanup(srv.Close) + _, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":") + for k, v := range map[string]string{"PORT": port, "SIDE_SLOT": "on", "CTX": "8192"} { + if err := SetConfigKey(k, v); err != nil { + t.Fatal(err) + } + } + stubSideProps(t, 2, 8192) + msgs := []Message{um(strings.Repeat("x", 30000))} // près de la fenêtre : l'estimation crierait au débordement + if _, err := runChat(withPerf(context.Background(), perfMain, "c"), msgs, 0.2, Caps{}, func(StreamEvent) bool { return true }); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + if len(bodies) != 2 || bodies[0] != bodies[1] { + t.Fatalf("%d requêtes ; rejeu identique attendu", len(bodies)) + } +} + +// Client /v1 : son corps part avec id_slot 1, quoi qu'il demande. Sans la clé, +// il passe octet pour octet. +func TestSideSlotProxyForceLeSecondSlot(t *testing.T) { + testHome(t) + var got []byte + fauxMoteur(t, func(w http.ResponseWriter, r *http.Request) { + got, _ = io.ReadAll(r.Body) + if r.ContentLength != int64(len(got)) { + t.Errorf("Content-Length %d pour %d octets", r.ContentLength, len(got)) + } + sendJSON(w, 200, map[string]any{"ok": true}) + }) + in := `{"model":"x","id_slot":0,"seed":12345678901234567890,"messages":[{"role":"user","content":"a"}]}` + call := func() { + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader([]byte(in))) + rec := httptest.NewRecorder() + oaiHandler().ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("statut %d : %s", rec.Code, rec.Body.String()) + } + } + call() + if string(got) != in { + t.Fatalf("corps réécrit sans la clé : %s", got) + } + if err := SetConfigKey("SIDE_SLOT", "on"); err != nil { + t.Fatal(err) + } + stubSideProps(t, 2, 32768) + call() + if slotOf(t, string(got)) != 1 || !strings.Contains(string(got), "12345678901234567890") { + t.Fatalf("corps routé : %s", got) + } +} + +// Deux slots de SIDE_SLOT : le préchauffage vise le slot de la discussion. +// Deux slots sans la clé : toujours rien. +func TestSideSlotPrewarmSurLeSlot0(t *testing.T) { + m := prewarmSetup(t, map[string]string{"PREWARM": "on", "SIDE_SLOT": "on", "CTX": "8192"}) + c := newTestConv() + prewarmNoteCaps(convActiveID(), Caps{Agent: true}) + c.mu.Lock() + c.Messages = []Message{um("bonjour"), am("salut")} + c.mu.Unlock() + m.mu.Lock() + m.slots = 2 + m.mu.Unlock() + stubSideProps(t, 2, 4096) // lancement refusé : fenêtre partagée + c.prewarm(prewarmTurnEnd) + if n := len(m.completions()); n != 0 { + t.Fatalf("%d préchauffage(s) sans second slot en service", n) + } + stubSideProps(t, 2, 8192) + c.prewarm(prewarmTurnEnd) + got := m.completions() + if len(got) != 1 { + t.Fatalf("%d préchauffage(s), attendu 1", len(got)) + } + if s := slotOf(t, got[0]); s != 0 { + t.Fatalf("id_slot = %d, attendu 0", s) + } +} diff --git a/internal/loki/llm_slots.go b/internal/loki/llm_slots.go index 4ff5b16..16f152c 100644 --- a/internal/loki/llm_slots.go +++ b/internal/loki/llm_slots.go @@ -47,6 +47,12 @@ import ( // repos d'après /slots ; // - appel synchrone, borné à 2 s, sous le verrou du compteur : aucune // requête de Loki ne peut partir entre la vérification et l'effacement. +// +// Avec SIDE_SLOT (deux slots, llm_sideslot.go), rien n'est jamais effacé : le +// garde-fou du slot unique s'abstient, et les travaux annexes ne passent de +// toute façon plus par le slot de la discussion. Pour la même raison, leurs +// requêtes n'avancent pas le numéro qui dit si le slot 0 porte encore le tour +// (engineSlotHolds) : COMPACT_CONTINUATION reste possible après eux. // slotEraseMinBuild : premier build de llama.cpp qui recharge un slot vide // depuis le cache de prompts (PR #20993, 50e0ad08fb). @@ -151,12 +157,22 @@ func engineRequestStartKeep(keep func(*prewarmRun) bool) func() { // engineRequestBegin : engineRequestStartKeep, avec le numéro de la requête // (engineMarkMain). func engineRequestBegin(keep func(*prewarmRun) bool) (func(), uint64) { + return engineRequestBeginSide(keep, false) +} + +// engineRequestBeginSide : engineRequestBegin pour une requête qui part, si +// side, sur le second slot de SIDE_SLOT (id_slot 1). Elle ne touche pas au slot +// de la discussion : le numéro n'avance pas, et engineSlotHolds dit toujours +// vrai après elle. Comptée en vol comme les autres. +func engineRequestBeginSide(keep func(*prewarmRun) bool, side bool) (func(), uint64) { engineGate.mu.Lock() if p := prewarmCur; p != nil && (keep == nil || !keep(p)) { prewarmDropLocked(p) } engineGate.inflight++ - engineGate.seq++ + if !side { + engineGate.seq++ + } seq := engineGate.seq engineGate.mu.Unlock() var once sync.Once diff --git a/internal/loki/relay_link.go b/internal/loki/relay_link.go index c967529..b30f8eb 100644 --- a/internal/loki/relay_link.go +++ b/internal/loki/relay_link.go @@ -525,7 +525,13 @@ func newLinkHandler(mux *http.ServeMux) http.Handler { http.Error(w, "/slots est en lecture seule via le relais", http.StatusMethodNotAllowed) return } - defer engineRequestStart()() // voir oaiHandler + side, status, msg := sideSlotProxyRewrite(r) // voir oaiHandler + if status != 0 { + http.Error(w, msg, status) + return + } + end, _ := engineRequestBeginSide(nil, side) // voir oaiHandler + defer end() lp.ServeHTTP(w, r) return } diff --git a/internal/loki/sys_service.go b/internal/loki/sys_service.go index cc4504e..aef1557 100644 --- a/internal/loki/sys_service.go +++ b/internal/loki/sys_service.go @@ -83,6 +83,11 @@ var configTemplate = []struct{ key, help string }{ {"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"}, + {"SIDE_SLOT", "on = second slot pour les travaux annexes : le moteur ouvre 2 slots de CTX jetons chacun (-c 2×CTX, " + + "--parallel 2, cache KV non unifié : aucun slot ne déborde sur l'autre), la discussion garde le slot 0, vérification, " + + "sous-agents, tâches, bench, résumé sur transcription et clients /v1 passent par le 1 (id_slot) — l'état de la " + + "discussion ne bouge plus. Cache KV et état récurrent en double en VRAM : refusé si le compte ne tient pas, si des " + + "poids sont sur CPU, ou si PARALLEL≠2, -c, -np, -kvu, --cache-idle-slots… sont réglés à la main. Vide/off (défaut) = un slot"}, {"CTX_CHECKPOINTS", "points de reprise par slot d'un modèle hybride (--ctx-checkpoints) ; vide = défaut du moteur (32) ; " + "chacun pèse 70 à 200 Mio de RAM hôte : 8 à 16 si le modèle remplit déjà la RAM"}, {"CKPT_MIN_STEP", "espacement minimal en jetons entre deux points de reprise (--checkpoint-min-step), > 0 ; " + @@ -109,7 +114,8 @@ var configTemplate = []struct{ key, help string }{ {"PREWARM", "on = préparer le prochain tour pendant que tu lis : après un tour (compaction comprise) ou une tâche, " + "Loki envoie au moteur local la requête suivante avec un message « . » et 1 jeton de réponse, jetés ; le vrai message " + "ne calcule plus que lui-même. Annulé dès qu'une autre requête part, sauf si elle prolonge exactement ce préfixe ; " + - "jamais avec plus d'un slot. full = aussi au changement de discussion (après 3 s). Vide/off (défaut) = aucune requête de plus"}, + "jamais avec plus d'un slot (sauf les deux de SIDE_SLOT : slot de la discussion). full = aussi au changement de discussion " + + "(après 3 s). Vide/off (défaut) = aucune requête de plus"}, {"KEEP_TURN_IMAGES", "zone grise — on = garder dans l'historique les images montrées par les outils (captures, see_image), " + "rangées par référence, au lieu de les oublier en fin de tour : le tour suivant ne recalcule plus la boucle d'outils " + "depuis la première capture. Coût mesuré par le moteur, total borné à 10 % de la fenêtre, retirées d'abord à toute " + diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 0cc8273..bd30687 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -5998,8 +5998,11 @@ async function refreshCacheRAM(){ 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); + // SIDE_SLOT : la VRAM du second slot, ou la raison du refus au lancement. + const side = r.side ? (r.side.on ? ' · SIDE_SLOT : +' + r.side.vramMiB + ' Mio de VRAM' + : ' · SIDE_SLOT refusé : ' + r.side.why) : ''; if(sub) sub.textContent = 'RAM hôte, copie exacte du contexte · jamais la VRAM' + - (r.why ? ' · ' + r.why : '') + (r.mib > 0 || fixed ? '' : ' · défaut du moteur'); + (r.why ? ' · ' + r.why : '') + (r.mib > 0 || fixed ? '' : ' · défaut du moteur') + side; } 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 59ef897..4464367 100644 --- a/internal/loki/ui/src/js/07-models.js +++ b/internal/loki/ui/src/js/07-models.js @@ -1030,8 +1030,11 @@ async function refreshCacheRAM(){ 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); + // SIDE_SLOT : la VRAM du second slot, ou la raison du refus au lancement. + const side = r.side ? (r.side.on ? ' · SIDE_SLOT : +' + r.side.vramMiB + ' Mio de VRAM' + : ' · SIDE_SLOT refusé : ' + r.side.why) : ''; if(sub) sub.textContent = 'RAM hôte, copie exacte du contexte · jamais la VRAM' + - (r.why ? ' · ' + r.why : '') + (r.mib > 0 || fixed ? '' : ' · défaut du moteur'); + (r.why ? ' · ' + r.why : '') + (r.mib > 0 || fixed ? '' : ' · défaut du moteur') + side; } function syncKVSub(){ const el = document.getElementById('s-kv-sub'); diff --git a/internal/loki/web_api.go b/internal/loki/web_api.go index 9b32099..7ff4ed6 100644 --- a/internal/loki/web_api.go +++ b/internal/loki/web_api.go @@ -495,7 +495,13 @@ func handlePresetCacheRAM(w http.ResponseWriter, r *http.Request) { return } mib, why := cacheRAMPreview(req.Content) - sendJSON(w, 200, map[string]any{"ok": true, "mib": mib, "default": engineCacheRAMDefault, "why": why}) + out := map[string]any{"ok": true, "mib": mib, "default": engineCacheRAMDefault, "why": why} + // Second slot (SIDE_SLOT) : la VRAM qu'il coûterait, ou pourquoi il sera + // refusé. Absent sans la clé. + if on, extra, swhy := sideSlotPreview(req.Content); on || swhy != "" { + out["side"] = map[string]any{"on": on, "vramMiB": extra, "why": swhy} + } + sendJSON(w, 200, out) } func handlePresetSave(w http.ResponseWriter, r *http.Request) {