Einstellungen → Modelle & KI → "LLM-Karten": fuer jede Karte, die auf einer Box fuers LLM vorgesehen ist, ein Dropdown mit den Katalog-Modellen und "— leer —" (entlaedt die Karte). Nur echte LLM-Karten erscheinen. Modelle, die auf derselben Box schon auf einer anderen Karte liegen, werden nicht angeboten; zu grosse (Katalog-Groesse > VRAM der Karte) sind als "passt nicht" markiert. Waehrend eines Ladevorgangs ist das Dropdown gesperrt und zeigt den Fortschritt. Box meldet dafuer pro Karte den VRAM (nvidia-smi memory.total) und das Kontextfenster, mit dem llama-server das Modell startet (-c). Kontext an Clients: llm_models enthaelt jetzt "ctx". Anlass: die Software- Schmiede rechnete mit 32768 Tokens, die Box startet die Modelle mit 8192 — lange Auftraege waeren gescheitert oder falsch gekuerzt worden, und der Client konnte es nicht wissen. Doku entsprechend ergaenzt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
297 lines
11 KiB
Markdown
297 lines
11 KiB
Markdown
# 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`:
|
||
|
||
```bash
|
||
# 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:
|
||
|
||
```json
|
||
{ "type": "<typ>", "payload": { … }, "timestamp": 1760000000000 }
|
||
```
|
||
|
||
### 1. Welche Modelle gibt es? — `llm_models_request`
|
||
|
||
```json
|
||
{ "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):
|
||
|
||
```json
|
||
{
|
||
"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", "ctx": 8192, "state": "ready" },
|
||
{ "model": "qwen3-4b", "gpu": "2", "gpuName": "NVIDIA GeForce RTX 3060", "ctx": 8192, "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[].ctx` | **Kontextfenster in Tokens**, mit dem das Modell gestartet ist — Eingabe und Ausgabe zusammen. Diesen Wert verwenden, nicht den, den das Modell theoretisch kann: Die Box gibt nur so viel her, wie hier steht. `0` = unbekannt. |
|
||
| `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`
|
||
|
||
```json
|
||
{
|
||
"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`
|
||
|
||
```json
|
||
{
|
||
"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.
|
||
|
||
```python
|
||
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.
|
||
|
||
```bash
|
||
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.
|