From 69e89e5c2f6e49986d9eca4fb43ca3e69f60a7e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 08:56:32 +0000 Subject: [PATCH] Compteur de vitesse par bulle, et budget souple d'appels d'outils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux défauts révélés par un tour d'agent d'une heure (~50 appels d'outils) sur un modèle très quantifié. 1. La vitesse affichée sous les réponses tombait de 17 tok/s à 0,9 au fil du tour, ce qui donnait à croire que le moteur s'effondrait. Il n'en était rien : un tour d'agent ouvre une bulle NEUVE après chaque appel d'outil (le flux repasse contentEl à null), mais les compteurs n'étaient jamais remis à zéro. Chaque bulle affichait donc le CUMUL de tout le tour divisé par le temps écoulé depuis le tout premier token — exécution des outils, pages web et prefill compris. La vitesse convergeait mécaniquement vers « tokens générés ÷ durée totale du tour ». Les compteurs sont maintenant remis à zéro à la CRÉATION de la bulle, ce qui couvre tout chemin qui en ouvre une neuve, aujourd'hui comme demain. Rejoué sur un tour synthétique où le moteur décode à 20 tok/s constants entre deux outils de deux minutes : 20,5 / 0,6 / 0,5 tok/s avant, 20,5 sur les trois bulles après. La durée « travail », elle, reste bien celle du tour entier — c'est sa définition. 2. Rien n'exerçait de pression sur un tour qui tourne en rond. Le plafond d'itérations avait été retiré en v0.6.3 (il coupait des recherches légitimes) et la déduplication d'appels ne rattrape pas ce cas : sa clé est « nom + arguments bruts », or relire le même fichier par tranches (`sed -n '1,80p'` puis `sed -n '80,160p'`) produit des clés différentes. D'où un budget SOUPLE : au-delà de 24 appels d'outils sur un tour, on rappelle au modèle combien il en a déjà faits et on lui demande de conclure. Le rappel revient à chaque palier en durcissant le ton, et ne coupe jamais le tour. Il est ajouté EN FIN d'historique, ce qui laisse intact le préfixe déjà en cache côté llama-server, et n'est pas persisté. `AGENT_BUDGET` dans config.env règle le palier, `off` le désactive. Élargir plutôt la clé de déduplication à la CIBLE de l'appel a été écarté : deux tranches d'un même fichier renvoient un contenu différent, les confondre casserait toute lecture paginée légitime. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01J5UndZ9DedRXPuAoRXbmDb --- README.md | 9 +++ internal/loki/llm_budget.go | 91 +++++++++++++++++++++++++++ internal/loki/llm_budget_test.go | 93 ++++++++++++++++++++++++++++ internal/loki/llm_client.go | 18 ++++-- internal/loki/ui/index.html | 22 +++++-- internal/loki/ui/src/js/09-stream.js | 22 +++++-- 6 files changed, 243 insertions(+), 12 deletions(-) create mode 100644 internal/loki/llm_budget.go create mode 100644 internal/loki/llm_budget_test.go diff --git a/README.md b/README.md index 85096ee..be8270a 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,15 @@ Ajoutées par ce fork : (`.oai.ajean.link`) est retirée de l'interface : elle exigeait un jeton de relais que ce fork ne permet plus d'obtenir, l'interrupteur ne pouvait donc qu'échouer. +- **Budget d'appels d'outils** : un tour d'agent n'a aucun plafond — couper une + recherche légitime est pire que la laisser durer — mais au-delà de 24 appels + sur un même tour, Loki rappelle au modèle combien il en a déjà faits et lui + demande de conclure. Le rappel revient tous les 24 appels, en durcissant le + ton ; il ne coupe jamais le tour, c'est de la pression, pas une barrière. + Sans lui, un petit modèle qui tourne en rond n'avait rien en face de lui sauf + le bouton stop (vu en production : 50 appels, 55 minutes, à relire cinq fois + les mêmes fichiers). Réglable par `AGENT_BUDGET` dans `config.env` — + `AGENT_BUDGET=off` le désactive complètement. - **Identité** : ton prénom et un avatar emoji pour toi et pour Loki, affichés dans le fil. - **Paramètres** : les réglages d'application (identité, apparence, accès diff --git a/internal/loki/llm_budget.go b/internal/loki/llm_budget.go new file mode 100644 index 0000000..af9a374 --- /dev/null +++ b/internal/loki/llm_budget.go @@ -0,0 +1,91 @@ +package loki + +// llm_budget.go — budget SOUPLE d'appels d'outils par tour. +// +// Le plafond dur (TOOL_LIMIT) et l'anti-boucle ont été retirés en v0.6.3 : ils +// coupaient des tours parfaitement légitimes, une recherche enchaînant sans +// problème des dizaines d'appels. Mais il ne restait plus RIEN entre « le +// modèle converge » et « le modèle tourne en rond pendant une heure » — vu en +// production : ~50 appels d'outil, 55 minutes, le modèle relisant cinq fois les +// mêmes fonctions pour re-dériver une conclusion qu'il avait déjà écrite. +// +// La déduplication d'appels identiques (repeatedCallResult) ne rattrape pas ce +// cas : sa clé est « nom + arguments bruts », et relire le même fichier par +// tranches (`sed -n '1,80p'` puis `sed -n '80,160p'`) produit des clés +// différentes. Élargir cette clé à la CIBLE serait pire que le mal — deux +// tranches d'un même fichier renvoient un contenu différent, les confondre +// casserait toute lecture paginée légitime. +// +// D'où un budget qui ne coupe rien : au-delà d'un palier, on RAPPELLE au modèle +// combien d'appels il a déjà faits et on lui demande de conclure. Le tour reste +// entièrement sous son contrôle — c'est de la pression, pas une barrière. + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// agentBudgetDefault : nombre d'appels d'outils au-delà duquel un tour cesse +// d'être une recherche et commence à ressembler à du sur-place. Assez haut pour +// qu'une vraie enquête (lire dix fichiers, croiser trois pages web) passe sans +// jamais voir le rappel. +const agentBudgetDefault = 24 + +// agentBudget lit le palier dans config.env (AGENT_BUDGET). 0 — ou une valeur +// off/false/no/non — désactive complètement le mécanisme. +func agentBudget() int { + v := strings.ToLower(strings.TrimSpace(ReadConfig()["AGENT_BUDGET"])) + switch v { + case "": + return agentBudgetDefault + case "off", "false", "no", "non", "disable", "disabled": + return 0 + } + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + return n + } + return agentBudgetDefault +} + +// budgetNudge renvoie le rappel à injecter quand le tour vient de franchir un +// palier, ou "" s'il n'y a rien à dire. +// +// calls : appels d'outils exécutés depuis le début du tour +// budget : taille d'un palier (0 = mécanisme désactivé) +// sent : rappels déjà envoyés dans ce tour +// +// Les paliers sont à budget, 2×budget, 3×budget… : la pression revient donc +// régulièrement au lieu de s'éteindre après un avertissement ignoré. Le coût en +// contexte est négligeable (une phrase toutes les 24 requêtes) et le message est +// AJOUTÉ EN FIN d'historique, ce qui laisse intact le préfixe déjà en cache côté +// llama-server. +// +// Le texte est en anglais comme le reste du protocole d'outils : c'est la langue +// dans laquelle les modèles suivent le mieux une consigne impérative, quelle que +// soit celle de la conversation. +func budgetNudge(calls, budget, sent int) string { + if budget <= 0 || calls < budget*(sent+1) { + return "" + } + n := fmt.Sprintf("%d tool calls", calls) + switch sent { + case 0: + return "[system] You have made " + n + " in this single turn. That is a lot: you almost certainly have enough to answer already. " + + "Stop exploring and write your final answer now, unless exactly one specific call is genuinely still missing." + case 1: + return "[system] " + n + " now, and you are going in circles. Re-reading a file you have already read, or re-deriving a conclusion you have already written, adds nothing. " + + "Answer NOW with what you have. If something remains uncertain, say so in the answer instead of investigating further." + default: + return "[system] " + n + ". Stop. Write your final answer in this message. Do not call another tool." + } +} + +// logBudget trace UNE ligne par rappel sur la sortie d'erreur (donc dans +// `journalctl -u loki-ui`), comme logCompact. Sans ça, un tour qui part en +// vrille est invisible côté serveur : on ne voit qu'une génération qui dure. +func logBudget(calls, budget, sent int) { + fmt.Fprintf(os.Stderr, "[agent] %d appels d'outils sur ce tour (palier=%d) — rappel n°%d envoyé au modèle\n", + calls, budget, sent) +} diff --git a/internal/loki/llm_budget_test.go b/internal/loki/llm_budget_test.go new file mode 100644 index 0000000..17363db --- /dev/null +++ b/internal/loki/llm_budget_test.go @@ -0,0 +1,93 @@ +package loki + +import "testing" + +// Le budget ne se déclenche pas avant son palier, et il revient à chaque +// palier suivant : un modèle qui ignore le premier rappel doit en recevoir un +// second, plus ferme. +func TestBudgetNudgePaliers(t *testing.T) { + const budget = 24 + cases := []struct { + calls, sent int + want bool + }{ + {0, 0, false}, + {1, 0, false}, + {23, 0, false}, + {24, 0, true}, // 1er palier + {40, 1, false}, // rappel déjà envoyé, palier suivant pas atteint + {48, 1, true}, // 2e palier + {72, 2, true}, // 3e palier + {95, 3, false}, + {96, 3, true}, // 4e palier : la pression ne s'éteint pas + } + for _, c := range cases { + got := budgetNudge(c.calls, budget, c.sent) != "" + if got != c.want { + t.Errorf("budgetNudge(calls=%d, sent=%d) = %v, attendu %v", c.calls, c.sent, got, c.want) + } + } +} + +// Le message durcit à chaque rappel : trois textes distincts, tous porteurs du +// nombre d'appels (c'est LUI qui informe le modèle, pas le ton). +func TestBudgetNudgeEscalade(t *testing.T) { + const budget = 10 + seen := map[string]bool{} + for sent := 0; sent < 3; sent++ { + m := budgetNudge(budget*(sent+1), budget, sent) + if m == "" { + t.Fatalf("rappel n°%d vide", sent+1) + } + if seen[m] { + t.Errorf("rappel n°%d identique à un précédent : %q", sent+1, m) + } + seen[m] = true + } + // Au-delà, on réutilise le texte le plus ferme plutôt que d'en inventer un + // nouveau à chaque palier (à nombre d'appels égal, le message est le même). + if a, b := budgetNudge(50, budget, 3), budgetNudge(50, budget, 4); a != b { + t.Errorf("les rappels tardifs devraient partager le même texte :\n%q\n%q", a, b) + } +} + +// budget=0 (AGENT_BUDGET=off) : plus aucun rappel, quel que soit le nombre +// d'appels — le mécanisme doit pouvoir être totalement coupé. +func TestBudgetNudgeDesactive(t *testing.T) { + for _, calls := range []int{0, 24, 500, 10000} { + if m := budgetNudge(calls, 0, 0); m != "" { + t.Errorf("budget désactivé mais rappel émis à %d appels : %q", calls, m) + } + } + if m := budgetNudge(100, -5, 0); m != "" { + t.Errorf("budget négatif devrait être inerte, obtenu %q", m) + } +} + +func TestAgentBudgetConfig(t *testing.T) { + testHome(t) + set := func(v string) { + cfg := ReadConfig() + cfg["AGENT_BUDGET"] = v + if err := WriteConfig(cfg); err != nil { + t.Fatal(err) + } + } + for _, c := range []struct { + val string + want int + }{ + {"", agentBudgetDefault}, + {"40", 40}, + {"0", 0}, + {"off", 0}, + {"NON", 0}, + {"n'importe quoi", agentBudgetDefault}, // valeur illisible : on ne l'invente pas + {"-3", agentBudgetDefault}, + } { + set(c.val) + if got := agentBudget(); got != c.want { + t.Errorf("AGENT_BUDGET=%q → %d, attendu %d", c.val, got, c.want) + } + } +} diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 709e003..20b7e4b 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -599,11 +599,20 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // (ni réponse, ni tool_call). On relance alors UNE fois le tour avec un nudge // explicite au lieu d'afficher « pas de réponse ». nudged := false - // Pas de plafond d'itérations ni d'anti-boucle : ils coupaient des tours - // parfaitement légitimes (une recherche enchaîne facilement des dizaines - // d'appels, parfois identiques). Le seul frein est le bouton stop, qui annule - // le contexte — c'est un choix assumé. + // Budget SOUPLE d'appels d'outils (llm_budget.go). Toujours pas de plafond + // d'itérations : couper un tour cassait des recherches légitimes. Mais au-delà + // d'un palier on RAPPELLE au modèle combien d'appels il a déjà faits et on lui + // demande de conclure — de la pression, pas une barrière. Sans ça, un modèle + // qui tourne en rond n'avait rien en face de lui sauf le bouton stop. + toolRuns, budgetNudges, budget := 0, 0, agentBudget() for iter := 0; ; iter++ { + // Rappel injecté EN FIN d'historique : le préfixe déjà en cache côté + // llama-server reste valide, seul le nouveau message est à traiter. + if msg := budgetNudge(toolRuns, budget, budgetNudges); msg != "" { + budgetNudges++ + logBudget(toolRuns, budget, budgetNudges) + messages = append(messages, Message{Role: "user", Content: msg}) + } payload := map[string]any{ "model": "loki", "messages": messages, @@ -946,6 +955,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps if ctx.Err() != nil { return extra, nil } + toolRuns++ // alimente le budget souple (voir budgetNudge) var args map[string]any _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) // Derive the human label (command / skill name) up front so we can diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 154beb5..abed389 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -5716,6 +5716,20 @@ function renderStats(el, s){ if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(workLabel()||[], parts).join(' · ')); else paintStats(el, parts.join(' · ')); } +// --- Compteurs de bulle ----------------------------------------------------- +// Ils sont PAR BULLE, jamais par tour. Un tour d'agent en ouvre une nouvelle +// après CHAQUE appel d'outil (le flux repasse T.contentEl à null) ; les +// compteurs, eux, n'étaient jamais remis à zéro. Chaque bulle affichait donc le +// CUMUL de tout le tour, divisé par le temps écoulé depuis le tout premier +// token — exécution des outils, pages web et prefill compris. +// +// Sur un tour agentique d'une heure, ça donnait une vitesse qui décroissait +// mécaniquement de 17 tok/s à 0,9 : le moteur n'avait pas ralenti, le compteur +// mesurait « tokens générés ÷ durée totale du tour ». La remise à zéro est faite +// à la CRÉATION de la bulle : tout chemin qui en ouvre une neuve est couvert, +// aujourd'hui comme demain. +function resetContentStats(){ T.contentTok=0; T.contentFirstTs=0; T.contentLastTs=0; T.speedText=''; } +function resetReasonStats(){ T.reasonTok=0; T.reasonFirstTs=0; T.reasonLastTs=0; } // Label d'une bulle : nombre de tokens + vitesse. La vitesse est calculée à // partir des HORODATAGES SERVEUR (firstTs→lastTs) : le temps réel de génération, // donc correct aussi bien en direct qu'au replay (où les deltas arrivent d'un @@ -5840,11 +5854,11 @@ function handleDelta(d){ return; } if(d.reasoning_content){ killTyping('reasoning'); - if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; T.turnCollapsibles.push(T.reasonEl); } + if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; resetReasonStats(); T.turnCollapsibles.push(T.reasonEl); } // d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà // le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro // au lieu de concaténer, sinon le texte apparaît en double. - if(d.replace){ T.fullReason=''; T.reasonTok=0; T.reasonFirstTs=0; } + if(d.replace){ T.fullReason=''; resetReasonStats(); } showTyping('reasoning'); T.fullReason+=d.reasoning_content; renderBody(T.reasonEl, T.fullReason); // d.toks/d.ts0 présents quand l'événement est coalescé (replay) : plusieurs // tokens d'un coup. Sinon (direct), 1 token, ts0=ts. @@ -5853,8 +5867,8 @@ function handleDelta(d){ return; } if(d.content){ removeTyping(); - if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; } - if(d.replace){ T.fullContent=''; T.contentTok=0; T.contentFirstTs=0; } + if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; resetContentStats(); } + if(d.replace){ T.fullContent=''; resetContentStats(); } T.fullContent+=d.content; renderBody(T.contentEl, T.fullContent); if(!T.contentFirstTs) T.contentFirstTs=d.ts0||d.ts||0; T.contentLastTs=d.ts||T.contentLastTs; T.contentTok+=(d.toks||1); labelTokens(T.contentEl, 'assistant', T.contentTok, T.contentFirstTs, T.contentLastTs); diff --git a/internal/loki/ui/src/js/09-stream.js b/internal/loki/ui/src/js/09-stream.js index d6cc63a..d28bf5c 100644 --- a/internal/loki/ui/src/js/09-stream.js +++ b/internal/loki/ui/src/js/09-stream.js @@ -173,6 +173,20 @@ function renderStats(el, s){ if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(workLabel()||[], parts).join(' · ')); else paintStats(el, parts.join(' · ')); } +// --- Compteurs de bulle ----------------------------------------------------- +// Ils sont PAR BULLE, jamais par tour. Un tour d'agent en ouvre une nouvelle +// après CHAQUE appel d'outil (le flux repasse T.contentEl à null) ; les +// compteurs, eux, n'étaient jamais remis à zéro. Chaque bulle affichait donc le +// CUMUL de tout le tour, divisé par le temps écoulé depuis le tout premier +// token — exécution des outils, pages web et prefill compris. +// +// Sur un tour agentique d'une heure, ça donnait une vitesse qui décroissait +// mécaniquement de 17 tok/s à 0,9 : le moteur n'avait pas ralenti, le compteur +// mesurait « tokens générés ÷ durée totale du tour ». La remise à zéro est faite +// à la CRÉATION de la bulle : tout chemin qui en ouvre une neuve est couvert, +// aujourd'hui comme demain. +function resetContentStats(){ T.contentTok=0; T.contentFirstTs=0; T.contentLastTs=0; T.speedText=''; } +function resetReasonStats(){ T.reasonTok=0; T.reasonFirstTs=0; T.reasonLastTs=0; } // Label d'une bulle : nombre de tokens + vitesse. La vitesse est calculée à // partir des HORODATAGES SERVEUR (firstTs→lastTs) : le temps réel de génération, // donc correct aussi bien en direct qu'au replay (où les deltas arrivent d'un @@ -297,11 +311,11 @@ function handleDelta(d){ return; } if(d.reasoning_content){ killTyping('reasoning'); - if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; T.turnCollapsibles.push(T.reasonEl); } + if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; resetReasonStats(); T.turnCollapsibles.push(T.reasonEl); } // d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà // le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro // au lieu de concaténer, sinon le texte apparaît en double. - if(d.replace){ T.fullReason=''; T.reasonTok=0; T.reasonFirstTs=0; } + if(d.replace){ T.fullReason=''; resetReasonStats(); } showTyping('reasoning'); T.fullReason+=d.reasoning_content; renderBody(T.reasonEl, T.fullReason); // d.toks/d.ts0 présents quand l'événement est coalescé (replay) : plusieurs // tokens d'un coup. Sinon (direct), 1 token, ts0=ts. @@ -310,8 +324,8 @@ function handleDelta(d){ return; } if(d.content){ removeTyping(); - if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; } - if(d.replace){ T.fullContent=''; T.contentTok=0; T.contentFirstTs=0; } + if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; resetContentStats(); } + if(d.replace){ T.fullContent=''; resetContentStats(); } T.fullContent+=d.content; renderBody(T.contentEl, T.fullContent); if(!T.contentFirstTs) T.contentFirstTs=d.ts0||d.ts||0; T.contentLastTs=d.ts||T.contentLastTs; T.contentTok+=(d.toks||1); labelTokens(T.contentEl, 'assistant', T.contentTok, T.contentFirstTs, T.contentLastTs);