mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +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>
323 lines
10 KiB
Go
323 lines
10 KiB
Go
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))
|
|
}
|