Moteur : points de reprise des hybrides protégés sur les moteurs anciens, et la version précédente gardée après une mise à jour

Avant b10864 (PR #28302), llama-server effaçait un point de reprise trop
proche du précédent même quand la liste avait de la place : sur un hybride
(Qwen3.5/3.6, Qwen3-Next), une reprise retombait jusqu'à ~8 k jetons en
arrière, recalculés à chaque tour d'outil. Un point de reprise est une copie
exacte de l'état récurrent : rien de ce que voit le modèle ne change, seul le
temps de prefill et la RAM hôte. Aucune mise à jour du moteur ici.

- Build cru seulement pour un moteur officiel (image, téléchargé, précompilé)
  et au-dessus de 5000 : un compilé en clone superficiel (build 1) ou un fork
  n'a ni avis ni drapeau. --version gardé par binaire, taille et date.
- Avis « moteur trop ancien » dans le panneau Moteur et au lancement d'un
  hybride.
- Opt-in CTX_CHECKPOINTS (--ctx-checkpoints) et CKPT_MIN_STEP
  (--checkpoint-min-step, > 0 : 0 laisserait l'éviction FIFO jeter le point du
  début de la conversation). Gardes séparées : -ctxcp/--ctx-checkpoints/
  --swa-checkpoints et -cms/--checkpoint-min-step, plus LLAMA_ARG_*.
- D'office : --checkpoint-min-step 2048 seulement sur un hybride lu dans le
  GGUF, build officiel < 10864, aide qui connaît le drapeau, aucun réglage de
  l'utilisateur, points de reprise actifs, modèle sous 70 % de la RAM et 32
  points sous le quart de la RAM libre. Sinon l'avis seul ; sur un MoE mmappé
  trop gros, conseil CTX_CHECKPOINTS=8 à 16.
- REASONING_PRESERVE=on|off → --reasoning-preserve / --no-reasoning-preserve,
  seulement si la clé est posée et que l'aide le connaît. Rien par défaut :
  Loki ne renvoie pas la réflexion passée, et aucun choix global ne garde le
  rendu de tous les gabarits (Qwen3.6 contre Qwen3.8).
- Mise à jour du moteur : la version téléchargée qui tournait avant n'est plus
  supprimée ; le panneau propose d'y revenir sans réseau.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-03 23:52:31 +02:00
1 parent c72467ce4c
commit dd27b81af0
8 files changed
+604 -13

No files matched your search

+19
View File
@@ -343,6 +343,12 @@ func cmdServe(args []string) error {
probeExpertEnv(&si)
probeFidelityEnv(&si)
probeCacheRAM(cfg, extra, &si)
probeCkptEnv(&si)
// Build du moteur : seulement pour un hybride, le seul cas où il décide de
// quelque chose (voir ckptArgs) — inutile de lancer « --version » sinon.
if ggufHybrid(si.GGUF) {
si.EngineBuild = engineBuildTrusted(bin)
}
if strings.Contains(si.Help, "--slot-save-path") && !hasAnyFlag(extra, "--slot-save-path") {
si.SlotDir = prepareSlotDir(LokiHome())
}
@@ -401,6 +407,10 @@ type serveSysInfo struct {
RAMAvailMiB int64 // RAM libre au lancement
VRAMMiB int64 // VRAM NVIDIA des cartes visibles
SlotDir string // dossier de --slot-save-path, créé et vérifié ; vide = pas de drapeau
// Build d'un moteur officiel (engineBuildTrusted), lu seulement pour un
// modèle hybride ; 0 = inconnu ou non lu : ni avis ni drapeau automatique.
EngineBuild int
}
// cudaDeviceEnv : sélection GPU (loki gpu), on filtre les devices visibles par
@@ -579,6 +589,11 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS
notes = append(notes, "ce moteur ne connaît pas --reasoning : impossible de désactiver le raisonnement")
}
}
// Conservation du raisonnement des tours passés : seulement si
// REASONING_PRESERVE est posée (voir reasoningPreserveArgs pour le compromis).
rp, rpNotes := reasoningPreserveArgs(cfg, extra, si)
args = append(args, rp...)
notes = append(notes, rpNotes...)
if si.APIKey != "" {
args = append(args, "--api-key", si.APIKey)
}
@@ -587,6 +602,10 @@ 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...)
// Points de reprise des hybrides (voir backend_serve_ckpt.go).
ck, ckNotes := ckptArgs(cfg, extra, si)
args = append(args, ck...)
notes = append(notes, ckNotes...)
// EXTRA_ARGS (déjà découpé comme le ferait le shell — les guillemets gardent
// ensemble un chemin qui contient des espaces) ferme la marche.
args = append(args, extra...)
+305
View File
@@ -0,0 +1,305 @@
package loki
import (
"fmt"
"os"
"strconv"
"strings"
"sync"
)
// Points de reprise des modèles hybrides (--ctx-checkpoints,
// --checkpoint-min-step) et conservation du raisonnement (--reasoning-preserve).
//
// Un modèle hybride (couches récurrentes : Qwen3.5/3.6, Qwen3-Next…) ne sait pas
// rembobiner son état récurrent comme un cache KV : llama-server en garde des
// copies exactes, les points de reprise, et repart du plus proche quand la
// conversation diverge ou revient du cache de prompts. Avant b10864 (PR #28302),
// le moteur effaçait un point trop proche du précédent (moins de 8192 jetons)
// MÊME quand la liste avait de la place : la reprise retombait jusqu'à ~8 k
// jetons en arrière, recalculés à chaque tour d'outil. Rien de ce que voit le
// modèle ne change ici — un point de reprise est une copie octet pour octet de
// l'état — seul le temps de prefill, et la RAM hôte que ces copies occupent.
//
// Loki ne touche à rien sans savoir : le build n'est cru que pour un moteur
// officiel (voir engineBuildTrusted), le drapeau n'est posé que si l'aide le
// connaît, et l'utilisateur garde le dernier mot (clés du preset, EXTRA_ARGS,
// LLAMA_ARG_*). Tout ce qui décide est pur ; cmdServe fait les lectures.
const (
// engineMinRecommended : premier build où l'espacement minimal n'évince un
// point de reprise que lorsque la liste est pleine (PR #28302, 5d806aa).
engineMinRecommended = 10864
// engineBuildFloor : en dessous, le numéro ne prouve rien. Un llama.cpp
// compilé depuis un clone superficiel (--depth=1) annonce « build 1 », un
// fork numérote à sa façon : traiter ça comme un moteur ancien poserait un
// avis faux et un drapeau inutile sur un moteur récent.
engineBuildFloor = 5000
// engineCkptDefault : n_ctx_checkpoints par défaut de llama.cpp (common.h).
engineCkptDefault = 32
// ckptAutoMinStep : espacement posé d'office sur un moteur ancien. Plus serré
// que les 8192 du moteur, il garde des points qu'il aurait jetés ; jamais 0,
// qui laisserait l'éviction FIFO jeter le point du début de la conversation
// dans une longue boucle d'outils.
ckptAutoMinStep = 2048
// ckptFallbackMiB : poids d'un point de reprise quand le GGUF ne permet pas de
// le calculer (70 à 200 Mio mesurés sur les hybrides Qwen) — on prend le haut.
ckptFallbackMiB = 200
)
// ckptArgEnv : les variables qui règlent la même chose sans drapeau. llama.cpp
// les lit avant la ligne de commande : un drapeau de Loki les écraserait.
var ckptArgEnv = []string{"LLAMA_ARG_CTX_CHECKPOINTS", "LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT",
"LLAMA_ARG_REASONING_PRESERVE", "LLAMA_ARG_CHAT_TEMPLATE_KWARGS"}
// probeCkptEnv complète ArgEnv avec ckptArgEnv.
func probeCkptEnv(si *serveSysInfo) {
if si.ArgEnv == nil {
si.ArgEnv = map[string]string{}
}
for _, k := range ckptArgEnv {
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
si.ArgEnv[k] = v
}
}
}
// hybridArchs : architectures hybrides connues, au cas où le GGUF ne porterait
// pas de clé ssm.* lisible.
var hybridArchs = map[string]bool{"qwen35": true, "qwen35moe": true, "qwen3next": true, "qwen4exp": true}
// ggufHybrid : le modèle a-t-il des couches récurrentes ? Jamais deviné d'après
// le nom du fichier : sans métadonnées, on répond non.
func ggufHybrid(g *GGUFInfo) bool {
return g != nil && (g.Hybrid || hybridArchs[strings.ToLower(g.Arch)])
}
// engineSource classe un moteur comme le panneau Moteur : celui de l'image,
// un moteur téléchargé par Loki depuis l'image officielle, le précompilé des
// releases officielles, ou tout le reste (« custom » : compilé, fork, chemin
// à la main).
func engineSource(bin string) string {
imageBin := providedEngineBin()
switch {
case imageBin != "" && samePath(bin, imageBin):
return "image"
case engineOwns(bin):
return "downloaded"
case prebuiltOwns(bin):
return "prebuilt"
}
return "custom"
}
// engineBuildTrust : le build n'est cru que pour un moteur publié par ggml-org,
// dont la CI numérote sur l'historique complet, et s'il est vraisemblable. 0 =
// inconnu : ni avis, ni drapeau automatique.
func engineBuildTrust(source string, build int) int {
if source == "custom" || build < engineBuildFloor {
return 0
}
return build
}
// engineBuildTrusted : build du moteur, ou 0 s'il n'est pas digne de confiance.
// Le « --version » est gardé par binaire, comme l'aide (binHelp), et la clé
// porte la taille et la date du fichier : un moteur recompilé au même chemin
// est relu.
func engineBuildTrusted(bin string) int {
src := engineSource(bin)
if src == "custom" {
return 0 // pas la peine de lancer le binaire
}
return engineBuildTrust(src, engineBuildCached(bin))
}
var (
buildMu sync.Mutex
buildCache = map[string]int{}
)
func engineBuildCached(bin string) int {
st, err := os.Stat(bin)
if err != nil {
return 0
}
key := fmt.Sprintf("%s|%d|%d", bin, st.Size(), st.ModTime().UnixNano())
buildMu.Lock()
defer buildMu.Unlock()
if b, ok := buildCache[key]; ok {
return b
}
b, _ := engineBuildOf(bin)
buildCache[key] = b
return b
}
// engineBuildNotice : l'avis du panneau Moteur et du journal de lancement pour
// un build trop ancien. Vide = rien à dire (à jour, ou build inconnu).
func engineBuildNotice(build int) string {
if build <= 0 || build >= engineMinRecommended {
return ""
}
return fmt.Sprintf("moteur b%d : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont "+
"évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b%d.",
build, engineMinRecommended)
}
// ckptCount : nombre de points de reprise par slot que le moteur gardera
// (CTX_CHECKPOINTS valide, sinon EXTRA_ARGS, sinon la variable, sinon le
// défaut). -1 = illisible ; un CTX_CHECKPOINTS illisible est ignoré (voir
// ckptArgs), le moteur garde alors son défaut.
func ckptCount(cfg map[string]string, extra []string, argEnv map[string]string) int {
v := flagValue(extra, "-ctxcp", "--ctx-checkpoints", "--swa-checkpoints")
if v == "" {
v = argEnv["LLAMA_ARG_CTX_CHECKPOINTS"]
}
if v == "" {
if n, err := strconv.Atoi(strings.TrimSpace(cfg["CTX_CHECKPOINTS"])); err == nil && n >= 0 {
return n
}
return engineCkptDefault
}
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil || n < 0 {
return -1
}
return n
}
// ckptMiB : poids d'un point de reprise d'un hybride (état récurrent en f32).
func ckptMiB(g *GGUFInfo) int64 {
if g != nil {
if _, rec := ggufStateBytes(*g, "", ""); rec > 0 {
return (rec + (1 << 20) - 1) >> 20
}
}
return ckptFallbackMiB
}
// ramTight : le modèle occupe plus de 70 % de la RAM (mmap d'un gros MoE dont
// les experts vivent dans le cache de pages). Chaque Mio de points de reprise
// y est pris aux poids : plus de lectures disque, décodage plus lent.
func ramTight(si serveSysInfo) bool {
return si.RAMMiB > 0 && si.ModelBytes > 0 && float64(si.ModelBytes) > 0.7*float64(si.RAMMiB)*(1<<20)
}
// ckptArgs : --ctx-checkpoints et --checkpoint-min-step que Loki ajoute, et ce
// qu'il en dit.
//
// CTX_CHECKPOINTS=<n> → --ctx-checkpoints n (opt-in ; jamais de défaut relevé)
// CKPT_MIN_STEP=<n> → --checkpoint-min-step n (n > 0)
// clé absente → --checkpoint-min-step 2048 seulement sur un hybride,
// moteur officiel < b10864, RAM non serrée
//
// Chaque drapeau a sa propre garde : un --ctx-checkpoints écrit à la main ne
// coupe pas l'atténuation de l'espacement, qui est une autre question.
func ckptArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) {
hybrid := ggufHybrid(si.GGUF)
if v := strings.TrimSpace(cfg["CTX_CHECKPOINTS"]); v != "" {
n, err := strconv.Atoi(v)
switch {
case err != nil || n < 0:
notes = append(notes, "CTX_CHECKPOINTS="+v+" illisible (un nombre de points de reprise par slot, ≥ 0) : ignoré")
case !strings.Contains(si.Help, "--ctx-checkpoints"):
notes = append(notes, "ce moteur ne connaît pas --ctx-checkpoints : CTX_CHECKPOINTS ignoré")
case hasAnyFlag(extra, "-ctxcp", "--ctx-checkpoints", "--swa-checkpoints"):
case si.ArgEnv["LLAMA_ARG_CTX_CHECKPOINTS"] != "":
notes = append(notes, "LLAMA_ARG_CTX_CHECKPOINTS est posée : CTX_CHECKPOINTS ignoré")
default:
args = append(args, "--ctx-checkpoints", strconv.Itoa(n))
}
} else if hybrid && ramTight(si) && strings.Contains(si.Help, "--ctx-checkpoints") &&
!hasAnyFlag(extra, "-ctxcp", "--ctx-checkpoints", "--swa-checkpoints") && si.ArgEnv["LLAMA_ARG_CTX_CHECKPOINTS"] == "" {
// Un conseil, pas un réglage : moins de points de reprise, c'est plus de
// prefill, et seul l'utilisateur peut mesurer lequel des deux coûte le plus.
notes = append(notes, fmt.Sprintf("modèle hybride de %d Gio pour %d Gio de RAM : jusqu'à %d points de reprise "+
"(~%d Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.",
si.ModelBytes>>30, si.RAMMiB>>10, engineCkptDefault, ckptMiB(si.GGUF)))
}
stepUser := hasAnyFlag(extra, "-cms", "--checkpoint-min-step") || si.ArgEnv["LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT"] != ""
if v := strings.TrimSpace(cfg["CKPT_MIN_STEP"]); v != "" {
n, err := strconv.Atoi(v)
switch {
case err != nil || n <= 0:
// 0 = « aucun minimum » pour le moteur : la liste se remplit de points
// serrés et l'éviction FIFO finit par jeter celui du début de la
// conversation. On ne le pose pas, sans se taire pour autant.
notes = append(notes, "CKPT_MIN_STEP="+v+" refusé (un nombre de jetons > 0 ; 0 laisserait le moteur jeter "+
"le point de reprise du début de la conversation) : défaut du moteur")
case !strings.Contains(si.Help, "--checkpoint-min-step"):
notes = append(notes, "ce moteur ne connaît pas --checkpoint-min-step : CKPT_MIN_STEP ignoré")
case hasAnyFlag(extra, "-cms", "--checkpoint-min-step"):
case si.ArgEnv["LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT"] != "":
notes = append(notes, "LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT est posée : CKPT_MIN_STEP ignoré")
default:
args = append(args, "--checkpoint-min-step", strconv.Itoa(n))
}
return args, notes
}
notice := engineBuildNotice(si.EngineBuild)
if !hybrid || notice == "" {
return args, notes
}
n := ckptCount(cfg, extra, si.ArgEnv)
switch {
case stepUser || !strings.Contains(si.Help, "--checkpoint-min-step"):
return args, append(notes, notice)
case n <= 0:
return args, append(notes, notice) // points de reprise coupés : l'espacement ne sert à rien
case si.RAMMiB <= 0 || si.ModelBytes <= 0 || ramTight(si):
return args, append(notes, notice+" Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle "+
"(CKPT_MIN_STEP=2048 pour l'imposer).")
}
worst := int64(n) * ckptMiB(si.GGUF)
if si.RAMAvailMiB > 0 && worst > si.RAMAvailMiB/4 {
return args, append(notes, fmt.Sprintf("%s Pas d'espacement resserré d'office : %d points de reprise (~%d Mio) "+
"dépasseraient le quart de la RAM libre (CKPT_MIN_STEP=2048 pour l'imposer).", notice, n, worst))
}
args = append(args, "--checkpoint-min-step", strconv.Itoa(ckptAutoMinStep))
return args, append(notes, fmt.Sprintf("%s → --checkpoint-min-step %d : points de reprise exacts plus rapprochés, "+
"~%d Mio de RAM hôte au plus. CKPT_MIN_STEP=<jetons> pour fixer.", notice, ckptAutoMinStep, worst))
}
// reasoningPreserveArgs : REASONING_PRESERVE=on|off → --reasoning-preserve /
// --no-reasoning-preserve, seulement si la clé est posée.
//
// Le compromis, qui explique pourquoi Loki ne choisit pas : depuis b10763 le
// moteur active preserve_reasoning par défaut, et le gabarit garde alors la
// réflexion de TOUS les tours passés. Mais Loki ne renvoie pas le raisonnement
// des tours passés : un gabarit qui ne conserve rien de lui-même (Qwen3.6) rend
// alors des blocs de réflexion vides, là où « off » rend l'historique des
// anciens moteurs. À l'inverse, un gabarit qui conserve déjà de lui-même
// (Qwen3.8) changerait de rendu avec « off ». Aucun choix global n'est neutre
// pour tous les modèles : vide = défaut du moteur, inchangé.
func reasoningPreserveArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) {
v := strings.ToLower(strings.TrimSpace(cfg["REASONING_PRESERVE"]))
if v == "" {
return nil, nil
}
flag := ""
switch v {
case "on":
flag = "--reasoning-preserve"
case "off":
flag = "--no-reasoning-preserve"
default:
return nil, []string{"REASONING_PRESERVE=" + cfg["REASONING_PRESERVE"] + " illisible (on ou off) : ignoré"}
}
switch {
case !strings.Contains(si.Help, "--no-reasoning-preserve"):
return nil, []string{"ce moteur ne connaît pas --reasoning-preserve : REASONING_PRESERVE ignoré"}
case hasAnyFlag(extra, "--reasoning-preserve", "--no-reasoning-preserve"):
return nil, nil
case strings.Contains(strings.Join(flagValues(extra, "--chat-template-kwargs"), " "), "preserve_reasoning"):
return nil, []string{"--chat-template-kwargs fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré"}
case si.ArgEnv["LLAMA_ARG_REASONING_PRESERVE"] != "":
return nil, []string{"LLAMA_ARG_REASONING_PRESERVE est posée : REASONING_PRESERVE ignoré"}
case strings.Contains(si.ArgEnv["LLAMA_ARG_CHAT_TEMPLATE_KWARGS"], "preserve_reasoning"):
return nil, []string{"LLAMA_ARG_CHAT_TEMPLATE_KWARGS fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré"}
}
return []string{flag}, nil
}
+220
View File
@@ -0,0 +1,220 @@
package loki
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
const helpCkpt = helpRecent + `-ctxcp, --ctx-checkpoints, --swa-checkpoints N
max number of context checkpoints to create per slot (default: 32)
-cms, --checkpoint-min-step N minimum spacing between context checkpoints in tokens (default: 8192, 0 = no minimum)
--reasoning-preserve, --no-reasoning-preserve
preserve reasoning trace in the full history, not just the last assistant message (default: enabled)
`
// hybridOld : un Qwen3.6 27B hybride de 17 Go sur un moteur officiel b10678,
// machine de 64 Go. État récurrent : 48 couches × (3 × 10240 + 128 × 6144) × 4
// octets ≈ 150 Mio par point de reprise.
func hybridOld() serveSysInfo {
return serveSysInfo{
Help: helpCkpt,
Model: "/models/Qwen3.6-27B.gguf",
GGUF: &GGUFInfo{Arch: "qwen35", Hybrid: true, BlockCount: 64, FullAttnInterval: 4, HeadCountKV: 4,
KeyLen: 256, ValLen: 256, SSMConv: 4, SSMInner: 6144, SSMState: 128, SSMGroups: 16},
ModelBytes: 17 << 30,
RAMMiB: 65536,
RAMAvailMiB: 50000,
ArgEnv: map[string]string{},
EngineBuild: 10678,
}
}
func TestCkptArgs(t *testing.T) {
cms := []string{"--checkpoint-min-step", "2048"}
cases := []struct {
name string
cfg map[string]string
extra []string
si func(*serveSysInfo)
want []string
notes int
}{
{name: "hybride, moteur b10678 : espacement resserré d'office", want: cms, notes: 1},
{name: "hybride, moteur b11351 : rien", si: func(s *serveSysInfo) { s.EngineBuild = 11351 }},
{name: "hybride, build pile au correctif : rien", si: func(s *serveSysInfo) { s.EngineBuild = engineMinRecommended }},
{name: "build inconnu (compilé, clone superficiel, fork) : ni avis ni drapeau",
si: func(s *serveSysInfo) { s.EngineBuild = 0 }},
{name: "modèle dense sur moteur ancien : rien",
si: func(s *serveSysInfo) { s.GGUF = &GGUFInfo{Arch: "qwen3", BlockCount: 64} }},
{name: "métadonnées illisibles : jamais deviné au nom du fichier",
si: func(s *serveSysInfo) { s.GGUF = nil }},
{name: "arch hybride connue sans clé ssm.*", want: cms, notes: 1,
si: func(s *serveSysInfo) { s.GGUF = &GGUFInfo{Arch: "qwen3next"} }},
{name: "CKPT_MIN_STEP=4096 passé tel quel, quel que soit le build",
cfg: map[string]string{"CKPT_MIN_STEP": "4096"}, want: []string{"--checkpoint-min-step", "4096"},
si: func(s *serveSysInfo) { s.EngineBuild = 0 }},
{name: "CKPT_MIN_STEP=0 refusé, défaut du moteur, et on le dit",
cfg: map[string]string{"CKPT_MIN_STEP": "0"}, notes: 1},
{name: "-cms d'EXTRA_ARGS : Loki se tait sur l'espacement",
extra: []string{"-cms", "1024"}, notes: 1},
{name: "--checkpoint-min-step=1024 d'EXTRA_ARGS", cfg: map[string]string{"CKPT_MIN_STEP": "4096"},
extra: []string{"--checkpoint-min-step=1024"}},
{name: "LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT posée : pas d'espacement d'office", notes: 1,
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT"] = "4096" }},
{name: "--ctx-checkpoints d'EXTRA_ARGS ne coupe pas l'espacement", extra: []string{"--ctx-checkpoints", "8"},
want: cms, notes: 1},
{name: "aide sans -cms : pas de drapeau, l'avis reste", notes: 1,
si: func(s *serveSysInfo) { s.Help = helpRecent }},
{name: "points de reprise coupés : l'espacement ne sert à rien", cfg: map[string]string{"CTX_CHECKPOINTS": "0"},
want: []string{"--ctx-checkpoints", "0"}, notes: 1},
{name: "MoE mmappé de 82 Go sur 64 Go : conseil, pas d'espacement d'office", notes: 2,
si: func(s *serveSysInfo) { s.ModelBytes = 82 << 30 }},
{name: "RAM inconnue : pas d'espacement d'office", notes: 1,
si: func(s *serveSysInfo) { s.RAMMiB = 0 }},
{name: "RAM libre trop juste pour 32 points", notes: 1,
si: func(s *serveSysInfo) { s.RAMAvailMiB = 8000 }},
{name: "CTX_CHECKPOINTS=16 passé", cfg: map[string]string{"CTX_CHECKPOINTS": "16"},
want: []string{"--ctx-checkpoints", "16", "--checkpoint-min-step", "2048"}, notes: 1},
{name: "CTX_CHECKPOINTS illisible", cfg: map[string]string{"CTX_CHECKPOINTS": "beaucoup"}, want: cms, notes: 2},
{name: "CTX_CHECKPOINTS et LLAMA_ARG_CTX_CHECKPOINTS", cfg: map[string]string{"CTX_CHECKPOINTS": "16"},
want: cms, notes: 2,
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CTX_CHECKPOINTS"] = "8" }},
{name: "CTX_CHECKPOINTS et -ctxcp d'EXTRA_ARGS", cfg: map[string]string{"CTX_CHECKPOINTS": "16"},
extra: []string{"-ctxcp", "8"}, want: cms, notes: 1},
{name: "moteur sans --ctx-checkpoints : CTX_CHECKPOINTS ignoré, dit", cfg: map[string]string{"CTX_CHECKPOINTS": "16"},
notes: 2, si: func(s *serveSysInfo) { s.Help = helpRecent }},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
si := hybridOld()
if c.si != nil {
c.si(&si)
}
got, notes := ckptArgs(c.cfg, c.extra, si)
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("ckptArgs = %v, attendu %v", got, c.want)
}
if len(notes) != c.notes {
t.Fatalf("notes = %q, attendu %d", notes, c.notes)
}
})
}
}
// Place dans la ligne complète : avant EXTRA_ARGS, qui garde le dernier mot.
func TestBuildServeArgsCkpt(t *testing.T) {
args, _, notes := buildServeArgs(map[string]string{"NGL": "999"}, []string{"--jinja"}, "/bin/llama-server", hybridOld())
if got := strings.Join(args, " "); !strings.HasSuffix(got, "--checkpoint-min-step 2048 --jinja") {
t.Fatalf("ligne = %s", got)
}
found := false
for _, n := range notes {
found = found || strings.Contains(n, "b10678")
}
if !found {
t.Fatalf("l'avis du moteur ancien manque : %q", notes)
}
}
func TestEngineBuildTrust(t *testing.T) {
cases := []struct {
src string
build int
want int
}{
{"downloaded", 10678, 10678},
{"image", 11351, 11351},
{"prebuilt", 10864, 10864},
{"custom", 10678, 0}, // compilé ou fork : numérotation inconnue
{"downloaded", 0, 0}, // --version illisible
{"image", 1, 0}, // clone superficiel
{"prebuilt", 4999, 0}, // sous le plancher
{"downloaded", 5000, 5000},
}
for _, c := range cases {
if got := engineBuildTrust(c.src, c.build); got != c.want {
t.Errorf("engineBuildTrust(%s, %d) = %d, attendu %d", c.src, c.build, got, c.want)
}
}
if engineBuildNotice(10678) == "" || engineBuildNotice(engineMinRecommended) != "" || engineBuildNotice(0) != "" {
t.Fatal("avis : seulement pour un build connu et antérieur au correctif")
}
}
func TestReasoningPreserveArgs(t *testing.T) {
cases := []struct {
name string
cfg map[string]string
extra []string
si func(*serveSysInfo)
want []string
notes int
}{
{name: "clé absente : rien, même sur un moteur qui connaît le drapeau"},
{name: "on", cfg: map[string]string{"REASONING_PRESERVE": "on"}, want: []string{"--reasoning-preserve"}},
{name: "OFF", cfg: map[string]string{"REASONING_PRESERVE": "OFF"}, want: []string{"--no-reasoning-preserve"}},
{name: "illisible", cfg: map[string]string{"REASONING_PRESERVE": "peut-être"}, notes: 1},
{name: "moteur sans le drapeau", cfg: map[string]string{"REASONING_PRESERVE": "off"}, notes: 1,
si: func(s *serveSysInfo) { s.Help = helpRecent }},
{name: "EXTRA_ARGS tranche déjà", cfg: map[string]string{"REASONING_PRESERVE": "off"},
extra: []string{"--reasoning-preserve"}},
{name: "--chat-template-kwargs le fixe", cfg: map[string]string{"REASONING_PRESERVE": "off"},
extra: []string{"--chat-template-kwargs", `{"preserve_reasoning":true}`}, notes: 1},
{name: "LLAMA_ARG_REASONING_PRESERVE posée", cfg: map[string]string{"REASONING_PRESERVE": "off"}, notes: 1,
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_REASONING_PRESERVE"] = "1" }},
{name: "LLAMA_ARG_CHAT_TEMPLATE_KWARGS le fixe", cfg: map[string]string{"REASONING_PRESERVE": "on"}, notes: 1,
si: func(s *serveSysInfo) {
s.ArgEnv["LLAMA_ARG_CHAT_TEMPLATE_KWARGS"] = `{"preserve_reasoning":false}`
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
si := serveSysInfo{Help: helpCkpt, ArgEnv: map[string]string{}}
if c.si != nil {
c.si(&si)
}
got, notes := reasoningPreserveArgs(c.cfg, c.extra, si)
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("reasoningPreserveArgs = %v, attendu %v", got, c.want)
}
if len(notes) != c.notes {
t.Fatalf("notes = %q, attendu %d", notes, c.notes)
}
})
}
}
// Après une mise à jour, la version téléchargée qui tournait reste installée :
// c'est le retour arrière sans réseau. Le moteur de l'image, lui, n'a rien à
// garder sous engine/.
func TestEngineKeepAfterUpdate(t *testing.T) {
testHome(t)
for _, tag := range []string{"server-cuda-b10500", "server-cuda-b10678", "server-cuda-b10900"} {
d := filepath.Join(engineDir(), tag)
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(d, "llama-server"), []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
}
nouveau := filepath.Join(engineDir(), "server-cuda-b10900", "llama-server")
ancien := filepath.Join(engineDir(), "server-cuda-b10678", "llama-server")
keep := engineKeepAfterUpdate(nouveau, ancien, nil)
if !reflect.DeepEqual(keep, map[string]bool{"server-cuda-b10900": true, "server-cuda-b10678": true}) {
t.Fatalf("gardés = %v", keep)
}
enginePrune(keep, nil)
if got := engineInstalled(); !reflect.DeepEqual(got, []string{"server-cuda-b10678", "server-cuda-b10900"}) {
t.Fatalf("après ménage : %v", got)
}
if keep := engineKeepAfterUpdate(nouveau, "/app/llama-server", nil); len(keep) != 1 {
t.Fatalf("moteur de l'image : %v", keep)
}
if keep := engineKeepAfterUpdate(nouveau, nouveau, nil); len(keep) != 1 {
t.Fatalf("même version : %v", keep)
}
}
+7
View File
@@ -83,7 +83,14 @@ var configTemplate = []struct{ key, help string }{
{"CACHE_RAM", "cache de prompts en RAM hôte (--cache-ram, Mio), copie exacte des conversations quittées, jamais la VRAM ; " +
"vide = auto (agrandi seulement pour un modèle tout-GPU), -1 = sans limite, 0 = coupé"},
{"CACHE_ISOLATE", "off = ne plus effacer le slot après un sous-agent, une vérification, une tâche ou un bench"},
{"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 ; " +
"vide = défaut du moteur, ou 2048 d'office sur un hybride avec un moteur officiel antérieur à b10864"},
{"REASONING", "passthrough du mode raisonnement (on/auto/deepseek)"},
{"REASONING_PRESERVE", "on/off = garder ou non la réflexion des tours passés dans le gabarit (--reasoning-preserve) ; " +
"vide = défaut du moteur (on depuis b10763). Loki ne renvoie pas cette réflexion : off rend l'ancien historique " +
"aux gabarits type Qwen3.6, mais change celui des gabarits qui la gardent d'eux-mêmes (Qwen3.8)"},
{"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"},
{"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high / xhigh"},
{"TEMP", "température d'échantillonnage ; vide = défaut du moteur"},
+16
View File
@@ -2763,6 +2763,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<div style="font-size:12px;line-height:1.5">
<div>Moteur <b>llama.cpp</b> <span id="eng-build" class="mcp-tag eng-ver"></span> <span id="eng-source" class="mcp-tag"></span></div>
<div class="muted" style="margin-top:4px"><code id="lc-provided-bin" style="word-break:break-all"></code></div>
<div id="eng-notice" class="muted" style="margin-top:6px;display:none"></div>
<div id="eng-latest" style="margin-top:6px"></div>
<div class="row" style="margin:8px 0 0">
<!-- « vérifier » ne fait QUE regarder (aucun téléchargement, aucune
@@ -2771,6 +2772,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<button onclick="engCheck()">vérifier les mises à jour</button>
<button onclick="engUpdate()">mettre à jour le moteur</button>
<button id="eng-revert" style="display:none" onclick="engUse('image')">revenir au moteur de l'image</button>
<span id="eng-others"></span>
</div>
<div class="muted" style="margin-top:6px">Télécharge llama-server et ses bibliothèques depuis l'image officielle
<code id="eng-repo"></code> (~170&nbsp;Mo), sans reconstruire l'image de Loki. Le nouveau moteur est essayé
@@ -7979,6 +7981,20 @@ async function loadEngine(){
// Le retour arrière n'a de sens que si on n'est pas déjà dessus.
const revert = document.getElementById('eng-revert');
if(revert) revert.style.display = (s.image_bin && s.source !== 'image') ? '' : 'none';
// Versions téléchargées gardées à côté (la précédente survit à une mise à
// jour) : revenir dessus ne demande ni réseau ni que le tag soit encore publié.
const others = document.getElementById('eng-others');
if(others) others.innerHTML = (s.installed || []).filter(v=>!v.in_use).map(v=>{
const tag = String(v.tag).replace(/[^A-Za-z0-9._-]/g,'');
return '<button onclick="engUse(\''+tag+'\')">utiliser '+(v.build ? 'b'+v.build : tag)+'</button>';
}).join(' ');
// Build trop ancien pour les points de reprise des hybrides (voir
// engineBuildNotice) : un avis, rien n'est changé d'office ici.
const notice = document.getElementById('eng-notice');
if(notice){
notice.textContent = s.notice ? '⚠ '+s.notice : '';
notice.style.display = s.notice ? '' : 'none';
}
engSay('');
}
+2
View File
@@ -540,6 +540,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<div style="font-size:12px;line-height:1.5">
<div>Moteur <b>llama.cpp</b> <span id="eng-build" class="mcp-tag eng-ver"></span> <span id="eng-source" class="mcp-tag"></span></div>
<div class="muted" style="margin-top:4px"><code id="lc-provided-bin" style="word-break:break-all"></code></div>
<div id="eng-notice" class="muted" style="margin-top:6px;display:none"></div>
<div id="eng-latest" style="margin-top:6px"></div>
<div class="row" style="margin:8px 0 0">
<!-- « vérifier » ne fait QUE regarder (aucun téléchargement, aucune
@@ -548,6 +549,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<button onclick="engCheck()">vérifier les mises à jour</button>
<button onclick="engUpdate()">mettre à jour le moteur</button>
<button id="eng-revert" style="display:none" onclick="engUse('image')">revenir au moteur de l'image</button>
<span id="eng-others"></span>
</div>
<div class="muted" style="margin-top:6px">Télécharge llama-server et ses bibliothèques depuis l'image officielle
<code id="eng-repo"></code> (~170&nbsp;Mo), sans reconstruire l'image de Loki. Le nouveau moteur est essayé
+14
View File
@@ -140,6 +140,20 @@ async function loadEngine(){
// Le retour arrière n'a de sens que si on n'est pas déjà dessus.
const revert = document.getElementById('eng-revert');
if(revert) revert.style.display = (s.image_bin && s.source !== 'image') ? '' : 'none';
// Versions téléchargées gardées à côté (la précédente survit à une mise à
// jour) : revenir dessus ne demande ni réseau ni que le tag soit encore publié.
const others = document.getElementById('eng-others');
if(others) others.innerHTML = (s.installed || []).filter(v=>!v.in_use).map(v=>{
const tag = String(v.tag).replace(/[^A-Za-z0-9._-]/g,'');
return '<button onclick="engUse(\''+tag+'\')">utiliser '+(v.build ? 'b'+v.build : tag)+'</button>';
}).join(' ');
// Build trop ancien pour les points de reprise des hybrides (voir
// engineBuildNotice) : un avis, rien n'est changé d'office ici.
const notice = document.getElementById('eng-notice');
if(notice){
notice.textContent = s.notice ? '⚠ '+s.notice : '';
notice.style.display = s.notice ? '' : 'none';
}
engSay('');
}
+21 -13
View File
@@ -33,16 +33,7 @@ func handleEngineStatus(w http.ResponseWriter, r *http.Request) {
bin := currentEngineBin()
build, commit := engineBuildOf(bin)
imageBin := providedEngineBin()
source := "custom"
switch {
case imageBin != "" && samePath(bin, imageBin):
source = "image"
case engineOwns(bin):
source = "downloaded"
case prebuiltOwns(bin):
source = "prebuilt"
}
source := engineSource(bin)
var installed []map[string]any
for _, tag := range engineInstalled() {
@@ -67,6 +58,9 @@ func handleEngineStatus(w http.ResponseWriter, r *http.Request) {
"dir": engineDir(),
"installed": installed,
"job": lcJobSnapshot(0, false),
// Avis « moteur trop ancien » : seulement pour un build digne de foi
// (voir engineBuildTrust), jamais pour un compilé ou un fork.
"notice": engineBuildNotice(engineBuildTrust(source, build)),
})
}
@@ -166,12 +160,26 @@ func engineRunUpdate(tag string) {
lcFail(err)
return
}
// On garde la version qu'on vient de poser ; les précédentes ne servent plus
// (le retour arrière se fait sur le moteur de l'image, qui, lui, est intact).
enginePrune(map[string]bool{filepath.Base(filepath.Dir(bin)): true}, lcAppend)
enginePrune(engineKeepAfterUpdate(bin, ancien, lcAppend), lcAppend)
lcDone("moteur mis à jour (" + tag + ")")
}
// engineKeepAfterUpdate : les versions téléchargées que le ménage épargne après
// une bascule. La nouvelle, et celle qui tournait juste avant si c'en était une
// : le moteur de l'image n'est pas forcément l'ancien build (il dépend de
// LLAMACPP_IMAGE), et revenir sur la version d'hier ne doit demander ni réseau
// ni que le tag soit encore publié. Les plus anciennes partent (~450 Mo chacune).
func engineKeepAfterUpdate(bin, ancien string, logf func(string)) map[string]bool {
keep := map[string]bool{filepath.Base(filepath.Dir(bin)): true}
if prev := engineTagOf(ancien); prev != "" && !keep[prev] {
keep[prev] = true
if logf != nil {
logf("version précédente gardée pour revenir en arrière : " + prev)
}
}
return keep
}
// engineSwitchTo pointe BIN sur un moteur et redémarre le service s'il tournait.
func engineSwitchTo(bin string) error {
wasUp := serviceIsActive()