mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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:
1 parent
c1d3757f23
commit
7a57322a62
18 files changed
+927
-16
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 " +
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user