mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Cache : PREWARM prépare le prochain tour pendant que l'utilisateur lit, en opt-in
Une compaction, un dernier message rendu autrement au tour suivant ou une tâche planifiée qui a pris le slot laissent au message suivant un recalcul inévitable : des secondes sur un 27B, des dizaines sur un MoE aux experts en RAM, avant le premier mot. Nouvelle clé PREWARM (off par défaut) : dès que le moteur local est libre, Loki lui envoie la requête du prochain tour suivie d'un message utilisateur « . », max_tokens 1, sans flux. Le moteur calcule le préfixe et pose un point de reprise au début de ce message ; le vrai tour ne calcule plus que le sien. Sans la clé, aucune requête de plus (testé). - assemblage partagé, contenu vivant : turnViewDry (même code que turnView, sans rien toucher à la discussion, décision PROJ_SNAPSHOT via projSnapDecide), wireMessages, turnReasoning et buildChatPayload, extraits de runChat ; corps identique à l'octet près à celui d'avant (matrice d'options et de modes, testée contre une copie de l'ancien code) - jamais devant un vrai travail : toute requête de Loki l'annule au départ (compteur de llm_slots.go, sous son verrou), sauf un tour de chat dont les messages sérialisés, outils et arguments du gabarit prolongent exactement le préfixe préchauffé - moteur local, un seul slot (/props total_slots), pas pendant un tour, une tâche, un bench, un travail annexe ni une requête en vol ; un seul à la fois ; pas si le prochain tour compactera, ni à moins de 10 min de minuit - requête brute : ni compaction, ni relance, ni effort appris, ni CtxUsed, ni stats ; erreurs lâchées (une ligne de journal par statut) ; rien persisté - déclencheurs : fin de tour sans file d'attente, fin de tâche (projet forcé levé, slot effacé) ; full = aussi changement de discussion après 3 s - capacités du dernier tour gardées en mémoire par discussion ; mode Code = rôle d'un message ordinaire, une demande de plan diverge et annule - télémétrie kind=prewarm ; la vérification du cache RAM après une tâche se fait sur lui Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4932999d5c
commit
630e84cc08
14 files changed
+1186
-86
No files matched your search
@@ -387,6 +387,24 @@ Ajoutées par ce fork :
|
||||
tâches planifiées gardent le bloc à jour à chaque tour ; sans agent, rien du
|
||||
projet n'est envoyé ; un preset externe (API) n'est jamais concerné. Le titre, l'export JSON, le résumé de compaction et la
|
||||
passe de vérification ne voient pas ces blocs.
|
||||
- **Préchauffage du prochain tour** (clé `PREWARM`, **off** par défaut,
|
||||
`loki config set PREWARM on`) : certains recalculs sont inévitables — prompt
|
||||
réécrit par une compaction, dernier message rendu autrement au tour suivant,
|
||||
discussion reprise après une tâche planifiée qui a pris le slot — et sans la
|
||||
clé ils retardent le premier mot du message suivant. Avec `on`, dès que le
|
||||
moteur local est libre après un tour ou une tâche, Loki lui envoie la requête
|
||||
du prochain tour, assemblée par les mêmes fonctions que la vraie (contenu
|
||||
vivant, rien de figé), suivie d'un message `.` et limitée à 1 jeton : le
|
||||
moteur calcule le préfixe pendant que tu lis, et le vrai message ne calcule
|
||||
plus que lui-même. Le jeton et le `.` sont jetés, rien n'est enregistré. Toute
|
||||
autre requête de Loki l'annule aussitôt, sauf un message dont la requête
|
||||
prolonge exactement le préfixe préparé (même historique, mêmes outils, mêmes
|
||||
réglages du gabarit). Jamais avec plus d'un slot (`PARALLEL` ou `-np` dans
|
||||
`EXTRA_ARGS`), pendant un tour, une tâche, un bench, ni vers un preset externe.
|
||||
`full` prépare aussi la discussion qu'on ouvre, si on y reste 3 s. Visible dans
|
||||
la télémétrie sous `prewarm`. Ce que voit le modèle ne change pas : au pire, le
|
||||
préchauffage ne sert à rien (moteur sans points de reprise aux messages
|
||||
utilisateur, image juste avant, agent ou web changé avant d'envoyer).
|
||||
- **Discussions multiples** : historique complet dans la barre latérale, titre
|
||||
repris du premier message (renommable), suppression. **Chaque discussion a son
|
||||
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
|
||||
|
||||
@@ -512,20 +512,19 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
prompt = "Prends-en connaissance."
|
||||
}
|
||||
askedCaps := caps
|
||||
// Mode code : fige le rôle du tour (plan demandé → planner, sinon builder).
|
||||
// La détection de bascule (puce « passer en mode Code ? ») est émise après
|
||||
// la bulle utilisateur, plus bas.
|
||||
if caps.Code && caps.Role == "" {
|
||||
caps.Role = "builder"
|
||||
if wantsPlan(text) {
|
||||
caps.Role = "planner"
|
||||
}
|
||||
}
|
||||
caps = withTurnRole(caps, text)
|
||||
// Content = simple texte d'ordinaire ; format multimodal (texte + images) quand
|
||||
// la vision est active et qu'une pièce jointe est une image (userMessageContent).
|
||||
c.Messages = append(c.Messages, Message{Role: "user", Content: userMessageContent(files, prompt)})
|
||||
epoch := c.epoch
|
||||
c.mu.Unlock()
|
||||
// Capacités demandées pour ce tour, retenues en mémoire (jamais persistées)
|
||||
// pour préchauffer le suivant (PREWARM, chat_prewarm.go).
|
||||
prewarmNoteCaps(convActiveID(), askedCaps)
|
||||
|
||||
// Borne de tour + bulle utilisateur (rejouables). Persistée tout de suite
|
||||
// (sur disque quelques ms plus tard, voir persistAsync) : si le process
|
||||
@@ -763,9 +762,15 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// (pas d'extrait de réponse) : la notif transite par Apple/Google. Pas
|
||||
// de notification après un « stop » (retour ci-dessus) : l'utilisateur
|
||||
// est là et a coupé volontairement.
|
||||
if !c.startQueuedIfAny() && hasPushSubs() {
|
||||
started := c.startQueuedIfAny()
|
||||
if !started && hasPushSubs() {
|
||||
go sendPushToAll("Loki", "Réponse prête · "+fmtDurFR(time.Since(turnStart)))
|
||||
}
|
||||
// Plus rien ne suit : le prochain tour peut être préparé pendant que
|
||||
// l'utilisateur lit (PREWARM, off par défaut — sans la clé, rien ne part).
|
||||
if !started {
|
||||
c.prewarmKick(prewarmTurnEnd)
|
||||
}
|
||||
}()
|
||||
// Télémétrie : tout ce que ce tour envoie au moteur (étapes, compaction,
|
||||
// sous-agents, vérification) est rattaché à la discussion active.
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
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) ;
|
||||
// - 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++
|
||||
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
|
||||
}
|
||||
if prewarmSlots(ep) != 1 {
|
||||
return
|
||||
}
|
||||
payload, run, skip := c.prewarmBodyFor(ep, cfg)
|
||||
if skip != "" {
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// moteurPrewarm : faux llama-server qui note TOUTES les requêtes (chemin et
|
||||
// corps), sert /health et /props, et répond aux complétions streamées comme aux
|
||||
// autres.
|
||||
type moteurPrewarm struct {
|
||||
mu sync.Mutex
|
||||
reqs []prewarmReq
|
||||
slots int
|
||||
}
|
||||
|
||||
type prewarmReq struct{ path, body string }
|
||||
|
||||
func (m *moteurPrewarm) start(t *testing.T) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var buf bytes.Buffer
|
||||
_, _ = buf.ReadFrom(r.Body)
|
||||
m.mu.Lock()
|
||||
m.reqs = append(m.reqs, prewarmReq{r.URL.Path, buf.String()})
|
||||
slots := m.slots
|
||||
m.mu.Unlock()
|
||||
switch r.URL.Path {
|
||||
case "/health":
|
||||
sendJSON(w, 200, map[string]any{"status": "ok"})
|
||||
case "/props":
|
||||
sendJSON(w, 200, map[string]any{"total_slots": slots, "build_info": "b9000-x"})
|
||||
default:
|
||||
if strings.Contains(buf.String(), `"stream":true`) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte(sseChunk("ok.") + sseFinal("stop", 100, 2) + "data: [DONE]\n\n"))
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"choices": []any{map[string]any{"message": map[string]any{"content": "x"}}},
|
||||
"usage": map[string]any{"prompt_tokens": 120, "completion_tokens": 1},
|
||||
"timings": map[string]any{"prompt_n": 4, "cache_n": 116, "predicted_n": 1},
|
||||
})
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *moteurPrewarm) all() []prewarmReq {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return append([]prewarmReq(nil), m.reqs...)
|
||||
}
|
||||
|
||||
// completions : corps des complétions, streamées ou non.
|
||||
func (m *moteurPrewarm) completions() []string {
|
||||
var out []string
|
||||
for _, r := range m.all() {
|
||||
if r.path == "/v1/chat/completions" {
|
||||
out = append(out, r.body)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// prewarmSetup : base neuve, discussion active, faux moteur à un slot,
|
||||
// préchauffage synchrone. keys : réglages posés.
|
||||
func prewarmSetup(t *testing.T, keys map[string]string) *moteurPrewarm {
|
||||
t.Helper()
|
||||
withWorkspace(t)
|
||||
activeProjMu.Lock()
|
||||
activeProjCache = ""
|
||||
activeProjMu.Unlock()
|
||||
setProjectOverride("")
|
||||
t.Cleanup(func() {
|
||||
activeProjMu.Lock()
|
||||
activeProjCache = ""
|
||||
activeProjMu.Unlock()
|
||||
setProjectOverride("")
|
||||
})
|
||||
ensureDefaultProject()
|
||||
freshTplProbe(t)
|
||||
convEnsureActive()
|
||||
prewarmAsync = false
|
||||
prewarmCaps.mu.Lock()
|
||||
prewarmCaps.m = nil
|
||||
prewarmCaps.mu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
prewarmAsync = true
|
||||
prewarmCaps.mu.Lock()
|
||||
prewarmCaps.m = nil
|
||||
prewarmCaps.mu.Unlock()
|
||||
})
|
||||
if err := SetConfigKey("MODEL", "/models/"+echoTestModel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for k, v := range keys {
|
||||
if err := SetConfigKey(k, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
m := &moteurPrewarm{slots: 1}
|
||||
m.start(t)
|
||||
return m
|
||||
}
|
||||
|
||||
// prewarmTurn : un tour de chat comme StartTurn le lance (capacités notées pour
|
||||
// le préchauffage), joué jusqu'au bout, préchauffage éventuel compris.
|
||||
func prewarmTurn(t *testing.T, c *Conversation, caps Caps, text string) {
|
||||
t.Helper()
|
||||
prewarmNoteCaps(convActiveID(), caps)
|
||||
c.mu.Lock()
|
||||
c.Messages = append(c.Messages, um(text))
|
||||
c.mu.Unlock()
|
||||
c.generate(context.Background(), withTurnRole(caps, text), 0.7, c.epoch)
|
||||
}
|
||||
|
||||
// Clé absente : pas une requête de plus au moteur — ni complétion, ni /health,
|
||||
// ni /props — et la conversation est celle d'avant.
|
||||
func TestPrewarmDefautAucuneRequete(t *testing.T) {
|
||||
m := prewarmSetup(t, nil)
|
||||
c := newTestConv()
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
prewarmTurn(t, c, caps, "bonjour")
|
||||
prewarmTurn(t, c, caps, "et ensuite ?")
|
||||
reqs := m.all()
|
||||
if len(reqs) != 2 {
|
||||
t.Fatalf("2 requêtes attendues (une par tour), %d reçues : %+v", len(reqs), reqs)
|
||||
}
|
||||
for _, r := range reqs {
|
||||
if r.path != "/v1/chat/completions" || strings.Contains(r.body, `"max_tokens"`) || !strings.Contains(r.body, `"stream":true`) {
|
||||
t.Fatalf("requête inattendue sans la clé : %s %s", r.path, r.body)
|
||||
}
|
||||
}
|
||||
// La fin de tour ne lance rien non plus en direct.
|
||||
c.prewarm(prewarmTurnEnd)
|
||||
if n := len(m.all()); n != 2 {
|
||||
t.Fatalf("prewarm sans la clé a parlé au moteur (%d requêtes)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Preset externe : la clé est sans effet.
|
||||
func TestPrewarmJamaisExterne(t *testing.T) {
|
||||
cfg := map[string]string{"PREWARM": "on", extKeyFlag: "1", extKeyURL: "http://127.0.0.1:1"}
|
||||
if prewarmMode(cfg) != "" {
|
||||
t.Fatal("PREWARM actif sur un preset externe")
|
||||
}
|
||||
for v, want := range map[string]string{"": "", "off": "", "on": "on", "1": "on", "full": "full", "FULL": "full", "x": ""} {
|
||||
if got := prewarmMode(map[string]string{"PREWARM": v}); got != want {
|
||||
t.Fatalf("PREWARM=%q : %q, attendu %q", v, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Avec la clé : la requête de préchauffage est celle du prochain tour, sentinelle
|
||||
// en plus — mêmes messages jusqu'au dernier, mêmes outils et arguments du
|
||||
// gabarit — avec max_tokens 1, sans flux. Rien n'en reste dans la discussion,
|
||||
// et la télémétrie la range sous kind=prewarm.
|
||||
func TestPrewarmEgalTourSuivantPlusSentinelle(t *testing.T) {
|
||||
m := prewarmSetup(t, map[string]string{"PREWARM": "on", "REASONING_EFFORT": "high", "TOP_P": "0.9"})
|
||||
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := newTestConv()
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
prewarmTurn(t, c, caps, "bonjour")
|
||||
comps := m.completions()
|
||||
if len(comps) != 2 {
|
||||
t.Fatalf("tour + préchauffage attendus, %d complétions", len(comps))
|
||||
}
|
||||
var pw map[string]any
|
||||
if err := json.Unmarshal([]byte(comps[1]), &pw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pw["stream"] != false || pw["max_tokens"] != float64(1) || pw["stream_options"] != nil {
|
||||
t.Fatalf("champs de réponse du préchauffage : stream=%v max_tokens=%v stream_options=%v", pw["stream"], pw["max_tokens"], pw["stream_options"])
|
||||
}
|
||||
c.mu.Lock()
|
||||
for _, msg := range c.Messages {
|
||||
if s, _ := msg.Content.(string); s == prewarmSentinel {
|
||||
c.mu.Unlock()
|
||||
t.Fatal("la sentinelle a été rangée dans la discussion")
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
prewarmTurn(t, c, caps, "et ensuite ?")
|
||||
comps = m.completions()
|
||||
var real map[string]any
|
||||
if err := json.Unmarshal([]byte(comps[2]), &real); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pm, rm := pw["messages"].([]any), real["messages"].([]any)
|
||||
if len(pm) != len(rm) {
|
||||
t.Fatalf("préchauffage %d messages, tour suivant %d", len(pm), len(rm))
|
||||
}
|
||||
if !reflect.DeepEqual(pm[:len(pm)-1], rm[:len(rm)-1]) {
|
||||
t.Fatalf("préfixe différent du tour suivant :\npréchauffage %v\ntour %v", pm[:len(pm)-1], rm[:len(rm)-1])
|
||||
}
|
||||
if last := pm[len(pm)-1].(map[string]any); last["role"] != "user" || last["content"] != prewarmSentinel {
|
||||
t.Fatalf("dernier message du préchauffage : %v", last)
|
||||
}
|
||||
for _, k := range append([]string{"temperature", "top_p", "model"}, prewarmOptsKeys...) {
|
||||
if !reflect.DeepEqual(pw[k], real[k]) {
|
||||
t.Fatalf("champ %s : préchauffage %v, tour %v", k, pw[k], real[k])
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, r := range perfLog.snapshot() {
|
||||
if r.Kind == perfPrewarm {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("aucune complétion kind=prewarm dans la télémétrie")
|
||||
}
|
||||
}
|
||||
|
||||
// Les garde-fous : plus d'un slot, tour en cours, capacités inconnues, travail
|
||||
// annexe — aucune complétion de préchauffage.
|
||||
func TestPrewarmGardeFous(t *testing.T) {
|
||||
m := prewarmSetup(t, map[string]string{"PREWARM": "on"})
|
||||
c := newTestConv()
|
||||
caps := Caps{Agent: true}
|
||||
c.mu.Lock()
|
||||
c.Messages = []Message{um("bonjour"), am("salut")}
|
||||
c.mu.Unlock()
|
||||
count := func() int { return len(m.completions()) }
|
||||
|
||||
// Capacités inconnues (aucun tour joué dans cette discussion).
|
||||
c.prewarm(prewarmTurnEnd)
|
||||
if count() != 0 {
|
||||
t.Fatal("préchauffage sans capacités connues")
|
||||
}
|
||||
prewarmNoteCaps(convActiveID(), caps)
|
||||
|
||||
m.mu.Lock()
|
||||
m.slots = 2
|
||||
m.mu.Unlock()
|
||||
c.prewarm(prewarmTurnEnd)
|
||||
if count() != 0 {
|
||||
t.Fatal("préchauffage avec deux slots")
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.slots = 1
|
||||
m.mu.Unlock()
|
||||
|
||||
c.mu.Lock()
|
||||
c.Generating = true
|
||||
c.mu.Unlock()
|
||||
c.prewarm(prewarmTurnEnd)
|
||||
c.mu.Lock()
|
||||
c.Generating = false
|
||||
c.mu.Unlock()
|
||||
if count() != 0 {
|
||||
t.Fatal("préchauffage pendant un tour")
|
||||
}
|
||||
|
||||
sideJobs.Add(1)
|
||||
c.prewarm(prewarmTurnEnd)
|
||||
sideJobs.Add(-1)
|
||||
if count() != 0 {
|
||||
t.Fatal("préchauffage pendant un travail annexe")
|
||||
}
|
||||
|
||||
end := engineRequestStart()
|
||||
c.prewarm(prewarmTurnEnd)
|
||||
end()
|
||||
if count() != 0 {
|
||||
t.Fatal("préchauffage avec une requête de Loki en vol")
|
||||
}
|
||||
|
||||
c.prewarm(prewarmTurnEnd)
|
||||
if count() != 1 {
|
||||
t.Fatalf("toutes conditions réunies : 1 préchauffage attendu, %d", count())
|
||||
}
|
||||
}
|
||||
|
||||
// Toute requête de Loki annule le préchauffage en vol, sauf un tour de chat qui
|
||||
// prolonge exactement son préfixe.
|
||||
func TestPrewarmAnnuleSaufPrefixeExact(t *testing.T) {
|
||||
hist := []Message{{Role: "system", Content: "sys"}, um("a"), am("b")}
|
||||
pwMsgs := append(append([]Message(nil), hist...), um(prewarmSentinel))
|
||||
real := append(append([]Message(nil), hist...), um("suite"))
|
||||
payload := map[string]any{"tools": []string{"t"}, "parallel_tool_calls": false}
|
||||
other := []Message{{Role: "system", Content: "sys"}, um("a"), am("c"), um("suite")}
|
||||
|
||||
// Sans préchauffage en vol, rien n'est calculé.
|
||||
if prewarmKeeper(perfMain, real, payload) != nil {
|
||||
t.Fatal("empreintes calculées sans préchauffage en vol")
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
keep func() func(*prewarmRun) bool // construit préchauffage en vol
|
||||
kept bool
|
||||
}{
|
||||
{"tour qui prolonge", func() func(*prewarmRun) bool { return prewarmKeeper(perfMain, real, payload) }, true},
|
||||
{"autres outils", func() func(*prewarmRun) bool {
|
||||
return prewarmKeeper(perfMain, real, map[string]any{"tools": []string{"u"}, "parallel_tool_calls": false})
|
||||
}, false},
|
||||
{"autre historique", func() func(*prewarmRun) bool { return prewarmKeeper(perfMain, other, payload) }, false},
|
||||
{"préfixe seul, rien après", func() func(*prewarmRun) bool { return prewarmKeeper(perfMain, hist, payload) }, false},
|
||||
{"compaction", func() func(*prewarmRun) bool { return prewarmKeeper(perfCompact, real, payload) }, false},
|
||||
{"requête quelconque", func() func(*prewarmRun) bool { return nil }, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
run := &prewarmRun{n: len(pwMsgs) - 1, prefix: perfPrefix(pwMsgs)[len(pwMsgs)-2], opts: prewarmOptsHash(payload), cancel: cancel, done: make(chan struct{})}
|
||||
end, ok := prewarmBegin(run)
|
||||
if !ok {
|
||||
t.Fatal("prewarmBegin refusé, moteur libre")
|
||||
}
|
||||
done := engineRequestStartKeep(tc.keep())
|
||||
if kept := ctx.Err() == nil; kept != tc.kept {
|
||||
t.Fatalf("%s : gardé=%v, attendu %v", tc.name, kept, tc.kept)
|
||||
}
|
||||
done()
|
||||
end()
|
||||
cancel()
|
||||
}
|
||||
// Une requête en vol interdit d'en démarrer un.
|
||||
busy := engineRequestStart()
|
||||
if _, ok := prewarmBegin(&prewarmRun{cancel: func() {}}); ok {
|
||||
t.Fatal("préchauffage inscrit avec une requête en vol")
|
||||
}
|
||||
busy()
|
||||
if prewarmLive.Load() {
|
||||
t.Fatal("prewarmLive resté levé")
|
||||
}
|
||||
}
|
||||
|
||||
// turnViewDry ne touche à rien (instantané, mises à jour, nettoyage) et rend,
|
||||
// jusqu'au dernier message, ce que turnView enverra.
|
||||
func TestTurnViewDryNeTouchePas(t *testing.T) {
|
||||
for _, on := range []bool{false, true} {
|
||||
m := projSnapSetup(t, on)
|
||||
c := newTestConv()
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
playTurn(t, m, c, caps, "bonjour")
|
||||
if err := MemAdd("docker.md", "# Docker\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !on {
|
||||
// Reste d'une époque avec la clé : turnView le nettoierait.
|
||||
c.mu.Lock()
|
||||
c.ProjSnap = &projSnapshot{Key: "x"}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
c.mu.Lock()
|
||||
before, _ := json.Marshal(c)
|
||||
hist := append([]Message(nil), c.Messages...)
|
||||
c.mu.Unlock()
|
||||
|
||||
dry, _ := c.turnViewDry(caps, append(append([]Message(nil), hist...), um(prewarmSentinel)), true)
|
||||
c.mu.Lock()
|
||||
after, _ := json.Marshal(c)
|
||||
c.mu.Unlock()
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatalf("on=%v : turnViewDry a modifié la discussion", on)
|
||||
}
|
||||
sent, _, _ := c.turnView(caps, c.epoch, append(append([]Message(nil), hist...), um("suite")), true)
|
||||
dw, sw := normalizeSystemMessages(dry), normalizeSystemMessages(sent)
|
||||
if len(dw) != len(sw) || !reflect.DeepEqual(jsonValue(t, dw[:len(dw)-1]), jsonValue(t, sw[:len(sw)-1])) {
|
||||
t.Fatalf("on=%v : préfixe de turnViewDry ≠ turnView\n%v\n%v", on, jsonValue(t, dw), jsonValue(t, sw))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// oldChatPayload : la construction du corps dans runChat avant buildChatPayload,
|
||||
// recopiée telle quelle.
|
||||
func oldChatPayload(ep chatEndpoint, messages []Message, temperature float64, tools []Tool, disableTools, toolChoiceNone, echoOff bool, effort string, kwargs map[string]any) map[string]any {
|
||||
sent := normalizeSystemMessages(messages)
|
||||
if toolChoiceNone {
|
||||
sent = withTrailingHint(sent, toolsOffHint)
|
||||
}
|
||||
pol := currentEchoPolicy()
|
||||
if ep.External || echoOff {
|
||||
pol = echoPolicy{}
|
||||
}
|
||||
sent, _ = echoMessages(sent, pol)
|
||||
payload := map[string]any{
|
||||
"model": ep.Model,
|
||||
"messages": expandImageRefs(sent),
|
||||
"stream": true,
|
||||
"temperature": temperature,
|
||||
"stream_options": map[string]any{"include_usage": true},
|
||||
}
|
||||
applySampling(payload)
|
||||
if effort != "" {
|
||||
payload["reasoning_effort"] = effort
|
||||
}
|
||||
if kwargs != nil && !ep.External {
|
||||
payload["chat_template_kwargs"] = kwargs
|
||||
}
|
||||
if len(tools) > 0 && !disableTools {
|
||||
payload["tools"] = tools
|
||||
payload["parallel_tool_calls"] = false
|
||||
if toolChoiceNone {
|
||||
payload["tool_choice"] = "none"
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// oldTurnReasoning : le calcul du raisonnement dans runChat d'avant, recopié.
|
||||
func oldTurnReasoning(cfg map[string]string) (string, string, bool, map[string]any) {
|
||||
effortWanted := reasoningEffortValue(cfg["REASONING_EFFORT"])
|
||||
reasoningEffort := effortResolve(effortWanted)
|
||||
thinkOff := reasoningExplicitlyOff(cfg["REASONING"]) || effortWanted == "none"
|
||||
return effortWanted, reasoningEffort, thinkOff, reasoningTemplateKwargs(thinkOff, reasoningEffort)
|
||||
}
|
||||
|
||||
// Refactorisation : le corps construit par wireMessages + buildChatPayload est,
|
||||
// octet pour octet, celui d'avant — sur toute la matrice des options d'une
|
||||
// requête (outils coupés ou neutralisés, intensité, gabarit, API externe,
|
||||
// raisonnement renvoyé, images).
|
||||
func TestBuildChatPayloadIdentiqueAvant(t *testing.T) {
|
||||
m := prewarmSetup(t, map[string]string{"TOP_P": "0.9", "TOP_K": "20", "REASONING_ECHO": "on"})
|
||||
_ = m
|
||||
tools := EnabledTools(Caps{Agent: true, Mem: MemAlways})
|
||||
if len(tools) == 0 {
|
||||
t.Fatal("aucun outil pour la matrice")
|
||||
}
|
||||
msgs := []Message{
|
||||
{Role: "system", Content: "sys"},
|
||||
projectContextMessageForTest(),
|
||||
um("bonjour"),
|
||||
{Role: "assistant", Content: "salut", ReasoningContent: "je pense", ReasoningModel: echoTestModel},
|
||||
{Role: "user", Content: []any{map[string]any{"type": "text", "text": "et ça ?"}, map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,AAAA"}}}},
|
||||
}
|
||||
eps := []chatEndpoint{{URL: "http://localhost:1/v1/chat/completions", Model: "loki"}, {URL: "https://api.example/v1/chat/completions", Model: "gpt", External: true}}
|
||||
for _, effortCfg := range []string{"", "high", "none"} {
|
||||
cfg := map[string]string{"REASONING_EFFORT": effortCfg, "REASONING": "on"}
|
||||
w1, e1, o1, k1 := oldTurnReasoning(cfg)
|
||||
w2, e2, o2, k2 := turnReasoning(cfg)
|
||||
if w1 != w2 || e1 != e2 || o1 != o2 || !reflect.DeepEqual(k1, k2) {
|
||||
t.Fatalf("turnReasoning(%q) différent d'avant", effortCfg)
|
||||
}
|
||||
for _, ep := range eps {
|
||||
for _, tl := range [][]Tool{nil, tools} {
|
||||
for _, disable := range []bool{false, true} {
|
||||
for _, none := range []bool{false, true} {
|
||||
for _, echoOff := range []bool{false, true} {
|
||||
want := oldChatPayload(ep, msgs, 0.7, tl, disable, none, echoOff, e2, k2)
|
||||
sent, _ := wireMessages(msgs, none, turnEchoPolicy(ep.External || echoOff))
|
||||
got := buildChatPayload(ep, sent, 0.7, chatPayloadOpts{tools: tl, disableTools: disable, toolChoiceNone: none, effort: e2, kwargs: k2})
|
||||
wb, _ := json.Marshal(want)
|
||||
gb, _ := json.Marshal(got)
|
||||
if !bytes.Equal(wb, gb) {
|
||||
t.Fatalf("corps différent (effort=%q ext=%v outils=%d off=%v none=%v echoOff=%v)\navant %s\naprès %s",
|
||||
effortCfg, ep.External, len(tl), disable, none, echoOff, wb, gb)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// projectContextMessageForTest : un message projet, que normalizeSystemMessages
|
||||
// déplace en tête du premier message utilisateur.
|
||||
func projectContextMessageForTest() Message {
|
||||
return renderProjectContext("Projet de test")
|
||||
}
|
||||
|
||||
// Refactorisation, de bout en bout : la requête d'un tour joué par generate est
|
||||
// celle de l'assemblage et du corps d'avant, pour chaque combinaison de modes.
|
||||
func TestGenerateCorpsIdentiqueAvant(t *testing.T) {
|
||||
m := prewarmSetup(t, map[string]string{"REASONING_EFFORT": "high", "TOP_P": "0.9"})
|
||||
matrix := []Caps{
|
||||
{},
|
||||
{Agent: true},
|
||||
{Agent: true, Mem: MemAlways},
|
||||
{Agent: true, Mem: MemOnDemand, Internet: true},
|
||||
{Agent: true, Code: true, Role: "builder"},
|
||||
{Agent: true, Code: true, Role: "planner"},
|
||||
}
|
||||
for i, caps := range matrix {
|
||||
c := newTestConv()
|
||||
c.mu.Lock()
|
||||
c.Messages = []Message{um("bonjour"), am("salut")}
|
||||
history := append(append([]Message(nil), c.Messages...), um("suite"))
|
||||
c.mu.Unlock()
|
||||
_, effort, _, kwargs := oldTurnReasoning(ReadConfig())
|
||||
final := oldAssembly(caps, history) // normalisé
|
||||
want := oldChatPayload(resolveChatEndpoint(), final, 0.7, EnabledTools(caps), false, false, false, effort, kwargs)
|
||||
before := len(m.completions())
|
||||
prewarmTurn(t, c, caps, "suite")
|
||||
comps := m.completions()
|
||||
if len(comps) <= before {
|
||||
t.Fatalf("cas %d : aucune requête", i)
|
||||
}
|
||||
var got any
|
||||
if err := json.Unmarshal([]byte(comps[before]), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, jsonValue(t, want)) {
|
||||
t.Fatalf("cas %d (%+v) : corps différent d'avant\nreçu %v\nattendu %v", i, caps, got, jsonValue(t, want))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrewarmNearMidnight(t *testing.T) {
|
||||
loc := time.Local
|
||||
if !prewarmNearMidnight(time.Date(2026, 10, 4, 23, 55, 0, 0, loc)) {
|
||||
t.Fatal("23:55 doit s'abstenir")
|
||||
}
|
||||
if prewarmNearMidnight(time.Date(2026, 10, 4, 23, 45, 0, 0, loc)) || prewarmNearMidnight(time.Date(2026, 10, 4, 0, 5, 0, 0, loc)) {
|
||||
t.Fatal("23:45 et 00:05 doivent préchauffer")
|
||||
}
|
||||
}
|
||||
@@ -233,7 +233,7 @@ func projSnapKey(caps Caps, cfg map[string]string) string {
|
||||
// projSnapIgnoredKeys : réglages changés en cours de discussion sans relancer le
|
||||
// moteur ni toucher au bloc. Les compter rafraîchirait pour rien.
|
||||
var projSnapIgnoredKeys = map[string]bool{
|
||||
"PROJ_SNAPSHOT": true, "REASONING_ECHO": true, "REASONING_EFFORT": true, "TEMP": true,
|
||||
"PROJ_SNAPSHOT": true, "PREWARM": true, "REASONING_ECHO": true, "REASONING_EFFORT": true, "TEMP": true,
|
||||
"COMPACT": true, "CRAWL4AI_URL": true, "CRAWL4AI_KEY": true, "WEB_ENGINE": true,
|
||||
}
|
||||
|
||||
@@ -515,6 +515,20 @@ func replaceProjectHead(msgs []Message, block []Message) []Message {
|
||||
// (sous c.mu, si l'epoch n'a pas changé). pt sert au rafraîchissement en cours
|
||||
// de tour (runChatTools) ; nil sans la clé.
|
||||
func (c *Conversation) turnView(caps Caps, epoch int, msgs []Message, inject bool) (sent []Message, tools []Tool, pt *projSnapTurn) {
|
||||
return c.assembleTurn(caps, epoch, msgs, inject, false)
|
||||
}
|
||||
|
||||
// turnViewDry : la séquence que turnView enverrait pour msgs, SANS rien toucher
|
||||
// à la conversation — ni instantané pris, ni mise à jour rangée, ni nettoyage.
|
||||
// Pour le préchauffage (chat_prewarm.go) : contenu vivant, mêmes décisions que
|
||||
// le vrai tour. La mise à jour qu'il poserait en tête du dernier message n'y est
|
||||
// pas : ce message-là est la sentinelle, hors du préfixe préchauffé.
|
||||
func (c *Conversation) turnViewDry(caps Caps, msgs []Message, inject bool) ([]Message, []Tool) {
|
||||
sent, tools, _ := c.assembleTurn(caps, 0, msgs, inject, true)
|
||||
return sent, tools
|
||||
}
|
||||
|
||||
func (c *Conversation) assembleTurn(caps Caps, epoch int, msgs []Message, inject, dry bool) (sent []Message, tools []Tool, pt *projSnapTurn) {
|
||||
cfg := ReadConfig()
|
||||
on := projSnapEnabled(cfg)
|
||||
if !inject {
|
||||
@@ -526,7 +540,7 @@ func (c *Conversation) turnView(caps Caps, epoch int, msgs []Message, inject boo
|
||||
}
|
||||
if !on {
|
||||
c.mu.Lock()
|
||||
if c.epoch == epoch && (c.ProjSnap != nil || hasContextUpdates(c.Messages)) {
|
||||
if !dry && c.epoch == epoch && (c.ProjSnap != nil || hasContextUpdates(c.Messages)) {
|
||||
c.ProjSnap = nil
|
||||
c.Messages = stripContextUpdates(c.Messages)
|
||||
}
|
||||
@@ -559,6 +573,17 @@ func (c *Conversation) turnView(caps Caps, epoch int, msgs []Message, inject boo
|
||||
|
||||
block := live.msgs
|
||||
c.mu.Lock()
|
||||
if dry {
|
||||
// Même décision que projSnapApplyLocked, appliquée à la seule copie.
|
||||
if refresh, _, _ := projSnapDecide(c.ProjSnap, live, key, sysHash, asOf); refresh {
|
||||
msgs = stripContextUpdates(msgs)
|
||||
} else {
|
||||
block = c.ProjSnap.Msgs
|
||||
}
|
||||
c.mu.Unlock()
|
||||
final := append(append(append([]Message(nil), spMsgs...), block...), msgs...)
|
||||
return InjectSkills(final, caps, tools), tools, nil
|
||||
}
|
||||
if c.epoch == epoch {
|
||||
refresh, delta := c.projSnapApplyLocked(live, key, sysHash, asOf)
|
||||
if refresh {
|
||||
@@ -586,15 +611,7 @@ func (c *Conversation) turnView(caps Caps, epoch int, msgs []Message, inject boo
|
||||
// que l'appelant l'applique à sa propre copie.
|
||||
func (c *Conversation) projSnapApplyLocked(live projLive, key, sysHash, asOf string) (refresh bool, delta string) {
|
||||
snap := c.ProjSnap
|
||||
refresh = snap == nil || snap.Key != key || snap.SysHash != sysHash
|
||||
tok := 0
|
||||
if !refresh {
|
||||
delta = projDelta(snap.State, live, asOf)
|
||||
tok = len(delta) / 4
|
||||
if delta != "" && snap.DeltaTok+tok > projSnapDeltaLimit(snap.SnapTok) {
|
||||
refresh = true
|
||||
}
|
||||
}
|
||||
refresh, delta, tok := projSnapDecide(snap, live, key, sysHash, asOf)
|
||||
if refresh {
|
||||
c.Messages = stripContextUpdates(c.Messages)
|
||||
c.ProjSnap = newProjSnapshot(live, key, sysHash)
|
||||
@@ -619,6 +636,21 @@ func (c *Conversation) projSnapApplyLocked(live projLive, key, sysHash, asOf str
|
||||
return false, delta
|
||||
}
|
||||
|
||||
// projSnapDecide : rafraîchir ou non, et la mise à jour à annoncer sinon (tok :
|
||||
// sa taille estimée). Pur : projSnapApplyLocked l'applique, le préchauffage
|
||||
// (turnViewDry) s'en sert pour prévoir le tour sans rien toucher.
|
||||
func projSnapDecide(snap *projSnapshot, live projLive, key, sysHash, asOf string) (refresh bool, delta string, tok int) {
|
||||
refresh = snap == nil || snap.Key != key || snap.SysHash != sysHash
|
||||
if !refresh {
|
||||
delta = projDelta(snap.State, live, asOf)
|
||||
tok = len(delta) / 4
|
||||
if delta != "" && snap.DeltaTok+tok > projSnapDeltaLimit(snap.SnapTok) {
|
||||
refresh = true
|
||||
}
|
||||
}
|
||||
return refresh, delta, tok
|
||||
}
|
||||
|
||||
// --- rafraîchissement en cours de tour -------------------------------------
|
||||
|
||||
// projSnapTurn accompagne un tour de discussion avec la clé posée. runChatTools
|
||||
|
||||
@@ -220,6 +220,7 @@ func projectSwitch(slug string) error {
|
||||
}
|
||||
// convIndex rend la plus récemment modifiée en tête.
|
||||
convActivate(list[0].ID, storeBytes(bkChat, convKey(list[0].ID)))
|
||||
prewarmAfterSwitch()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -247,6 +248,7 @@ func convSwitch(id string) error {
|
||||
}
|
||||
conv.persist() // fige la discussion qu'on quitte
|
||||
convActivate(id, storeBytes(bkChat, convKey(id)))
|
||||
prewarmAfterSwitch()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -42,3 +42,17 @@ func wantsPlan(text string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// withTurnRole fige le rôle d'un tour du mode Code d'après son message : plan
|
||||
// demandé → planner, sinon builder. Un rôle déjà choisi par le client est gardé.
|
||||
// Partagé par StartTurn et le préchauffage (chat_prewarm.go), qui prévoit le
|
||||
// rôle le plus probable du prochain message.
|
||||
func withTurnRole(caps Caps, text string) Caps {
|
||||
if caps.Code && caps.Role == "" {
|
||||
caps.Role = "builder"
|
||||
if wantsPlan(text) {
|
||||
caps.Role = "planner"
|
||||
}
|
||||
}
|
||||
return caps
|
||||
}
|
||||
+109
-64
@@ -1117,6 +1117,101 @@ func toolCallLabel(name string, args map[string]any) string {
|
||||
return label
|
||||
}
|
||||
|
||||
// turnReasoning : ce que la requête dit du raisonnement, tiré de la
|
||||
// configuration. Partagé par runChat et le préchauffage (chat_prewarm.go).
|
||||
func turnReasoning(cfg map[string]string) (effortWanted, effort string, thinkOff bool, kwargs map[string]any) {
|
||||
// Intensité du raisonnement, passée telle quelle au gabarit du modèle. Vide
|
||||
// = on n'envoie rien. Réglage par preset, donc de fait par modèle.
|
||||
effortWanted = reasoningEffortValue(cfg["REASONING_EFFORT"])
|
||||
// …sauf si le gabarit de CE modèle a déjà refusé ce niveau (llm_effort.go) :
|
||||
// on part alors directement sur la traduction apprise, sans repayer le 500.
|
||||
effort = effortResolve(effortWanted)
|
||||
// Le raisonnement est-il interdit pour ce tour ? « aucune » compte comme une
|
||||
// interdiction, y compris quand le repli ci-dessus a retiré le niveau : c'est
|
||||
// `enable_thinking` qui porte alors la consigne, seul.
|
||||
thinkOff = reasoningExplicitlyOff(cfg["REASONING"]) || effortWanted == "none"
|
||||
// Ce que le gabarit du modèle doit savoir, dans SA langue : `reasoning_effort`
|
||||
// ne parle qu'aux gabarits qui le lisent, `enable_thinking` parle aux modèles
|
||||
// hybrides (Qwen3 & co). Sans ça, « aucune » n'avait aucun effet sur eux : le
|
||||
// modèle réfléchissait pendant que l'interface annonçait le contraire.
|
||||
kwargs = reasoningTemplateKwargs(thinkOff, effort)
|
||||
return effortWanted, effort, thinkOff, kwargs
|
||||
}
|
||||
|
||||
// turnEchoPolicy : la politique de REASONING_ECHO d'une requête ; off = preset
|
||||
// externe ou repli en cours, rien ne repart.
|
||||
func turnEchoPolicy(off bool) echoPolicy {
|
||||
if off {
|
||||
return echoPolicy{}
|
||||
}
|
||||
return currentEchoPolicy()
|
||||
}
|
||||
|
||||
// wireMessages : les messages tels qu'ils partent au moteur — un seul système
|
||||
// en tête, la consigne de fin si les outils sont neutralisés, le raisonnement
|
||||
// renvoyé selon pol — et le nombre de raisonnements renvoyés.
|
||||
func wireMessages(messages []Message, toolChoiceNone bool, pol echoPolicy) ([]Message, int) {
|
||||
sent := normalizeSystemMessages(messages)
|
||||
if toolChoiceNone {
|
||||
sent = withTrailingHint(sent, toolsOffHint)
|
||||
}
|
||||
return echoMessages(sent, pol)
|
||||
}
|
||||
|
||||
// chatPayloadOpts : ce qui, en plus des messages, façonne une requête de chat.
|
||||
type chatPayloadOpts struct {
|
||||
tools []Tool
|
||||
disableTools bool
|
||||
toolChoiceNone bool
|
||||
effort string
|
||||
kwargs map[string]any
|
||||
}
|
||||
|
||||
// buildChatPayload : le corps d'une requête de complétion de chat, à partir des
|
||||
// messages déjà passés par wireMessages. UN seul endroit le construit : le
|
||||
// préchauffage (chat_prewarm.go) envoie ce même corps, aux seuls champs de
|
||||
// réponse près (stream, max_tokens), et ne peut donc pas en dériver.
|
||||
func buildChatPayload(ep chatEndpoint, sent []Message, temperature float64, o chatPayloadOpts) map[string]any {
|
||||
payload := map[string]any{
|
||||
"model": ep.Model,
|
||||
// Les images de l'historique y sont rangées par référence
|
||||
// (chat_images.go) : on remet leurs octets juste avant l'envoi.
|
||||
"messages": expandImageRefs(sent),
|
||||
"stream": true,
|
||||
"temperature": temperature,
|
||||
// include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE
|
||||
// du prompt (préfixe caché compris), contrairement à timings.prompt_n qui
|
||||
// ne compte que les tokens nouvellement traités. Sert au comptage exact du
|
||||
// contexte (sinon le system prompt déjà en cache n'est pas recompté).
|
||||
"stream_options": map[string]any{"include_usage": true},
|
||||
}
|
||||
// Échantillonnage du preset (top_p/top_k/min_p/pénalités, et TEMP qui
|
||||
// l'emporte sur la température ci-dessus). Posé AVANT le raisonnement : les
|
||||
// deux blocs écrivent des clés disjointes, mais l'ordre rend explicite que
|
||||
// c'est bien loki qui a le dernier mot sur `chat_template_kwargs`.
|
||||
applySampling(payload)
|
||||
if o.effort != "" {
|
||||
payload["reasoning_effort"] = o.effort
|
||||
}
|
||||
// chat_template_kwargs est propre à llama.cpp (--jinja) : une API
|
||||
// distante stricte (OpenAI) refuse un argument inconnu (400).
|
||||
if o.kwargs != nil && !ep.External {
|
||||
payload["chat_template_kwargs"] = o.kwargs
|
||||
}
|
||||
if len(o.tools) > 0 && !o.disableTools {
|
||||
payload["tools"] = o.tools
|
||||
// The model sometimes emits parallel tool calls, which this llama.cpp
|
||||
// build serialises as two concatenated JSON objects in one arguments
|
||||
// string ("{...}{...}") and then fails to parse (HTTP 500). Forcing a
|
||||
// single tool call per turn avoids that.
|
||||
payload["parallel_tool_calls"] = false
|
||||
if o.toolChoiceNone {
|
||||
payload["tool_choice"] = "none"
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// injectQueued (optionnel, variadique pour ne pas toucher aux appels hors chat)
|
||||
// est consulté à CHAQUE frontière d'étape de la boucle d'outils : il renvoie les
|
||||
// messages utilisateur mis en file PENDANT la génération, pour que le modèle les
|
||||
@@ -1157,21 +1252,7 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
|
||||
// (perf_log.go) : rien de tout ça ne part dans la requête.
|
||||
ptag, perfIter := perfTagOf(ctx), 0
|
||||
reasoningOn := reasoningActive(chatCfg["REASONING"])
|
||||
// Intensité du raisonnement, passée telle quelle au gabarit du modèle. Vide
|
||||
// = on n'envoie rien. Réglage par preset, donc de fait par modèle.
|
||||
effortWanted := reasoningEffortValue(chatCfg["REASONING_EFFORT"])
|
||||
// …sauf si le gabarit de CE modèle a déjà refusé ce niveau (llm_effort.go) :
|
||||
// on part alors directement sur la traduction apprise, sans repayer le 500.
|
||||
reasoningEffort := effortResolve(effortWanted)
|
||||
// Le raisonnement est-il interdit pour ce tour ? « aucune » compte comme une
|
||||
// interdiction, y compris quand le repli ci-dessus a retiré le niveau : c'est
|
||||
// `enable_thinking` qui porte alors la consigne, seul.
|
||||
thinkOff := reasoningExplicitlyOff(chatCfg["REASONING"]) || effortWanted == "none"
|
||||
// Ce que le gabarit du modèle doit savoir, dans SA langue : `reasoning_effort`
|
||||
// ne parle qu'aux gabarits qui le lisent, `enable_thinking` parle aux modèles
|
||||
// hybrides (Qwen3 & co). Sans ça, « aucune » n'avait aucun effet sur eux : le
|
||||
// modèle réfléchissait pendant que l'interface annonçait le contraire.
|
||||
reasoningKwargs := reasoningTemplateKwargs(thinkOff, reasoningEffort)
|
||||
effortWanted, reasoningEffort, thinkOff, reasoningKwargs := turnReasoning(chatCfg)
|
||||
// When llama.cpp fails to parse a model-generated tool call (HTTP 500), we
|
||||
// retry the same turn once with tools removed so the model answers in plain
|
||||
// text from the tool results already gathered, instead of dying mid-chat.
|
||||
@@ -1289,55 +1370,17 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
|
||||
// stricts (Qwen3.x) refusent un système ailleurs qu'en position 0.
|
||||
// Gardé à part : la télémétrie compare ces messages-là d'une requête à
|
||||
// l'autre (perfPrefix).
|
||||
sent := normalizeSystemMessages(messages)
|
||||
if toolChoiceNone {
|
||||
sent = withTrailingHint(sent, toolsOffHint)
|
||||
}
|
||||
// Raisonnement renvoyé : décidé ici, au point de sortie unique, pour
|
||||
// chaque requête. Clé absente (ou preset externe, ou repli en cours) :
|
||||
// Raisonnement renvoyé : décidé au point de sortie unique, pour chaque
|
||||
// requête. Clé absente (ou preset externe, ou repli en cours) :
|
||||
// echoMessages rend la tranche telle quelle, requête inchangée.
|
||||
pol := currentEchoPolicy()
|
||||
if ep.External || echoOffTurn {
|
||||
pol = echoPolicy{}
|
||||
}
|
||||
sent, echoSent := echoMessages(sent, pol)
|
||||
payload := map[string]any{
|
||||
"model": ep.Model,
|
||||
// Les images de l'historique y sont rangées par référence
|
||||
// (chat_images.go) : on remet leurs octets juste avant l'envoi.
|
||||
"messages": expandImageRefs(sent),
|
||||
"stream": true,
|
||||
"temperature": temperature,
|
||||
// include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE
|
||||
// du prompt (préfixe caché compris), contrairement à timings.prompt_n qui
|
||||
// ne compte que les tokens nouvellement traités. Sert au comptage exact du
|
||||
// contexte (sinon le system prompt déjà en cache n'est pas recompté).
|
||||
"stream_options": map[string]any{"include_usage": true},
|
||||
}
|
||||
// Échantillonnage du preset (top_p/top_k/min_p/pénalités, et TEMP qui
|
||||
// l'emporte sur la température ci-dessus). Posé AVANT le raisonnement : les
|
||||
// deux blocs écrivent des clés disjointes, mais l'ordre rend explicite que
|
||||
// c'est bien loki qui a le dernier mot sur `chat_template_kwargs`.
|
||||
applySampling(payload)
|
||||
if reasoningEffort != "" {
|
||||
payload["reasoning_effort"] = reasoningEffort
|
||||
}
|
||||
// chat_template_kwargs est propre à llama.cpp (--jinja) : une API
|
||||
// distante stricte (OpenAI) refuse un argument inconnu (400).
|
||||
if reasoningKwargs != nil && !ep.External {
|
||||
payload["chat_template_kwargs"] = reasoningKwargs
|
||||
}
|
||||
if len(tools) > 0 && !disableTools {
|
||||
payload["tools"] = tools
|
||||
// The model sometimes emits parallel tool calls, which this llama.cpp
|
||||
// build serialises as two concatenated JSON objects in one arguments
|
||||
// string ("{...}{...}") and then fails to parse (HTTP 500). Forcing a
|
||||
// single tool call per turn avoids that.
|
||||
payload["parallel_tool_calls"] = false
|
||||
if toolChoiceNone {
|
||||
payload["tool_choice"] = "none"
|
||||
}
|
||||
}
|
||||
// Assemblage partagé avec le préchauffage (chat_prewarm.go) : les deux ne
|
||||
// peuvent pas diverger.
|
||||
pol := turnEchoPolicy(ep.External || echoOffTurn)
|
||||
sent, echoSent := wireMessages(messages, toolChoiceNone, pol)
|
||||
payload := buildChatPayload(ep, sent, temperature, chatPayloadOpts{
|
||||
tools: tools, disableTools: disableTools, toolChoiceNone: toolChoiceNone,
|
||||
effort: reasoningEffort, kwargs: reasoningKwargs,
|
||||
})
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
@@ -1352,9 +1395,11 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
|
||||
// Requête en vol vers le moteur local, jusqu'à la lecture complète du corps :
|
||||
// l'isolation des travaux annexes n'efface jamais le slot pendant ce temps
|
||||
// (llm_slots.go). Rien à compter pour une API externe.
|
||||
// Un préchauffage en vol (PREWARM, chat_prewarm.go) est annulé ici, sauf
|
||||
// s'il prépare exactement le début de CETTE requête d'un tour de chat.
|
||||
endReq := func() {}
|
||||
if !ep.External {
|
||||
endReq = engineRequestStart()
|
||||
endReq = engineRequestStartKeep(prewarmKeeper(ptag.kind, sent, payload))
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -82,9 +82,20 @@ var sideJobs atomic.Int32
|
||||
|
||||
// engineRequestStart marque une requête vers le moteur local ; la fonction
|
||||
// rendue la clôt (une seule fois, quel que soit le nombre d'appels), une fois
|
||||
// le corps de la réponse lu.
|
||||
func engineRequestStart() func() {
|
||||
// le corps de la réponse lu. Un préchauffage en vol (PREWARM, chat_prewarm.go)
|
||||
// est annulé d'abord : il ne passe jamais devant un vrai travail.
|
||||
func engineRequestStart() func() { return engineRequestStartKeep(nil) }
|
||||
|
||||
// engineRequestStartKeep : engineRequestStart, sauf pour le préchauffage en vol
|
||||
// que keep reconnaît comme le début exact de cette requête — il continue, et la
|
||||
// requête le suit dans le même slot sur le préfixe qu'il vient de calculer.
|
||||
// Décidé sous le verrou du compteur : aucun préchauffage ne peut démarrer entre
|
||||
// la décision et le compte.
|
||||
func engineRequestStartKeep(keep func(*prewarmRun) bool) func() {
|
||||
engineGate.mu.Lock()
|
||||
if p := prewarmCur; p != nil && (keep == nil || !keep(p)) {
|
||||
prewarmDropLocked(p)
|
||||
}
|
||||
engineGate.inflight++
|
||||
engineGate.mu.Unlock()
|
||||
var once sync.Once
|
||||
|
||||
@@ -37,6 +37,7 @@ const (
|
||||
perfCompact = "compact" // résumé de compaction
|
||||
perfBench = "bench" // mesure du moteur (cache_prompt:false)
|
||||
perfForeign = "foreign" // client externe passé par /v1 : vu, pas mesuré
|
||||
perfPrewarm = "prewarm" // préchauffage du cache (PREWARM) : réponse jetée
|
||||
)
|
||||
|
||||
type perfCtxKey struct{}
|
||||
|
||||
@@ -106,6 +106,10 @@ var configTemplate = []struct{ key, help string }{
|
||||
"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. full = aussi au changement de discussion (après 3 s). Vide/off (défaut) = aucune requête de plus"},
|
||||
{"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"},
|
||||
|
||||
@@ -175,6 +175,11 @@ func runTask(t Task) {
|
||||
// dans la mémoire du projet ouvert à l'écran au moment du tic. Sûr : une seule
|
||||
// inférence tourne à la fois (verrou de génération), et RunAutonomous a déjà
|
||||
// rendu la main quand on libère.
|
||||
// Préchauffage de la discussion ouverte (PREWARM, off par défaut) une fois
|
||||
// la tâche finie : différé AVANT la levée du projet forcé, donc exécuté
|
||||
// APRÈS elle — le contexte préparé est celui de la discussion, pas de la
|
||||
// tâche. Le slot a déjà été effacé à la fin de RunAutonomous.
|
||||
defer conv.prewarmKick(prewarmAfterTask)
|
||||
setProjectOverride(taskProjectOf(t))
|
||||
defer setProjectOverride("")
|
||||
|
||||
|
||||
@@ -7520,7 +7520,7 @@ function renderStats(el, s){
|
||||
}
|
||||
// Ce qui s'est intercalé avant une perte de cache (lost_after, perf_log.go),
|
||||
// dit en clair : les identifiants internes n'ont rien à faire dans l'interface.
|
||||
const PERF_KIND={main:'tour', subagent:'sous-agent', verify:'vérification', task:'tâche', compact:'compaction', bench:'bench', foreign:'client /v1'};
|
||||
const PERF_KIND={main:'tour', subagent:'sous-agent', verify:'vérification', task:'tâche', compact:'compaction', bench:'bench', foreign:'client /v1', prewarm:'préchauffage'};
|
||||
// Milliers séparés par une espace fine insécable : « 41 230 », pas « 41230 ».
|
||||
function nfmt(n){ return String(Math.round(n||0)).replace(/\B(?=(\d{3})+(?!\d))/g,'\u202f'); }
|
||||
// --- Compteurs de bulle -----------------------------------------------------
|
||||
|
||||
@@ -263,7 +263,7 @@ function renderStats(el, s){
|
||||
}
|
||||
// Ce qui s'est intercalé avant une perte de cache (lost_after, perf_log.go),
|
||||
// dit en clair : les identifiants internes n'ont rien à faire dans l'interface.
|
||||
const PERF_KIND={main:'tour', subagent:'sous-agent', verify:'vérification', task:'tâche', compact:'compaction', bench:'bench', foreign:'client /v1'};
|
||||
const PERF_KIND={main:'tour', subagent:'sous-agent', verify:'vérification', task:'tâche', compact:'compaction', bench:'bench', foreign:'client /v1', prewarm:'préchauffage'};
|
||||
// Milliers séparés par une espace fine insécable : « 41 230 », pas « 41230 ».
|
||||
function nfmt(n){ return String(Math.round(n||0)).replace(/\B(?=(\d{3})+(?!\d))/g,'\u202f'); }
|
||||
// --- Compteurs de bulle -----------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user