文档 让 AI 写 Bot

大模型当参谋

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

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

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

分层

大模型(顾问)          每 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 兼容的接口(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 = """你是《魔兽争霸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> 和一个 {
云端模型延迟通常更高,但这套分层本来就是异步的;运营决定以分钟计,几秒的延迟可以接受

同一个模型还能给单位配音:见 头顶气泡与本地模型。想让模型直接逐拍下令(而不是当参谋),等 对战平台 的 JSON 网关。