mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 14:24:42 +02:00
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:
parent
7025d7ae70
commit
914942de73
6 changed files with 165 additions and 78 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "stt-server"
|
name = "stt-server"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
description = "STT-server — orchestrateur AI du homelab Funk (API pour les clients STT)"
|
description = "STT-server — orchestrateur AI du homelab Funk (API pour les clients STT)"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
"""STT-server — orchestrateur AI in-cluster pour les clients STT."""
|
"""STT-server — orchestrateur AI in-cluster pour les clients STT."""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.2.0"
|
||||||
|
|
|
||||||
|
|
@ -7,21 +7,39 @@ Endpoints :
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import BackgroundTasks, FastAPI, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from stt_server import __version__
|
from stt_server import __version__
|
||||||
|
from stt_server import brain
|
||||||
from stt_server.brain import ask as brain_ask
|
from stt_server.brain import ask as brain_ask
|
||||||
from stt_server.config import settings
|
from stt_server.config import settings
|
||||||
from stt_server.longterm import LongTermMemory
|
from stt_server.longterm import LongTermMemory
|
||||||
from stt_server.memory import SessionStore
|
from stt_server.memory import SessionStore
|
||||||
|
|
||||||
app = FastAPI(title="STT-server", version=__version__)
|
log = logging.getLogger("stt_server")
|
||||||
|
|
||||||
sessions = SessionStore()
|
sessions = SessionStore()
|
||||||
longterm = LongTermMemory() if settings.memory_longterm else None
|
longterm = LongTermMemory() if settings.memory_longterm else None
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
yield
|
||||||
|
# Fermeture propre des clients HTTP persistants (pooling).
|
||||||
|
await brain.aclose()
|
||||||
|
if longterm:
|
||||||
|
await longterm.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="STT-server", version=__version__, lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
class AskRequest(BaseModel):
|
class AskRequest(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
model: str | None = None # alias LiteLLM ; défaut serveur si absent
|
model: str | None = None # alias LiteLLM ; défaut serveur si absent
|
||||||
|
|
@ -59,7 +77,7 @@ async def v1_reset(req: AskRequest) -> dict:
|
||||||
|
|
||||||
|
|
||||||
@app.post("/v1/ask", response_model=AskReply)
|
@app.post("/v1/ask", response_model=AskReply)
|
||||||
async def v1_ask(req: AskRequest) -> AskReply:
|
async def v1_ask(req: AskRequest, background: BackgroundTasks) -> AskReply:
|
||||||
text = req.text.strip()
|
text = req.text.strip()
|
||||||
if not text:
|
if not text:
|
||||||
raise HTTPException(status_code=400, detail="text vide")
|
raise HTTPException(status_code=400, detail="text vide")
|
||||||
|
|
@ -69,17 +87,30 @@ async def v1_ask(req: AskRequest) -> AskReply:
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"modèle '{model}' non autorisé ; dispo : {settings.allowed_models}",
|
detail=f"modèle '{model}' non autorisé ; dispo : {settings.allowed_models}",
|
||||||
)
|
)
|
||||||
|
t0 = time.perf_counter()
|
||||||
history = sessions.history(req.session_id) if req.session_id else None
|
history = sessions.history(req.session_id) if req.session_id else None
|
||||||
memories = await longterm.recall(text) if longterm else None
|
# recall : timeout serré, dégrade vite ; renvoie aussi le vecteur (réutilisé par store)
|
||||||
|
memories, qvec = await longterm.recall(text) if longterm else ([], None)
|
||||||
|
t_recall = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
reply = await brain_ask(text, model, history, memories)
|
reply = await brain_ask(text, model, history, memories)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
raise HTTPException(status_code=502, detail=f"upstream LiteLLM : {e}") from e
|
raise HTTPException(status_code=502, detail=f"upstream LiteLLM : {e}") from e
|
||||||
|
t_gen = time.perf_counter()
|
||||||
if req.session_id:
|
if req.session_id:
|
||||||
sessions.add(req.session_id, "user", text)
|
sessions.add(req.session_id, "user", text)
|
||||||
sessions.add(req.session_id, "assistant", reply)
|
sessions.add(req.session_id, "assistant", reply)
|
||||||
|
# store : APRÈS la réponse (BackgroundTasks) → hors latence perçue, et on réutilise qvec
|
||||||
if longterm:
|
if longterm:
|
||||||
await longterm.remember(req.session_id or "anon", text)
|
background.add_task(longterm.store, req.session_id or "anon", text, qvec)
|
||||||
|
log.info(
|
||||||
|
"ask model=%s recall=%.0fms gen=%.0fms total=%.0fms mem=%d",
|
||||||
|
model,
|
||||||
|
(t_recall - t0) * 1000,
|
||||||
|
(t_gen - t_recall) * 1000,
|
||||||
|
(t_gen - t0) * 1000,
|
||||||
|
len(memories),
|
||||||
|
)
|
||||||
return AskReply(reply=reply, model=model)
|
return AskReply(reply=reply, model=model)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,23 @@ import httpx
|
||||||
|
|
||||||
from stt_server.config import settings
|
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(
|
async def ask(
|
||||||
text: str,
|
text: str,
|
||||||
|
|
@ -42,8 +59,7 @@ async def ask(
|
||||||
"temperature": settings.temperature,
|
"temperature": settings.temperature,
|
||||||
}
|
}
|
||||||
headers = {"Authorization": f"Bearer {settings.litellm_key}"}
|
headers = {"Authorization": f"Bearer {settings.litellm_key}"}
|
||||||
async with httpx.AsyncClient(timeout=settings.request_timeout) as client:
|
r = await _get_client().post(settings.litellm_url, json=payload, headers=headers)
|
||||||
r = await client.post(settings.litellm_url, json=payload, headers=headers)
|
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
msg = r.json()["choices"][0]["message"]
|
msg = r.json()["choices"][0]["message"]
|
||||||
# Filet de sécurité : si un modèle « thinking » renvoie un content vide (tout parti
|
# Filet de sécurité : si un modèle « thinking » renvoie un content vide (tout parti
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,11 @@ class Settings:
|
||||||
embed_url: str = os.getenv("STT_EMBED_URL", "http://192.168.10.20:1238/v1/embeddings")
|
embed_url: str = os.getenv("STT_EMBED_URL", "http://192.168.10.20:1238/v1/embeddings")
|
||||||
embed_model: str = os.getenv("STT_EMBED_MODEL", "nomic-embed-text")
|
embed_model: str = os.getenv("STT_EMBED_MODEL", "nomic-embed-text")
|
||||||
memory_top_k: int = int(os.getenv("STT_MEMORY_TOPK", "3"))
|
memory_top_k: int = int(os.getenv("STT_MEMORY_TOPK", "3"))
|
||||||
|
# Le recall (embed + recherche) est sur le chemin de réponse : timeout SERRÉ pour qu'un
|
||||||
|
# embed lent/mort dégrade vite (souvenirs vides) au lieu d'ajouter des secondes au client.
|
||||||
|
# Le store tourne en tâche de fond (après la réponse) → timeout plus large toléré.
|
||||||
|
memory_recall_timeout: float = float(os.getenv("STT_MEMORY_RECALL_TIMEOUT", "4"))
|
||||||
|
memory_store_timeout: float = float(os.getenv("STT_MEMORY_STORE_TIMEOUT", "20"))
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
|
||||||
|
|
@ -27,21 +27,37 @@ class LongTermMemory:
|
||||||
self.embed_url = settings.embed_url
|
self.embed_url = settings.embed_url
|
||||||
self.embed_model = settings.embed_model
|
self.embed_model = settings.embed_model
|
||||||
self.top_k = settings.memory_top_k
|
self.top_k = settings.memory_top_k
|
||||||
|
self.recall_timeout = settings.memory_recall_timeout
|
||||||
|
self.store_timeout = settings.memory_store_timeout
|
||||||
self._ready = False
|
self._ready = False
|
||||||
|
# Client persistant : pooling + keep-alive (évite un handshake TCP par appel).
|
||||||
|
self._http: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
async def _embed(self, client: httpx.AsyncClient, text: str) -> list[float]:
|
def _client(self) -> httpx.AsyncClient:
|
||||||
r = await client.post(
|
if self._http is None or self._http.is_closed:
|
||||||
|
self._http = httpx.AsyncClient()
|
||||||
|
return self._http
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
if self._http is not None and not self._http.is_closed:
|
||||||
|
await self._http.aclose()
|
||||||
|
|
||||||
|
async def _embed(self, text: str, timeout: float) -> list[float]:
|
||||||
|
r = await self._client().post(
|
||||||
self.embed_url,
|
self.embed_url,
|
||||||
json={"model": self.embed_model, "input": text},
|
json={"model": self.embed_model, "input": text},
|
||||||
timeout=30,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()["data"][0]["embedding"]
|
return r.json()["data"][0]["embedding"]
|
||||||
|
|
||||||
async def _ensure_collection(self, client: httpx.AsyncClient, dim: int) -> None:
|
async def _ensure_collection(self, dim: int) -> None:
|
||||||
if self._ready:
|
if self._ready:
|
||||||
return
|
return
|
||||||
r = await client.get(f"{self.qdrant}/collections/{self.collection}")
|
client = self._client()
|
||||||
|
r = await client.get(
|
||||||
|
f"{self.qdrant}/collections/{self.collection}", timeout=self.store_timeout
|
||||||
|
)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
# Collection existante : si la dimension a changé (modèle d'embedding
|
# Collection existante : si la dimension a changé (modèle d'embedding
|
||||||
# différent, ex. Qwen3 4096 → nomic 768), on recrée — les anciens vecteurs
|
# différent, ex. Qwen3 4096 → nomic 768), on recrée — les anciens vecteurs
|
||||||
|
|
@ -57,30 +73,38 @@ class LongTermMemory:
|
||||||
await client.put(
|
await client.put(
|
||||||
f"{self.qdrant}/collections/{self.collection}",
|
f"{self.qdrant}/collections/{self.collection}",
|
||||||
json={"vectors": {"size": dim, "distance": "Cosine"}},
|
json={"vectors": {"size": dim, "distance": "Cosine"}},
|
||||||
|
timeout=self.store_timeout,
|
||||||
)
|
)
|
||||||
self._ready = True
|
self._ready = True
|
||||||
|
|
||||||
async def recall(self, text: str) -> list[str]:
|
async def recall(self, text: str) -> tuple[list[str], list[float] | None]:
|
||||||
"""Souvenirs pertinents (texte) ou [] si indisponible."""
|
"""Souvenirs pertinents + le vecteur de la requête (réutilisable pour `store`).
|
||||||
|
|
||||||
|
Sur le chemin de réponse → timeout serré (`recall_timeout`) : si l'embed ou Qdrant
|
||||||
|
traîne, on dégrade vite en `([], None)` plutôt que de faire patienter le client.
|
||||||
|
Le vecteur renvoyé évite de ré-embedder le même texte au moment du `store`.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=20) as client:
|
client = self._client()
|
||||||
vec = await self._embed(client, text)
|
vec = await self._embed(text, timeout=self.recall_timeout)
|
||||||
r = await client.post(
|
r = await client.post(
|
||||||
f"{self.qdrant}/collections/{self.collection}/points/search",
|
f"{self.qdrant}/collections/{self.collection}/points/search",
|
||||||
json={"vector": vec, "limit": self.top_k, "with_payload": True},
|
json={"vector": vec, "limit": self.top_k, "with_payload": True},
|
||||||
|
timeout=self.recall_timeout,
|
||||||
)
|
)
|
||||||
if r.status_code == 404: # collection pas encore créée
|
if r.status_code == 404: # collection pas encore créée → rien à rappeler
|
||||||
return []
|
return [], vec
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
pts = r.json().get("result", [])
|
pts = r.json().get("result", [])
|
||||||
return [p["payload"]["text"] for p in pts if p.get("payload", {}).get("text")]
|
texts = [p["payload"]["text"] for p in pts if p.get("payload", {}).get("text")]
|
||||||
|
return texts, vec
|
||||||
except Exception: # noqa: BLE001 — dégrade silencieusement
|
except Exception: # noqa: BLE001 — dégrade silencieusement
|
||||||
return []
|
return [], None
|
||||||
|
|
||||||
async def health(self) -> dict:
|
async def health(self) -> dict:
|
||||||
"""Diagnostic actif : sonde embeddings + Qdrant + collection, sans rien avaler.
|
"""Diagnostic actif : sonde embeddings + Qdrant + collection, sans rien avaler.
|
||||||
|
|
||||||
Contrairement à recall/remember (qui dégradent en silence), expose les erreurs
|
Contrairement à recall/store (qui dégradent en silence), expose les erreurs
|
||||||
pour pouvoir déboguer la mémoire long-terme sans `kubectl exec`.
|
pour pouvoir déboguer la mémoire long-terme sans `kubectl exec`.
|
||||||
"""
|
"""
|
||||||
out: dict = {
|
out: dict = {
|
||||||
|
|
@ -91,22 +115,24 @@ class LongTermMemory:
|
||||||
"embed": {"ok": False},
|
"embed": {"ok": False},
|
||||||
"qdrant": {"ok": False},
|
"qdrant": {"ok": False},
|
||||||
}
|
}
|
||||||
async with httpx.AsyncClient(timeout=20) as client:
|
client = self._client()
|
||||||
# 1) embeddings
|
# 1) embeddings
|
||||||
try:
|
try:
|
||||||
vec = await self._embed(client, "ping mémoire")
|
vec = await self._embed("ping mémoire", timeout=self.store_timeout)
|
||||||
out["embed"] = {"ok": True, "dim": len(vec)}
|
out["embed"] = {"ok": True, "dim": len(vec)}
|
||||||
except Exception as e: # noqa: BLE001 — on veut l'erreur
|
except Exception as e: # noqa: BLE001 — on veut l'erreur
|
||||||
out["embed"] = {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
out["embed"] = {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||||
# 2) Qdrant + collection
|
# 2) Qdrant + collection
|
||||||
try:
|
try:
|
||||||
r = await client.get(f"{self.qdrant}/collections")
|
r = await client.get(f"{self.qdrant}/collections", timeout=self.store_timeout)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
names = [c["name"] for c in r.json().get("result", {}).get("collections", [])]
|
names = [c["name"] for c in r.json().get("result", {}).get("collections", [])]
|
||||||
exists = self.collection in names
|
exists = self.collection in names
|
||||||
qdrant: dict = {"ok": True, "collections": names, "collection_exists": exists}
|
qdrant: dict = {"ok": True, "collections": names, "collection_exists": exists}
|
||||||
if exists:
|
if exists:
|
||||||
cr = await client.get(f"{self.qdrant}/collections/{self.collection}")
|
cr = await client.get(
|
||||||
|
f"{self.qdrant}/collections/{self.collection}", timeout=self.store_timeout
|
||||||
|
)
|
||||||
if cr.status_code == 200:
|
if cr.status_code == 200:
|
||||||
qdrant["points_count"] = cr.json().get("result", {}).get("points_count")
|
qdrant["points_count"] = cr.json().get("result", {}).get("points_count")
|
||||||
out["qdrant"] = qdrant
|
out["qdrant"] = qdrant
|
||||||
|
|
@ -114,13 +140,21 @@ class LongTermMemory:
|
||||||
out["qdrant"] = {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
out["qdrant"] = {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||||
return out
|
return out
|
||||||
|
|
||||||
async def remember(self, session_id: str, text: str) -> None:
|
async def store(
|
||||||
|
self, session_id: str, text: str, vec: list[float] | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Mémorise un tour. Pensé pour tourner **en tâche de fond** (hors chemin de réponse).
|
||||||
|
|
||||||
|
`vec` : si fourni (réutilisé depuis `recall`), on évite un 2ᵉ embed du même texte.
|
||||||
|
Pas de `?wait=true` : on n'attend pas le flush disque de Qdrant — le client a déjà
|
||||||
|
sa réponse, ce point peut être indexé en arrière-plan.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=20) as client:
|
if vec is None:
|
||||||
vec = await self._embed(client, text)
|
vec = await self._embed(text, timeout=self.store_timeout)
|
||||||
await self._ensure_collection(client, len(vec))
|
await self._ensure_collection(len(vec))
|
||||||
await client.put(
|
await self._client().put(
|
||||||
f"{self.qdrant}/collections/{self.collection}/points?wait=true",
|
f"{self.qdrant}/collections/{self.collection}/points",
|
||||||
json={
|
json={
|
||||||
"points": [
|
"points": [
|
||||||
{
|
{
|
||||||
|
|
@ -134,6 +168,7 @@ class LongTermMemory:
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
timeout=self.store_timeout,
|
||||||
)
|
)
|
||||||
except Exception: # noqa: BLE001 — dégrade silencieusement
|
except Exception: # noqa: BLE001 — dégrade silencieusement
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue