From dd27b81af01e813911bdc7fa9104649644af3981 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sat, 3 Oct 2026 23:52:31 +0200 Subject: [PATCH] =?UTF-8?q?Moteur=20:=20points=20de=20reprise=20des=20hybr?= =?UTF-8?q?ides=20prot=C3=A9g=C3=A9s=20sur=20les=20moteurs=20anciens,=20et?= =?UTF-8?q?=20la=20version=20pr=C3=A9c=C3=A9dente=20gard=C3=A9e=20apr?= =?UTF-8?q?=C3=A8s=20une=20mise=20=C3=A0=20jour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/loki/backend_serve.go | 19 ++ internal/loki/backend_serve_ckpt.go | 305 +++++++++++++++++++++++ internal/loki/backend_serve_ckpt_test.go | 220 ++++++++++++++++ internal/loki/sys_service.go | 7 + internal/loki/ui/index.html | 16 ++ internal/loki/ui/src/index.tmpl.html | 2 + internal/loki/ui/src/js/10-llamacpp.js | 14 ++ internal/loki/web_engine.go | 34 ++- 8 files changed, 604 insertions(+), 13 deletions(-) create mode 100644 internal/loki/backend_serve_ckpt.go create mode 100644 internal/loki/backend_serve_ckpt_test.go diff --git a/internal/loki/backend_serve.go b/internal/loki/backend_serve.go index c7b3f3b..ceb74f1 100644 --- a/internal/loki/backend_serve.go +++ b/internal/loki/backend_serve.go @@ -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...) diff --git a/internal/loki/backend_serve_ckpt.go b/internal/loki/backend_serve_ckpt.go new file mode 100644 index 0000000..44f0c14 --- /dev/null +++ b/internal/loki/backend_serve_ckpt.go @@ -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= → --ctx-checkpoints n (opt-in ; jamais de défaut relevé) +// CKPT_MIN_STEP= → --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= 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 +} diff --git a/internal/loki/backend_serve_ckpt_test.go b/internal/loki/backend_serve_ckpt_test.go new file mode 100644 index 0000000..9770cdb --- /dev/null +++ b/internal/loki/backend_serve_ckpt_test.go @@ -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) + } +} diff --git a/internal/loki/sys_service.go b/internal/loki/sys_service.go index 6203118..973b022 100644 --- a/internal/loki/sys_service.go +++ b/internal/loki/sys_service.go @@ -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"}, diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 22c4419..d15216b 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -2763,6 +2763,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
Moteur llama.cpp
+