mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
En --parallel 1, une vérification, un sous-agent, une tâche ou un bench prennent le slot de la discussion : son état part dans le cache RAM et en revient, ou se recalcule s'il n'y tient plus. Avec SIDE_SLOT=on (off par défaut), le moteur ouvre deux slots et la discussion garde le sien. Dimensionnement choisi pour qu'un débordement concurrent soit impossible par construction : cache KV NON unifié (--no-kv-unified) et -c 2×CTX, soit deux flux séparés de CTX jetons. Sous cache unifié, les deux slots partagent le pool et, plein, llama.cpp renvoie « Context size has been exceeded. » aux deux — que Loki prenait pour un débordement de la conversation. Chaque slot garde la fenêtre entière : ctxWindow et la compaction restent sur CTX, rien n'est raccourci. --cache-idle-slots ne vide les slots au repos que sous cache unifié : rien à régler. - lancement : refus (un slot, ligne d'avant, note au journal) si modèle + deux états dépassent 90 % de la VRAM, VRAM ou GGUF inconnus, poids sur CPU, moteur sans --no-kv-unified, PARALLEL≠2, ou -c/-np/-kvu/--no-kv-unified/ --kv-unified-per-slot/--cache-idle-slots dans EXTRA_ARGS (ou leurs LLAMA_ARG_*) ; note de VRAM en plus, avertissement si NGL imposé - routage id_slot seulement si /props annonce 2 slots d'au moins CTX jetons : 0 pour le tour, ses étapes, le préchauffage et le résumé en continuation ; 1 pour vérification, sous-agents, tâches, bench, résumé sur transcription - clients /v1 (proxy et relais) : corps réécrit en id_slot 1, quel qu'il soit - cohérence lot 1 : l'effacement de slot s'abstient (2 slots) ; une requête du slot 1 n'avance pas le numéro d'engineSlotHolds, la continuation de compaction reste possible après une vérification - « Context size has been exceeded. » sans nombre de jetons, deux slots en service : requête rejouée telle quelle, jamais compaction ni réduction - préchauffage permis sur les deux slots de SIDE_SLOT, vers le slot 0 - éditeur de preset : VRAM du second slot ou raison du refus - tests : ligne identique sans la clé, chaque conflit refuse sans changer la ligne, routage par nature, aucun id_slot sur un slot / preset externe / fenêtre partagée, rejeu sans compaction, proxy forcé, préchauffage Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
248 lines
14 KiB
Go
248 lines
14 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"},
|
||
{"SIDE_SLOT", "on = second slot pour les travaux annexes : le moteur ouvre 2 slots de CTX jetons chacun (-c 2×CTX, " +
|
||
"--parallel 2, cache KV non unifié : aucun slot ne déborde sur l'autre), la discussion garde le slot 0, vérification, " +
|
||
"sous-agents, tâches, bench, résumé sur transcription et clients /v1 passent par le 1 (id_slot) — l'état de la " +
|
||
"discussion ne bouge plus. Cache KV et état récurrent en double en VRAM : refusé si le compte ne tient pas, si des " +
|
||
"poids sont sur CPU, ou si PARALLEL≠2, -c, -np, -kvu, --cache-idle-slots… sont réglés à la main. Vide/off (défaut) = un slot"},
|
||
{"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). Sans REASONING_ECHO, 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). " +
|
||
"Avec REASONING_ECHO=on, on = préfixe stable d'un message à l'autre (mode entraîné mais optionnel selon la fiche " +
|
||
"Qwen3.6), au prix de plus de contexte par tour, donc d'une compaction plus tôt"},
|
||
{"REASONING_ECHO", "on = renvoyer au moteur local le raisonnement du modèle (reasoning_content), au format entraîné : " +
|
||
"les étapes d'une boucle d'outils se relisent avec leur réflexion au lieu de blocs vides, et le moteur ne recalcule " +
|
||
"plus le dernier message ; plus de contexte par tour, compaction plus tôt. Vide/off (défaut) = rien n'est renvoyé. " +
|
||
"Jamais vers une API externe ni vers un autre modèle ; suspendu de lui-même si le gabarit le refuse"},
|
||
{"PROJ_SNAPSHOT", "on = figer le bloc projet (description, index mémoire, trackers, AGENTS.md) par discussion et livrer " +
|
||
"ses changements en <context_update> en tête du message suivant : une page créée ou une valeur de tracker ne fait plus " +
|
||
"recalculer toute la conversation. Repris tout neuf à chaque compaction, redémarrage ou changement de modèle/projet. " +
|
||
"Vide/off (défaut) = bloc reconstruit à chaque tour ; sans effet sur un preset externe"},
|
||
{"PREWARM", "on = préparer le prochain tour pendant que tu lis : après un tour (compaction comprise) ou une tâche, " +
|
||
"Loki envoie au moteur local la requête suivante avec un message « . » et 1 jeton de réponse, jetés ; le vrai message " +
|
||
"ne calcule plus que lui-même. Annulé dès qu'une autre requête part, sauf si elle prolonge exactement ce préfixe ; " +
|
||
"jamais avec plus d'un slot (sauf les deux de SIDE_SLOT : slot de la discussion). full = aussi au changement de discussion " +
|
||
"(après 3 s). Vide/off (défaut) = aucune requête de plus"},
|
||
{"KEEP_TURN_IMAGES", "zone grise — on = garder dans l'historique les images montrées par les outils (captures, see_image), " +
|
||
"rangées par référence, au lieu de les oublier en fin de tour : le tour suivant ne recalcule plus la boucle d'outils " +
|
||
"depuis la première capture. Coût mesuré par le moteur, total borné à 10 % de la fenêtre, retirées d'abord à toute " +
|
||
"compaction (qui arrive donc plus tôt) ; vision active seulement, preset externe s'il déclare la vision (images " +
|
||
"refacturées à chaque tour). Vide/off (défaut) = images éphémères, requête inchangée"},
|
||
{"NUDGE_IN_TOOL", "zone grise — on = le rappel de budget d'outils part au bout du dernier résultat d'outil au lieu d'un " +
|
||
"message à part, sur le moteur local et seulement si la sonde de gabarit dit que le rendu bouge quand un message " +
|
||
"s'ajoute (Qwen3.5) : la boucle d'outils du tour n'est plus recalculée. Un modèle peut moins bien suivre une consigne " +
|
||
"lue dans une sortie d'outil : à comparer avant de l'adopter. Vide/off (défaut) = message à part"},
|
||
{"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)"},
|
||
{"COMPACT_CONTINUATION", "on = le résumé d'une compaction prolonge la requête du tour que le moteur local a en cache " +
|
||
"(mêmes messages, outils et réglages du gabarit, plus une demande de résumé) au lieu d'une transcription calculée à froid ; " +
|
||
"repli sur la transcription au moindre écart (autre requête passée par le slot, marge, refus, appel d'outil, résumé vide). " +
|
||
"Ne redemande pas un résumé voué au refus. Vide/off (défaut) = compaction d'avant ; sans effet sur un preset externe"},
|
||
{"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
|
||
}
|