Files
Loki/internal/loki/chat_prewarm.go
T
MichaelandClaude Opus 5.5 7a57322a62 Slots : SIDE_SLOT ouvre un second slot pour les travaux annexes, en opt-in
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>
2026-10-04 10:02:40 +02:00

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)
}
}