From 111ecd8edf336c2bdc0118b6b84facaffc3b463b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Thu, 18 Jun 2026 08:27:37 +0200 Subject: [PATCH] perf(startup): lokale Modelle beim Start vorladen (Warm-up) Lifespan-Hintergrundtask laedt profilabhaengig piper-Stimme + faster-whisper-Modell vor (nur was die aktive Konfiguration nutzt; Cloud-Profile = No-op). Server ist sofort verfuegbar; der erste Nutzer zahlt nicht mehr den Kaltstart. Messung: erster Turn direkt nach Start first_audio 1,62 s (statt ~4,5 s mit Kaltstart). Co-Authored-By: Claude Opus 4.8 --- app/core/warmup.py | 45 ++++++++++++++++++++++++++++++++++++++++++++ app/main.py | 15 ++++++++++++++- tests/test_warmup.py | 25 ++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 app/core/warmup.py create mode 100644 tests/test_warmup.py diff --git a/app/core/warmup.py b/app/core/warmup.py new file mode 100644 index 0000000..b2f8d7e --- /dev/null +++ b/app/core/warmup.py @@ -0,0 +1,45 @@ +"""Laedt lokale KI-Modelle beim Start vor. + +So zahlt nicht der erste Nutzer den Kaltstart (piper ~2 s Modell-Load, faster-whisper +Modell-Load). Es wird nur vorgeladen, was die aktive Konfiguration tatsaechlich nutzt +(Default-Provider) - bei reinen Cloud-Profilen passiert nichts. Best-effort: Fehler +werden geloggt, brechen den Start nie ab. +""" + +import io +import logging +import wave + +from app.config import settings + +logger = logging.getLogger(__name__) + + +def _silence_wav(seconds: float = 0.1, rate: int = 16000) -> bytes: + buf = io.BytesIO() + with wave.open(buf, "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(rate) + w.writeframes(b"\x00\x00" * int(rate * seconds)) + return buf.getvalue() + + +async def warmup_local_models() -> None: + from app.dependencies import get_stt_provider, get_tts_provider + + try: + tts = get_tts_provider() + if type(tts).__name__ == "PiperTTSProvider": + await tts.synthesize("Hallo.", audio_format="pcm") + logger.info("warmup: piper-Stimmmodell geladen") + except Exception: + logger.exception("warmup: TTS-Vorladen fehlgeschlagen (ignoriert)") + + try: + stt = get_stt_provider() + if type(stt).__name__ == "FasterWhisperProvider": + await stt.transcribe(_silence_wav(), fmt="wav", language=settings.default_language) + logger.info("warmup: faster-whisper-Modell geladen") + except Exception: + logger.exception("warmup: STT-Vorladen fehlgeschlagen (ignoriert)") diff --git a/app/main.py b/app/main.py index 6f1e587..6d37576 100644 --- a/app/main.py +++ b/app/main.py @@ -1,9 +1,12 @@ +import asyncio import time +from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, Request from fastapi.staticfiles import StaticFiles +from app.core.warmup import warmup_local_models from app.metrics import metrics from app.api.health import router as health_router from app.api.chat import router as chat_router @@ -17,7 +20,17 @@ from app.api.me import router as me_router from app.api.metrics import router as metrics_router from app.api.ws import router as ws_router -app = FastAPI(title="Voice Assistant Gateway") +@asynccontextmanager +async def _lifespan(app: FastAPI): + # Lokale Modelle im Hintergrund vorladen -> Server ist sofort verfuegbar, + # der erste Nutzer zahlt nicht den Kaltstart. + task = asyncio.create_task(warmup_local_models()) + yield + if not task.done(): + task.cancel() + + +app = FastAPI(title="Voice Assistant Gateway", lifespan=_lifespan) @app.middleware("http") diff --git a/tests/test_warmup.py b/tests/test_warmup.py new file mode 100644 index 0000000..cdc51af --- /dev/null +++ b/tests/test_warmup.py @@ -0,0 +1,25 @@ +import asyncio + +from app.core.warmup import warmup_local_models + + +def test_warmup_is_noop_for_cloud_defaults(): + # Mit den Test-Defaults (openrouter-Provider) lädt warmup nichts und wirft nicht. + asyncio.run(warmup_local_models()) + + +def test_warmup_loads_piper(monkeypatch): + calls = {} + + class FakePiperTTSProvider: # Name muss zur Typpruefung im warmup passen + async def synthesize(self, text, voice=None, audio_format="pcm"): + calls["tts"] = text + return b"AUDIO" + + import app.dependencies as deps + monkeypatch.setattr(deps, "get_tts_provider", lambda *a, **k: FakePiperTTSProvider()) + # Klassennamen auf den vom warmup geprueften Namen setzen. + FakePiperTTSProvider.__name__ = "PiperTTSProvider" + + asyncio.run(warmup_local_models()) + assert calls.get("tts") # synthesize wurde aufgerufen