Files
Loki/internal/loki/sys_service.go
T
MichaelandClaude Opus 5.5 3a6ad6f23b Moteur : décodage spéculatif MTP en opt-in — tête détectée dans le fichier, garde-fous VRAM, et un essai raté ne se rejoue pas
Une tête MTP (Qwen3.6/3.8, intégrée ou publiée à part en mtp-*.gguf) accélère
le décodage sans rien changer à la sortie : chaque jeton émis est tiré par
l'échantillonneur du modèle cible, un jeton du brouillon n'est gardé que s'il
coïncide. Le prix est ailleurs — 1 à 2 Go de VRAM, un prefill parfois plus
lent, une fonction très récente du moteur — d'où l'opt-in : rien ne change
pour un preset existant.

- SPEC=off (défaut, clé absente comprise) / auto / mtp. La tête se reconnaît
  au TENSEUR blk.{N-1}.nextn.eh_proj (backend_gguf.go), comme llama.cpp : la
  clé nextn_predict_layers seule ne prouve rien.
- MODEL_DRAFT : résolu comme MMPROJ, mais introuvable ne bloque pas le
  lancement. Tête à part → -md + --spec-type draft-mtp explicite (llama.cpp ne
  devine que sur la première tranche) ; autre brouillon → draft-simple, sinon
  chargé en VRAM pour rien.
- auto seulement si --fit place tout (ni couches chiffrées, -ts, -ot, experts
  sur CPU, -sm row, --fit off, -dev), contexte chiffré (fit pourrait sinon le
  réduire), pas de vision, build officiel ≥ 11009, jamais qwen4exp. mtp impose,
  avec un avertissement.
- Aide du moteur lue à chaque fois : draft-mtp et --spec-draft-n-max requis ;
  --draft/--draft-max jamais émis. EXTRA_ARGS (--spec-type, -md, -hfd,
  --spec-default…) et LLAMA_ARG_SPEC_* gardent la main.
- Tirage greedy fixé (exact pour toute chaîne). probabilistic seulement avec
  SPEC=mtp, refusé avec mirostat ou adaptive-p. SPEC_N_MAX → --spec-draft-n-max.
- Jeton de tentative : « loki serve » le pose avant de lancer, le process web
  l'efface dès que le moteur répond (sans page ouverte). Resté au lancement
  suivant, même preset, moteur et build → auto coupé et dit. Couches poussées
  en RAM après chargement : même verdict. Les erreurs MTP/brouillon du journal
  ont leur message.
- Éditeur : « auto » et « MTP » écrivent SPEC, « non » écrit SPEC=off ; champ
  Brouillon ; la recherche HF propose la tête MTP du dépôt, décochée.
- Tests : table de specArgs (gardes, sauts, brouillons, tirage), place avant
  EXTRA_ARGS, cycle du jeton sur une vraie base, lecture du journal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:09:24 +02:00

215 lines
9.9 KiB
Go

