mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-24 15:38:12 +02:00
Raisonnement : REASONING_ECHO renvoie au moteur local la réflexion du modèle, en opt-in
Les gabarits Qwen3.5/3.6 rendent <think>…</think> pour chaque message assistant après la dernière question : c'est leur format entraîné. Loki ne gardait que le texte et les appels, donc à chaque étape d'une boucle d'outils le modèle relisait ses étapes précédentes avec des blocs vides, et le moteur recalculait le dernier message. Nouvelle clé REASONING_ECHO (off par défaut) : avec on, le raisonnement séparé par le serveur est gardé avec chaque message et renvoyé au même modèle. Sans la clé, rien n'est capturé ni envoyé : la requête est identique à l'octet près (testé). - Message.ReasoningContent (reasoning_content) + ReasoningModel, persistés ; seulement depuis reasoning_content du flux, jamais depuis le découpage « </think> » fait chez nous (rendu en double) - un seul point de sortie (echoMessages) : étiquette toujours retirée, raisonnement retiré pour une API externe, un autre modèle, un message sans texte ni appel, ou si la sonde dit que le gabarit ne le rend jamais - comptage : estimateTokens/compactBounds comptent ce qui part et que le gabarit rend (passé seulement si la sonde dit qu'il le garde, ou l'ignore) ; ctxAfter ne retire plus un raisonnement renvoyé - débordement : relance d'abord sans raisonnement, avant toute compaction ; shrinkToFit retire le raisonnement le plus ancien avant tout le reste ; le torse compacté le perd comme le résumé - erreur de gabarit (raise_exception, thinking, Failed to parse messages) : relance une fois sans raisonnement avant tool_choice none / outils coupés ; retenu pour le modèle si la relance passe - runBuilderTurn applique enfin NewHistory comme generate (compaction perdue pendant une passe de correction) ; forwardStream affiche la bannière - sonde de gabarit : nouveau verdict renders_reasoning - REASONING_PRESERVE (lot 1) inchangée, son interaction documentée Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3af481ff05
commit
7c9aa04fd9
12 files changed
+1121
-37
No files matched your search
+105
-8
@@ -25,6 +25,13 @@ type Message struct {
|
||||
Content any `json:"content,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
// ReasoningContent : raisonnement que le moteur a séparé pour ce message
|
||||
// assistant, gardé seulement avec REASONING_ECHO=on (llm_reasoning_echo.go).
|
||||
// ReasoningModel : le modèle qui l'a produit. Les deux sont persistés avec la
|
||||
// discussion ; à l'envoi, echoMessages retire toujours l'étiquette, et le
|
||||
// raisonnement quand il ne doit pas partir. Vides : JSON inchangé.
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
ReasoningModel string `json:"reasoning_model,omitempty"`
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
@@ -506,8 +513,8 @@ func EnabledTools(caps Caps) []Tool {
|
||||
}
|
||||
|
||||
// StreamEvent is what a ChatCallback receives for each piece of streamed output.
|
||||
// Exactly one of {Content, Reasoning, ToolUsed, Stats, Err, DropReasoning} is set
|
||||
// per call.
|
||||
// Exactly one of {Content, Reasoning, ToolUsed, Stats, Err, DropReasoning, Echo}
|
||||
// is set per call.
|
||||
type StreamEvent struct {
|
||||
Content string
|
||||
Reasoning string
|
||||
@@ -532,6 +539,11 @@ type StreamEvent struct {
|
||||
// REMPLACER son historique par elle (préfixe système injecté retiré), pas l'y
|
||||
// ajouter.
|
||||
NewHistory []Message
|
||||
// Echo : raisonnement séparé de la complétion qui porte la réponse finale,
|
||||
// à ranger avec elle (withEcho). Émis une fois, en fin de tour, et seulement
|
||||
// avec REASONING_ECHO=on sur le moteur local : les autres tours n'en voient
|
||||
// jamais. Les messages tool_calls du tour portent déjà le leur (extra).
|
||||
Echo *ReasoningEcho
|
||||
}
|
||||
|
||||
// AskEvent : l'outil ask — question structurée posée à l'utilisateur, rendue
|
||||
@@ -902,12 +914,15 @@ type StatsEvent struct {
|
||||
// `usage.prompt_tokens`. 0 si le backend ne renvoie pas d'usage.
|
||||
PromptTokensTotal int `json:"prompt_tokens_total,omitempty"`
|
||||
// Morceaux de raisonnement reçus SÉPARÉS par le moteur (reasoning_content),
|
||||
// llama-server local seulement. Loki ne renvoie jamais ce raisonnement au
|
||||
// modèle (Message n'a pas de champ pour lui) : ces jetons font partie de
|
||||
// GenTokens mais pas de la requête suivante. Un morceau vaut au plus un
|
||||
// jeton, donc ce compte ne peut que sous-estimer — le sens sans danger : on
|
||||
// compacte au pire comme avant, jamais trop tard. Voir ctxAfter.
|
||||
// llama-server local seulement. Sans REASONING_ECHO, Loki ne renvoie jamais
|
||||
// ce raisonnement au modèle : ces jetons font partie de GenTokens mais pas de
|
||||
// la requête suivante. Un morceau vaut au plus un jeton, donc ce compte ne
|
||||
// peut que sous-estimer — le sens sans danger : on compacte au pire comme
|
||||
// avant, jamais trop tard. Voir ctxAfter.
|
||||
ReasoningTokens int `json:"reasoning_tokens,omitempty"`
|
||||
// echoed : ce raisonnement-là repart dans la requête suivante
|
||||
// (REASONING_ECHO) — ctxAfter ne le retire alors pas. Jamais sérialisé.
|
||||
echoed bool
|
||||
// Conseil ponctuel quand le cache de prompts n'a pas tenu après un travail
|
||||
// annexe (voir noteEnginePrompt). Vide la plupart du temps.
|
||||
CacheHint string `json:"cache_hint,omitempty"`
|
||||
@@ -932,7 +947,12 @@ type StatsEvent struct {
|
||||
// raisonnement, jamais renvoyé. Compter ce raisonnement gonflait le contexte de
|
||||
// 1 à 8 k jetons par étape, et la compaction (avec perte) partait trop tôt.
|
||||
// Borné par GenTokens : le compte de morceaux ne retire jamais plus que généré.
|
||||
// Raisonnement renvoyé (echoed) : il fait partie de la requête suivante, il
|
||||
// reste compté.
|
||||
func (s StatsEvent) ctxAfter() int {
|
||||
if s.echoed {
|
||||
return s.PromptTokensTotal + s.GenTokens
|
||||
}
|
||||
return s.PromptTokensTotal + s.GenTokens - min(s.GenTokens, s.ReasoningTokens)
|
||||
}
|
||||
|
||||
@@ -1199,6 +1219,12 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
|
||||
// entier.
|
||||
const maxStreamRetries = 5
|
||||
streamRetries := 0
|
||||
// REASONING_ECHO (llm_reasoning_echo.go). echoOffTurn : plus aucun
|
||||
// raisonnement ne part de ce tour (prompt trop long, ou gabarit qui l'a
|
||||
// refusé) — retour exact à la requête d'avant la clé. echoTplRetry : relance
|
||||
// en cours après une erreur de gabarit ; si elle passe, c'était bien le
|
||||
// raisonnement, et le refus est retenu pour ce modèle.
|
||||
echoOffTurn, echoTplRetry := false, false
|
||||
// Destination des complétions : llama-server local, ou une API OpenAI-compatible
|
||||
// externe si le preset actif en est un (backend_external.go). Résolu UNE FOIS
|
||||
// par tour — une bascule de preset en plein tour est rare, et se rejoue de
|
||||
@@ -1251,6 +1277,14 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
|
||||
if toolChoiceNone {
|
||||
sent = withTrailingHint(sent, toolsOffHint)
|
||||
}
|
||||
// Raisonnement renvoyé : décidé ici, au point de sortie unique, pour
|
||||
// chaque requête. Clé absente (ou preset externe, ou repli en cours) :
|
||||
// echoMessages rend la tranche telle quelle, requête inchangée.
|
||||
pol := currentEchoPolicy()
|
||||
if ep.External || echoOffTurn {
|
||||
pol = echoPolicy{}
|
||||
}
|
||||
sent, echoSent := echoMessages(sent, pol)
|
||||
payload := map[string]any{
|
||||
"model": ep.Model,
|
||||
// Les images de l'historique y sont rangées par référence
|
||||
@@ -1336,6 +1370,9 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
|
||||
if msg == "" {
|
||||
msg = resp.Status
|
||||
}
|
||||
// La relance sans raisonnement est refusée à son tour : il n'y était
|
||||
// pour rien, aucun refus à retenir pour ce modèle.
|
||||
echoTplRetry = false
|
||||
// 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
|
||||
@@ -1352,12 +1389,36 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Gabarit qui refuse le raisonnement renvoyé (gpt-oss : « Cannot pass
|
||||
// both content and thinking », message assistant jugé invalide…) :
|
||||
// rejoué UNE fois sans lui, AVANT la coupure des outils et la consigne
|
||||
// système, qui casseraient le tour et tout le cache de prompt.
|
||||
if echoSent > 0 && echoTemplateError(resp.StatusCode, msg) {
|
||||
echoOffTurn, echoTplRetry = true, true
|
||||
logCtx("erreur de gabarit avec le raisonnement renvoyé (%d) : relance sans lui", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
// Prompt trop long alors que du raisonnement est parti : on retente
|
||||
// d'abord sans lui — exactement la requête d'avant REASONING_ECHO —,
|
||||
// avant toute compaction ou réduction, qui perdent de l'information.
|
||||
if echoSent > 0 && contextOverflow(msg, messages) {
|
||||
echoOffTurn = true
|
||||
logCtx("prompt trop long avec le raisonnement renvoyé : relance sans lui avant toute compaction")
|
||||
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.
|
||||
// ⚠️ Seulement si l'erreur est VRAIMENT un débordement de contexte : avant,
|
||||
// n'importe quel refus (appel d'outil mal formé, modèle en chargement,
|
||||
// erreur de template…) résumait ~75 % de la conversation, même courte.
|
||||
// Raisonnement déjà retiré de ce tour (repli ci-dessus) : la réduction
|
||||
// travaille sur ce qui part vraiment. Sinon elle compterait — puis
|
||||
// « retirerait » — du raisonnement qui ne partait plus, et gâcherait
|
||||
// une relance sur une requête identique.
|
||||
if echoOffTurn {
|
||||
messages = stripReasoning(messages)
|
||||
}
|
||||
if compactEnabled() && !compactedRetry && contextOverflow(msg, messages) {
|
||||
if c, changed := compactMessages(ctx, messages, caps); changed {
|
||||
compactedRetry = true
|
||||
@@ -1439,6 +1500,12 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
|
||||
// Une réponse est arrivée : le budget de reprise réseau repart à neuf pour
|
||||
// la suite de la boucle d'outils.
|
||||
netRetries = 0
|
||||
// La relance sans raisonnement passe : c'était bien lui que le gabarit
|
||||
// refusait. Retenu pour ce modèle, plus de 500 à repayer à chaque tour.
|
||||
if echoTplRetry {
|
||||
echoTplRetry = false
|
||||
echoRefuse(reasoningEchoModel(ReadConfig()))
|
||||
}
|
||||
if !ep.External {
|
||||
engineServed() // le slot porte désormais cette requête (llm_slots.go)
|
||||
}
|
||||
@@ -1492,6 +1559,10 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
|
||||
}
|
||||
// Per-completion reasoning-split state (see reasoningOn comment above).
|
||||
sawReasoningField := false
|
||||
// Raisonnement séparé PAR LE SERVEUR, gardé pour être renvoyé
|
||||
// (REASONING_ECHO). Jamais celui découpé chez nous sur « </think> » : il
|
||||
// reste dans le contenu, où le gabarit le redécoupe lui-même.
|
||||
var echoBuf strings.Builder
|
||||
thinkOpen := reasoningOn
|
||||
var thinkTail strings.Builder
|
||||
// Scanner à gros tampon : un chunk peut porter un gros JSON d'arguments
|
||||
@@ -1719,6 +1790,9 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
|
||||
if !ep.External {
|
||||
stats.ReasoningTokens++
|
||||
}
|
||||
if pol.on {
|
||||
echoBuf.WriteString(ch.Delta.ReasoningContent)
|
||||
}
|
||||
if !scb(StreamEvent{Reasoning: ch.Delta.ReasoningContent}) {
|
||||
aborted = true
|
||||
break
|
||||
@@ -1824,6 +1898,17 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
|
||||
scanErr := sc.Err()
|
||||
resp.Body.Close()
|
||||
endReq()
|
||||
// Raisonnement de cette complétion à renvoyer, s'il y a lieu. Il repart
|
||||
// dans la requête suivante quand elle continue ce tour (appel d'outil),
|
||||
// ou au tour suivant si le gabarit garde le passé : ctxAfter le compte —
|
||||
// posé avant toute émission de stats de cette complétion.
|
||||
echoText := ""
|
||||
if pol.on && sawReasoningField {
|
||||
echoText = echoBuf.String()
|
||||
}
|
||||
if echoText != "" && (len(toolCalls) > 0 || pol.keepsPast) {
|
||||
stats.echoed = true
|
||||
}
|
||||
// Premier jeton, de quelque nature qu'il soit (raisonnement, texte ou
|
||||
// appel d'outil). tReq est repris à chaque tentative.
|
||||
if !tFirst.IsZero() {
|
||||
@@ -1971,6 +2056,9 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
|
||||
if s := assistantContent.String(); s != "" {
|
||||
assistant.Content = s
|
||||
}
|
||||
if echoText != "" {
|
||||
assistant.ReasoningContent, assistant.ReasoningModel = echoText, pol.model
|
||||
}
|
||||
result := preflight.msg + " (écriture interrompue avant la fin : rien n'a été modifié)"
|
||||
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: preflight.name, Label: preflight.file, Done: true, Result: result}})
|
||||
toolMsg := Message{Role: "tool", ToolCallID: id, Content: result}
|
||||
@@ -2025,6 +2113,9 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
|
||||
if s := assistantContent.String(); s != "" {
|
||||
assistant.Content = s
|
||||
}
|
||||
if echoText != "" {
|
||||
assistant.ReasoningContent, assistant.ReasoningModel = echoText, pol.model
|
||||
}
|
||||
messages = append(messages, assistant)
|
||||
extra = append(extra, assistant)
|
||||
// Début des résultats de CETTE étape, que le moteur n'a pas encore
|
||||
@@ -2355,7 +2446,7 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
|
||||
if snippet := textualToolCallSnippet(assistantContent.String()); snippet != "" &&
|
||||
patternRetries < maxPatternRetries && callsOn {
|
||||
patternRetries++
|
||||
bad := Message{Role: "assistant", Content: assistantContent.String()}
|
||||
bad := withEcho(Message{Role: "assistant", Content: assistantContent.String()}, &ReasoningEcho{Text: echoText, Model: pol.model})
|
||||
fix := Message{Role: "user", Content: retryCorrective(snippet)}
|
||||
messages = append(messages, bad, fix)
|
||||
extra = append(extra, bad, fix)
|
||||
@@ -2412,6 +2503,12 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
|
||||
}
|
||||
cb(StreamEvent{Content: "_(le modèle n'a pas produit de réponse — finish: " + finishReason + ")_"})
|
||||
}
|
||||
// Raisonnement de la réponse finale : l'appelant le range avec elle.
|
||||
// Jamais sans texte (un message assistant de pur raisonnement serait
|
||||
// refusé par llama.cpp).
|
||||
if echoText != "" && strings.TrimSpace(assistantContent.String()) != "" {
|
||||
cb(StreamEvent{Echo: &ReasoningEcho{Text: echoText, Model: pol.model}})
|
||||
}
|
||||
return extra, nil
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user