资讯详情

资讯详情

Python分层架构游戏自动战斗:协议封装与PPO强化学习

简介围绕赛尔号游戏场景聚焦Python登录器开发与强化学习实战面向具备一定Python基础、希望理解游戏辅助工具与游戏AI训练流程的学习者和开发者。压缩包共2000个文件约42.75MB以txt文本、bmp图像素材为主辅以dll、exe、dat等运行与数据文件以及py脚本和pyc字节码另有少量png、gif、wav等资源可支撑脚本调试与素材复现。已有167人学习下载。读者可从中了解登录器涉及的HTTP请求、页面解析与socket通信思路并参考强化学习训练AI角色的实践路径包括环境构建、状态与动作空间定义、奖励函数设计及Q-learning、DQN、PPO等算法的落地方式包内脚本与素材便于对照复现实验适合需要系统理解游戏辅助开发与强化学习应用的中高级读者查漏补缺。描述涉及的登录器身份验证、请求处理与服务器数据交互以及Gym环境、TensorFlow/PyTorch建模、经验回放和目标网络等技巧也可结合包内脚本延伸实践。1. 从一堆 if-else 到会自己选技能的脚本凌晨一点屏幕上还开着三个窗口一个 Python 登陆器在轮询账号状态一个终端在刷训练日志一个 sqlite 浏览器里存着两万多场战斗快照。这套东西最初只是几十行的 requests 脚本用来省掉每天手动点日常写到第三个月if-else 堆了两百多行对面一换精灵就开始犯傻。后来整个目录被拆成三块登陆器负责把账号登进去、把会话维持住协议层把页面上的每一次点击翻译成函数调用强化学习那部分把战斗过程包成标准 Gym 环境让 PPO 自己学什么时候该放技能。这份 zip 就是三块的合集适合已经会抓包、想把脚本从「写死规则」推到「自己学策略」的开发者只想找个开箱即用挂机工具的里面的分层反而显得啰嗦。2. 项目全景三层架构、技术栈与目录结构拿到压缩包先别急着 pip install。这类项目最常见的翻车不是代码写错而是没搞清楚哪一层在干什么改了一个文件结果把另外两层带崩。这份包的价值不在某个炫技的算法而在于它把「登录一个页游」和「训练一个策略」用一层薄薄的接口隔开了下面按层拆开看。2.1 三层结构登陆器、协议层、学习层最上面是登陆器launcher一个 PyQt5 桌面程序负责账号表格、登录按钮、状态灯、日志窗口。它不直接发请求所有网络动作都交给工作线程里的登录函数UI 只负责收信号。中间是协议层client核心是一个GameApi类。它把「进入战斗」「释放技能」「切换精灵」这些页面行为映射成start_battle()、do_action(action_id)这样的方法内部维护requests.Session、签名计算、节流和错误码处理。这一层不知道界面长什么样也不知道有没有人在训练它。最下面是学习层rl把GameApi包成gymnasium.Envreset()开一局新战斗step()出一次招返回观测、奖励、结束标志。PPO 训练脚本只跟环境打交道完全不关心底层是 HTTP 还是别的什么。三层之间只有两条依赖线launcher → clientrl → client。这个方向是单向的所以你可以把 PyQt5 换成命令行、把 PPO 换成 DQN另一头不用动。这种拆法在真实项目里很值钱因为改需求时你只需要知道该动哪一层。2.2 技术栈与版本约束模块依赖建议版本用途运行环境Python3.10Gymnasium 与 SB3 2.x 在 3.10 上踩坑最少界面PyQt55.15.x账号表格、登录线程、日志面板网络requests2.31Session 复用 Cookie 与连接加密pycryptodome3.20RSA 加密密码、MD5 签名环境接口gymnasium0.29.xreset/step 五元组契约算法stable-baselines32.2.xPPO、向量化环境、回调后端torch2.1.xSB3 的计算后端记录loguru / pyyaml最新日志与账号、训练配置提示这套组合里最容易出问题的是 numpy。SB3 早期 2.x 版本对 numpy 2.0 不兼容某些版本会抛出与np.float_相关的属性错误。如果环境里有别的库把 numpy 推到 2.x先把 numpy 锁在 1.26 再装 SB3能省掉一小时排查。版本这块我不是随便写的。gymnasium 0.29 之后reset()必须返回(obs, info)、step()必须返回五元组网上大量老教程还是四元组写法直接抄过来会报too many values to unpack。这份包里统一用的是新接口所以环境代码看起来会比旧例子多几行。2.3 目录结构与文件职责seer_py/ ├── launcher/ # 登陆器只管界面和账号编排 │ ├── main.py # 程序入口创建 QApplication │ ├── ui/main_window.py # 主窗口账号表 状态 日志 │ ├── workers/login_worker.py # QThread登录动作丢到这里跑 │ └── core/ │ ├── login.py # 登录流程编排拼参数 - 发请求 - 解析 │ ├── crypto.py # RSA 加密、MD5 签名 │ └── session.py # GameSession心跳、重登、请求封装 ├── client/ # 协议层页面行为 - 函数调用 │ ├── api.py # GameApi业务方法都在这 │ ├── endpoints.py # 所有 URL 和错误码常量 │ ├── sign.py # 请求签名与时间戳 │ └── storage.py # 战斗快照写 sqlite ├── rl/ # 学习层 │ ├── envs/battle_env.py # Gymnasium 环境 │ ├── reward.py # 奖励函数单独抽出来 │ ├── train_ppo.py # 训练脚本 │ └── eval.py # 离线评估跑 N 局看胜率 ├── config/ │ ├── accounts.yaml # 账号、并发上限、心跳间隔 │ └── train.yaml # 超参、环境数量、保存路径 ├── tools/sniff_to_api.py # 把抓包记录转成 endpoints 草稿 ├── data/game.db # 战斗记录 └── requirements.txt值得单独说的是reward.py被抽成了独立文件。很多人写 RL 项目把奖励塞在step()里训练一崩就得回环境代码里翻改一次重启一次。把奖励拆出去以后你可以写个脚本拿历史快照离线重算奖励曲线不用连游戏这个习惯能省掉大量来回试错的时间。tools/sniff_to_api.py是个辅助脚本把抓包工具导出的请求记录转成endpoints.py里的常量草稿省掉手工抄 URL 的功夫。它不参与运行但换目标或者补接口时挺有用。3. 登陆器与协议层一次登录请求的完整链路这一章从「点一下登录按钮」讲到「拿到一个能复用的会话」中间的每一步都是后面训练能不能跑起来的前提。登录没打通环境再漂亮也是空转。3.1 界面与网络分离登录按钮背后的线程PyQt5 里最常见的写法错误是在按钮的槽函数里直接requests.post。请求一慢整个窗口就卡成白屏用户以为程序死了。正确做法是把网络动作放进 QThread通过信号把结果发回主线程。# launcher/workers/login_worker.py from PyQt5.QtCore import QThread, pyqtSignal from launcher.core.login import login_account class LoginWorker(QThread): # 成功账号 会话信息失败账号 原因 ok pyqtSignal(str, dict) fail pyqtSignal(str, str) def __init__(self, account: str, password: str, parentNone): super().__init__(parent) self.account account self.password password def run(self): try: info login_account(self.account, self.password, timeout8) self.ok.emit(self.account, info) except Exception as e: # 这里只发信号绝对不要直接改控件 self.fail.emit(self.account, repr(e))run()里跑的是子线程任何self.label.setText()之类的操作都会让程序在 Windows 上直接闪退报错信息还不一定给你。主窗口那边用worker.ok.connect(self.on_login_ok)接住槽函数里再更新表格和状态灯。timeout8是刻意设的页游登录接口正常在 1 秒内返回超过 8 秒基本是网络或风控问题早点失败比挂着好。多账号登录时不要一个账号起一个线程就完事。常见做法是用一个线程池控制并发config/accounts.yaml里配max_concurrent: 3超过就排队。同 IP 下几十个账号同时打登录接口被踢的概率相当高。3.2 登录参数拼装RSA 密码与 MD5 签名这一步是整个包里最「玄学」的部分因为参数形式完全取决于前端 JS 怎么写的。包里crypto.py的做法是把前端 bundle 里的逻辑原样搬到 Python密码用 RSA 公钥加密后 base64另加一个由账号和时间戳拼出来的签名。# launcher/core/crypto.py import base64 import hashlib import time from Crypto.Cipher import PKCS1_v1_5 from Crypto.PublicKey import RSA # 公钥从前端 JS 里抄出来换目标时只需要换这一段 RSA_PUB -----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC...省略 -----END PUBLIC KEY----- SALT 前端硬编码的盐值 def encrypt_password(raw: str) - str: PKCS1_v1_5 填充单段明文不能超过 117 字节1024 位密钥 key RSA.import_key(RSA_PUB) cipher PKCS1_v1_5.new(key) block cipher.encrypt(raw.encode(utf-8)) return base64.b64encode(block).decode() def make_sign(account: str, ts: int) - str: 前端是 md5(account ts salt)顺序错了服务端就返回签名错误 return hashlib.md5(f{account}{ts}{SALT}.encode()).hexdigest() def build_login_form(account: str, password: str, device_id: str) - dict: ts int(time.time() * 1000) # 毫秒不是秒 return { username: account, password: encrypt_password(password), deviceId: device_id, ts: ts, sign: make_sign(account, ts), }几个参数值得逐个说。ts用毫秒时间戳前端一般写成Date.now()Python 的time.time()是秒少乘 1000 会一直返回参数错误但错误提示往往只说「请求非法」很容易查半天。device_id一般是首次登录下发的存在本地复用同一个账号频繁换设备 ID 反而会引起风控。sign的拼接顺序必须和前端完全一致account ts salt和ts account salt算出来的结果完全不同。注意RSA 加密有长度上限1024 位密钥的 PKCS1_v1_5 填充单段最多 117 字节。密码短一般没事但如果接口还要加密别的内容就需要分段加密再拼接直接一把梭会抛ValueError。3.3 会话保持心跳、掉线码与自动重登登录成功只是开始页游服务端通常有会话超时一段时间没请求就把你踢下线。包里用GameSession统一管理持有一个requests.SessionCookie 自动维护、一个后台心跳线程、一个重登锁。# launcher/core/session.py import threading import time import requests class GameSession: def __init__(self, account: str, token: str, timeout: float 8.0): self.account account self.token token self.timeout timeout self.s requests.Session() self.s.headers.update({ User-Agent: Mozilla/5.0 ..., Referer: https://example.invalid/game/, }) self._lock threading.Lock() # 重登时只允许一个线程进去 self.last_ok time.time() self._hb threading.Thread(targetself._heartbeat, daemonTrue) self._hb.start() def _heartbeat(self): # 间隔取服务端 idle 超时的三分之一留足余量 while True: time.sleep(60) if time.time() - self.last_ok 120: self.relogin() def relogin(self): with self._lock: if time.time() - self.last_ok 30: # 别人已经重登过了 return self.s.cookies.clear() info do_login(self.account) # 复用 3.2 的表单 self.token info[token] self.last_ok time.time() def post(self, url: str, data: dict, retry: int 2) - dict: for i in range(retry 1): try: resp self.s.post(url, datadata, timeoutself.timeout) resp.raise_for_status() payload resp.json() if payload.get(code) in (1001, 1002): # 会话失效类错误码 self.relogin() continue self.last_ok time.time() return payload except requests.RequestException: if i retry: raise time.sleep(1.5 * (i 1)) # 退避别贴着打 def close(self): self.s.close()_lock这个双检锁是血泪经验。早期版本没加锁心跳线程和业务请求同时发现掉线两个线程一起去重登结果后一次登录把前一次的会话顶掉紧接着又被踢开始无限循环。加上锁和 30 秒的时间窗判断之后这个问题就消失了。错误码分类也很关键。1001/1002属于会话失效值得重登重试2003这类是参数校验失败重试一百次也没用只会让日志爆炸。endpoints.py里把这些码定义成常量判断的时候用名字不用魔数后面加接口不容易写错。3.4 请求节流与重试别把账号打挂协议层封装GameApi的时候第一个要加的不是业务方法而是限速。页游服务端的风控通常看单位时间请求数脚本跑起来一秒钟几十发前几分钟没感觉之后就开始随机返回失败甚至直接封禁。# client/api.py节选 import threading import time class RateLimiter: 最小间隔限流比令牌桶简单够用且好调 def __init__(self, qps: float 2.0): self.min_interval 1.0 / qps self._last 0.0 self._lock threading.Lock() def wait(self): with self._lock: delta time.monotonic() - self._last if delta self.min_interval: time.sleep(self.min_interval - delta) self._last time.monotonic() class GameApi: def __init__(self, session, qps: float 2.0): self.session session self.limiter RateLimiter(qps) def start_battle(self, stage_id: int) - dict: self.limiter.wait() return self.session.post(EP_START_BATTLE, {stageId: stage_id}) def do_action(self, action_id: int) - dict: self.limiter.wait() return self.session.post(EP_ACTION, {action: action_id})qps2.0意味着任意两次请求间隔不小于 500 毫秒。这个值不是拍脑袋是拿几个小号一点点试出来的间隔 100 毫秒时跑十分钟出现零星失败500 毫秒时连续跑几小时没异常。换目标时这个数一定要重测别人的经验值在你这里不一定成立。time.monotonic()而不是time.time()是因为后者会被系统时间同步影响机器时间一跳限流就失效或者卡死。3.5 多账号调度配置表与并发上限登陆器的账号表读的是config/accounts.yaml结构大致这样max_concurrent: 3 heartbeat_interval: 60 qps_per_account: 2.0 accounts: - user: acc_01 pass: ****** device_id: d-8f3a... enabled: true - user: acc_02 pass: ****** device_id: d-2c91... enabled: true调度逻辑很简单用一个threading.Semaphore(max_concurrent)控制同时在线的账号数每个账号一个LoginWorker登录成功就把GameSession塞进一个字典key 是账号名。后续无论是界面上手动操作还是训练脚本要取会话都从这个字典里拿不要全局共用一个 Session。全局共用一个 Session 是新手最容易犯的错表面上看 Cookie 都在实际上是所有账号的身份混在一起请求发出去之后数据串号A 账号的战斗结果出现在 B 账号的界面里。这类 bug 很难从日志上看出来因为每个请求单独看都是成功的。4. 强化学习把游戏状态封装成 Gym 环境脚本时代的瓶颈在于规则是人写的遇到没见过的局面就抓瞎。这一章讲怎么把战斗过程变成一个标准的强化学习问题包括状态怎么表示、动作怎么定义、奖励怎么给以及训练脚本里那几个关键超参为什么取这个值。4.1 状态、动作、奖励三件套怎么定先把问题形式化。战斗是回合制的每回合你从若干个技能里选一个环境状态变化直到一方全灭。这是天然的离散动作、回合制决策问题用 PPO 这类 on-policy 算法很合适不需要复杂的经验回放。项设计说明观测10 维己方血量比、己方能量比、对方血量比、对方主属性 onehot(5)、回合数比、己方存活数比全部归一化到 0~1动作7 个0 普攻1~4 四个技能5 切换精灵6 逃跑Discrete(7)奖励扣血差 × 10 − 每回合 0.05 胜负 ±10 − 逃跑 5见 4.3观测维度刻意压得很低只有 10 维。有人喜欢把整个战斗报文塞进去动辄几百维网络是能跑但样本效率极差训练几十万步还在原地打转。10 维意味着模型几百步就能看出趋势调试的时候也容易判断是哪一维没归一化。动作空间是 7 个离散动作但要注意技能 1~4 在某些精灵身上是不存在的。常见的处理是让GameApi.do_action()在服务端返回「该技能不可用」时抛一个可捕获的异常环境捕获后给一个小额负奖励并原地踏步而不是直接崩掉训练。这个细节不做训练跑到一半突然抛异常退出前面几万步全白费。4.2 环境实现gymnasium 的 reset/step 契约# rl/envs/battle_env.py import gymnasium as gym import numpy as np from gymnasium import spaces class BattleEnv(gym.Env): metadata {render_modes: [human]} def __init__(self, api, max_steps: int 60): super().__init__() self.api api self.max_steps max_steps self.observation_space spaces.Box( low0.0, high1.0, shape(10,), dtypenp.float32) self.action_space spaces.Discrete(7) self._steps 0 self._prev_enemy_hp 1.0 def _obs(self, state: dict) - np.ndarray: elem [0.0] * 5 elem[state[enemy_element]] 1.0 # 属性 onehot return np.array([ state[my_hp] / max(state[my_hp_max], 1), state[my_energy] / max(state[my_energy_max], 1), state[enemy_hp] / max(state[enemy_hp_max], 1), *elem, min(self._steps / self.max_steps, 1.0), state[my_alive] / max(state[my_total], 1), ], dtypenp.float32) def reset(self, *, seedNone, optionsNone): super().reset(seedseed) state self.api.start_battle(stage_idoptions.get(stage, 1)) self._steps 0 self._prev_enemy_hp state[enemy_hp] / max(state[enemy_hp_max], 1) return self._obs(state), {} def step(self, action): state self.api.do_action(int(action)) self._steps 1 reward self._reward(state, int(action)) self._prev_enemy_hp state[enemy_hp] / max(state[enemy_hp_max], 1) terminated bool(state[over]) truncated self._steps self.max_steps info {win: bool(state.get(win, False)), steps: self._steps} return self._obs(state), reward, terminated, truncated, info def _reward(self, state, action): from rl.reward import compute_reward return compute_reward(state, action, self._prev_enemy_hp)reset()返回(obs, info)、step()返回五元组这是 gymnasium 0.29 之后的强制契约SB3 2.x 直接依赖它。terminated表示战斗真的结束了赢了或全灭truncated表示被步数上限截断两者语义不同SB3 里terminatedTrue才会触发 bootstrap 终止混用会让价值估计偏掉。info里带上win和steps评估脚本才能统计胜率不用再去解析日志。这个字段在多环境向量化时会被 SB3 自动拼成列表写评估代码时要注意取出来的是 list 而不是 dict。4.3 奖励函数与观测归一化奖励是这类项目里最容易翻车的地方甚至比网络结构重要得多。# rl/reward.py def compute_reward(state, action, prev_enemy_hp: float) - float: enemy_hp state[enemy_hp] / max(state[enemy_hp_max], 1) r 0.0 r (prev_enemy_hp - enemy_hp) * 10.0 # 打掉的血量差给正反馈 r - 0.05 # 每回合固定成本逼它速战速决 if state[over] and state.get(win): r 10.0 if state[over] and not state.get(win): r - 10.0 if action 6: # 逃跑单独重罚 r - 5.0 return r每一项都有来由。血量差乘 10 是为了让「打伤害」这个信号足够明显不然前期几乎全是小额的 0.05 级奖励网络学半天没反应。每回合 −0.05 的摩擦成本是为了防止智能体学会「苟着」只放低伤害技能慢慢磨把回合数拖满胜率上不去但累计奖励不低。逃跑 −5 是因为逃跑本身能提前结束一个必败的局面如果不罚智能体会把它当成止损手段训练初期就学会「一打不过就跑」数据分布直接废掉。观测归一化是另一个必须做的动作。原始血量是几百点的整数如果不除到 0~1网络第一层的梯度会被大数值主导学起来非常慢表现就是损失曲线长期不动。max(..., 1)是防除零血量为 0 时精灵已死也能算出 0 而不是 NaN。4.4 训练与评估PPO 超参和一把 100 局的验证# rl/train_ppo.py import yaml from stable_baselines3 import PPO from stable_baselines3.common.callbacks import CheckpointCallback from stable_baselines3.common.monitor import Monitor from stable_baselines3.common.vec_env import DummyVecEnv from client.api import GameApi from launcher.core.session import GameSession from rl.envs.battle_env import BattleEnv CFG yaml.safe_load(open(config/train.yaml)) def make_env(account_cfg): def _init(): session GameSession(account_cfg[user], account_cfg[token]) env BattleEnv(GameApi(session, qpsCFG[qps])) return Monitor(env) # Monitor 负责统计 episode 奖励 return _init if __name__ __main__: # 用 DummyVecEnv 而不是 SubprocVecEnvSession 不是进程安全的 env DummyVecEnv([make_env(a) for a in CFG[accounts]]) model PPO( MlpPolicy, env, n_steps512, # 每个环境采 512 步再更新一次 batch_size128, gamma0.99, ent_coef0.01, # 探索系数太小会过早收敛 learning_rate3e-4, tensorboard_log./tb/, verbose1, ) model.learn( total_timestepsCFG[total_steps], callbackCheckpointCallback(save_freq10_000, save_path./ckpt/), ) model.save(ppo_battle_v1)n_steps512和batch_size128是一组折中。回合平均 8~15 步512 步大约覆盖 40~60 局梯度估计已经够用再大内存占用上去但收益不明显。ent_coef0.01是典型的探索系数调到 0.1 时智能体会一直随机乱放技能调到 0.001 时早早就锁定在「只用普攻」这个局部最优。gamma0.99对应大约 100 步的有效视野远大于单局长度等于说它基本把整局看完了。注意这里用DummyVecEnv而不用SubprocVecEnv是因为每个环境持有一个requests.Session和后台心跳线程多进程 fork 之后连接状态会被复制多个进程用同一个账号身份发请求直接触发风控。想要多环境并行就配多个账号一个账号一个环境。评估脚本不要看训练日志里的平均回报那个数字受奖励尺度影响无法直接判断好坏。eval.py的做法是加载模型用deterministicTrue跑 100 局统计胜率和平均回合数# rl/eval.py节选 from stable_baselines3 import PPO from client.api import GameApi from launcher.core.session import GameSession from rl.envs.battle_env import BattleEnv model PPO.load(ppo_battle_v1) env BattleEnv(GameApi(GameSession(CFG[user], CFG[token]), qps2.0)) wins, rounds 0, 0 for _ in range(100): obs, _ env.reset() done False while not done: action, _ model.predict(obs, deterministicTrue) obs, _, terminated, truncated, info env.step(action) done terminated or truncated wins int(info[win]) rounds info[steps] print(f胜率 {wins}% 平均回合 {rounds / 100:.1f})胜率低于基线就不上线这是唯一验收标准。训练回报涨了但胜率掉了的情况太常见了原因基本都在奖励设计上。5. 避坑与排查五个真实翻过的坑下面这些坑我都实际踩过按「现象 → 原因 → 解决」记下来遇到类似症状可以直接对照。5.1 登录与协议层的三处坑坑一一直返回签名错误但字段一个不差。现象是服务端固定返回「请求非法」抓包比对表单看起来一模一样。原因通常是三个细节ts用了秒而前端用毫秒参与签名的字段顺序不对account ts salt写成ts account salt或者 MD5 之后用了大写而前端是小写。解决办法是把 Python 组装的表单和抓包记录做逐字段 diff写个最小脚本只发登录请求打印sorted(form.items())和大写小写、长度都对一遍。别靠肉眼长得很像的十六进制串肉眼几乎分辨不出。坑二跑二十分钟后全体掉线。现象是刚启动一切正常二十到三十分钟后所有账号几乎同时失效手动重登又能用。原因是心跳间隔设得比服务端 idle 超时还长会话早被回收了心跳才姗姗来迟。另一层原因是心跳线程和业务请求没加锁两边同时重登后面那次把前面那次顶掉。解决办法是把心跳间隔压到超时的三分之一服务端 180 秒就设 60 秒重登加双检锁靠last_ok时间戳判断是否已经有人重登过。坑三多开时数据串号。现象是 A 账号的战斗结果显示在 B 账号那一行日志里每个请求都是 200。原因是GameSession被写成了类属性或者全局单例所有账号共用同一个requests.SessionCookie 互相覆盖。解决办法是一个账号一个GameSession实例用字典按账号名索引任何地方都不许从模块级变量取 Session。这条改起来五分钟排查起来一整天。5.2 训练与运行环境的两处坑坑四奖励曲线一路涨胜率纹丝不动。现象是 TensorBoard 里rollout/ep_rew_mean稳步上升跑 100 局评估胜率还是 40% 出头。原因基本是两个血量差权重给得太大智能体学会了「只磨血不打完」回合被truncated截断前一直输出小额正奖励或者ent_coef衰减太快策略过早收敛到「只用普攻」。解决办法是加每回合固定成本把逃跑和超时都做成负项评估只看胜率和平均回合数把训练回报当成参考指标。我一般会先看一眼平均回合数如果逼近max_steps上限基本可以断定是在磨。坑五Windows 上训练跑着跑着整个程序消失。现象是没有 traceback窗口直接没了日志停在某个 step。原因通常有两个PyQt5 在子线程里更新控件或者 numpy 版本和 SB3 不匹配在计算时直接崩在 C 层。解决办法是把 numpy 锁在 1.26 并重装 SB3训练脚本和界面完全解耦训练进程里不 import 任何 PyQt 模块所有界面更新一律走信号槽。另外训练时把verbose1和 loguru 的文件日志都打开崩之前至少能看到最后一步是什么。还有一个不算坑但值得提前说限流参数一定要在换目标后重测。别人的qps2在你这里可能十分钟就被踢也可能过于保守拖慢训练。拿两个小号从 5 qps 开始往下降找到第一个连续跑一小时不出错的点再打个七折用。6. 二次开发换目标、换算法、加监控的三条路这份包的分层做得还算克制所以二次开发基本是「改哪一层」的选择题不是推倒重来。换目标或补接口只动client/endpoints.py和client/api.py里的解析函数。BattleEnv依赖的是start_battle()和do_action()这两个方法的返回结构只要你新写的解析函数返回同样的字典字段my_hp、enemy_hp、over、win等环境代码一行都不用改。补接口时先用tools/sniff_to_api.py把抓包记录转成常量草稿再手工核对参数名比从头抄 URL 快得多。换算法前提是动作空间不变。Discrete(7)的情况下SB3 里换 A2C 只要改一行PPO(...)为A2C(...)DQN 则需要OffPolicyAlgorithm那套配置learning_starts和buffer_size得重新调。如果想把动作改成连续空间比如技能蓄力百分比action_space换成Box(low0, high1, shape(2,))再用np.digitize离散回技能 ID这时候 PPO 比 DQN 更合适。改完先跑eval.py100 局胜率低于上一版基线就不往下走。改造目标动哪个文件验证方式换游戏 / 补接口endpoints.pyapi.py单接口脚本跑通再跑 20 局换算法train_ppo.py100 局评估胜率对比基线改奖励reward.py离线重算历史快照 100 局评估加监控storage.py 界面日志查 sqlite 里的胜率按天趋势加监控是我最推荐的第一个改造。storage.py里已经有一个写 sqlite 的架子把每局的stage_id、win、steps、model_version落进去再写个十行的 pandas 脚本按天聚合胜率。模型更新之后胜率掉了你至少知道是哪天开始掉的而不是靠记忆。最后说个教训。早期我改奖励函数改得很随意改完直接开训看训练回报涨了就当成功。有一次连着换了三个奖励版本胜率从 62% 掉到 41%回头根本查不出是哪一版引入的问题只能一版一版重训两天时间没了。从那以后我每次动reward.py都强制先跑一遍 100 局离线评估把胜率和平均回合数记在文件头注释里再决定要不要继续。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →