78448220b2
voice_metrics.py: neben den rollenden Stats jetzt ein echter Per-Turn-Trace (TurnTrace + Ringpuffer der letzten 60 Turns). Balken-Stufen zeitlich disjunkt (stt · vision · hirn · gen); hirn misst ab mark_brain_start() VOR dem Hermes- Request, damit die Vision-Zeit nicht doppelt gezaehlt wird. STT (davor) und Mem0-Retrieve (Rueckruf waehrend) werden per park()/_take_* best-effort dem Turn zugeordnet (Ein-Nutzer-Geraet, kein Turn-ID noetig). Mem0 = Unter-Detail INNERHALB hirn (nicht addieren) -> ehrlich, kein Doppelzaehlen. voice.py: TurnTrace in voice_chat's gen() (commit garantiert 1x via finally, auch bei Fehler/Abbruch). STT-Endpoint parkt seine Dauer. NEU: GET /api/voice/metrics (schliesst die Luecke - selbstkritik-feed.sh curlte das, existierte nie -> 404) + GET /api/voice/trace?limit=N. memory.py: GET /api/memory?q=... (= Hermes' Mem0-Prefetch) misst + parkt die Retrieve-Zeit. Frontend: LatencyCard (gestapelte Balken je Turn, "Taeter" = groesste Stufe, Fehler-Turns rot, Tooltip mit "davon Mem0 X s"), Query useVoiceTrace, in der Zentrale unter "Stack & Telemetrie". Lokal gegen Seed-Server verifiziert: 30,3-s- Haenger -> Hirn-Balken 94% + "davon Mem0 26,1 s". Grenzen (ehrlich, als Fussnote in der Karte): Tool-Runden im Hermes-LLM-Loop haben keinen Callback an MC2 -> stecken in "Antwort". Reine Telegram-Text-Turns laufen an MC2 vorbei und erscheinen hier nicht. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
188 lines
7.6 KiB
Python
188 lines
7.6 KiB
Python
"""
|
|
Per-Stage-Latenz-Metriken + Per-Turn-Trace für die Voice/Lucy-Pipeline.
|
|
|
|
Zwei Sichten auf dieselben Messungen:
|
|
|
|
1. **Rollende Stats** (`record_stage`/`Timer`/`get_metrics`) — avg/p50/p95/last je Stufe über die
|
|
letzten N Messungen. Gut für Trends („Wie schnell ist STT im Schnitt?").
|
|
2. **Per-Turn-Trace** (`TurnTrace`/`get_trace`) — jeder einzelne Chat-Turn als eigener Datensatz mit
|
|
seinem Stufen-Breakdown. Nur so sieht man den EINEN langsamen Turn (der 30-s-Hänger), den ein
|
|
Durchschnitt verschluckt. Das war der eigentliche Anlass (Telegram-/Voice-Hänger diagnostizieren).
|
|
|
|
**Was MC2 messen kann — und was nicht:** MC2 proxyt den Chat nur an Hermes (:8642). Die Stufen STT,
|
|
Vision, Hirn-TTFT (Zeit bis zum ersten Inhalts-Token) und Generierung sind hier direkt messbar. Der
|
|
**Mem0-Retrieve** läuft zwar in Hermes, ruft aber MC2s `/api/memory` per HTTP zurück → messbar und als
|
|
Unter-Detail INNERHALB der Hirn-Zeit ausgewiesen (kein Doppelzählen). Die **Tool-Runden** dagegen laufen
|
|
im Hermes-LLM-Loop ohne Callback an MC2 → für MC2 unsichtbar, sie stecken im „Generierung"-Bucket.
|
|
|
|
STT (davor) und Mem0-Retrieve (währenddessen) sind separate HTTP-Requests ohne Turn-ID. Auf einem
|
|
EIN-Nutzer-Gerät genügt eine schlanke Best-Effort-Korrelation: die zuletzt gemessene STT-Dauer bzw. der
|
|
letzte Retrieve werden global „geparkt" und vom nächsten Chat-Turn eingesammelt (mit Frist-/Reihenfolge-
|
|
Check). Kein Turn-ID-Durchreichen durch den Lucy-Client nötig.
|
|
|
|
In-Memory + thread-safe (keine Datei-I/O — Latenz-Telemetrie ist transient, Restart = Reset).
|
|
"""
|
|
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from collections import deque
|
|
|
|
_LOCK = threading.Lock()
|
|
_MAX = 200
|
|
_STAGES: dict[str, deque] = {}
|
|
_TURNS: deque = deque(maxlen=60) # letzte N vollständige Chat-Turns (Per-Turn-Trace)
|
|
|
|
# Bekannte Stufen (für stabile UI-Reihenfolge); unbekannte werden trotzdem erfasst.
|
|
STAGES = ("stt", "vision", "memory_retrieve", "chat_ttfb", "chat_first_content", "tts")
|
|
|
|
# Best-effort-Korrelation (Ein-Nutzer-Gerät): zuletzt gemessene STT-Dauer / Mem0-Retrieve, je
|
|
# (ms, perf_counter-Zeitstempel). Der nächste passende Chat-Turn sammelt sie ein und leert sie.
|
|
_PARKED: dict[str, tuple[float, float] | None] = {"stt": None, "retrieve": None}
|
|
|
|
|
|
def record_stage(stage: str, ms: float) -> None:
|
|
"""Eine gemessene Stage-Dauer (ms) verbuchen. No-op bei negativen Werten."""
|
|
if ms is None or ms < 0:
|
|
return
|
|
with _LOCK:
|
|
dq = _STAGES.get(stage)
|
|
if dq is None:
|
|
dq = _STAGES[stage] = deque(maxlen=_MAX)
|
|
dq.append(float(ms))
|
|
|
|
|
|
def park(kind: str, ms: float) -> None:
|
|
"""Eine Messung, die NICHT im Chat-Request selbst passiert (STT davor, Mem0-Retrieve als
|
|
Rückruf während), global parken, damit der nächste Chat-Turn sie einsammeln kann."""
|
|
if ms is None or ms < 0 or kind not in _PARKED:
|
|
return
|
|
with _LOCK:
|
|
_PARKED[kind] = (float(ms), time.perf_counter())
|
|
|
|
|
|
def _take_stt(max_age: float = 20.0) -> float | None:
|
|
"""Geparkte STT-Dauer einsammeln, wenn frisch (STT liegt VOR dem Turn-Start)."""
|
|
with _LOCK:
|
|
v = _PARKED.get("stt")
|
|
if v and (time.perf_counter() - v[1]) <= max_age:
|
|
_PARKED["stt"] = None
|
|
return round(v[0], 1)
|
|
return None
|
|
|
|
|
|
def _take_retrieve(since_perf: float, max_age: float = 90.0) -> float | None:
|
|
"""Geparkten Mem0-Retrieve einsammeln, wenn er NACH dem Turn-Start kam (Rückruf während des
|
|
Turns) und frisch ist."""
|
|
with _LOCK:
|
|
v = _PARKED.get("retrieve")
|
|
if v and v[1] >= since_perf and (time.perf_counter() - v[1]) <= max_age:
|
|
_PARKED["retrieve"] = None
|
|
return round(v[0], 1)
|
|
return None
|
|
|
|
|
|
class Timer:
|
|
"""Context-Manager: misst die verstrichene Zeit und verbucht sie auf `stage`.
|
|
Funktioniert um `await`-Aufrufe herum (enter → await → exit)."""
|
|
|
|
def __init__(self, stage: str) -> None:
|
|
self.stage = stage
|
|
self._t0 = 0.0
|
|
|
|
def __enter__(self) -> "Timer":
|
|
self._t0 = time.perf_counter()
|
|
return self
|
|
|
|
def __exit__(self, *exc) -> None:
|
|
record_stage(self.stage, (time.perf_counter() - self._t0) * 1000.0)
|
|
|
|
|
|
class TurnTrace:
|
|
"""Ein Per-Turn-Trace für den Voice/Lucy-Chatpfad. In `voice.py` über die Dauer eines Chat-Turns
|
|
gehalten; `commit()` schreibt den Datensatz in den Ringpuffer UND speist die rollenden Stats.
|
|
|
|
Balken-Stufen (zeitlich DISJUNKT, ergeben zusammen den Turn): stt · vision · hirn · gen.
|
|
Unter-Detail: mem0 (Teil VON hirn, wird separat ausgewiesen, aber NICHT zum Balken addiert)."""
|
|
|
|
def __init__(self, session_id: str = "", kind: str = "voice") -> None:
|
|
self.id = uuid.uuid4().hex[:8]
|
|
self.ts = time.time()
|
|
self.perf0 = time.perf_counter()
|
|
self._brain0 = self.perf0 # Referenz für die Hirn-Zeit (nach Vision neu gesetzt)
|
|
self.session_id = (session_id or "")[:24]
|
|
self.kind = kind
|
|
self.vision_ms: float | None = None # Bildschirm-Beschreibung (falls Bilder)
|
|
self.hirn_ms: float | None = None # Zeit bis zum ersten Inhalts-Token (Agent + Mem0 + TTFT)
|
|
self.had_images = False
|
|
self.error: str | None = None
|
|
|
|
def note_vision(self, ms: float) -> None:
|
|
self.vision_ms = round(ms, 1)
|
|
record_stage("vision", ms)
|
|
|
|
def mark_brain_start(self) -> None:
|
|
"""Startpunkt der Hirn-Zeit — direkt VOR dem Hermes-Request, damit die Vision-Zeit NICHT
|
|
in die Hirn-Zeit gezählt wird (sonst Doppelzählung mit dem Vision-Segment)."""
|
|
self._brain0 = time.perf_counter()
|
|
|
|
def note_ttfb(self) -> None:
|
|
record_stage("chat_ttfb", (time.perf_counter() - self._brain0) * 1000.0) # SSE-Start (~5 ms), nur rollend
|
|
|
|
def note_first_content(self) -> None:
|
|
"""Erster Inhalts-Delta = echte Hirn-Latenz (Agent-Overhead + Mem0 + LLM-TTFT)."""
|
|
ms = (time.perf_counter() - self._brain0) * 1000.0
|
|
self.hirn_ms = round(ms, 1)
|
|
record_stage("chat_first_content", ms)
|
|
|
|
def commit(self) -> dict:
|
|
total = (time.perf_counter() - self.perf0) * 1000.0
|
|
stt = _take_stt() # rollend bereits in /voice/stt erfasst
|
|
mem0 = _take_retrieve(self.perf0) # rollend bereits in /api/memory erfasst
|
|
# Generierung = alles nach dem ersten Inhalts-Token bis Stream-Ende.
|
|
gen = round(total - self.hirn_ms, 1) if self.hirn_ms is not None else None
|
|
rec = {
|
|
"id": self.id,
|
|
"ts": round(self.ts, 3),
|
|
"session": self.session_id,
|
|
"kind": self.kind,
|
|
"had_images": self.had_images,
|
|
"stt_ms": stt,
|
|
"vision_ms": self.vision_ms,
|
|
"hirn_ms": self.hirn_ms,
|
|
"gen_ms": gen if (gen is None or gen >= 0) else 0.0,
|
|
"mem0_ms": mem0,
|
|
"total_ms": round(total, 1),
|
|
"error": self.error,
|
|
}
|
|
with _LOCK:
|
|
_TURNS.append(rec)
|
|
return rec
|
|
|
|
|
|
def _summary(vals: list[float]) -> dict:
|
|
if not vals:
|
|
return {"count": 0}
|
|
s = sorted(vals)
|
|
n = len(s)
|
|
return {
|
|
"count": n,
|
|
"avg_ms": round(sum(s) / n, 1),
|
|
"p50_ms": round(s[n // 2], 1),
|
|
"p95_ms": round(s[min(n - 1, int(n * 0.95))], 1),
|
|
"last_ms": round(vals[-1], 1),
|
|
}
|
|
|
|
|
|
def get_metrics() -> dict:
|
|
"""Rollende Zusammenfassung je Stufe."""
|
|
with _LOCK:
|
|
return {stage: _summary(list(dq)) for stage, dq in _STAGES.items()}
|
|
|
|
|
|
def get_trace(limit: int = 20) -> list[dict]:
|
|
"""Die letzten `limit` Chat-Turns (neueste zuerst) mit Stufen-Breakdown."""
|
|
limit = max(1, min(int(limit or 20), _TURNS.maxlen or 60))
|
|
with _LOCK:
|
|
return list(_TURNS)[-limit:][::-1]
|