Slots : SLOT_PERSIST garde l'état de la discussion à la bascule de preset, en opt-in

Passer de A à B puis revenir à A relance llama-server deux fois, et la
conversation de A est recalculée en entier au retour. Avec SLOT_PERSIST=on
(off par défaut), Loki demande au moteur d'écrire l'état du slot 0 juste
avant la bascule, et le recharge au retour avant le premier message de la
discussion — seulement si tout concorde, sinon calcul normal, en silence.

llama.cpp ne vérifie à la relecture que types et tailles : rien ne dit quel
build ni quels réglages ont calculé ce cache. La clé de validité est donc
l'empreinte de tout ce qui touche au calcul, prise par « loki serve » au
lancement : ligne de commande complète (modèle, CTX, types KV, NGL, lots,
gabarit, SPEC, EXTRA_ARGS…), LLAMA_ARG_*/GGML_*/CUDA_*, taille et date du
modèle et de ses tranches, du projecteur, du brouillon, du binaire et de
ses bibliothèques. Pas de cas « mise à jour du moteur » : elle change la clé.

- --slot-save-path posé aussi pour SLOT_PERSIST accepté, sous les mêmes
  conditions de sécurité que l'isolation du lot 1 (boucle locale ou clé
  d'API) ; refusé avec le décodage spéculatif (le save ne garde pas le
  brouillon ; /slots speculative revérifié à chaque fois), des poids sur
  CPU, un --slot-save-path à la main ; la ligne reste alors celle d'avant
