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:
Claude committed 2026-08-20 08:49:09 +00:00
1 parent 6a77999aac
commit f3b0f78b64
9 files changed
+370 -12

No files matched your search

+12 -5
View File
@@ -72,17 +72,24 @@ func reasoningActive(v string) bool {
// ReasoningEfforts liste les valeurs proposées par l'UI pour REASONING_EFFORT.
// "" (auto) n'y figure pas : c'est l'absence de réglage.
var ReasoningEfforts = []string{"none", "low", "medium", "high"}
//
// Aucun gabarit ne les connaît toutes — gpt-oss lit low/medium/high, Qwen3.8 lit
// low/medium/xhigh — et certains REFUSENT celles qu'ils ne connaissent pas (voir
// llm_effort.go, qui traduit alors vers le niveau accepté le plus proche). La
// liste reste donc l'union des niveaux courants plutôt que le plus petit
// dénominateur commun, sinon le maximum d'un modèle est hors d'atteinte.
var ReasoningEfforts = []string{"none", "low", "medium", "high", "xhigh"}
// reasoningEffortValue normalise REASONING_EFFORT en une valeur envoyable telle
// quelle à llama-server, ou "" pour ne rien envoyer du tout.
//
// llama-server accepte `reasoning_effort` dans /v1/chat/completions : la valeur
// `none` coupe le raisonnement, toute autre valeur est simplement passée au
// gabarit jinja du modèle. Un gabarit qui ne la lit pas l'ignore sans erreur —
// c'est pourquoi il n'y a pas de repli à prévoir ici, contrairement à un backend
// qui rejetterait la requête. En pratique seuls les modèles dont le gabarit
// gère `reasoning_effort` (gpt-oss et apparentés) changent de comportement.
// gabarit jinja du modèle. Un gabarit qui ne la lit pas l'ignore sans erreur ;
// un gabarit qui la VALIDE, lui, peut lever une exception jinja et faire
// répondre 500 à llama-server (Qwen3.8 sur « high »). Le repli n'est pas ici —
// on ne sait pas d'avance ce que le gabarit accepte — mais dans llm_effort.go,
// qui apprend la réponse du refus lui-même et rejoue le tour.
func reasoningEffortValue(v string) string {
switch s := strings.ToLower(strings.TrimSpace(v)); s {
case "", "auto", "default":
+28 -2
View File
@@ -616,12 +616,19 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
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.
reasoningEffort := reasoningEffortValue(chatCfg["REASONING_EFFORT"])
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(reasoningExplicitlyOff(chatCfg["REASONING"]), reasoningEffort)
reasoningKwargs := reasoningTemplateKwargs(thinkOff, reasoningEffort)
// 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.
@@ -630,6 +637,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// dépassement de la fenêtre de contexte après de gros résultats d'outils), on
// compacte l'historique en vol et on rejoue le tour — une seule fois.
compactedRetry := false
// Repli d'intensité de raisonnement (llm_effort.go) : une seule tentative par
// tour, comme les autres filets.
effortRetried := false
// Appels d'outil déjà exécutés (clé = nom + arguments bruts) : sert à ne pas
// rejouer deux fois exactement la même écriture dans un même échange.
doneCalls := map[string]string{}
@@ -709,6 +719,22 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
if msg == "" {
msg = resp.Status
}
// Refus du niveau de raisonnement par le gabarit du modèle (Qwen3.8 ne
// connaît pas « high », gpt-oss ne connaît pas « xhigh »…). C'est un
// refus DÉFINITIF, pas une question de taille de prompt : à traiter
// avant la compaction, qui sinon taillait l'historique pour rien puis
// échouait quand même. On rejoue le tour avec le niveau accepté (ou
// sans le champ), l'historique intact.
if !effortRetried && effortWanted != "" {
if fixed, ok := effortFromRejection(msg, effortWanted); ok {
effortRetried = true
effortRemember(effortWanted, fixed)
logEffortFallback(effortWanted, fixed)
reasoningEffort = fixed
reasoningKwargs = reasoningTemplateKwargs(thinkOff, fixed)
continue
}
}
// Le prompt a peut-être dépassé la fenêtre de contexte : on tente une
// compaction en vol et on rejoue le tour (une seule fois) avant tout le
// reste. C'est le filet de secours à la Hermes.
+195
View File
@@ -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)
}
+119
View File
@@ -0,0 +1,119 @@
package loki
import "testing"
// Le message qui a motivé tout ce fichier : Qwen3.8-27B refuse « high », le
// niveau que l'interface enregistre par défaut, et llama-server répond 500.
const qwenRefus = `{"error":{"code":500,"message":"\n------------\nWhile executing CallExpression at line 49, column 28 in source:\n...', 'low') %}\n {{- raise_exception('Unexpected reasoning effort ' ~ reason...\n ^\nError: Jinja Exception: Unexpected reasoning effort high. Supported types are xhigh (default), medium, and low.","type":"server_error"}}`
func TestEffortRejection(t *testing.T) {
sup, ok := effortRejection(qwenRefus)
if !ok {
t.Fatal("refus de reasoning_effort non reconnu")
}
// « high » est cité AVANT la liste (c'est la valeur refusée) : le lire comme
// une valeur acceptée renverrait loki sur la même erreur en boucle.
want := map[string]bool{"xhigh": true, "medium": true, "low": true}
if len(sup) != len(want) {
t.Fatalf("niveaux acceptés = %v, attendu %v", sup, want)
}
for _, s := range sup {
if !want[s] {
t.Errorf("niveau %q lu à tort comme accepté (%v)", s, sup)
}
}
// Les autres 500 (prompt trop long, appel d'outil illisible) ne doivent pas
// passer par ce filet : ils ont le leur.
for _, body := range []string{
"",
`{"error":{"code":500,"message":"the request exceeds the available context size"}}`,
`{"error":{"code":500,"message":"Failed to parse tool call"}}`,
// Parle bien de reasoning_effort mais ne le refuse pas.
`{"error":{"code":500,"message":"reasoning_effort applied"}}`,
} {
if _, ok := effortRejection(body); ok {
t.Errorf("corps %q pris à tort pour un refus d'intensité", body)
}
}
}
// Sans liste de niveaux acceptés, il n'y a rien à deviner : on retire le champ.
func TestEffortRejectionSansListe(t *testing.T) {
sup, ok := effortRejection("Error: Unknown reasoning effort 'xhigh'")
if !ok {
t.Fatal("refus non reconnu")
}
if len(sup) != 0 {
t.Fatalf("niveaux acceptés = %v, attendu aucun", sup)
}
if got := nearestEffort("xhigh", sup); got != "" {
t.Errorf("nearestEffort sans liste = %q, attendu \"\"", got)
}
}
func TestNearestEffort(t *testing.T) {
qwen := []string{"xhigh", "medium", "low"}
oss := []string{"low", "medium", "high"}
cases := []struct {
want string
supported []string
expect string
}{
{"high", qwen, "xhigh"}, // voisin immédiat vers le haut
{"xhigh", oss, "high"}, // voisin immédiat vers le bas
{"medium", qwen, "low"}, // « medium » refusé quand même : on ne le renvoie pas
{"low", []string{"medium", "high"}, "medium"},
{"high", nil, ""}, // rien d'annoncé : on retire le champ
{"", qwen, ""}, // auto : il n'y avait rien à envoyer
{"none", qwen, ""}, // consigne de coupure, pas une intensité
{"maximum", qwen, ""}, // hors échelle : on n'invente pas
}
for _, c := range cases {
if got := nearestEffort(c.want, c.supported); got != c.expect {
t.Errorf("nearestEffort(%q, %v) = %q, attendu %q", c.want, c.supported, got, c.expect)
}
}
// À égalité de distance, le niveau le plus FORT : dégrader en silence est
// pire que générer un peu plus longtemps.
if got := nearestEffort("medium", []string{"low", "high"}); got != "high" {
t.Errorf("égalité de distance = %q, attendu high", got)
}
}
func TestEffortFromRejection(t *testing.T) {
got, ok := effortFromRejection(qwenRefus, "high")
if !ok || got != "xhigh" {
t.Fatalf("effortFromRejection = %q, %v ; attendu xhigh, true", got, ok)
}
if _, ok := effortFromRejection("boom", "high"); ok {
t.Error("un 500 quelconque ne doit pas déclencher le repli d'intensité")
}
}
// La traduction apprise doit valoir pour les messages SUIVANTS (sinon chaque
// message repaie un aller-retour 500) et rester attachée au modèle : un autre
// modèle a un autre gabarit, donc d'autres niveaux.
func TestEffortMemoire(t *testing.T) {
testHome(t)
if err := WriteConfig(map[string]string{"MODEL": "/data/models/qwen.gguf"}); err != nil {
t.Fatal(err)
}
if got := effortResolve("high"); got != "high" {
t.Fatalf("sans refus connu : %q, attendu high", got)
}
effortRemember("high", "xhigh")
if got := effortResolve("high"); got != "xhigh" {
t.Errorf("après apprentissage : %q, attendu xhigh", got)
}
if err := WriteConfig(map[string]string{"MODEL": "/data/models/gpt-oss.gguf"}); err != nil {
t.Fatal(err)
}
if got := effortResolve("high"); got != "high" {
t.Errorf("autre modèle : %q, attendu high (gabarit inconnu)", got)
}
// Champ retiré ("") : c'est une réponse apprise, pas une absence de réponse.
effortRemember("high", "")
if got := effortResolve("high"); got != "" {
t.Errorf("repli « champ retiré » : %q, attendu \"\"", got)
}
}
+1 -1
View File
@@ -75,7 +75,7 @@ var configTemplate = []struct{ key, help string }{
{"KV_TYPE", "quantization du cache KV (q8_0, q4_0…) ; KV_TYPE_K / KV_TYPE_V pour les séparer"},
{"REASONING", "passthrough du mode raisonnement (on/auto/deepseek)"},
{"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"},
{"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high"},
{"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high / xhigh"},
{"COMPACT", "compactage automatique du contexte (off pour couper)"},
{"MEM_MODE", "mémoire de l'IA : off / ondemand / always"},
{"EXTRA_ARGS", "ajouté tel quel à la ligne de commande de llama-server"},
+3
View File
@@ -2459,6 +2459,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<option value="low">basse</option>
<option value="medium">moyenne</option>
<option value="high">haute</option>
<option value="xhigh">maximale</option>
</select>
<!-- Joindre un fichier. Icône dessinée en SVG : pas de dépendance à une
police d'emoji, dont le rendu varie énormément d'un système à l'autre. -->
@@ -2899,6 +2900,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<option value="low">basse</option>
<option value="medium">moyenne</option>
<option value="high">haute</option>
<option value="xhigh">maximale</option>
</select>
</span>
</div>
@@ -6830,6 +6832,7 @@ const THINK_EFFORT_LABELS = {
low: 'basse',
medium: 'moyenne',
high: 'haute',
xhigh: 'maximale',
};
async function loadThinkEffort(){
+2
View File
@@ -429,6 +429,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<option value="low">basse</option>
<option value="medium">moyenne</option>
<option value="high">haute</option>
<option value="xhigh">maximale</option>
</select>
<!-- Joindre un fichier. Icône dessinée en SVG : pas de dépendance à une
police d'emoji, dont le rendu varie énormément d'un système à l'autre. -->
@@ -869,6 +870,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<option value="low">basse</option>
<option value="medium">moyenne</option>
<option value="high">haute</option>
<option value="xhigh">maximale</option>
</select>
</span>
</div>
+1
View File
@@ -14,6 +14,7 @@ const THINK_EFFORT_LABELS = {
low: 'basse',
medium: 'moyenne',
high: 'haute',
xhigh: 'maximale',
};
async function loadThinkEffort(){