mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-14 18:58:43 +02:00
Les gabarits Qwen3.5/3.6 rendent <think>…</think> pour chaque message assistant après la dernière question : c'est leur format entraîné. Loki ne gardait que le texte et les appels, donc à chaque étape d'une boucle d'outils le modèle relisait ses étapes précédentes avec des blocs vides, et le moteur recalculait le dernier message. Nouvelle clé REASONING_ECHO (off par défaut) : avec on, le raisonnement séparé par le serveur est gardé avec chaque message et renvoyé au même modèle. Sans la clé, rien n'est capturé ni envoyé : la requête est identique à l'octet près (testé). - Message.ReasoningContent (reasoning_content) + ReasoningModel, persistés ; seulement depuis reasoning_content du flux, jamais depuis le découpage « </think> » fait chez nous (rendu en double) - un seul point de sortie (echoMessages) : étiquette toujours retirée, raisonnement retiré pour une API externe, un autre modèle, un message sans texte ni appel, ou si la sonde dit que le gabarit ne le rend jamais - comptage : estimateTokens/compactBounds comptent ce qui part et que le gabarit rend (passé seulement si la sonde dit qu'il le garde, ou l'ignore) ; ctxAfter ne retire plus un raisonnement renvoyé - débordement : relance d'abord sans raisonnement, avant toute compaction ; shrinkToFit retire le raisonnement le plus ancien avant tout le reste ; le torse compacté le perd comme le résumé - erreur de gabarit (raise_exception, thinking, Failed to parse messages) : relance une fois sans raisonnement avant tool_choice none / outils coupés ; retenu pour le modèle si la relance passe - runBuilderTurn applique enfin NewHistory comme generate (compaction perdue pendant une passe de correction) ; forwardStream affiche la bannière - sonde de gabarit : nouveau verdict renders_reasoning - REASONING_PRESERVE (lot 1) inchangée, son interaction documentée Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
333 lines
14 KiB
Go
333 lines
14 KiB
Go
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
|
|
}
|
|
|
|
// ckptSlots : nombre de slots que le moteur ouvrira, chacun avec sa propre liste
|
|
// de points de reprise. --parallel d'EXTRA_ARGS, sinon PARALLEL, sinon 1 (Loki
|
|
// pose toujours --parallel, voir buildServeArgs). Illisible ou « auto » (-1) :
|
|
// 4, ce que choisissent les moteurs récents — on compte large.
|
|
func ckptSlots(cfg map[string]string, extra []string) int {
|
|
v := flagValue(extra, "-np", "--parallel")
|
|
if v == "" {
|
|
v = strings.TrimSpace(cfg["PARALLEL"])
|
|
}
|
|
if v == "" {
|
|
return 1
|
|
}
|
|
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 {
|
|
return n
|
|
}
|
|
return 4
|
|
}
|
|
|
|
// 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).")
|
|
}
|
|
// La liste est PAR SLOT : avec PARALLEL=4, quatre listes se remplissent.
|
|
total := n * ckptSlots(cfg, extra)
|
|
worst := int64(total) * 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, total, 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 sans REASONING_ECHO, 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é.
|
|
//
|
|
// Avec REASONING_ECHO=on (llm_reasoning_echo.go), les deux clés se complètent
|
|
// sans se recouvrir : l'écho fournit le raisonnement, cette clé décide si le
|
|
// gabarit le garde au-delà du tour en cours. « on » donne alors un préfixe
|
|
// stable d'un message utilisateur à l'autre, au prix de plus de contexte ; le
|
|
// comptage de Loki suit le verdict de la sonde de gabarit (preservesHistory),
|
|
// pas cette clé, car un gabarit sans le réglage (Qwen3.5) l'ignore.
|
|
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
|
|
}
|