文件 讓 AI 寫 Bot

LLM 當參謀

把「該存什麼、該往哪裡派人、這一分鐘該打還是該緩」交給 LLM,規則層只負責執行和否決。參考大腦已經這樣做了,這一頁把模式和陷阱講清楚。

Bot 寫到一定程度,你會發現運營層的規則是一條一條疊上去的:伐木人數一條、每座礦 5 人一條、木材多了減半一條、金多木少就多派人一條……每條單獨看都對,合起來卻會出現「礦上缺人,而所有農民都在砍樹」這種沒有任何一條規則負責的局面。

這類「看全局、排優先順序」的判斷本來就不適合寫成 if / else,卻正好是 LLM 擅長的。參考大腦(brains/xwar3/strategy/brain/coach.py)用的就是下面這套分層。

分層

LLM(顧問)             每 20 遊戲秒一次,非同步,不阻塞任何一拍
  輸入:一頁局面快照(資源、人口、農民分布、礦、兵種、科技、英雄、敵情、最近發生的事)
  輸出:嚴格 JSON —— 一句診斷 + 工人配比 + 優先生產什麼 + 這一分鐘的姿態 + 不要做的事
        │
        ▼  白名單 + 上下限鉗制 + 否決
規則層(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只用於日誌和指揮台顯示—

每個種族各一份提示詞,只寫這一族特有的取捨(人類的協力建造和民兵、獸人的地洞、不死族的鬧鬼金礦、夜精靈的纏繞金礦……),通用規則放在共用部分,不要抄四遍。

四條硬性約束

這四條都是參考大腦實際繳過學費的:

  1. 顧問永遠不直接對單位下令。 它看不到 150 ms 的現場,也會產生幻覺。它只改目標和優先順序,具體誰去哪、打誰,仍由規則層和毫秒層決定 —— 指揮權只能有一個主人。
  2. 非同步。 顧問一次大約 1 秒,在背景執行緒跑,以最新結果為準,永遠不阻塞一拍。模型沒啟動、逾時、亂答,就當沒有這一層,行為回到純規則。建議太舊(超過 3 個間隔)也不用。
  3. 白名單 + 鉗制。 每個欄位都要能對應到既有能力,數值鉗制在合理區間。不認得的內容計數後丟棄,而不是默默忽略。
  4. 全程計數。 問過幾次、成功幾次、逾時幾次、被鉗制幾次、各欄位被採納幾次,連同最後一次送給模型的輸入一起發布出來。否則「這一層到底有沒有用」是個無法回答的問題。

能安全降級的東西,最容易悄悄降級

顧問設計成「失敗 = 當沒有這一層」,所以模型服務沒啟動時,Bot 的行為和純規則一模一樣,從外面完全看不出來。參考大腦就發生過整整一天 6 個實例的顧問全部連不上、卻沒人發現的事。一定要把「最近一次成功時間」和「最近一次失敗原因」發布出來 —— 遠見指揮台 的「運營顧問」頁就是做這件事的。

在你自己的 Bot 裡實作

下面是一個最小的骨架,走任何 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 = """你是《魔獸爭霸 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 秒,最慢 1.45 秒,5/5 的輸出能直接 json.loads
本機、會「思考」的模型必須關掉思考段落,否則 token 全花在思考上、一個 JSON 都產生不出來。LM Studio 不理會 /no_think;參考大腦改走 /v1/completions,自己組 ChatML、預先填入空的 <think></think> 和一個 {
雲端模型延遲通常更高,但這套分層本來就是非同步的;運營決策以分鐘計,幾秒的延遲可以接受

同一個模型還能幫單位配音:見 頭頂氣泡與本機模型。想讓模型直接逐拍下令(而不是當參謀),請等 對戰平台 的 JSON 閘道。