文档 让 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 | 只用于日志和指挥台展示 | — |
不同种族各一份提示词,只写这一族特有的取舍(人族的协建和民兵、兽族的地洞、亡灵的闹鬼金矿、暗夜的缠绕金矿……),通用规则放在公共部分,不要抄四遍。
四条硬约束
这四条都是参考大脑实际付过学费的:
- 顾问永远不直接下单位令。 它看不到 150 ms 的现场,也会幻觉。它只改目标和优先级,具体谁去哪、打谁,仍由规则层和毫秒层决定 —— 指挥权只能有一个主人。
- 异步。 顾问一次大约 1 秒,后台线程跑,最新结果生效,永远不阻塞一拍。模型没起来、超时、乱答,就当没有这一层,行为回到纯规则。建议太旧(超过 3 个间隔)也不用。
- 白名单 + 钳位。 每个字段都要能映射到已有能力,数值钳在合理区间。不认识的内容记数后丢弃,而不是静默忽略。
- 全程计数。 问过几次、成功几次、超时几次、被钳位几次、各字段被采纳几次,连同最后一次发给模型的输入一起发布出来。否则「这一层到底有没有用」是个无法回答的问题。
能安全降级的东西,最容易悄悄降级
顾问设计成「失败 = 当没有这一层」,所以模型服务没起来时,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> 和一个 { |
| 云端模型 | 延迟通常更高,但这套分层本来就是异步的;运营决定以分钟计,几秒的延迟可以接受 |