Cache : PREWARM prépare le prochain tour pendant que l'utilisateur lit, en opt-in

Une compaction, un dernier message rendu autrement au tour suivant ou une
tâche planifiée qui a pris le slot laissent au message suivant un recalcul
inévitable : des secondes sur un 27B, des dizaines sur un MoE aux experts en
RAM, avant le premier mot. Nouvelle clé PREWARM (off par défaut) : dès que le
moteur local est libre, Loki lui envoie la requête du prochain tour suivie
d'un message utilisateur « . », max_tokens 1, sans flux. Le moteur calcule le
préfixe et pose un point de reprise au début de ce message ; le vrai tour ne
calcule plus que le sien. Sans la clé, aucune requête de plus (testé).

- assemblage partagé, contenu vivant : turnViewDry (même code que turnView,
  sans rien toucher à la discussion, décision PROJ_SNAPSHOT via
  projSnapDecide), wireMessages, turnReasoning et buildChatPayload, extraits
  de runChat ; corps identique à l'octet près à celui d'avant (matrice
  d'options et de modes, testée contre une copie de l'ancien code)
- jamais devant un vrai travail : toute requête de Loki l'annule au départ
  (compteur de llm_slots.go, sous son verrou), sauf un tour de chat dont les
  messages sérialisés, outils et arguments du gabarit prolongent exactement
  le préfixe préchauffé
- moteur local, un seul slot (/props total_slots), pas pendant un tour, une
  tâche, un bench, un travail annexe ni une requête en vol ; un seul à la
  fois ; pas si le prochain tour compactera, ni à moins de 10 min de minuit
- requête brute : ni compaction, ni relance, ni effort appris, ni CtxUsed,
  ni stats ; erreurs lâchées (une ligne de journal par statut) ; rien persisté
- déclencheurs : fin de tour sans file d'attente, fin de tâche (projet forcé
  levé, slot effacé) ; full = aussi changement de discussion après 3 s
- capacités du dernier tour gardées en mémoire par discussion ; mode Code =
  rôle d'un message ordinaire, une demande de plan diverge et annule
- télémétrie kind=prewarm ; la vérification du cache RAM après une tâche se
  fait sur lui

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 09:01:57 +02:00
1 parent 4932999d5c
commit 630e84cc08
14 files changed
+1186 -86

No files matched your search

