Bisher konnte jeder Client per Modellname llama-swap zum Wechseln bringen — ein Fremdprogramm haette ARIA das Modell unter den Fuessen weggetauscht. Jetzt: - Jede LLM-Karte einer Box (LLM_GPUS=1,2, alt LLM_GPU=1) haelt GENAU ein Modell, dauerhaft. Welches, legt nur die Diagnostic fest. Karten, die nicht fuers LLM vorgesehen sind, tauchen beim LLM nicht auf und werden als Ziel abgelehnt. - Clients (ARIA, Fremdprogramme ueber RVS, OpenHands ueber den OpenAI-Port) sehen nur die geladenen Modelle und laden nie: Anfrage fuer ein anderes → model_not_loaded mit Liste des Geladenen (RVS) bzw. HTTP 404 (OpenAI-Port). Nichts wird verdraengt. - Mehrere Clients koennen dasselbe Modell gleichzeitig nutzen. llm-adapter: Belegung in /models/aria_slots.json. Die llama-swap-Config enthaelt nur belegte Karten: pro Modell CUDA_VISIBLE_DEVICES=<Karte>, ttl 0, groups.swap=false, hooks.on_startup.preload. Alles gegen llama-swap ausprobiert (zwei Modelle gleichzeitig ready, keins verdraengt, Unbekanntes 404, env kommt beim jeweiligen llama-server an). llama-swap laeuft mit --watch-config; die frueher aufgerufenen Reload-Endpunkte (/reload, /api/config/reload) gibt es nicht (404). Neu: llm_unload_model (Karte leeren), llm_models_request → llm_models (Erkundung, jede Box antwortet einzeln). Fehlgeschlagenes Laden setzt die Karte auf ihr vorheriges Modell zurueck, sonst risse jedes Neuladen die anderen Karten mit. Uebernahme: ohne Belegungsdatei landet LLM_MODEL auf der ersten LLM-Karte. Compose: llama-swap sieht alle Karten (NVIDIA_VISIBLE_DEVICES=all) und zaehlt sie wie nvidia-smi (CUDA_DEVICE_ORDER=PCI_BUS_ID); das Pinnen pro Modell macht der Adapter. Bridge: uebernimmt models/slots auch aus worker_ping (nach "Entladen" sonst 30 s veraltet) und sagt sofort ab, wenn das Modell auf keiner Box liegt, statt per Broadcast in den Timeout zu laufen. Diagnostic: Flotte zeigt pro LLM-Karte Modell + Zustand + "Entladen"; Katalog waehlt eine Ziel-KARTE (leere vorausgewaehlt, Ersetzen mit Rueckfrage); Download-Fenster nennt Karte und was ersetzt wird. RVS: llm_unload_model, llm_models_request, llm_models freigegeben. docs/LLM-SCHNITTSTELLE.md: Schnittstelle fuer Drittprogramme (RVS + OpenAI- Port), Fehlercodes, Gleichzeitigkeit, Python-Beispiel. Tests: 23 gegen llama-swap (Adapter direkt) + Ende-zu-Ende ueber den echten RVS mit dem Adapter als Prozess: Laden per RVS mit Fortschritt, das Doku-Beispiel unveraendert (nur Adresse) findet beide Karten und bekommt eine Antwort, Absage/Schweigen wie dokumentiert, zwei Clients parallel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 KiB
Lokale Sprachmodelle nutzen — Schnittstelle für Drittprogramme
Diese Seite beschreibt, wie ein Programm die lokalen Sprachmodelle auf den KI-Boxen von ARIA nutzt: über den RVS (WebSocket) oder direkt über den OpenAI-kompatiblen Port einer Box.
Die Grundregel
Clients wählen aus, laden aber nie.
- Jede KI-Box hat eine oder mehrere Grafikkarten fürs LLM. Jede dieser Karten hält genau ein Modell, dauerhaft geladen.
- Welches Modell auf welcher Karte liegt, legt ausschließlich der Betreiber in der ARIA-Diagnostic fest (Modell-Katalog → Laden → Karte).
- Ein Client — ARIA selbst, ein Drittprogramm, OpenHands — sieht nur die geladenen Modelle aller Boxen und schickt seine Anfrage an eines davon.
- Eine Anfrage für ein Modell, das nicht geladen ist, wird abgelehnt. Sie lädt nichts nach und verdrängt nichts. Kein Client kann einem anderen das Modell wegnehmen.
- Mehrere Clients dürfen gleichzeitig dasselbe Modell nutzen. Die Karte bedient sie parallel; jede einzelne Antwort wird dabei etwas langsamer, die Karte schafft in Summe aber mehr.
Welche Karten einer Box dem LLM gehören, steht in deren .env:
# Beispiel: Karte 0 für Spracherkennung + Sprachausgabe, Karten 1 und 2 fürs LLM
WHISPER_GPU=0
F5TTS_GPU=0
LLM_GPUS=1,2
Zwei Zugänge
| RVS (WebSocket) | OpenAI-Port der Box | |
|---|---|---|
| Erreichbar | überall, wo der RVS erreichbar ist | nur im Netz der Box (Standard: nur localhost) |
| Sieht | alle Boxen und ihre Karten | nur diese eine Box |
| Format | eigene Nachrichten (unten) | OpenAI /v1/chat/completions |
| Typische Nutzer | eigene Programme, ARIA | OpenHands, Continue, Open WebUI |
A) Über den RVS
Verbinden
wss://<RVS_HOST>:<RVS_PORT>/?token=<RVS_TOKEN>
- Das Token ist der Raum. Es ist dasselbe Token, das ARIA, die Diagnostic
und die KI-Boxen nutzen (
RVS_TOKEN). Wer das Token hat, ist im Raum. - Der RVS ist ein Verteiler: Jede Nachricht geht an alle anderen
Teilnehmer des Raums. Du siehst also auch Antworten, die für andere Clients
bestimmt sind — ordne Antworten immer über die
requestIdzu. - Der RVS lässt nur bekannte Nachrichtentypen durch. Unbekannte Typen verschwinden ohne Fehlermeldung. Die hier beschriebenen sind freigegeben.
Jede Nachricht hat diese Hülle:
{ "type": "<typ>", "payload": { … }, "timestamp": 1760000000000 }
1. Welche Modelle gibt es? — llm_models_request
{ "type": "llm_models_request", "payload": { "requestId": "abc-1" } }
Jede Box im Raum antwortet einzeln mit llm_models. Sammle die Antworten
ein paar Sekunden lang ein (2–3 s reichen):
{
"type": "llm_models",
"payload": {
"requestId": "abc-1",
"instanceId": "llm@ki-box",
"node": "ki-box",
"models": [
{ "model": "qwen3-8b", "gpu": "1", "gpuName": "NVIDIA GeForce RTX 3060", "state": "ready" },
{ "model": "qwen3-4b", "gpu": "2", "gpuName": "NVIDIA GeForce RTX 3060", "state": "ready" }
]
}
}
| Feld | Bedeutung |
|---|---|
instanceId |
Adresse der Box — brauchst du gleich als targetInstance |
node |
lesbarer Name der Box |
models[].model |
Modellname, so in der Anfrage angeben |
models[].gpu |
Karte, auf der es läuft |
models[].state |
ready = bereit, starting = startet gerade neu (Anfragen warten dann kurz), unknown = Box konnte es nicht prüfen |
Es stehen nur geladene Modelle in der Liste. Was dort nicht steht, kannst du nicht nutzen.
Mitlesen statt fragen: Die Boxen melden sich außerdem regelmäßig selbst
(worker_hello etwa alle 30 s, worker_ping alle 10 s, jeweils mit
service: "llm", models und slots). Ein Client, der länger verbunden
bleibt, kann die Liste daraus aktuell halten.
2. Fragen — llm_request
{
"type": "llm_request",
"payload": {
"requestId": "abc-2",
"targetInstance": "llm@ki-box",
"model": "qwen3-8b",
"messages": [
{ "role": "system", "content": "Du antwortest kurz." },
{ "role": "user", "content": "Wie hoch ist die Zugspitze?" }
],
"max_tokens": 256,
"temperature": 0.7
}
}
| Feld | Pflicht | Bedeutung |
|---|---|---|
requestId |
ja | eindeutig pro Anfrage (z.B. UUID) — damit findest du deine Antwort |
messages |
ja | Verlauf im OpenAI-Format (system / user / assistant / tool) |
model |
dringend empfohlen | eines aus llm_models. Ohne Angabe nimmt die Box das Modell ihrer ersten LLM-Karte. |
targetInstance |
dringend empfohlen | die instanceId aus llm_models. Siehe „Ohne targetInstance". |
max_tokens |
nein | Standard 512 |
temperature |
nein | Standard 0.7 |
stop |
nein | Liste von Stop-Strings |
tools |
nein | Tool-Definitionen im OpenAI-Format; Antwort enthält dann ggf. tool_calls |
3. Antwort — llm_response
{
"type": "llm_response",
"payload": {
"requestId": "abc-2",
"ok": true,
"content": "Die Zugspitze ist 2962 Meter hoch.",
"tool_calls": null,
"model": "qwen3-8b",
"instanceId": "llm@ki-box",
"node": "ki-box",
"gpu": "1",
"usage": { "prompt_tokens": 31, "completion_tokens": 12, "total_tokens": 43 },
"elapsedMs": 640
}
}
Im Fehlerfall ist ok false, dazu kommen error (Text für Menschen) und
errorCode (für Programme):
errorCode |
Bedeutung | Was tun |
|---|---|---|
model_not_loaded |
Das Modell liegt auf dieser Box nicht. loadedModels nennt, was dort geladen ist. |
Ein geladenes Modell wählen. Die Liste neu holen (llm_models_request). |
no_model_loaded |
Auf dieser Box ist keine LLM-Karte belegt. | Andere Box wählen. |
| (fehlt) | Fehler des Modells selbst, z.B. Kontext zu lang | error lesen |
Ohne targetInstance
Ohne targetInstance geht die Anfrage an alle Boxen. Antworten werden nur die,
die das Modell geladen haben — die anderen schweigen, damit du nicht von
jeder Box einen Fehler bekommst. Hat keine Box das Modell, kommt also gar
keine Antwort und dein Client läuft in seinen Timeout. Darum: immer
targetInstance setzen. Dann bekommst du bei einem Problem sofort eine klare
Absage.
Haben mehrere Boxen dasselbe Modell geladen und du lässt targetInstance
weg, antworten alle. Auch das spricht für targetInstance.
Was Clients nicht senden
llm_provision_model, llm_unload_model und llm_remove_model sind der
Diagnostic vorbehalten. Technisch könnte jeder im Raum sie senden — der RVS
unterscheidet die Teilnehmer nicht. Ein Programm, das sich daran nicht hält,
tauscht anderen Clients das Modell unter den Füßen weg; genau das soll diese
Schnittstelle verhindern.
Beispiel (Python)
Braucht Python 3.11+ und websockets (pip install websockets). Genau so gegen einen
echten RVS, llm-adapter und llama-swap getestet — nur die Adresse war eine andere.
import asyncio, json, uuid
import websockets
RVS = "wss://rvs.example.org:443/?token=DEIN_RVS_TOKEN"
async def models(ws, wait=2.5):
"""Alle geladenen Modelle aller Boxen: [(instanceId, node, model, gpu)]."""
rid = str(uuid.uuid4())
await ws.send(json.dumps({"type": "llm_models_request", "payload": {"requestId": rid}}))
found = []
loop = asyncio.get_running_loop()
end = loop.time() + wait
while (left := end - loop.time()) > 0:
try:
msg = json.loads(await asyncio.wait_for(ws.recv(), left))
except asyncio.TimeoutError:
break
p = msg.get("payload") or {}
if msg.get("type") == "llm_models" and p.get("requestId") == rid:
found += [(p["instanceId"], p["node"], m["model"], m["gpu"]) for m in p["models"]]
return found
async def ask(ws, instance_id, model, prompt, timeout=120):
rid = str(uuid.uuid4())
await ws.send(json.dumps({"type": "llm_request", "payload": {
"requestId": rid, "targetInstance": instance_id, "model": model,
"messages": [{"role": "user", "content": prompt}], "max_tokens": 256}}))
async with asyncio.timeout(timeout):
while True:
msg = json.loads(await ws.recv())
p = msg.get("payload") or {}
# Der Raum ist ein Verteiler: nur UNSERE Antwort nehmen.
if msg.get("type") == "llm_response" and p.get("requestId") == rid:
if not p["ok"]:
raise RuntimeError(f"{p.get('errorCode')}: {p.get('error')}")
return p["content"]
async def main():
async with websockets.connect(RVS, max_size=2**24) as ws:
available = await models(ws)
for iid, node, model, gpu in available:
print(f"{node} / Karte {gpu}: {model} ({iid})")
if available:
iid, node, model, gpu = available[0]
print(await ask(ws, iid, model, "Sag Hallo in einem Satz."))
asyncio.run(main())
B) Über den OpenAI-Port einer Box
Jede KI-Box bietet einen OpenAI-kompatiblen Endpunkt — für Programme, die nur „OpenAI-kompatibel" können (OpenHands, Continue, Open WebUI …).
http://<IP der Box>:<LLM_OPENAI_PORT>/v1 (Standard-Port 8081)
GET /v1/modelslistet nur die geladenen Modelle dieser Box.POST /v1/chat/completionsmit"model": "<Name>"wie gewohnt.- Ein nicht geladenes Modell wird mit HTTP 404 abgelehnt
(
"no router for requested model") — es wird nichts nachgeladen. - Ist in der Diagnostic ein API-Key gesetzt, mit
Authorization: Bearer <KEY>schicken. - Standardmäßig hört der Port nur auf
localhostder Box. Für Zugriff aus dem NetzLLM_OPENAI_BIND=0.0.0.0in der.envder Box setzen — dann unbedingt einen API-Key vergeben. - Dieser Zugang sieht nur seine eigene Box. Wer mehrere Boxen nutzen will, nimmt den RVS.
curl http://192.168.177.153:8081/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model": "qwen3-8b", "messages": [{"role": "user", "content": "Hallo"}]}'
Gleichzeitige Nutzung und Geschwindigkeit
- Mehrere Clients können dasselbe Modell gleichzeitig nutzen. llama.cpp bedient sie parallel (Continuous Batching); jede Antwort wird etwas langsamer, insgesamt schafft die Karte mehr.
- Der Kontext (
-c, Standard 8192 Tokens) ist bei aktuellen llama.cpp-Versionen ein gemeinsamer Vorrat aller gleichzeitigen Anfragen. Schicken mehrere Clients gleichzeitig sehr lange Verläufe, wird es dort eng. - Ältere llama.cpp-Versionen arbeiten Anfragen an dasselbe Modell nacheinander
ab statt parallel. Welche Version eine Box fährt, hängt am Image von
llama-swap (
docker compose pull llama-swapholt die aktuelle).
Wenn sich etwas ändert
- Lädt der Betreiber ein anderes Modell in eine Karte, ist das alte sofort
weg; laufende Clients bekommen dann
model_not_loadedund sollten die Liste neu holen. - Beim Laden oder Entladen startet die Box alle ihre Modelle kurz neu
(wenige Sekunden). Anfragen in diesem Moment warten, bis das Modell wieder
bereit ist (
state: "starting"). - Diese Schnittstelle wird erweitert, nicht umgebaut: Neue Felder können dazukommen. Ein Client sollte unbekannte Felder ignorieren.