# 大模型当参谋

> 把「该攒什么、该往哪投人、这一分钟该打还是该缓」交给大模型，规则层只负责执行和否决。参考大脑已经这样做了，这一页讲清楚模式和坑。

来源: https://war3ai.com/docs/llm-coach/

写 Bot 写到一定程度，你会发现运营层的规则是一层一层贴上去的：伐木人数一条、每矿 5 人一条、木头多了减半一条、金多木少多派一条……每条单独看都对，合起来却会出现「矿上缺人，而所有农民都在砍树」这种**没有任何一条规则负责**的局面。

这类「看全局、排优先级」的判断本来就不适合写成 `if / else`，却正好是大模型擅长的。参考大脑（`brains/xwar3/strategy/brain/coach.py`）用的就是下面这套分层。

## 分层

```text
大模型（顾问）          每 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/docs/console/) 的「运营顾问」页就是干这个的。

## 在你自己的 Bot 里实现

下面是一个最小的骨架，走任何 OpenAI 兼容的接口（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 = """你是《魔兽争霸3》的运营教练，只管运营和战略，不管微操。
只输出 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/docs/speech/)。想让模型直接逐拍下令（而不是当参谋），等 [对战平台](https://war3ai.com/arena/) 的 JSON 网关。
