mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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>
443 lines
14 KiB
Go
443 lines
14 KiB
Go
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)
|
|
}
|
|
}
|