Files
Loki/internal/loki/backend_serve_persist.go
T
MichaelandClaude Opus 5.5 28ba44eb46 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>
2026-10-04 10:17:08 +02:00

266 lines
8.7 KiB
Go

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()))
}
}
}