Docs KI schreibt Bots

LLM als Strategie-Coach

Überlass dem LLM, worauf gespart wird, wohin die Arbeiter gehen und ob in dieser Minute angegriffen oder abgewartet wird – die Regelschicht führt nur aus und legt bei Bedarf ein Veto ein. Das Referenz-Brain arbeitet bereits so; diese Seite erklärt das Muster und die Fallstricke.

Ab einer gewissen Größe merkst du, dass die Wirtschaftsregeln deines Bots Schicht um Schicht aufeinandergestapelt sind: eine Regel für die Zahl der Holzfäller, eine für 5 Arbeiter pro Mine, eine, die das Holzfällen halbiert, wenn zu viel Holz da ist, eine, die mehr Arbeiter aufs Gold schickt, wenn Gold knapp und Holz reichlich ist … Jede Regel ist für sich richtig, zusammen erzeugen sie aber Situationen wie „an der Mine fehlen Arbeiter, während alle Bauern Holz hacken“ – Situationen, für die keine einzelne Regel zuständig ist.

Urteile wie „das Gesamtbild betrachten und Prioritäten setzen“ lassen sich ohnehin schlecht als if / else schreiben – genau darin sind LLMs aber gut. Das Referenz-Brain (brains/xwar3/strategy/brain/coach.py) nutzt die folgende Schichtung.

Schichten

LLM (Berater)           Alle 20 Spielsekunden, asynchron, blockiert nie einen Tick
  Eingabe: ein einseitiger Snapshot der Partie (Ressourcen, Nahrung, Verteilung der Bauern, Minen, Einheitentypen, Tech, Helden, Feindlage, jüngste Ereignisse)
  Ausgabe: striktes JSON – Diagnose in einem Satz + Arbeiterverteilung + was zuerst kommt + Haltung für diese Minute + was zu vermeiden ist
        │
        ▼  Whitelist + Min/Max-Clamping + Veto
Regelschicht (Bot, jeder Tick)  Übersetzt die Empfehlungen in „Biases“ auf vorhandene Fähigkeiten: Arbeiterverteilung, Bau- / Trainingspriorität, Angriffshaltung
        │
        ▼
Ausführungsschicht (SDK / Reflexschicht)  Befehle erteilen, Quittungen lesen, Mikro

Ausgabevertrag

Lass das Modell nur JSON mit festen Feldern ausgeben – nichts hinzufügen, nichts weglassen:

{
  "diagnosis": "Ein Satz: das größte Problem der Partie, das sich in den Eingabedaten belegen lassen muss",
  "workers":   { "gold": 10, "lumber": 6 },
  "priority":  ["hpea", "hhou", "hbar"],
  "posture":   "creep",
  "avoid":     ["Bei Holzmangel nicht zuerst Iron Plating erforschen"]
}
FeldSo nutzt es die RegelschichtClamping im Referenz-Brain
workersZielzahl der Arbeiter auf Gold und auf HolzGold 2 ~ 25, Holz 1 ~ 20; die Summe darf die Gesamtzahl der Bauern nicht überschreiten
priorityPrioritätsreihenfolge für Training / Bau / ForschungHöchstens 4; nur Vier-Zeichen-Codes, die in der Tabelle „erlaubte Codes“ vorkommen
postureHaltung für diese MinuteMuss eines von attack defend creep expand recover hold sein
avoidWas in dieser Minute nicht getan werden sollHöchstens 2 Einträge
diagnosisNur für Logs und die Anzeige in der Konsole—

Schreib pro Volk einen eigenen Prompt, der nur die volksspezifischen Abwägungen enthält (gemeinsames Bauen und Miliz bei den Menschen, Burrows bei den Orcs, Haunted Gold Mine bei den Untoten, Entangled Gold Mine bei den Nachtelfen …). Allgemeine Regeln gehören in einen gemeinsamen Teil – nicht viermal kopieren.

Vier harte Regeln

