Passe de qualité : contexte explosé par les captures, sonde vision, courses

Le journal moteur de production a livré la vraie cause première des 500 en
cascade : « request (55407 tokens) exceeds the available context size
(32768) ». Le message multimodal qui relaie une capture d'écran au modèle
était PERSISTÉ dans l'historique ; son base64 (des dizaines de milliers de
tokens) repartait à chaque tour, et la conversation dépassait
définitivement la fenêtre — plus aucun tour ne passait, et l'exception
Jinja du rattrapage (corrigée au commit précédent) masquait tout.

- L'image devient ÉPHÉMÈRE : jointe au tour en cours, jamais à l'historique.
  Le modèle la regarde maintenant ; sa description textuelle, elle, reste.
- stripImageParts guérit les conversations déjà empoisonnées au chargement
  et à la bascule : parties image retirées, texte aplati.
- engineSeesImages : l'image n'est envoyée que si llama-server DÉCLARE la
  vision (/props, modalities.vision, cache 10 s). La clé MMPROJ ne suffit
  pas — projecteur d'un autre modèle ou modèle sans vision (gpt-oss), le
  gabarit sérialise le base64 en texte. La description de l'outil suit le
  même état : ne jamais promettre une image qui n'arrivera pas.

Deux défauts trouvés à l'analyse, au passage :
- renderBody reconstruit le DOM à chaque delta du streaming : une image déjà
  affichée était RE-TÉLÉCHARGÉE à chaque token arrivé après elle. Cache de
  blobs par URL, une seule requête par capture.
- les opérations de discussions (création, bascule, renommage, suppression)
  entrelaçaient leurs lectures-écritures d'index sous requêtes simultanées :
  sérialisées par un verrou dédié.

