TypeSafe hat Jev rausgehauen, und mein kompletter Feed ist deswegen völlig ausgerastet. Sie nennen das ein System-One-Modell: du gibst ihm unordentlichen Zustand plus ein paar typisierte Fragen, und zurück kommt eine Auswahl, ein Score oder ein Ja/Nein mit einer echten Wahrscheinlichkeit dran. Kein Aufsatz, kein “Sure! Here’s the JSON you asked for:”, kein Zurückparsen von Prosa in das eine Wort, das du eigentlich wolltest. Ehre, wem Ehre gebührt: das ist die verdammt richtige Idee.
Dann liest du das Kleingedruckte. Gehostete API. Early Access. Warteliste. Keine Weights. Und das, was du beurteilt haben willst, ist per Definition genau das heikle Zeug, “dieser Agent ist gerade dabei, die customers-Tabelle zu droppen”, also muss es dein Netzwerk verlassen und in der Warteschlange von jemand anderem hocken, bevor du dein Ja oder Nein kriegst. Nö.
Also habe ich gemacht, was ich schon mit talkies für Sprache, mit flickies für Video und mit predictalot fürs Forecasting gemacht habe. Ich bin die offenen Modelle durchgegangen, die diesen Job erledigen, habe die besten behalten, Laya und Von, später CLM als drittes dazugepackt und alle zusammen in ein Docker-Image hinter eine API genagelt. Das ist decidealot, der Offline-Jev: dieselbe System-One-Form für Request und Response, MCP auf demselben Port, deine Hardware, keine Cloud-Rechnung, keine Warteliste, und bei den lokalen Modellen verlässt das, wonach du fragst, nie die Kiste. Gib ihm einen TypeSafe-Key, und es reicht dir auch das echte gehostete Jev durch, der einzige Weg, auf dem dein State tatsächlich rausgeht. Danach habe ich decidealot in aigate gestopft, direkt neben seine Geschwister, wenn du den Stack also schon laufen hast, trennt dich genau eine Env-Variable davon.
Einen Textgenerator um eine Entscheidung zu bitten ist scheißdumm
Jev ging durch die Decke, weil der Status quo saudumm ist:
- LLMs als Klassifikatoren. Ein Modell, gebaut um Text zu generieren, generiert Text, und den parst du dann zurück in das eine Wort, das du wolltest. Du bettelst um “valid JSON ONLY, no explanation” und kriegst es in Markdown-Fences eingewickelt, samt einer hilfsbereiten kleinen Notiz zum eigenen Gedankengang, plus das eine Mal von fünfzig, bei dem das Modell eine vierte Kategorie erfindet, die du nie aufgelistet hast. “Respond only with JSON” ist kein Vertrag, das ist ein Stoßgebet, und Structured-Output-Modi schieben das Parsen bloß in den Sampler von jemand anderem. Du zahlst immer noch für Decoding Token für Token, nur um ein Label rauszubekommen.
- Selbst gemeldete Konfidenz. Frag einen Chatbot, wie sicher er ist, und er sagt “85%”, eine Zahl, die er sich mit todernster Miene aus dem Arsch gezogen hat, weil an der Stelle eine Zahl gut aussah. Diese Zahl war nie auch nur in der Nähe eines Softmax. Du kannst keinen Schwellenwert darauf setzen, du kannst sie nicht kalibrieren, und du kannst sie nicht in ein Audit-Log schreiben und später verteidigen.
- Latenz, verbrannt für nichts. Jedes “Certainly! Based on the context provided” ist echte Zeit, die zwischen deinem Event und deiner Entscheidung rumhockt.
- Die Open-Weight-Modelle. Die zwei besten, die diesen Job lokal erledigen, sind Laya von NandhaKishorM und Von, gebaut von wfzyx, die Weights unter Apache 2.0. Super. Jedes der beiden bringt seinen eigenen Server mit, mit eigenen Ansichten darüber, wie ein Request auszusehen hat, eigener Installation und eigener Torch-Runtime, und keins nimmt den offiziellen TypeSafe-Request so, wie er ist. Willst du beide, fährst du zwei Server, zwei Installationen und zwei Torch-Runtimes und schreibst den Klebecode selbst.
Ich wollte eine Kiste, die ich einmal starte und auf die ich dann alles richte. Den gehosteten Vertrag, damit Code, der dagegen geschrieben wurde, keine zweite API lernen muss. Alle Modelle dahinter, jedes einzeln abschaltbar. MCP für die Agenten. Und keine Kiste, die ein geladenes Modell samt kompletter Torch-Runtime im RAM hortet, während kein Schwein ihr irgendeine Frage stellt.
Drei Fragetypen, ein Request
Der Vertrag ist klein. Du schickst ein model, einen state und eine Map benannter questions. Der State ist, was auch immer du beurteilt haben willst: ein String, ein JSON-Objekt oder ein Array. Die Namen der Fragen gehören dir und kommen als Keys unter answers zurück. Jede Frage hat einen von drei Typen:
choicewählt ein Label aus den Keys voncriteria. Diese Keys sind die einzigen möglichen Antworten. Eine vierte Schublade erfinden geht nicht, weil hier niemand einen gottverdammten Satz schreibt. Du bekommst diechoice, eineconfidenceund eine Wahrscheinlichkeit für jedes Label.scorenimmt ein geordnetes Array von Kriterien, bei dem die Position der Score ist, beginnend bei 0. Zurück kommt ein erwarteter Score, 1.9 auf einer dreistufigen Skala ist also eine echte Antwort und heißt “blockierend, mit einem Hauch von bald”, dazu Wahrscheinlichkeiten pro Stufe und einelegend, die die Positionen wieder auf deinen eigenen Wortlaut abbildet.noulist Ja oder Nein. Ein Feld,noul, die Wahrscheinlichkeit, dass die Aussage stimmt. Kein separates Konfidenzfeld, weil diese Zahl schon die Konfidenz ist.
Pack alle drei in einen Request, und sie werden gegen denselben State beantwortet:
curl --fail http://127.0.0.1:8080/v1/systemone \
--header 'Content-Type: application/json' \
--data '{
"model": "laya",
"state": "You billed me twice for March. Refund the duplicate today or I am cancelling.",
"questions": {
"department": {
"type": "choice",
"instructions": "Which team should handle this?",
"criteria": {
"billing": "Invoices, payments, refunds.",
"technical": "Bugs, outages, errors.",
"other": "Everything else."
}
},
"urgency": {
"type": "score",
"criteria": ["not urgent", "soon", "blocking"]
},
"churn_risk": {
"type": "noul",
"instructions": "Does the customer threaten to leave?"
}
}
}'Das ist kein ausgedachtes Beispiel. Genau das hat Laya tatsächlich zurückgeschickt, auf dem CUDA-Image hinter meiner eigenen aigate-Kiste:
{
"model": "laya",
"answers": {
"department": {
"type": "choice",
"choice": "billing",
"confidence": 0.8055,
"probabilities": { "billing": 0.9543, "technical": 0.0317, "other": 0.014 }
},
"urgency": {
"type": "score",
"score": 1.8986,
"confidence": 0.7053,
"legend": { "0": "not urgent", "1": "soon", "2": "blocking" },
"probabilities": { "0": 0.0222, "1": 0.0571, "2": 0.9208 }
},
"churn_risk": {
"type": "noul",
"noul": 0.2673
}
},
"usage": { "input_tokens": 156, "output_tokens": 0 }
}Schau dir output_tokens an. Null. Laya hat kein einziges Token geschrieben, also kann auch nichts als “Sure! Here’s” zurückkommen. Derselbe Request an Von meldet drei, was immer noch meilenweit von einem Absatz entfernt ist. So oder so legt das Schema die Form der Antwort fest, nicht die Frage, ob dem Modell deine Anweisungen heute scheißegal waren.
Jetzt schau dir churn_risk an. Der Kunde hat wortwörtlich “or I am cancelling” geschrieben, und Laya setzt die Drohung bei 0.27 an. Ich habe Von denselben Request als Zweitmeinung geschickt, und Von kam auf 0.23. Bei billing und blocking lagen beide goldrichtig. Bei der Kündigungsdrohung haben beide nur mit den Schultern gezuckt. Das ist nicht decidealot, das irgendwas verhunzt, das haben die Modelle so geantwortet, und genau deshalb gibt es den nächsten Absatz. Teste deine Fragen an deinen eigenen Fällen, bevor du einen Schwellenwert daran verdrahtest. Die Frage umzuformulieren kostet fast nichts. Es erst in Produktion rauszufinden kostet richtig.
decidealot gibt dir die Zahlen und hört genau da auf. decidealot handelt nicht. Der Schwellenwert gehört deinem Code: erlauben, wenn allow über 0.95 liegt, alles andere in die Warteschlange für einen Menschen, und die komplette Antwort neben dem Datensatz der Aktion speichern. Dieselbe Haltung wie bei predictalot, nur eine Schicht weiter. Zahlen rein, und die Entscheidung, was damit passiert, bleibt bei dir.
Drei lokale Modelle plus gehostetes Jev, jedes mit eigenem Ausschalter
Jeder Request nennt ein Modell. Es gibt keinen Default, damit deine Entscheidungen nicht heimlich das Gehirn wechseln, weil irgendein Arschloch eine Env-Variable geändert hat. GET /v1/models liefert den Katalog von dem, was dieses Deployment eingeschaltet hat. Mit allen drei lokalen Providern an sind das alle vierzehn hier unten, plus die Namen, die TypeSafe dir zurückgibt, wenn du zusätzlich das gehostete Jev einschaltest:
laya,laya-auto,laya-latest: Laya mit automatischem Checkpoint-Routing. Laya schaut sich Schriftsystem und Sprache des States an und wählt selbst den englischen oder den mehrsprachigen Checkpoint.laya-english: der englische Checkpoint, für Englisch in lateinischer Schrift.laya-multilingual: der mehrsprachige Checkpoint, für alles andere, auch für kurzen Text in lateinischer Schrift, der nicht eindeutig Englisch ist.laya-typed-decisions: der Checkpoint, der auf wiederholte strukturierte Workflow-Aufrufe getunt ist, also Policy, Routing, Triage und Freigaben. Teste diesen Checkpoint an deinen eigenen Fällen, bevor du ihm traust.von,von-latest,von-1.1,von-1.1.0: Von, ein unabhängiges, reines Englisch-Modell für kurze, sauber gestellte Entscheidungen. Für sich allein nützlich, und nützlich als Zweitmeinung, bevor du einen Workflow auf Laya standardisierst.clm,clm-latest,clm-0.1,clm-0.1-8b: CLM v0.1, komplett anders gebaut als die anderen beiden. Mehr dazu direkt hier drunter.
Laya ist eine Modellfamilie mit drei Checkpoints. Von ist ein anderes Modell von anderen Leuten. CLM, das ich später drangeschraubt habe, ist noch mal ein ganz anderes Tier: ein 75 MB großer Projection Head von Contrastive-LM, der für sich allein kein einziges Wort lesen kann. CLM schickt deinen State und deine Kriterien, als Text gerendert, an genau einen OpenAI-kompatiblen /v1/embeddings-Endpoint, der Qwen3-8B-Vektoren vom letzten Token mit exakt 4096 Floats Breite zurückliefern muss, wirft jeden Vektor mit anderer Breite weg, normalisiert den Rest, bevor der Head überhaupt läuft, und bewertet aus diesen Vektoren jede Antwort gegen den State. Antworttexte, die schon einmal durch die Embeddings gelaufen sind, kommen aus einem begrenzten Cache mit TTL, statt den Endpoint erneut anzufragen, aber dein roher State landet nie im Cache und geht jedes Mal frisch zu den Embeddings. Alle drei nehmen denselben Request und liefern dieselben Antworttypen, und genau darum stecken sie hinter einem gemeinsamen Vertrag: den model-String tauschen und vergleichen.
Jedes der drei lokalen hat seinen eigenen Schalter: DECIDEALOT_LAYA_ENABLED, DECIDEALOT_VON_ENABLED und DECIDEALOT_CLM_ENABLED. Lass alle drei laufen, zwei beliebige oder nur eins, aber mindestens ein Provider muss an sein, denn eine Kiste ohne einen einzigen Provider weigert sich zu starten, statt da rumzustehen und auf nichts zu antworten. Laya und Von sind standardmäßig an. CLM steht standardmäßig auf auto, geht also an, sobald du DECIDEALOT_CLM_EMBEDDINGS_URL setzt. Stell es auf true, und ohne URL stirbt der Start, stell es auf false, und CLM bleibt aus, auch mit URL. Worüber du nachdenken solltest, ist die URL: Dein State geht an diesen Endpoint, CLM ist also genau so privat wie das, was dahinter hängt. Häng CLM an etwas, das du selbst betreibst, oder wenigstens an etwas, dem du vertraust. Ein abgeschalteter Provider wird nicht heruntergeladen, startet nicht, taucht weder in GET /v1/models noch in der Modellliste von MCP auf, und seine Namen werden abgelehnt. Eine reine CLM-Kiste lädt den einen CLM-Checkpoint herunter und sonst nichts. Und ja, inzwischen gibt es einen vierten Schalter, für das gehostete Jev, dem dieser Post von der ersten Zeile an den Mittelfinger zeigt. DECIDEALOT_JEV_ENABLED steht ebenfalls standardmäßig auf auto, geht also an, sobald du DECIDEALOT_TYPESAFE_API_KEY setzt. Die Modellnamen kommen direkt aus dem Katalog von TypeSafe, 60 Sekunden lang gecacht und mit dem ersten Request danach neu geholt, ohne lokalen jev-Alias, und jede Jev-Entscheidung schickt deinen kompletten State und alle Fragen an TypeSafe. Das ist das genaue Gegenteil von offline, also lass den Key weg, wenn du das nicht willst, dann gibt es Jev schlicht nicht. Mit gesetztem Key kannst du sogar alle drei lokalen abschalten und eine reine Jev-Kiste fahren, die gar nichts herunterlädt.
Entladen heißt, der Prozess stirbt
Das ist der Teil, der mir wirklich wichtig ist. Im Image stecken zwei Python-Virtualenvs. /opt/app-venv ist das Gateway: FastAPI, httpx, das MCP SDK, pydantic, uvicorn. Da drin ist kein Torch, und das Gateway importiert es nie. /opt/torch-venv ist die Modellseite, die sich Laya, Von und CLM teilen: Die Modellpakete kommen aus einer einzigen Datei mit festgenagelten Hashes obendrauf auf meine Torchbase-Images, die es gibt, damit die fetten Python-, Torch- und CUDA-Layer einmal gebaut und wiederverwendet werden, statt jedes Mal neu gebaut zu werden, wenn ich eine Zeile App-Code anfasse. Ein geteiltes venv ist aber kein geteilter Prozess. In dem Prozess, der deine HTTP-Requests beantwortet, steckt nie ein Modell.
Jedes Modell läuft als Kindprozess des Gateways, gestartet von einem Supervisor aus einem festen Befehl ohne Shell, und lauscht auf einem festen Loopback-Port. Standardmäßig ist immer nur eins resident, und DECIDEALOT_MAX_RESIDENT_LOCAL_PROVIDERS hebt das auf zwei oder drei, wenn du das RAM oder VRAM dafür hast. Mit der Standardeinstellung gilt: Fragst du nach Von, während Laya geladen ist, wartet der Supervisor, bis jeder laufende Laya-Request fertig ist, killt Laya, startet Von und pollt alle Viertelsekunde das /health von Von, bis Von antwortet. Mit mehr Slots killt er erst etwas, wenn alle belegt sind, und dann das Modell im Leerlauf, das am längsten nicht benutzt wurde. Das gehostete Jev berührt nichts davon, das ist nur ein HTTPS-Aufruf. Der Wechsel zwischen den Laya-Selektoren bleibt im Laya-Prozess, weil das Checkpoints desselben Modells sind. Zusammengestutzt sieht die Schranke so aus:
async with self._provider_switch_condition:
spec = self._require_spec(provider_name)
while (
provider_name not in self._processes
and len(self._processes) >= self._settings.max_resident_local_providers
):
idle_candidates = [
name for name in self._processes if self._active_requests[name] == 0
]
if idle_candidates:
oldest = min(idle_candidates, key=lambda name: self._last_used_at.get(name, 0))
await self._stop_provider_locked(oldest, reason="model_switch")
continue
await self._provider_switch_condition.wait()
await self._start_provider_locked(spec)
self._active_requests[provider_name] += 1
try:
yield
finally:
async with self._provider_switch_condition:
self._active_requests[provider_name] -= 1
self._last_used_at[provider_name] = asyncio.get_running_loop().time()
self._provider_switch_condition.notify_all()Warum ein ganzer Prozess statt del model und einem Stoßgebet an den Garbage Collector? Weil das den Speicher nicht zurückgibt. Der Caching-Allocator von PyTorch klammert sich an alles, was er sich gegriffen hat, und der CUDA-Kontext bleibt, solange der Prozess lebt. Den Prozess zu killen ist das einzige Entladen, das auch wirklich ein Entladen ist, also macht unload genau das: terminate, zehn Sekunden Gnadenfrist, dann kill, wenn der Prozess rumzickt wie ein kleines Miststück. Weights, Torch-Allokationen, Worker-Threads und der CUDA-Kontext gehen alle mit drauf. Der nächste Request startet den Prozess neu.
Dieser Start ist nicht gratis, und ich tu auch nicht so. Auf meiner GPU-Kiste, über aigate, brauchte der erste Laya-Request nach einem Kaltstart etwa 61 Sekunden, weil der Prozess hochfahren und das Modell laden musste. Der nächste identische Request kam in 55 Millisekunden zurück, Ende zu Ende, Netzwerk inklusive, mit Byte für Byte derselben Antwort. Der Wechsel zu Von dauerte etwa zwei Minuten, weil Laya erst runter und Von erst hoch musste. Im warmen Zustand antwortete Von in etwa 115 Millisekunden. Der Idle-Timeout ist also ein echter Kompromiss: lang genug, dass dein Traffic nicht ständig den Kaltstart bezahlt, und kurz genug, dass die GPU nicht für ein Modell den Babysitter spielt, das keiner benutzt.
Zwei Dinge lösen das aus. Ein Idle-Reaper entlädt einen Provider, der DECIDEALOT_PROVIDER_IDLE_UNLOAD_SECONDS lang ungenutzt rumlag, standardmäßig 600, und schaut dabei in Abständen von einem Zehntel dieses Fensters nach, begrenzt auf 10 Millisekunden bis 30 Sekunden. Setzt du den Wert auf 0, geht nur der Timer aus. Und POST /v1/models/unload macht es auf Zuruf. Dieser Endpoint kennt nur alles oder nichts: steckt irgendein lokaler Provider mitten in einem Request, bekommst du ein 409 PROVIDER_BUSY, und nichts wird freigegeben. Der Endpoint zieht einem Aufrufer nie ein Modell unter dem Hintern weg.
Einmal laden, dann bleibt das Internet draußen
Du mountest ein Host-Verzeichnis unter /models, und decidealot verwaltet darin /models/laya, /models/von und, wenn CLM an ist, /models/clm. Beim ersten Start fährt der Supervisor für jedes eingeschaltete Modell einen prepare-Schritt im Torch-venv, der den Snapshot von Hugging Face zieht, convaiinnovations/laya und wfzyx/von, jeweils auf eine exakte Commit-Revision gepinnt. Danach prüft derselbe Schritt, ob jede Datei, die die Runtime braucht, auch wirklich da ist: vier Dateien in jedem der drei Checkpoint-Verzeichnisse von Laya, sechs für Von. CLM zieht genau eine Datei aus Contrastive-LM/CLM-v0.1-8B, ebenfalls auf einer gepinnten Revision, und benutzt sie nur, wenn ihr SHA-256 zum gepinnten passt. Nichts davon importiert Torch. /health antwortet mit 503, bis alle eingeschalteten Bundles bereit sind. Mit Laya und Von ab Werk sind das beim ersten Mal etwa 5.3 GB und ein paar Minuten, und CLM legt seine 75 MB obendrauf. Danach liegen die Dateien schon da, und es wird nichts mehr heruntergeladen.
Bevor ein Modell lädt, setzt decidealot dann HF_HUB_OFFLINE=1 und TRANSFORMERS_OFFLINE=1. Sobald das Bundle verifiziert ist, darf kein Modell mehr mitten im Lauf zurück zum Hub spazieren und irgendeinen Scheiß holen, den du nicht gepinnt hast.
Zwei Upstream-Server, ein gemeinsamer Vertrag
Das offizielle Schema sagt, instructions ist optional und darf ein verschachtelter JSON-Wert sein, und dasselbe gilt für die Werte der Kriterien. Laya will bei jeder einzelnen Frage ein instructions-Feld. Von will, dass das Feld ein String ist. Schick einem der beiden einen Request, den die offizielle API anstandslos schluckt, und du fliegst raus, wegen eines Formunterschieds, um den keiner gebeten hat.
Also validiert decidealot deinen Request zuerst gegen die offiziellen Request-Modelle und baut daraus dann die nativen Bodies für Laya und Von: ein fehlendes instructions wird zum leeren String, und verschachtelte Werte werden als kompakter JSON-Text gerendert. Deine Choice-Labels, deine Score-Reihenfolge und dein state gehen unangetastet durch. Auf dem Rückweg wird die Antwort des Providers gegen das offizielle Response-Schema validiert. Provider-eigener Müll wie der Routing-Block von Laya fliegt raus, und model wird auf den öffentlichen Namen dessen gesetzt, was tatsächlich geantwortet hat. Gibt ein Provider etwas zurück, das nicht ins Schema passt, bekommst du ein sauberes 503 statt Müll, der sich als Entscheidung verkleidet. Lehnt ein Provider einen Request mit einer nackten Meldung ab, wird sie in den offiziellen Validierungsumschlag {"detail": [...]} umgeschrieben, deine Fehlerbehandlung sieht also immer nur eine Form.
Und dann ist da noch der Server, den Von mitbringt. Der findet sein Backend über ein prozessweites Singleton, und dem öffentlichen Konstruktor kann man nicht sagen, wo der Checkpoint liegt. Also baut decidealot die Engine selbst und rammt sie in den Slot, aus dem der Server liest:
engine = engine_type(
backend_name=os.environ.get(_von_backend_env, _default_von_backend),
device=os.environ.get(_von_device_env),
)
engine.backend = option_marker_backend_type(checkpoint_dir=str(model_dir), device=engine.device)
# Von's server resolves its backend from this singleton, whose public constructor
# has no checkpoint-directory argument.
with engine_type._lock:
engine_type._instance = engineJa, das ist ein Griff in ein privates Singleton. Das ist scheißhässlich, und zwar genau so hässlich, wie es sein muss, um Von auf ein Verzeichnis zu richten, das du kontrollierst.
MCP auf demselben Port
Derselbe Container liefert MCP Streamable HTTP unter /mcp aus, mit vier Tools: system_one, system_one_batch, list_models und unload_models. Sie laufen durch denselben Entscheidungsdienst wie die REST-Routen, also dieselbe Validierung, derselbe Supervisor, dasselbe Body-Limit und derselbe Bearer-Token. system_one nimmt exakt das model, den state, die questions und das optionale config, die du per POST schicken würdest, und liefert dasselbe strukturierte Ergebnis, damit ein Agent die Wahrscheinlichkeiten lesen kann, bevor er entscheidet, was er als Nächstes tut. system_one_batch ist das MCP-Gegenstück zu POST /v1/systemone/batch: eine Liste unabhängiger Requests, jeder mit eigenem Modell, eigenem State und eigenen Fragen, beantwortet in der Reihenfolge, in der du sie geschickt hast, und jedes Ergebnis nennt das Modell, das tatsächlich geantwortet hat. Requests an dasselbe Modell laufen nacheinander, DECIDEALOT_MAX_BATCH_CONCURRENCY deckelt, wie viele Provider-Aufrufe sich überlappen, und ein einziges fehlgeschlagenes Element lässt den ganzen Batch scheitern, ohne Teilergebnisse. Ein Validierungsfehler kommt als isError: true zurück, mit dem TypeSafe-Body detail im Text, damit der Agent sieht, was er verkackt hat, statt eines nackten “error”.
Kein Tool nimmt eine URL, einen Dateisystempfad, ein Executable, ein Modellverzeichnis oder eine beliebige Runtime-Option. Der einzige Regler, den ein Request bekommt, ist config, und momentan nimmt da nur CLM etwas an: eine validierte temperature. Alle anderen quittieren alles, was da drinsteht, mit einem 422. Der Modell-Router bildet jeden lokalen Alias auf einen festen Loopback-Endpoint ab und jeden Jev-Namen auf die eine feste HTTPS-API von TypeSafe, und ein Aufrufer darf sich nie ein Netzwerkziel aussuchen. Mehr Macht, als einen Namen aus dem Katalog zu wählen, hat ein Agent hier nicht. Die Embeddings-URL von CLM und den TypeSafe-Key legt einmal fest, wer die Kiste deployt, und kein Request kann daran drehen.
Den Container hinter einen Reverse Proxy oder einen Tunnel zu stellen heißt nicht, den DNS-Rebinding-Schutz abzuschalten. Du setzt den exakten öffentlichen Host auf die Allowlist in DECIDEALOT_MCP_ALLOWED_HOSTS und, für Browser-Clients, den exakten Origin in DECIDEALOT_MCP_ALLOWED_ORIGINS. Die Prüfungen laufen in fester Reihenfolge: ein fehlender oder falscher Bearer bekommt zuerst 401, dann bekommt ein unbekannter Host 421, dann ein unbekannter Browser-Origin 403. Keine Wildcard auf einer Kiste, die am Internet hängt. Setz DECIDEALOT_API_KEY auf ein echtes Secret, bevor der Container auch nur in die Nähe einer öffentlichen Adresse kommt.
Der langweilige Scheiß, mit dem du decidealot beruhigt laufen lassen kannst
- Ein Container, der nicht viel darf. Der dokumentierte Aufruf läuft mit read-only Root-Dateisystem,
--cap-drop ALL,no-new-privileges,noexec-tmpfs für/tmpund/var/run, einem pids-Limit, einer Speicherobergrenze und einem Port, der nur auf Loopback veröffentlicht wird. - Deine UID, nicht die des Images. Der Container läuft als der
--user, den du ihm mitgibst, das Modellverzeichnis, das du gerade permkdirangelegt hast, ist also beschreibbar, ganz ohnechown-Tänzchen. Nur wenn du nichts angibst, fällt das Image auf non-root1000:1000zurück. - Ein Exec-Loch für CUDA, und genau eins. Triton kompiliert zur Laufzeit kleine CUDA-Helfer und muss sie von irgendwo laden, also fügt der CUDA-Aufruf ein einzelnes
exec-tmpfs unter/var/cachehinzu. Alles andere bleibt read-only undnoexec. - Bearer-Auth, die kein Timing verrät. Optional, aus, solange
DECIDEALOT_API_KEYnicht gesetzt ist, verglichen mithmac.compare_digestauf kodierten Bytes./healthbleibt offen für deine Liveness-Probe. - Ein Limit für den Body. Standardmäßig 1 MiB über
DECIDEALOT_MAX_REQUEST_BYTES, alles, was größer deklariert ist, kriegt ein413, bevor ein Modell es überhaupt zu sehen bekommt. - Request-IDs, die du greppen kannst. Schick eine UUID oder ULID in
X-Request-Id, und die ID begleitet den Request durch die Logs. Schick Müll oder gar nichts, und decidealot erzeugt selbst eine UUID. So oder so kommt sie in der Response zurück. - Ein Supply-Chain-Gate, für das die Modelle zu jung waren. Die Abhängigkeiten des Gateways stecken hinter einem Alters-Gate per uv-
exclude-newer, und der Modell-Stack installiert sich mit--require-hashesaus einer Datei mit festgenagelten Hashes ins Torchbase-venv. Beide Modellpakete sind jünger, als das Gate erlaubt. Das gepinnte Release von Laya ist nicht mal auf PyPI, also wird es aus dem Commit-Tarball von Upstream installiert, mit dem SHA-256 im Lockfile. Jedes der beiden trägt eine schriftliche, vom Owner abgesegnete Ausnahme. Das Gate hat seinen Job gemacht, und ich habe brav die Einverständniserklärung unterschrieben, wie für den Wandertag. - Tests mit Untergrenze. Branch Coverage mit hartem Minimum von 90%, plus echte HTTP-Läufe gegen die heruntergeladenen Weights für CPU und CUDA.
Zwei Images, jedes auf dem passenden Torchbase-Image. Das CPU-Image ist komprimiert auf Docker Hub etwa 300 MB groß und der richtige Default. Das CUDA-Image hat etwa 5.4 GB, basiert auf CUDA 12.6, gibt es nur für amd64 und braucht das NVIDIA Container Toolkit und --gpus all. Greif nur zum CUDA-Image, wenn Geschwindigkeit und Modellspeicher das GPU-Setup wirklich rechtfertigen.
Anwerfen
model_directory="$HOME/.local/share/decidealot/models"
mkdir --parents "$model_directory"
docker run --detach --name decidealot --init --restart unless-stopped \
--user "$(id -u):$(id -g)" \
--read-only --cap-drop ALL --security-opt no-new-privileges:true \
--pids-limit 512 --memory 8g --cpus 4 \
--tmpfs /tmp:rw,noexec,nosuid,size=128m \
--tmpfs /var/run:rw,noexec,nosuid,size=8m \
--mount type=bind,source="$model_directory",target=/models \
--publish 127.0.0.1:8080:8080 \
psyb0t/decidealot:latest
curl --fail http://127.0.0.1:8080/healthWarte, bis /health grün wird, dann schick den Request von oben hin. Für die GPU kommen --gpus all und das tmpfs unter /var/cache dazu, und du nimmst psyb0t/decidealot:latest-cuda. Das Deployment-Doc im Repo hat das komplette CUDA-Rezept.
Wenn du schon aigate laufen hast, spar dir den ganzen Scheiß. Setz DECIDEALOT=1 für den CPU-Dienst unter /decidealot/ oder DECIDEALOT_CUDA=1 für den GPU-Dienst unter /decidealot-cuda/, jeweils mit MCP darunter, und die Tools landen außerdem im aggregierten /mcp/ von aigate, neben allem anderen, mit dem deine Agenten sowieso schon reden. Die beiden können nebeneinander laufen und sich ein Modellverzeichnis teilen, die 5.3 GB landen also nur einmal auf der Platte. CLM ist dort standardmäßig an: make run-bg fährt auch die eigene llama.cpp-CUDA-Route von aigate hoch, local-llamacpp-cuda-qwen3-8b, die Qwen3-8B-Embeddings mit genau den 4096 Dimensionen liefert, die CLM will, über das interne Netz. Damit verlässt nicht mal der State, den CLM rausschickt, deinen Stack. Diese Route braucht eine NVIDIA-GPU, auch neben dem CPU-Dienst, also setz auf einer Kiste ohne eine DECIDEALOT_CLM_ENABLED=false. Das gehostete Jev taucht nur auf, wenn du DECIDEALOT_TYPESAFE_API_KEY in die .env von aigate schreibst, und dieser State verlässt den Stack sehr wohl. Ein Haken: aigate pinnt decidealot auf v0.6.0, der temperature-Regler für CLM aus v0.7.0 ist dort also noch nicht drin.
aigate passt außerdem auf den Speicher auf, damit du es nicht musst. Bevor irgendein anderes lokales Modell hinter dem LiteLLM von aigate (Ollama, sd.cpp, talkies, vLLM, llama.cpp) auf derselben Hardware läuft, weist der Ressourcenmanager von aigate decidealot an zu entladen. Laya- und Von-Entscheidungen nehmen denselben Hardware-Lock wie alles andere, egal ob sie über REST, über MCP oder in einem Batch reinkommen, und auf CUDA entlädt aigate seinen eigenen Qwen-Encoder, bevor Laya oder Von geladen wird. CLM überlässt den Lock seinem verschachtelten Embeddings-Aufruf. POST /v1/unload/cuda oder /v1/unload/cpu räumt decidealot zusammen mit dem Rest des Stacks ab. Eine Entscheidung, die gerade läuft, antwortet mit 409 und behält ihr Modell bis zum Idle-Timer, also wird keinem Aufrufer etwas unter dem Hintern weggezogen. Das Loch, das bleibt: ein Request, der direkt an /decidealot/ geht, wirft nur diesen Encoder raus, nicht jedes andere Modell, das auf der GPU hockt, also kann dir ein fremdes residentes Modell immer noch den VRAM wegfressen.
In deinen Agenten installieren
Ein Agent, der gleich auf Basis einer Wahrscheinlichkeit handelt, sollte wenigstens wissen, was diese Wahrscheinlichkeit bedeutet. Der Skill bringt dem Agenten bei, wie er den Container deployt, Laya, Von, CLM oder das gehostete Jev wählt, eine typisierte Entscheidung schickt, die Wahrscheinlichkeiten liest, ohne sie als Freifahrtschein zu behandeln, und MCP direkt nutzt. Alles unter .agents/ ist in einem einzigen Marketplace katalogisiert, also sind es zwei Befehle:
claude plugin marketplace add psyb0t/agents
claude plugin install decidealot@psyb0tCodex nutzt denselben Marketplace mit einem anderen Verb, codex plugin add decidealot@psyb0t, weil es kein codex plugin install gibt. OpenClaw bekommt den Skill, dazu eine optionale stdio-Bridge für Clients, die nur mit einem lokalen stdio-MCP-Server reden können, und diese Bridge leitet an den Container weiter, den du schon laufen hast:
openclaw skills install @psyb0t/decidealot
openclaw plugins install clawhub:@psyb0t/decidealotdecidealot entscheidet. Du handelst.
Jev hatte die richtige Idee, nur auf dem falschen Server. decidealot ist dieselbe Idee auf deinem: eine geschlossene Menge an Antworten, eine echte Wahrscheinlichkeit auf jeder, und null Chance, einen Aufsatz zurückzukriegen. decidealot weiß nicht, wo dein Schwellenwert liegen sollte, und tut auch nicht so. Du legst die Grenze danach fest, was dich ein Fehler kostet, und das Modell hat dabei kein Stimmrecht.
Hol es dir auf github.com/psyb0t/decidealot oder zieh psyb0t/decidealot von Docker Hub. Der Code steht unter WTFPL, also mach damit, was du verdammt nochmal willst. Laya, Von, CLM, PyTorch, Transformers und die heruntergeladenen Weights behalten alle ihre eigenen Lizenzen, also lies die, bevor du ein Modell in etwas reinschraubst, das du verkaufst. Und jetzt hör auf, Chatbots nach einem Ja oder Nein zu fragen.