文件 讓 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 | 只用於日誌和指揮台顯示 | — |
每個種族各一份提示詞,只寫這一族特有的取捨(人類的協力建造和民兵、獸人的地洞、不死族的鬧鬼金礦、夜精靈的纏繞金礦……),通用規則放在共用部分,不要抄四遍。
四條硬性約束
這四條都是參考大腦實際繳過學費的:
- 顧問永遠不直接對單位下令。 它看不到 150 ms 的現場,也會產生幻覺。它只改目標和優先順序,具體誰去哪、打誰,仍由規則層和毫秒層決定 —— 指揮權只能有一個主人。
- 非同步。 顧問一次大約 1 秒,在背景執行緒跑,以最新結果為準,永遠不阻塞一拍。模型沒啟動、逾時、亂答,就當沒有這一層,行為回到純規則。建議太舊(超過 3 個間隔)也不用。
- 白名單 + 鉗制。 每個欄位都要能對應到既有能力,數值鉗制在合理區間。不認得的內容計數後丟棄,而不是默默忽略。
- 全程計數。 問過幾次、成功幾次、逾時幾次、被鉗制幾次、各欄位被採納幾次,連同最後一次送給模型的輸入一起發布出來。否則「這一層到底有沒有用」是個無法回答的問題。
能安全降級的東西,最容易悄悄降級
顧問設計成「失敗 = 當沒有這一層」,所以模型服務沒啟動時,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> 和一個 { |
| 雲端模型 | 延遲通常更高,但這套分層本來就是非同步的;運營決策以分鐘計,幾秒的延遲可以接受 |