Files
ARIA-AGENT/docs/LLM-SCHNITTSTELLE.md
T
duffyduckandClaude Opus 5.5 23feb3ba2d feat(llm): ein Modell pro Karte, Clients waehlen nur aus — und Doku fuer Drittprogramme
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>
2026-10-18 16:04:24 +02:00

11 KiB
Raw Blame History

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 requestId zu.
  • 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/models listet nur die geladenen Modelle dieser Box.
  • POST /v1/chat/completions mit "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 localhost der Box. Für Zugriff aus dem Netz LLM_OPENAI_BIND=0.0.0.0 in der .env der 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-swap holt 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_loaded und 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.