feat(llm): Cluster — Programme fragen ein Modell, der Cluster waehlt die Karte
Programme sollen nicht Box und Karte aussuchen muessen. Die ARIA-Bridge kennt
alle Boxen samt Karten-Belegung und verteilt (bridge/llm_cluster.py):
- Auswahl: unter allen online Boxen mit dem Modell die mit den wenigsten
laufenden Anfragen, bei Gleichstand reihum. Eine Karte / alles frei →
alles dorthin ("verteilen, wenn noetig").
- Ausfall: geht eine Box waehrend der Anfrage offline (kein Ping mehr), wird
sie EINMAL an eine andere Box mit demselben Modell gegeben. Modell-Fehler
(z.B. Kontext zu lang) werden nicht wiederholt — waeren ueberall gleich.
- Fehler kommen als Fehler (ok=false + errorCode), nie als Antwort-Text: ein
Programm wuerde ihn sonst als Aussage der KI in seinen Verlauf schreiben —
so ist bei ARIA schon mal ein ganzer Chat kaputtgegangen.
RVS: llm_cluster_models_request → llm_cluster_models (jedes Modell EINMAL,
ctx = kleinstes Kontextfenster unter seinen Karten), llm_cluster_request →
llm_cluster_response. In der RVS-Allow-List freigegeben.
ARIA nutzt denselben Router (mit ihrer 30-s-Frist fuer den lokalen
Schnellweg) — vorher Round-Robin ohne Ausfall-Ersatz, und errorCode ging
verloren.
xtts/openai_rvs_wrapper: fuer Programme, die nur "OpenAI-kompatibel" koennen
(OpenHands & Co.). Kleiner Container NEBEN dem Programm, fuer das der Cluster
dann http://127.0.0.1:8099/v1 ist; braucht nur eine ausgehende Verbindung zum
RVS. /v1/models (jedes Modell einmal, context_length), /v1/chat/completions
(auch stream und tools), Fehler als HTTP mit OpenAI-Fehlerobjekt. Hilfsseite
auf / zeigt Status, Base URL und Modelle. Fuer Programme in Docker: Netz
aria-llm, Name openai-rvs-wrapper. .env.example mit RVS_HOST/PORT/TOKEN.
(Ein OpenAI-Endpunkt direkt auf der VM war kurz drin und ist wieder raus —
der Wrapper braucht keinen offenen Port auf der VM.)
docs/LLM-SCHNITTSTELLE.md neu gegliedert: A) Cluster ueber RVS (empfohlen),
B) Wrapper, C) direkt an eine Box (Sonderfall), plus "Umstellen" fuer
bestehende Clients (Software-Schmiede).
Tests: mit echtem RVS und zwei echten llm-adaptern als zwei Boxen —
Modell einmal gelistet, 6 parallele Anfragen 3/3 verteilt, Box friert
mitten in der Anfrage ein → nach 15 s von der anderen beantwortet
(attempts=2), danach direkt die gesunde, nicht geladenes Modell →
model_unavailable ohne content. Wrapper als Docker-Container gegen das
offizielle OpenAI-SDK: models.list, create mit/ohne stream, NotFoundError,
AuthenticationError. Das Doku-Beispiel lief unveraendert (nur Adresse).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+29
-59
@@ -41,6 +41,7 @@ import websockets
|
||||
from faster_whisper import WhisperModel
|
||||
from openwakeword.model import Model as WakeWordModel
|
||||
|
||||
from llm_cluster import LlmCluster
|
||||
from modes import Mode, canonical_id, detect_mode_switch, mode_from_id, should_speak
|
||||
|
||||
|
||||
@@ -806,6 +807,9 @@ class ARIABridge:
|
||||
# llm_request via RVS an den llm-adapter (AI-Box) und warten auf
|
||||
# llm_response.
|
||||
self._pending_llm: dict[str, asyncio.Future] = {}
|
||||
# LLM-Cluster: ein Eingang fuer alle lokalen Modelle aller Boxen
|
||||
# (Auswahl nach Last, Ausfall-Ersatz). Siehe llm_cluster.py.
|
||||
self._llm_cluster = LlmCluster(self)
|
||||
# User-Message-Counter fuer Auto-Compact. Bei zu langer Konversation
|
||||
# sprengt die argv-Liste beim Claude-Subprocess-Spawn (E2BIG). Bei
|
||||
# COMPACT_AFTER erreicht → Sessions reset + Container restart.
|
||||
@@ -2453,6 +2457,9 @@ class ARIABridge:
|
||||
|
||||
msg_type = message.get("type", "")
|
||||
payload = message.get("payload", {})
|
||||
if msg_type.startswith("llm_cluster_") and isinstance(payload, dict):
|
||||
if await self._llm_cluster.handle_rvs(msg_type, payload):
|
||||
return
|
||||
|
||||
if msg_type == "chat":
|
||||
# Nur User-Nachrichten weiterleiten — ARIA/Diagnostic-Antworten ignorieren (sonst Loop!)
|
||||
@@ -4252,65 +4259,28 @@ class ARIABridge:
|
||||
if not isinstance(messages, list) or not messages:
|
||||
return {"ok": False, "error": "messages leer/ungueltig"}
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
loop = asyncio.get_event_loop()
|
||||
future: asyncio.Future = loop.create_future()
|
||||
self._pending_llm[request_id] = future
|
||||
try:
|
||||
req_payload = {
|
||||
"requestId": request_id,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if stop:
|
||||
req_payload["stop"] = stop
|
||||
if tools:
|
||||
req_payload["tools"] = tools
|
||||
if model:
|
||||
req_payload["model"] = model
|
||||
# Redundanz/Multitasking: freie llm-Instanz gezielt adressieren, die
|
||||
# das gewaehlte Modell fahren kann; None → Broadcast wie bisher.
|
||||
# Mehrere Boxen mit demselben Modell → Round-Robin (pro Projekt verteilt).
|
||||
llm_target = self._pick_worker("llm", model=model or None)
|
||||
if llm_target:
|
||||
req_payload["targetInstance"] = llm_target
|
||||
elif model and self._llm_boxes_with_slots():
|
||||
# Boxen mit Karten-Belegung laden nichts nach und schweigen auf
|
||||
# Broadcasts fuer fremde Modelle. Ein Broadcast liefe also nur in
|
||||
# den Timeout — lieber sofort ehrlich absagen.
|
||||
self._pending_llm.pop(request_id, None)
|
||||
loaded = sorted({m for w in self._llm_boxes_with_slots() for m in (w.get("models") or [])})
|
||||
return {"ok": False, "errorCode": "model_not_loaded",
|
||||
"error": (f"Lokales Modell {model!r} ist auf keiner KI-Box geladen. "
|
||||
f"Geladen: {', '.join(loaded) or 'nichts'}. "
|
||||
"Laden: Diagnostic → Modell-Katalog.")}
|
||||
logger.info("[rvs] llm_request → llm-adapter (id=%s, msgs=%d, max_tokens=%d, tools=%d, model=%s, target=%s)",
|
||||
request_id[:8], len(messages), max_tokens, len(tools) if tools else 0,
|
||||
model or "-", llm_target or "(broadcast)")
|
||||
ok = await self._send_to_rvs({
|
||||
"type": "llm_request",
|
||||
"payload": req_payload,
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
if not ok:
|
||||
return {"ok": False, "error": "llm_request konnte nicht gesendet werden"}
|
||||
try:
|
||||
result = await asyncio.wait_for(future, timeout=self._LLM_TIMEOUT_S)
|
||||
except asyncio.TimeoutError:
|
||||
return {"ok": False, "error": f"Timeout ({self._LLM_TIMEOUT_S:.0f}s) — AI-Box nicht erreichbar?"}
|
||||
if not isinstance(result, dict) or not result.get("ok"):
|
||||
err = (result or {}).get("error") if isinstance(result, dict) else "leeres Resultat"
|
||||
return {"ok": False, "error": err or "llm-adapter Fehler"}
|
||||
return {
|
||||
"ok": True,
|
||||
"content": result.get("content", ""),
|
||||
"tool_calls": result.get("tool_calls"),
|
||||
"model": result.get("model"),
|
||||
"elapsedMs": result.get("elapsedMs"),
|
||||
}
|
||||
finally:
|
||||
self._pending_llm.pop(request_id, None)
|
||||
# Derselbe Router wie fuer Fremdprogramme: Box nach Last waehlen, bei
|
||||
# Ausfall einmal anderswo versuchen, ehrliche Absage, wenn das Modell
|
||||
# nirgends geladen ist (statt Broadcast + Timeout).
|
||||
res = await self._llm_cluster.route({
|
||||
"model": model or "", "messages": messages, "max_tokens": max_tokens,
|
||||
"temperature": temperature, "stop": stop, "tools": tools,
|
||||
}, timeout=self._LLM_TIMEOUT_S)
|
||||
if not res.get("ok"):
|
||||
err = res.get("error") or "llm-adapter Fehler"
|
||||
if res.get("errorCode") == "model_unavailable":
|
||||
err += " Laden: Diagnostic → Einstellungen → LLM-Karten."
|
||||
return {"ok": False, "error": err, "errorCode": res.get("errorCode")}
|
||||
logger.info("[cluster] ARIA ← %s (Karte %s, %s ms, Versuche %s)",
|
||||
res.get("node") or res.get("instanceId"), res.get("gpu", "?"),
|
||||
res.get("elapsedMs"), res.get("attempts"))
|
||||
return {
|
||||
"ok": True,
|
||||
"content": res.get("content", ""),
|
||||
"tool_calls": res.get("tool_calls"),
|
||||
"model": res.get("model"),
|
||||
"elapsedMs": res.get("elapsedMs"),
|
||||
}
|
||||
|
||||
async def _flux_generate(self, prompt: str, width: int, height: int,
|
||||
steps: Optional[int], guidance: Optional[float],
|
||||
|
||||
Reference in New Issue
Block a user