perf(stt-server): mémoire long-terme hors chemin de réponse + résilience embed (#20)

Cause de la latence 30-45s : l'endpoint d'embeddings (gpu-01:1238) peut se
geler ; recall ET remember l'attendaient ~20s chacun (timeout → dégradation
silencieuse), s'ajoutant à la réponse. Refactor :

- store (ex-remember) en BackgroundTasks → APRÈS la réponse, hors latence perçue ;
  suppression de `?wait=true` (pas d'attente du flush Qdrant)
- recall renvoie aussi le vecteur de la requête → store le réutilise (1 embed/tour
  au lieu de 2, le 2ᵉ portait sur le même texte)
- timeout recall serré (4s, STT_MEMORY_RECALL_TIMEOUT) : un embed lent/mort dégrade
  vite (souvenirs vides) au lieu de bloquer ; store tolère 20s en arrière-plan
- clients httpx persistants (pooling/keep-alive) côté brain + longterm, fermés via
  lifespan (plus de handshake TCP par appel)
- log de timing par requête (recall/gen/total/mem) pour diagnostiquer
- bump serveur 0.1.0 → 0.2.0

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ALI YESILKAYA 2026-06-19 15:27:59 +02:00 committed by GitHub
parent 7025d7ae70
commit 914942de73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 165 additions and 78 deletions

View file

@ -11,6 +11,23 @@ import httpx
from stt_server.config import settings
# Client persistant (pooling + keep-alive) : évite un handshake TCP vers LiteLLM à chaque tour.
_client: httpx.AsyncClient | None = None
def _get_client() -> httpx.AsyncClient:
global _client
if _client is None or _client.is_closed:
_client = httpx.AsyncClient(timeout=settings.request_timeout)
return _client
async def aclose() -> None:
global _client
if _client is not None and not _client.is_closed:
await _client.aclose()
_client = None
async def ask(
text: str,
@ -42,13 +59,12 @@ async def ask(
"temperature": settings.temperature,
}
headers = {"Authorization": f"Bearer {settings.litellm_key}"}
async with httpx.AsyncClient(timeout=settings.request_timeout) as client:
r = await client.post(settings.litellm_url, json=payload, headers=headers)
r.raise_for_status()
msg = r.json()["choices"][0]["message"]
# Filet de sécurité : si un modèle « thinking » renvoie un content vide (tout parti
# en reasoning_content), on récupère le raisonnement plutôt que de renvoyer "".
content = (msg.get("content") or "").strip()
if not content:
content = (msg.get("reasoning_content") or "").strip()
return content
r = await _get_client().post(settings.litellm_url, json=payload, headers=headers)
r.raise_for_status()
msg = r.json()["choices"][0]["message"]
# Filet de sécurité : si un modèle « thinking » renvoie un content vide (tout parti
# en reasoning_content), on récupère le raisonnement plutôt que de renvoyer "".
content = (msg.get("content") or "").strip()
if not content:
content = (msg.get("reasoning_content") or "").strip()
return content