mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Qwen3.8-27B valide `reasoning_effort` au lieu de l'ignorer : il connaît xhigh/medium/low, pas « high » — le niveau que l'interface enregistre par défaut. Chaque message partait donc en 500, avec une trace jinja affichée en guise d'erreur, et le 500 tombait dans la branche « prompt trop long » de runChat : loki compactait l'historique pour rien avant d'abandonner. Le refus dit lui-même ce que le gabarit accepte. On le lit (llm_effort.go), on traduit le niveau demandé vers le plus proche sur l'échelle none/minimal/low/medium/high/xhigh — à égalité, le plus fort, dégrader en silence étant pire que générer un peu plus longtemps — et on rejoue le tour, historique intact. Sans liste annoncée, le champ est simplement retiré. « aucune » n'est jamais traduite : c'est une coupure, portée par `enable_thinking` que tous les gabarits comprennent. La traduction est retenue par modèle : les messages suivants ne repaient pas l'aller-retour. Le repli est tracé sur stderr, sinon l'intensité choisie dans l'interface n'est pas celle qui part au moteur sans que rien ne le dise. « maximale » (xhigh) rejoint la liste des niveaux proposés : aucun gabarit ne les connaît toutes, et sans elle le maximum d'un Qwen3.8 restait hors d'atteinte. Le repli couvre les gabarits qui la refusent.
196 lines
7.0 KiB
Go
196 lines
7.0 KiB
Go
package loki
|
|
|
|
// llm_effort.go — repli quand le gabarit du modèle REFUSE la valeur de
|
|
// `reasoning_effort`.
|
|
//
|
|
// L'hypothèse d'origine (backend_models.go) était : un gabarit jinja qui ne lit
|
|
// pas `reasoning_effort` l'ignore sans erreur, donc rien à prévoir côté loki.
|
|
// Elle tombe dès qu'un gabarit VALIDE la valeur au lieu de la subir. Vu en
|
|
// production sur Qwen3.8-27B :
|
|
//
|
|
// {"error":{"code":500,"message":"... raise_exception('Unexpected reasoning
|
|
// effort ' ~ reasoning_effort) ... Unexpected reasoning effort high. Supported
|
|
// types are xhigh (default), medium, and low."}}
|
|
//
|
|
// Ce gabarit connaît xhigh/medium/low mais PAS « high » — le niveau que
|
|
// l'interface propose et enregistre par défaut. Résultat : chaque message part
|
|
// en 500, le tour meurt, et le message d'erreur affiché est une trace jinja.
|
|
// Pire, le 500 arrivait dans la branche « prompt trop long » de runChat, donc
|
|
// loki compactait l'historique pour rien avant d'abandonner.
|
|
//
|
|
// D'où ce fichier : reconnaître ce refus, traduire le niveau demandé vers celui
|
|
// que le gabarit accepte (le plus proche sur l'échelle), rejouer le tour SANS
|
|
// toucher à l'historique, et retenir la traduction pour ce modèle afin de ne
|
|
// pas repayer l'aller-retour à chaque message.
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// effortLadder : les niveaux connus, du plus faible au plus fort. Sert à choisir
|
|
// un remplaçant PROCHE quand le gabarit refuse la valeur demandée — « high »
|
|
// devient « xhigh » (voisin immédiat) plutôt que « low ».
|
|
//
|
|
// La liste vaut pour tous les gabarits croisés jusqu'ici : gpt-oss lit
|
|
// low/medium/high, Qwen3.8 lit low/medium/xhigh, certains ajoutent « minimal ».
|
|
// Une valeur hors échelle n'est pas classable : on retire alors le champ plutôt
|
|
// que d'inventer un niveau.
|
|
var effortLadder = []string{"none", "minimal", "low", "medium", "high", "xhigh"}
|
|
|
|
// effortRank renvoie la position d'un niveau sur l'échelle, ou -1 s'il est
|
|
// inconnu.
|
|
func effortRank(v string) int {
|
|
v = strings.ToLower(strings.TrimSpace(v))
|
|
for i, s := range effortLadder {
|
|
if s == v {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// effortWords capte les niveaux cités dans un message d'erreur. Les bornes de
|
|
// mot évitent de lire « high » à l'intérieur de « xhigh ».
|
|
var effortWords = regexp.MustCompile(`\b(none|minimal|low|medium|high|xhigh)\b`)
|
|
|
|
// effortRejection dit si le corps d'erreur renvoyé par llama-server est un refus
|
|
// de la valeur de `reasoning_effort` par le gabarit, et renvoie les niveaux que
|
|
// ce gabarit déclare accepter (éventuellement vide : tous ne les listent pas).
|
|
//
|
|
// La détection reste volontairement large — le texte vient du gabarit du modèle,
|
|
// pas de llama.cpp, donc sa formulation change d'un modèle à l'autre. Le seul
|
|
// invariant : il parle de reasoning effort et dit que la valeur ne va pas.
|
|
func effortRejection(body string) (supported []string, ok bool) {
|
|
low := strings.ToLower(body)
|
|
if !strings.Contains(low, "reasoning effort") && !strings.Contains(low, "reasoning_effort") {
|
|
return nil, false
|
|
}
|
|
refus := false
|
|
for _, w := range []string{"unexpected", "unsupported", "not supported", "invalid", "unknown"} {
|
|
if strings.Contains(low, w) {
|
|
refus = true
|
|
break
|
|
}
|
|
}
|
|
if !refus {
|
|
return nil, false
|
|
}
|
|
// Les niveaux acceptés sont annoncés APRÈS « supported » (« Supported types
|
|
// are xhigh (default), medium, and low. »). Ne lire que cette fin de message
|
|
// évite de prendre pour une liste le niveau refusé, cité juste avant.
|
|
if i := strings.LastIndex(low, "supported"); i >= 0 {
|
|
for _, m := range effortWords.FindAllString(low[i:], -1) {
|
|
if effortRank(m) >= 0 && !slicesHas(supported, m) {
|
|
supported = append(supported, m)
|
|
}
|
|
}
|
|
}
|
|
return supported, true
|
|
}
|
|
|
|
func slicesHas(list []string, v string) bool {
|
|
for _, s := range list {
|
|
if s == v {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// nearestEffort traduit le niveau demandé vers le plus proche parmi ceux que le
|
|
// gabarit accepte. Renvoie "" quand il n'y a rien de sensé à envoyer : le champ
|
|
// est alors simplement retiré de la requête, et le gabarit reprend son défaut.
|
|
//
|
|
// À égalité de distance on prend le niveau le PLUS FORT : demander « haute » et
|
|
// se retrouver avec « moyenne » dégrade la réponse en silence, alors qu'un cran
|
|
// au-dessus ne coûte que du temps de génération.
|
|
//
|
|
// « none » n'est jamais traduit : c'est une consigne de couper le raisonnement,
|
|
// pas une intensité. Un gabarit qui la refuse reçoit déjà `enable_thinking:
|
|
// false` par chat_template_kwargs (backend_models.go), qui dit la même chose
|
|
// dans une langue que tous comprennent.
|
|
func nearestEffort(want string, supported []string) string {
|
|
want = strings.ToLower(strings.TrimSpace(want))
|
|
if want == "" || want == "none" {
|
|
return ""
|
|
}
|
|
rank := effortRank(want)
|
|
if rank < 0 {
|
|
return ""
|
|
}
|
|
best, bestRank := "", -1
|
|
for _, s := range supported {
|
|
r := effortRank(s)
|
|
if r < 0 || s == want {
|
|
continue // s == want : le gabarit ne peut pas à la fois refuser et accepter
|
|
}
|
|
if best == "" || abs(r-rank) < abs(bestRank-rank) || (abs(r-rank) == abs(bestRank-rank) && r > bestRank) {
|
|
best, bestRank = s, r
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
func abs(n int) int {
|
|
if n < 0 {
|
|
return -n
|
|
}
|
|
return n
|
|
}
|
|
|
|
// effortFromRejection combine les deux : à partir du corps d'erreur et du niveau
|
|
// demandé, renvoie le niveau à réessayer ("" = ne rien envoyer) et true si le
|
|
// refus a bien été reconnu.
|
|
func effortFromRejection(body, want string) (string, bool) {
|
|
supported, ok := effortRejection(body)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
return nearestEffort(want, supported), true
|
|
}
|
|
|
|
// effortFallbacks retient les traductions apprises, par modèle : sans ça chaque
|
|
// message repaierait un aller-retour 500 avant de tomber sur la bonne valeur.
|
|
// Clé = fichier de modèle + niveau demandé ; la valeur "" est significative
|
|
// (= ne rien envoyer), d'où sync.Map plutôt qu'une map avec test de zéro.
|
|
var effortFallbacks sync.Map
|
|
|
|
func effortKey(want string) string {
|
|
return filepath.Base(strings.TrimSpace(ReadConfig()["MODEL"])) + "\x00" + want
|
|
}
|
|
|
|
// effortResolve renvoie le niveau réellement envoyable pour la valeur demandée,
|
|
// c'est-à-dire la traduction déjà apprise pour ce modèle s'il y en a une.
|
|
func effortResolve(want string) string {
|
|
if want == "" {
|
|
return ""
|
|
}
|
|
if v, ok := effortFallbacks.Load(effortKey(want)); ok {
|
|
return v.(string)
|
|
}
|
|
return want
|
|
}
|
|
|
|
// effortRemember enregistre la traduction pour les messages suivants.
|
|
func effortRemember(want, got string) {
|
|
if want == "" {
|
|
return
|
|
}
|
|
effortFallbacks.Store(effortKey(want), got)
|
|
}
|
|
|
|
// logEffortFallback trace le repli sur stderr : sinon l'intensité choisie dans
|
|
// l'interface n'est pas celle qui part au moteur, sans que rien ne le dise.
|
|
func logEffortFallback(want, got string) {
|
|
if got == "" {
|
|
fmt.Fprintf(os.Stderr, "[reasoning] gabarit du modèle : intensité %q refusée — champ retiré pour ce modèle\n", want)
|
|
return
|
|
}
|
|
fmt.Fprintf(os.Stderr, "[reasoning] gabarit du modèle : intensité %q refusée — repli sur %q pour ce modèle\n", want, got)
|
|
}
|