- sauvegarde depuis l'interface ou une tâche, avant l'arrêt : le slot doit
  porter un tour de la discussion (tampon d'engineMarkMain), ≥ 4096 jetons,
  ≤ 8 Gio estimés, double de place libre ; nom provisoire, renommé
  seulement si aucune requête n'est partie pendant l'écriture
- rechargement avant la première requête de la discussion (ou son
  préchauffage) : même clé de moteur, même discussion, slot 0 vierge (/slots
  sans id_task) ; une seule tentative, fichier retiré ; toute erreur =
  calcul normal
- deux fichiers au plus ; clé retirée = tout effacé au lancement suivant ;
  SLOT_PERSIST préservé à la bascule comme HOST (sinon B effaçait l'état de A)
- README : SIDE_SLOT, le cache KV plein ne déclenche jamais de compaction
- tests : plan et refus, ligne inchangée, empreinte (clé d'API exclue,
  fichiers et réglages inclus), rotation, séquence save puis restore sur un
  faux moteur, refus à la sauvegarde et au rechargement, autre clé, défaut
  sans aucun appel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 10:17:08 +02:00
1 parent 7a57322a62
commit 28ba44eb46
12 files changed
+1133 -23

No files matched your search

+33
View File
@@ -498,6 +498,39 @@ Ajoutées par ce fork :
requêtes partent sans `id_slot`, comme avant. Ce que voit le modèle ne requêtes partent sans `id_slot`, comme avant. Ce que voit le modèle ne
change pas : deux slots qui calculent en même temps donnent des logits égaux change pas : deux slots qui calculent en même temps donnent des logits égaux
au bruit de virgule flottante près, comme un découpage de lots différent. au bruit de virgule flottante près, comme un découpage de lots différent.
Un « cache KV plein » sans nombre de jetons (le pool, pas le prompt) est
rejoué tel quel et ne déclenche jamais de compaction.
- **État du slot gardé à la bascule de preset** (clé `SLOT_PERSIST`, **off**
par défaut, `loki config set SLOT_PERSIST on`) : passer d'un preset A à B
puis revenir à A relance le moteur deux fois, et la conversation de A est
recalculée en entier au retour. Avec `on`, juste avant une bascule faite
depuis l'interface ou par une tâche, Loki demande au moteur d'écrire l'état
du slot de la discussion (`/slots/0?action=save`, sous `LOKI_HOME/slots`),
et le recharge au retour, avant le premier message de cette discussion.
Conditions strictes, sinon rien n'est écrit ou rechargé et le calcul est
normal : la dernière requête passée par le slot était bien un tour de
cette discussion (pas une vérification, une tâche, un préchauffage ou un
client `/v1`), au moins 4096 jetons, au plus 8 Gio estimés et le double
de place libre ; au retour, le moteur doit avoir **exactement** la même
empreinte — ligne de commande complète (modèle, `CTX`, types KV, `NGL`,
lots, gabarit, `EXTRA_ARGS`…), variables `LLAMA_ARG_*`/`GGML_*`/`CUDA_*`,
taille et date du modèle, du projecteur, du binaire et de ses bibliothèques
— et son slot 0 ne doit encore avoir servi aucune tâche. Une mise à jour du
moteur change l'empreinte : jamais de reprise d'un build à l'autre. Une
seule tentative par fichier, retiré ensuite ; deux fichiers au plus,
supprimés dès que la clé est retirée. Réglage de la machine : il survit aux
bascules comme `HOST` (un preset qui le pose l'emporte). Refusé au lancement (note au journal)
avec le décodage spéculatif (`SPEC`, brouillon dans `EXTRA_ARGS` : la
sauvegarde du moteur ne garde pas l'état du brouillon, et un brouillon MTP
détecté sur le slot bloque aussi), des poids sur CPU (MoE déporté), un
`--slot-save-path` déjà réglé, ou un moteur joignable par d'autres sans clé
d'API (`HOST` autre que `127.0.0.1`). `loki switch` en ligne de commande ne
garde rien (seul le process web sait ce que porte le slot). Le gain est
réel surtout sur un modèle dense ; sur un hybride (Qwen3.5/3.6), le fichier
n'a pas de points de reprise et ne sert que si le message suivant prolonge
exactement les jetons gardés (gabarit qui rend le dernier tour à
l'identique), sinon recalcul comme avant. Ce que voit le modèle ne change
pas : llama.cpp ne reprend un état que sur un préfixe de jetons identique.
- **Discussions multiples** : historique complet dans la barre latérale, titre - **Discussions multiples** : historique complet dans la barre latérale, titre
repris du premier message (renommable), suppression. **Chaque discussion a son repris du premier message (renommable), suppression. **Chaque discussion a son
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
+11 -2
View File
@@ -192,7 +192,10 @@ func safePresetPath(name string) (string, error) {
// CUDA_LAUNCH_QUEUES (files de lancement CUDA, voir launchQueuesEnv) suit la // CUDA_LAUNCH_QUEUES (files de lancement CUDA, voir launchQueuesEnv) suit la
// même logique : un « off » posé parce que CETTE machine bloque ne doit pas // même logique : un « off » posé parce que CETTE machine bloque ne doit pas
// disparaître à la bascule suivante. // disparaître à la bascule suivante.
var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST", "CUDA_LAUNCH_QUEUES"} // SLOT_PERSIST (état du slot gardé à la bascule, backend_serve_persist.go) est
// par nature un réglage de bascule : posé sur A seulement, il disparaîtrait en
// passant sur B, dont le lancement effacerait l'état gardé de A.
var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST", "CUDA_LAUNCH_QUEUES", "SLOT_PERSIST"}
// softPreservedKeys : préservées SEULEMENT si le preset d'arrivée ne les définit // softPreservedKeys : préservées SEULEMENT si le preset d'arrivée ne les définit
// pas lui-même. CUDA_VISIBLE_DEVICES est dans ce cas : c'est d'ordinaire un // pas lui-même. CUDA_VISIBLE_DEVICES est dans ce cas : c'est d'ordinaire un
@@ -203,7 +206,7 @@ var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VIS
// modèle se retrouvait entièrement sur une seule carte et mourait sur // modèle se retrouvait entièrement sur une seule carte et mourait sur
// « cudaMalloc failed: out of memory ». Le preset explicite gagne. // « cudaMalloc failed: out of memory ». Le preset explicite gagne.
// CUDA_LAUNCH_QUEUES aussi : un preset peut l'imposer pour son modèle. // CUDA_LAUNCH_QUEUES aussi : un preset peut l'imposer pour son modèle.
var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true, "CUDA_LAUNCH_QUEUES": true} var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true, "CUDA_LAUNCH_QUEUES": true, "SLOT_PERSIST": true}
// applyPresetFile installe le preset comme configuration active, en réinjectant // applyPresetFile installe le preset comme configuration active, en réinjectant
// les réglages « appareil » par-dessus. Séparé de SwitchToPreset pour être // les réglages « appareil » par-dessus. Séparé de SwitchToPreset pour être
@@ -264,6 +267,10 @@ func presetImposesEngine(p string) bool {
// SwitchToPreset installe le preset et redémarre le service. Les réglages // SwitchToPreset installe le preset et redémarre le service. Les réglages
// « appareil » (preservedKeys) sont conservés à travers la bascule. // « appareil » (preservedKeys) sont conservés à travers la bascule.
func SwitchToPreset(target string) error { func SwitchToPreset(target string) error {
// SLOT_PERSIST : relevé sur la configuration d'avant, gardé juste avant
// l'arrêt (llm_slotpersist.go). Sans la clé, ou hors du process web (rien
// n'y dit ce que porte le slot) : nil, rien ne se passe.
persist := slotPersistPrepare()
if err := applyPresetFile(target); err != nil { if err := applyPresetFile(target); err != nil {
return err return err
} }
@@ -277,9 +284,11 @@ func SwitchToPreset(target string) error {
if !serviceIsActive() { if !serviceIsActive() {
return nil return nil
} }
persist.save()
return serviceAction("stop") return serviceAction("stop")
} }
fmt.Println(dim("[info] redémarrage du service...")) fmt.Println(dim("[info] redémarrage du service..."))
persist.save()
return serviceAction("restart") return serviceAction("restart")
} }
+20 -1
View File
@@ -362,12 +362,24 @@ func cmdServe(args []string) error {
if spec == "auto" { if spec == "auto" {
si.SpecAutoBlocked, specRecord = specAutoPeek(specMark) si.SpecAutoBlocked, specRecord = specAutoPeek(specMark)
} }
probeSlotPersistEnv(cfg, &si)
if strings.Contains(si.Help, "--slot-save-path") && !hasAnyFlag(extra, "--slot-save-path") { if strings.Contains(si.Help, "--slot-save-path") && !hasAnyFlag(extra, "--slot-save-path") {
si.SlotDir = prepareSlotDir(LokiHome()) si.SlotDir = prepareSlotDir(LokiHome(), slotPersistOn(cfg))
} }
llmArgs, env, notes := buildServeArgs(cfg, extra, bin, si) llmArgs, env, notes := buildServeArgs(cfg, extra, bin, si)
applyServeEnv(env) applyServeEnv(env)
// SLOT_PERSIST (backend_serve_persist.go) : la clé de CE lancement, et les
// états gardés au-delà de deux retirés. Refusé pour ce preset (brouillon,
// poids sur CPU…) : ceux des autres presets restent, pour leur retour.
// Clé retirée : plus aucun état gardé sur le disque.
persistKey := ""
if ok, _ := slotPersistPlan(cfg, extra, si); ok {
persistKey = slotPersistKey(llmArgs, env, si, bin)
slotPersistPrune(si.SlotDir)
} else if !slotPersistOn(cfg) {
slotPersistPurge(filepath.Join(LokiHome(), "slots"))
}
for _, n := range notes { for _, n := range notes {
fmt.Fprintln(os.Stderr, "[loki serve] "+n) fmt.Fprintln(os.Stderr, "[loki serve] "+n)
} }
@@ -395,6 +407,10 @@ func cmdServe(args []string) error {
specAutoSettle(specMark, si.SpecAutoBlocked, specRecord, auto) specAutoSettle(specMark, si.SpecAutoBlocked, specRecord, auto)
} }
// Posée au dernier moment, port libre : l'ancien moteur est parti, la clé
// lue par le process web est celle du moteur qui répondra.
writeSlotPersistMarker(LokiHome(), persistKey)
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n", fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
bin, filepath.Base(model), port) bin, filepath.Base(model), port)
@@ -644,6 +660,9 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS
cram, cramNotes := cacheRAMArgs(cfg, extra, si) cram, cramNotes := cacheRAMArgs(cfg, extra, si)
args = append(append(args, cram...), slotSaveArgs(cfg, extra, si)...) args = append(append(args, cram...), slotSaveArgs(cfg, extra, si)...)
notes = append(notes, cramNotes...) notes = append(notes, cramNotes...)
if n := slotPersistNote(slotPersistPlan(cfg, extra, si)); n != "" {
notes = append(notes, n)
}
// Points de reprise des hybrides (voir backend_serve_ckpt.go). // Points de reprise des hybrides (voir backend_serve_ckpt.go).
ck, ckNotes := ckptArgs(cfg, extra, si) ck, ckNotes := ckptArgs(cfg, extra, si)
args = append(args, ck...) args = append(args, ck...)
+15 -16
View File
@@ -450,30 +450,29 @@ func loopbackHost(h string) bool {
// restore, qui écrivent des Gio sur le disque, à quiconque joint le moteur. On // restore, qui écrivent des Gio sur le disque, à quiconque joint le moteur. On
// ne le pose donc que si le moteur est fermé aux autres (boucle locale, ou clé // ne le pose donc que si le moteur est fermé aux autres (boucle locale, ou clé
// d'API exigée), que le dossier existe (le moteur refuse de démarrer sinon), et // d'API exigée), que le dossier existe (le moteur refuse de démarrer sinon), et
// que l'isolation servira : cache actif, CACHE_ISOLATE pas à off. Les proxys de // qu'il servira : à l'isolation (cache actif, CACHE_ISOLATE pas à off), ou à
// Loki refusent de leur côté tout /slots qui n'est pas une lecture. // SLOT_PERSIST accepté (backend_serve_persist.go). Les proxys de Loki refusent
// de leur côté tout /slots qui n'est pas une lecture.
func slotSaveArgs(cfg map[string]string, extra []string, si serveSysInfo) []string { func slotSaveArgs(cfg map[string]string, extra []string, si serveSysInfo) []string {
if si.SlotDir == "" || !strings.Contains(si.Help, "--slot-save-path") || if si.SlotDir == "" || !strings.Contains(si.Help, "--slot-save-path") || hasAnyFlag(extra, "--slot-save-path") {
hasAnyFlag(extra, "--slot-save-path") || !cacheIsolationOn(cfg) || cacheRAMOff(cfg, extra, si.ArgEnv) {
return nil return nil
} }
host := flagValue(extra, "--host") persist, _ := slotPersistPlan(cfg, extra, si)
if host == "" { if !persist && (!cacheIsolationOn(cfg) || cacheRAMOff(cfg, extra, si.ArgEnv)) {
host = cfg["HOST"] return nil
} }
if host == "" { if !slotSaveSafe(cfg, extra, si) {
host = "0.0.0.0"
}
if !loopbackHost(host) && si.APIKey == "" {
return nil return nil
} }
return []string{"--slot-save-path", si.SlotDir} return []string{"--slot-save-path", si.SlotDir}
} }
// prepareSlotDir crée LOKI_HOME/slots (0700) et le vide : Loki n'y sauve jamais // prepareSlotDir crée LOKI_HOME/slots (0700) et le vide : un fichier présent ne
// rien, un fichier présent ne peut venir que d'un appel qui n'aurait pas dû // peut venir que d'un appel qui n'aurait pas dû passer. Seule exception, avec
// passer. Vide = dossier indisponible, le drapeau ne sera pas posé. // keepPersist (SLOT_PERSIST demandé) : les états que Loki y a lui-même gardés
func prepareSlotDir(home string) string { // à la bascule de preset, au nom reconnaissable. Vide = dossier indisponible,
// le drapeau ne sera pas posé.
func prepareSlotDir(home string, keepPersist bool) string {
dir := filepath.Join(home, "slots") dir := filepath.Join(home, "slots")
if err := os.MkdirAll(dir, 0o700); err != nil { if err := os.MkdirAll(dir, 0o700); err != nil {
return "" return ""
@@ -483,7 +482,7 @@ func prepareSlotDir(home string) string {
} }
if entries, err := os.ReadDir(dir); err == nil { if entries, err := os.ReadDir(dir); err == nil {
for _, e := range entries { for _, e := range entries {
if e.Type().IsRegular() { if e.Type().IsRegular() && !(keepPersist && slotPersistFileRe.MatchString(e.Name())) {
_ = os.Remove(filepath.Join(dir, e.Name())) _ = os.Remove(filepath.Join(dir, e.Name()))
} }
} }
+3 -3
View File
@@ -238,7 +238,7 @@ func TestBuildServeArgsCacheRAM(t *testing.T) {
func TestPrepareSlotDir(t *testing.T) { func TestPrepareSlotDir(t *testing.T) {
home := t.TempDir() home := t.TempDir()
dir := prepareSlotDir(home) dir := prepareSlotDir(home, false)
if dir != filepath.Join(home, "slots") { if dir != filepath.Join(home, "slots") {
t.Fatalf("dossier = %q", dir) t.Fatalf("dossier = %q", dir)
} }
@@ -246,7 +246,7 @@ func TestPrepareSlotDir(t *testing.T) {
if err := os.WriteFile(stray, []byte("x"), 0o600); err != nil { if err := os.WriteFile(stray, []byte("x"), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if prepareSlotDir(home) == "" { if prepareSlotDir(home, false) == "" {
t.Fatal("second appel") t.Fatal("second appel")
} }
if _, err := os.Stat(stray); !os.IsNotExist(err) { if _, err := os.Stat(stray); !os.IsNotExist(err) {
@@ -258,7 +258,7 @@ func TestPrepareSlotDir(t *testing.T) {
if err := os.WriteFile(filepath.Join(bad, "slots"), nil, 0o600); err != nil { if err := os.WriteFile(filepath.Join(bad, "slots"), nil, 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := prepareSlotDir(bad); got != "" { if got := prepareSlotDir(bad, false); got != "" {
t.Fatalf("slots est un fichier : %q", got) t.Fatalf("slots est un fichier : %q", got)
} }
} }
+265
View File
@@ -0,0 +1,265 @@
package loki
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// État du slot gardé à travers une bascule de preset (SLOT_PERSIST, off par
// défaut).
//
// Basculer d'un preset A vers B puis revenir à A relance llama-server deux
// fois : au retour, la conversation de A est recalculée en entier — 30 à 80 s
// sur un 27B dense. Avec SLOT_PERSIST=on, Loki demande au moteur, juste avant
// la bascule, d'écrire l'état du slot 0 sur disque (/slots/0?action=save), et
// le recharge au retour, avant la première requête de la discussion
// (llm_slotpersist.go). llama-server ne reprend un état que sur un préfixe de
// jetons identique : au pire, rien n'est repris.
//
// Mais rien, dans le fichier, ne dit quel moteur ni quels réglages ont calculé
// ce cache : llama.cpp ne vérifie que les types et les tailles. Le recharger
// sous un autre build, une autre fenêtre ou un autre placement donnerait au
// modèle un contexte que ce moteur n'aurait jamais produit. D'où la clé : une
// empreinte de TOUT ce qui touche au calcul — ligne de commande complète
// (modèle, fenêtre, types KV, NGL, lots, gabarit, EXTRA_ARGS…), variables
// LLAMA_ARG_*, GGML_* et CUDA_*, identité (taille, date) du modèle et de ses
// tranches, du projecteur, du brouillon, du binaire et de ses bibliothèques.
// Un fichier ne se recharge que sous la même clé ; une mise à jour du moteur
// change donc tout, et n'est jamais un cas de reprise.
//
// Refusé d'emblée : le décodage spéculatif (la sauvegarde de llama.cpp ne
// garde que le modèle cible, pas le brouillon MTP — l'état rechargé serait
// incohérent pour lui), les poids sur CPU (MoE déporté : états de plusieurs
// Gio pour un recalcul de toute façon lent), et un moteur joignable par
// d'autres sans clé d'API : save et restore écrivent des Gio sur le disque.
// slotPersistOn : la clé SLOT_PERSIST est-elle posée ?
func slotPersistOn(cfg map[string]string) bool {
switch strings.ToLower(strings.TrimSpace(cfg["SLOT_PERSIST"])) {
case "on", "1", "true", "yes", "oui":
return true
}
return false
}
// slotPersistArgEnv : variables qui contredisent SLOT_PERSIST sans drapeau.
var slotPersistArgEnv = []string{"LLAMA_ARG_SLOT_SAVE_PATH", "LLAMA_ARG_SPEC_TYPE", "LLAMA_ARG_SPEC_DRAFT_MODEL",
"LLAMA_ARG_SPEC_DRAFT_HF_REPO"}
// probeSlotPersistEnv complète ArgEnv, seulement si la clé est posée.
func probeSlotPersistEnv(cfg map[string]string, si *serveSysInfo) {
if !slotPersistOn(cfg) {
return
}
if si.ArgEnv == nil {
si.ArgEnv = map[string]string{}
}
for _, k := range slotPersistArgEnv {
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
si.ArgEnv[k] = v
}
}
}
// slotPersistPlan : SLOT_PERSIST vaut-il pour ce lancement ? why dit pourquoi
// non (vide = clé absente).
func slotPersistPlan(cfg map[string]string, extra []string, si serveSysInfo) (ok bool, why string) {
if !slotPersistOn(cfg) {
return false, ""
}
switch {
case isExternalConfig(cfg):
return false, "preset externe"
case !strings.Contains(si.Help, "--slot-save-path"):
return false, "ce moteur ne connaît pas --slot-save-path"
case hasAnyFlag(extra, "--slot-save-path") || si.ArgEnv["LLAMA_ARG_SLOT_SAVE_PATH"] != "":
return false, "--slot-save-path est déjà réglé à la main"
case si.SlotDir == "":
return false, "dossier des slots indisponible"
case specMode(cfg) != "off" || specUserSet(extra, si.ArgEnv) || si.ArgEnv["LLAMA_ARG_SPEC_TYPE"] != "":
return false, "décodage spéculatif : la sauvegarde du moteur ne garde pas l'état du brouillon"
}
if !slotSaveSafe(cfg, extra, si) {
return false, "moteur joignable par d'autres sans clé d'API (HOST=127.0.0.1 ou une clé d'API)"
}
blocks := 0
if si.GGUF != nil {
blocks = si.GGUF.BlockCount
}
if r := cpuWeights(cfg, extra, si.ArgEnv, blocks); r != "" {
return false, r
}
return true, ""
}
// slotSaveSafe : le moteur n'est joignable que par la machine, ou exige une
// clé d'API. Sans ça, --slot-save-path donnerait à n'importe qui le droit
// d'écrire des Gio sur le disque.
func slotSaveSafe(cfg map[string]string, extra []string, si serveSysInfo) bool {
host := flagValue(extra, "--host")
if host == "" {
host = cfg["HOST"]
}
if host == "" {
host = "0.0.0.0"
}
return loopbackHost(host) || si.APIKey != ""
}
// slotPersistNote : ce que le lancement dit de SLOT_PERSIST.
func slotPersistNote(ok bool, why string) string {
if ok || why == "" {
return ""
}
return "SLOT_PERSIST refusé (" + why + ") : l'état du slot ne sera pas gardé à la bascule de preset"
}
// slotPersistFileRe : les seuls fichiers que Loki écrit dans le dossier des
// slots — clé du moteur, empreinte de la discussion.
var slotPersistFileRe = regexp.MustCompile(`^persist-[0-9a-f]{16}-[0-9a-f]{12}\.bin$`)
// slotPersistMaxFiles : au plus deux états gardés (A→B→A, puis B→A→B).
const slotPersistMaxFiles = 2
// slotPersistKey : l'empreinte de tout ce qui décide du calcul du cache.
// args : la ligne de commande finale (la clé d'API n'y entre pas) ; env : ce
// que buildServeArgs pose. Les fichiers sont pris par taille et date : un
// modèle retéléchargé, un moteur mis à jour changent la clé.
func slotPersistKey(args []string, env map[string]string, si serveSysInfo, bin string) string {
h := sha256.New()
w := func(s string) { h.Write([]byte(s)); h.Write([]byte{0}) }
for i := 0; i < len(args); i++ {
if args[i] == "--api-key" {
i++
continue
}
w(args[i])
}
vars := map[string]string{}
for _, kv := range os.Environ() {
k, v, _ := strings.Cut(kv, "=")
if strings.HasPrefix(k, "LLAMA_ARG_") || strings.HasPrefix(k, "GGML_") || strings.HasPrefix(k, "CUDA_") {
vars[k] = v
}
}
for k, v := range env {
vars[k] = v
}
delete(vars, "LLAMA_ARG_API_KEY")
keys := make([]string, 0, len(vars))
for k := range vars {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
w(k + "=" + vars[k])
}
stat := func(p string) {
if p == "" {
return
}
if st, err := os.Stat(p); err == nil {
w(fmt.Sprintf("%s|%d|%d", p, st.Size(), st.ModTime().UnixNano()))
} else {
w(p + "|?")
}
}
if si.Model != "" {
for _, n := range shardFamily(filepath.Base(si.Model)) {
stat(filepath.Join(filepath.Dir(si.Model), n))
}
stat(si.Model)
}
stat(si.MMProj)
stat(si.Draft)
stat(bin)
// Bibliothèques du moteur (libllama, ggml-cuda…) : une mise à jour peut
// les changer sans toucher au binaire.
if entries, err := os.ReadDir(filepath.Dir(bin)); err == nil {
for _, e := range entries {
n := strings.ToLower(e.Name())
if strings.HasSuffix(n, ".so") || strings.Contains(n, ".so.") || strings.HasSuffix(n, ".dll") || strings.HasSuffix(n, ".dylib") {
stat(filepath.Join(filepath.Dir(bin), e.Name()))
}
}
}
w(fmt.Sprintf("build=%d", si.EngineBuild))
return hex.EncodeToString(h.Sum(nil))[:16]
}
// slotPersistMarker : la clé du moteur lancé, écrite par « loki serve » juste
// avant de lui céder la place, lue par le process web. Hors du dossier des
// slots : le moteur ne peut pas l'écraser par un save.
func slotPersistMarker(home string) string { return filepath.Join(home, "slots-engine.key") }
// writeSlotPersistMarker pose (key non vide) ou retire la clé du moteur.
func writeSlotPersistMarker(home, key string) {
p := slotPersistMarker(home)
if key == "" {
_ = os.Remove(p)
return
}
_ = os.WriteFile(p, []byte(key+"\n"), 0o600)
}
// readSlotPersistMarker : la clé du moteur en service ; vide = SLOT_PERSIST
// refusé ou absent à son lancement.
func readSlotPersistMarker(home string) string {
b, err := os.ReadFile(slotPersistMarker(home))
if err != nil {
return ""
}
k := strings.TrimSpace(string(b))
if len(k) != 16 {
return ""
}
if _, err := hex.DecodeString(k); err != nil {
return ""
}
return k
}
// slotPersistPrune garde les slotPersistMaxFiles états les plus récents.
func slotPersistPrune(dir string) {
type f struct {
name string
at int64
}
var fs []f
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if !e.Type().IsRegular() || !slotPersistFileRe.MatchString(e.Name()) {
continue
}
if info, err := e.Info(); err == nil {
fs = append(fs, f{e.Name(), info.ModTime().UnixNano()})
}
}
sort.Slice(fs, func(i, j int) bool { return fs[i].at > fs[j].at })
for i := slotPersistMaxFiles; i < len(fs); i++ {
_ = os.Remove(filepath.Join(dir, fs[i].name))
}
}
// slotPersistPurge retire tous les états gardés : la clé n'est plus là, ils
// n'ont plus rien à faire sur le disque.
func slotPersistPurge(dir string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if e.Type().IsRegular() && slotPersistFileRe.MatchString(e.Name()) {
_ = os.Remove(filepath.Join(dir, e.Name()))
}
}
}
+3
View File
@@ -398,6 +398,9 @@ func (c *Conversation) prewarm(why string) {
if err != nil { if err != nil {
return return
} }
// SLOT_PERSIST : l'état gardé de la discussion d'abord, s'il y en a un — le
// préchauffage le prolonge au lieu de tout recalculer.
slotPersistRestore(ep, convActiveID())
ctx, cancel := context.WithTimeout(context.Background(), prewarmTimeout) ctx, cancel := context.WithTimeout(context.Background(), prewarmTimeout)
defer cancel() defer cancel()
run.cancel, run.done = cancel, make(chan struct{}) run.cancel, run.done = cancel, make(chan struct{})
+9 -1
View File
@@ -1454,6 +1454,12 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
// s'il prépare exactement le début de CETTE requête d'un tour de chat. // s'il prépare exactement le début de CETTE requête d'un tour de chat.
endReq := func() {} endReq := func() {}
var reqSeq uint64 var reqSeq uint64
// SLOT_PERSIST (llm_slotpersist.go) : l'état gardé à la dernière bascule
// de preset est rechargé avant la première requête de la discussion,
// si tout concorde. Sans la clé, rien.
if !ep.External && ptag.kind == perfMain {
slotPersistRestore(ep, ptag.conv)
}
if !ep.External { if !ep.External {
endReq, reqSeq = engineRequestBeginSide(prewarmKeeper(ptag.kind, sent, payload), payloadOnSideSlot(payload)) endReq, reqSeq = engineRequestBeginSide(prewarmKeeper(ptag.kind, sent, payload), payloadOnSideSlot(payload))
} }
@@ -1645,7 +1651,9 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
engineServed() // le slot porte désormais cette requête (llm_slots.go) engineServed() // le slot porte désormais cette requête (llm_slots.go)
// Étape d'un tour de discussion : une compaction en continuation // Étape d'un tour de discussion : une compaction en continuation
// pourra la prolonger (chat_compact_cont.go). Sans la clé, rien. // pourra la prolonger (chat_compact_cont.go). Sans la clé, rien.
if ptag.kind == perfMain && compactContinuationOn(chatCfg) { // SLOT_PERSIST s'en sert aussi : seul un slot qui porte un tour de
// discussion est gardé à la bascule de preset.
if ptag.kind == perfMain && (compactContinuationOn(chatCfg) || slotPersistOn(chatCfg)) {
engineMarkMain(reqSeq, ptag.conv) engineMarkMain(reqSeq, ptag.conv)
} }
} }
+322
View File
@@ -0,0 +1,322 @@
package loki
import (
"bytes"
"context"
"encoding/json"
"fmt"
"hash/fnv"
"io"
"math"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// SLOT_PERSIST, côté process web : garder l'état du slot de la discussion
// avant une bascule de preset, le recharger au retour (backend_serve_persist.go
// pour la clé et les refus au lancement).
//
// Sauver : seulement si la dernière requête de Loki passée par le slot 0 était
// une étape d'un tour de la discussion (le tampon d'engineMarkMain) — un
// vérificateur, une tâche, un titre ou un client /v1 passés depuis, et le slot
// ne porte plus ce qu'on croit. Ni brouillon spéculatif actif, ni état trop
// gros (plafond fixe, place libre vérifiée), ni contexte trop court pour valoir
// une écriture. Le fichier est écrit sous un nom provisoire et ne prend son
// nom définitif que si aucune requête n'est partie pendant l'écriture.
//
// Recharger : avant la première requête de la discussion sur le moteur relancé,
// et seulement si le fichier porte la clé de CE moteur (même modèle, même
// build, mêmes réglages — tout ce qui touche au cache) et l'empreinte de CETTE
// discussion, que le slot 0 n'a encore servi aucune tâche (/slots sans
// id_task) et qu'aucun brouillon spéculatif n'y tourne. Une seule chance : le
// fichier est retiré après la tentative, réussie ou non. Toute erreur retombe
// en silence sur un calcul normal — llama.cpp vide lui-même le slot sur un
// rechargement raté.
//
// Sur un modèle hybride (Qwen3.5/3.6), le fichier n'a pas de points de reprise
// : il ne sert que si la requête suivante prolonge exactement les jetons
// gardés, ce qui suppose un gabarit qui rend le dernier tour à l'identique.
// slotPersistMaxBytes : au-delà, l'état n'est pas gardé. Un 27B dense en f16 à
// 32k jetons pèse 8 Gio ; plus gros, l'écriture et la relecture coûtent autant
// que le recalcul sur un disque lent.
const slotPersistMaxBytes = 8 << 30
// slotPersistMinTokens : en dessous, le recalcul est trop court pour valoir une
// écriture.
const slotPersistMinTokens = 4096
// Bornes des appels au moteur : écrire ou relire 8 Gio sur un disque lent.
var (
slotPersistSaveTimeout = 3 * time.Minute
slotPersistRestoreTimeout = 2 * time.Minute
)
// slotPersistName : le fichier d'une discussion sous une clé de moteur.
func slotPersistName(key, conv string) string {
h := fnv.New64a()
h.Write([]byte(conv))
return fmt.Sprintf("persist-%s-%012x.bin", key, h.Sum64()&0xffffffffffff)
}
func slotPersistDir() string { return filepath.Join(LokiHome(), "slots") }
func slotPersistLog(format string, args ...any) {
fmt.Fprintf(os.Stderr, "[slot] "+format+"\n", args...)
}
// engineMainConv : la discussion dont le slot porte plausiblement le tour
// (engineSlotHolds), et le numéro de cette requête ; vide = aucune.
func engineMainConv() (conv string, seq uint64) {
model, window := engineMainNow()
engineGate.mu.Lock()
defer engineGate.mu.Unlock()
m := engineGate.main
if m.seq == 0 || m.seq != engineGate.seq || m.model != model || m.window != window {
return "", 0
}
return m.conv, m.seq
}
// slotPersistSnap : ce qu'une bascule doit savoir du moteur EN SERVICE, relevé
// avant que la configuration ne change (le modèle et la fenêtre du tampon se
// lisent dans la configuration).
type slotPersistSnap struct {
conv, key string
seq uint64
cfg map[string]string
iso slotIsolator // port et clé d'API du moteur en service
}
// slotPersistPrepare : nil = rien à garder (clé absente, moteur sans clé de
// persistance, slot qui ne porte pas un tour de discussion).
func slotPersistPrepare() *slotPersistSnap {
cfg := ReadConfig()
if !slotPersistOn(cfg) || isExternalConfig(cfg) {
return nil
}
key := readSlotPersistMarker(LokiHome())
if key == "" {
return nil
}
conv, seq := engineMainConv()
if conv == "" {
return nil
}
return &slotPersistSnap{conv: conv, key: key, seq: seq, cfg: cfg, iso: localSlotIsolator(resolveChatEndpoint())}
}
// slotState : ce que /slots dit du slot 0.
type slotState struct {
found, processing, speculative, used bool
tokens int
}
func (s slotIsolator) slot0(ctx context.Context) (slotState, error) {
var slots []map[string]json.RawMessage
if err := s.get(ctx, "/slots", &slots); err != nil {
return slotState{}, err
}
for _, sl := range slots {
var id int
if json.Unmarshal(sl["id"], &id) != nil || id != 0 {
continue
}
st := slotState{found: true}
_ = json.Unmarshal(sl["is_processing"], &st.processing)
_ = json.Unmarshal(sl["speculative"], &st.speculative)
_ = json.Unmarshal(sl["n_prompt_tokens"], &st.tokens)
_, st.used = sl["id_task"]
return st, nil
}
return slotState{}, nil
}
// action : POST /slots/0?action=…, borné par timeout. Rend le nombre d'octets
// écrits ou lus d'après la réponse (0 = non dit).
func (s slotIsolator) action(ctx context.Context, action, filename string, timeout time.Duration) (int64, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
body, _ := json.Marshal(map[string]string{"filename": filename})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.base+"/slots/0?action="+action, bytes.NewReader(body))
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
s.auth(req.Header.Set)
resp, err := s.client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("%s : %s %s", action, resp.Status, strings.TrimSpace(string(raw[:min(len(raw), 300)])))
}
var out struct {
Written int64 `json:"n_written"`
Read int64 `json:"n_read"`
}
_ = json.Unmarshal(raw, &out)
return max(out.Written, out.Read), nil
}
func localSlotIsolator(ep chatEndpoint) slotIsolator {
return slotIsolator{base: fmt.Sprintf("http://localhost:%d", LLMPort()), auth: ep.auth, client: http.DefaultClient}
}
// slotPersistSize : slotPersistEstimate, remplaçable par les tests.
var slotPersistSize = slotPersistEstimate
// slotPersistEstimate : octets de l'état de tokens jetons sous cfg ; 0 =
// inconnu (et l'état n'est alors pas gardé : on ne borne pas l'inconnu).
func slotPersistEstimate(cfg map[string]string, tokens int) int64 {
p, err := resolveServeModelPath(strings.TrimSpace(cfg["MODEL"]))
if err != nil {
return 0
}
g, err := ggufMeta(p)
if err != nil {
return 0
}
argEnv := map[string]string{}
for _, k := range fidelityArgEnv {
if v := os.Getenv(k); v != "" {
argEnv[k] = v
}
}
kt, vt, _ := effectiveKVTypes(cfg, splitArgs(cfg["EXTRA_ARGS"]), argEnv)
perTok, rec := ggufStateBytes(g, kt, vt)
if perTok <= 0 {
return 0
}
return int64(math.Ceil(perTok*float64(tokens))) + rec
}
// save garde l'état du slot de la discussion, juste avant l'arrêt du moteur.
// Jamais d'erreur rendue : au pire, rien n'est gardé.
func (s *slotPersistSnap) save() {
if s == nil {
return
}
iso := s.iso
ctx := context.Background()
st, err := iso.slot0(ctx)
switch {
case err != nil:
slotPersistLog("état non gardé : /slots illisible (%v)", err)
return
case !st.found || st.processing:
return
case st.speculative:
slotPersistLog("état non gardé : décodage spéculatif actif (le brouillon ne serait pas sauvé)")
return
case st.tokens < slotPersistMinTokens:
return
}
est := slotPersistSize(s.cfg, st.tokens)
if est <= 0 || est > slotPersistMaxBytes {
slotPersistLog("état non gardé : %d jetons, taille estimée %d Mio (plafond %d Mio)", st.tokens, est>>20, slotPersistMaxBytes>>20)
return
}
dir := slotPersistDir()
if free := diskFree(dir); free < 2*est+(1<<30) {
slotPersistLog("état non gardé : place libre insuffisante ou inconnue dans %s", dir)
return
}
name := slotPersistName(s.key, s.conv)
tmp := "tmp-" + name
end, seq := engineRequestBegin(nil)
t0 := time.Now()
n, err := iso.action(ctx, "save", tmp, slotPersistSaveTimeout)
end()
engineGate.mu.Lock()
moved := seq != s.seq+1 || engineGate.seq != seq
engineGate.mu.Unlock()
if err != nil || moved {
_ = os.Remove(filepath.Join(dir, tmp))
if err != nil {
slotPersistLog("état non gardé : %v", err)
}
return
}
if err := os.Rename(filepath.Join(dir, tmp), filepath.Join(dir, name)); err != nil {
_ = os.Remove(filepath.Join(dir, tmp))
slotPersistLog("état non gardé : %v", err)
return
}
slotPersistPrune(dir)
slotPersistLog("état de la discussion gardé : %d jetons, %d Mio en %s", st.tokens, n>>20, time.Since(t0).Round(time.Millisecond))
}
// slotPersistTried : fichiers déjà tentés (nom et date), pour qu'un fichier
// qu'on n'aurait pas pu retirer ne soit pas relu à chaque requête.
var slotPersistTried struct {
mu sync.Mutex
m map[string]bool
}
// slotPersistRestore recharge l'état gardé de conv dans le slot 0, si tout
// concorde. Appelée juste avant une requête de la discussion vers le moteur
// local ; sans la clé, une lecture de configuration et rien d'autre.
func slotPersistRestore(ep chatEndpoint, conv string) {
if ep.External || conv == "" {
return
}
cfg := ReadConfig()
if !slotPersistOn(cfg) || isExternalConfig(cfg) {
return
}
key := readSlotPersistMarker(LokiHome())
if key == "" {
return
}
name := slotPersistName(key, conv)
path := filepath.Join(slotPersistDir(), name)
fi, err := os.Stat(path)
if err != nil {
return
}
// Un fichier, une tentative : le même nom réécrit par une bascule suivante
// (A→B→A→B→A) est un autre fichier.
once := fmt.Sprintf("%s|%d", name, fi.ModTime().UnixNano())
iso := localSlotIsolator(ep)
ctx := context.Background()
st, err := iso.slot0(ctx)
if err != nil || !st.found {
return // moteur pas encore prêt : la requête suivante retentera
}
slotPersistTried.mu.Lock()
if slotPersistTried.m == nil {
slotPersistTried.m = map[string]bool{}
}
tried := slotPersistTried.m[once]
slotPersistTried.m[once] = true
slotPersistTried.mu.Unlock()
if tried {
return
}
// Une seule chance, quoi qu'il arrive.
defer os.Remove(path)
switch {
case st.used || st.processing:
slotPersistLog("état gardé ignoré : le slot a déjà servi depuis le lancement")
return
case st.speculative:
slotPersistLog("état gardé ignoré : décodage spéculatif actif")
return
}
end, _ := engineRequestBegin(nil)
t0 := time.Now()
n, err := iso.action(ctx, "restore", name, slotPersistRestoreTimeout)
end()
if err != nil {
slotPersistLog("état gardé non rechargé, calcul normal : %v", err)
return
}
slotPersistLog("état de la discussion rechargé : %d Mio en %s", n>>20, time.Since(t0).Round(time.Millisecond))
}
+442
View File
@@ -0,0 +1,442 @@
package loki
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
)
// persistSI : moteur qui connaît --slot-save-path, dossier prêt, boucle locale.
func persistSI() serveSysInfo {
si := denseGPU()
si.SlotDir = "/data/slots"
return si
}
func TestSlotPersistPlan(t *testing.T) {
loop := map[string]string{"SLOT_PERSIST": "on", "HOST": "127.0.0.1"}
with := func(kv ...string) map[string]string {
m := map[string]string{}
for k, v := range loop {
m[k] = v
}
for i := 0; i+1 < len(kv); i += 2 {
m[kv[i]] = kv[i+1]
}
return m
}
cases := []struct {
name string
cfg map[string]string
extra []string
si func(*serveSysInfo)
ok bool
why string
}{
{name: "clé absente", cfg: map[string]string{"HOST": "127.0.0.1"}},
{name: "boucle locale", cfg: loop, ok: true},
{name: "0.0.0.0 avec clé d'API", cfg: with("HOST", "0.0.0.0"), si: func(s *serveSysInfo) { s.APIKey = "k" }, ok: true},
{name: "0.0.0.0 sans clé", cfg: with("HOST", ""), why: "sans clé"},
{name: "SPEC=auto", cfg: with("SPEC", "auto"), why: "spéculatif"},
{name: "brouillon dans EXTRA_ARGS", cfg: loop, extra: []string{"-md", "d.gguf"}, why: "spéculatif"},
{name: "--slot-save-path à la main", cfg: loop, extra: []string{"--slot-save-path", "/x"}, why: "à la main"},
{name: "variable LLAMA_ARG_SLOT_SAVE_PATH", cfg: loop, si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_SLOT_SAVE_PATH"] = "/x" }, why: "à la main"},
{name: "experts sur CPU", cfg: loop, extra: []string{"--n-cpu-moe", "30"}, why: "CPU"},
{name: "moteur sans le drapeau", cfg: loop, si: func(s *serveSysInfo) { s.Help = helpRecent }, why: "--slot-save-path"},
{name: "dossier indisponible", cfg: loop, si: func(s *serveSysInfo) { s.SlotDir = "" }, why: "dossier"},
{name: "preset externe", cfg: with(extKeyFlag, "1"), why: "externe"},
}
for _, c := range cases {
si := persistSI()
if c.si != nil {
c.si(&si)
}
ok, why := slotPersistPlan(c.cfg, c.extra, si)
if ok != c.ok || !strings.Contains(why, c.why) {
t.Errorf("%s : ok=%v why=%q", c.name, ok, why)
}
}
}
// --slot-save-path : posé pour SLOT_PERSIST même quand l'isolation n'en veut
// pas (cache coupé) ; sans la clé, la règle du lot 1 inchangée.
func TestSlotPersistSlotSaveArgs(t *testing.T) {
si := serveSysInfo{Help: helpCacheRAM, SlotDir: "/data/slots", ArgEnv: map[string]string{}}
base := map[string]string{"HOST": "127.0.0.1", "CACHE_RAM": "0"}
if got := slotSaveArgs(base, nil, si); got != nil {
t.Fatalf("sans la clé : %v", got)
}
on := map[string]string{"HOST": "127.0.0.1", "CACHE_RAM": "0", "SLOT_PERSIST": "on"}
if got := slotSaveArgs(on, nil, si); !reflect.DeepEqual(got, []string{"--slot-save-path", "/data/slots"}) {
t.Fatalf("avec la clé : %v", got)
}
open := map[string]string{"CACHE_RAM": "0", "SLOT_PERSIST": "on"}
if got := slotSaveArgs(open, nil, si); got != nil {
t.Fatalf("0.0.0.0 sans clé : %v", got)
}
}
// Refus : la ligne est celle sans la clé, une note de plus.
func TestSlotPersistRefusLigneInchangee(t *testing.T) {
si := persistSI()
cfg := map[string]string{"CTX": "32768", "SPEC": "auto"}
want, _, wantNotes := buildServeArgs(cfg, nil, "/bin/llama-server", si)
cfg["SLOT_PERSIST"] = "on"
got, _, notes := buildServeArgs(cfg, nil, "/bin/llama-server", si)
if !reflect.DeepEqual(got, want) || len(notes) != len(wantNotes)+1 {
t.Fatalf("refus :\n%v\n%v\n%q", got, want, notes)
}
}
func TestSlotPersistKey(t *testing.T) {
dir := t.TempDir()
bin := filepath.Join(dir, "llama-server")
lib := filepath.Join(dir, "libggml-cuda.so")
model := filepath.Join(dir, "m.gguf")
for _, p := range []string{bin, lib, model} {
if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
}
si := serveSysInfo{Model: model}
args := []string{bin, "-m", model, "-c", "32768", "--api-key", "secret"}
k0 := slotPersistKey(args, nil, si, bin)
if len(k0) != 16 || k0 != slotPersistKey(args, nil, si, bin) {
t.Fatalf("clé instable : %q", k0)
}
if slotPersistKey([]string{bin, "-m", model, "-c", "32768", "--api-key", "autre"}, nil, si, bin) != k0 {
t.Fatal("la clé d'API ne doit pas entrer dans l'empreinte")
}
if slotPersistKey([]string{bin, "-m", model, "-c", "65536"}, nil, si, bin) == k0 {
t.Fatal("CTX ignoré")
}
if slotPersistKey(args, map[string]string{"GGML_CUDA_GRAPH_OPT": "1"}, si, bin) == k0 {
t.Fatal("environnement ignoré")
}
later := time.Now().Add(time.Hour)
for _, p := range []string{lib, model, bin} {
if err := os.Chtimes(p, later, later); err != nil {
t.Fatal(err)
}
k := slotPersistKey(args, nil, si, bin)
if k == k0 {
t.Fatalf("%s modifié, clé inchangée", filepath.Base(p))
}
k0 = k
}
}
func TestSlotPersistFichiers(t *testing.T) {
home := t.TempDir()
dir := prepareSlotDir(home, true)
names := []string{"persist-0123456789abcdef-000000000001.bin", "persist-0123456789abcdef-000000000002.bin",
"persist-fedcba9876543210-000000000003.bin"}
for i, n := range append(append([]string{}, names...), "tmp-"+names[0], "autre.bin") {
p := filepath.Join(dir, n)
if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
at := time.Now().Add(time.Duration(i) * time.Minute)
_ = os.Chtimes(p, at, at)
}
prepareSlotDir(home, true)
slotPersistPrune(dir)
left, _ := os.ReadDir(dir)
var got []string
for _, e := range left {
got = append(got, e.Name())
}
if !reflect.DeepEqual(got, names[1:]) {
t.Fatalf("restent %v, attendu les deux plus récents %v", got, names[1:])
}
prepareSlotDir(home, false)
if left, _ := os.ReadDir(dir); len(left) != 0 {
t.Fatalf("clé retirée : %d fichier(s) gardé(s)", len(left))
}
if readSlotPersistMarker(home) != "" {
t.Fatal("marqueur absent : clé vide attendue")
}
writeSlotPersistMarker(home, "0123456789abcdef")
if readSlotPersistMarker(home) != "0123456789abcdef" {
t.Fatal("marqueur illisible")
}
writeSlotPersistMarker(home, "")
if readSlotPersistMarker(home) != "" {
t.Fatal("marqueur non retiré")
}
}
// moteurSlots : faux llama-server pour /slots, save et restore. Un save écrit
// le fichier dans le dossier des slots, comme le vrai.
type moteurSlots struct {
mu sync.Mutex
actions []string
used, spec bool
restoreFail bool
tokens int
}
func (m *moteurSlots) start(t *testing.T, home string) {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.mu.Lock()
defer m.mu.Unlock()
switch {
case r.URL.Path == "/slots" && r.Method == http.MethodGet:
s := map[string]any{"id": 0, "is_processing": false, "speculative": m.spec, "n_prompt_tokens": m.tokens}
if m.used {
s["id_task"] = 7
}
sendJSON(w, 200, []any{s})
case r.URL.Path == "/slots/0" && r.Method == http.MethodPost:
var body struct {
Filename string `json:"filename"`
}
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &body)
act := r.URL.Query().Get("action")
m.actions = append(m.actions, act+" "+body.Filename)
if act == "save" {
_ = os.WriteFile(filepath.Join(home, "slots", body.Filename), []byte("kv"), 0o600)
sendJSON(w, 200, map[string]any{"n_saved": m.tokens, "n_written": 2 << 20})
return
}
if m.restoreFail {
sendJSON(w, 400, map[string]any{"error": map[string]any{"message": "Unable to restore slot"}})
return
}
sendJSON(w, 200, map[string]any{"n_restored": m.tokens, "n_read": 2 << 20})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
_, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
if err := SetConfigKey("PORT", port); err != nil {
t.Fatal(err)
}
}
func (m *moteurSlots) got() []string {
m.mu.Lock()
defer m.mu.Unlock()
return append([]string(nil), m.actions...)
}
const persistTestKey = "0123456789abcdef"
// persistSetup : discussion « c1 » dont le tour vient de passer par le slot.
func persistSetup(t *testing.T, on bool) (*moteurSlots, string) {
t.Helper()
home := testHome(t)
if err := os.MkdirAll(filepath.Join(home, "slots"), 0o700); err != nil {
t.Fatal(err)
}
for k, v := range map[string]string{"MODEL": "m.gguf", "CTX": "32768"} {
if err := SetConfigKey(k, v); err != nil {
t.Fatal(err)
}
}
if on {
if err := SetConfigKey("SLOT_PERSIST", "on"); err != nil {
t.Fatal(err)
}
}
writeSlotPersistMarker(home, persistTestKey)
prev := slotPersistSize
slotPersistSize = func(map[string]string, int) int64 { return 1 << 20 }
reset := func() {
engineGate.mu.Lock()
engineGate.main = engineMainStamp{}
engineGate.mu.Unlock()
}
reset()
t.Cleanup(func() { slotPersistSize = prev; reset() })
m := &moteurSlots{tokens: 20000}
m.start(t, home)
end, seq := engineRequestBegin(nil)
end()
engineMarkMain(seq, "c1")
return m, home
}
// Bascule puis retour : gardé sous la clé du moteur, rechargé une fois avant
// la première requête de la discussion, puis retiré.
func TestSlotPersistSauveEtRecharge(t *testing.T) {
m, home := persistSetup(t, true)
snap := slotPersistPrepare()
if snap == nil {
t.Fatal("rien à garder")
}
snap.save()
name := slotPersistName(persistTestKey, "c1")
path := filepath.Join(home, "slots", name)
if _, err := os.Stat(path); err != nil {
t.Fatalf("état non gardé : %v (%v)", err, m.got())
}
if _, err := os.Stat(filepath.Join(home, "slots", "tmp-"+name)); !os.IsNotExist(err) {
t.Fatal("fichier provisoire resté")
}
ep := resolveChatEndpoint()
slotPersistRestore(ep, "autre") // autre discussion : rien
slotPersistRestore(ep, "c1")
slotPersistRestore(ep, "c1") // une seule fois
want := []string{"save tmp-" + name, "restore " + name}
if got := m.got(); !reflect.DeepEqual(got, want) {
t.Fatalf("appels %v, attendu %v", got, want)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("fichier gardé après la tentative")
}
}
// Sans la clé : ni relevé, ni appel au moteur.
func TestSlotPersistDefautRien(t *testing.T) {
m, home := persistSetup(t, false)
if snap := slotPersistPrepare(); snap != nil {
t.Fatal("relevé sans la clé")
}
var snap *slotPersistSnap
snap.save()
name := slotPersistName(persistTestKey, "c1")
if err := os.WriteFile(filepath.Join(home, "slots", name), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
slotPersistRestore(resolveChatEndpoint(), "c1")
if got := m.got(); len(got) != 0 {
t.Fatalf("appels sans la clé : %v", got)
}
}
// Rien n'est gardé si le slot ne porte plus le tour, si un brouillon tourne,
// si l'état est trop court ou trop gros, ou si une requête part pendant
// l'écriture.
func TestSlotPersistSauvegardeRefusee(t *testing.T) {
m, home := persistSetup(t, true)
end := engineRequestStart() // une autre requête est passée par le slot
end()
if slotPersistPrepare() != nil {
t.Fatal("relevé alors que le slot ne porte plus le tour")
}
for _, c := range []struct {
name string
set func()
}{
{"brouillon spéculatif", func() { m.spec = true }},
{"contexte court", func() { m.tokens = 100 }},
{"état trop gros", func() { slotPersistSize = func(map[string]string, int) int64 { return slotPersistMaxBytes + 1 } }},
} {
m.mu.Lock()
m.spec, m.tokens = false, 20000
m.mu.Unlock()
slotPersistSize = func(map[string]string, int) int64 { return 1 << 20 }
e, seq := engineRequestBegin(nil)
e()
engineMarkMain(seq, "c1")
snap := slotPersistPrepare()
m.mu.Lock()
c.set()
m.mu.Unlock()
snap.save()
if got := m.got(); len(got) != 0 {
t.Fatalf("%s : appels %v", c.name, got)
}
}
// Une requête entre le relevé et l'écriture : fichier jeté.
slotPersistSize = func(map[string]string, int) int64 { return 1 << 20 }
m.mu.Lock()
m.spec, m.tokens = false, 20000
m.mu.Unlock()
e, seq := engineRequestBegin(nil)
e()
engineMarkMain(seq, "c1")
snap := slotPersistPrepare()
e2 := engineRequestStart()
e2()
snap.save()
left, _ := os.ReadDir(filepath.Join(home, "slots"))
if len(left) != 0 {
t.Fatalf("fichier gardé malgré une requête pendant l'écriture : %v", left[0].Name())
}
}
// Rechargement refusé (slot déjà servi, brouillon) ou raté : calcul normal,
// fichier retiré, aucune erreur.
func TestSlotPersistRechargementPrudent(t *testing.T) {
for _, c := range []struct {
name string
set func(*moteurSlots)
restore bool
}{
{"slot déjà servi", func(m *moteurSlots) { m.used = true }, false},
{"brouillon spéculatif", func(m *moteurSlots) { m.spec = true }, false},
{"échec du moteur", func(m *moteurSlots) { m.restoreFail = true }, true},
} {
t.Run(c.name, func(t *testing.T) {
m, home := persistSetup(t, true)
name := slotPersistName(persistTestKey, "c1")
path := filepath.Join(home, "slots", name)
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
c.set(m)
slotPersistRestore(resolveChatEndpoint(), "c1")
if got := m.got(); (len(got) == 1) != c.restore {
t.Fatalf("appels %v", got)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("fichier gardé après la tentative")
}
})
}
}
// Clé du moteur différente (autre build, autres réglages) : le fichier n'est
// même pas cherché.
func TestSlotPersistAutreMoteur(t *testing.T) {
m, home := persistSetup(t, true)
name := slotPersistName(persistTestKey, "c1")
if err := os.WriteFile(filepath.Join(home, "slots", name), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
writeSlotPersistMarker(home, "fedcba9876543210")
slotPersistRestore(resolveChatEndpoint(), "c1")
if got := m.got(); len(got) != 0 {
t.Fatalf("rechargé sous une autre clé : %v", got)
}
}
// SLOT_PERSIST survit à la bascule (A → B sans la clé → A) : sans ça, le
// lancement de B effacerait l'état gardé de A. Un preset qui la pose gagne.
func TestSlotPersistSurvitALaBascule(t *testing.T) {
home := testHome(t)
setConfig(t, "MODEL=a.gguf\nSLOT_PERSIST=on\n")
b := filepath.Join(home, "b.env")
if err := os.WriteFile(b, []byte("MODEL=b.gguf\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := applyPresetFile(b); err != nil {
t.Fatal(err)
}
if got := ReadConfig()["SLOT_PERSIST"]; got != "on" {
t.Fatalf("SLOT_PERSIST = %q après bascule", got)
}
c := filepath.Join(home, "c.env")
if err := os.WriteFile(c, []byte("MODEL=c.gguf\nSLOT_PERSIST=off\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := applyPresetFile(c); err != nil {
t.Fatal(err)
}
if got := ReadConfig()["SLOT_PERSIST"]; got != "off" {
t.Fatalf("le preset qui pose la clé doit gagner : %q", got)
}
}
+5
View File
@@ -88,6 +88,11 @@ var configTemplate = []struct{ key, help string }{
"sous-agents, tâches, bench, résumé sur transcription et clients /v1 passent par le 1 (id_slot) — l'état de la " + "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 " + "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"}, "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"},
{"SLOT_PERSIST", "on = garder sur disque (LOKI_HOME/slots, 2 fichiers au plus, 8 Gio chacun au plus) l'état du slot de " +
"la discussion à la bascule de preset, et le recharger au retour avant son premier message : moteur, modèle et réglages " +
"identiques à l'octet près (empreinte), slot encore vierge, sinon calcul normal. Refusé avec le décodage spéculatif, " +
"des poids sur CPU, ou un moteur joignable sans clé d'API hors 127.0.0.1. Jamais à travers une mise à jour du moteur. " +
"Réglage de machine, gardé à la bascule. Vide/off (défaut) = rien n'est écrit"},
{"CTX_CHECKPOINTS", "points de reprise par slot d'un modèle hybride (--ctx-checkpoints) ; vide = défaut du moteur (32) ; " + {"CTX_CHECKPOINTS", "points de reprise par slot d'un modèle hybride (--ctx-checkpoints) ; vide = défaut du moteur (32) ; " +
"chacun pèse 70 à 200 Mio de RAM hôte : 8 à 16 si le modèle remplit déjà la RAM"}, "chacun pèse 70 à 200 Mio de RAM hôte : 8 à 16 si le modèle remplit déjà la RAM"},
{"CKPT_MIN_STEP", "espacement minimal en jetons entre deux points de reprise (--checkpoint-min-step), > 0 ; " + {"CKPT_MIN_STEP", "espacement minimal en jetons entre deux points de reprise (--checkpoint-min-step), > 0 ; " +
+5
View File
@@ -1310,6 +1310,9 @@ func handleSwitch(w http.ResponseWriter, r *http.Request) {
// SwitchToPreset bloquait la réponse pendant tout l'arrêt de llama-server plus // SwitchToPreset bloquait la réponse pendant tout l'arrêt de llama-server plus
// les 2 s de vérification de checkStarted : côté UI, le clic paraissait mou et // les 2 s de vérification de checkStarted : côté UI, le clic paraissait mou et
// la sélection ne bougeait qu'au bout de plusieurs secondes, pour rien. // la sélection ne bougeait qu'au bout de plusieurs secondes, pour rien.
// SLOT_PERSIST : relevé avant que la configuration change, gardé juste
// avant l'arrêt du moteur (llm_slotpersist.go). Sans la clé : nil.
persist := slotPersistPrepare()
if err := applyPresetFile(target.Path); err != nil { if err := applyPresetFile(target.Path); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return return
@@ -1323,6 +1326,7 @@ func handleSwitch(w http.ResponseWriter, r *http.Request) {
fmt.Println(dim("[info] preset externe — arrêt du moteur local")) fmt.Println(dim("[info] preset externe — arrêt du moteur local"))
go func() { go func() {
if serviceIsActive() { if serviceIsActive() {
persist.save()
if err := serviceAction("stop"); err != nil { if err := serviceAction("stop"); err != nil {
fmt.Printf("%s arrêt du moteur après bascule externe : %v\n", red("[ERREUR]"), err) fmt.Printf("%s arrêt du moteur après bascule externe : %v\n", red("[ERREUR]"), err)
} }
@@ -1333,6 +1337,7 @@ func handleSwitch(w http.ResponseWriter, r *http.Request) {
} }
fmt.Println(dim("[info] redémarrage du service...")) fmt.Println(dim("[info] redémarrage du service..."))
go func() { go func() {
persist.save()
if err := serviceAction("restart"); err != nil { if err := serviceAction("restart"); err != nil {
fmt.Printf("%s redémarrage après bascule: %v\n", red("[ERREUR]"), err) fmt.Printf("%s redémarrage après bascule: %v\n", red("[ERREUR]"), err)
} }