Lot 29 : télémétrie des complétions de la façade

Quand la façade tient la conversation (LOKI_CHAT=rust), ses complétions
rejoignent l'anneau de télémétrie du Go (perf_log.go) au lieu de passer
inaperçues : /api/perf/summary les compte, l'optimiseur en tire ses
poids, et l'interface reçoit à nouveau la perte de cache.

- chat/perf.rs : perfRecFromStats, perfRecFromWire (lu sans échec
  possible, mêmes tolérances que perfNum, timings d'un autre type
  présents mais vides comme le Go) et perfPrefix (FNV-1a cumulé) ;
- la boucle du tour range chaque complétion lue, numérotée dans le tour,
  avec ses empreintes si elle est complète ; une complétion coupée par un
  stop est rangée incomplète ; la dernière statistique porte la nature,
  la perte, ce qui s'est intercalé et l'alerte ;
- natures : main (tour), verify (vérification du mode Code), compact
  (résumé de compaction, rangé dès que la réponse se lit) ;
- le Go (/internal/v1/perf/record) range le relevé comme les siens et
  calcule l'alerte au seuil du preset actif, jamais pour un preset
  externe.

Tests : relevés et empreintes (chat/perf.rs), télémétrie de la boucle
(complète, coupée), du vrai hôte et des résumés (tests/lot6.rs),
TestFacadePerf côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-16 01:17:49 +00:00
1 parent f6c191922f
commit 8a61879bfd
15 files changed
+731 -16

No files matched your search

