Документация ИИ пишет бота

LLM как стратегический советник

Отдайте LLM вопросы «на что копить, куда ставить рабочих, атаковать или выжидать в эту минуту», а слою правил оставьте только исполнение и право вето. Эталонный мозг уже работает так; на этой странице — сам паттерн и подводные камни.

Когда бот дорастает до определённого размера, вы замечаете, что правила экономики наслаиваются одно на другое: правило про число лесорубов, правило «5 рабочих на рудник», правило «урезать добычу древесины вдвое, если её слишком много», правило «отправить больше людей на золото, если золота мало, а древесины много»… Каждое правило по отдельности верно, но вместе они порождают ситуации вроде «на руднике не хватает рабочих, а все крестьяне рубят лес» — ситуации, за которые не отвечает ни одно правило.

Решения вида «оценить всю картину и расставить приоритеты» изначально плохо ложатся на if / else, зато это ровно то, в чём сильны LLM. Эталонный мозг (brains/xwar3/strategy/brain/coach.py) использует следующее разделение на слои.

Слои

LLM (советник)          Раз в 20 игровых секунд, асинхронно, никогда не блокирует тик
  Вход: снимок партии на одну страницу (ресурсы, пища, распределение рабочих, рудники, войска, технологии, герои, разведданные о противнике, недавние события)
  Выход: строгий JSON — диагноз в одну фразу + распределение рабочих + что строить первым + позиция на эту минуту + чего не делать
        │
        ▼  белый список + ограничение min/max + вето
Слой правил (Bot, каждый тик)   Переводит советы в «смещения» уже имеющихся возможностей: распределение рабочих, приоритет постройки / найма, атакующая позиция
        │
        ▼
Слой исполнения (SDK / рефлекторный слой)   Отдаёт приказы, читает квитанции, микроконтроль

Контракт вывода

Заставьте модель выдавать JSON с фиксированным набором полей — ничего не добавлять и не опускать:

{
  "diagnosis": "Одна фраза: главная проблема в партии; должна подтверждаться входными данными",
  "workers":   { "gold": 10, "lumber": 6 },
  "priority":  ["hpea", "hhou", "hbar"],
  "posture":   "creep",
  "avoid":     ["Не исследовать улучшение брони первым, когда не хватает древесины"]
}
ПолеКак его использует слой правилОграничение в эталонном мозге
workersЦелевое число рабочих на золоте и на древесинеЗолото 2 ~ 25, древесина 1 ~ 20; сумма не больше общего числа крестьян
priorityПорядок приоритетов для найма / постройки / исследованийНе больше 4; принимаются только четырёхсимвольные коды из таблицы «допустимых кодов»
postureПозиция на эту минутуТолько одно из attack defend creep expand recover hold
avoidЧего не делать в эту минутуНе больше 2 пунктов
diagnosisТолько для логов и отображения в консоли—

Для каждой расы — свой промпт, и в нём только компромиссы, характерные для этой расы (совместное строительство и Ополчение у Людей, Норы у Орды, Haunted Gold Mine у Нежити, Entangled Gold Mine у Ночных эльфов…). Общие правила вынесите в общую часть — не копируйте их четыре раза.

Четыре жёстких ограничения

Каждое из них эталонный мозг усвоил на собственных ошибках:

  1. Советник никогда не отдаёт приказы юнитам напрямую. Он не видит происходящего в масштабе 150 ms и к тому же галлюцинирует. Он меняет только цели и приоритеты; кто куда идёт и кого атакует, по-прежнему решают слой правил и рефлекторный слой — у командования может быть только один хозяин.
  2. Асинхронность. Один вызов советника занимает около 1 секунды и выполняется в фоновом потоке; действует последний результат, и он никогда не блокирует тик. Если модель не запущена, не уложилась в тайм-аут или ответила чепухой, ведите себя так, будто этого слоя нет, и возвращайтесь к чистым правилам. Слишком старый совет (старше 3 интервалов) тоже не используется.
  3. Белый список + ограничение значений. Каждое поле должно отображаться на уже существующую возможность, а числа ограничиваются разумным диапазоном. Всё нераспознанное подсчитывается и отбрасывается, а не игнорируется молча.
  4. Считайте всё. Сколько раз спросили, сколько успешных ответов, сколько тайм-аутов, сколько раз сработало ограничение, сколько раз было принято каждое поле — публикуйте всё это вместе с последним входом, отправленным модели. Иначе на вопрос «а этот слой вообще что-то даёт?» ответить невозможно.

Что умеет безопасно деградировать, легче всего деградирует незаметно

Советник устроен так, что «сбой = этого слоя нет», поэтому, когда сервис модели не запущен, бот ведёт себя в точности как на чистых правилах, и снаружи ничего не заметно. Однажды у эталонного мозга советник целый день не мог подключиться ни на одном из 6 экземпляров — и никто этого не заметил. Обязательно публикуйте «время последнего успешного вызова» и «причину последнего сбоя» — именно для этого в консоли Farsight есть страница «Стратегический советник».

Реализация в вашем боте

Ниже — минимальный каркас, который работает с любым OpenAI-совместимым API (LM Studio, Ollama или облачный API) и использует только стандартную библиотеку:

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

BASE = "http://127.0.0.1:1234/v1"            # LM Studio / Ollama / любой OpenAI-совместимый сервис
MODEL = "your-model"
POSTURES = {"attack", "defend", "creep", "expand", "recover", "hold"}
SYSTEM = """Ты тренер по экономике в Warcraft III. Ты отвечаешь только за экономику и стратегию, не за микроконтроль.
Выводи только JSON с фиксированными полями: {"diagnosis": одна фраза, "workers": {"gold": целое, "lumber": целое},
"priority": [четырёхсимвольные коды, не больше 4, только из allowed], "posture": одно из шести, "avoid": [не больше 2]}
Опирайся только на переданные данные о партии; не выдумывай того, чего в данных нет."""


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                              # игровые секунды: экономические решения живут в масштабе минут, спрашивать каждый тик не нужно
    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:             # снимок читаем в основном потоке; фоновый поток g не трогает
        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                       # посчитать и отбросить, не молча
                posture = "hold"
            self.plan = {"gold": min(25, max(2, int(p["workers"]["gold"]))),      # ограничение
                         "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:                                       # тайм-аут / чепуха в ответе: этого слоя нет
            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 {}   # слишком старый совет не используем
        # ↓ слой правил: при пустом plan — правила по умолчанию; с plan меняем только распределение, приоритеты и позицию, конкретные приказы по-прежнему решают правила
        ...

Как выбрать модель

СитуацияРекомендация
Локально, нужна скоростьMoE-модели (за один вызов активируется лишь малая часть параметров) намного быстрее плотных моделей того же размера. Эталонный мозг использует Qwen3.6-35B-A3B (LM Studio, Q4): медиана 1.09 s, самый медленный ответ 1.45 s, 5/5 ответов напрямую разбираются через json.loads
Локальные «думающие» моделиОбязательно отключите блок размышлений, иначе все токены уйдут на размышления и ни одного JSON вы не получите. LM Studio игнорирует /no_think; эталонный мозг перешёл на /v1/completions, сам собирает ChatML и заранее подставляет пустой <think></think> и {
Облачные моделиЗадержка обычно выше, но эта схема слоёв асинхронна по своей природе; экономические решения измеряются минутами, так что несколько секунд задержки допустимы

Та же модель может озвучивать ваших юнитов: см. Реплики и локальные модели. Если хотите, чтобы модель сама отдавала приказы каждый тик (а не работала советником), дождитесь JSON-шлюза Арены.