D16d-Fix: volles Warm-Set nachwärmen statt nur das Hirn
Auto-Rewarm (Nudge + Idle-Tick) lädt jetzt das GANZE Warm-Set über deploy/warmup.sh nach (fast+vision via chat, embed via /v1/embeddings, Agent-Prompt-Prefill) statt nur einen Brain-Ping. Erkennt TEIL-Kälte (Mitglied fehlt in /running), nicht nur den komplett leeren Zustand — genau der Fall nach einem watch-config-Reload/Deploy (Augen+Gedächtnis fielen raus, Hirn blieb warm). deploy.sh ruft am Ende warmup.sh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+65
-41
@@ -1,19 +1,26 @@
|
|||||||
"""
|
"""
|
||||||
Hält das Agent-Hirn (Rolle `hermes`) dauerhaft warm.
|
Hält das ganze WARM-SET (Hirn + Augen/vision + Gedächtnis/embed) dauerhaft warm.
|
||||||
|
|
||||||
Hintergrund: llama-swap ist EIN-Gruppen-resident — lädt ein on-demand-Modell außerhalb
|
Hintergrund: llama-swap ist EIN-Gruppen-resident — lädt ein on-demand-Modell außerhalb
|
||||||
der `brains`-Gruppe, wird die ganze Gruppe (inkl. Hirn) verdrängt. `persist: true`
|
der `brains`-Gruppe, wird die ganze Gruppe verdrängt. `persist: true` verhindert nur
|
||||||
verhindert nur Idle-Unload, NICHT die Gruppen-Verdrängung; neu vorgewärmt wird sonst erst
|
Idle-Unload, NICHT die Gruppen-Verdrängung; auch ein `-watch-config`-Reload (jede Config-
|
||||||
beim nächsten llama-swap-(Re)Start. Dieser Wächter schließt die Lücke: ist die Box idle
|
Änderung/Deploy) verwirft das Set, ohne llama-swaps ExecStartPost-Warmup neu auszulösen.
|
||||||
(nichts geladen), pingt er das Hirn vor. Während aktiver Last (irgendetwas geladen) hält
|
Dieser Wächter schließt die Lücke: ist die Box idle (nichts geladen), lädt er das ganze
|
||||||
er sich raus, verdrängt also nie ein gerade genutztes Modell.
|
Set über deploy/warmup.sh nach — NICHT nur das Hirn (sonst blieben Augen+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.
|
||||||
|
|
||||||
Abschaltbar/justierbar via Env: MC_REWARM_ENABLED=0, MC_REWARM_INTERVAL, MC_REWARM_MODEL.
|
warmup.sh deckt korrekt ab: fast+vision ü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 vision') + MC_WARMUP_EMBED (Default 'embed').
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -26,6 +33,10 @@ INTERVAL = int(os.environ.get("MC_REWARM_INTERVAL", "90")) # Sekunden zwisch
|
|||||||
START_DELAY = int(os.environ.get("MC_REWARM_START_DELAY", "25"))
|
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
|
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).
|
# 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
|
# 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
|
# neue Config und verwirft dabei ALLE Modelle inkl. Hirn — ohne Nudge bliebe es bis zu INTERVAL
|
||||||
@@ -44,56 +55,67 @@ def nudge() -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _brain_model() -> str:
|
def _run_warmup() -> bool:
|
||||||
"""Aktives Agent-Hirn = Hermes' model.default (sonst model.model). So wärmt der Wächter
|
"""Volles Warm-Set via deploy/warmup.sh nachladen (fast+vision über /v1/chat/completions,
|
||||||
immer das WIRKLICH genutzte Hirn (passt sich Hirn-Wechseln an). Override: MC_REWARM_MODEL."""
|
embed über /v1/embeddings, plus Agent-Prompt-Prefill). Selbst-detachend, blockiert nicht.
|
||||||
forced = os.environ.get("MC_REWARM_MODEL")
|
False, wenn das Skript fehlt."""
|
||||||
if forced:
|
if not os.path.exists(_WARMUP_SH):
|
||||||
return forced
|
log.warning("rewarm: warmup.sh nicht gefunden (%s) — kein Nachwärmen möglich", _WARMUP_SH)
|
||||||
|
return False
|
||||||
try:
|
try:
|
||||||
from ruamel.yaml import YAML
|
subprocess.Popen(["bash", _WARMUP_SH],
|
||||||
from config import HERMES_HOME
|
env={**os.environ, "MC_LLAMA_SWAP_URL": LLAMA_SWAP_URL},
|
||||||
p = HERMES_HOME / "config.yaml"
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
if p.exists():
|
return True
|
||||||
with p.open(encoding="utf-8") as f:
|
|
||||||
cfg = YAML().load(f) or {}
|
|
||||||
m = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
|
|
||||||
v = m.get("default") or m.get("model")
|
|
||||||
if v:
|
|
||||||
return str(v)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
log.debug("rewarm: Hirn-Lookup fehlgeschlagen", exc_info=True)
|
log.debug("rewarm: warmup.sh-Start fehlgeschlagen", exc_info=True)
|
||||||
return "fast"
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def _running_empty() -> bool:
|
def _warmset_members() -> set[str]:
|
||||||
|
"""Soll-Warm-Set = Mitglieder aller ko-residenten Gruppen (swap:false bzw. persist/persistent)."""
|
||||||
|
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:
|
async with httpx.AsyncClient(timeout=8.0) as c:
|
||||||
r = await c.get(f"{LLAMA_SWAP_URL}/running")
|
r = await c.get(f"{LLAMA_SWAP_URL}/running")
|
||||||
data = r.json() or {}
|
data = r.json() or {}
|
||||||
return not (data.get("running") or [])
|
except Exception:
|
||||||
|
return None
|
||||||
|
ready = {str(x.get("model")) for x in (data.get("running") or []) if x.get("state") == "ready"}
|
||||||
async def _warm(model: str) -> None:
|
want = _warmset_members()
|
||||||
async with httpx.AsyncClient(timeout=180.0) as c:
|
if not want: # Set unbekannt → alte Heuristik: nur bei ganz leer
|
||||||
await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
|
return [] if ready else ["<leer>"]
|
||||||
"model": model, "max_tokens": 1,
|
return [m for m in want if m not in ready]
|
||||||
"messages": [{"role": "user", "content": "ping"}],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
async def rewarm_loop() -> None:
|
async def rewarm_loop() -> None:
|
||||||
"""Endlos-Schleife (Hintergrund-Task): prüft periodisch (und sofort nach einem Config-
|
"""Endlos-Schleife (Hintergrund-Task): hält das ganze Warm-Set warm. Prüft periodisch, ob ein
|
||||||
Reload-Nudge), wärmt bei Idle vor."""
|
Mitglied fehlt (Teil-Kälte!), und sofort nach einem Config-Reload-Nudge — lädt via warmup.sh nach."""
|
||||||
global _loop, _wake
|
global _loop, _wake
|
||||||
_loop = asyncio.get_running_loop()
|
_loop = asyncio.get_running_loop()
|
||||||
_wake = asyncio.Event()
|
_wake = asyncio.Event()
|
||||||
await asyncio.sleep(START_DELAY) # Box/Engine nach MC-Start setzen lassen
|
await asyncio.sleep(START_DELAY) # Box/Engine nach MC-Start setzen lassen
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
if await _running_empty():
|
missing = await _warmset_missing()
|
||||||
model = _brain_model()
|
if missing:
|
||||||
log.info("rewarm: Box idle → Hirn '%s' wird vorgewärmt", model)
|
log.info("rewarm: Warm-Set unvollständig (%s) → warmup.sh", ", ".join(missing))
|
||||||
await _warm(model)
|
_run_warmup()
|
||||||
except Exception:
|
except Exception:
|
||||||
log.debug("rewarm: Tick fehlgeschlagen", exc_info=True)
|
log.debug("rewarm: Tick fehlgeschlagen", exc_info=True)
|
||||||
# Bis zum nächsten Tick warten ODER sofort auf einen Config-Reload-Nudge reagieren.
|
# Bis zum nächsten Tick warten ODER sofort auf einen Config-Reload-Nudge reagieren.
|
||||||
@@ -101,5 +123,7 @@ async def rewarm_loop() -> None:
|
|||||||
await asyncio.wait_for(_wake.wait(), timeout=INTERVAL)
|
await asyncio.wait_for(_wake.wait(), timeout=INTERVAL)
|
||||||
_wake.clear()
|
_wake.clear()
|
||||||
await asyncio.sleep(NUDGE_GRACE) # llama-swap den Reload abschließen lassen
|
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:
|
except asyncio.TimeoutError:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -93,4 +93,10 @@ command -v ttyd >/dev/null 2>&1 && systemctl --user restart hermes-terminal 2>/d
|
|||||||
sleep 2
|
sleep 2
|
||||||
echo "--- Health ---"
|
echo "--- Health ---"
|
||||||
curl -sf http://127.0.0.1:9001/api/health && echo
|
curl -sf http://127.0.0.1:9001/api/health && echo
|
||||||
|
|
||||||
|
# Warm-Set (Hirn + Augen/vision + Gedächtnis/embed) nach dem Deploy nachladen — ein Deploy bzw.
|
||||||
|
# watch-config-Reload verwirft es sonst und die erste Anfrage wäre kalt (D16d). warmup.sh ist
|
||||||
|
# selbst-detachend (blockiert den Deploy nicht) und lädt jedes Modell über den richtigen Endpunkt.
|
||||||
|
bash "$SRC/deploy/warmup.sh" || true
|
||||||
|
|
||||||
echo "OK — Mission Control 2.0 läuft auf :9001 (User-Dienst, sudo-frei)."
|
echo "OK — Mission Control 2.0 läuft auf :9001 (User-Dienst, sudo-frei)."
|
||||||
|
|||||||
Reference in New Issue
Block a user