Slots : SIDE_SLOT ouvre un second slot pour les travaux annexes, en opt-in

En --parallel 1, une vérification, un sous-agent, une tâche ou un bench
prennent le slot de la discussion : son état part dans le cache RAM et en
revient, ou se recalcule s'il n'y tient plus. Avec SIDE_SLOT=on (off par
défaut), le moteur ouvre deux slots et la discussion garde le sien.

Dimensionnement choisi pour qu'un débordement concurrent soit impossible par
construction : cache KV NON unifié (--no-kv-unified) et -c 2×CTX, soit deux
flux séparés de CTX jetons. Sous cache unifié, les deux slots partagent le
pool et, plein, llama.cpp renvoie « Context size has been exceeded. » aux
deux — que Loki prenait pour un débordement de la conversation. Chaque slot
garde la fenêtre entière : ctxWindow et la compaction restent sur CTX, rien
n'est raccourci. --cache-idle-slots ne vide les slots au repos que sous cache
unifié : rien à régler.

- lancement : refus (un slot, ligne d'avant, note au journal) si modèle + deux
  états dépassent 90 % de la VRAM, VRAM ou GGUF inconnus, poids sur CPU,
  moteur sans --no-kv-unified, PARALLEL≠2, ou -c/-np/-kvu/--no-kv-unified/
  --kv-unified-per-slot/--cache-idle-slots dans EXTRA_ARGS (ou leurs
  LLAMA_ARG_*) ; note de VRAM en plus, avertissement si NGL imposé
- routage id_slot seulement si /props annonce 2 slots d'au moins CTX jetons :
  0 pour le tour, ses étapes, le préchauffage et le résumé en continuation ;
  1 pour vérification, sous-agents, tâches, bench, résumé sur transcription
- clients /v1 (proxy et relais) : corps réécrit en id_slot 1, quel qu'il soit
- cohérence lot 1 : l'effacement de slot s'abstient (2 slots) ; une requête
  du slot 1 n'avance pas le numéro d'engineSlotHolds, la continuation de
  compaction reste possible après une vérification
- « Context size has been exceeded. » sans nombre de jetons, deux slots en
  service : requête rejouée telle quelle, jamais compaction ni réduction
- préchauffage permis sur les deux slots de SIDE_SLOT, vers le slot 0
- éditeur de preset : VRAM du second slot ou raison du refus
- tests : ligne identique sans la clé, chaque conflit refuse sans changer la
  ligne, routage par nature, aucun id_slot sur un slot / preset externe /
  fenêtre partagée, rejeu sans compaction, proxy forcé, préchauffage

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 10:02:40 +02:00
1 parent c1d3757f23
commit 7a57322a62
18 files changed
+925 -14

No files matched your search

+29 -1
View File
@@ -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 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 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 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 `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 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 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 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 d'outil peut moins bien le suivre. À comparer (tours qui concluent après le
rappel) avant de l'adopter. 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 - **Discussions multiples** : historique complet dans la barre latérale, titre
repris du premier message (renommable), suppression. **Chaque discussion a son repris du premier message (renommable), suppression. **Chaque discussion a son
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
+18 -2
View File
@@ -9,6 +9,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -344,6 +345,7 @@ func cmdServe(args []string) error {
probeFidelityEnv(&si) probeFidelityEnv(&si)
probeCacheRAM(cfg, extra, &si) probeCacheRAM(cfg, extra, &si)
probeCkptEnv(&si) probeCkptEnv(&si)
probeSideSlotEnv(cfg, &si)
spec := specMode(cfg) spec := specMode(cfg)
if spec == "auto" || spec == "mtp" { if spec == "auto" || spec == "mtp" {
probeSpec(cfg, &si) 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). // physiques au lieu de tous les threads logiques (voir threadArgs).
threads, threadNotes := threadArgs(cfg["THREADS"], cfg["THREADS_BATCH"], extra, si.CPU) threads, threadNotes := threadArgs(cfg["THREADS"], cfg["THREADS_BATCH"], extra, si.CPU)
notes = append(notes, threadNotes...) 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, args = []string{bin,
"-m", si.Model, "-m", si.Model,
"-c", get("CTX", "32768"), "-c", ctxArg,
} }
args = append(args, threads...) args = append(args, threads...)
args = append(args, 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 // 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 // veut vraiment servir plusieurs requêtes à la fois — ou --parallel dans
// EXTRA_ARGS, auquel cas on ne redouble pas le drapeau. // 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")) 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 : // Couches GPU. Quatre cas, dans cet ordre :
// //
// NGL=auto → rien du tout (le moteur décide seul) // NGL=auto → rien du tout (le moteur décide seul)
+211
View File
@@ -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")
}
+147
View File
@@ -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)
}
}
}
+9 -1
View File
@@ -72,6 +72,9 @@ func compactEnabled() bool {
// ctxWindow renvoie la fenêtre de contexte configurée (config.env CTX), 32768 // 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. // 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 { func ctxWindow() int {
if v := ReadConfig()["CTX"]; v != "" { if v := ReadConfig()["CTX"]; v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 { 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. // argument inconnu par un 400, et la compaction échouait à chaque fois.
delete(payload, "chat_template_kwargs") 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}) ch, err := postSummary(ctx, ep, payload, perfTag{kind: perfCompact, conv: perfTagOf(ctx).conv})
if err != nil { if err != nil {
return "", err 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") req.Header.Set("Content-Type", "application/json")
ep.auth(req.Header.Set) ep.auth(req.Header.Set)
if !ep.External { 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) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
+2
View File
@@ -441,6 +441,8 @@ func summarizeContinuation(ctx context.Context, msgs []Message, tailStart int, v
payload["stream"] = false payload["stream"] = false
delete(payload, "stream_options") delete(payload, "stream_options")
payload["max_tokens"] = compactSummaryBudget() 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}) ch, err := postSummary(ctx, ep, payload, perfTag{kind: perfCompact, conv: view.conv})
if err != nil { if err != nil {
// Refus de la requête elle-même (gabarit à alternance stricte, champ // Refus de la requête elle-même (gabarit à alternance stricte, champ
+10 -2
View File
@@ -26,7 +26,9 @@ package loki
// //
// Il ne passe jamais devant un vrai travail : // Il ne passe jamais devant un vrai travail :
// - moteur local seulement, un seul slot (/props total_slots = 1 : ni // - 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 // - 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 // 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 // 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() { if !healthCheck() {
return return
} }
if prewarmSlots(ep) != 1 { // 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 return
} }
}
payload, run, skip := c.prewarmBodyFor(ep, cfg) payload, run, skip := c.prewarmBodyFor(ep, cfg)
if skip != "" { if skip != "" {
return return
} }
setEngineSlot(payload, slot)
body, err := json.Marshal(payload) body, err := json.Marshal(payload)
if err != nil { if err != nil {
return return
+15 -1
View File
@@ -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 // 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). // 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) resp, err := e.client.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -422,6 +429,9 @@ type benchSetup struct {
kv string kv string
kvQuant bool kvQuant bool
gpus string 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 { 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 { if s.kwargs != nil {
p["chat_template_kwargs"] = s.kwargs p["chat_template_kwargs"] = s.kwargs
} }
if s.sideSlot {
setEngineSlot(p, 1)
}
return p return p
} }
@@ -825,6 +838,7 @@ func runBench(ctx context.Context, opts benchOpts, progress benchProgress) (*ben
effortRemember(want, got) effortRemember(want, got)
logEffortFallback(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} 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 // 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 // annulation comprises), on l'efface si c'est sans risque, pour que son état
+24 -1
View File
@@ -1312,6 +1312,8 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
compactedRetry := false compactedRetry := false
// Réductions forcées (shrinkToFit) quand le compactage n'a pas suffi. // Réductions forcées (shrinkToFit) quand le compactage n'a pas suffi.
shrinkRetries := 0 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 // Repli d'intensité de raisonnement (llm_effort.go) : une seule tentative par
// tour, comme les autres filets. // tour, comme les autres filets.
effortRetried := false effortRetried := false
@@ -1430,6 +1432,9 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
tools: tools, disableTools: disableTools, toolChoiceNone: toolChoiceNone, tools: tools, disableTools: disableTools, toolChoiceNone: toolChoiceNone,
effort: reasoningEffort, kwargs: reasoningKwargs, 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) body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body)) req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body))
if err != nil { if err != nil {
@@ -1450,7 +1455,7 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
endReq := func() {} endReq := func() {}
var reqSeq uint64 var reqSeq uint64
if !ep.External { 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) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
@@ -1501,6 +1506,24 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
continue 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 // Gabarit qui refuse le raisonnement renvoyé (gpt-oss : « Cannot pass
// both content and thinking », message assistant jugé invalide…) : // both content and thinking », message assistant jugé invalide…) :
// rejoué UNE fois sans lui, AVANT la coupure des outils et la consigne // rejoué UNE fois sans lui, AVANT la coupure des outils et la consigne
+10 -1
View File
@@ -112,10 +112,19 @@ func oaiHandler() http.Handler {
return return
} }
if strings.HasPrefix(p, "/v1") || p == "/health" || p == "/props" || p == "/metrics" || strings.HasPrefix(p, "/slots") { 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 // Requête en vol vers le moteur : l'isolation des travaux annexes
// n'efface pas le slot pendant ce temps. Différé : ReverseProxy panique // n'efface pas le slot pendant ce temps. Différé : ReverseProxy panique
// (ErrAbortHandler) quand le client coupe en plein flux. // (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 // 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. // la conversation. Notée pour nommer la perte de cache qui suit.
if r.Method == http.MethodPost && strings.HasPrefix(p, "/v1/") { if r.Method == http.MethodPost && strings.HasPrefix(p, "/v1/") {
+175
View File
@@ -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)
}
+230
View File
@@ -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)
}
}
+16
View File
@@ -47,6 +47,12 @@ import (
// repos d'après /slots ; // repos d'après /slots ;
// - appel synchrone, borné à 2 s, sous le verrou du compteur : aucune // - 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. // 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 // slotEraseMinBuild : premier build de llama.cpp qui recharge un slot vide
// depuis le cache de prompts (PR #20993, 50e0ad08fb). // 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 // engineRequestBegin : engineRequestStartKeep, avec le numéro de la requête
// (engineMarkMain). // (engineMarkMain).
func engineRequestBegin(keep func(*prewarmRun) bool) (func(), uint64) { 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() engineGate.mu.Lock()
if p := prewarmCur; p != nil && (keep == nil || !keep(p)) { if p := prewarmCur; p != nil && (keep == nil || !keep(p)) {
prewarmDropLocked(p) prewarmDropLocked(p)
} }
engineGate.inflight++ engineGate.inflight++
if !side {
engineGate.seq++ engineGate.seq++
}
seq := engineGate.seq seq := engineGate.seq
engineGate.mu.Unlock() engineGate.mu.Unlock()
var once sync.Once var once sync.Once
+7 -1
View File
@@ -525,7 +525,13 @@ func newLinkHandler(mux *http.ServeMux) http.Handler {
http.Error(w, "/slots est en lecture seule via le relais", http.StatusMethodNotAllowed) http.Error(w, "/slots est en lecture seule via le relais", http.StatusMethodNotAllowed)
return 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) lp.ServeHTTP(w, r)
return return
} }
+7 -1
View File
@@ -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 ; " + {"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é"}, "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"}, {"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) ; " + {"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"}, "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 ; " + {"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, " + {"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 " + "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 ; " + "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), " + {"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 " + "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 " + "depuis la première capture. Coût mesuré par le moteur, total borné à 10 % de la fenêtre, retirées d'abord à toute " +
+4 -1
View File
@@ -5998,8 +5998,11 @@ async function refreshCacheRAM(){
const fixed = /^fixé/.test(r.why || ''); const fixed = /^fixé/.test(r.why || '');
el.placeholder = fixed ? (r.mib < 0 ? 'sans limite' : r.mib === 0 ? 'coupé' : String(r.mib)) el.placeholder = fixed ? (r.mib < 0 ? 'sans limite' : r.mib === 0 ? 'coupé' : String(r.mib))
: 'auto · ' + (r.mib > 0 ? r.mib : r.default); : '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' + 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(){ function syncKVSub(){
const el = document.getElementById('s-kv-sub'); const el = document.getElementById('s-kv-sub');
+4 -1
View File
@@ -1030,8 +1030,11 @@ async function refreshCacheRAM(){
const fixed = /^fixé/.test(r.why || ''); const fixed = /^fixé/.test(r.why || '');
el.placeholder = fixed ? (r.mib < 0 ? 'sans limite' : r.mib === 0 ? 'coupé' : String(r.mib)) el.placeholder = fixed ? (r.mib < 0 ? 'sans limite' : r.mib === 0 ? 'coupé' : String(r.mib))
: 'auto · ' + (r.mib > 0 ? r.mib : r.default); : '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' + 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(){ function syncKVSub(){
const el = document.getElementById('s-kv-sub'); const el = document.getElementById('s-kv-sub');
+7 -1
View File
@@ -495,7 +495,13 @@ func handlePresetCacheRAM(w http.ResponseWriter, r *http.Request) {
return return
} }
mib, why := cacheRAMPreview(req.Content) 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) { func handlePresetSave(w http.ResponseWriter, r *http.Request) {