Für jede dieser vier Regeln hat das Referenz-Brain in der Praxis Lehrgeld bezahlt:

  1. Der Berater erteilt nie direkt Einheitenbefehle. Er sieht nicht, was im 150-ms-Takt passiert, und er halluziniert. Er ändert nur Ziele und Prioritäten; wer wohin geht und wen angreift, entscheiden weiterhin Regelschicht und Reflexschicht – die Befehlsgewalt kann nur einen Besitzer haben.
  2. Asynchron. Ein Beratungsaufruf dauert etwa 1 Sekunde und läuft in einem Hintergrund-Thread; das neueste Ergebnis gilt, und er blockiert nie einen Tick. Läuft das Modell nicht, gibt es einen Timeout oder antwortet es Unsinn, tu so, als gäbe es diese Schicht nicht – das Verhalten fällt auf reine Regeln zurück. Zu alte Empfehlungen (älter als 3 Intervalle) werden ebenfalls nicht verwendet.
  3. Whitelist + Clamping. Jedes Feld muss sich auf eine vorhandene Fähigkeit abbilden lassen, Zahlenwerte werden auf einen sinnvollen Bereich begrenzt. Unbekanntes wird gezählt und dann verworfen, nicht stillschweigend ignoriert.
  4. Alles zählen. Wie oft gefragt, wie oft erfolgreich, wie viele Timeouts, wie oft geclampt, wie oft jedes Feld übernommen wurde – veröffentliche das alles zusammen mit der letzten Eingabe an das Modell. Sonst ist „bringt diese Schicht überhaupt etwas?“ eine Frage, die niemand beantworten kann.

Was sicher degradiert, degradiert am leichtesten unbemerkt

Der Berater ist so gebaut, dass „Fehler = als gäbe es diese Schicht nicht“ gilt. Läuft der Modelldienst nicht, verhält sich der Bot also exakt wie mit reinen Regeln, und von außen merkt man nichts. Beim Referenz-Brain konnte der Berater einmal einen ganzen Tag lang auf allen 6 Instanzen keine Verbindung herstellen, ohne dass es jemand bemerkte. Veröffentliche unbedingt „Zeitpunkt des letzten Erfolgs“ und „Grund des letzten Fehlers“ – genau dafür gibt es die Seite „Strategie-Coach“ in der Farsight-Konsole.

In deinem eigenen Bot umsetzen

Unten findest du ein minimales Gerüst, das mit jeder OpenAI-kompatiblen API funktioniert (LM Studio, Ollama oder eine Cloud-API) und nur die Standardbibliothek nutzt:

import collections, json, threading, urllib.request
from openwar3 import Bot

BASE = "http://127.0.0.1:1234/v1"            # LM Studio / Ollama / jeder OpenAI-kompatible Dienst
MODEL = "your-model"
POSTURES = {"attack", "defend", "creep", "expand", "recover", "hold"}
SYSTEM = """Du bist ein Makro-Coach für Warcraft III. Du kümmerst dich nur um Wirtschaft und Strategie, nicht um Mikro.
Gib nur JSON aus, mit festen Feldern: {"diagnosis": ein Satz, "workers": {"gold": Ganzzahl, "lumber": Ganzzahl},
"priority": [Vier-Zeichen-Codes, höchstens 4, nur aus allowed], "posture": eine von sechs, "avoid": [höchstens 2]}
Stütze dich nur auf die Spieldaten, die du bekommst; erfinde nichts, was nicht in den Daten steht."""


