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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-18 08:27:37 +02:00
commit 111ecd8edf
3 changed files with 84 additions and 1 deletions

45
app/core/warmup.py Normal file
View file

@ -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)")

View file

@ -1,9 +1,12 @@
import asyncio
import time import time
from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from app.core.warmup import warmup_local_models
from app.metrics import metrics from app.metrics import metrics
from app.api.health import router as health_router from app.api.health import router as health_router
from app.api.chat import router as chat_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.metrics import router as metrics_router
from app.api.ws import router as ws_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") @app.middleware("http")

25
tests/test_warmup.py Normal file
View file

@ -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