@@ -0,0 +1,75 @@
# Lot 29 — Télémétrie des complétions de la façade
## Contexte
Le Go range chaque complétion dans un anneau en mémoire (`perf_log.go`).
Pour chacune, il note :
- combien du prompt venait du cache, combien a été recalculé ;
- à quelle vitesse ;
- ce que le décodage spéculatif a gardé.
Il expose des agrégats sur `/api/perf/summary`. Il calcule aussi la **perte
de cache** d'une complétion : ce que le cache aurait dû reprendre de la
précédente complétion de la même discussion, et n'a pas repris. Il nomme ce
qui s'est intercalé entre les deux, et signale la perte à l'interface
au-delà d'un seuil tiré du preset.
Quand la façade tient la conversation (`LOKI_CHAT=rust`), ses complétions
n'apparaissaient nulle part. Le résumé des performances ignorait les tours ;
l'optimiseur tirait ses poids d'une télémétrie vide ; l'interface ne
recevait jamais la perte de cache.
## Le lot
Les complétions de la façade rejoignent l'anneau du Go :
- `chat/perf.rs` porte la partie « relevé » :
- `perfRecFromStats` (complétion streamée) ;
- `perfRecFromWire` (réponse non streamée, lue sans échec possible, avec
les mêmes tolérances que `perfNum`) ;
- `perfPrefix` (empreintes FNV-1a cumulées des messages envoyés).
- `TurnHost::perf` range une complétion. La boucle du tour l'appelle :
- après chaque complétion lue, complète ou non, numérotée dans le tour ;
- avec les empreintes seulement si elle est complète ;
- pour une complétion coupée par un stop, rangée incomplète.
La dernière statistique d'une complétion complète porte la nature, la
perte de cache, ce qui s'est intercalé et l'alerte, comme celle du Go.
- La nature : `main` pour un tour, `verify` pour la vérification du mode
Code, `compact` pour un résumé de compaction. Le résumé est rangé dès que
sa réponse se lit, sans empreintes.
- Le Go (`/internal/v1/perf/record`) range le relevé comme les siens :
numéro, instant, perte de cache, ce qui s'est intercalé. Il calcule
l'alerte au seuil du preset actif (`perfLostAlertAt`), jamais pour un
preset externe.
`/api/perf/summary` reste au Go, qui tient l'anneau.
## Tests
- `chat/perf.rs` :
- relevé d'une réponse entière : nombres en chaîne, valeurs hors bornes,
octet parasite, cache d'OpenAI, timings d'un autre type ;
- empreintes cumulées, FNV-1a de référence ;
- relevé au format du Go.
- La boucle du tour :
- deux complétions numérotées, nature et totaux, empreintes qui se
prolongent ;
- dernière statistique avec la perte vue par le Go ;
- complétion coupée rangée incomplète, sans statistique finale.
- Le vrai hôte (`tests/lot6.rs`) :
- complétions d'un tour rangées par le Go, rattachées à la discussion ;
- résumés de compaction (rangés si la réponse se lit, pas sinon).
- Le Go (`TestFacadePerf`) :
- première complétion, perte et ce qui s'est intercalé, alerte ;
- preset externe sans alerte, complétion incomplète ;
- champs envoyés en trop ignorés, corps illisible, GET refusé.
## Écarts assumés
- Les empreintes sont calculées sur le JSON des messages tel que Rust
l'écrit (sans l'échappement HTML du Go). Elles ne se comparent qu'entre
complétions de la même discussion et de la même nature, toutes produites
par la façade.
- Une complétion coupée par un stop est rangée sans ses compteurs partiels.
+37
View File
@@ -0,0 +1,37 @@
package loki
import (
"net/http"
"time"
)
// facade_perf.go — la télémétrie des complétions de la façade (lot 29 de la
// migration). Quand la façade tient la conversation (LOKI_CHAT=rust), ses
// complétions — tours, vérifications, résumés de compaction — rejoignent
// l'anneau du Go (perf_log.go) : /api/perf/summary les compte, la perte de
// cache se calcule contre la complétion précédente de la même discussion, et
// l'alerte suit le seuil du preset actif (perfLostAlertAt), comme pour un
// tour du Go.
// handleInternalPerfRecord : POST {rec, prefix, external} → {seq, lost,
// lost_after, alert}. rec : une complétion (perfRec, sans at ni seq) ;
// prefix : les empreintes cumulées des messages envoyés (perfPrefix), vide
// pour une complétion incomplète ; external : preset externe (jamais
// d'alerte).
func handleInternalPerfRecord(w http.ResponseWriter, r *http.Request) {
var in struct {
Rec perfRec `json:"rec"`
Prefix []uint64 `json:"prefix"`
External bool `json:"external"`
}
if !readInternalJSON(w, r, &in) {
return
}
in.Rec.At, in.Rec.Seq, in.Rec.Lost, in.Rec.LostAfter = time.Time{}, 0, nil, ""
rec := perfRecord(in.Rec, in.Prefix)
alert := false
if rec.Complete && !in.External && ((rec.Lost != nil && *rec.Lost > 0) || (rec.Cached != nil && *rec.Cached == 0)) {
alert = perfAlert(rec, perfLostAlertAt(ReadConfig()))
}
sendJSON(w, http.StatusOK, map[string]any{"seq": rec.Seq, "lost": rec.Lost, "lost_after": rec.LostAfter, "alert": alert})
}
+61
View File
@@ -0,0 +1,61 @@
package loki
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
)
// La télémétrie des complétions de la façade rejoint l'anneau du Go : perte
// de cache calculée contre la complétion précédente de la même discussion,
// ce qui s'est intercalé, et l'alerte au seuil du preset actif.
func TestFacadePerf(t *testing.T) {
testHome(t)
post := func(body string) (int, map[string]any) {
rec := httptest.NewRecorder()
handleInternalPerfRecord(rec, httptest.NewRequest("POST", "/internal/v1/perf/record", strings.NewReader(body)))
var out map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &out)
return rec.Code, out
}
// Première complétion : rien à comparer. Ce que la façade enverrait en
// trop (at, seq, lost) est ignoré.
code, out := post(`{"rec":{"kind":"main","conv":"fp1","iter":0,"complete":true,"total":5000,"cached":0,"new":5000,
"seq":99,"lost":7,"lost_after":"x"},"prefix":[11,22]}`)
if code != 200 || out["lost"] != nil || out["lost_after"] != "" || out["alert"] != false {
t.Fatalf("première : %d %v", code, out)
}
seq := out["seq"].(float64)
if seq == 99 || seq <= 0 {
t.Fatalf("seq : %v", seq)
}
// Une tâche passe entre deux.
perfRecord(perfRec{Kind: perfTask, Conv: "t", Complete: true, Total: 10}, nil)
// La suivante prolonge la première, mais n'en reprend que 100 jetons.
code, out = post(`{"rec":{"kind":"main","conv":"fp1","iter":1,"complete":true,"total":6000,"cached":100,"new":5900},"prefix":[11,22,33]}`)
if code != 200 || out["lost"] != float64(4900) || out["lost_after"] != perfTask || out["alert"] != true {
t.Fatalf("perte : %d %v", code, out)
}
if recs := perfLog.snapshot(); recs[len(recs)-1].Conv != "fp1" || recs[len(recs)-1].Iter != 1 || recs[len(recs)-1].New != 5900 {
t.Fatalf("relevé rangé : %+v", recs[len(recs)-1])
}
// Preset externe : la perte est rangée, jamais signalée.
code, out = post(`{"rec":{"kind":"main","conv":"fp1","iter":2,"complete":true,"total":7000,"cached":100},"prefix":[11,22,33,44],"external":true}`)
if code != 200 || out["lost"] != float64(5900) || out["alert"] != false {
t.Fatalf("externe : %d %v", code, out)
}
// Incomplète : ni perte ni alerte.
code, out = post(`{"rec":{"kind":"main","conv":"fp1","iter":3,"complete":false,"cached":0}}`)
if code != 200 || out["lost"] != nil || out["alert"] != false {
t.Fatalf("incomplète : %d %v", code, out)
}
if code, _ := post(`{"rec":`); code != 400 {
t.Fatalf("corps illisible : %d", code)
}
rec := httptest.NewRecorder()
handleInternalPerfRecord(rec, httptest.NewRequest("GET", "/internal/v1/perf/record", nil))
if rec.Code != 405 {
t.Fatalf("GET : %d", rec.Code)
}
}
+2
View File
@@ -135,6 +135,8 @@ func withInternalAPI(next http.Handler) http.Handler {
handleInternalBenchPerf(w, r)
case "/internal/v1/tune/context":
handleInternalTuneContext(w, r)
case "/internal/v1/perf/record":
handleInternalPerfRecord(w, r)
case "/internal/v1/tasks/run":
handleInternalTaskRun(w, r)
case "/internal/v1/push/send":
+4 -1
View File
@@ -853,6 +853,9 @@ struct WireMsg {
tool_calls: Option<Vec<Value>>,
}
/// Réponse lue mais sans choix.
pub const ERR_EMPTY_SUMMARY: &str = "résumé: réponse vide";
/// Décodage d'une réponse non streamée de /v1/chat/completions, comme
/// `postSummary` : seule la PREMIÈRE valeur JSON compte (un octet parasite
/// après elle est ignoré), aucun choix → « résumé: réponse vide ».
@@ -866,7 +869,7 @@ pub fn parse_summary_response(raw: &[u8]) -> Result<SummaryChoice, SummaryError>
Some(Ok(r)) => r,
};
let Some(ch) = resp.choices.and_then(|c| c.into_iter().next()) else {
return Err(SummaryError::Other("résumé: réponse vide".into()));
return Err(SummaryError::Other(ERR_EMPTY_SUMMARY.into()));
};
Ok(SummaryChoice {
content: ch.message.content.unwrap_or_default(),
+5 -2
View File
@@ -23,8 +23,9 @@
//! ([`super::verify`]) ; toute compaction rend l'état des critères au résumé.
//!
//! Écarts assumés (restent au Go) : PROJ_SNAPSHOT et KEEP_TURN_IMAGES
//! (refusés par `/turn/view`), préchauffage, télémétrie, motifs
//! `retry_patterns` personnalisés.
//! (refusés par `/turn/view`), préchauffage, motifs `retry_patterns`
//! personnalisés. La télémétrie des complétions (tours, vérifications,
//! résumés) rejoint l'anneau du Go (`/internal/v1/perf/record`).
use std::collections::VecDeque;
use std::future::Future;
@@ -508,6 +509,7 @@ impl ChatService {
cfg: cfg.clone(),
patterns: self.shared.patterns.clone(),
port,
perf: Some((store.clone(), self.sessions.conv().id())),
};
TurnEnv {
cfg,
@@ -797,6 +799,7 @@ impl ChatService {
echo: echo.clone(),
compact: ccfg.clone(),
patterns: self.shared.patterns.clone(),
perf_kind: super::perf::KIND_MAIN,
};
let me = self.clone();
let host = GoTurnHost {
+27 -1
View File
@@ -170,6 +170,9 @@ pub struct EngineSummarizer {
pub cfg: HashMap<String, String>,
pub patterns: Arc<RetryPatterns>,
pub port: u16,
/// Télémétrie des résumés (kind=compact), rattachée à cette discussion :
/// `None` sans base (tests).
pub perf: Option<(Store, String)>,
}
impl EngineSummarizer {
@@ -200,7 +203,19 @@ impl EngineSummarizer {
body: String::from_utf8_lossy(b).trim().to_string(),
});
}
parse_summary_response(&raw)
let out = parse_summary_response(&raw);
// Télémétrie de la compaction : réponse non streamée, timings et usage
// à la racine, lus sans échec possible — dès que la réponse se lit.
let decoded = match &out {
Ok(_) => true,
Err(SummaryError::Other(e)) => e == super::compact::ERR_EMPTY_SUMMARY,
Err(_) => false,
};
if decoded && let Some((store, conv)) = &self.perf {
let rec = super::perf::PerfRec::from_wire(super::perf::KIND_COMPACT, conv, &raw);
super::perf::record(store, &rec, &[], self.ep.external).await;
}
out
}
}
@@ -393,6 +408,17 @@ impl TurnHost for GoTurnHost {
}
}
async fn perf(
&self,
rec: &super::perf::PerfRec,
prefix: &[u64],
external: bool,
) -> super::perf::PerfNoted {
let mut rec = rec.clone();
rec.conv = self.conv_id.clone();
super::perf::record(&self.shared.store, &rec, prefix, external).await
}
async fn exec_tool(&self, name: &str, args: &Map<String, Value>, label: &str) -> ToolOutcome {
// Outils fichiers et shell : ici (lot 7), l'état partagé restant au Go.
if crate::tools::is_local(name) {
+3 -1
View File
@@ -18,7 +18,8 @@
//! - [`host`] : le tour branché sur le moteur, les outils du Go et la base ;
//! - [`history`] : début et fin d'un tour côté historique ;
//! - [`generate`] : un tour de bout en bout (envoi, file, fin de tour) ;
//! - [`tplprobe`] : sonde de gabarit (rendus de `/apply-template`).
//! - [`tplprobe`] : sonde de gabarit (rendus de `/apply-template`) ;
//! - [`perf`] : télémétrie des complétions (relevés rangés par le Go).
pub mod compact;
pub mod conv;
@@ -29,6 +30,7 @@ pub mod host;
pub mod journal;
pub mod lease;
pub mod nudges;
pub mod perf;
pub mod persist;
pub mod sessions;
pub mod stream;
+279
View File
@@ -0,0 +1,279 @@
//! Télémétrie des complétions de la façade (lot 29) — port de la partie
//! « relevé » de `perf_log.go` : `perfRecFromStats`, `perfRecFromWire`,
//! `perfPrefix`. L'anneau, le calcul de la perte de cache et son seuil
//! d'alerte restent au Go (`/internal/v1/perf/record`) : les complétions de
//! la façade rejoignent celles du Go dans `/api/perf/summary`.
//!
//! Des identifiants et des compteurs, jamais de texte. Rien ne retourne au
//! moteur.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::types::{Message, StatsEvent};
use crate::store::Store;
/// Nature d'une complétion.
pub const KIND_MAIN: &str = "main";
pub const KIND_VERIFY: &str = "verify";
pub const KIND_COMPACT: &str = "compact";
fn is_zero_i(v: &i64) -> bool {
*v == 0
}
fn is_zero_f(v: &f64) -> bool {
*v == 0.0
}
/// `perfRec` (sans `at` ni `seq`, posés par le Go).
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct PerfRec {
pub kind: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub conv: String,
pub iter: i64,
pub complete: bool,
/// usage.prompt_tokens : la taille du prompt entier.
#[serde(skip_serializing_if = "is_zero_i")]
pub total: i64,
/// cache_n ; `None` : inconnu.
#[serde(skip_serializing_if = "Option::is_none")]
pub cached: Option<i64>,
/// prompt_n : les jetons réellement recalculés.
#[serde(skip_serializing_if = "is_zero_i")]
pub new: i64,
#[serde(skip_serializing_if = "is_zero_f")]
pub pp_ms: f64,
#[serde(skip_serializing_if = "is_zero_f")]
pub pp_tps: f64,
#[serde(rename = "gen", skip_serializing_if = "is_zero_i")]
pub generated: i64,
#[serde(skip_serializing_if = "is_zero_f")]
pub tg_tps: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_n: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_accepted: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ttft_ms: Option<i64>,
}
impl PerfRec {
/// `perfRecFromStats` : la complétion telle que la boucle l'a vue.
pub fn from_stats(kind: &str, conv: &str, iter: i64, complete: bool, s: &StatsEvent) -> Self {
PerfRec {
kind: kind.into(),
conv: conv.into(),
iter,
complete,
total: s.prompt_tokens_total,
cached: s.cache_tokens,
new: s.prompt_tokens,
pp_ms: s.prompt_ms,
pp_tps: s.prompt_per_second,
generated: s.gen_tokens,
tg_tps: s.gen_per_second,
draft_n: s.draft_n,
draft_accepted: s.draft_accepted,
ttft_ms: s.ttft_ms,
}
}
/// `perfRecFromWire` : une réponse NON streamée (compaction), lue d'un
/// bloc. Jamais d'échec : un compteur illisible reste inconnu.
pub fn from_wire(kind: &str, conv: &str, raw: &[u8]) -> Self {
let mut r = PerfRec {
kind: kind.into(),
conv: conv.into(),
..Default::default()
};
// Seule la première valeur JSON compte (`json.Decoder`).
let v = serde_json::Deserializer::from_slice(raw)
.into_iter::<Value>()
.next()
.and_then(Result::ok)
.unwrap_or(Value::Null);
// Une valeur non nulle d'un autre type (chaîne, nombre) alloue quand
// même la structure côté Go avant l'erreur de type : présente, vide.
let has_timings = v.get("timings").is_some_and(|t| !t.is_null());
let timings = v.get("timings").filter(|t| t.is_object());
if let Some(t) = timings {
let n = |k: &str| perf_num(t.get(k));
r.new = n("prompt_n").unwrap_or(0.0) as i64;
r.pp_ms = n("prompt_ms").unwrap_or(0.0);
r.pp_tps = n("prompt_per_second").unwrap_or(0.0);
r.generated = n("predicted_n").unwrap_or(0.0) as i64;
r.tg_tps = n("predicted_per_second").unwrap_or(0.0);
r.cached = n("cache_n").map(|f| f as i64);
r.draft_n = n("draft_n").map(|f| f as i64);
r.draft_accepted = n("draft_n_accepted").map(|f| f as i64);
}
let usage = v.get("usage").filter(|u| u.is_object());
if let Some(u) = usage {
r.total = perf_num(u.get("prompt_tokens")).unwrap_or(0.0) as i64;
if r.generated == 0 {
r.generated = perf_num(u.get("completion_tokens")).unwrap_or(0.0) as i64;
}
}
if r.cached.is_none() {
r.cached = usage
.and_then(|u| u.get("prompt_tokens_details"))
.filter(|d| d.is_object())
.and_then(|d| perf_num(d.get("cached_tokens")))
.map(|f| f as i64);
}
r.complete = r.total > 0 || has_timings;
r
}
}
/// `perfNum` : un nombre (ou une chaîne qui en contient un), fini, positif
/// et < 1e12 ; sinon inconnu.
fn perf_num(v: Option<&Value>) -> Option<f64> {
let f = match v? {
Value::Number(n) => n.as_f64()?,
Value::String(s) => s.parse::<f64>().ok()?,
_ => return None,
};
(f.is_finite() && (0.0..1e12).contains(&f)).then_some(f)
}
/// FNV-1a 64 bits.
fn fnv1a(h: &mut u64, b: &[u8]) {
for &c in b {
*h ^= u64::from(c);
*h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
}
/// `perfPrefix` : empreintes cumulées des messages envoyés (l'empreinte i
/// couvre les messages 0..i). Une requête PROLONGE la précédente de la même
/// discussion quand l'empreinte de la précédente est un de ses préfixes.
pub fn prefix(msgs: &[Message]) -> Vec<u64> {
let mut h = 0xcbf2_9ce4_8422_2325u64;
msgs.iter()
.map(|m| {
fnv1a(&mut h, &serde_json::to_vec(m).unwrap_or_default());
fnv1a(&mut h, &[0]);
h
})
.collect()
}
/// Ce que le Go a tiré de la complétion rangée.
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
pub struct PerfNoted {
#[serde(default)]
pub lost: Option<i64>,
#[serde(default)]
pub lost_after: String,
#[serde(default)]
pub alert: bool,
}
/// Range la complétion dans l'anneau du Go. Le Go injoignable : rien (la
/// télémétrie ne fait jamais échouer un tour).
pub async fn record(store: &Store, rec: &PerfRec, prefix: &[u64], external: bool) -> PerfNoted {
match store
.internal_post(
"/internal/v1/perf/record",
&json!({ "rec": rec, "prefix": prefix, "external": external }),
)
.await
{
Ok((code, v)) if code.is_success() => serde_json::from_value(v).unwrap_or_default(),
_ => PerfNoted::default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn releve_d_une_reponse_entiere() {
let r = PerfRec::from_wire(
KIND_COMPACT,
"c1",
br#"{"timings":{"prompt_n":"120","prompt_ms":50.5,"prompt_per_second":2376,"predicted_n":30,"predicted_per_second":40,"cache_n":900,"draft_n":-1},"usage":{"prompt_tokens":1020,"completion_tokens":31}} parasite"#,
);
assert_eq!(
r,
PerfRec {
kind: "compact".into(),
conv: "c1".into(),
complete: true,
total: 1020,
cached: Some(900),
new: 120,
pp_ms: 50.5,
pp_tps: 2376.0,
generated: 30,
tg_tps: 40.0,
..Default::default()
}
);
// Sans timings : le cache d'OpenAI, les jetons générés de l'usage.
let r = PerfRec::from_wire(
KIND_COMPACT,
"",
br#"{"usage":{"prompt_tokens":10,"completion_tokens":3,"prompt_tokens_details":{"cached_tokens":4}}}"#,
);
assert_eq!(
(r.complete, r.total, r.generated, r.cached),
(true, 10, 3, Some(4))
);
// Rien de lisible : incomplète, tout inconnu.
let r = PerfRec::from_wire(KIND_COMPACT, "", b"pas du json");
assert!(!r.complete);
assert_eq!(r.cached, None);
let r = PerfRec::from_wire(
KIND_COMPACT,
"",
br#"{"timings":null,"usage":{"prompt_tokens":1e13}}"#,
);
assert_eq!((r.complete, r.total), (false, 0));
// Des timings d'un autre type : présents mais vides, comme le Go.
let r = PerfRec::from_wire(KIND_COMPACT, "", br#"{"timings":"x"}"#);
assert_eq!((r.complete, r.new), (true, 0));
}
#[test]
fn empreintes_cumulees() {
let m = |c: &str| Message {
role: "user".into(),
content: Some(json!(c)),
..Default::default()
};
let a = prefix(&[m("a"), m("b")]);
let b = prefix(&[m("a"), m("b"), m("c")]);
assert_eq!(a.len(), 2);
assert_eq!(b[..2], a[..]);
assert_ne!(prefix(&[m("x"), m("b")])[1], a[1]);
assert!(prefix(&[]).is_empty());
// FNV-1a de référence : « a » seul.
let mut h = 0xcbf2_9ce4_8422_2325u64;
fnv1a(&mut h, b"a");
assert_eq!(h, 0xaf63_dc4c_8601_ec8c);
}
#[test]
fn releve_au_format_du_go() {
let s = StatsEvent {
prompt_tokens: 5,
prompt_tokens_total: 100,
cache_tokens: Some(95),
gen_tokens: 7,
gen_per_second: 20.5,
ttft_ms: Some(30),
..Default::default()
};
let r = PerfRec::from_stats(KIND_MAIN, "c", 2, true, &s);
assert_eq!(
serde_json::to_value(&r).unwrap(),
json!({ "kind": "main", "conv": "c", "iter": 2, "complete": true, "total": 100,
"cached": 95, "new": 5, "gen": 7, "tg_tps": 20.5, "ttft_ms": 30 })
);
}
}
+51 -10
View File
@@ -23,12 +23,15 @@
//! `cb` (comme le `ChatCallback` Go) ; le tour rend ses messages à ajouter à
//! l'historique ([`TurnResult::extra`]).
//!
//! Télémétrie : chaque complétion lue est rangée (`perfRecord`, par
//! [`TurnHost::perf`]) ; la dernière statistique d'une complétion complète
//! porte la nature et la perte de cache calculée par le Go.
//!
//! Non portés (clés opt-in, ou machinerie du moteur local restée au Go) :
//! KEEP_TURN_IMAGES, PROJ_SNAPSHOT, SIDE_SLOT (slot et rejeux « cache KV
//! plein »), SLOT_PERSIST, préchauffage, télémétrie `perfRecord`, comptage
//! des requêtes en vol du moteur. Avec l'une
//! de ces clés, le Go garde la conversation (`facadeChatBlocker`, décidé au
//! démarrage).
//! plein »), SLOT_PERSIST, préchauffage, comptage des requêtes en vol du
//! moteur. Avec l'une de ces clés, le Go garde la conversation
//! (`facadeChatBlocker`, décidé au démarrage).
use std::collections::HashMap;
use std::future::Future;
@@ -54,6 +57,7 @@ use super::nudges::{
MAX_NUDGES, THINK_NUDGE_FIRST, THINK_NUDGE_STUCK, append_nudge, budget_nudge, dedupable_tool,
log_budget, repeated_call_result, steer_system, tool_result_preview,
};
use super::perf::PerfRec;
use super::stream::{
CacheHintFn, MAX_PATTERN_RETRIES, PreflightFn, RetryPatterns, StreamConfig, StreamParser,
call_args, retry_corrective, tool_call_label,
@@ -181,6 +185,16 @@ pub trait TurnHost: Send + Sync {
fn cache_hint(&self) -> Option<CacheHintFn> {
None
}
/// Range la télémétrie d'une complétion (`perfRecord`), rattachée à la
/// discussion du tour ; rend la perte de cache calculée et l'alerte.
fn perf(
&self,
_rec: &super::perf::PerfRec,
_prefix: &[u64],
_external: bool,
) -> impl Future<Output = super::perf::PerfNoted> + Send {
async { super::perf::PerfNoted::default() }
}
/// Garde d'écriture du mode Code (`codeWriteGuard`).
fn preflight(&self) -> Option<PreflightFn> {
None
@@ -202,6 +216,8 @@ pub struct TurnConfig {
pub echo: EchoPolicy,
pub compact: CompactConfig,
pub patterns: Arc<RetryPatterns>,
/// Nature des complétions pour la télémétrie (`perfMain`, `perfVerify`).
pub perf_kind: &'static str,
}
/// Fin du tour : messages à ajouter à l'historique, et l'erreur s'il y en a
@@ -348,6 +364,8 @@ struct Loop {
echo_tpl_retry: bool,
tool_runs: i64,
budget_nudges: i64,
/// Complétions rangées dans la télémétrie (`perfIter`).
perf_iter: i64,
}
/// `runChatLoop` : le tour, de la première requête à la réponse finale.
@@ -415,8 +433,9 @@ pub async fn run_turn<H: TurnHost>(
let calls_on = !tc.tools.is_empty() && !s.disable_tools && !s.tool_choice_none;
let pol = turn_echo_policy(ep.external || s.echo_off_turn, || tc.echo.clone());
host.prepare_images(&messages).await;
let (body, echo_sent) = {
let (body, echo_sent, sent_prefix) = {
let (sent, echo_sent) = wire_messages(&messages, s.tool_choice_none, &pol);
let sent_prefix = super::perf::prefix(&sent);
let payload = build_chat_payload(
ep,
&sent,
@@ -432,7 +451,11 @@ pub async fn run_turn<H: TurnHost>(
&tc.cfg,
&|r| host.image_data_url(r),
);
(serde_json::to_vec(&payload).unwrap_or_default(), echo_sent)
(
serde_json::to_vec(&payload).unwrap_or_default(),
echo_sent,
sent_prefix,
)
};
let t_req = Instant::now();
let reply = match cancel.or_stop(host.send(ep, body)).await {
@@ -603,14 +626,32 @@ pub async fn run_turn<H: TurnHost>(
}
}
// Arrêt pendant la lecture : le tour s'arrête là, sans erreur, et rien
// de plus ne part vers l'écran.
// de plus ne part vers l'écran. La complétion coupée reste dans la
// télémétrie, incomplète.
if stopped {
let rec =
PerfRec::from_stats(tc.perf_kind, "", s.perf_iter, false, &Default::default());
host.perf(&rec, &[], ep.external).await;
return TurnResult { extra, err: None };
}
let out = parser.finish(&mut emit);
if out.is_complete(read_err.is_some()) {
// Dernier événement de la complétion : copie complète des stats.
cb(StreamEvent::Stats(out.stats.clone()));
// Télémétrie : une complétion coupée avant son chunk final n'a ni cache
// ni total fiables — rangée incomplète, sans calcul de perte.
let complete = out.is_complete(read_err.is_some());
let rec = PerfRec::from_stats(tc.perf_kind, "", s.perf_iter, complete, &out.stats);
let noted = host
.perf(&rec, if complete { &sent_prefix } else { &[] }, ep.external)
.await;
s.perf_iter += 1;
if complete {
// Dernier événement de la complétion : copie complète des stats,
// avec la perte de cache vue par la télémétrie.
let mut st = out.stats.clone();
st.kind = tc.perf_kind.into();
st.lost = noted.lost;
st.lost_after = noted.lost_after;
st.lost_alert = noted.alert;
cb(StreamEvent::Stats(st));
// Sonde de gabarit, en tâche de fond et après coup : rien n'en
// revient dans la requête suivante.
if !ep.external && !s.disable_tools && !s.tool_choice_none {
+88 -1
View File
@@ -2,7 +2,7 @@
use super::*;
use crate::chat::compact::CompactConfig;
use crate::chat::types::ToolFunction;
use crate::chat::types::{StatsEvent, ToolFunction};
use crate::chat::wire::resolve_chat_endpoint;
use serde_json::json;
use std::collections::VecDeque;
@@ -36,6 +36,10 @@ struct Fake {
unstable: Mutex<bool>,
/// Complétions signalées à la sonde : (outils, kwargs, effort).
completions: Mutex<Vec<Completion>>,
/// Télémétrie rangée : (relevé, empreintes, externe) ; et ce que le Go
/// en tire.
perfs: Mutex<Vec<(crate::chat::perf::PerfRec, Vec<u64>, bool)>>,
noted: Mutex<crate::chat::perf::PerfNoted>,
}
impl Fake {
@@ -143,6 +147,18 @@ impl TurnHost for Fake {
fn prefix_unstable(&self, _t: &[Tool], _k: Option<&Map<String, Value>>, _e: &str) -> bool {
*self.unstable.lock().unwrap()
}
async fn perf(
&self,
rec: &crate::chat::perf::PerfRec,
prefix: &[u64],
external: bool,
) -> crate::chat::perf::PerfNoted {
self.perfs
.lock()
.unwrap()
.push((rec.clone(), prefix.to_vec(), external));
self.noted.lock().unwrap().clone()
}
}
fn content(s: &str) -> String {
@@ -190,6 +206,7 @@ fn config(tools: &[&str]) -> TurnConfig {
echo: EchoPolicy::default(),
compact: CompactConfig::default(),
patterns: RetryPatterns::builtin(),
perf_kind: "main",
}
}
@@ -623,3 +640,73 @@ async fn flux_externe_coupe_repris_apres_le_texte_affiche() {
let (r, _) = run(&host, &config(&[]), &Cancel::never()).await;
assert!(r.err.unwrap().contains("a été coupé en cours de route"));
}
/// Télémétrie : chaque complétion rangée, numérotée dans le tour, avec les
/// empreintes des messages envoyés ; la dernière statistique porte la
/// nature et la perte de cache vue par le Go.
#[tokio::test]
async fn telemetrie_des_completions() {
use crate::chat::perf::PerfNoted;
let host = Fake::new(vec![
tool_step("c1", "glob", r#"{"pattern":"*.go"}"#),
answer("Trouvé"),
]);
*host.noted.lock().unwrap() = PerfNoted {
lost: Some(40),
lost_after: "foreign".into(),
alert: true,
};
let mut tc = config(&["glob"]);
tc.perf_kind = "verify";
let (r, evs) = run(&host, &tc, &Cancel::never()).await;
assert_eq!(r.err, None);
let perfs = host.perfs.lock().unwrap().clone();
assert_eq!(perfs.len(), 2);
for (i, (rec, prefix, external)) in perfs.iter().enumerate() {
assert_eq!(rec.kind, "verify");
assert_eq!(rec.iter, i as i64);
assert!(rec.complete);
assert_eq!((rec.total, rec.generated), (100, 5));
assert!(!external);
assert!(!prefix.is_empty());
}
// La seconde requête prolonge la première : mêmes empreintes en tête.
let (a, b) = (&perfs[0].1, &perfs[1].1);
assert_eq!(a.len(), 1);
assert_eq!(b.len(), 3);
assert_eq!(b[..1], a[..]);
let last: Vec<&StatsEvent> = evs
.iter()
.filter_map(|e| match e {
StreamEvent::Stats(s) if !s.kind.is_empty() => Some(s),
_ => None,
})
.collect();
assert_eq!(last.len(), 2);
assert_eq!(
(
last[1].kind.as_str(),
last[1].lost,
last[1].lost_after.as_str(),
last[1].lost_alert
),
("verify", Some(40), "foreign", true)
);
}
/// Une complétion coupée avant son chunk final : rangée incomplète, sans
/// empreintes ; un stop pendant la lecture aussi.
#[tokio::test]
async fn telemetrie_incomplete() {
let host = Fake::new(vec![Script::Cut(vec![content("Bon")])]);
let (_, evs) = run(&host, &config(&[]), &Cancel::never()).await;
let perfs = host.perfs.lock().unwrap().clone();
assert_eq!(perfs.len(), 1);
assert!(!perfs[0].0.complete);
assert!(perfs[0].1.is_empty());
assert!(
!evs.iter()
.any(|e| matches!(e, StreamEvent::Stats(s) if !s.kind.is_empty())),
"pas de statistique finale"
);
}
+1
View File
@@ -408,6 +408,7 @@ impl ChatService {
echo: env.echo.clone(),
compact: env.ccfg.clone(),
patterns: self.shared.patterns.clone(),
perf_kind: super::perf::KIND_VERIFY,
};
let host = GoTurnHost {
shared: self.shared.clone(),
+10
View File
@@ -125,6 +125,8 @@ pub struct FakeGo {
/// pour une action donnée.
pub service_os: Mutex<Vec<String>>,
pub service_os_fail: Mutex<Option<(String, String)>>,
/// /internal/v1/perf/record reçus (corps entiers).
pub perf_records: Mutex<Vec<serde_json::Value>>,
}
impl FakeGo {
@@ -500,6 +502,14 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
}
resp(200, r#"{"ran":true,"error":""}"#)
}
"/internal/v1/perf/record" => {
let body = req.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
let mut log = st.perf_records.lock().unwrap();
log.push(v);
let out = serde_json::json!({"seq": log.len(), "lost": 12, "lost_after": "task", "alert": false});
resp(200, out.to_string())
}
"/internal/v1/tune/context" => {
let v = serde_json::json!({
"busy": *st.tune_ctx_busy.lock().unwrap(),
+87
View File
@@ -680,6 +680,7 @@ async fn tour_complet_par_le_go() {
cfg: cfg.clone(),
patterns: shared.patterns.clone(),
port: 8080,
perf: None,
},
compact: TurnCompact {
cfg: CompactConfig::default(),
@@ -710,6 +711,7 @@ async fn tour_complet_par_le_go() {
echo: Default::default(),
compact: CompactConfig::default(),
patterns: shared.patterns.clone(),
perf_kind: "main",
};
let question = Message {
role: "user".into(),
@@ -761,6 +763,38 @@ async fn tour_complet_par_le_go() {
})
.collect();
assert_eq!(answer, "Voilà.");
// Télémétrie : chaque complétion rangée par le Go, rattachée à la
// discussion ; la première, sans usage, reste incomplète.
let perfs = go.perf_records.lock().unwrap().clone();
assert_eq!(perfs.len(), 2);
assert_eq!(perfs[0]["rec"]["complete"], false);
assert_eq!(perfs[0]["prefix"], serde_json::json!([]));
let r1 = &perfs[1]["rec"];
assert_eq!(
(
r1["conv"].clone(),
r1["kind"].clone(),
r1["iter"].clone(),
r1["total"].clone()
),
(
serde_json::json!("c7"),
serde_json::json!("main"),
serde_json::json!(1),
serde_json::json!(50)
)
);
assert_eq!(perfs[1]["prefix"].as_array().unwrap().len(), 3);
assert_eq!(perfs[1]["external"], false);
let last = evs
.iter()
.rev()
.find_map(|e| match e {
StreamEvent::Stats(s) if !s.kind.is_empty() => Some(s.clone()),
_ => None,
})
.unwrap();
assert_eq!((last.lost, last.lost_after.as_str()), (Some(12), "task"));
}
#[tokio::test]
@@ -978,6 +1012,7 @@ async fn hote_et_sonde_de_gabarit() {
cfg,
patterns: shared.patterns.clone(),
port: 1,
perf: None,
},
compact: TurnCompact {
cfg: CompactConfig::default(),
@@ -1768,3 +1803,55 @@ async fn parite_du_tour_complet() {
failures.join("\n\n")
);
}
/// Lot 29 : le résumé d'une compaction rejoint la télémétrie du Go
/// (kind=compact, rattaché à la discussion), dès que la réponse se lit.
#[tokio::test]
async fn telemetrie_des_resumes() {
use loki_rs::chat::compact::{Summarizer, TranscriptRequest};
let (st, go, _stop, _t) = store().await;
let (url, _seen) = fake_engine(vec![
serde_json::json!({
"choices": [{"message": {"content": "résumé"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 3000, "completion_tokens": 200},
"timings": {"prompt_n": 3000, "prompt_ms": 1500, "predicted_n": 200, "predicted_per_second": 40}
})
.to_string(),
r#"{"choices":[]}"#.into(),
"pas du json".into(),
])
.await;
let s = EngineSummarizer {
http: EngineHttp::new(),
ep: ChatEndpoint {
url,
model: "loki".into(),
key: String::new(),
external: false,
},
cfg: Default::default(),
patterns: RetryPatterns::builtin(),
port: 8080,
perf: Some((st, "c9".into())),
};
let req = || TranscriptRequest {
system: "s".into(),
transcript: "t".into(),
max_tokens: 500,
};
assert_eq!(
s.summarize_transcript(req()).await.unwrap().content,
"résumé"
);
assert!(s.summarize_transcript(req()).await.is_err(), "réponse vide");
assert!(s.summarize_transcript(req()).await.is_err(), "illisible");
let perfs = go.perf_records.lock().unwrap().clone();
assert_eq!(perfs.len(), 2, "la réponse illisible n'est pas rangée");
assert_eq!(
perfs[0]["rec"],
serde_json::json!({ "kind": "compact", "conv": "c9", "iter": 0, "complete": true,
"total": 3000, "new": 3000, "pp_ms": 1500.0, "gen": 200, "tg_tps": 40.0 })
);
assert_eq!(perfs[0]["prefix"], serde_json::json!([]));
assert_eq!(perfs[1]["rec"]["complete"], false);
}
+1
View File
@@ -195,6 +195,7 @@ async fn replay(fx: Fixture) -> Result<(), String> {
echo,
patterns: RetryPatterns::builtin(),
cfg,
perf_kind: "main",
};
let host = ParityHost {
replies: Mutex::new(fx.replies.into()),