Moteur : corrections de relecture du MTP opt-in — le contrôle après chargement voit enfin le journal, et un second lancement ne prend plus le jeton du premier

La relecture de 3a6ad6f a trouvé quatre trous, aucun ne touche la sortie du
modèle ; tous font que le garde-fou se trompe de verdict.

- Couches en RAM : lastOffload repartait de la dernière ligne contenant
  « load_model » — or llama-server écrit « load_model: initializing… » APRÈS
  le chargement, et « loading model tensors » aussi pour le brouillon. Le
  contrôle ne trouvait jamais rien, ou ne regardait que le brouillon. Repère
  désormais la ligne « loading model '<chemin>' » du serveur, et la pire des
  lignes offloaded compte (modèle ou brouillon).
- Jeton de tentative : il était consommé (et l'échec inscrit) AVANT le test du
  port. Un second « loki serve » refusé pendant que le premier chargeait
  marquait la configuration comme ratée. Lecture d'abord (specAutoPeek), puis
  rangement une fois le port libre (specAutoSettle).
- Process web : il effaçait le jeton sans le relire sous verrou, donc parfois
  celui d'un lancement plus récent. specAttemptClear ne touche qu'au sien.
- MODEL_DRAFT vers une tête EAGLE-3 ou dFlash : Loki imposait draft-simple, que
  le moteur ne peut pas charger. La note renvoie maintenant à -md et
  --spec-type dans EXTRA_ARGS.
- Une tête MTP choisie comme MODEL_DRAFT compte comme modèle utilisé (« utilisé
  par », garde à la suppression).
- Éditeur : choisir un brouillon sur un ancien preset dont le MTP est en
  --spec-type dans EXTRA_ARGS fait passer le preset à SPEC=mtp. Sans ça, le
  brouillon était ignoré.
- Tests : un journal réel (initializing après chargement, brouillon chargé à
  part), un second lancement qui ne fait que lire, le jeton d'un autre
  lancement gardé, les têtes eagle3/dflash, et MODEL_DRAFT dans les références.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 00:16:41 +02:00
1 parent 3a6ad6f23b
commit 3ff56e01db
7 files changed
+188 -53

No files matched your search

+4 -3
View File
@@ -46,10 +46,11 @@ func modelRefKey(v string) string {
} }
// modelKeysOf liste les .gguf référencés par une configuration : le modèle, le // modelKeysOf liste les .gguf référencés par une configuration : le modèle, le
// projecteur vision, et un --mmproj posé à la main dans EXTRA_ARGS (les presets // projecteur vision, le brouillon (MODEL_DRAFT : une tête MTP effacée sous le
// d'avant la clé MMPROJ l'écrivaient là). // moteur coupe la spéculation sans rien dire), et un --mmproj posé à la main
// dans EXTRA_ARGS (les presets d'avant la clé MMPROJ l'écrivaient là).
func modelKeysOf(env map[string]string) []string { func modelKeysOf(env map[string]string) []string {
vals := []string{env["MODEL"], env["MMPROJ"]} vals := []string{env["MODEL"], env["MMPROJ"], env["MODEL_DRAFT"]}
args := splitArgs(env["EXTRA_ARGS"]) args := splitArgs(env["EXTRA_ARGS"])
for i, a := range args { for i, a := range args {
if a == "--mmproj" && i+1 < len(args) { if a == "--mmproj" && i+1 < len(args) {
@@ -142,6 +142,14 @@ func TestProjecteurCompteCommeReference(t *testing.T) {
if len(users) != 1 || users[0] != "Vision" { if len(users) != 1 || users[0] != "Vision" {
t.Fatalf("--mmproj d'EXTRA_ARGS non compté : %v", users) t.Fatalf("--mmproj d'EXTRA_ARGS non compté : %v", users)
} }
// La tête MTP publiée à part aussi (MODEL_DRAFT).
writeModel(t, modelsDir(), "mtp-Qwen3.6-27B.gguf", 4)
if _, err := SavePreset("", "MTP", `MODEL="m.gguf"`+"\n"+`SPEC=auto`+"\n"+`MODEL_DRAFT="mtp-Qwen3.6-27B.gguf"`+"\n"); err != nil {
t.Fatal(err)
}
if users, _ := modelUsers("mtp-Qwen3.6-27B.gguf"); len(users) != 1 || users[0] != "MTP" {
t.Fatalf("MODEL_DRAFT= non compté : %v", users)
}
} }
// Une famille de tranches incomplète n'est pas « déjà là » : il reste à // Une famille de tranches incomplète n'est pas « déjà là » : il reste à
+9 -5
View File
@@ -354,9 +354,11 @@ func cmdServe(args []string) error {
if ggufHybrid(si.GGUF) || spec == "auto" { if ggufHybrid(si.GGUF) || spec == "auto" {
si.EngineBuild = engineBuildTrusted(bin) si.EngineBuild = engineBuildTrusted(bin)
} }
// Lu seulement ici ; rien n'est consommé avant que le port soit libre.
specMark := specAutoMark{FP: configFingerprint(cfg), Bin: bin, Build: si.EngineBuild} specMark := specAutoMark{FP: configFingerprint(cfg), Bin: bin, Build: si.EngineBuild}
specRecord := false
if spec == "auto" { if spec == "auto" {
si.SpecAutoBlocked = specAutoCheck(specMark) si.SpecAutoBlocked, specRecord = specAutoPeek(specMark)
} }
if strings.Contains(si.Help, "--slot-save-path") && !hasAnyFlag(extra, "--slot-save-path") { if strings.Contains(si.Help, "--slot-save-path") && !hasAnyFlag(extra, "--slot-save-path") {
si.SlotDir = prepareSlotDir(LokiHome()) si.SlotDir = prepareSlotDir(LokiHome())
@@ -383,10 +385,12 @@ func cmdServe(args []string) error {
// lui que le moteur accélère ou non. // lui que le moteur accélère ou non.
kt, vt, _ := effectiveKVTypes(cfg, extra, si.ArgEnv) kt, vt, _ := effectiveKVTypes(cfg, extra, si.ArgEnv)
warnSlowKV(kt, vt) warnSlowKV(kt, vt)
// Jeton de tentative : posé au dernier moment, port libre, l'ancien moteur // Jeton de tentative : l'ancien consommé (et l'échec inscrit), le nouveau
// est donc bien parti — le process web ne peut pas le confondre avec lui. // posé, au dernier moment — port libre, l'ancien moteur est donc bien parti
if _, _, auto := specArgs(cfg, extra, si); auto { // et le process web ne peut pas le confondre avec lui.
specAutoAttempt(specMark) if spec == "auto" {
_, _, auto := specArgs(cfg, extra, si)
specAutoSettle(specMark, si.SpecAutoBlocked, specRecord, auto)
} }
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n", fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
+88 -36
View File
@@ -80,6 +80,17 @@ func probeSpec(cfg map[string]string, si *serveSysInfo) {
} }
} }
// specSidecarArch : têtes de spéculation qui ne sont pas des modèles autonomes
// (llama.cpp : draft-eagle3, draft-dflash/dspark). Un -md vers elles avec
// draft-simple ne démarrerait pas : leur type se règle à la main.
func specSidecarArch(arch string) bool {
switch strings.ToLower(strings.TrimSpace(arch)) {
case "eagle3", "dflash":
return true
}
return false
}
// specMode lit SPEC : "off", "auto", "mtp", ou "" si illisible. // specMode lit SPEC : "off", "auto", "mtp", ou "" si illisible.
func specMode(cfg map[string]string) string { func specMode(cfg map[string]string) string {
switch v := strings.ToLower(strings.TrimSpace(cfg["SPEC"])); v { switch v := strings.ToLower(strings.TrimSpace(cfg["SPEC"])); v {
@@ -228,6 +239,9 @@ func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, not
} }
// Type explicite : llama.cpp ne le devine que sur la première tranche. // Type explicite : llama.cpp ne le devine que sur la première tranche.
args, mtp = []string{"-md", si.Draft, "--spec-type", "draft-mtp"}, true args, mtp = []string{"-md", si.Draft, "--spec-type", "draft-mtp"}, true
case specSidecarArch(si.DraftGGUF.Arch):
return skip("MODEL_DRAFT est une tête " + si.DraftGGUF.Arch +
", pas un modèle brouillon : règle -md et --spec-type dans EXTRA_ARGS")
default: default:
if !strings.Contains(si.Help, "--spec-type") || !strings.Contains(si.Help, "draft-simple") { if !strings.Contains(si.Help, "--spec-type") || !strings.Contains(si.Help, "draft-simple") {
return skip("ce moteur ne connaît pas --spec-type draft-simple") return skip("ce moteur ne connaît pas --spec-type draft-simple")
@@ -359,32 +373,56 @@ func specAutoVerdict(cur specAutoMark, attempt *specAutoMark, failed map[string]
return "", false return "", false
} }
// specAutoCheck lit l'état, tranche et range : jeton consommé, échec inscrit. // specAutoPeek lit l'état sans rien changer : la raison de couper l'auto (vide
func specAutoCheck(cur specAutoMark) string { // = permis) et s'il faudra inscrire l'échec. Rien n'est consommé avant que le
why := "" // port soit libre (specAutoSettle) : un second « loki serve », refusé parce que
// le premier charge encore, ne doit pas prendre le jeton de celui-ci pour un
// échec.
func specAutoPeek(cur specAutoMark) (why string, record bool) {
_ = view(bkState, func(b *bolt.Bucket) error {
attempt, failed := readSpecAuto(b)
why, record = specAutoVerdict(cur, attempt, failed)
return nil
})
return why, record
}
// specAutoSettle range, port libre, juste avant de lancer le moteur : l'ancien
// jeton est consommé, l'échec inscrit s'il y a lieu (record, why), et un
// nouveau jeton posé si ce lancement ajoute des drapeaux automatiques.
func specAutoSettle(cur specAutoMark, why string, record, attempt bool) {
_ = update(bkState, func(b *bolt.Bucket) error { _ = update(bkState, func(b *bolt.Bucket) error {
var attempt *specAutoMark _, failed := readSpecAuto(b)
if raw := b.Get([]byte(specAttemptKey)); raw != nil { _ = b.Delete([]byte(specAttemptKey))
var a specAutoMark if record {
if json.Unmarshal(raw, &a) == nil { if err := putFailed(b, failed, cur.key(), why); err != nil {
attempt = &a return err
} }
} }
failed := map[string]string{} if !attempt {
if raw := b.Get([]byte(specFailedKey)); raw != nil {
_ = json.Unmarshal(raw, &failed)
}
w, record := specAutoVerdict(cur, attempt, failed)
why = w
if attempt != nil {
_ = b.Delete([]byte(specAttemptKey))
}
if !record {
return nil return nil
} }
return putFailed(b, failed, cur.key(), w) raw, err := json.Marshal(cur)
if err != nil {
return err
}
return b.Put([]byte(specAttemptKey), raw)
}) })
return why }
// readSpecAuto : le jeton en cours (nil = aucun) et les échecs inscrits.
func readSpecAuto(b *bolt.Bucket) (attempt *specAutoMark, failed map[string]string) {
if raw := b.Get([]byte(specAttemptKey)); raw != nil {
var a specAutoMark
if json.Unmarshal(raw, &a) == nil {
attempt = &a
}
}
failed = map[string]string{}
if raw := b.Get([]byte(specFailedKey)); raw != nil {
_ = json.Unmarshal(raw, &failed)
}
return attempt, failed
} }
func putFailed(b *bolt.Bucket, failed map[string]string, key, why string) error { func putFailed(b *bolt.Bucket, failed map[string]string, key, why string) error {
@@ -399,11 +437,6 @@ func putFailed(b *bolt.Bucket, failed map[string]string, key, why string) error
return b.Put([]byte(specFailedKey), raw) return b.Put([]byte(specFailedKey), raw)
} }
// specAutoAttempt pose le jeton juste avant de lancer le moteur.
func specAutoAttempt(cur specAutoMark) {
_ = putJSON(bkState, specAttemptKey, cur)
}
// --- Côté process web -------------------------------------------------------- // --- Côté process web --------------------------------------------------------
var ( var (
@@ -431,7 +464,7 @@ func specAttemptTick() {
return return
} }
if externalActive() { if externalActive() {
_ = putBytes(bkState, specAttemptKey, nil) // plus de moteur local à attendre specAttemptClear(a, "") // plus de moteur local à attendre
return return
} }
if !healthCheck() { if !healthCheck() {
@@ -445,27 +478,38 @@ func specAttemptTick() {
why = fmt.Sprintf("seulement %d/%d couches sur GPU avec le brouillon : fit a déplacé des couches en RAM", n, m) why = fmt.Sprintf("seulement %d/%d couches sur GPU avec le brouillon : fit a déplacé des couches en RAM", n, m)
fmt.Println("[loki] SPEC=auto : " + why + " — coupé au prochain démarrage (SPEC=mtp pour l'imposer)") fmt.Println("[loki] SPEC=auto : " + why + " — coupé au prochain démarrage (SPEC=mtp pour l'imposer)")
} }
specAttemptClear(a, why)
}
// specAttemptClear efface le jeton a — et lui seul : relu sous verrou, car un
// « loki serve » a pu entre-temps poser celui d'un AUTRE lancement, qui n'a pas
// encore répondu. why non vide inscrit l'échec de a.
func specAttemptClear(a specAutoMark, why string) {
_ = update(bkState, func(b *bolt.Bucket) error { _ = update(bkState, func(b *bolt.Bucket) error {
cur, failed := readSpecAuto(b)
if cur == nil || cur.key() != a.key() {
return nil
}
_ = b.Delete([]byte(specAttemptKey)) _ = b.Delete([]byte(specAttemptKey))
if why == "" { if why == "" {
return nil return nil
} }
failed := map[string]string{}
if raw := b.Get([]byte(specFailedKey)); raw != nil {
_ = json.Unmarshal(raw, &failed)
}
return putFailed(b, failed, a.key(), why) return putFailed(b, failed, a.key(), why)
}) })
} }
// lastOffload lit « offloaded N/M layers to GPU » du DERNIER chargement du // lastOffload lit les lignes « offloaded N/M layers to GPU » du DERNIER
// journal (après la dernière ligne load_model). 0, 0 = introuvable : on ne // chargement du journal, repéré à la ligne du serveur « loading model
// '<chemin>' ». Pas à « load_model » (le serveur écrit encore « load_model:
// initializing… » APRÈS le chargement) ni à « loading model tensors » (écrit
// aussi pour le brouillon, qui masquerait le modèle). Un brouillon chargé à
// part a sa propre ligne : la pire des deux compte. 0, 0 = introuvable : on ne
// conclut rien. // conclut rien.
func lastOffload(log string) (n, m int) { func lastOffload(log string) (n, m int) {
lines := strings.Split(log, "\n") lines := strings.Split(log, "\n")
start := -1 start := -1
for i, l := range lines { for i, l := range lines {
if strings.Contains(l, "loading model") || strings.Contains(l, "load_model") { if strings.Contains(l, "loading model '") {
start = i start = i
} }
} }
@@ -473,9 +517,17 @@ func lastOffload(log string) (n, m int) {
return 0, 0 return 0, 0
} }
for _, l := range lines[start:] { for _, l := range lines[start:] {
if s := offloadedRe.FindStringSubmatch(l); s != nil { s := offloadedRe.FindStringSubmatch(l)
n, _ = strconv.Atoi(s[1]) if s == nil {
m, _ = strconv.Atoi(s[2]) continue
}
a, _ := strconv.Atoi(s[1])
b, _ := strconv.Atoi(s[2])
if m == 0 || a < b {
n, m = a, b
}
if a < b {
break
} }
} }
return n, m return n, m
+73 -9
View File
@@ -114,6 +114,18 @@ func TestSpecArgs(t *testing.T) {
s.Draft = "/models/Qwen3-0.6B.gguf" s.Draft = "/models/Qwen3-0.6B.gguf"
s.DraftGGUF = &GGUFInfo{Arch: "qwen3", BlockCount: 28} s.DraftGGUF = &GGUFInfo{Arch: "qwen3", BlockCount: 28}
}}, }},
{name: "MODEL_DRAFT tête dFlash : pas de draft-simple, réglage à la main",
cfg: with(forced, "MODEL_DRAFT", "dflash-Qwen3.6.gguf"), notes: 1,
si: func(s *serveSysInfo) {
s.Draft = "/models/dflash-Qwen3.6.gguf"
s.DraftGGUF = &GGUFInfo{Arch: "dflash", BlockCount: 5}
}},
{name: "MODEL_DRAFT tête EAGLE-3 : idem",
cfg: with(auto, "MODEL_DRAFT", "eagle3.gguf"), notes: 1,
si: func(s *serveSysInfo) {
s.Draft = "/models/eagle3.gguf"
s.DraftGGUF = &GGUFInfo{Arch: "eagle3", BlockCount: 1}
}},
{name: "MODEL_DRAFT introuvable : moteur lancé sans, pas d'erreur", {name: "MODEL_DRAFT introuvable : moteur lancé sans, pas d'erreur",
cfg: with(auto, "MODEL_DRAFT", "absent.gguf"), notes: 1, cfg: with(auto, "MODEL_DRAFT", "absent.gguf"), notes: 1,
si: func(s *serveSysInfo) { s.DraftErr = "fichier introuvable" }}, si: func(s *serveSysInfo) { s.DraftErr = "fichier introuvable" }},
@@ -223,28 +235,62 @@ func TestSpecAutoVerdict(t *testing.T) {
// pour cette combinaison seulement. // pour cette combinaison seulement.
func TestSpecAutoCheckStore(t *testing.T) { func TestSpecAutoCheckStore(t *testing.T) {
testHome(t) testHome(t)
// Ce que fait cmdServe : lire, puis — port libre — ranger et poser le jeton.
launch := func(cur specAutoMark, wantAttempt bool) string {
why, record := specAutoPeek(cur)
specAutoSettle(cur, why, record, wantAttempt && why == "")
return why
}
cur := specAutoMark{FP: "abc", Bin: "/e/llama-server", Build: 11351} cur := specAutoMark{FP: "abc", Bin: "/e/llama-server", Build: 11351}
if why := specAutoCheck(cur); why != "" { if why := launch(cur, true); why != "" {
t.Fatalf("base vide : permis, got %q", why) t.Fatalf("base vide : permis, got %q", why)
} }
specAutoAttempt(cur) // Second « loki serve » refusé (port pris) : il a lu, sans rien ranger. Le
if why := specAutoCheck(cur); why == "" { // jeton du premier, qui charge encore, doit rester intact.
if why, _ := specAutoPeek(cur); why == "" {
t.Fatal("jeton présent : le lecteur doit le voir")
}
var a specAutoMark
if !getJSON(bkState, specAttemptKey, &a) || a != cur {
t.Fatal("specAutoPeek ne doit rien consommer")
}
if why := launch(cur, true); why == "" {
t.Fatal("jeton resté : l'auto doit être coupé") t.Fatal("jeton resté : l'auto doit être coupé")
} }
if why := specAutoCheck(cur); why == "" { if getJSON(bkState, specAttemptKey, &a) {
t.Error("auto coupé : aucun nouveau jeton")
}
if why := launch(cur, true); why == "" {
t.Error("l'échec doit rester inscrit au lancement suivant") t.Error("l'échec doit rester inscrit au lancement suivant")
} }
updated := cur updated := cur
updated.Build = 11400 updated.Build = 11400
if why := specAutoCheck(updated); why != "" { if why := launch(updated, true); why != "" {
t.Errorf("moteur mis à jour : nouvel essai permis, got %q", why) t.Errorf("moteur mis à jour : nouvel essai permis, got %q", why)
} }
// Le process web efface SON jeton ; pas celui d'un lancement plus récent.
specAttemptClear(cur, "")
if !getJSON(bkState, specAttemptKey, &a) || a != updated {
t.Fatal("le jeton d'un autre lancement doit rester")
}
specAttemptClear(updated, "")
if getJSON(bkState, specAttemptKey, &a) {
t.Fatal("le moteur a répondu : jeton effacé")
}
if why := launch(updated, true); why != "" {
t.Errorf("lancement réussi : permis, got %q", why)
}
// Répondu mais couches en RAM : échec inscrit pour cette combinaison.
specAttemptClear(updated, "couches en RAM")
if why, _ := specAutoPeek(updated); why != "couches en RAM" {
t.Errorf("échec après chargement : coupé, got %q", why)
}
// Un jeton d'une autre configuration est jeté sans rien conclure. // Un jeton d'une autre configuration est jeté sans rien conclure.
specAutoAttempt(specAutoMark{FP: "zzz", Bin: "/e/llama-server", Build: 11351}) other := specAutoMark{FP: "zzz", Bin: "/e/llama-server", Build: 11351}
if why := specAutoCheck(updated); why != "" { specAutoSettle(other, "", false, true)
t.Errorf("jeton étranger : permis, got %q", why) if why := launch(cur, false); why == "" {
t.Error("échec de cur toujours inscrit")
} }
var a specAutoMark
if getJSON(bkState, specAttemptKey, &a) { if getJSON(bkState, specAttemptKey, &a) {
t.Error("le jeton étranger doit être consommé") t.Error("le jeton étranger doit être consommé")
} }
@@ -267,4 +313,22 @@ func TestLastOffload(t *testing.T) {
if n, m := lastOffload("srv load_model: loading model 'x'\nload_tensors: offloaded 50/65 layers to GPU"); n != 50 || m != 65 { if n, m := lastOffload("srv load_model: loading model 'x'\nload_tensors: offloaded 50/65 layers to GPU"); n != 50 || m != 65 {
t.Errorf("chargement partiel : got %d/%d", n, m) t.Errorf("chargement partiel : got %d/%d", n, m)
} }
// Le journal réel de llama-server : « load_model: initializing » APRÈS le
// chargement, et un brouillon chargé à part qui a ses propres lignes.
srvLog := strings.Join([]string{
"srv load_model: loading model '/models/Qwen3.6-27B.gguf'",
"load_tensors: loading model tensors, this can take a while... (load_mode = mmap)",
"load_tensors: offloaded 52/65 layers to GPU",
"load_tensors: loading model tensors, this can take a while... (load_mode = mmap)",
"load_tensors: offloaded 2/2 layers to GPU",
"srv load_model: initializing, n_slots = 1, n_ctx_slot = 65536, kv_unified = 'false'",
"main: server is listening on http://127.0.0.1:8080",
}, "\n")
if n, m := lastOffload(srvLog); n != 52 || m != 65 {
t.Errorf("modèle partiel, brouillon complet : le pire compte, got %d/%d", n, m)
}
full := strings.Replace(srvLog, "52/65", "65/65", 1)
if n, m := lastOffload(full); n != m || m == 0 {
t.Errorf("tout sur GPU : got %d/%d", n, m)
}
} }
+3
View File
@@ -6061,6 +6061,9 @@ function onPickDraft(){
cfgWriteKey('MODEL_DRAFT', v); cfgWriteKey('MODEL_DRAFT', v);
const sel = document.getElementById('s-spec'); const sel = document.getElementById('s-spec');
if(v && sel && !sel.value){ sel.value = 'auto'; onSpecType(); toast('décodage spéculatif : auto'); } if(v && sel && !sel.value){ sel.value = 'auto'; onSpecType(); toast('décodage spéculatif : auto'); }
// Un ancien preset portait le MTP en --spec-type dans EXTRA_ARGS : sans clé
// SPEC, le brouillon serait ignoré au lancement. On le range dans SPEC=mtp.
else if(v && sel && sel.value === 'draft-mtp' && specFromKey(cfgReadKey('SPEC')) !== 'draft-mtp') onSpecType();
syncSpecRow(); syncSpecRow();
} }
+3
View File
@@ -1126,6 +1126,9 @@ function onPickDraft(){
cfgWriteKey('MODEL_DRAFT', v); cfgWriteKey('MODEL_DRAFT', v);
const sel = document.getElementById('s-spec'); const sel = document.getElementById('s-spec');
if(v && sel && !sel.value){ sel.value = 'auto'; onSpecType(); toast('décodage spéculatif : auto'); } if(v && sel && !sel.value){ sel.value = 'auto'; onSpecType(); toast('décodage spéculatif : auto'); }
// Un ancien preset portait le MTP en --spec-type dans EXTRA_ARGS : sans clé
// SPEC, le brouillon serait ignoré au lancement. On le range dans SPEC=mtp.
else if(v && sel && sel.value === 'draft-mtp' && specFromKey(cfgReadKey('SPEC')) !== 'draft-mtp') onSpecType();
syncSpecRow(); syncSpecRow();
} }