# LLM 當參謀

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

來源: https://war3ai.com/zh-tw/docs/llm-coach/

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

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

## 分層

```text
LLM（顧問）             每 20 遊戲秒一次，非同步，不阻塞任何一拍
  輸入：一頁局面快照（資源、人口、農民分布、礦、兵種、科技、英雄、敵情、最近發生的事）
  輸出：嚴格 JSON —— 一句診斷 + 工人配比 + 優先生產什麼 + 這一分鐘的姿態 + 不要做的事
        │
        ▼  白名單 + 上下限鉗制 + 否決
規則層（Bot 每拍）      把建議轉成既有能力的「偏置」：工人配比、建造 / 訓練優先順序、進攻姿態
        │
        ▼
執行層（SDK / 毫秒層）  下令、讀回執、微操
```

## 輸出契約

讓模型只輸出固定欄位的 JSON，不准增刪：

```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 個實例的顧問全部連不上、卻沒人發現的事。一定要把「最近一次成功時間」和「最近一次失敗原因」發布出來 —— [遠見指揮台](https://war3ai.com/zh-tw/docs/console/) 的「運營顧問」頁就是做這件事的。

## 在你自己的 Bot 裡實作

下面是一個最小的骨架，走任何 OpenAI 相容的 API（LM Studio、Ollama、雲端 API 都可以），只用標準函式庫：

```python title="coached_bot.py"
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>` 和一個 `{` |
| 雲端模型 | 延遲通常更高，但這套分層本來就是非同步的；運營決策以分鐘計，幾秒的延遲可以接受 |

> **說明**
>
> 同一個模型還能幫單位配音：見 [頭頂氣泡與本機模型](https://war3ai.com/zh-tw/docs/speech/)。想讓模型直接逐拍下令（而不是當參謀），請等 [對戰平台](https://war3ai.com/zh-tw/arena/) 的 JSON 閘道。
