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
+927 -16

No files matched your search

+18 -2
View File
@@ -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)
+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
// 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 {
+2
View File
@@ -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
+11 -3
View File
@@ -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
+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
// 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
+24 -1
View File
@@ -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
+10 -1
View File
@@ -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/") {
+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)
}
}
+17 -1
View File
@@ -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
+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)
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
}
+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 ; " +
"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 " +
+4 -1
View File
@@ -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');
+4 -1
View File
@@ -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');
+7 -1
View File
@@ -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) {