decidealot: Hör auf, einen Chatbot einen Aufsatz schreiben zu lassen, wenn du ein verdammtes Ja oder Nein brauchst

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 zwei besten behalten, Laya und Von, und sie 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 das, wonach du fragst, verlässt nie die Kiste. 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. Beide lokalen Modelle dahinter. 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:

  • choice wählt ein Label aus den Keys von criteria. Diese Keys sind die einzigen möglichen Antworten. Eine vierte Schublade erfinden geht nicht, weil hier niemand einen gottverdammten Satz schreibt. Du bekommst die choice, eine confidence und eine Wahrscheinlichkeit für jedes Label.
  • score nimmt 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 eine legend, die die Positionen wieder auf deinen eigenen Wortlaut abbildet.
  • noul ist 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.

Zwei Modelle, zehn Selektoren, eins im Speicher

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, und das sind diese zehn:

  • 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.

Laya ist eine Modellfamilie mit drei Checkpoints. Von ist ein anderes Modell von anderen Leuten. Beide nehmen denselben Request und liefern dieselben Antworttypen, und genau darum stecken sie hinter einem gemeinsamen Vertrag: den model-String tauschen und vergleichen.

Entladen heißt, der Prozess stirbt

Das ist der Teil, der mir wirklich wichtig ist. Im Image stecken drei Python-Virtualenvs. /opt/app-venv ist das Gateway: FastAPI, httpx, das MCP SDK, pydantic, uvicorn. Kein Torch, nirgendwo im Quellcode des Gateways auch nur ein einziger Import davon. /opt/laya-venv und /opt/von-venv enthalten jeweils den Stack eines Modells. Gerade pinnen beide zufällig dieselben Versionen von torch und transformers, das ist also kein Workaround für einen Streit, der schon läuft. Es heißt, dass ein Laya-Upgrade nie in die Umgebung von Von hineinpfuschen kann, und dass in dem Prozess, der deine HTTP-Requests beantwortet, nie ein Modell steckt.

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. Resident ist immer nur eins. 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. 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 self._has_active_other_provider(provider_name):
        await self._provider_switch_condition.wait()
    await self._unload_other_idle_providers_locked(provider_name)
    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 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 und /models/von. Beim ersten Start fährt der Supervisor für jedes Modell einen prepare-Schritt in der eigenen venv dieses Modells, der den Snapshot von Hugging Face zieht, convaiinnovations/laya und wfzyx/von, jeweils auf eine exakte Commit-Revision gepinnt. Danach prüft er, 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. Nichts davon importiert Torch. /health antwortet mit 503, bis beide Bundles bereit sind, was beim ersten Mal etwa 5.3 GB und ein paar Minuten bedeutet. Danach liegen die Dateien schon da, und es wird nichts mehr heruntergeladen.

Bevor ein Modell lädt, setzt der Supervisor 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 den nativen Body für jedes Modell: 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 = engine

Ja, 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 drei Tools: system_one, 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 und die questions, 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. 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 Runtime-Option. Der Modell-Router bildet jeden Alias auf einen festen Loopback-Endpoint ab, 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.

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 /tmp und /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 per mkdir angelegt hast, ist also beschreibbar, ganz ohne chown-Tänzchen. Nur wenn du nichts angibst, fällt das Image auf non-root 1000:1000 zurü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/cache hinzu. Alles andere bleibt read-only und noexec.
  • Bearer-Auth, die kein Timing verrät. Optional, aus, solange DECIDEALOT_API_KEY nicht gesetzt ist, verglichen mit hmac.compare_digest auf kodierten Bytes. /health bleibt 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 ein 413, 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 die Modell-Stacks installieren mit --require-hashes aus Dateien mit festgenagelten Hashes. 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. Das CPU-Image ist komprimiert auf Docker Hub etwa ein halbes Gig groß und der richtige Default. Das CUDA-Image hat etwa 9 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/health

Warte, 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.

aigate passt außerdem auf den Speicher auf, damit du es nicht musst. Bevor irgendein anderes lokales Modell (Ollama, sd.cpp, talkies, vLLM, llama.cpp) auf derselben Hardware läuft, weist der Ressourcenmanager von aigate decidealot an zu entladen. Entscheidungen, die über das MCP von aigate reinkommen, nehmen denselben Hardware-Lock wie alles andere, und 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 eine Loch: ein Request, der direkt an /decidealot/ geht, verdrängt niemanden sonst, auf diesem Pfad jonglierst du die GPU also weiterhin selbst.

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 oder Von 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@psyb0t

Codex 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/decidealot

decidealot 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, 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.