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 у Ночных эльфов…). Общие правила вынесите в общую часть — не копируйте их четыре раза.
Четыре жёстких ограничения
Каждое из них эталонный мозг усвоил на собственных ошибках:
- Советник никогда не отдаёт приказы юнитам напрямую. Он не видит происходящего в масштабе 150 ms и к тому же галлюцинирует. Он меняет только цели и приоритеты; кто куда идёт и кого атакует, по-прежнему решают слой правил и рефлекторный слой — у командования может быть только один хозяин.
- Асинхронность. Один вызов советника занимает около 1 секунды и выполняется в фоновом потоке; действует последний результат, и он никогда не блокирует тик. Если модель не запущена, не уложилась в тайм-аут или ответила чепухой, ведите себя так, будто этого слоя нет, и возвращайтесь к чистым правилам. Слишком старый совет (старше 3 интервалов) тоже не используется.
- Белый список + ограничение значений. Каждое поле должно отображаться на уже существующую возможность, а числа ограничиваются разумным диапазоном. Всё нераспознанное подсчитывается и отбрасывается, а не игнорируется молча.
- Считайте всё. Сколько раз спросили, сколько успешных ответов, сколько тайм-аутов, сколько раз сработало ограничение, сколько раз было принято каждое поле — публикуйте всё это вместе с последним входом, отправленным модели. Иначе на вопрос «а этот слой вообще что-то даёт?» ответить невозможно.
Что умеет безопасно деградировать, легче всего деградирует незаметно
Советник устроен так, что «сбой = этого слоя нет», поэтому, когда сервис модели не запущен, бот ведёт себя в точности как на чистых правилах, и снаружи ничего не заметно. Однажды у эталонного мозга советник целый день не мог подключиться ни на одном из 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-шлюза Арены.