Tests : historique guéri (aplati sans l'image), transmission conditionnée à
la sonde, description alignée ; suite complète, vet, staticcheck verts.
This commit is contained in:
Loki committed 2026-08-15 14:43:41 +00:00
1 parent 73196a1ff6
commit 4854ae3784
7 files changed
+172 -14

No files matched your search

+4
View File
@@ -80,6 +80,9 @@ func LoadConversation() {
conv.mu.Lock() conv.mu.Lock()
defer conv.mu.Unlock() defer conv.mu.Unlock()
_ = json.Unmarshal(b, conv) _ = json.Unmarshal(b, conv)
// Guérit les conversations d'avant le passage des captures en éphémère : un
// base64 d'image persisté était rejoué à chaque tour et dépassait le contexte.
conv.Messages = stripImageParts(conv.Messages)
// Une génération n'a pas pu survivre à l'arrêt du process : on repart propre. // Une génération n'a pas pu survivre à l'arrêt du process : on repart propre.
conv.Generating = false conv.Generating = false
conv.cancel = nil conv.cancel = nil
@@ -95,6 +98,7 @@ func (c *Conversation) loadFrom(b []byte) {
c.Messages, c.Log, c.Seq, c.CtxUsed = nil, nil, 0, 0 c.Messages, c.Log, c.Seq, c.CtxUsed = nil, nil, 0, 0
if len(b) > 0 { if len(b) > 0 {
_ = json.Unmarshal(b, c) _ = json.Unmarshal(b, c)
c.Messages = stripImageParts(c.Messages) // même guérison qu'au chargement
} }
c.Generating = false c.Generating = false
c.cancel = nil c.cancel = nil
+83 -4
View File
@@ -17,13 +17,16 @@ package loki
import ( import (
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json"
"fmt" "fmt"
"net/http"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"sort" "sort"
"strings" "strings"
"sync"
"time" "time"
) )
@@ -87,15 +90,91 @@ func webScreenshotTool() Tool {
} }
// screenshotVisionNote : ce que le modèle doit savoir de SA propre perception. // screenshotVisionNote : ce que le modèle doit savoir de SA propre perception.
// Avec un projecteur configuré, la capture lui est réellement transmise (voir // Alignée sur la capacité RÉELLE du moteur, pas seulement sur la clé MMPROJ —
// screenshotImageMessage) ; sans projecteur, il photographie sans regarder. // sinon on promet une image qui n'arrivera pas et le modèle se contredit.
func screenshotVisionNote() string { func screenshotVisionNote() string {
if visionEnabled() { if visionEnabled() && engineSeesImages() {
return "L'image t'est ensuite montrée : tu peux la décrire." return "L'image t'est ensuite montrée : tu peux la décrire."
} }
return "Tu ne vois pas l'image." return "Tu ne vois pas l'image."
} }
// engineSeesImages : le moteur ACCEPTE-t-il réellement des images ? On
// interroge /props de llama-server (modalities.vision), avec un cache court —
// l'appel est local et instantané moteur en marche, mais moteur ARRÊTÉ chaque
// sonde attendrait le timeout, et elle est faite à chaque construction du
// catalogue d'outils.
//
// La clé MMPROJ ne suffit pas : configurée avec le projecteur d'un AUTRE
// modèle, ou avec un modèle sans vision (gpt-oss), le gabarit sérialise le
// base64 de l'image en TEXTE — la requête vue en production pesait 55 000
// tokens pour 32 768 de contexte, et tous les tours suivants échouaient.
var (
visionProbeMu sync.Mutex
visionProbeAt time.Time
visionProbeSeen bool
)
func engineSeesImages() bool {
visionProbeMu.Lock()
defer visionProbeMu.Unlock()
if time.Since(visionProbeAt) < 10*time.Second {
return visionProbeSeen
}
visionProbeAt = time.Now()
visionProbeSeen = false
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/props", LLMPort()))
if err != nil {
return false
}
defer resp.Body.Close()
var props struct {
Modalities struct {
Vision bool `json:"vision"`
} `json:"modalities"`
}
if resp.StatusCode != 200 || json.NewDecoder(resp.Body).Decode(&props) != nil {
return false
}
visionProbeSeen = props.Modalities.Vision
return visionProbeSeen
}
// stripImageParts retire les parties image_url des messages persistés et
// aplatit ce qui reste en texte simple. Deux raisons :
// - guérir les conversations créées AVANT le passage de l'image en éphémère,
// où un base64 de plusieurs dizaines de milliers de tokens était rejoué à
// chaque tour jusqu'à dépasser définitivement le contexte ;
// - garantir l'invariant à l'avenir, quel que soit le chemin d'écriture.
func stripImageParts(msgs []Message) []Message {
for i, m := range msgs {
parts, ok := m.Content.([]any)
if !ok {
continue
}
var texts []string
dropped := false
for _, p := range parts {
pm, ok := p.(map[string]any)
if !ok {
continue
}
if pm["type"] == "image_url" {
dropped = true
continue
}
if t, ok := pm["text"].(string); ok {
texts = append(texts, t)
}
}
if dropped {
msgs[i].Content = strings.Join(texts, "\n")
}
}
return msgs
}
// screenshotImageMessage construit le message utilisateur qui PORTE la capture // screenshotImageMessage construit le message utilisateur qui PORTE la capture
// jusqu'au modèle. Le résultat d'un outil est un message `tool`, qui ne // jusqu'au modèle. Le résultat d'un outil est un message `tool`, qui ne
// transporte que du texte : pour qu'un modèle multimodal voie l'image, elle doit // transporte que du texte : pour qu'un modèle multimodal voie l'image, elle doit
@@ -106,7 +185,7 @@ func screenshotVisionNote() string {
// llama-server rejette un contenu image sans --mmproj, donc mieux vaut ne rien // llama-server rejette un contenu image sans --mmproj, donc mieux vaut ne rien
// envoyer que de faire échouer le tour. // envoyer que de faire échouer le tour.
func screenshotImageMessage(relPath string) (Message, bool) { func screenshotImageMessage(relPath string) (Message, bool) {
if !visionEnabled() { if !visionEnabled() || !engineSeesImages() {
return Message{}, false return Message{}, false
} }
abs := filepath.Join(agentWorkspace(), filepath.FromSlash(relPath)) abs := filepath.Join(agentWorkspace(), filepath.FromSlash(relPath))
+34 -1
View File
@@ -87,6 +87,11 @@ func TestConvDeleteSupprimeLesCaptures(t *testing.T) {
func TestScreenshotSuitLEtatDeLaVision(t *testing.T) { func TestScreenshotSuitLEtatDeLaVision(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir()) t.Setenv("LOKI_HOME", t.TempDir())
// Sans projecteur, la sonde moteur ne doit même pas être consultée : on
// s'assure que son cache est froid pour que le test reste hermétique.
visionProbeMu.Lock()
visionProbeAt, visionProbeSeen = time.Now(), false
visionProbeMu.Unlock()
if got := screenshotVisionNote(); !strings.Contains(got, "ne vois pas") { if got := screenshotVisionNote(); !strings.Contains(got, "ne vois pas") {
t.Fatalf("sans projecteur, la description doit annoncer l'absence de vision : %q", got) t.Fatalf("sans projecteur, la description doit annoncer l'absence de vision : %q", got)
} }
@@ -105,10 +110,16 @@ func TestScreenshotSuitLEtatDeLaVision(t *testing.T) {
t.Fatal("image transmise au modèle alors qu'aucun projecteur n'est configuré") t.Fatal("image transmise au modèle alors qu'aucun projecteur n'est configuré")
} }
// Projecteur configuré → description ET transmission changent. // Projecteur configuré ET moteur qui déclare la vision (on amorce le cache
// de la sonde : aucun llama-server ne tourne pendant les tests) →
// description ET transmission changent.
if err := SetConfigKey("MMPROJ", "mmproj-test.gguf"); err != nil { if err := SetConfigKey("MMPROJ", "mmproj-test.gguf"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
visionProbeMu.Lock()
visionProbeAt, visionProbeSeen = time.Now(), true
visionProbeMu.Unlock()
defer func() { visionProbeMu.Lock(); visionProbeAt = time.Time{}; visionProbeMu.Unlock() }()
if got := screenshotVisionNote(); strings.Contains(got, "ne vois pas") { if got := screenshotVisionNote(); strings.Contains(got, "ne vois pas") {
t.Fatalf("avec projecteur, la description ne doit plus nier la vision : %q", got) t.Fatalf("avec projecteur, la description ne doit plus nier la vision : %q", got)
} }
@@ -125,6 +136,28 @@ func TestScreenshotSuitLEtatDeLaVision(t *testing.T) {
} }
} }
// Un base64 d'image persisté dans l'historique est rejoué à chaque tour : vu en
// production, 55 000 tokens de requête pour 32 768 de contexte — plus aucun
// tour ne passait. stripImageParts guérit les conversations existantes en
// retirant les parties image et en aplatissant le texte restant.
func TestStripImagePartsGueritLHistorique(t *testing.T) {
msgs := []Message{
{Role: "user", Content: "bonjour"}, // simple chaîne : intouchée
{Role: "user", Content: []any{ // message multimodal persisté (via JSON)
map[string]any{"type": "text", "text": "Voici la capture demandée."},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/jpeg;base64,AAAA"}},
}},
}
out := stripImageParts(msgs)
if out[0].Content.(string) != "bonjour" {
t.Fatalf("message texte modifié : %#v", out[0].Content)
}
got, ok := out[1].Content.(string)
if !ok || got != "Voici la capture demandée." {
t.Fatalf("le message multimodal doit être aplati en texte sans l'image, obtenu %#v", out[1].Content)
}
}
// Le chemin de la capture est extrait du texte rendu par l'outil : si le format // Le chemin de la capture est extrait du texte rendu par l'outil : si le format
// de ce texte change, le relais vers le modèle casse en silence. // de ce texte change, le relais vers le modèle casse en silence.
func TestCapturedRelPath(t *testing.T) { func TestCapturedRelPath(t *testing.T) {
+17
View File
@@ -24,6 +24,7 @@ import (
"fmt" "fmt"
"sort" "sort"
"strings" "strings"
"sync"
"time" "time"
) )
@@ -33,6 +34,14 @@ const (
ckLegacy = "conversation" // fil unique d'avant le multi-discussions ckLegacy = "conversation" // fil unique d'avant le multi-discussions
) )
// convOpMu sérialise les opérations de haut niveau sur les discussions
// (création, bascule, renommage, suppression). Chacune fait plusieurs
// lectures-écritures de l'index et de la clé active : deux requêtes HTTP
// simultanées (deux appareils, double-clic) pouvaient entrelacer ces étapes et
// perdre une entrée d'index ou basculer sur une discussion supprimée. Le verrou
// de Conversation protège l'état en mémoire, pas cette séquence-là.
var convOpMu sync.Mutex
// convMeta décrit une discussion SANS ses messages : c'est ce que liste l'UI. // convMeta décrit une discussion SANS ses messages : c'est ce que liste l'UI.
type convMeta struct { type convMeta struct {
ID string `json:"id"` ID string `json:"id"`
@@ -136,6 +145,8 @@ func ConvList() ([]convMeta, string) { return convIndex(), convEnsureActive() }
// la cible chargée en mémoire, et l'epoch incrémenté pour que tous les clients // la cible chargée en mémoire, et l'epoch incrémenté pour que tous les clients
// rejouent le nouveau fil. // rejouent le nouveau fil.
func convSwitch(id string) error { func convSwitch(id string) error {
convOpMu.Lock()
defer convOpMu.Unlock()
if id == "" { if id == "" {
return fmt.Errorf("identifiant manquant") return fmt.Errorf("identifiant manquant")
} }
@@ -172,11 +183,15 @@ func convCreate() string {
// convNew met de côté la discussion courante puis en ouvre une neuve. // convNew met de côté la discussion courante puis en ouvre une neuve.
func convNew() string { func convNew() string {
convOpMu.Lock()
defer convOpMu.Unlock()
conv.persist() conv.persist()
return convCreate() return convCreate()
} }
func convRename(id, title string) error { func convRename(id, title string) error {
convOpMu.Lock()
defer convOpMu.Unlock()
title = strings.TrimSpace(title) title = strings.TrimSpace(title)
if id == "" || title == "" { if id == "" || title == "" {
return fmt.Errorf("identifiant ou titre manquant") return fmt.Errorf("identifiant ou titre manquant")
@@ -196,6 +211,8 @@ func convRename(id, title string) error {
// sur la plus récente restante — ou sur une discussion neuve s'il n'en reste // sur la plus récente restante — ou sur une discussion neuve s'il n'en reste
// aucune : il y a TOUJOURS une discussion active. // aucune : il y a TOUJOURS une discussion active.
func convDelete(id string) error { func convDelete(id string) error {
convOpMu.Lock()
defer convOpMu.Unlock()
idx := convIndex() idx := convIndex()
next := make([]convMeta, 0, len(idx)) next := make([]convMeta, 0, len(idx))
found := false found := false
+8 -1
View File
@@ -1101,11 +1101,18 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// texte, donc sans ce relais le modèle recevait le chemin du fichier // texte, donc sans ce relais le modèle recevait le chemin du fichier
// et rien d'autre — il annonçait alors à l'utilisateur qu'il ne // et rien d'autre — il annonçait alors à l'utilisateur qu'il ne
// voyait pas l'image, alors que le projecteur était bien chargé. // voyait pas l'image, alors que le projecteur était bien chargé.
//
// ÉPHÉMÈRE : l'image va dans `messages` (le tour en cours) mais PAS
// dans `extra` (l'historique persistant). Un base64 de capture pèse
// des dizaines de milliers de tokens ; persisté, il était renvoyé à
// CHAQUE tour suivant et la conversation dépassait définitivement le
// contexte (vu en production : requêtes de 55 000 tokens pour une
// fenêtre de 32 768, plus aucun tour ne passait). Le modèle regarde
// l'image MAINTENANT et sa description textuelle, elle, reste.
if tc.Function.Name == "web_screenshot" { if tc.Function.Name == "web_screenshot" {
if rel := capturedRelPath(result); rel != "" { if rel := capturedRelPath(result); rel != "" {
if imgMsg, ok := screenshotImageMessage(rel); ok { if imgMsg, ok := screenshotImageMessage(rel); ok {
messages = append(messages, imgMsg) messages = append(messages, imgMsg)
extra = append(extra, imgMsg)
} }
} }
} }
+13 -4
View File
@@ -4184,21 +4184,30 @@ function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(encodeMdLink
// est définie. On récupère donc l'image par fetch authentifié et on la pose en // est définie. On récupère donc l'image par fetch authentifié et on la pose en
// blob:. Sans ça, toute instance protégée par une clé n'affichait que des images // blob:. Sans ça, toute instance protégée par une clé n'affichait que des images
// cassées. Les URLs externes (http…) ne sont pas touchées. // cassées. Les URLs externes (http…) ne sont pas touchées.
// Cache des blobs par URL source. renderBody est rappelé à CHAQUE delta du
// streaming et reconstruit le DOM : sans cache, une image déjà affichée était
// re-téléchargée à chaque token arrivé après elle. On garde l'objet URL (pas de
// revoke) : quelques captures par discussion, mémoire négligeable, et un revoke
// casserait les rendus suivants qui réutilisent la même entrée.
const IMG_CACHE = new Map();
function hydrateImages(root){ function hydrateImages(root){
root.querySelectorAll('img[src*="/api/chat/image"]').forEach(async img => { root.querySelectorAll('img[src*="/api/chat/image"]').forEach(async img => {
if(img.dataset.hydrated) return; if(img.dataset.hydrated) return;
img.dataset.hydrated = '1'; img.dataset.hydrated = '1';
const src = img.getAttribute('src'); const src = img.getAttribute('src');
img.classList.add('chatimg');
const cached = IMG_CACHE.get(src);
if(cached){ img.src = cached; return; }
try{ try{
const r = await jfetch(src.startsWith('/') ? src : '/' + src); const r = await jfetch(src.startsWith('/') ? src : '/' + src);
if(!r.ok) throw new Error(r.status); if(!r.ok) throw new Error(r.status);
const url = URL.createObjectURL(await r.blob()); const url = URL.createObjectURL(await r.blob());
IMG_CACHE.set(src, url);
img.src = url; img.src = url;
img.classList.add('chatimg');
// La révocation attend le chargement : révoquer tout de suite laisserait
// une image vide sur les navigateurs qui décodent en différé.
img.addEventListener('load', () => URL.revokeObjectURL(url), {once:true});
}catch(e){ }catch(e){
// Pas de remplacement définitif pendant le STREAMING : l'URL peut être
// tronquée en cours de frappe (404 transitoire) et le prochain delta
// re-rend le markdown complet. On note l'échec sans le graver.
const note = document.createElement('span'); const note = document.createElement('span');
note.className = 'muted'; note.className = 'muted';
note.textContent = '[image indisponible : ' + src.replace(/^.*path=/, '') + ']'; note.textContent = '[image indisponible : ' + src.replace(/^.*path=/, '') + ']';
+13 -4
View File
@@ -146,21 +146,30 @@ function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(encodeMdLink
// est définie. On récupère donc l'image par fetch authentifié et on la pose en // est définie. On récupère donc l'image par fetch authentifié et on la pose en
// blob:. Sans ça, toute instance protégée par une clé n'affichait que des images // blob:. Sans ça, toute instance protégée par une clé n'affichait que des images
// cassées. Les URLs externes (http…) ne sont pas touchées. // cassées. Les URLs externes (http…) ne sont pas touchées.
// Cache des blobs par URL source. renderBody est rappelé à CHAQUE delta du
// streaming et reconstruit le DOM : sans cache, une image déjà affichée était
// re-téléchargée à chaque token arrivé après elle. On garde l'objet URL (pas de
// revoke) : quelques captures par discussion, mémoire négligeable, et un revoke
// casserait les rendus suivants qui réutilisent la même entrée.
const IMG_CACHE = new Map();
function hydrateImages(root){ function hydrateImages(root){
root.querySelectorAll('img[src*="/api/chat/image"]').forEach(async img => { root.querySelectorAll('img[src*="/api/chat/image"]').forEach(async img => {
if(img.dataset.hydrated) return; if(img.dataset.hydrated) return;
img.dataset.hydrated = '1'; img.dataset.hydrated = '1';
const src = img.getAttribute('src'); const src = img.getAttribute('src');
img.classList.add('chatimg');
const cached = IMG_CACHE.get(src);
if(cached){ img.src = cached; return; }
try{ try{
const r = await jfetch(src.startsWith('/') ? src : '/' + src); const r = await jfetch(src.startsWith('/') ? src : '/' + src);
if(!r.ok) throw new Error(r.status); if(!r.ok) throw new Error(r.status);
const url = URL.createObjectURL(await r.blob()); const url = URL.createObjectURL(await r.blob());
IMG_CACHE.set(src, url);
img.src = url; img.src = url;
img.classList.add('chatimg');
// La révocation attend le chargement : révoquer tout de suite laisserait
// une image vide sur les navigateurs qui décodent en différé.
img.addEventListener('load', () => URL.revokeObjectURL(url), {once:true});
}catch(e){ }catch(e){
// Pas de remplacement définitif pendant le STREAMING : l'URL peut être
// tronquée en cours de frappe (404 transitoire) et le prochain delta
// re-rend le markdown complet. On note l'échec sans le graver.
const note = document.createElement('span'); const note = document.createElement('span');
note.className = 'muted'; note.className = 'muted';
note.textContent = '[image indisponible : ' + src.replace(/^.*path=/, '') + ']'; note.textContent = '[image indisponible : ' + src.replace(/^.*path=/, '') + ']';