简介:围绕赛尔号游戏自动化与Python实践整理的配套资料包,面向希望了解登录器开发、网络请求处理及强化学习入门应用的编程学习者和游戏爱好者。内容从身份验证、登录请求与服务器数据交互切入,延伸到Q-learning、DQN等算法在战斗决策中的模拟思路,并涉及状态空间、动作空间、奖励函数与经验回放等关键概念。压缩包共2000个文件,约42.75MB,以txt文本、bmp图像、py脚本、exe程序、dll组件和dat数据为主,另有少量配置、界面与音频文件,便于对照代码、素材和运行组件理解项目结构。已有167人学习下载,可作为登录器实现、脚本调试和强化学习实验的参考样本,帮助读者梳理从环境交互到策略训练的完整链路。若能结合requests、socket通信及相关库对照阅读,还可进一步积累接口调试、模块拆分与训练排错的实践经验。
1. 一款老页游 + Python:登录器与强化学习打包在一个 zip 里意味着什么
先说清楚这份资源长什么样:它把「赛尔号」这类经典宠物对战页游的两块东西塞进了同一个压缩包——一块是 Python 写的登陆器,负责把「打开网页、输账号、选服、进游戏」这套人工流程变成脚本;另一块是强化学习,把回合制对战包装成可训练的环境,让智能体自己去学「什么时候换精灵、放哪个技能」。前者解决的是「省手」,后者解决的是「省脑」,两者共用同一套协议层,所以才会被打包在一起。
适合谁?手里有老账号、想批量做日常的;想把页游对战当 RL 练手项目、又嫌自己搭环境太麻烦的;以及想研究「网页游戏协议怎么被还原成 API」的。不适合谁?指望开箱即用、不打算读代码的人——这份东西的配置项不少,协议层还得自己对一遍。下面按「协议怎么拆 → 客户端怎么包 → 环境怎么建 → 坑在哪」四段推。
2. 登陆器的协议层:从抓包到会话复用的五个关键字段
登陆器不是「模拟点击」,那是按键精灵的思路,慢且脆。真正能干活的登陆器走的是 HTTP 层:把浏览器发出的每一个请求原样复刻。这一步做扎实了,后面强化学习才有稳定的数据源——因为 RL 环境每一步决策,都要靠这层协议把「当前局面」拉回来。
2.1 先把一次登录拆成请求链,而不是一个请求
新手最容易犯的错,是把「登录」当成一个 POST。实际链路通常是四到五跳:拉登录页(拿初始 cookie)→ 提交账号密码(拿票据)→ 用票据换服务器列表 → 选定服务器(拿游戏内 token)→ 建立游戏会话。少任何一跳,后面都会以「登录成功但进不去」的形式报错,而且报错信息往往只有一句「系统繁忙」。
我的做法是先开浏览器开发者工具,把 Network 面板清空,完整走一遍登录,然后把所有请求按时间顺序导出成 HAR,再用脚本过滤出同一域名下的 XHR。判断哪些是关键请求有个土办法:看响应体里有没有出现后续请求用到的字段。出现了,就是链路节点;没出现,多半是埋点或静态资源,直接扔。
import json # 从浏览器导出的 har 文件里筛出登录链路的关键请求 with open("login.har", "r", encoding="utf-8") as f: har = json.load(f) entries = har["log"]["entries"] seen = set() for e in entries: req = e["request"] url = req["url"] # 只保留接口类请求,过滤图片/css/js if not any(k in url for k in ("/api/", "/login", "/server", "/game")): continue if url in seen: continue seen.add(url) print(req["method"], url) # 打印请求体里出现的参数名,方便对照后续依赖 body = req.get("postData", {}).get("text", "") print(" body:", body[:200])这段代码的用途是把链路可视化,不是让你直接照抄参数。/api/、/login这类关键字要按实际域名路径改,不同游戏差得很远。跑完你会得到一张请求清单,接下来逐个用requests复现,每复现一个就立刻打印响应里的关键字段,确认拿到了再往下走。别一次写完五个请求再一起调,那样出错时你根本不知道断在哪一跳。
提示:HAR 里会带完整 cookie 和票据,属于敏感数据,别提交到代码仓库,本地调试完就删。
2.2 密码加密与签名参数:把前端 JS 翻译成 Python
登录请求里几乎不可能出现明文密码。常见做法有三种:MD5 加盐、SHA1 加时间戳、以及 RSA 公钥加密。前两种用 Python 标准库就能复刻,第三种要拿到公钥(通常在某个 js 文件里硬编码成一长串 base64)。
判断是哪种,看提交的密文长度:32 位十六进制基本是 MD5,40 位是 SHA1,128 位以上十六进制串大概率是 RSA。盐值从哪来?两种情况——写死在 js 里的固定串,或者由服务端先下发。后者意味着你必须先发一个「取盐」请求,这也是很多人漏掉的一跳。
import hashlib import time import random import string def md5_hex(s: str) -> str: return hashlib.md5(s.encode("utf-8")).hexdigest() def build_sign(params: dict, secret: str) -> str: """常见签名规则:参数按 key 字典序拼接 + 密钥,再取 md5""" items = sorted((k, str(v)) for k, v in params.items() if k != "sign") raw = "&".join(f"{k}={v}" for k, v in items) + secret return md5_hex(raw) def build_login_form(user: str, pwd: str, salt: str, secret: str) -> dict: ts = int(time.time()) nonce = "".join(random.choices(string.ascii_lowercase + string.digits, k=8)) form = { "username": user, "password": md5_hex(pwd + salt), # 密码 + 盐 "ts": ts, # 时间戳,服务端通常允许 ±300s 偏差 "nonce": nonce, # 防重放 "client_ver": "1.0.0", # 版本号,有些接口会校验 } form["sign"] = build_sign(form, secret) return formbuild_sign用的是最常见的「字典序拼接 + 密钥」规则,实际项目里可能是key=value直接连、可能是拼接后再拼密钥、也可能先 URL 编码再算。验证办法只有一个:拿浏览器发出的真实请求,把同样的参数丢进你的函数,看输出是否和抓到的sign一致。一致了才算通,别靠猜。
ts那个参数是高频翻车点。服务端一般不校验绝对时间,只校验「与服务器时间差在阈值内」,所以本机时间不准会导致签名永远失败,报错还只给你「参数错误」。跑之前先ntp校时,或者直接请求一次服务器时间接口再算偏移。
client_ver这类字段最容易被忽略。它往往不参与签名,但服务端会独立校验,版本太老会直接拒绝。升级游戏后登陆器突然全挂,八成就是这里。
2.3 会话维持:为什么脚本跑十分钟就掉线
登录成功只是开始。游戏类接口几乎都依赖会话态,会话失效的表现是:前面几十个请求都正常,突然全部返回「未登录」,重登又好了,过一会再挂。这不是玄学,是三个原因之一。
第一,cookie 没复用。每次请求都新建requests.get(),等于每次都开新会话,服务端认不出你。正确做法是全局一个requests.Session(),它自动维护 cookie jar 和连接池。这一点看起来是常识,但我在别人的代码里见过太多次。
第二,心跳缺失。服务端一般有闲置超时,30 到 120 秒不等。没有心跳,会话就被回收。心跳接口通常是/api/heartbeat或/api/ping,返回体很短。写个后台线程按固定间隔打就行,但间隔别贴着超时线设,留 30% 余量。
第三,单点登录互踢。同一个账号在别处登录,服务端会把旧会话作废。多开脚本如果共用账号,必然互相踢。解决办法是账号池隔离,一个账号一个 session,绝不交叉。
import threading import time import requests class GameSession: def __init__(self, cookie_str: str = ""): self.s = requests.Session() self.s.headers.update({ "User-Agent": "Mozilla/5.0 ...", # 与抓包时保持一致 "Referer": "https://example.com/", # 部分接口校验来源 }) if cookie_str: self.s.headers["Cookie"] = cookie_str self._alive = True self._t = threading.Thread(target=self._heartbeat, daemon=True) self._t.start() def _heartbeat(self, interval: int = 45): while self._alive: try: r = self.s.post("https://example.com/api/heartbeat", timeout=10) # 心跳失败不要立刻退出,连续 3 次失败再判定掉线 if r.status_code != 200: print("heartbeat abnormal:", r.status_code) except requests.RequestException as e: print("heartbeat error:", e) time.sleep(interval) def close(self): self._alive = False关键点在daemon=True和失败容忍。心跳线程必须是守护线程,否则主程序退出时会被它挂住;心跳失败也不能立刻把整个 session 判死,网络抖一下很常见,连续几次失败再触发重登逻辑才合理。interval这个参数要按实测调,从 60 开始往下试,找到不掉线的最小值再上浮 20 秒。
3. 把协议包成能用的客户端:GUI 线程模型与配置管理
协议通了,接下来是「人怎么用」。zip 里的登陆器如果只有命令行,日常操作体验会很差——尤其要切账号、看日志、改配置的时候。加一层 GUI 不难,难的是别把界面卡死。这一节讲清楚线程怎么分、配置怎么存、打包怎么不翻车。
3.1 Tkinter 与请求线程的分工:主线程只做渲染
Tkinter 是单线程的,所有控件操作必须在主线程。一旦你在按钮回调里直接requests.post()等三秒,界面就白屏假死,用户以为程序崩了。标准解法是「工作线程 + 队列 + 定时轮询」:工作线程干活,把结果丢进queue.Queue,主线程用after()每 100 毫秒取一次队列刷新界面。
import queue import threading import tkinter as tk from tkinter import ttk class LauncherApp: def __init__(self, root): self.root = root self.q = queue.Queue() # 工作线程 -> 主线程 的单向通道 self.login_btn = ttk.Button(root, text="登录", command=self.on_login) self.login_btn.pack() self.log = tk.Text(root, height=12, width=60) self.log.pack() self.root.after(100, self._drain) # 启动轮询 def on_login(self): self.login_btn.config(state="disabled") # 防重复点击 threading.Thread(target=self._do_login, daemon=True).start() def _do_login(self): try: # 这里调用第 2 节的 GameSession,耗时操作全在工作线程 for i in range(3): self.q.put(f"步骤 {i + 1} 完成") except Exception as e: self.q.put(f"ERROR: {e}") finally: self.q.put("__DONE__") def _drain(self): while True: try: msg = self.q.get_nowait() except queue.Empty: break if msg == "__DONE__": self.login_btn.config(state="normal") else: self.log.insert("end", msg + "\n") self.log.see("end") self.root.after(100, self._drain) # 递归续约,保持轮询这套模型的三个要点:队列必须是无界的(Queue()不传参数),否则工作线程写满会阻塞;_drain里用get_nowait()循环取空,不要一次只取一个;after(100, self._drain)要在函数末尾重新注册,形成自循环。__DONE__这种哨兵值是用来恢复按钮状态的,不然一次失败之后按钮就永久灰掉了——这是 GUI 里最常见的「假死」。
注意:任何
self.log.insert都必须发生在主线程。工作线程里直接操作控件,在 Windows 上表现为随机崩溃,在 Linux 上可能只是日志乱序,很难排查。
3.2 账号池与配置文件的组织方式
多账号场景下,配置不能散落在代码里。常见做法是用 YAML 或 JSON,把「账号、服务器、代理、行为策略」分层。不要图省事把密码明文写进去——不是怕攻击,是怕你自己手滑提交到公开仓库。
# config.yaml defaults: server: "s1" timeout: 10 heartbeat: 45 accounts: - user: "acc_001" pwd_md5: "e10adc3949ba59abbe56e057f20f883e" # 已加盐的密文,不放明文 salt: "fixed_salt_v1" enabled: true - user: "acc_002" pwd_md5: "25f9e794323b453885f5181f1b624d0b" salt: "fixed_salt_v1" enabled: false读取侧要处理三个现实问题:文件不存在时给默认值而不是抛异常;enabled: false的账号要跳过但保留在列表里;单个账号解析失败不能让整体崩。
import yaml def load_config(path: str = "config.yaml") -> dict: try: with open(path, "r", encoding="utf-8") as f: cfg = yaml.safe_load(f) or {} except FileNotFoundError: cfg = {} cfg.setdefault("defaults", {"server": "s1", "timeout": 10, "heartbeat": 45}) cfg.setdefault("accounts", []) # 过滤掉禁用项,同时保留原始列表供界面展示 cfg["active"] = [a for a in cfg["accounts"] if a.get("enabled", True)] return cfgsafe_load而不是load,这是硬性要求,load在旧版本 PyYAML 里能构造任意对象。setdefault那几行是给缺字段的配置兜底,省得后面到处写if "defaults" in cfg。
3.3 打包成 exe:路径、控制台与隐藏的日志
从源码到可分发,绕不开 PyInstaller。三个参数决定成败:-F单文件、-w无控制台、--add-data带资源。其中-w是最坑的——它把 stdout 也一起吞了,你所有的print全部消失,出问题只能靠猜。
# Windows 下资源分隔符是分号,Linux/macOS 是冒号,写错了资源就找不到 pyinstaller -F -w --add-data "config.yaml;." --add-data "assets;assets" main.py # 调试阶段建议先不打包成单文件、保留控制台,方便看 traceback pyinstaller -D --add-data "config.yaml;." main.py打包后最容易出的错是「配置文件找不到」。因为单文件模式运行时,程序会被解压到临时目录,os.getcwd()和__file__都不指向 exe 所在目录。正确姿势是用sys._MEIPASS拿资源路径,用sys.executable拿 exe 路径。
import os import sys def resource_path(rel: str) -> str: """读取打包进 exe 的只读资源""" base = getattr(sys, "_MEIPASS", os.path.abspath(".")) return os.path.join(base, rel) def user_path(rel: str) -> str: """读写用户数据,必须落在 exe 同级目录,不能落临时目录""" base = os.path.dirname(sys.executable) if getattr(sys, "frozen", False) else os.path.abspath(".") return os.path.join(base, rel)区分这两个函数是关键:配置模板、图标这类只读资源走resource_path;账号缓存、训练日志、模型权重这类要持久化的走user_path。混用会导致「每次启动配置都被重置」——因为临时目录每次都不一样。
4. 强化学习部分:把回合制对战封装成可训练环境
这块是整份资源里最有意思的部分,也是最容易被误用的部分。把游戏对战做成 RL 环境,难点不在算法——DQN、PPO 现成的库一堆——而在于状态怎么表示、动作怎么约束、奖励怎么设计。这三件事错了,再强的算法也学不出东西,而且症状极具迷惑性:loss 在降,胜率不动。
4.1 状态空间:固定维度 + 归一化是底线
回合制对战的状态有两类信息:己方全部可见(六只精灵的血量、技能 PP、属性强化等级),对方部分可见(当前场上精灵、已出场过的精灵、可见的强化状态)。设计状态向量时,己方用完整槽位,对方用「已知信息 + 未知占位」,这样维度固定,不会因为对面换精灵就变长。
固定维度的做法:每只精灵用 8 维表示(当前血量比、最大血量比、是否存活、属性 ID 的 one-hot 压缩值、攻击/防御/速度强化等级各一维),六只精灵就是 48 维;再加场上状态 4 维(天气、场地、回合数归一化、己方剩余数量),对方场上精灵 8 维。总共 60 维左右,对 MLP 完全够用。
import numpy as np def encode_side(team: list, active_idx: int) -> np.ndarray: """把一队精灵编码成固定 6*8=48 维向量""" vec = np.zeros((6, 8), dtype=np.float32) for i, p in enumerate(team[:6]): vec[i, 0] = p["hp"] / max(p["max_hp"], 1) # 血量比,归一到 0~1 vec[i, 1] = p["max_hp"] / 600.0 # 血量上限粗归一 vec[i, 2] = 1.0 if p["hp"] > 0 else 0.0 # 存活标志 vec[i, 3] = p["type_id"] / 18.0 # 属性编号压缩 vec[i, 4] = np.clip(p["atk_stage"] / 6.0, -1, 1) # 强化等级 -6~+6 vec[i, 5] = np.clip(p["def_stage"] / 6.0, -1, 1) vec[i, 6] = np.clip(p["spd_stage"] / 6.0, -1, 1) vec[i, 7] = 1.0 if i == active_idx else 0.0 # 是否在场 return vec.reshape(-1)几个参数要说明:除以 600 是血量上限的粗归一,实际值按你游戏里的最大种族值调整,目的是让不同量级的特征落在同一尺度,否则网络会被大数值主导。强化等级裁剪到 ±6 是这类游戏的常见上限。active_idx那一维很重要,少了它,网络分不清「哪只在场上」,训练会卡在随机水平。
对方的状态编码同理,但只填「已出场过的精灵」,未知槽位全零并额外加一维「是否已知」标志。这样模型能学会在信息不完全下决策。
4.2 动作空间与合法动作掩码
动作空间是离散的:换精灵(最多 5 个)加放技能(最多 4 个),一共 9 个。用Discrete(9)。但直接这么用会出大问题——模型会学着去选「已经阵亡的精灵」或「PP 为 0 的技能」,这些动作在环境里非法,你得返回一个惩罚,结果模型学到的是「别选这些」,而不是「怎么赢」。这就是典型的奖励污染。
正确做法是动作掩码:每一步把非法动作的 Q 值置为负无穷,softmax 或 argmax 时自然被排除。
import torch def masked_q(q: torch.Tensor, legal: torch.Tensor) -> torch.Tensor: """legal 是与 q 同形状的 bool 张量,True 表示该动作合法""" return q.masked_fill(~legal, -1e9) def select_action(q: torch.Tensor, legal: torch.Tensor, eps: float = 0.05) -> int: if torch.rand(1).item() < eps: idx = torch.nonzero(legal, as_tuple=False).flatten() return int(idx[torch.randint(len(idx), (1,))].item()) # 探索也只能在合法动作里 return int(masked_q(q, legal).argmax().item())-1e9而不是-inf,是为了避免后续 softmax 出现 NaN。探索那一路也必须限制在合法动作集合内——否则随机探索会撞非法动作,白白浪费样本。这个细节我在第一次做的时候完全没意识到,白白跑了两天。
4.3 奖励函数:稀疏胜负奖励怎么破
只用「赢 +1、输 -1、其余 0」是最朴素的,也是训练最慢的。一局 20 到 50 回合,中间全是零奖励,模型几乎收不到有效信号。常见做法是加 shaping 项,但要小心别引入偏差。
我一般用三层:终局奖励(赢 +1,输 -1,平局 0)、血量差奖励(每回合双方总血量变化之差,乘以 0.1)、击败奖励(打掉对方一只 +0.2,自己被击败 -0.2)。血量差那项要除以最大血量做归一,否则数值会爆掉。
def step_reward(prev: dict, cur: dict, done: bool, winner: int) -> float: r = 0.0 # 1) 血量差:己方总血量占比提升为正 r += 0.1 * (cur["my_hp_ratio"] - prev["my_hp_ratio"]) r -= 0.1 * (cur["opp_hp_ratio"] - prev["opp_hp_ratio"]) # 2) 击败事件 if cur["my_fainted"] > prev["my_fainted"]: r -= 0.2 if cur["opp_fainted"] > prev["opp_fainted"]: r += 0.2 # 3) 终局 if done: r += 1.0 if winner == 1 else (-1.0 if winner == -1 else 0.0) # 4) 回合惩罚,抑制无限拖延 r -= 0.005 return r回合惩罚这一项容易被忽略。不加的话,模型会发现「疯狂换精灵拖到平局」比「冒险进攻」更安全,最后学出一个只会拖时间的策略,胜率极低但活得久。加上 -0.005 这种极小值就能压住,量级一定要小,否则会盖过终局信号。
4.4 训练循环:经验回放 + 目标网络
算法选 DQN 就够,别一上来就上 PPO。离散动作、状态维度不高、单步决策,DQN 的样本效率反而更好,调试也直观。
import random from collections import deque import torch import torch.nn as nn class QNet(nn.Module): def __init__(self, obs_dim: int, act_dim: int): super().__init__() self.net = nn.Sequential( nn.Linear(obs_dim, 256), nn.ReLU(), nn.Linear(256, 256), nn.ReLU(), nn.Linear(256, act_dim), ) def forward(self, x): return self.net(x) def train(env, obs_dim: int, act_dim: int, episodes: int = 5000): q, tq = QNet(obs_dim, act_dim), QNet(obs_dim, act_dim) tq.load_state_dict(q.state_dict()) opt = torch.optim.Adam(q.parameters(), lr=3e-4) # 3e-4 是这个规模下比较稳的起点 buf = deque(maxlen=100_000) gamma, batch = 0.99, 128 # gamma 偏高,因为一局 20+ 回合 for ep in range(episodes): obs, legal = env.reset() eps = max(0.02, 0.3 * (0.9995 ** ep)) # 探索率指数衰减,保底 0.02 while True: with torch.no_grad(): qv = q(torch.as_tensor(obs).float().unsqueeze(0))[0] a = select_action(qv, torch.as_tensor(legal), eps) nobs, nlegal, r, done, info = env.step(a) buf.append((obs, legal, a, r, nobs, nlegal, done)) obs, legal = nobs, nlegal if done: break if len(buf) < batch: continue for _ in range(4): # 每回合更新 4 次,提高样本利用率 s = random.sample(buf, batch) o, lg, ac, rw, no, nlg, dn = zip(*s) o = torch.as_tensor(np.array(o)).float() no = torch.as_tensor(np.array(no)).float() ac = torch.as_tensor(ac).long() rw = torch.as_tensor(rw).float() dn = torch.as_tensor(dn).float() cur = q(o).gather(1, ac.unsqueeze(1)).squeeze(1) with torch.no_grad(): nxt = masked_q(tq(no), torch.as_tensor(np.array(nlg))).max(1).values target = rw + gamma * nxt * (1 - dn) # done 时切断自举 loss = nn.functional.smooth_l1_loss(cur, target) opt.zero_grad() loss.backward() nn.utils.clip_grad_norm_(q.parameters(), 10) # 梯度裁剪,防偶发爆炸 opt.step() if ep % 200 == 0: tq.load_state_dict(q.state_dict()) # 每 200 回合同步一次目标网络 print(f"ep={ep} eps={eps:.3f} buf={len(buf)}")几个参数值得单独说。gamma=0.99比常见的 0.9 高,因为一局动辄二三十步,折扣太狠会让远期胜负信号衰减殆尽。target网络同步频率从 200 回合起步,太频繁等于没目标网络,太稀疏又会训练发散。smooth_l1_loss比 MSE 对异常奖励更鲁棒。梯度裁剪那行是后悔药,一次异常大的 TD 误差就能把网络打崩,此后再也训不回来。
5. 避坑与排查:六个真实翻车的现场
这部分是我踩过最多次的地方,按「现象 → 原因 → 解决」列出来,遇到问题时可以直接对号入座。
5.1 登录返回成功,但进游戏一直转圈
现象:登陆器打印「登录成功」,票据也拿到了,但后续所有游戏接口都返回空数据或「请重新登录」。翻原因:漏了「选服」那一跳。账号登录只是拿到一个通用票据,必须再用票据请求服务器列表,选一个服务器换出游戏内 token,这个 token 才是后续接口的凭证。两个票据长得很像,很容易混。解决:把请求链逐条重放,确认每个请求的响应字段被下一个请求真正使用;打印每次切换后的 cookie 和 header,对比抓包原件。
5.2 脚本跑五到十分钟就掉线,重登立刻恢复
现象:日志显示某次请求突然全部返回未登录,手动重登又正常。原因通常是三个之一——没心跳、cookie 被覆盖、或者同一账号在别处登录被踢。排查顺序建议从心跳开始:抓包看浏览器自己在闲置时会不会定时发某个请求,有就补上。其次是检查有没有代码在别处手动s.headers["Cookie"] = ...把 Session 维护的 cookie 覆盖了。最后确认账号是否被多开共享。
5.3 训练 loss 一路下降,胜率纹丝不动
现象:loss从 2.0 降到 0.1,评估胜率始终在 50% 上下。原因多半是状态里有信息泄漏或无效特征。典型的信息泄漏是:把对方的隐藏信息(未出场精灵)也编进了状态向量,模型学到的是「作弊」,一旦对手池换了就废。无效特征是:所有精灵的属性 ID 编码方式一致,网络分不出区别。解决:逐个特征做消融,把某一维置零重训,看胜率变化;如果置零后毫无影响,那一维就是废的。
5.4 打包后提示「配置文件不存在」
现象:源码跑得好好的,pyinstaller -F之后一启动就报错。原因:单文件模式会把资源解压到临时目录,os.path.abspath(".")指向的不是 exe 所在目录。解决:只读资源用sys._MEIPASS,可写数据用sys.executable的目录,两者分开处理(第 3.3 节的resource_path/user_path)。顺带提一句,-w打包后没有控制台,出问题连 traceback 都看不到,调试阶段务必先-D保留控制台。
5.5 多账号并发把号挤下线
现象:单开完全正常,一旦同时跑三个号,就互相踢。原因:服务端对同账号做了单点登录限制,脚本里多个实例共用了同一份账号配置,或者账号池分配逻辑有重复。解决:启动前对账号列表做去重,运行时用一个「账号 → 会话」的字典互斥,绝不让两个线程操作同一个账号。真要同号多角色,那属于另一种机制,得看服务端给不给。
5.6 模型在固定对手池上胜率 90%,换个阵容掉到 30%
现象:离线评估非常漂亮,一上线就原形毕露。原因:对手池太单一,模型记住了「对某几个固定阵容的最优解」,本质是过拟合。解决:训练时引入对手池随机化——把历史版本的模型、规则脚本、随机策略混在一起做对手,比例大概是 5:3:2。同时评估集必须和训练集完全不重叠,否则数字没有意义。
6. 验证与进阶:怎么判断模型真的学到了东西,而不是碰巧赢了
训练跑起来只是开始,判断「学没学会」才是分水岭。我这里有一套固定流程,每次训完必走一遍,省了很多自欺欺人的时间。
第一步,固定种子做对照。把随机种子、对手池、初始阵容全部锁死,跑 200 局,记录胜率和平均回合数。同一份模型跑两次,胜率差超过 5 个百分点,说明评估本身就不稳定,先别急着看绝对值。第二步,看动作分布熵。把每一步的动作概率导出,算熵。熵接近均匀分布,说明模型根本没学到偏好;熵极低但胜率不高,说明它卡在某个局部策略里了。健康的状态是熵缓慢下降但不归零。
第三步,也是最有效的一步:把每局落成 jsonl 复盘。每行记录状态向量、合法掩码、选中动作、即时奖励、是否终局。有了这份数据,你可以在不重跑游戏的情况下重放整局,检查某一步的决策到底合不合理。
import json def dump_transition(fp, step: int, obs, legal, action: int, reward: float, done: bool): rec = { "step": step, "obs": [round(float(x), 4) for x in obs], # 保留 4 位,文件体积可控 "legal": [int(x) for x in legal], "action": int(action), "reward": round(float(reward), 4), "done": bool(done), } fp.write(json.dumps(rec, ensure_ascii=False) + "\n") # 用法:一局一个文件,文件名带时间戳和结果,方便按胜负筛选 with open("replay/ep0001_win.jsonl", "w", encoding="utf-8") as f: # 循环里调用 dump_transition(f, ...) passobs保留四位小数是为了控制体积——一局 40 步、每步 60 维,全精度能到几百 KB,压到四位后只有几十 KB,几千局也扛得住。文件名带胜负标签,后面想单独看输的局直接grep就行。
进阶方向有两个。一是课程学习:先用简单对手(随机策略)训到稳定,再逐步换成规则脚本,最后混入历史模型。这比一上来就打最强对手收敛快得多。二是动作空间的层次化:把「换精灵 / 放技能」拆成两级决策,先选大类再选具体项,动作数从 9 降到 2 + 5,样本效率会明显提升。
最后说个血泪经验。我早期图省事,训练和评估用的是同一个环境实例,结果模型把「上一步的动作」也间接编码进了状态(因为环境内部有残留状态没重置干净),胜率虚高到 85%,一换实例直接掉到 40%。从那以后我每次建环境都强制走一遍「reset 后状态是否与全新实例一致」的断言,多花两行代码,省掉一整轮白训。希望帮到你。
本文还有配套的精品资源,点击获取