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>
448 lines
15 KiB
Go
448 lines
15 KiB
Go
package loki
|
|
|
|
// chat_prewarm.go — PREWARM : préparer le prochain tour pendant que
|
|
// l'utilisateur lit.
|
|
//
|
|
// Pourquoi. Certains recalculs sont inévitables : le prompt réécrit par une
|
|
// compaction (12 à 20k jetons), le dernier message assistant rendu autrement
|
|
// au tour suivant (réflexion retirée par le gabarit, raisonnement jamais
|
|
// renvoyé), la discussion reprise après une tâche planifiée qui a pris le slot.
|
|
// Sans la clé, ils tombent sur le premier jeton du message suivant : des
|
|
// secondes sur un 27B, des dizaines sur un MoE aux experts en RAM.
|
|
//
|
|
// Avec la clé (off par défaut), Loki envoie au moteur local, dès qu'il est
|
|
// libre, la requête du prochain tour telle qu'elle partira — assemblée par
|
|
// les MÊMES fonctions (turnViewDry, wireMessages, buildChatPayload), contenu
|
|
// vivant, rien de figé ni de mémorisé pour gagner du cache — suivie d'un
|
|
// message utilisateur sentinelle « . », avec max_tokens 1. Le moteur calcule
|
|
// tout le préfixe et pose un point de reprise au début de ce dernier message :
|
|
// le vrai tour ne calcule plus que son propre message. Le jeton produit et la
|
|
// sentinelle sont jetés ; rien n'est persisté, ni dans la discussion, ni dans
|
|
// le journal, ni dans les compteurs (CtxUsed, stats, effort appris).
|
|
//
|
|
// Ce que le modèle voit ne change pas : llama-server ne reprend un état que sur
|
|
// un préfixe de jetons identique, et la vraie requête est construite à l'envoi,
|
|
// comme toujours. Au pire, le préchauffage ne sert à rien.
|
|
//
|
|
// Il ne passe jamais devant un vrai travail :
|
|
// - moteur local seulement, un seul slot (/props total_slots = 1 : ni
|
|
// PARALLEL ni -np d'EXTRA_ARGS ne peuvent le contredire) — ou les deux de
|
|
// SIDE_SLOT en service, et il vise alors le slot de la discussion
|
|
// (id_slot 0, llm_sideslot.go) ;
|
|
// - jamais pendant un tour, une tâche, une compaction, un bench, un travail
|
|
// annexe, ni s'il reste une requête de Loki en vol (compteur de
|
|
// llm_slots.go, qu'il tient lui-même : l'isolation des slots n'efface rien
|
|
// pendant qu'il tourne) ;
|
|
// - toute requête de Loki vers le moteur l'annule à l'instant de partir —
|
|
// sauf un tour de chat dont la requête PROLONGE exactement le préfixe
|
|
// préchauffé (empreintes des messages sérialisés, des outils et des
|
|
// arguments du gabarit) : elle suit alors dans le même slot et réutilise
|
|
// ce qui vient d'être calculé, sans rien attendre d'inutile ;
|
|
// - un seul en vol ; un nouveau déclencheur annule l'ancien ;
|
|
// - aucune erreur ne remonte : 400 (contexte plein), 503 (chargement), 500
|
|
// (gabarit) sont lâchés, sans compaction, sans relance, sans rien apprendre.
|
|
//
|
|
// Déclencheurs : fin d'un tour (compaction de fin de tour comprise) quand rien
|
|
// n'attend dans la file, fin d'une tâche planifiée (slot déjà effacé, projet
|
|
// forcé déjà levé). PREWARM=full ajoute le changement de discussion, après 3 s
|
|
// sans nouveau changement. Les capacités sont celles du dernier tour de la
|
|
// discussion, gardées en mémoire ; en mode Code, le rôle est celui d'un message
|
|
// ordinaire (builder) — une demande de plan diverge et annule.
|
|
//
|
|
// Limites, sans effet sur la fidélité : un moteur sans point de reprise au
|
|
// début des messages utilisateur, ou des images juste avant (le moteur n'en
|
|
// pose pas après un bloc d'image), retombe sur un recalcul complet et exact ;
|
|
// changer agent, web ou mémoire avant d'envoyer rend le préchauffage inutile.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"hash/fnv"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// prewarmMode : « on », « full » ou "" (off). Jamais pour un preset externe.
|
|
func prewarmMode(cfg map[string]string) string {
|
|
if isExternalConfig(cfg) {
|
|
return ""
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(cfg["PREWARM"])) {
|
|
case "on", "1", "true", "yes", "oui":
|
|
return "on"
|
|
case "full":
|
|
return "full"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Déclencheurs (pour le journal).
|
|
const (
|
|
prewarmTurnEnd = "fin-tour"
|
|
prewarmAfterTask = "après-tâche"
|
|
prewarmSwitch = "discussion"
|
|
)
|
|
|
|
// prewarmSentinel : le message utilisateur qui tient la place du vrai.
|
|
const prewarmSentinel = "."
|
|
|
|
// prewarmRun : un préchauffage en vol. prefix couvre ses n premiers messages
|
|
// (sans la sentinelle), opts les champs qui façonnent le rendu.
|
|
type prewarmRun struct {
|
|
n int
|
|
prefix uint64
|
|
opts uint64
|
|
cancel context.CancelFunc
|
|
done chan struct{}
|
|
}
|
|
|
|
// prewarmCur : le préchauffage en vol, protégé par engineGate.mu (llm_slots.go).
|
|
// prewarmLive le reflète sans verrou : sans préchauffage, une requête ne
|
|
// calcule aucune empreinte.
|
|
var (
|
|
prewarmCur *prewarmRun
|
|
prewarmLive atomic.Bool
|
|
)
|
|
|
|
// prewarmDropLocked annule p, engineGate.mu tenu. Le client ferme la
|
|
// connexion, et llama-server arrête la tâche (au plus un micro-lot plus tard).
|
|
func prewarmDropLocked(p *prewarmRun) {
|
|
if prewarmCur == p {
|
|
prewarmCur = nil
|
|
prewarmLive.Store(false)
|
|
}
|
|
p.cancel()
|
|
}
|
|
|
|
// prewarmBegin inscrit p comme LA requête en vol, si le moteur est libre de
|
|
// toute autre requête de Loki. end le retire (une seule fois).
|
|
func prewarmBegin(p *prewarmRun) (end func(), ok bool) {
|
|
engineGate.mu.Lock()
|
|
defer engineGate.mu.Unlock()
|
|
if engineGate.inflight > 0 || prewarmCur != nil {
|
|
return nil, false
|
|
}
|
|
prewarmCur = p
|
|
prewarmLive.Store(true)
|
|
engineGate.inflight++
|
|
engineGate.seq++ // le slot ne porte plus le tour d'avant (engineSlotHolds)
|
|
var once sync.Once
|
|
return func() {
|
|
once.Do(func() {
|
|
engineGate.mu.Lock()
|
|
engineGate.inflight--
|
|
if prewarmCur == p {
|
|
prewarmCur = nil
|
|
prewarmLive.Store(false)
|
|
}
|
|
engineGate.mu.Unlock()
|
|
})
|
|
}, true
|
|
}
|
|
|
|
// prewarmStop annule le préchauffage en vol et rend celui-ci (nil sinon), pour
|
|
// qu'on puisse attendre sa fin.
|
|
func prewarmStop() *prewarmRun {
|
|
// Cas de toujours : rien en vol, et pas de verrou à prendre (l'effacement
|
|
// d'un slot peut le tenir deux secondes).
|
|
if !prewarmLive.Load() {
|
|
return nil
|
|
}
|
|
engineGate.mu.Lock()
|
|
defer engineGate.mu.Unlock()
|
|
p := prewarmCur
|
|
if p != nil {
|
|
prewarmDropLocked(p)
|
|
}
|
|
return p
|
|
}
|
|
|
|
// matches : la requête dont prefix est l'empreinte cumulée des messages
|
|
// (perfPrefix) prolonge-t-elle exactement ce préchauffage ?
|
|
func (p *prewarmRun) matches(prefix []uint64, opts uint64) bool {
|
|
return p.n > 0 && len(prefix) > p.n && prefix[p.n-1] == p.prefix && opts == p.opts
|
|
}
|
|
|
|
// prewarmOptsKeys : les champs du corps, hors messages, qui changent le prompt
|
|
// rendu par le gabarit. L'échantillonnage n'y est pas : il ne touche pas au
|
|
// prompt.
|
|
var prewarmOptsKeys = []string{"tools", "parallel_tool_calls", "tool_choice", "chat_template_kwargs", "reasoning_effort"}
|
|
|
|
func prewarmOptsHash(payload map[string]any) uint64 {
|
|
h := fnv.New64a()
|
|
for _, k := range prewarmOptsKeys {
|
|
h.Write([]byte(k))
|
|
h.Write([]byte{0})
|
|
if v, ok := payload[k]; ok {
|
|
b, _ := json.Marshal(v)
|
|
h.Write(b)
|
|
}
|
|
h.Write([]byte{0})
|
|
}
|
|
return h.Sum64()
|
|
}
|
|
|
|
// prewarmKeeper : pour runChat, ce qui reconnaît un préchauffage à garder. nil
|
|
// (tout annuler) hors tour de chat, ou sans préchauffage en vol — dans ce cas,
|
|
// le cas de toujours, rien n'est calculé.
|
|
func prewarmKeeper(kind string, sent []Message, payload map[string]any) func(*prewarmRun) bool {
|
|
if kind != perfMain || !prewarmLive.Load() {
|
|
return nil
|
|
}
|
|
prefix, opts := perfPrefix(sent), prewarmOptsHash(payload)
|
|
return func(p *prewarmRun) bool { return p.matches(prefix, opts) }
|
|
}
|
|
|
|
// Capacités du dernier tour de chaque discussion, en mémoire seulement : le
|
|
// client les envoie avec chaque message, le serveur ne les connaît pas sinon.
|
|
var prewarmCaps struct {
|
|
mu sync.Mutex
|
|
m map[string]Caps
|
|
}
|
|
|
|
const prewarmCapsMax = 64
|
|
|
|
func prewarmNoteCaps(convID string, caps Caps) {
|
|
if convID == "" {
|
|
return
|
|
}
|
|
prewarmCaps.mu.Lock()
|
|
defer prewarmCaps.mu.Unlock()
|
|
if prewarmCaps.m == nil {
|
|
prewarmCaps.m = map[string]Caps{}
|
|
}
|
|
if _, ok := prewarmCaps.m[convID]; !ok && len(prewarmCaps.m) >= prewarmCapsMax {
|
|
for k := range prewarmCaps.m {
|
|
delete(prewarmCaps.m, k)
|
|
break
|
|
}
|
|
}
|
|
prewarmCaps.m[convID] = caps
|
|
}
|
|
|
|
func prewarmCapsFor(convID string) (Caps, bool) {
|
|
prewarmCaps.mu.Lock()
|
|
defer prewarmCaps.mu.Unlock()
|
|
c, ok := prewarmCaps.m[convID]
|
|
return c, ok
|
|
}
|
|
|
|
// prewarmNearMidnight : la date du jour fait partie du prompt système ; à
|
|
// moins de 10 min de minuit, le prochain message a toutes les chances de
|
|
// partir avec la suivante.
|
|
func prewarmNearMidnight(now time.Time) bool {
|
|
y, m, d := now.Date()
|
|
next := time.Date(y, m, d+1, 0, 0, 0, 0, now.Location())
|
|
return next.Sub(now) < 10*time.Minute
|
|
}
|
|
|
|
// Points d'accroche remplaçables par les tests.
|
|
var (
|
|
// prewarmAsync : false = le préchauffage tourne dans l'appelant (tests).
|
|
prewarmAsync = true
|
|
// prewarmSlots : nombre de slots du moteur local (/props total_slots).
|
|
prewarmSlots = func(ep chatEndpoint) int {
|
|
var props struct {
|
|
TotalSlots int `json:"total_slots"`
|
|
}
|
|
iso := slotIsolator{base: fmt.Sprintf("http://localhost:%d", LLMPort()), auth: ep.auth, client: http.DefaultClient}
|
|
if err := iso.get(context.Background(), "/props", &props); err != nil {
|
|
return 0
|
|
}
|
|
return props.TotalSlots
|
|
}
|
|
prewarmSwitchDelay = 3 * time.Second
|
|
// prewarmTimeout : borne d'un préchauffage (un MoE aux experts en RAM met
|
|
// des minutes sur 20k jetons). Toute vraie requête l'annule bien avant.
|
|
prewarmTimeout = 15 * time.Minute
|
|
)
|
|
|
|
// prewarmKick : un déclencheur. Sans la clé, rien — ni goroutine, ni requête.
|
|
func (c *Conversation) prewarmKick(why string) {
|
|
if prewarmMode(ReadConfig()) == "" {
|
|
return
|
|
}
|
|
if prewarmAsync {
|
|
go c.prewarm(why)
|
|
return
|
|
}
|
|
c.prewarm(why)
|
|
}
|
|
|
|
// prewarmAfterSwitch : changement de discussion. Le préchauffage de l'ancienne
|
|
// est annulé ; avec PREWARM=full, la nouvelle est préparée si l'utilisateur y
|
|
// reste 3 s (cliquer d'une discussion à l'autre ne lance rien).
|
|
func prewarmAfterSwitch() {
|
|
prewarmStop() // sa fin n'est pas attendue : le changement ne doit pas bloquer
|
|
if prewarmMode(ReadConfig()) != "full" {
|
|
return
|
|
}
|
|
id := convActiveID()
|
|
time.AfterFunc(prewarmSwitchDelay, func() {
|
|
if convActiveID() == id {
|
|
conv.prewarmKick(prewarmSwitch)
|
|
}
|
|
})
|
|
}
|
|
|
|
// prewarmBodyFor construit le corps du préchauffage de c, ou "" avec la raison
|
|
// de s'abstenir. Lecture seule : rien n'est modifié dans la discussion.
|
|
func (c *Conversation) prewarmBodyFor(ep chatEndpoint, cfg map[string]string) (payload map[string]any, run *prewarmRun, skip string) {
|
|
caps, ok := prewarmCapsFor(convActiveID())
|
|
if !ok {
|
|
return nil, nil, "capacités du dernier tour inconnues"
|
|
}
|
|
caps = withTurnRole(caps, prewarmSentinel)
|
|
|
|
c.mu.Lock()
|
|
if c.Generating {
|
|
c.mu.Unlock()
|
|
return nil, nil, "moteur occupé"
|
|
}
|
|
msgs := append([]Message(nil), c.Messages...)
|
|
ctxNow := c.ctxNowLocked(msgs, true)
|
|
peak := c.genPeak
|
|
c.mu.Unlock()
|
|
|
|
hasUser := false
|
|
for _, m := range msgs {
|
|
if m.Role == "user" {
|
|
hasUser = true
|
|
break
|
|
}
|
|
}
|
|
// Sans message utilisateur dans l'historique, le contexte projet irait en
|
|
// tête de la sentinelle (normalizeSystemMessages) : rien à préparer.
|
|
if !hasUser {
|
|
return nil, nil, "discussion vide"
|
|
}
|
|
msgs = append(msgs, Message{Role: "user", Content: prewarmSentinel})
|
|
// Le prochain tour compactera d'abord : son prompt ne sera pas celui-ci.
|
|
if compactNeeded(msgs, ctxNow, peak) {
|
|
return nil, nil, "compaction attendue au prochain tour"
|
|
}
|
|
|
|
sent, tools := c.turnViewDry(caps, msgs, caps.Agent)
|
|
_, effort, _, kwargs := turnReasoning(cfg)
|
|
wire, _ := wireMessages(sent, false, turnEchoPolicy(false))
|
|
n := len(wire)
|
|
if n < 2 || wire[n-1].Role != "user" {
|
|
return nil, nil, "assemblage inattendu"
|
|
}
|
|
if s, _ := wire[n-1].Content.(string); s != prewarmSentinel {
|
|
return nil, nil, "assemblage inattendu"
|
|
}
|
|
payload = buildChatPayload(ep, wire, 0.7, chatPayloadOpts{tools: tools, effort: effort, kwargs: kwargs})
|
|
// Seuls les champs de réponse changent : une réponse d'un seul jeton, non
|
|
// streamée (jetée de toute façon).
|
|
payload["stream"] = false
|
|
delete(payload, "stream_options")
|
|
payload["max_tokens"] = 1
|
|
return payload, &prewarmRun{n: n - 1, prefix: perfPrefix(wire)[n-2], opts: prewarmOptsHash(payload)}, ""
|
|
}
|
|
|
|
var prewarmLogged sync.Map // raison d'échec → déjà journalisée
|
|
|
|
func prewarmLogOnce(key, format string, args ...any) {
|
|
if _, dup := prewarmLogged.LoadOrStore(key, true); dup {
|
|
return
|
|
}
|
|
fmt.Fprintf(os.Stderr, "[prewarm] "+format+"\n", args...)
|
|
}
|
|
|
|
// prewarm : un préchauffage complet, ou rien. Jamais d'erreur rendue.
|
|
func (c *Conversation) prewarm(why string) {
|
|
cfg := ReadConfig()
|
|
if prewarmMode(cfg) == "" {
|
|
return
|
|
}
|
|
ep := resolveChatEndpoint()
|
|
if ep.External {
|
|
return
|
|
}
|
|
// Un seul en vol : le nouveau déclencheur remplace l'ancien.
|
|
if p := prewarmStop(); p != nil {
|
|
select {
|
|
case <-p.done:
|
|
case <-time.After(5 * time.Second):
|
|
return
|
|
}
|
|
}
|
|
if sideJobs.Load() > 0 || benchRunning() || prewarmNearMidnight(time.Now()) {
|
|
return
|
|
}
|
|
if !healthCheck() {
|
|
return
|
|
}
|
|
// Un seul slot, ou les deux de SIDE_SLOT : le préchauffage vise alors le
|
|
// slot de la discussion (id_slot 0), celui où partira le vrai tour.
|
|
slot := -1
|
|
if n := prewarmSlots(ep); n != 1 {
|
|
if slot = engineSlotFor(ep, true); n != 2 || slot != 0 {
|
|
return
|
|
}
|
|
}
|
|
payload, run, skip := c.prewarmBodyFor(ep, cfg)
|
|
if skip != "" {
|
|
return
|
|
}
|
|
setEngineSlot(payload, slot)
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), prewarmTimeout)
|
|
defer cancel()
|
|
run.cancel, run.done = cancel, make(chan struct{})
|
|
defer close(run.done)
|
|
end, ok := prewarmBegin(run)
|
|
if !ok {
|
|
return // une requête de Loki est partie entre-temps
|
|
}
|
|
defer end()
|
|
ctx = withPerf(ctx, perfPrewarm, convActiveID())
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ep.URL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
ep.auth(req.Header.Set)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
if ctx.Err() == nil {
|
|
prewarmLogOnce("net", "moteur injoignable (%s) : %v", why, err)
|
|
}
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
return
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
prewarmLogOnce(resp.Status, "refusé par le moteur (%s) : %s %s", why, resp.Status, strings.TrimSpace(string(raw[:min(len(raw), 200)])))
|
|
return
|
|
}
|
|
// Télémétrie seulement (kind=prewarm) : la réponse est jetée.
|
|
rec := perfRecord(perfRecFromWire(perfTagOf(ctx), decodePerfWire(raw)), nil)
|
|
// Après une tâche, c'est lui qui retrouve (ou non) la conversation dans le
|
|
// cache RAM : la vérification de llm_slots.go se fait donc ici — le vrai
|
|
// tour, servi depuis le slot préchauffé, n'aurait plus rien à en dire. Le
|
|
// conseil va au journal (aucun fil où l'afficher).
|
|
if rec.Total > 0 {
|
|
cached := 0
|
|
if rec.Cached != nil {
|
|
cached = *rec.Cached
|
|
}
|
|
noteEnginePrompt(rec.Total, cached, rec.Cached != nil)
|
|
}
|
|
}
|