Skip to content

Pré-chauffage du prompt : le premier message ne paie plus le préfill du préfixe - #163

Merged
nathaninline merged 3 commits into
nathaninline:mainfrom
youtsuhodev:main
Oct 24, 2026
Merged

nathaninline merged 3 commits into
nathaninline:mainfrom
youtsuhodev:main

Conversation

@youtsuhodev

Copy link
Copy Markdown
Contributor

Contexte

Sur un modèle local, le délai avant le premier jeton d'une conversation est presque entièrement du préfill : préambule système, briefing machine, prompt du preset et surtout les schémas d'outils — plusieurs milliers de jetons. Or ce préfixe est identique d'une conversation à l'autre (le contexte projet, lui, part dans le premier message utilisateur, voir normalizeSystemMessages).

Aujourd'hui ce préfill est repayé après chaque démarrage du moteur, alors que llama-server sait garder le cache KV du créneau (--cache-ram, --cache-idle-slots sont actifs par défaut) : un préfixe traité une fois n'a plus besoin d'être retraité.

Ce que fait ce changement

Dès que le moteur répond, une complétion minuscule (max_tokens=1) est envoyée avec exactement le préfixe du premier tour. Elle reste interne : rien n'est affiché, la réponse est jetée, et la connexion reste ouverte dans le pool par défaut pour que le vrai message la réutilise.

// internal/ajean/llm_prewarm.go
system, tools, ok := prewarmPrefix(globalCaps())
if !ok {
	return // modèle brut : rien à chauffer
}

Mécanique

Élément Valeur
Préfixe chauffé baseSystemPrompt + machineSystemPrompt + readSysPrompt (si agent, hors mode terminal), même ordre que generate / InjectSkills
Outils withRecallTools(EnabledTools(caps), nil) — fil neuf, comme runChat avant tout compactage
Signature sha256 sur URL + modèle + système + outils JSON (tronquée à 12 octets)
Corps max_tokens: 1, temperature: 0, stream: false, cache_prompt: true, tools + parallel_tool_calls: false
Bornes délai 30 s, 2 tentatives au plus, recul 15 s × tentative, revérification toutes les 15 s une fois chauffé, un seul envoi à la fois
Déclenchement newWebMux() (santé immédiate, sinon sonde toutes les 3 s pendant 10 min) et handleStatus sur /api/status (goroutine détachée)
Ré-armement passage healthy: false → true (moteur redémarré) remet signature, warmed et tentatives à zéro

Garde-fous

  • Jamais pendant une génération. doLLM incrémente llmInFlight (chat, tâches, Jean, apps, compaction, cloud) ; le pré-chauffage s'abstient tant qu'il est non nul.
  • Jamais dans les trous d'un tour. llmTurnsActive est incrémenté par StartTurn et décrémenté dans le defer de generate : il couvre l'exécution d'un outil ou une lecture de fichier, quand llmInFlight est retombé à zéro.
  • Le message de l'utilisateur passe devant. cancelLLMPrewarmForTurn() est appelé au démarrage du tour et coupe un pré-chauffage déjà parti ; le cache est marqué chauffé puisque le tour traitera le préfixe de toute façon.
  • Pas de travail lourd sous verrou. Le préfixe est construit hors verrou (les serveurs MCP peuvent se connecter, Crawl4AI est sondé) ; l'état est revérifié après coup, au cas où un tour aurait démarré entre-temps.
  • Aucun coût inutile. Rien n'est envoyé pour un modèle brut (ni système ni outil), ni pour un preset externe ou un GPU cloud, où le préchauffage n'a pas de sens. Le résultat d'un envoi obsolète (signature changée) n'écrase pas l'état du nouveau préfixe.

Fichiers

Fichier Nature
internal/ajean/llm_prewarm.go nouveau — état, construction du préfixe, envoi
internal/ajean/llm_prewarm_test.go nouveau — 4 tests
internal/ajean/backend_cloud.go llmInFlight dans doLLM
internal/ajean/chat_conversation.go llmTurnsActive + annulation au démarrage du tour
internal/ajean/web_server.go déclenchement au démarrage du service d'interface
internal/ajean/web_api.go déclenchement sur /api/status