def ask(state: dict) -> dict:
    body = {"model": MODEL, "temperature": 0.3, "max_tokens": 260,
            "messages": [{"role": "system", "content": SYSTEM},
                         {"role": "user", "content": json.dumps(state, ensure_ascii=False)}]}
    req = urllib.request.Request(f"{BASE}/chat/completions", json.dumps(body).encode(),
                                 {"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=8) as r:
        text = json.load(r)["choices"][0]["message"]["content"]
    return json.loads(text[text.index("{"): text.rindex("}") + 1])


class CoachedBot(Bot):
    EVERY = 20.0                              # Spielsekunden: Makro-Entscheidungen laufen im Minutentakt, nicht jeden Tick fragen
    allowed = {"hpea", "hfoo", "hrif", "hkni", "hhou", "hbar", "hbla", "Rhme", "Rhar"}

    def on_start(self, g):
        self.plan, self.plan_at, self.asked_at, self.busy = {}, -1e9, -1e9, False
        self.stats = collections.Counter()

    def summary(self, g) -> dict:             # Snapshot im Haupt-Thread lesen; der Hintergrund-Thread fasst g nie an
        res = g.resources() or {}
        return {"clock": round(g.clock() or 0), "gold": res.get("gold"), "lumber": res.get("lumber"),
                "food": [res.get("food_used"), res.get("food_cap")],
                "workers": len(g.my_workers()), "idle_workers": len(g.idle_workers()),
                "army": collections.Counter(u.type for u in g.my_army()),
                "enemy_seen": collections.Counter(u.type for u, _t, _age in g.last_seen(max_age=90)),
                "night": g.is_night(), "allowed": sorted(self.allowed)}

    def consult(self, state, now):
        try:
            p = ask(state)
            self.stats["ok"] += 1
            posture = p.get("posture")
            if posture not in POSTURES:
                self.stats["bad_posture"] += 1                       # zählen, dann verwerfen – nie stillschweigend
                posture = "hold"
            self.plan = {"gold": min(25, max(2, int(p["workers"]["gold"]))),      # clampen
                         "lumber": min(20, max(1, int(p["workers"]["lumber"]))),
                         "priority": [c for c in p.get("priority", []) if c in self.allowed][:4],
                         "posture": posture}
            self.plan_at = now
        except Exception as e:                                       # Timeout / Unsinn: als gäbe es diese Schicht nicht
            self.stats[f"error:{type(e).__name__}"] += 1
        finally:
            self.busy = False

    def on_tick(self, g):
        now = g.clock() or 0.0
        if not self.busy and now - self.asked_at >= self.EVERY:
            self.busy, self.asked_at = True, now
            threading.Thread(target=self.consult, args=(self.summary(g), now), daemon=True).start()
        plan = self.plan if now - self.plan_at <= 3 * self.EVERY else {}   # zu alte Empfehlungen nicht verwenden
        # ↓ Regelschicht: ohne plan gelten die Standardregeln; mit plan nur Verteilung, Prioritäten und Haltung anpassen – die konkreten Befehle entscheiden weiterhin die Regeln
        ...

Welches Modell?

SituationEmpfehlung
Lokal, muss schnell seinMoE-Modelle (die pro Aufruf nur einen kleinen Teil der Parameter aktivieren) sind deutlich schneller als dichte Modelle gleicher Größe. Das Referenz-Brain nutzt Qwen3.6-35B-A3B (LM Studio, Q4): Median 1.09 s, langsamster Aufruf 1.45 s, 5/5 Ausgaben lassen sich direkt mit json.loads parsen
Lokal, „denkendes“ ModellDen Thinking-Abschnitt unbedingt abschalten, sonst gehen alle Tokens fürs Denken drauf und es kommt nie ein JSON heraus. LM Studio ignoriert /no_think; das Referenz-Brain nutzt stattdessen /v1/completions, baut das ChatML selbst zusammen und füllt ein leeres <think></think> plus ein { vor
Cloud-ModelleDie Latenz ist meist höher, aber diese Schichtung ist ohnehin asynchron; Makro-Entscheidungen bemessen sich in Minuten, ein paar Sekunden Latenz sind in Ordnung

Dasselbe Modell kann deinen Einheiten auch eine Stimme geben: siehe Sprechblasen und lokale Modelle. Wenn das Modell direkt jeden Tick Befehle erteilen soll (statt als Berater zu arbeiten), warte auf das JSON-Gateway der Arena.