package loki
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
// sys_service.go holds the platform-neutral pieces of service management. The
// actual start/stop/restart/status/logs implementation is platform-specific:
// - sys_service_linux.go → systemd (systemctl/journalctl)
// - sys_service_darwin.go → launchd (launchctl)
// - sys_service_windows.go → PID-file background process supervisor
//
// editConfig and showVram live here because they work the same everywhere.
// preflightEngine vérifie ce sans quoi le moteur ne PEUT pas démarrer, avant de
// lancer le service. Sinon llama-server sortait en erreur, systemd le relançait
// toutes les 3 s, et `loki start` affichait un « activating » rassurant pendant
// que `loki test` répondait « /health ne répond pas ». Le diagnostic n'était
// visible que dans le journal.
func preflightEngine() error {
cfg := ReadConfig()
bin := strings.TrimSpace(cfg["BIN"])
if bin == "" {
return fmt.Errorf("BIN non défini — installe un moteur : %s (ou renseigne BIN avec %s)",
bold("loki llamacpp install"), bold("loki edit"))
}
if !filepath.IsAbs(bin) {
bin = filepath.Join(LokiHome(), bin)
}
if _, err := os.Stat(prebuiltResolveBin(bin)); err != nil {
return fmt.Errorf("moteur introuvable : %s — relance %s", bin, bold("loki llamacpp install"))
}
model := strings.TrimSpace(cfg["MODEL"])
if model == "" {
return fmt.Errorf("MODEL non défini — indique un .gguf avec %s (dossier des modèles : %s)",
bold("loki edit"), modelsDir())
}
p, err := resolveServeModelPath(model)
if err != nil {
return fmt.Errorf("MODEL=%s : %w", model, err)
}
if _, err := os.Stat(p); err != nil {
return fmt.Errorf("modèle introuvable : %s — corrige MODEL avec %s", p, bold("loki edit"))
}
// Modèle en plusieurs fichiers : une tranche manquante ne se voit qu'au moment
// où llama-server réclame un tenseur absent, dans le journal du service.
if missing := shardFamilyMissing(filepath.Dir(p), filepath.Base(p)); len(missing) > 0 {
return fmt.Errorf("modèle incomplet : il manque %s dans %s — ce modèle tient en %d fichiers, télécharge-les tous",
strings.Join(missing, ", "), filepath.Dir(p), len(shardFamily(filepath.Base(p))))
}
return nil
}
// configTemplate est le squelette commenté proposé par `loki edit` : la
// configuration vit en base, donc sur une installation neuve le fichier
// temporaire était QUASI VIDE — impossible de deviner quoi écrire. On y déroule
// donc les clés utiles avec leur rôle, les valeurs déjà définies telles quelles,
// les autres commentées.
var configTemplate = []struct{ key, help string }{
{"BIN", "chemin de llama-server (posé par « loki llamacpp install »)"},
{"MODEL", "nom de fichier .gguf ou chemin complet"},
{"HOST", "adresse d'écoute du moteur (défaut 0.0.0.0)"},
{"PORT", "port du moteur (défaut 8080)"},
{"CTX", "taille du contexte (défaut 32768)"},
{"NGL", "couches déportées sur le GPU (999 ou auto = ce qui tient en VRAM ; all = tout, quitte à saturer)"},
{"BATCH", "batch (défaut 2048)"},
{"UBATCH", "micro-batch (défaut 512)"},
{"THREADS", "threads CPU ; vide ou 0 = auto (cœurs physiques ; cœurs P seulement sur Intel hybride Linux)"},
{"THREADS_BATCH", "threads CPU du prefill et de la vérification spéculative (MTP) ; vide ou 0 = comme THREADS"},
{"FIT_TARGET", "Mio laissés libres par carte par le placement auto (--fit-target), ex. 1024,3072 dans l'ordre des cartes ; " +
"vide = 1024, minimum 1024 ; sans effet si NGL chiffré, --tensor-split, -ot ou --n-cpu-moe"},
{"OP_OFFLOAD_MIN_BATCH", "experts MoE sur CPU : taille de lot à partir de laquelle ils sont recopiés vers le GPU ; " +
"vide = défaut du moteur (32) ; plus haut = lots courts sur CPU, à mesurer"},
{"CUDA_GRAPH_OPT", "on = GGML_CUDA_GRAPH_OPT=1, branches Q/K/V en parallèle au décodage (expérimental, " +
"sortie identique, gain de 0 à quelques %) ; off puis redémarrage s'il plante"},
{"KV_TYPE", "quantization du cache KV ; vide = f16, sorties de référence ; q8_0 les modifie légèrement, " +
"q4_0 avec perte mesurable ; KV_TYPE_K / KV_TYPE_V pour les séparer"},
{"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"},
{"SPEC", "décodage spéculatif, sortie inchangée : off (défaut) / auto = tête MTP du modèle ou MODEL_DRAFT si --fit place " +
"tout et qu'aucun essai n'a échoué / mtp = imposé ; ~1-2 Go de VRAM en plus"},
{"MODEL_DRAFT", "tête MTP publiée à part (mtp-*.gguf) ou petit modèle brouillon, nom ou chemin comme MODEL ; utilisé si SPEC≠off"},
{"SPEC_N_MAX", "jetons anticipés par étape (--spec-draft-n-max) ; vide = défaut du moteur (3)"},
{"SPEC_SAMPLING", "tirage du brouillon : greedy (défaut, exact) ; probabilistic seulement avec SPEC=mtp"},
{"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"},
{"TOP_P", "noyau de probabilité (top_p) ; vide = défaut du moteur"},
{"TOP_K", "top_k ; 0 = désactivé, vide = défaut du moteur"},
{"MIN_P", "seuil de probabilité (min_p) ; vide = défaut du moteur"},
{"PRESENCE_PENALTY", "pénalité de présence ; vide = défaut du moteur"},
{"REPEAT_PENALTY", "pénalité de répétition (1 = neutre) ; vide = défaut du moteur"},
{"COMPACT", "compactage automatique du contexte (off pour couper)"},
{"MEM_MODE", "mémoire de l'IA : off / ondemand / always"},
{"EXTRA_ARGS", "ajouté tel quel à la ligne de commande de llama-server"},
}
// configEditorText rend la configuration au format présenté dans $EDITOR.
func configEditorText(cfg map[string]string) string {
var b strings.Builder
b.WriteString("# Configuration du moteur Loki (loki-engine).\n")
b.WriteString("# Une clé par ligne : CLE=valeur. Les lignes commentées (#) sont ignorées :\n")
b.WriteString("# décommente celles dont tu as besoin. « loki restart » applique.\n\n")
seen := map[string]bool{}
for _, f := range configTemplate {
seen[f.key] = true
fmt.Fprintf(&b, "# %s\n", f.help)
if v, ok := cfg[f.key]; ok && v != "" {
fmt.Fprintf(&b, "%s=%s\n\n", f.key, quoteValue(v))
} else {
fmt.Fprintf(&b, "#%s=\n\n", f.key)
}
}
// Tout ce que le squelette ne connaît pas (clés d'une version plus récente,
// réglages posés par l'UI) : conservé tel quel, en fin de fichier.
rest := map[string]string{}
for k, v := range cfg {
if !seen[k] {
rest[k] = v
}
}
if len(rest) > 0 {
b.WriteString("# --- autres clés déjà définies ---\n")
b.WriteString(formatEnv(rest))
}
return b.String()
}
// editConfig ouvre la configuration dans $EDITOR. La configuration vit en base
// (voir store.go) : on la déroule dans un fichier temporaire au format clé=valeur,
// on laisse l'éditeur faire son travail, puis on relit. Le contenu n'est réécrit
// que si l'éditeur sort proprement — un éditeur avorté ne doit rien effacer.
func editConfig() error {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = defaultEditor()
}
tmp, err := os.CreateTemp("", "loki-config-*.env")
if err != nil {
return err
}
path := tmp.Name()
defer os.Remove(path)
if _, err := tmp.WriteString(configEditorText(ReadConfig())); err != nil {
tmp.Close()
return err
}
tmp.Close()
cmd := exec.Command(editor, path)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
if err := cmd.Run(); err != nil {
return err
}
b, err := os.ReadFile(path)
if err != nil {
return err
}
if err := WriteConfig(parseEnv(string(b))); err != nil {
return err
}
fmt.Println(dim("[info] loki restart pour appliquer"))
return nil
}
// showVram parses `nvidia-smi --query-gpu=...` and renders a colored bar.
// nvidia-smi is available on both Linux and Windows when an NVIDIA driver is
// installed, so this is platform-neutral.
func showVram() error {
out, err := hideCmd(exec.Command("nvidia-smi",
"--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu",
"--format=csv,noheader,nounits")).Output()
if err != nil {
return fmt.Errorf("nvidia-smi indisponible: %w", err)
}
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
parts := strings.Split(line, ",")
if len(parts) != 5 {
continue
}
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
name := parts[0]
used, _ := strconv.Atoi(parts[1])
total, _ := strconv.Atoi(parts[2])
util, _ := strconv.Atoi(parts[3])
temp, _ := strconv.Atoi(parts[4])
pct := 0
if total > 0 {
pct = used * 100 / total
}
full := pct / 5
bar := strings.Repeat("█", full) + strings.Repeat("░", 20-full)
fmt.Printf("\n %s\n", cyan(name))
fmt.Printf(" VRAM %s %3d%% %.1f / %.1f GiB\n", green(bar), pct, float64(used)/1024, float64(total)/1024)
fmt.Printf(" GPU %3d%% Temp %d°C\n\n", util, temp)
}
return nil
}