mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-18 20:57:05 +02:00
Avec LOKI_BENCH=rust (expérimental), la façade sert /api/bench, /api/bench/status, /api/bench/cancel et /api/bench/last, et déroule le protocole de mesure contre le moteur local (rust/loki-rs/src/bench) : mode rapide (préchauffage, ligne courte) et mode complet (prefill à froid à profondeur réelle, trois tours qui reprennent le cache). Corpus, requêtes, calculs et messages sont ceux du Go ; seule une mesure complète est enregistrée (last_bench, bench_presets, empreinte de configuration du Go). Le verrou de génération reste celui de la conversation du Go : la façade le prend par le bail du moteur, avec le motif « bench ». Le Go note alors benching et donne aux autres le refus du benchmark ; changer de discussion ne rompt pas le bail ; benchRunning() le voit (clients /v1, préchauffage, optimiseur). Un stop du chat, un Reset ou un bail perdu annulent le bench. Le Go prête aussi le travail annexe (effacement du slot à la fin, /internal/v1/engine/sidejob) et la télémétrie (/internal/v1/bench/perf). Correction des tests du Go : trois tests du préchauffage échouaient dix minutes par jour, avant minuit (prewarmNearMidnight lisait l'heure réelle). L'heure du garde-fou devient remplaçable (prewarmNow), fixée à midi dans les tests. Parité : parity_bench_test.go tire du Go un journal rejoué par tests/bench.rs contre le même faux moteur ; chaque requête reçue par le moteur est comparée (longs textes réduits à leur taille et à leur empreinte). Il couvre les refus, les deux modes, les échecs de chaque phase, le raisonnement refusé puis rejoué, la profondeur partielle et l'annulation. Les tests du Go sont portés (bench/tests.rs). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
251 lines
8.0 KiB
Go
251 lines
8.0 KiB
Go
package loki
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Le benchmark tourne en TÂCHE DE FOND. Le mode complet dure de une à cinq
|
|
// minutes : une requête HTTP synchrone était coupée bien avant par un reverse
|
|
// proxy (nginx d'Unraid à 60 s, Cloudflare à 100 s) ou par le délai de
|
|
// l'interface (30 s), pendant que le moteur, lui, continuait — et un second
|
|
// clic empilait un deuxième bench derrière le premier. Désormais :
|
|
//
|
|
// POST /api/bench lance (202 + id), 409 si le moteur est occupé
|
|
// GET /api/bench/status phase en cours, puis résultat ou erreur
|
|
// POST /api/bench/cancel annule (chaque requête au moteur porte le contexte)
|
|
// GET /api/bench/last dernier résultat enregistré
|
|
//
|
|
// Pendant toute la mesure, le bench tient le verrou de génération de la
|
|
// conversation : un message, une tâche planifiée ou une compaction reçoivent
|
|
// un refus clair au lieu d'attendre des minutes dans la file du moteur. Les
|
|
// clients /v1 reçoivent un 503 avec Retry-After (llm_oai.go). Restent hors du
|
|
// verrou les appels annexes déclenchés par la fin d'un tour (titre, mémoire) :
|
|
// le verrou les précède de toute façon.
|
|
|
|
var errBenchBusy = errors.New("un benchmark occupe le moteur — attends sa fin ou annule-le")
|
|
|
|
// benchLease prend le verrou de génération pour un benchmark. cancel est
|
|
// branché sur le bouton stop du chat, comme pour une tâche planifiée. La
|
|
// fonction rendue le libère tant que benching dit que le verrou est encore au
|
|
// bench : un Reset l'a peut-être déjà rendu (benching remis à faux), et le tour
|
|
// qui a pu démarrer depuis ne doit pas être déclaré libre. L'epoch ne suffit
|
|
// pas : changer de discussion le bumpe SANS arrêter le bench (loadFrom).
|
|
func (c *Conversation) benchLease(cancel context.CancelFunc) (func(), error) {
|
|
return c.measureLease(cancel, errBenchBusy)
|
|
}
|
|
|
|
// measureLease : le verrou de génération pour une mesure du moteur — bench,
|
|
// ou optimiseur (backend_tune_job.go) — why étant le refus que reçoivent les
|
|
// autres pendant ce temps.
|
|
func (c *Conversation) measureLease(cancel context.CancelFunc, why error) (func(), error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.Generating {
|
|
return nil, ErrBusy
|
|
}
|
|
c.Generating, c.benching, c.cancel, c.leaseWhy = true, true, cancel, why
|
|
var once sync.Once
|
|
return func() {
|
|
once.Do(func() {
|
|
c.mu.Lock()
|
|
if c.benching {
|
|
c.Generating, c.benching, c.cancel, c.leaseWhy = false, false, nil, nil
|
|
}
|
|
c.mu.Unlock()
|
|
})
|
|
}, nil
|
|
}
|
|
|
|
// benchJob : le benchmark en cours ou le dernier terminé (un seul à la fois).
|
|
var benchJob struct {
|
|
mu sync.Mutex
|
|
seq int
|
|
running bool
|
|
id string
|
|
mode string
|
|
phase string
|
|
step int
|
|
steps int
|
|
started time.Time
|
|
finished time.Time
|
|
cancel context.CancelFunc
|
|
result *benchResult
|
|
err string
|
|
canceled bool
|
|
}
|
|
|
|
// benchRunning : un benchmark est-il en cours, de ce processus ou de la
|
|
// façade (LOKI_BENCH=rust, qui tient alors le bail du moteur) ?
|
|
func benchRunning() bool {
|
|
benchJob.mu.Lock()
|
|
running := benchJob.running
|
|
benchJob.mu.Unlock()
|
|
return running || benchLeased()
|
|
}
|
|
|
|
// benchRunner est runBench, remplaçable dans les tests.
|
|
var benchRunner = runBench
|
|
|
|
// benchStart lance un benchmark en arrière-plan. code : le statut HTTP à
|
|
// renvoyer en cas de refus.
|
|
func benchStart(opts benchOpts) (id string, code int, err error) {
|
|
// Hors verrou : healthCheck peut attendre le moteur quelques secondes, le
|
|
// suivi de progression ne doit pas rester bloqué derrière.
|
|
if externalActive() {
|
|
return "", http.StatusBadRequest, errors.New("benchmark indisponible : le preset actif est une API externe")
|
|
}
|
|
if err := tuneGuard(); err != nil {
|
|
return "", http.StatusConflict, err
|
|
}
|
|
if !healthCheck() {
|
|
return "", http.StatusServiceUnavailable, errModelLoading
|
|
}
|
|
benchJob.mu.Lock()
|
|
defer benchJob.mu.Unlock()
|
|
if benchJob.running {
|
|
return "", http.StatusConflict, errors.New("un benchmark tourne déjà")
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
release, err := conv.benchLease(cancel)
|
|
if err != nil {
|
|
cancel()
|
|
return "", http.StatusConflict, conv.busyReason()
|
|
}
|
|
benchJob.seq++
|
|
id = fmt.Sprintf("b%d-%d", time.Now().Unix(), benchJob.seq)
|
|
benchJob.running, benchJob.id, benchJob.mode = true, id, opts.Mode
|
|
benchJob.phase, benchJob.step, benchJob.steps = "démarrage", 0, 0
|
|
benchJob.started, benchJob.finished = time.Now(), time.Time{}
|
|
benchJob.cancel, benchJob.result, benchJob.err, benchJob.canceled = cancel, nil, "", false
|
|
go func() {
|
|
defer cancel()
|
|
res, err := benchRunner(ctx, opts, func(phase string, step, steps int) {
|
|
benchJob.mu.Lock()
|
|
if benchJob.id == id {
|
|
benchJob.phase, benchJob.step, benchJob.steps = phase, step, steps
|
|
}
|
|
benchJob.mu.Unlock()
|
|
})
|
|
canceled := ctx.Err() != nil
|
|
// Le verrou du chat est rendu AVANT d'annoncer la fin : l'interface qui
|
|
// lit « terminé » et envoie aussitôt un message ne doit pas se voir
|
|
// refuser « benchmark en cours ».
|
|
release()
|
|
benchJob.mu.Lock()
|
|
defer benchJob.mu.Unlock()
|
|
if benchJob.id != id {
|
|
return
|
|
}
|
|
benchJob.running, benchJob.finished, benchJob.cancel = false, time.Now(), nil
|
|
benchJob.result = res
|
|
if err != nil {
|
|
benchJob.canceled = canceled
|
|
benchJob.err = err.Error()
|
|
if canceled {
|
|
benchJob.err = "benchmark annulé"
|
|
}
|
|
}
|
|
}()
|
|
return id, http.StatusAccepted, nil
|
|
}
|
|
|
|
// benchCancel annule le benchmark en cours. false : aucun ne tournait.
|
|
func benchCancel() bool {
|
|
benchJob.mu.Lock()
|
|
defer benchJob.mu.Unlock()
|
|
if !benchJob.running || benchJob.cancel == nil {
|
|
return false
|
|
}
|
|
benchJob.cancel()
|
|
return true
|
|
}
|
|
|
|
// benchStatus : l'état à renvoyer à l'interface.
|
|
func benchStatus() map[string]any {
|
|
benchJob.mu.Lock()
|
|
defer benchJob.mu.Unlock()
|
|
st := map[string]any{"ok": true, "running": benchJob.running, "id": benchJob.id, "mode": benchJob.mode}
|
|
if benchJob.id == "" {
|
|
return st
|
|
}
|
|
st["phase"], st["step"], st["steps"] = benchJob.phase, benchJob.step, benchJob.steps
|
|
end := benchJob.finished
|
|
if benchJob.running {
|
|
end = time.Now()
|
|
}
|
|
st["elapsed_sec"] = end.Sub(benchJob.started).Seconds()
|
|
if !benchJob.running {
|
|
if benchJob.result != nil {
|
|
st["result"] = benchJob.result
|
|
st["saved"] = benchSavable(benchJob.result)
|
|
}
|
|
if benchJob.err != "" {
|
|
st["error"] = benchJob.err
|
|
st["canceled"] = benchJob.canceled
|
|
}
|
|
}
|
|
return st
|
|
}
|
|
|
|
// handleBench lance un benchmark : POST seulement — il occupe le moteur des
|
|
// minutes durant, une balise <img> sur une page tierce ne doit pas y suffire.
|
|
// Corps : {"mode":"quick"|"full", "prompt":N, "n":N}, tous facultatifs.
|
|
func handleBench(w http.ResponseWriter, r *http.Request) {
|
|
if !postOnly(w, r) {
|
|
return
|
|
}
|
|
var body struct {
|
|
Mode string `json:"mode"`
|
|
Prompt int `json:"prompt"`
|
|
N int `json:"n"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
opts := benchOpts{Mode: benchModeQuick, Prompt: 2000, Predict: 300}
|
|
if strings.EqualFold(strings.TrimSpace(body.Mode), benchModeFull) {
|
|
opts.Mode = benchModeFull
|
|
}
|
|
if body.Prompt > 0 {
|
|
opts.Prompt = min(body.Prompt, 32768)
|
|
}
|
|
if body.N > 0 {
|
|
opts.Predict = min(body.N, 4096)
|
|
}
|
|
id, code, err := benchStart(opts)
|
|
if err != nil {
|
|
sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
sendJSON(w, code, map[string]any{"ok": true, "id": id})
|
|
}
|
|
|
|
// handleBenchStatus : progression du benchmark en cours, ou issue du dernier.
|
|
func handleBenchStatus(w http.ResponseWriter, r *http.Request) {
|
|
sendJSON(w, 200, benchStatus())
|
|
}
|
|
|
|
// handleBenchCancel annule le benchmark en cours.
|
|
func handleBenchCancel(w http.ResponseWriter, r *http.Request) {
|
|
if !postOnly(w, r) {
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": benchCancel()})
|
|
}
|
|
|
|
// handleBenchLast returns the most recent persisted benchmark, or {ok:false}
|
|
// when none has been run yet.
|
|
func handleBenchLast(w http.ResponseWriter, r *http.Request) {
|
|
sb := loadLastBench()
|
|
if sb == nil {
|
|
sendJSON(w, 200, map[string]any{"ok": false})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true, "result": sb.Result, "model": sb.Model, "at": sb.At})
|
|
}
|