+109 -64
View File
@@ -1117,6 +1117,101 @@ func toolCallLabel(name string, args map[string]any) string {
return label
}
// turnReasoning : ce que la requête dit du raisonnement, tiré de la
// configuration. Partagé par runChat et le préchauffage (chat_prewarm.go).
func turnReasoning(cfg map[string]string) (effortWanted, effort string, thinkOff bool, kwargs map[string]any) {
// 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.
effortWanted = reasoningEffortValue(cfg["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.
effort = 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(cfg["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.
kwargs = reasoningTemplateKwargs(thinkOff, effort)
return effortWanted, effort, thinkOff, kwargs
}
// turnEchoPolicy : la politique de REASONING_ECHO d'une requête ; off = preset
// externe ou repli en cours, rien ne repart.
func turnEchoPolicy(off bool) echoPolicy {
if off {
return echoPolicy{}
}
return currentEchoPolicy()
}
// wireMessages : les messages tels qu'ils partent au moteur — un seul système
// en tête, la consigne de fin si les outils sont neutralisés, le raisonnement
// renvoyé selon pol — et le nombre de raisonnements renvoyés.
func wireMessages(messages []Message, toolChoiceNone bool, pol echoPolicy) ([]Message, int) {
sent := normalizeSystemMessages(messages)
if toolChoiceNone {
sent = withTrailingHint(sent, toolsOffHint)
}
return echoMessages(sent, pol)
}
// chatPayloadOpts : ce qui, en plus des messages, façonne une requête de chat.
type chatPayloadOpts struct {
tools []Tool
disableTools bool
toolChoiceNone bool
effort string
kwargs map[string]any
}
// buildChatPayload : le corps d'une requête de complétion de chat, à partir des
// messages déjà passés par wireMessages. UN seul endroit le construit : le
// préchauffage (chat_prewarm.go) envoie ce même corps, aux seuls champs de
// réponse près (stream, max_tokens), et ne peut donc pas en dériver.
func buildChatPayload(ep chatEndpoint, sent []Message, temperature float64, o chatPayloadOpts) map[string]any {
payload := map[string]any{
"model": ep.Model,
// Les images de l'historique y sont rangées par référence
// (chat_images.go) : on remet leurs octets juste avant l'envoi.
"messages": expandImageRefs(sent),
"stream": true,
"temperature": temperature,
// include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE
// du prompt (préfixe caché compris), contrairement à timings.prompt_n qui
// ne compte que les tokens nouvellement traités. Sert au comptage exact du
// contexte (sinon le system prompt déjà en cache n'est pas recompté).
"stream_options": map[string]any{"include_usage": true},
}
// Échantillonnage du preset (top_p/top_k/min_p/pénalités, et TEMP qui
// l'emporte sur la température ci-dessus). Posé AVANT le raisonnement : les
// deux blocs écrivent des clés disjointes, mais l'ordre rend explicite que
// c'est bien loki qui a le dernier mot sur `chat_template_kwargs`.
applySampling(payload)
if o.effort != "" {
payload["reasoning_effort"] = o.effort
}
// chat_template_kwargs est propre à llama.cpp (--jinja) : une API
// distante stricte (OpenAI) refuse un argument inconnu (400).
if o.kwargs != nil && !ep.External {
payload["chat_template_kwargs"] = o.kwargs
}
if len(o.tools) > 0 && !o.disableTools {
payload["tools"] = o.tools
// The model sometimes emits parallel tool calls, which this llama.cpp
// build serialises as two concatenated JSON objects in one arguments
// string ("{...}{...}") and then fails to parse (HTTP 500). Forcing a
// single tool call per turn avoids that.
payload["parallel_tool_calls"] = false
if o.toolChoiceNone {
payload["tool_choice"] = "none"
}
}
return payload
}
// injectQueued (optionnel, variadique pour ne pas toucher aux appels hors chat)
// est consulté à CHAQUE frontière d'étape de la boucle d'outils : il renvoie les
// messages utilisateur mis en file PENDANT la génération, pour que le modèle les
@@ -1157,21 +1252,7 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
// (perf_log.go) : rien de tout ça ne part dans la requête.
ptag, perfIter := perfTagOf(ctx), 0
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.
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(thinkOff, reasoningEffort)
effortWanted, reasoningEffort, thinkOff, reasoningKwargs := turnReasoning(chatCfg)
// 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.
@@ -1289,55 +1370,17 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
// stricts (Qwen3.x) refusent un système ailleurs qu'en position 0.
// Gardé à part : la télémétrie compare ces messages-là d'une requête à
// l'autre (perfPrefix).
sent := normalizeSystemMessages(messages)
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) :
// Raisonnement renvoyé : décidé 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
// (chat_images.go) : on remet leurs octets juste avant l'envoi.
"messages": expandImageRefs(sent),
"stream": true,
"temperature": temperature,
// include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE
// du prompt (préfixe caché compris), contrairement à timings.prompt_n qui
// ne compte que les tokens nouvellement traités. Sert au comptage exact du
// contexte (sinon le system prompt déjà en cache n'est pas recompté).
"stream_options": map[string]any{"include_usage": true},
}
// Échantillonnage du preset (top_p/top_k/min_p/pénalités, et TEMP qui
// l'emporte sur la température ci-dessus). Posé AVANT le raisonnement : les
// deux blocs écrivent des clés disjointes, mais l'ordre rend explicite que
// c'est bien loki qui a le dernier mot sur `chat_template_kwargs`.
applySampling(payload)
if reasoningEffort != "" {
payload["reasoning_effort"] = reasoningEffort
}
// chat_template_kwargs est propre à llama.cpp (--jinja) : une API
// distante stricte (OpenAI) refuse un argument inconnu (400).
if reasoningKwargs != nil && !ep.External {
payload["chat_template_kwargs"] = reasoningKwargs
}
if len(tools) > 0 && !disableTools {
payload["tools"] = tools
// The model sometimes emits parallel tool calls, which this llama.cpp
// build serialises as two concatenated JSON objects in one arguments
// string ("{...}{...}") and then fails to parse (HTTP 500). Forcing a
// single tool call per turn avoids that.
payload["parallel_tool_calls"] = false
if toolChoiceNone {
payload["tool_choice"] = "none"
}
}
// Assemblage partagé avec le préchauffage (chat_prewarm.go) : les deux ne
// peuvent pas diverger.
pol := turnEchoPolicy(ep.External || echoOffTurn)
sent, echoSent := wireMessages(messages, toolChoiceNone, pol)
payload := buildChatPayload(ep, sent, temperature, chatPayloadOpts{
tools: tools, disableTools: disableTools, toolChoiceNone: toolChoiceNone,
effort: reasoningEffort, kwargs: reasoningKwargs,
})
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body))
if err != nil {
@@ -1352,9 +1395,11 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
// Requête en vol vers le moteur local, jusqu'à la lecture complète du corps :
// l'isolation des travaux annexes n'efface jamais le slot pendant ce temps
// (llm_slots.go). Rien à compter pour une API externe.
// Un préchauffage en vol (PREWARM, chat_prewarm.go) est annulé ici, sauf
// s'il prépare exactement le début de CETTE requête d'un tour de chat.
endReq := func() {}
if !ep.External {
endReq = engineRequestStart()
endReq = engineRequestStartKeep(prewarmKeeper(ptag.kind, sent, payload))
}
resp, err := http.DefaultClient.Do(req)
if err != nil {