mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-18 20:57:05 +02:00
La relecture du bench en tâche de fond a trouvé quatre défauts qui le faisaient échouer ou s'interrompre là où il n'aurait pas dû, et une course entre la fin annoncée et le verrou rendu. - Niveau de raisonnement refusé par le gabarit (Qwen3.8 et « high ») : le bench rejoue une fois avec le niveau accepté, comme runChatTools, et retient la traduction. Il échouait sur ce 500 dans un processus neuf. L'erreur HTTP garde le corps entier : les niveaux acceptés sont cités au-delà des 300 caractères affichés. - Préchauffage borné au quart du contexte : -ub 4096 sur 8k de contexte envoyait un prompt plus grand que la fenêtre. - Changer de discussion n'annule plus le bench, et ne lui reprend pas le verrou. La libération suit le drapeau benching au lieu de l'epoch, qu'une bascule de fil bumpe. Reset arrête toujours le bench et rend le verrou. - Le verrou du chat est rendu avant que le statut ne dise « terminé » : un message envoyé aussitôt n'est plus refusé (et le test n'est plus instable). - Indication « possible thrash » mesurée sur les tours seulement, pas sur le prefill à froid qui lit légitimement les pages du modèle. - Interface : agrégats absents (omitempty) affichés à 0 au lieu de casser le rendu. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
239 lines
7.5 KiB
Go
239 lines
7.5 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) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.Generating {
|
|
return nil, ErrBusy
|
|
}
|
|
c.Generating, c.benching, c.cancel = true, true, cancel
|
|
var once sync.Once
|
|
return func() {
|
|
once.Do(func() {
|
|
c.mu.Lock()
|
|
if c.benching {
|
|
c.Generating, c.benching, c.cancel = false, false, 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 de ce processus est-il en cours ?
|
|
func benchRunning() bool {
|
|
benchJob.mu.Lock()
|
|
defer benchJob.mu.Unlock()
|
|
return benchJob.running
|
|
}
|
|
|
|
// 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 !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})
|
|
}
|