Systemaudit vom 27.08.2026. Alle Befunde gemessen, nicht vermutet.
VIER STILLE DEFEKTE
1. mc2-steward startete seit Wochen nicht (live: 207.609 Neustarts).
steward.py importierte services.memory, das beim Mem0-Ausbau geloescht
wurde -> ImportError bei jedem Start. Re-Warm- und Health-Waechter
waren damit tot.
2. Jedes Hermes-Update wurde automatisch zurueckgerollt.
hermes-postcheck.sh prueft vier Dinge, die es seit dem 07.08. nicht mehr
gibt (Sidecar :8765, /api/memory, memory.provider, mc2-memory-Plugin).
Die Checks konnten nicht gruen werden -> autoupdate.sh wertete jedes
Update als rot und rollte es zurueck. Checks ersatzlos entfernt; der
Tool-Smoke laeuft ohnehin durch den echten Agenten.
3. 7 von 12 Skills waren per Knopfdruck nicht startbar.
deploy.sh kopiert Skills mit tr '-' '_' nach ~/.hermes/skills/,
routers/skills.py gab Hermes aber den Ordnernamen MIT Bindestrich.
Der Knopf meldete Erfolg, ausgefuehrt wurde nichts. Neu: _hermes_name().
4. deploy.sh warf bei jedem Deploy die Live-Modellkonfiguration weg.
MC2 schreibt /etc/llama-swap/config.yaml selbst; die Repo-Datei ist nur
ein Abzug (ihm fehlt u.a. kritiker/Devstral). Jetzt: erst sichern, Diff
zeigen, dann kopieren. MC_DEPLOY_SKIP_SWAP_CONFIG=1 ueberspringt.
MEM0-AUSBAU VOLLENDET (Kriterium 3: 17 -> 0 Dateien)
- mem0_service/, mcp/mcp_memory.py und hermes/plugins/mc2-memory entfernt;
das Plugin schickte bei JEDEM Turn zwei 404-Requests an tote Routen.
- MEMORY_DB/MEM0_SERVICE_URL, _mem0_reachable(), MC_MEMORY_DB und
MC_MEM_DEDUPE_ENABLED aus Config/Router/Unit entfernt.
- mem0_ms war strukturell tot (park("retrieve") wird nirgends mehr
aufgerufen) -> aus Backend, API-Typ und Latenzkarte entfernt.
- Verbinden-Tab: tote Gedaechtnis-MCP-Leitung raus, Status-Kachel bleibt.
- AGENTS.md beschrieb Mem0 noch als aktiv - korrigiert.
GATEWAY-ROBUSTHEIT
- _proxy gab bei ungueltigen Payloads HTTP 500 (gemessen 5/5: Rohtext,
leerer Body, JSON-Liste, JSON-String, null) -> jetzt 5/5 HTTP 400.
- Bild-Weiche ohne Deckel: 10 Bilder x 2 Versuche x 240 s hielten den
Client bis zu 80 min. Neu: MC_CODER_IMAGE_MAX (4), Rueckfall auf die
Vision-Umleitung.
- /v1/models: nicht-JSON von der Engine gab 500 -> jetzt 502.
UNITS UND DEPLOY
- mc2-steward.service, dessen warmset-Drop-in und voice-service.service
fehlten im Repo, obwohl maintenance.py und stack-postcheck.sh sie
voraussetzen. 1:1 von der laufenden Box uebernommen.
- deploy.sh startete mc2-steward nie neu; restore.sh liess mc2-gateway und
mc2-steward mit alter Config weiterlaufen. Beide ergaenzt.
FRONTEND
- useEigenleben rief /api/eigenleben - existiert im Backend nicht und wurde
nirgends genutzt. Samt Typen entfernt.
- Anleitung beschrieb einen Gedaechtnis-Tab, den es nicht gibt.
- Abgeglichen: alle uebrigen 63 Frontend-Aufrufe treffen echte Routen, alle
5 SSE-Invalidation-Keys sind gemappt, keine ungefangenen Promises.
Gates: compileall gruen - ruff "All checks passed" - tsc gruen - vite build
gruen (dist aktualisiert) - Importe app/steward/gateway_app gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
174 lines
6.8 KiB
Python
174 lines
6.8 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
|
|
**Gedächtnis-Abruf** dagegen läuft seit der Ablösung des Sidecars (07.08.2026) Hermes-intern — es
|
|
gibt keinen Rückruf an MC2 mehr, also auch keine Messung; er steckt jetzt in der Hirn-Zeit. Ebenso die
|
|
**Tool-Runden**: im Hermes-LLM-Loop ohne Callback an MC2 → unsichtbar, im „Generierung"-Bucket.
|
|
|
|
STT läuft als eigener HTTP-Request VOR dem Turn, ohne Turn-ID. Auf einem EIN-Nutzer-Gerät genügt eine
|
|
schlanke Best-Effort-Korrelation: die zuletzt gemessene STT-Dauer wird global „geparkt" und vom
|
|
nächsten Chat-Turn eingesammelt (mit Frist-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", "chat_ttfb", "chat_first_content", "tts")
|
|
|
|
# Best-effort-Korrelation (Ein-Nutzer-Gerät): zuletzt gemessene STT-Dauer als
|
|
# (ms, perf_counter-Zeitstempel). Der nächste Chat-Turn sammelt sie ein und leert sie.
|
|
_PARKED: dict[str, tuple[float, float] | None] = {"stt": 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 läuft davor), 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
|
|
|
|
|
|
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."""
|
|
|
|
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 + Gedächtnis + 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 + Gedächtnis + 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
|
|
# 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,
|
|
"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]
|