Bisher kam context_exceeded als 502 an — OpenAI-Clients (litellm/OpenHands) hielten das fuer einen Serverfehler statt zu kuerzen/verdichten. Jetzt 400 mit code context_length_exceeded und dem OpenAI-ueblichen Text inkl. Zahlen. Doku: Abschnitt "Den Verlauf verwaltet das Programm" (Kuerzen, Verdichten, Gedaechtnis) fuer Drittprogramme. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
385 lines
16 KiB
Markdown
385 lines
16 KiB
Markdown
# Lokale Sprachmodelle nutzen — Schnittstelle für Programme
|
||
|
||
Wie ein Programm die lokalen Sprachmodelle auf den KI-Boxen von ARIA nutzt.
|
||
|
||
**Kurz:** Du fragst ein **Modell**, nicht eine Box oder Karte. Der Cluster
|
||
sucht sich aus, wo es läuft. Eigene Programme sprechen dafür über den RVS
|
||
(Abschnitt A). Programme, die nur „OpenAI-kompatibel" können, bekommen den
|
||
OpenAI-RVS-Wrapper neben sich gestellt (Abschnitt B).
|
||
|
||
## Die Grundregel
|
||
|
||
**Programme wählen ein Modell 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 wo liegt, legt nur der Betreiber in der ARIA-Diagnostic fest
|
||
(Einstellungen → LLM-Karten).
|
||
- Ein Programm sieht die geladenen Modelle des ganzen Clusters — jedes
|
||
**einmal**, egal auf wie vielen Karten es liegt — und wählt eins.
|
||
- Liegt das Modell auf mehreren Karten, **verteilt der Cluster bei Last**.
|
||
Fällt eine Box mitten in einer Anfrage aus, übernimmt eine andere mit
|
||
demselben Modell. Davon merkt das Programm nichts.
|
||
- Ist das Modell nirgends geladen, gibt es einen **Fehler** — nie eine normale
|
||
Antwort mit Fehlertext. Ein Programm würde so einen Text sonst als Aussage
|
||
der KI in seinen Verlauf schreiben.
|
||
- Mehrere Programme dürfen **gleichzeitig dasselbe Modell** nutzen. Die Karte
|
||
bedient sie parallel; jede einzelne Antwort wird dabei etwas langsamer.
|
||
|
||
## Welcher Zugang?
|
||
|
||
| | A) Cluster über RVS | B) OpenAI-RVS-Wrapper | C) Direkt an eine Box |
|
||
|---|---|---|---|
|
||
| Für | eigene Programme | OpenHands, Continue, Open WebUI … | Sonderfälle |
|
||
| Sieht | alle Modelle, jedes einmal | alle Modelle, jedes einmal | nur diese Box |
|
||
| Verteilung + Ausfall-Ersatz | ja | ja | nein |
|
||
| Braucht | ausgehende Verbindung zum RVS | Wrapper-Container auf demselben Rechner | ausgehende Verbindung zum RVS |
|
||
|
||
---
|
||
|
||
## A) Cluster über RVS — empfohlen
|
||
|
||
### Verbinden
|
||
|
||
```
|
||
wss://<RVS_HOST>:<RVS_PORT>/?token=<RVS_TOKEN>
|
||
```
|
||
|
||
- Das **Token ist der Raum** — dasselbe `RVS_TOKEN` wie bei ARIA und den
|
||
KI-Boxen. Wer es hat, ist im Raum.
|
||
- Der Programm-Rechner braucht nur eine **ausgehende** Verbindung zum RVS.
|
||
Keine Portfreigabe, kein VPN — er kann in jedem Netz stehen.
|
||
- Der RVS ist ein **Verteiler**: Jede Nachricht geht an alle anderen im Raum.
|
||
Du siehst also auch Antworten für andere — ordne sie immer über die
|
||
`requestId` zu.
|
||
- Unbekannte Nachrichtentypen verwirft der RVS **ohne Fehlermeldung**. Die hier
|
||
beschriebenen sind freigegeben.
|
||
- **Nur ein Leser pro Verbindung.** Schickst du mehrere Anfragen gleichzeitig,
|
||
liest eine Schleife alle Nachrichten und verteilt sie per `requestId` an die
|
||
Wartenden (siehe Beispiel). Mehrere gleichzeitige `recv()` auf derselben
|
||
Verbindung gehen nicht.
|
||
|
||
Jede Nachricht hat diese Hülle:
|
||
|
||
```json
|
||
{ "type": "<typ>", "payload": { … }, "timestamp": 1760000000000 }
|
||
```
|
||
|
||
Beantwortet werden `llm_cluster_*`-Nachrichten von der **ARIA-Bridge** — sie
|
||
kennt alle Boxen und entscheidet, wohin eine Anfrage geht. Läuft ARIA nicht,
|
||
kommt keine Antwort (dein Programm sollte einen Timeout haben).
|
||
|
||
### 1. Welche Modelle gibt es? — `llm_cluster_models_request`
|
||
|
||
```json
|
||
{ "type": "llm_cluster_models_request", "payload": { "requestId": "a1" } }
|
||
```
|
||
|
||
Antwort:
|
||
|
||
```json
|
||
{
|
||
"type": "llm_cluster_models",
|
||
"payload": {
|
||
"requestId": "a1",
|
||
"models": [
|
||
{
|
||
"model": "qwen3-8b",
|
||
"ctx": 8192,
|
||
"available": 2,
|
||
"cards": [
|
||
{ "node": "ki-box", "instanceId": "llm@ki-box", "gpu": "1", "gpuName": "NVIDIA GeForce RTX 3060", "state": "ready" },
|
||
{ "node": "werkstatt", "instanceId": "llm@werkstatt", "gpu": "0", "gpuName": "NVIDIA RTX A4000", "state": "ready" }
|
||
]
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
| Feld | Bedeutung |
|
||
|---|---|
|
||
| `model` | Name — genau so in der Anfrage angeben. **Das** zeigst du dem Nutzer zur Auswahl. |
|
||
| `ctx` | **Kontextfenster in Tokens** (Eingabe + Ausgabe zusammen). Das kleinste unter allen Karten mit diesem Modell — darauf kannst du dich verlassen, egal wohin verteilt wird. Nicht mehr annehmen. `0` = unbekannt. **Kann sich ändern** (der Betreiber stellt es pro Karte ein) — vor längeren Aufträgen neu holen. |
|
||
| `available` | auf wie vielen Karten es gerade liegt |
|
||
| `cards` | nur zur Info (Anzeige, Fehlersuche) — für die Anfrage nicht nötig |
|
||
|
||
### 2. Fragen — `llm_cluster_request`
|
||
|
||
```json
|
||
{
|
||
"type": "llm_cluster_request",
|
||
"payload": {
|
||
"requestId": "a2",
|
||
"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) |
|
||
| `model` | ja* | aus `llm_cluster_models`. *Darf fehlen, wenn im Cluster genau ein Modell geladen ist. |
|
||
| `messages` | ja | Verlauf im OpenAI-Format (`system` / `user` / `assistant` / `tool`) |
|
||
| `max_tokens` | nein | Standard 512 |
|
||
| `temperature` | nein | Standard 0.7 |
|
||
| `stop` | nein | Liste von Stop-Strings |
|
||
| `tools` | nein | Tool-Definitionen im OpenAI-Format; die Antwort enthält dann ggf. `tool_calls` |
|
||
|
||
### 3. Antwort — `llm_cluster_response`
|
||
|
||
Erfolg:
|
||
|
||
```json
|
||
{
|
||
"type": "llm_cluster_response",
|
||
"payload": {
|
||
"requestId": "a2",
|
||
"ok": true,
|
||
"content": "Die Zugspitze ist 2962 Meter hoch.",
|
||
"model": "qwen3-8b",
|
||
"node": "werkstatt",
|
||
"gpu": "0",
|
||
"usage": { "prompt_tokens": 31, "completion_tokens": 12, "total_tokens": 43 },
|
||
"elapsedMs": 640,
|
||
"attempts": 1
|
||
}
|
||
}
|
||
```
|
||
|
||
`tool_calls` steht drin, wenn das Modell ein Werkzeug aufrufen will (OpenAI-Format).
|
||
`node`/`gpu` sagen, wer geantwortet hat; `attempts: 2` heißt, die erste Box ist
|
||
ausgefallen und eine andere hat übernommen.
|
||
|
||
Fehler — `ok: false`, dazu `error` (Text für Menschen) und `errorCode`:
|
||
|
||
| `errorCode` | Bedeutung | Was tun |
|
||
|---|---|---|
|
||
| `model_unavailable` | Das Modell ist gerade auf keiner Karte im Cluster geladen. `available` nennt, was es gibt. | Dem Nutzer anzeigen; Liste neu holen. |
|
||
| `bad_request` | Anfrage unvollständig, z.B. `messages` leer oder `model` fehlt bei mehreren Modellen | Anfrage korrigieren |
|
||
| `timeout` | Keine Antwort innerhalb der Frist (Standard 600 s) — auch nach Ersatzversuch | später nochmal |
|
||
| `box_offline` | Box ist während der Anfrage weggefallen, und es gab keine Ersatz-Box | später nochmal |
|
||
| `context_exceeded` | Der Verlauf ist länger als das Kontextfenster. Dazu `promptTokens` (so lang war die Anfrage) und `ctx` (so viel geht). | Verlauf kürzen und nochmal senden — z.B. auf ~80 % von `ctx` abzüglich der gewünschten Antwortlänge. |
|
||
| `model_error` | Das Modell selbst hat einen anderen Fehler gemeldet. Wird nicht anderswo wiederholt — wäre dort genauso. | `error` lesen |
|
||
|
||
**Fehler nie als Antwort der KI behandeln.** Zeig `error` dem Nutzer als
|
||
Fehlermeldung an, aber schreib ihn nicht als Assistant-Nachricht in den
|
||
Verlauf.
|
||
|
||
### Beispiel (Python)
|
||
|
||
Braucht Python 3.11+ und `websockets` (`pip install websockets`). Ein Leser
|
||
pro Verbindung, mehrere Anfragen gleichzeitig möglich.
|
||
|
||
```python
|
||
import asyncio, json, uuid
|
||
import websockets
|
||
|
||
RVS = "wss://rvs.example.org:444/?token=DEIN_RVS_TOKEN"
|
||
|
||
|
||
class AriaCluster:
|
||
"""Eine RVS-Verbindung, ein Leser. Antworten gehen per requestId an die
|
||
wartende Anfrage — der Raum ist ein Verteiler, Fremdes wird ignoriert."""
|
||
|
||
def __init__(self, ws):
|
||
self.ws, self.wait = ws, {}
|
||
self.reader = asyncio.create_task(self._read())
|
||
|
||
async def _read(self):
|
||
async for raw in self.ws:
|
||
msg = json.loads(raw)
|
||
p = msg.get("payload") or {}
|
||
fut = self.wait.get((msg.get("type"), p.get("requestId")))
|
||
if fut and not fut.done():
|
||
fut.set_result(p)
|
||
|
||
async def _call(self, mtype, answer, payload, timeout):
|
||
rid = str(uuid.uuid4())
|
||
fut = asyncio.get_running_loop().create_future()
|
||
self.wait[(answer, rid)] = fut
|
||
try:
|
||
await self.ws.send(json.dumps({"type": mtype, "payload": dict(payload, requestId=rid)}))
|
||
return await asyncio.wait_for(fut, timeout)
|
||
finally:
|
||
self.wait.pop((answer, rid), None)
|
||
|
||
async def models(self):
|
||
res = await self._call("llm_cluster_models_request", "llm_cluster_models", {}, 6)
|
||
return res["models"]
|
||
|
||
async def chat(self, model, messages, timeout=660, **opts):
|
||
res = await self._call("llm_cluster_request", "llm_cluster_response",
|
||
dict(opts, model=model, messages=messages), timeout)
|
||
if not res["ok"]:
|
||
raise RuntimeError(f"{res.get('errorCode')}: {res.get('error')}")
|
||
return res
|
||
|
||
|
||
async def main():
|
||
async with websockets.connect(RVS, max_size=2**24) as ws:
|
||
cluster = AriaCluster(ws)
|
||
models = await cluster.models()
|
||
for m in models:
|
||
print(f"{m['model']} (Kontext {m['ctx']}, auf {m['available']} Karte(n))")
|
||
if models:
|
||
res = await cluster.chat(models[0]["model"],
|
||
[{"role": "user", "content": "Sag Hallo in einem Satz."}],
|
||
max_tokens=64)
|
||
print(res["content"], f"— von {res['node']} / Karte {res['gpu']}")
|
||
|
||
asyncio.run(main())
|
||
```
|
||
|
||
### Umstellen von der direkten Box-Ansprache
|
||
|
||
Wer schon gegen Abschnitt C gebaut hat (`llm_models_request` /
|
||
`llm_request` mit `targetInstance`), stellt so um:
|
||
|
||
1. **Modelliste:** `llm_cluster_models_request` statt `llm_models_request`.
|
||
Statt einer Antwort pro Box kommt **eine** Antwort mit jedem Modell
|
||
einmal. Kein Einsammeln über Sekunden mehr.
|
||
2. **Auswahl:** Dem Nutzer nur den **Modellnamen** zeigen — nicht mehr
|
||
„Modell · Karte · Box". Wo es läuft, entscheidet der Cluster.
|
||
3. **Kontextfenster:** `ctx` aus der Liste übernehmen statt einen eigenen Wert
|
||
einzustellen — und **danach kürzen**, nicht nur anzeigen. Die Box gibt nur
|
||
so viel her, wie dort steht; rechnet ein Programm mit mehr, kommt
|
||
`context_exceeded`. Den Fehler zusätzlich abfangen: kürzen und nochmal
|
||
senden (`promptTokens`/`ctx` stehen dabei). Tokens grob schätzen reicht
|
||
nicht immer — Code und Deutsch brauchen mehr Tokens pro Zeichen als
|
||
englischer Fließtext.
|
||
4. **Anfrage:** `llm_cluster_request` statt `llm_request`; **kein**
|
||
`targetInstance` mehr. Die Antwort kommt als `llm_cluster_response`.
|
||
5. **Fehler:** `ok: false` als Fehler anzeigen, nicht als Antwort in den
|
||
Verlauf. Neue Codes siehe Tabelle oben.
|
||
6. **Lesen:** ein Leser pro Verbindung, Antworten per `requestId` zuordnen.
|
||
|
||
Felder und Format von `messages`, `tools`, `tool_calls` und `usage` bleiben
|
||
gleich.
|
||
|
||
---
|
||
|
||
## B) OpenAI-RVS-Wrapper — für Programme, die nur OpenAI können
|
||
|
||
Ein kleiner Container, der **auf demselben Rechner** wie das Programm läuft.
|
||
Für das Programm ist der Cluster dann ein ganz normaler OpenAI-Endpunkt:
|
||
|
||
```
|
||
Programm ──HTTP──▶ Wrapper (127.0.0.1:8099) ──RVS──▶ ARIA-Bridge ──▶ KI-Box / Karte
|
||
```
|
||
|
||
Liegt im Repo unter [`xtts/openai_rvs_wrapper/`](../xtts/openai_rvs_wrapper/):
|
||
|
||
```bash
|
||
cd xtts/openai_rvs_wrapper
|
||
cp .env.example .env # RVS_HOST, RVS_PORT, RVS_TOKEN eintragen
|
||
docker compose up -d --build
|
||
```
|
||
|
||
Im Browser zeigt **http://127.0.0.1:8099/** eine Hilfsseite: ob die Verbindung
|
||
steht und welche Modelle es gibt. Im Programm eintragen:
|
||
|
||
| Feld | Wert |
|
||
|---|---|
|
||
| Base URL | `http://127.0.0.1:8099/v1` |
|
||
| API-Key | beliebig — oder `WRAPPER_API_KEY`, falls gesetzt |
|
||
| Modell | ein Name von der Hilfsseite |
|
||
| Kontextfenster | der Wert von der Hilfsseite |
|
||
|
||
- `GET /v1/models` — jedes Modell einmal, mit `context_length`
|
||
- `POST /v1/chat/completions` — auch `stream: true` (kommt derzeit in einem
|
||
Rutsch) und `tools`
|
||
- Fehler als HTTP-Fehler mit OpenAI-Fehlerobjekt: nicht geladenes Modell →
|
||
404 `model_not_found`, Cluster nicht erreichbar → 503, Zeitüberschreitung → 504,
|
||
Verlauf zu lang → **400 `context_length_exceeded`** mit dem OpenAI-üblichen Text
|
||
(„maximum context length is N tokens …") — daran erkennen OpenAI-Clients
|
||
(litellm, OpenHands …) den Überlauf und kürzen/verdichten selbst
|
||
- Antwort-Header `X-Aria-Node` / `X-Aria-Gpu` sagen, wer geantwortet hat
|
||
- **Läuft das Programm selbst in Docker**, ist `127.0.0.1` dort der eigene
|
||
Container. Dann das Docker-Netz `aria-llm` anhängen und
|
||
`http://openai-rvs-wrapper:8099/v1` nehmen (Details in der README des Wrappers).
|
||
|
||
Mit dem offiziellen OpenAI-Python-SDK getestet (`models.list`,
|
||
`chat.completions.create` mit und ohne Stream, Fehlerklassen).
|
||
|
||
---
|
||
|
||
## C) Direkt an eine Box — Sonderfall
|
||
|
||
Ohne den Cluster, eine bestimmte Box gezielt. Keine Verteilung, kein
|
||
Ausfall-Ersatz. Nur nötig, wenn ein Programm bewusst eine bestimmte Box will
|
||
oder ARIA nicht läuft.
|
||
|
||
**Modelle der Boxen:** `llm_models_request` `{requestId}` → **jede** Box
|
||
antwortet einzeln mit `llm_models` `{requestId, instanceId, node, models:
|
||
[{model, gpu, gpuName, ctx, state}]}`. Ein paar Sekunden einsammeln.
|
||
|
||
**Anfrage:** `llm_request` `{requestId, targetInstance, model, messages, …}`
|
||
→ `llm_response` `{requestId, ok, content, tool_calls, model, instanceId,
|
||
node, gpu, usage, elapsedMs}`. Bei Fehler `errorCode`:
|
||
`model_not_loaded` (mit `loadedModels`) oder `no_model_loaded`.
|
||
|
||
Ohne `targetInstance` antworten nur Boxen, die das Modell geladen haben — die
|
||
anderen schweigen. Hat keine es, kommt **keine** Antwort. Darum hier immer
|
||
`targetInstance` setzen.
|
||
|
||
**OpenAI-Port einer Box:** Jede Box hat zusätzlich einen eigenen
|
||
OpenAI-Endpunkt (`http://<IP der Box>:8081/v1`, Standard nur `localhost` der
|
||
Box). Er sieht nur diese Box; nicht geladene Modelle → HTTP 404. Für mehrere
|
||
Boxen den Wrapper (B) nehmen.
|
||
|
||
---
|
||
|
||
## Den Verlauf verwaltet das Programm
|
||
|
||
Die Box merkt sich **nichts** zwischen zwei Anfragen. Jede Anfrage muss alles
|
||
mitbringen, was das Modell sehen soll (Systemprompt, Werkzeuge, Verlauf). Das
|
||
Kontextfenster (`ctx`) ist die Obergrenze für **eine** Anfrage inklusive
|
||
Antwort. Wächst der Verlauf darüber hinaus, muss das Programm ihn kleiner
|
||
machen — drei übliche Wege, auch kombiniert:
|
||
|
||
| Weg | Was passiert | Gut für |
|
||
|---|---|---|
|
||
| **Kürzen** | älteste Nachrichten weglassen (Systemprompt behalten) | einfache Chats, Werkzeug-Schleifen |
|
||
| **Verdichten** („Compacting") | alte Teile vom Modell zusammenfassen lassen, Zusammenfassung statt Original mitsenden | lange Arbeitssitzungen |
|
||
| **Gedächtnis** (RAG) | Wichtiges als Fakten in einer Datenbank ablegen, pro Anfrage nur Passendes dazuholen | dauerhaftes Wissen über Sitzungen hinweg |
|
||
|
||
Wichtig dabei: Systemprompt **und** Werkzeug-Definitionen zählen mit, Platz
|
||
für die Antwort (`max_tokens`) einplanen, Token grob schätzen (Deutsch/Code:
|
||
eher 2,5–3 Zeichen pro Token) — und `context_exceeded` trotzdem abfangen:
|
||
kürzen, einmal neu senden.
|
||
|
||
## Was Programme nicht senden
|
||
|
||
`llm_provision_model`, `llm_unload_model` und `llm_remove_model` sind der
|
||
ARIA-Diagnostic vorbehalten. Technisch könnte jeder im Raum sie senden — der
|
||
RVS unterscheidet die Teilnehmer nicht. Ein Programm, das sich nicht daran
|
||
hält, tauscht anderen das Modell unter den Füßen weg.
|
||
|
||
## Gleichzeitige Nutzung und Geschwindigkeit
|
||
|
||
- Mehrere Anfragen an dasselbe Modell laufen auf einer Karte **parallel**.
|
||
Jede einzelne wird etwas langsamer, in Summe schafft die Karte mehr.
|
||
- Liegt ein Modell auf mehreren Karten, schickt der Cluster neue Anfragen an
|
||
die Karte mit den wenigsten laufenden.
|
||
- Der Kontext (`ctx`) ist bei aktuellen llama.cpp-Versionen ein **gemeinsamer
|
||
Vorrat** aller gleichzeitigen Anfragen auf einer Karte. Schicken mehrere
|
||
Programme gleichzeitig sehr lange Verläufe, wird es dort eng.
|
||
|
||
## Wenn sich etwas ändert
|
||
|
||
- Lädt der Betreiber ein anderes Modell in eine Karte, ist das alte dort weg.
|
||
Liegt es noch auf einer anderen Karte, merkt ein Programm nichts; sonst kommt
|
||
`model_unavailable` — dann die Liste neu holen.
|
||
- Beim Laden oder Entladen startet die betroffene Box **alle** ihre Modelle
|
||
kurz neu (wenige Sekunden). Anfragen in diesem Moment warten oder gehen an
|
||
eine andere Box.
|
||
- Diese Schnittstelle wird erweitert, nicht umgebaut: Neue Felder können
|
||
dazukommen. Unbekannte Felder ignorieren.
|