Files
ARIA-AGENT/docs/LLM-SCHNITTSTELLE.md
T
duffyduckandClaude Opus 5.5 60f2d2e087 feat(diagnostic): pro LLM-Karte ein Dropdown — und Kontextfenster an Clients melden
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>
2026-10-19 01:22:06 +02:00

297 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.