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

+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()),