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

+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
// même logique : un « off » posé parce que CETTE machine bloque ne doit pas
// 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
// 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
// « cudaMalloc failed: out of memory ». Le preset explicite gagne.
// 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
// 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
// « appareil » (preservedKeys) sont conservés à travers la bascule.
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 {
return err
}
@@ -277,9 +284,11 @@ func SwitchToPreset(target string) error {
if !serviceIsActive() {
return nil
}
persist.save()
return serviceAction("stop")
}
fmt.Println(dim("[info] redémarrage du service..."))
persist.save()
return serviceAction("restart")
}
+20 -1
View File
@@ -362,12 +362,24 @@ func cmdServe(args []string) error {
if spec == "auto" {
si.SpecAutoBlocked, specRecord = specAutoPeek(specMark)
}
probeSlotPersistEnv(cfg, &si)
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)
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 {
fmt.Fprintln(os.Stderr, "[loki serve] "+n)
}
@@ -395,6 +407,10 @@ func cmdServe(args []string) error {
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",
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)
args = append(append(args, cram...), slotSaveArgs(cfg, extra, si)...)
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).
ck, ckNotes := ckptArgs(cfg, extra, si)
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
// 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
// que l'isolation servira : cache actif, CACHE_ISOLATE pas à off. Les proxys de
// Loki refusent de leur côté tout /slots qui n'est pas une lecture.
// qu'il servira : à l'isolation (cache actif, CACHE_ISOLATE pas à off), ou à
// 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 {
if si.SlotDir == "" || !strings.Contains(si.Help, "--slot-save-path") ||
hasAnyFlag(extra, "--slot-save-path") || !cacheIsolationOn(cfg) || cacheRAMOff(cfg, extra, si.ArgEnv) {
if si.SlotDir == "" || !strings.Contains(si.Help, "--slot-save-path") || hasAnyFlag(extra, "--slot-save-path") {
return nil
}
host := flagValue(extra, "--host")
if host == "" {
host = cfg["HOST"]
persist, _ := slotPersistPlan(cfg, extra, si)
if !persist && (!cacheIsolationOn(cfg) || cacheRAMOff(cfg, extra, si.ArgEnv)) {
return nil
}
if host == "" {
host = "0.0.0.0"
}
if !loopbackHost(host) && si.APIKey == "" {
if !slotSaveSafe(cfg, extra, si) {
return nil
}
return []string{"--slot-save-path", si.SlotDir}
}
// prepareSlotDir crée LOKI_HOME/slots (0700) et le vide : Loki n'y sauve jamais
// rien, un fichier présent ne peut venir que d'un appel qui n'aurait pas dû
// passer. Vide = dossier indisponible, le drapeau ne sera pas posé.
func prepareSlotDir(home string) string {
// prepareSlotDir crée LOKI_HOME/slots (0700) et le vide : un fichier présent ne
// peut venir que d'un appel qui n'aurait pas dû passer. Seule exception, avec
// keepPersist (SLOT_PERSIST demandé) : les états que Loki y a lui-même gardés
// à 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")
if err := os.MkdirAll(dir, 0o700); err != nil {
return ""
@@ -483,7 +482,7 @@ func prepareSlotDir(home string) string {
}
if entries, err := os.ReadDir(dir); err == nil {
for _, e := range entries {
if e.Type().IsRegular() {
if e.Type().IsRegular() && !(keepPersist && slotPersistFileRe.MatchString(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) {
home := t.TempDir()
dir := prepareSlotDir(home)
dir := prepareSlotDir(home, false)
if dir != filepath.Join(home, "slots") {
t.Fatalf("dossier = %q", dir)
}
@@ -246,7 +246,7 @@ func TestPrepareSlotDir(t *testing.T) {
if err := os.WriteFile(stray, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if prepareSlotDir(home) == "" {
if prepareSlotDir(home, false) == "" {
t.Fatal("second appel")
}
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 {
t.Fatal(err)
}
if got := prepareSlotDir(bad); got != "" {
if got := prepareSlotDir(bad, false); 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 {
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)
defer cancel()
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.
endReq := func() {}
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 {
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)
// Étape d'un tour de discussion : une compaction en continuation
// 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)
}
}
+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 " +
"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"},
{"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) ; " +
"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 ; " +
+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
// 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.
// 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 {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
@@ -1323,6 +1326,7 @@ func handleSwitch(w http.ResponseWriter, r *http.Request) {
fmt.Println(dim("[info] preset externe — arrêt du moteur local"))
go func() {
if serviceIsActive() {
persist.save()
if err := serviceAction("stop"); err != nil {
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..."))
go func() {
persist.save()
if err := serviceAction("restart"); err != nil {
fmt.Printf("%s redémarrage après bascule: %v\n", red("[ERREUR]"), err)
}