Vérifications

  • go build ./... — OK
  • go vet ./... — OK
  • staticcheck (v0.8.2) — aucun avertissement
  • gofmt -l . — propre (toolchain go1.25.0, celui de go.mod)
  • go test ./... — OK, sauf TestMoeMigrate qui échoue par absence du privilège Windows de création de lien symbolique (A required privilege is not held by the client) : limite de la machine de développement, sans rapport avec cette PR.

Les quatre tests ajoutés verrouillent les invariants qui rendent le pré-chauffage utile :

  • TestPrewarmPrefixMatchesChatPrefix — le bloc système et les outils pré-chauffés sont identiques à ce que InjectSkills + normalizeSystemMessages + runChat produisent pour une conversation neuve ; si ce n'est plus vrai, le cache ne se raccorde pas et le pré-chauffage devient du travail perdu.
  • TestPrewarmPrefixEmptyForRawModel — aucun envoi pour un modèle brut.
  • TestPrewarmPayload — corps minimal (un jeton, non streamé, cache_prompt), tools absent quand il n'y a pas d'outil.
  • TestLLMPrewarmSendsOnce — envoi réel contre un serveur de test, puis aucun second envoi pour le même préfixe.

…pond, en tâche de fond

Le préfill du premier message d'une conversation était payé à chaque démarrage
du moteur. Un pré-chauffage interne (llm_prewarm.go) envoie la même requête
max_tokens=1 sur le préfixe exact du premier tour, puis le laisse dans le cache
KV. Il attend le moteur, s'efface devant un tour utilisateur ou une génération en
cours, se relance si preset/prompt/outils changent, et s'abstient en preset
externe, cloud ou modèle brut.
@nathaninline

Copy link
Copy Markdown
Owner

Merci pour la PR, l'idée est bonne mais je ne peux pas la prendre en l'état, pour deux raisons :

  • Avec -np 1 (le cas courant ici), il n'y a qu'un seul créneau de cache. Le pré-chauffage se relance au redémarrage de l'UI ou quand le prompt ou les outils changent, alors qu'une conversation est en cours : il écrase son cache, et le message suivant repaie tout l'historique.
  • Le préfixe est construit avec globalCaps(), pas avec les réglages du mode réellement utilisé (Jean, Projet, Rapide). Le préfixe chauffé ne correspond donc souvent pas au vrai premier tour. Le test TestPrewarmPrefixMatchesChatPrefix ne le voit pas, parce qu'il compare prewarmPrefix avec les mêmes fonctions qu'il appelle.

Pour que ça passe : ne pré-chauffer que si la conversation active est vide, utiliser les réglages du mode actif, et un test qui compare avec la vue réellement envoyée par generate.

Prewarm only empty conversations using the mode and capabilities that will handle the first message. Remember the last chat mode, skip warming when the target is unknown or mismatched, and guard against a conversation starting before the request is sent. Add tests for mode-specific prefixes and conversation safety.
@youtsuhodev

Copy link
Copy Markdown
Contributor Author

Merci pour la PR, l'idée est bonne mais je ne peux pas la prendre en l'état, pour deux raisons :

* Avec `-np 1` (le cas courant ici), il n'y a qu'un seul créneau de cache. Le pré-chauffage se relance au redémarrage de l'UI ou quand le prompt ou les outils changent, alors qu'une conversation est en cours : il écrase son cache, et le message suivant repaie tout l'historique.

* Le préfixe est construit avec `globalCaps()`, pas avec les réglages du mode réellement utilisé (Jean, Projet, Rapide). Le préfixe chauffé ne correspond donc souvent pas au vrai premier tour. Le test `TestPrewarmPrefixMatchesChatPrefix` ne le voit pas, parce qu'il compare `prewarmPrefix` avec les mêmes fonctions qu'il appelle.

Pour que ça passe : ne pré-chauffer que si la conversation active est vide, utiliser les réglages du mode actif, et un test qui compare avec la vue réellement envoyée par generate.

C'est fait

@nathaninline
nathaninline merged commit 01ba998 into nathaninline:main Oct 24, 2026
2 checks passed
@nathaninline

Copy link
Copy Markdown
Owner

Merci, c'est fusionné. Mesuré sur notre machine avec le moteur MoE : le premier mot d'une nouvelle conversation passe de 4,3 s à 0,3 s en mode Projet, et de 5,9 s à 0,3 s en mode Jean. J'ai ajouté un interrupteur dans Paramètres pour couper le pré-chauffage (actif par défaut).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants