2365f7aac6
Ampel / ampel (push) Successful in 22s
Beim Nachsehen wegen einer haengenden Gitea-Karte im Steward-Log gefunden:
600 vergebliche warmup.sh-Laeufe in 24 h, alle 90 Sekunden. Aelteste Meldung
dieser Art: 15.07.2026 — also elf Tage, nicht erst seit dem Umbau.
Ursache: Der Reranker steckt in der ko-residenten Gruppe `brains` und gilt dem
Waechter damit als warm-pflichtig. Er antwortet aber NUR auf /v1/rerank — und
genau diesen Endpunkt kannte warmup.sh nicht. Das Modell konnte also nie warm
werden, der Waechter sah es ewig als "fehlend" und rief alle 90 s ein Skript auf,
das daran nichts aendern konnte. Mit Devstral in derselben Gruppe habe ich die
Falle vorgestern verdoppelt.
Zwei Korrekturen:
1. warmup.sh waermt jetzt auch Reranker (MC_WARMUP_RERANK, Default "reranker").
2. warmer.py bekommt MC_WARMSET als Override der Gruppen-Ableitung. Ko-Residenz
("duerfen gleichzeitig liegen") und Warm-Pflicht ("muessen immer liegen") sind
zwei verschiedene Aussagen; die Gruppe kann nur die erste ausdruecken. Seit
Coder (TTL 90 min) und Kritiker (TTL 30 min) in `brains` liegen, haette der
Waechter sonst gegen ihre TTLs gearbeitet und nachts 62 GB wieder hochgeladen.
Steward bekommt MC_WARMSET = embed + reranker + hermes (Drop-in auf der Box).
Falle dabei, live erlebt: systemd trennt `Environment=` an Leerzeichen — ohne
Anfuehrungszeichen kam nur das erste Modell an und das Warm-Ziel war still zu
klein. Der erste Fix sah deshalb erfolgreich aus (Schleife weg), war aber nur
eine kaputte Messung. Jetzt gequotet und im Prozess-Environ geprueft.
Belegt: alle drei Ziel-Modelle warm, 0 vergebliche Ausloesungen des neuen
Prozesses, Coder geladen aber korrekt kein Warm-Ziel.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
141 lines
6.7 KiB
Python
141 lines
6.7 KiB
Python
"""
|
|
Hält das ganze WARM-SET (Hirn + Gedächtnis/embed) dauerhaft warm. Die Augen (vision/VL-30B)
|
|
sind seit 07.07. ON-DEMAND (ttl 900, User-Entscheid) und gehören NICHT mehr zum Warm-Set.
|
|
|
|
Hintergrund: llama-swap ist EIN-Gruppen-resident — lädt ein on-demand-Modell außerhalb
|
|
der `brains`-Gruppe, wird die ganze Gruppe verdrängt. `persist: true` verhindert nur
|
|
Idle-Unload, NICHT die Gruppen-Verdrängung; auch ein `-watch-config`-Reload (jede Config-
|
|
Änderung/Deploy) verwirft das Set, ohne llama-swaps ExecStartPost-Warmup neu auszulösen.
|
|
Dieser Wächter schließt die Lücke: ist die Box idle (nichts geladen), lädt er das ganze
|
|
Set über deploy/warmup.sh nach — Hirn UND embed (sonst bliebe embed kalt, live vom
|
|
Review-Wächter beobachtet). Während aktiver Last (irgendetwas geladen) hält er sich
|
|
raus, verdrängt also nie ein gerade genutztes Modell.
|
|
|
|
warmup.sh deckt korrekt ab: fast über /v1/chat/completions, embed über /v1/embeddings
|
|
(anderer Endpunkt!) und das Vorkauen des ~70-KB-Agent-Prompts. Es ist selbst-detachend.
|
|
|
|
Abschaltbar/justierbar via Env: MC_REWARM_ENABLED=0, MC_REWARM_INTERVAL. Welche Modelle
|
|
warmup.sh lädt: MC_WARMUP_MODELS (Default 'fast') + MC_WARMUP_EMBED (Default 'embed').
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
|
|
import httpx
|
|
from config import LLAMA_SWAP_URL
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
ENABLED = os.environ.get("MC_REWARM_ENABLED", "1") != "0"
|
|
INTERVAL = int(os.environ.get("MC_REWARM_INTERVAL", "90")) # Sekunden zwischen Checks
|
|
START_DELAY = int(os.environ.get("MC_REWARM_START_DELAY", "25"))
|
|
NUDGE_GRACE = int(os.environ.get("MC_REWARM_NUDGE_GRACE", "3")) # llama-swap den Reload abschließen lassen
|
|
|
|
# Das geteilte Warm-Skript (auch llama-swaps ExecStartPost) — EINE Quelle für „was ist das
|
|
# Warm-Set und wie wärmt man es korrekt", statt hier eine zweite, ärmere Logik zu pflegen.
|
|
_WARMUP_SH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "deploy", "warmup.sh"))
|
|
|
|
# Wecksignal für einen sofortigen Vorwärm-Check (statt bis zum nächsten INTERVAL-Tick zu warten).
|
|
# Wird von write_config() nach einer Config-Änderung gesetzt: llama-swap (-watch-config) lädt die
|
|
# neue Config und verwirft dabei ALLE Modelle inkl. Hirn — ohne Nudge bliebe es bis zu INTERVAL
|
|
# Sekunden kalt liegen, bis der nächste Tick oder eine Anfrage es wieder lädt.
|
|
_loop: asyncio.AbstractEventLoop | None = None
|
|
_wake: asyncio.Event | None = None
|
|
|
|
|
|
def nudge() -> None:
|
|
"""Threadsicher: bittet den Wächter, nach einem Config-Reload bald vorzuwärmen. No-op,
|
|
solange der Wächter (noch) nicht läuft."""
|
|
if _loop is not None and _wake is not None and not _loop.is_closed():
|
|
try:
|
|
_loop.call_soon_threadsafe(_wake.set)
|
|
except RuntimeError:
|
|
pass
|
|
|
|
|
|
def _run_warmup() -> bool:
|
|
"""Volles Warm-Set via deploy/warmup.sh nachladen (fast+vision über /v1/chat/completions,
|
|
embed über /v1/embeddings, plus Agent-Prompt-Prefill). Selbst-detachend, blockiert nicht.
|
|
False, wenn das Skript fehlt."""
|
|
if not os.path.exists(_WARMUP_SH):
|
|
log.warning("rewarm: warmup.sh nicht gefunden (%s) — kein Nachwärmen möglich", _WARMUP_SH)
|
|
return False
|
|
try:
|
|
subprocess.Popen(["bash", _WARMUP_SH],
|
|
env={**os.environ, "MC_LLAMA_SWAP_URL": LLAMA_SWAP_URL},
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
return True
|
|
except Exception:
|
|
log.debug("rewarm: warmup.sh-Start fehlgeschlagen", exc_info=True)
|
|
return False
|
|
|
|
|
|
def _warmset_members() -> set[str]:
|
|
"""Soll-Warm-Set. `MC_WARMSET` (leerzeichengetrennt) schlägt die Gruppen-Ableitung.
|
|
|
|
Warum ein Override (26.07.2026): Seit die ko-residente Gruppe auch Modelle MIT TTL
|
|
enthält (Coder 90 min, Kritiker 30 min), ist „alle Gruppen-Mitglieder" das falsche Ziel.
|
|
Der Wächter würde gegen die TTLs arbeiten und nachts 62 GB wieder hochladen, die
|
|
absichtlich freigegeben wurden. Ko-Residenz („dürfen gleichzeitig liegen") und
|
|
Warm-Pflicht („müssen immer liegen") sind zwei verschiedene Aussagen — die Gruppe kann
|
|
nur die erste ausdrücken.
|
|
"""
|
|
explizit = os.environ.get("MC_WARMSET", "").split()
|
|
if explizit:
|
|
return set(explizit)
|
|
try:
|
|
from services import llamaswap
|
|
groups = llamaswap.list_groups() or {}
|
|
except Exception:
|
|
return set()
|
|
want: set[str] = set()
|
|
for g in groups.values():
|
|
if isinstance(g, dict) and (g.get("swap") is False or g.get("persist") or g.get("persistent")):
|
|
want.update(g.get("members") or [])
|
|
return want
|
|
|
|
|
|
async def _warmset_missing() -> list[str] | None:
|
|
"""Warm-Set-Mitglieder, die NICHT 'ready' in /running sind. [] = alles warm, None = /running
|
|
nicht lesbar. Erkennt auch TEIL-Kälte (nur Hirn warm, Augen/embed rausgefallen) — genau der
|
|
Fall, der nach einem watch-config-Reload/Deploy auftritt."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=8.0) as c:
|
|
r = await c.get(f"{LLAMA_SWAP_URL}/running")
|
|
data = r.json() or {}
|
|
except Exception:
|
|
return None
|
|
ready = {str(x.get("model")) for x in (data.get("running") or []) if x.get("state") == "ready"}
|
|
want = _warmset_members()
|
|
if not want: # Set unbekannt → alte Heuristik: nur bei ganz leer
|
|
return [] if ready else ["<leer>"]
|
|
return [m for m in want if m not in ready]
|
|
|
|
|
|
async def rewarm_loop() -> None:
|
|
"""Endlos-Schleife (Hintergrund-Task): hält das ganze Warm-Set warm. Prüft periodisch, ob ein
|
|
Mitglied fehlt (Teil-Kälte!), und sofort nach einem Config-Reload-Nudge — lädt via warmup.sh nach."""
|
|
global _loop, _wake
|
|
_loop = asyncio.get_running_loop()
|
|
_wake = asyncio.Event()
|
|
await asyncio.sleep(START_DELAY) # Box/Engine nach MC-Start setzen lassen
|
|
while True:
|
|
try:
|
|
missing = await _warmset_missing()
|
|
if missing:
|
|
log.info("rewarm: Warm-Set unvollständig (%s) → warmup.sh", ", ".join(missing))
|
|
_run_warmup()
|
|
except Exception:
|
|
log.debug("rewarm: Tick fehlgeschlagen", exc_info=True)
|
|
# Bis zum nächsten Tick warten ODER sofort auf einen Config-Reload-Nudge reagieren.
|
|
try:
|
|
await asyncio.wait_for(_wake.wait(), timeout=INTERVAL)
|
|
_wake.clear()
|
|
await asyncio.sleep(NUDGE_GRACE) # llama-swap den Reload abschließen lassen
|
|
log.info("rewarm: Config-Reload → warmup.sh (volles Warm-Set nachladen)")
|
|
_run_warmup()
|
|
except asyncio.TimeoutError:
|
|
pass
|