mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-24 15:38:12 +02:00
Raisonnement : un gabarit qui refuse le niveau ne tue plus le tour
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.
This commit is contained in:
9 files changed
+370
-12
No files matched your search
@@ -0,0 +1,195 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user