用 AI Coding + 强化学习,打造一个漫威终极逆转对战 AI
—— 算法、工程,与驾驭 Claude
一套用 AI Coding、从零训练出一个卡牌对战 PVP AI 的完整记录,分三部分:算法 —— Transformer 状态编码、决策注意力指针头、公平对手与课程学习,如何让它在一个不完全信息、动作组合爆炸的对抗环境里打到超过中高段位人类玩家;工程 —— 几十台机器的分布式训练、模型热替换、可信度量,怎么把它稳稳跑起来、数字信得过;驾驭 Claude(人在回路) —— 在 AI coding 协作里,人类的几次关键判断怎么把走偏的 AI 拉回正轨。中间还穿过一堵困了我们很久、最后发现根本不是"算法墙"的墙。
这篇分享分三部分。第一部分 · 算法讲怎么把一个会打牌的 AI 训出来 —— 重点是两件容易被忽略的事:为什么对手的"公平性"比"强度"更重要,以及一个看似无关紧要的动作建模缺陷如何伪装成一堵"能力上限"。第二部分 · 工程讲怎么让它在几十台机器上稳稳跑起来、数字信得过。第三部分 · 驾驭 Claude讲在 AI coding 时代,人类怎么把走偏的 AI 在关键节点掰回正轨 —— 也就是 AI coding 里的"人在回路"。下面是目录。
算法:把一个会打牌的 AI 训出来
这一部分讲算法本身:从把一局牌面编码成什么、用什么网络,到为什么对手的"公平性"比"强度"更重要,再到那堵伪装成"能力上限"、其实是动作建模缺陷的"墙"是怎么被穿过去的 —— 最终打到超过中高段位人类玩家。
为什么卡牌对战是个难啃的 RL 问题
训练的目标环境是一个回合制集换式卡牌对战:双方各用一套 12 张的卡组,对局共 6 个回合,每回合能量从 1 递增;场上有 3 个地点(路),每回合双方同时把牌面朝下打到某个地点,然后一起翻开结算;占下 2 路者赢。听上去简单,但对 RL 来说它同时把几个最棘手的特性叠在了一起:
它是一个 不完全信息 + 组合动作 + 高方差 + 长程依赖 的对抗博弈。任何一条单独都不算新鲜,难的是四条同时在一个不到 60 维动作空间里纠缠。
系统总览:训练端、数据通道、训练集群
整体是一套经典的分布式 PPO:少量 GPU 在中心做策略更新,大量纯 CPU 机器在边缘并行产对局数据,模型以 ONNX 为统一载体在两端流转。
(状态, 动作, 回报) 轨迹打包。几个值得强调的工程选择:
- 双语言、单载体。训练侧是 Python(PyTorch + PPO),推理侧(训练对局、评估、线上)是 C#,两端通过 ONNX 对齐。轨迹数据有一套跨语言序列化契约,保证 C# 写出的样本 Python 能逐字段还原。
- worker-pull 数据通道。副机只做"单向出站 HTTP POST"上报轨迹、并主动"拉"模型,不在副机上开服务端口。新机器自注册、撤机靠心跳自动摘除,集群规模可弹性增减。
- 推理用游戏引擎当"免费的、零误差的环境模型"。这点在第 4、7 节会变成关键 —— 训练对局里每打一张牌都真正落进引擎、刷新状态,而不是让网络去"脑补"出牌后的局面。
状态表示与神经网络
核心网络是一个把"局面"编码成实体 token 序列、再用 Transformer 做关系建模的 actor-critic。规模约 15M 参数(实测约 15.1M;hidden 384 / 10 层 / 12 头 / FFN 768)—— 刻意走"小而专",验证范式靠的是博弈深度而非参数堆叠。
为什么选 Transformer
这不是赶时髦。先看清楚"状态"到底长什么样,架构选型几乎是被它逼出来的 —— 一局牌面本质上是一组带类别、无固定顺序、数量还会变的实体(手里几张牌、场上哪些卡、3 个地点),而胜负又高度依赖实体之间的关系(这张牌配那个地点、combo 卡彼此的协同、我方与对方的对位)。把这几条诉求摊开,候选架构里只有一个全中:
| 候选 | 建模实体间关系 | 无序集合(置换等变) | 变长输入 | 动作头自洽 |
|---|---|---|---|---|
| MLP | ✕ 拍平成定长向量、丢结构 | ✕ | ✕ 定长 | ✕ 外挂 |
| CNN | △ 只擅长局部空间 | ✕ | ✕ | ✕ |
| RNN / LSTM | △ 需强加一个顺序 | ✕ 顺序敏感 | ✓ | ✕ |
| Transformer | ✓ 注意力建模两两关系 | ✓ 顺序不敏感 | ✓ padding mask | ✓ pointer 头 |
三点正好命中:① 注意力就是为"实体两两之间谁影响谁"而生的;② 牌和卡是一个无序集合,不像图像有空间网格、也不像文本有先后,模型靠 token 的"类别 embedding"而非位置来区分它们,天然置换等变,不会强加一个并不存在的顺序;③ 手牌数、场上卡数随对局变化,padding mask 直接吃下变长。MLP 要先把局面拍平成定长向量、当场丢掉结构;CNN 假设的是局部空间平移不变(3 个地点并非图像网格);RNN 则要硬给一组无序实体安排先后 —— 三者都和状态的真实形态不合。
选了 Transformer,动作头能原生复用同一套注意力 —— "哪张牌打到哪个地点"本就是"主体 token × 目标 token"的配对,正好是一张注意力图(下一节详述)。换成别的架构,动作就得外挂一个输出层去硬记 58 个动作。也就是说:Transformer 让状态编码和动作决策共用一套语言,首尾自洽。后面会看到,正是这一点让我们最终拆掉了那个困住胜率的瓶颈。
把局面拆成实体 token
不像图像那样喂一个扁平向量,我们把局面里每一个"实体"做成一个 token,让注意力机制去学它们之间的关系(这张牌和那个地点、我方和对方)。序列大致由这几类 token 组成:
关键不是"有多少 token",而是它们的索引和后面的动作语义一一对齐:场上卡槽用 loc×4+c 编址,这套编址在状态编码器和动作解码器里是同一套坐标。这个对齐让下一节的"动作即注意力"成立。
两个输出头:策略 + 价值
- 价值头(critic)从全局 token 读出当前局面的胜率估计
V,用于 PPO 的优势估计;它同时也是对局中"要不要加倍/撤退"这类元决策的天然信号。 - 策略头(actor)不是普通的
Linear(58),而是一个"决策注意力指针头"—— 这是这套方案在网络结构上最不一样的地方,单独拎到下一节讲。
决策注意力指针头:动作空间即注意力图
这个游戏的合法动作天然是"主体 × 目标"的结构:把某张牌(主体)打到某个地点(目标),或把场上某张牌(主体)移动到某个地点(目标)。既然结构本身就是一张"谁指向谁"的图,那它就该用注意力来表达,而不是把它拍平、外挂一个全连接输出层去硬背。
于是策略头直接复用 Transformer 的 scaled dot-product attention,做成一个 pointer network:
这样设计有三个好处:
- 结构归纳偏置正确。"哪张牌去哪条路"本就是"主体 × 目标"的配对,注意力天生就在算这种配对。早期版本恰恰相反 —— 把 19 个主体和 3 个目标先 mean-pool 压平、再用一个全连接层硬背 58 个动作,这正是第 3 章说的信息瓶颈;换成 attention pointer 才把它拆掉。
- 不丢 per-token 信息。早期一版用 mean-pool 把所有 token 压成一个向量再过
Linear(58),这一步把每个 token 的细粒度信息压平了,事后复盘认为它是一个长期存在的信息瓶颈。换成 per-token 注意力后瓶颈解除。 - 合法性由引擎兜底。每一步出牌前,从游戏引擎取一份合法动作掩码(地点满了、移动是否合法……),盖在 logit 上。网络只在真正合法的动作里选。
一回合出多张牌:step-wise 自回归
一个回合可以连续出好几张牌。我们不让网络"一次吐出一整套组合"(那是带顺序的排列爆炸,训练算力撑不住),而是 逐步决策:
连招的收益依赖"前一张牌已经生效"的真实局面。让引擎做这个状态推进,等于用零误差的真环境替代了网络对动力学的脑补。网络只需回答"下一张打哪",combo 的因果链由引擎保证。代价是一回合要多次前向,但每次都是一次干净的小决策。
公平性设计:战争迷雾 + 公平对手
这套方案最核心的一条原则不是某个网络技巧,而是:训练价值 = 对手的公平性 > 对手的强度。模型最终面对的是公平、同时决策的真人;如果拿一个"会偷看你出牌"的对手来训练,会同时坏两件事 —— 既把策略逼崩,又教出一身在真实对局里用不上的歪招。
① 战争迷雾:把观测做成真实视野
观测编码强制走"部分可观测"(POMDP)口径,把真实对局里玩家本就看不到的信息从神经网络输入中抹掉:
• 双方已翻开、生效的场上牌
• 3 个地点及其效果
• 当前回合、双方能量、总分
✕ 对手的牌库
✕ 对手本回合刚打下、尚未翻开的牌
✕ 自己牌库里未抽牌的顺序
② 公平对手:强,但不偷看
训练对手用的是脚本化的贪心基线(scripted greedy)。同一套贪心引擎、同样 80% 概率选最优招(probDist = {0.8, 0.1, 0.05, 0.05}),唯一被改掉的是它的"出牌时机假设":
| 对手类型 | 决策方式 | 是否公平 |
|---|---|---|
| 看牌型(AfterOpponent) | 等你把牌打下、看到你已 staged 的牌,再决定自己怎么出 | ✕ 作弊(真实对局双方同时下牌,看不到) |
| 公平型(OnStart) | 每回合假设对手不出牌,只基于公开局面暴力贪心,不偷看你本回合的牌 | ✓ 公平(即真实 PVP 的同时决策) |
在更早的阶段,训练对手会偷看本回合出牌。结果是:大部分对局变成"信息劣势下怎么打都输",策略梯度长期拿不到"哪个方向更好"的有效信号,理性的收敛结果就是熵塌缩(孤注一掷某一路)。而且这种"防全知对手"的扭曲策略,搬到真实对局完全用不上。把偷看从训练里剔除,是后续一切突破的前提。
当然,公平也意味着"上限有限"—— 一个 80% 选最优、但不偷看、也不做跨回合规划的贪心脚本,棋力天花板大致就在五成胜率附近。突破它的常规思路是自博弈(让模型和自己的历史版本互搏,制造"够得着又打得过"的对手阶梯)。不过在当前这个单卡组的正式训练里,对手池全部是公平脚本梯队(Random + 三档公平贪心),自博弈作为保留的后续方向尚未启用 —— 而结果证明,仅靠"公平对手 + 把动作建模修对"就已经把那堵墙穿过去了(见第 7、8 节)。这本身是个意外却有价值的结论:墙不在对手强度上。
训练方法:PPO + 课程学习 + 双轨评估
PPO 主循环
策略优化用 PPO(clipped surrogate + GAE 优势估计 + 价值/策略联合损失;AdamW 优化器、BF16 混合精度、6 卡 GPU 数据并行)。关键超参(生产实际值):
| 超参 | 值 | 超参 | 值 |
|---|---|---|---|
| 裁剪系数 clip ε | 0.15 | 学习率 | 1e-4 |
| GAE γ / λ | 0.99 / 0.95 | batch / minibatch | 512 / 512 |
| 每批 update epochs | 4 | 价值损失系数 | 0.5 |
| 熵正则 ent_coef | 0.045(固定) | target KL | 0.04 |
| 梯度裁剪范数 | 0.5 | 总训练步 | 6 亿 |
我们曾上线一个"自适应加熵"机制:熵一低就自动加大熵奖励、想把探索顶回去。实测它两次把训练推向死亡螺旋 —— 在连招/对抗阶段,低熵其实是"策略在收敛到确定的正确打法",强行注熵反而用大梯度搅碎已学到的策略,KL 失控、胜率崩盘。最后改回固定熵系数(0.045)+ KL 自适应学习率兜底。教训:熵低不一定是探索枯竭,要先分清是"塌缩"还是"收敛"。
课程学习:让对手难度跟着进度爬坡
对手不是一上来就最强。课程按训练进度(已消耗的样本步数 / 目标步数)推进,逐阶段提高强对手(Expert)占比,把模型推向真实对局里最常见、也最难赢的分布。下面是当前单卡组正式训练采用的 4 阶段配置:
best checkpoint:一个抗噪声的"对 Expert 加权"指标
"哪个 checkpoint 最好"由训练侧自己评出,而不是手动挑。指标对 4 档公平基线的胜率加权,且每一项都取 Wilson 95% 置信区间下界(而非裸胜率,抗采样噪声):
轨道 ①(公平锚):只用公平对手,进入 best 指标,给一个不掺水的训练进度读数。轨道 ②(人类对齐):单独跑一个会偷看牌的满血强对手,不进 best,仅用来和"人类玩家在同样劣势下的胜率"做横向对标。一句话:偷看牌是"量人类水平的标尺",不是"训练材料"。
关键突破:那堵墙其实是"动作残废墙"
在很长一段时间里,无论怎么调,模型对强对手的胜率都卡在 五成 上不去。两条完全相反的技术路线 —— "大模型多任务通才"和"小模型单任务专才"—— 本该一个赢在容量、一个赢在专注,结果撞在同一个数字上,而且都在能力接近上限时熵塌缩。
一个 bug、一次调参失误,不会让两条对立的架构长期停在同一个数字上。我们一度把它归因为"任务定义本身的上限"。直到回头审视一个最基础、却从没被怀疑过的环节:动作到底是怎么映射的。
真凶:出牌和"结束回合"被捆死了
排查发现,早期的动作映射里,"出一张牌"这个动作被和"结束回合"绑定在了一起。后果是致命的:
反复撞的五成墙,很可能根本不是"任务难度墙",而是"动作残废墙" —— 一个每回合只能出一张牌的智能体,结构上就打不过出满牌的对手,再怎么调网络、调超参都没用。把动作建模修对、配上前面讲的"决策注意力指针头 + step-wise 多动作"之后,胜率立刻开始单调往上爬,一路穿过那个数字。
这也解释了为什么之前的所有努力像在原地打转:我们一直在错误的地基上换姿势。网络结构、对手公平性、课程设计都没错,但底层的动作语义残缺,让上层一切优化都被封顶。
结果:单调穿墙,打到超人类
修复后的模型(决策注意力 + step-wise 多动作 + 公平对手 + 战争迷雾)以冷启动从零开始训练,对公平强对手的胜率单调爬升、不卡顿、不塌缩,干净利落地穿过了那堵五成墙。
对标人类:同口径下超过中高段位人类约 12 个百分点
把模型放进"和人类完全相同的劣势"下(自己看不到对手 = 迷雾,对手会偷看自己 = 满血看牌强对手),它的胜率超过了会和 AI 对战的中高段位人类玩家:
这把"中高段位人类 49.3%"不是估出来的,而是从大规模线上真实对局里、用相同对手和相同信息劣势测出来的锚点。把它和模型的完整战力谱并排,更清楚它站在什么位置:
模型完整战力谱(best / argmax · 迷雾口径)
| 对手档位 | 胜率 |
|---|---|
| Random | ~97% |
| 公平 Beginner | ~84% |
| 公平 Intermediate | 81% |
| 公平 Expert | 76% |
| 满血看牌 Expert(最难) | 61.6% |
同一对手下,线上人类的真实胜率
| 对战方 · vs 满血看牌 Expert | 胜率 |
|---|---|
| 线上新手段人类 | ~45% |
| 线上无限段 · 中高段位人类 | 49.3% |
| 本方案 RL | 61.6% |
口径与诚实标注:① 两表胜率都在"自己看不到对手、对手偷看自己"的相同迷雾劣势下,对手也是同一个"满血看牌 Expert",故可比;② 线上人类这条胜率其实是非单调的 —— 对手卡组会随玩家段位变强,段位较低的 rank 50–99 玩家面对的 AI 卡组偏弱、一度能到约 63%,这里取段位更高、对手同为满血强档的"无限段中高段位"玩家作锚;③ 因此结论是"超过会和 AI 对战的中高段位人类约 12pp",而非"超过世界顶尖"(顶尖玩家在匹配机制下基本不与 AI 对战)。
(best / argmax)
超过中高段位人类
反复塌缩的死亡点
~60MB
扛过历史"鬼门关"
历史上每次熵塌缩都精确发生在训练进度约 50%(强对手占比把期望胜率压破临界点的那个位置)。这一次,模型在同一个进度点上 熵 0.97、KL 0.007,对公平 Expert 仍稳在七成以上的高位,没有任何回落。这从反面印证了一件事:之前的塌缩是"任务被定义成怎么都赢不了"逼出来的数学必然,而不是 PPO 本身不稳定。把对手修公平、把动作修完整,塌缩就消失了。
推理性能(线上部署参考)
| 硬件 | 单次推理 | 单核 QPS |
|---|---|---|
| 服务器 CPU 单核(Broadwell 至强) | 60.5 ms | 16.5 |
| 桌面 CPU 单核(i7-12700F) | 44.0 ms | 23 |
| 入门 GPU(RTX 3050 · batch=1) | 9.2 ms | 108 |
单段 Transformer ONNX,约 60MB。一回合多动作(出 3–4 张)= 4–5 次前向,CPU 端约 250–300ms 完成整回合决策;GPU 的真正优势在多对局并发 batch。
方法论沉淀
如果说这套方案有什么能迁移到别的 RL 项目的经验,大概是这三条 —— 它们都不是"用了什么 SOTA 技巧",而是"避免了什么自欺"。
从"卡在五成"到"超过中高段位人类",中间真正起作用的不是某个漂亮的网络模块,而是把三件朴素的事做对:把动作建模建完整、把对手做公平、把尺子量准。剩下的,交给 PPO 和时间。
分布式工程实现
第一部分讲的是"AI 怎么学会打牌"。但要让它真的学得动,背后是一整套分布式工程:这套训练的本质,是用一大批廉价 CPU 不停产对局、喂给少数几张 GPU 更新策略 —— 这套基础设施决定了能不能、以及多快喂饱 GPU。下面是 5 块工程硬骨头,以及途中几次"从复杂退回简单"的取舍。
副机集群:两层进程栈 + 弹性自注册
N 台纯 CPU 副机要做到"插上电就自动加入、撤掉自动退出、崩了不连累训练"。做法是把每台副机的进程拆成控制面 + 数据面两层、全部对主机单向出站,主机端只维护一张内存里的活机表。
worker-pull:副机不开任何端口
副机与主机之间只有出站,副机上不监听任何端口 —— 三条控制出站(POST /heartbeat 注册+保活、GET /model/latest 拉模型、GET /progress 拉课程进度)+ 一条数据出站(POST /trajectory)。这让云主机、容器只要能出网就能接入,对 NAT / 防火墙极友好(为什么非得是这个方向,第 12 章有一段血泪史)。
弹性自注册:一张内存活机表
- 副机首启先
POST /register领一个 worker 编号(编号按 host 持久化、同机重启不漂移;克隆机靠boot_id+ 主机加随机后缀去重,避免共享 IP/MAC 的容器撞号)。之后每秒一次心跳。 - 主机端的活机表纯内存、零磁盘 IO,靠心跳驱动:30 秒(≈ 30× 心跳间隔,留足"漏一拍不误删"的余量)没收到心跳就把这台 lazy 剔除。加机器 = 自动出现,撤机 = 停掉进程、主机超时自动摘除,全程不用碰主机配置。
两个朴素但关键的取舍
⌊逻辑核数 × 90%⌋,异构机群(40 线程到 192 线程)自动适配,留 10% 给 agent/main 和系统。resume;只保留"进程内自愈"(supervisor 看子进程、agent 断线重连)。理由:worker 是无状态弹性资源,自动拉起反而会和部署脚本抢进程、让"老镜像复活"。三层运维命令体系
所有运维收口到三个入口,跨机层不写业务逻辑、只 SSH 进去调本机脚本 —— 保证单台机器的逻辑只有一处真源、不漂移:
| 入口 | 位置 | 职责 |
|---|---|---|
snap_worker.sh | 副机本机 | 副机进程栈的唯一控制入口:resume / stop / newtrain / remove / purge / restart-agent |
snap_master.sh | 主机本机 | 主机服务的统一门面(trainer / dashboard / server),转调既有工具不重写启停逻辑 |
cluster_ops.py | 控制机跨机 | SSH 下发,内部就是去调上面两个本机脚本(含批量 newtrain/resume、查全局状态) |
模型 A/B 热替换:进程级蓝绿 + 一局一致性
主机每 ~30 秒导出一版新模型,副机要在不中断对局的前提下换上去。我们没有在推理进程里"重载 session",而是做了一套进程级蓝绿(blue/green)双代切换 —— 这背后还藏着一条 PPO 算法正确性的硬约束。
落盘:单份文件 + 原子替换
副机 agent 每 5 秒用 ETag(= 文件大小-修改时间,只 stat 不读内容)问主机有没有新模型:没变返 304 不下载,变了才拉、sha256 校验通过后写到 latest.onnx.tmp,再 os.replace 原子改名成 latest.onnx。单份文件、原子替换 —— 这一步只保证"推理进程永远读不到写一半的文件",真正的 A/B 不在文件层,而在进程层。
切换:先起新代、再杀旧代、旧代跑完当前局
推理进程(dotnet selfplay)里的 ONNX session 只在启动时加载一次、整个生命周期不变,进程内没有任何"换模型"逻辑。真正盯着 latest.onnx 变化的是 shell supervisor:每 5 秒 stat 一次修改时间,变了就 ——
- 先用新模型起一整代新进程(gen A↔B 交替);
- 再给旧代发
SIGTERM,旧代跑完手头这一局再退出(30 秒宽限,超时才强杀,避免丢正在写的轨迹); - 新旧重叠 5–30 秒(这段副机短暂跑 2× 进程,靠内存富余扛);两次切换间至少冷却 180 秒,防止模型频繁更新导致旧代还没退完就被下一轮打断、进程越积越多。
换模型只发生在局与局之间,一局自始至终用同一个模型版本。深层原因不是工程图省事,而是算法正确性:PPO 的每条轨迹都记录了"旧策略在每一步选这个动作的概率 log πold(a|s)",更新时要算 πnew/πold 这个比值。如果一局前半段用旧模型、后半段用新模型,记录的 log_prob 就和实际动作来自两个不同的策略,比值算错、梯度学歪。所以"一局一致性"是被 PPO 逼出来的,而进程级切换天然满足它。
版本闸门:让"模型版本"和"数据新鲜度"闭环
每个推理进程在加载模型时算一个版本号 sha256前16位_时间戳,盖进它产出的每一条轨迹。主机这边维护"最近 100 个导出版本"的集合,轨迹的版本号不在这个集合里就丢弃。于是形成闭环:主机导出新模型 → 副机换代 → 新代轨迹带新版本号 → 主机版本窗口右移 → 旧代轨迹的版本号滑出窗口、被丢。这套以"版本成员资格"(而非步数算术)对齐的闸门,正好挡掉卡死的旧进程产的陈旧 off-policy 数据 —— 下一章你会看到它有多重要。
数据通道的三代演进
"副机怎么把轨迹送到主机"这件事,我们换了三代方案。每一代都是被上一代的真实痛点逼着改的 —— 这段演进本身就是一份"分布式数据通道避坑指南"。
第一代 · PUSH:被防火墙打回来
最直接的想法:副机主动把数据 push 给主机。设计、压测全过,真上灰度却当场失败 —— 主机的云安全组只放行了开发机 IP,副机经 NAT 出去的出口 IP 被直接 RST(主机网卡上的入站包计数是 0,说明包在上游就被挡了)。改安全组是跨团队大工程、还有多租户隔离风险,短期无解。
第二代 · streaming_pull:主机反向去拉
既然"副机连主机"被堵、"主机连副机"通,那就反转发起方向:主机在 GPU 进程里起一批异步客户端,主动连到副机的数据端口去拉轨迹(数据方向还是副→主,只是谁先握手反过来)。实测吞吐冲到 1020 局/秒(3.2× 老方案)。但痛点也实在:副机在 NAT 后面,主机要为每一台副机建 SSH 隧道、在一份 hosts 清单里登记,加机器就得改清单、重启通道,扩容高度手动、易错、还中断训练。
第三代 · worker-pull:副机单向出站,主机被动收
终态又把方向转回来,但这次彻底想清楚了:副机全程单向出站 HTTP,主机只当一个被动的单端口 server。控制面(心跳/模型/进度)和数据面(POST /trajectory)共用一个外网端口 + token 鉴权 —— 既绕开了第一代"入站被防火墙挡"的死结(只需放行一个端口),又干掉了第二代"每台建隧道 + 手动登记"的运维负担(心跳自注册)。而且这个 server 就跑在 GPU 训练进程的 rank0 线程里,收到的轨迹直接塞进同进程共享的队列、PPO 主循环直接消费,零跨进程 IPC。
① 防火墙友好:副机不开端口、只放主机一个外网端口;② 自注册弹性:心跳即注册、超时即摘,零主机介入,为镜像化批量扩容铺平路;③ 单端口集成:控制面 + 数据面一个 server,干掉隧道和独立同步通道;④ 零 IPC:rank0 收数据直接入队、主循环出队,没有多进程队列的拷贝开销。
吞吐优化的 A/B 实验:一次诚实的回退
这一章讲一件"做对了又撤回"的事。两组优化各自都有实测正收益,最后却被全部删掉 —— 因为它们换来的吞吐,悄悄偷走了数据的新鲜度。
诊断:6 张卡,只有 1 张在干活
先说 6 卡 DDP 怎么分工:只有 rank0 那张卡收数据,收到一批轨迹后广播给所有卡,每张卡只取属于自己的那条条带(envelope[rank::6])各自前向反向、再 all-reduce 同步梯度;另有两个 lock-step 同步点(轨迹量门取全局最小、minibatch 数截齐)防止快慢不一导致 desync。正因为"收数据"这件事压在 rank0 一张卡上,性能剖析才暴露一个刺眼现象:GPU0 利用率只有 8%,其余 5 张 92–100% —— 数据接收、解压、模型导出全挤在 rank0 这一个进程里串行,另外 5 卡在 NCCL 上空等它。三处 CPU 瓶颈:每 10 步一次的 ONNX 导出(占 rank0 主线程 58%、单次 5.5–6.8 秒、偶发 60 秒)、轨迹解压抢 GIL(接收线程 79.5% 忙)、广播序列化。结果消费速度 < 生产速度,接收队列长期撑满、丢弃约 30% 的数据。
两组优化,各有实测收益
两上两下:为什么还是全删了
| 节点 | 动作 | 结果 |
|---|---|---|
| 第 1 上 | A+B 两组同时上线 | 吞吐指标漂亮 |
| 第 1 下 | winrate 阴跌,全面回滚 | B 组把"原本与训练重叠的解压"挪到消费侧变串行,消费率反降 33% |
| 第 2 上 | 只上 A 组 + 数字化 SLA 告警 | 队列首次不再撑满,消费追平生产 |
| 第 2 下 | 再次回滚、彻底删除代码 | 根因锁定在数据新鲜度 |
异步化 + 步速加快,让副机用的模型落后了 12–14 个版本(同步时只落后 5 个)。PPO 是 on-policy 算法,数据越旧、重要性采样的偏差越被放大 —— winrate 从 0.42 一路阴跌到 0.354、4 个基线同步下滑,但 entropy / KL / clipfrac 全都正常,盯 entropy 的崩溃防线根本抓不到。
"PPO 指标正常 + winrate 单边跌" = 去查数据新鲜度,不是去调超参。entropy / KL / clipfrac 只反映"每次更新了多少",反映不出"更新的方向被旧数据带偏了"。后来正是靠第 11 章那套版本闸门(只收最近 100 个版本)把新鲜度钉死。顺带一提:同期另一个独立优化 —— 把 minibatch 从 256 加到 512、吞吐 +52% —— 因为不碰新鲜度,被保留了下来。
Model Mirror:把出口压力收成一对一
N 台副机每隔几秒就来主机拉一次模型,模型更新那一瞬间它们会同时涌来。一台镜像中继把这股扇出压力从主机卸到自己身上 —— 它几次"从复杂退回简单"的演进,比设计本身更有看头。
为什么要它
主机给副机发模型,出口 = N × 约60MB。真正烧带宽的不是稳态(95% 的请求因 ETag 没变直接返 304、不传字节),而是模型刚更新那一瞬间 N 台同时来取的尖峰。中继节点自己用一个 puller 每秒从主机拉一次最新模型、缓存到本地,副机改向它要 —— 主机的模型出口就从 N×60MB 收成 1×60MB。而副机的拉取协议一行都不用改(见下文"协议对齐")。
进程拓扑:1 个拉取 + 5 个服务,靠内核分发
中继内部是 1 个 puller_daemon(独占地从主机拉、原子写本地缓存和状态)+ 5 个纯服务进程。5 个服务进程用 SO_REUSEPORT 绑同一个端口、由内核按连接轮询分发 —— 既绕开单进程 GIL 的吞吐天花板,又对副机表现为一个端口。进程之间不用共享内存、不用 Redis,只用原子文件改名同步状态(写 .tmp 再 rename,读者要么看到旧的、要么看到新的,永不读到撕裂的半截)。
最初的设计想让 nginx 用内核 sendfile 零拷贝直接吐 60MB 文件、服务进程连文件都不碰。听上去完美,实跑却败给一个协议细节:nginx 的静态文件处理器会自己算一个 ETag、并丢掉上游带的 sha256 校验头 —— 副机每次看到的 ETag 都不一样、条件请求永远不命中,于是每次都全量重下,30 分钟烧掉 77GB。最终选择放弃零拷贝、保协议正确:反正 95% 的请求是 304 无 body,零拷贝省的那点根本不在关键路径上。连带蓝绿部署也一并简化掉了 —— 有了 5 个 SO_REUSEPORT 实例,单进程重启那 ~5 秒停机完全可接受,不必再搞复杂的端口接管。
协议对齐:副机零改动
镜像设计的硬约束是副机那套拉取逻辑一字不改。做法是:中继和主机复用同一个函数算 ETag(= 大小-修改时间)和 sha256,三端(主机 / 中继 / 独立 model server)返回的响应头字节级一致。副机在"主机直连"和"走中继"之间切换感知不到差别 —— 启动时它按"内网中继 → 公网中继 → 主机"的顺序探一次可达的源,自动选最近的一条路。
中继节点本身是台 16 核 / 31GB 的普通机器,无 sudo、无 systemd(用 nohup 守护、pip --target 装依赖)。它是全链路里唯一破例配开机自启的节点 —— 因为它是中心服务,不像无状态的副机可以"挂了人工拉起"。
断点续训:让 6 亿步训练随时停、无损接
一次正式训练要跑 6 亿步,期间机器会重启、训练会异常、超参要调 —— 全程不可能一气呵成。所以"随时停下、无损接续"不是锦上添花,而是长跑的生命线。
一个文件接住全部状态
恢复训练时,一个 state.pt 一次性接住五样东西:模型权重、Adam 优化器状态、全局 step、已消耗 timesteps、课程进度。少任何一样都会出岔 —— 丢了 Adam 动量会让 KL 短期飙高,丢了 timesteps 会让课程阶段算错、对手难度跳档。接续成功的标志很明确:日志打出 HOT RESUME: starting from step=N,train.jsonl 从 N 接着写而不是从 1 重来,课程进度不归零。
启动器带"验收后置条件"
启停训练不靠手敲 ssh + nohup torchrun(那是"看着起来了、其实静默失败"的经典坑),而是统一走一个启动器,把停旧 → 清 GPU 残留 → 拉新 → 验收做成一条流程。验收是硬性后置条件,任一不满足就非零退出、绝不假装成功(见上图)。万一 state.pt 缺失,还有三层 fallback:退化成"只恢复 step 计数"(从最新权重按 step 数字排序提取,Adam 重新开始、接受 KL 短期略高),保证训练能续上而不是从零再来。
配置即代码:SSOT + 冻结快照
训练超参由三份 yaml(trainer / worker / curriculum)做单一真源,全集群 sync 后 sha256 校验、不一致就中止。但有一个反直觉却关键的设计:续训时超参从 run 目录里的"冻结快照"加载,而不是当前 yaml。
因为训练中途万一有人改了 yaml(哪怕只是手滑),续训若直接读最新值,超参突变会让 PPO 发散。冻结快照在训练启动那一刻把超参定死,保证一次 run 全程超参自洽。要正经改超参,得显式"同步快照 + 热重启"才生效 —— 把"改配置"变成一个有意识、可追溯的动作,而不是随时可能污染长跑的暗雷。
可信的度量:把"尺子"当地基来建
第一部分讲过那个最痛的教训:项目前几个阶段栽在一把系统性高估的尺子上,长期在假信号上盲调。所以这套方案把"度量准"当成和算法同等重要的地基 —— 每一层都在防一种"自欺"。
胜率统计:宁可低估,不可高估
- 滑动窗口 + Wilson 置信区间下界:胜率取最近 1000 局的 Wilson 95% CI 下界而非裸胜率 —— 样本少时下界自动保守,防"手气好"被当成"变强了"。
- 平局算非赢:平局计入分母、不计入分子,只会拉低胜率、不会抬高 —— 一个一律往保守方向偏的口径。
- 与训练解耦:三个独立窗口(总体 / 按己方卡组 / 按对手类型)每 3 秒刷一次,节奏和 PPO 更新分开,互不阻塞。
best checkpoint:一个只升不降的棘轮
"哪个模型最好"由训练侧自动评、而非手挑。它沿用第 6 章那个 Expert 加权指标(4 档基线的 Wilson CI 下界加权 + 两道闸门:≥800 局才评估、+1pp 才更新),这里只补它作为"可信度量"最关键的一点 —— 做成棘轮(ratchet):
- 只升不降:metric 回落时 best 不退,峰值模型已存住,所以哪怕后面训崩,最好的那一版也丢不了。代价是 best 经常"很久不更新" —— 那是设计内的 noop,不是 bug。
- 责任分清:DDP 的 rank0 负责写 best、评估侧只读,写文件走原子替换,避免多进程互相踩。
CI 下界防运气、棘轮 + 双闸门防噪声促销、双轨评估(公平锚进 best、满血 Expert 只对齐人类)防口径漂移、版本闸门(第 11、13 章,只收最近 100 个模型版本产的数据)防陈旧数据。一个数字要骗过这四层才会被相信 —— 这正是第一部分"先把尺子量准、再谈优化"的工程兑现。
回看这七章会发现两条主线交织。一是朴素往往赢过聪明:数据通道从反向拉退回单向出站、mirror 从 nginx 零拷贝退回普通流式响应、部署从蓝绿退回简单 restart、吞吐优化整组被删回基线 —— 分布式系统里,正确性、可运维性、可观测性的权重往往高于某个局部的性能最优。二是可靠与可信是地基:断点续训让长跑随时停、无损接,冻结快照防超参漂移,那一整套"宁可低估"的度量防线把"数字能不能信"做实。能把数据稳定、新鲜、不丢地喂给 GPU、并让每个胜率数字都经得起推敲,比把某一段做到极致快重要得多 —— 这和第一部分"先把尺子量准再优化"是同一个道理。
驾驭 Claude:人在回路的决策与纠偏
前两部分讲"做了什么"。这一部分讲"是谁、在哪些节点、把方向掰对的"。我们翻遍了项目全过程、2858 条人类发言的原始对话记录 —— 高频出现的不是技术术语,而是几句很朴素的话:"搞复杂了""感觉不对劲""你别走歪方向了"。下面是中肯的复盘:AI 容易在哪儿栽,人类的判断在哪些节点救了场,以及在 AI coding 时代,人类的价值到底在哪。(下文引用均为对话原话,仅清理明显笔误。)
"搞复杂了":把 AI 一次次拉回简单
整个项目里,AI 反复表现出同一种倾向 —— 把事情做复杂:加一堆特殊逻辑、搭多套机制、追求"完备"。而人类几乎每隔一阵就得把它拉回来一次。最值得记下的是:项目里几个最终采用的核心架构,恰恰是这样"拉简单"拉出来的。
→ 这一段话几乎就是第二部分那套 worker-pull(副机单向出站、心跳自注册摘机)的设计说明。AI 当时在往更复杂的双向方案走,人类一段话定了方向。
→ AI 本来用 override hook + 藏牌一堆特殊逻辑,被拉回"做成一等公民 AI 类型",hack 全删(第 5 章那个公平对手就是这么来的)。
→ AI 一度想搞"自回归、出一张再推理一张"的复杂结构,人类点破"就是一次输出动作数组"。
→ 第 10 章那个 ⌊核数 × 90%⌋,是人类纠正出来的,不是 AI 拍的固定值。
AI 的本能是"完备、通用、不遗漏",于是容易过度工程化 —— 多搭一层抽象、多处理一个边界、多留一个开关。人类那句"搞复杂了"也不是图省事,而是抓住了"这个场景到底需要什么"。结果被反复验证:被拉简单的方案,往往更对、更稳、更好维护。这几乎是整个项目工程侧的一条隐形主线。
看见 AI 看不见的:常识、直觉与怀疑
AI 的视角是代码和数据。它常常看不见三样东西:这游戏到底怎么玩、这个数字凭直觉对不对、这个结果是不是"好得不正常"。而项目里几次最关键的转折,恰恰是人类用这三样东西换来的。
① 领域常识:一句话点破那堵反复撞的墙
困扰 phase1–4 的"五成墙",AI 和复盘都一度归因到"任务太难 / 对手作弊"。真正点破它的,是一句游戏常识 ——
"那必然的啊,游戏规则本来就可以出多个牌的。" 紧接着: "你在本机 eval replay 跑 100 局,看看是否真的最多只出一张牌。"
AI 把"每回合只出一张牌"当成既定事实分析了很久,却没意识到这违反游戏规则。人类一眼看穿,并立刻要求用 100 局实测验证 —— 真因(动作建模把"出牌"和"结束回合"绑死了)就此浮出水面,模型随后单调穿墙。这是整个项目最大的一次翻盘,靠的不是算法,是常识加实证。
② 直觉警报:"感觉不对劲"
人类对数字有一种 AI 没有的体感。项目里"感觉不对劲"这句话出现过很多次,每次几乎都指向一个真问题:
AI 倾向于相信自己刚算出来的数字、往前推进;人类的体感是一道独立校验 —— 两者冲突时,事后往往是体感对。AI 自己也把这条记进了日志:"用户的体感数字是有效信号,与实测矛盾时优先怀疑自己的采样方法。"
③ 科学怀疑:对"太好的结果"保持警惕
最难得的是对好消息的不轻信。当模型胜率突然变得很漂亮时,是人类先按住了刹车 ——
"这个难以置信,训练白费了;所以我觉得你应该慎重诊断,给出实际证据,包括 eval 和训练的情况,你可以在副集群拿一台机器来打几场,看出的卡牌、动作是否真有问题。"
正是这种"好得不正常就先查清楚"的怀疑,逼出了对数据是否虚高的彻底溯源(最终证明数据真实、提升真实)—— 这恰好是第一部分那个"在失真度量上反复盲调"教训的反面:这一次,没有再让一个可疑的数字带着项目跑。
AI 强在"把给定的问题算到底",弱在"判断这个问题、这个结果本身对不对"。领域常识、对数字的直觉、对异常结果的警惕 —— 这些不在代码和数据里,是 AI 的盲区,也正是人类不可替代的地方。
不只是纠错,更是主导
前两章讲人类怎么"纠 AI 的错"。但翻完整段对话记录会发现,人类做的远不止纠错 —— 更多时候是在主导:每一次重大改动前先审方案、控成本、定目标、盯根治、逼 AI 审查自己的代码。这才是 AI 时代项目主导者的真实工作方式。
① "先出方案我审查"
几乎每一次重大改动前,人类都先把住一道闸 —— 不准 AI 直接上手,先出方案:
AI 又快又能改,最危险的恰恰是"擅自动手、一改一大片"。人类用"先出方案"这道程序闸,守住了每一次重大改动的方向 —— 动手之前先讲清楚要改什么、为什么、影响多大。
② 控成本、定目标
人类始终有清晰的成本意识和量化标准,不让 AI 无止境堆规模:
"我在想阶段二有必要 5 亿场么,1 亿场不行么 —— 卡组大部分还是类似的,场次足够多,泛化能力也强。"
"训练的 expert 达到 70% 胜率、打全卡组胜率能 55% 就足够了。"
"够了"是一个 AI 很难自己拍板的判断 —— AI 的本能是"更多、更全、更稳"。人类清楚这个项目到底要什么、到什么程度就该停,省下的是真金白银的算力和时间。
③ 盯根治,不留烂尾
AI 改完容易留下残留(旧配置、死代码、半完成的清理),人类盯得很紧:
"能跑就行"和"清干净了"之间的差距,往往就是下一个雷。人类要的不是"问题暂时不犯了",而是"根上没了"。
④ 逼 AI 审查自己
最关键的一道把关,是人类要求 AI 回头审查自己的代码和假设,而不只是看"跑通了没有":
"你审查下 RL 的环境感知,输入参数是否都在工作、正常工作,有没有出现阶段三的问题。"
"你要审核下代码本身是否支持、留的余量是否足够。"
第一部分那个"救下 1 亿场训练"的观测验证,源头就是这类要求 —— 人类不满足于"模型在训练",而是逼 AI 去核实"它到底看到了什么、是不是和我们以为的一样"。让 AI 审查自己,是发现地基问题最有效的一招。
这些加起来,是"项目主导"—— 人类没有在执行层和 AI 拼速度(拼不过、也没必要),而是站在决策层:审方案、控成本、定目标、盯质量、逼复查。AI 负责把决策高效落地,人类负责保证"决策本身是对的、落地是干净的"。
关键转折:AI 走偏,人类一句话扳回
前几章分类讲了人类怎么纠偏。但更直观的,是把这些"AI 走偏 → 人类一句话扳回"的瞬间排在一起 —— 你会发现,项目几乎每一个关键转折点,都卡在这么一句话上。下面整列都是对话原话。
| AI 当时的判断(看着都合理) | 人类一句话(原话) | 扳回了什么 |
|---|---|---|
| 副机模型不更新,AI 在各种折腾 | "肯定是有其他 bug 啊,你别瞎整了行不行 …… 文件都不在了,你怎么还在用 etag?" | 一句点破 etag 的真 bug |
| 性能不达标,AI 想加机器、折腾别的 | "GPU 现在没吃满在浪费 …… 你别走歪方向了" | 钉回"先剖析 trainer、提吞吐" |
| 胜率下跌,AI 归因"overtrain / PPO 崩了" | "PPO 指标都正常,怎么会 overtrain?" / "我看胜率还在上涨,PPO 在崩溃?" | 自相矛盾的归因被推翻 |
| AI 读代码、给推断式结论 | "你刚刚是读代码来确定,我要的是实际跑起来、加日志、看实际情况" | 实测才抓到真问题 |
| AI 拿聚合胜率判"行不行" | "拿胜率来评估 ok 不 ok,是颠倒黑白 —— 要看实际跑 2 局的详细日志" | 纠正整套评估方法论 |
| AI 顾虑 fog 会影响"可训性" | "PPO + transformer 本来就支持非全局信息训练,不然都作弊了,那还是强化学习么?" | 把 POMDP / fog 钉成不可动的地基 |
| 长期跟脚本贪心对手磨胜率 | "greedy 是穷举,没 combo、没反制,一直和它磨上限有限" | 点明脚本上限,指向 self-play |
| eval 用 argmax 反而更低,AI 没在意 | "argmax 模式怎么才 36%?数据不对劲" | 牵出训练端度量虚高 |
| AI 判模型"lane 病、过度集中" | "这套牌是删除流,玩法上就该先堆一路打 combo" | 收回误判,承认要懂卡组 |
| AI 给一套自圆其说的论证 | "你别想着糊弄我,这是核心逻辑问题" | 升级为设计级矛盾,重审 gate |
| 阶段四失败,AI 归因到表面参数 | "经历过那么多次调参,我并不认为是这么表面的问题" | 逼出三层根因的彻底复盘 |
| 清完残留,准备直接 cold start 重训 | "先全部停止、清空,然后跟我说;等我说『重新开始 cold start』,你才开始" | 立下授权门,挡住擅自重训丢光进度 |
| 胜率下滑,AI 还在长跑里继续调参 | "你别折腾了,胜率开始下降,这是崩溃前兆" | 当场叫停 + 回滚到 best |
| 为把看板"1000 局"显示成"3000 局"去改 trainer | "只是 web 看板统计,你怎么改了那么多东西?train 你不用改" | 展示归展示,禁止为看板动训练核心 |
| AI 又偷偷另写了一个 yaml | "不是都该统一在一个 yaml 么?你单独写了一个?" | 揪出配置脱钩,统一单一真源 |
| 用整体大盘胜率来挑 best 模型 | "best 不该跟大盘胜率挂钩,应该每个难度的胜率加权" | 纠成 Expert 加权指标(第 6 章那把尺子) |
| 把 cube count 掺进奖励信号 | "核心目标是胜率,cube count 和胜率没关系,去掉 cube" | 奖励钉死纯胜率导向,去掉噪声 |
| AI 自称没改动,却出现不兼容 | "你确定你没改?不然为啥会不兼容?" | 一句反问逼它回查自己的改动 |
| 继续在 random / 弱对手上堆训练量 | "部署用的是 argmax,继续大量喂 random 会浪费模型能力,该投到 Expert" | 从部署口径反推,把算力投给核心强对手 |
每一次 AI 的判断,当时都看着合理 —— PPO 指标确实正常、代码逻辑确实通、数字确实漂亮。但人类有 AI 当时没有的东西:对历史基线的记忆("阶段一到四都 500 g/s 跑得很稳")、对"合理却反常"的警觉("胜率涨着 PPO 怎么会崩")、对"读代码 ≠ 真相"的坚持("我要的是实际跑起来加日志")。几乎每一个关键转折,都卡在人类这么一句话上 —— 这就是"人在回路"四个字最实在的含义。
中肯的分工:谁该做什么
讲了这么多人类的纠偏,必须同样诚实地说清楚:这个项目里大量的硬活是 AI 干的,而且干得不错。把功劳算清楚,才谈得上"分工"。
AI 真实做的事(不缩水)
- 全栈代码实现:跨 C# / Python / ONNX 的观测编码、公平对手、两套多动作架构、跨语言轨迹契约 —— 一次改动常牵动十几个文件,AI 把链路打通、测试写全。
- 实验与诊断:py-spy 性能剖析定位 rank0 瓶颈、上百局 replay 行为评估、百万级采样验证分布;几个关键根因(观测是完全信息、单动作残废、GIL 饱和)的挖掘动作本身,都是 AI 执行的。
- 分布式基础设施:几十台副机的装机、模型分发独立进程、断点续训与回滚 SOP,AI 一遍遍跑、一遍遍验证。
- 诚实留痕:AI 还把自己的判断失误写进日志("单次崩盘归因置信度天然低""单测只验挂载会漏行为 bug"),让协作可审计。
换句话说:人类的怀疑和常识指明方向,AI 把验证做到底、把铁证拿出来。"游戏本来能出多张牌"是人类说的,但"读三处源码 + 跑 100 局 replay + 抓到对手末回合 +228 翻盘的铁证"是 AI 做的。这两件事缺一不可。
那条边界在哪
| 放心交给 AI | 必须人类把关 |
|---|---|
| 写代码、改链路、补测试 | 定方向、定第一性原则 |
| 跑实验、查代码、维护基建 | 质疑数据、对"太好的结果"保持警惕 |
| 规模化分析、机械执行 | 领域常识(这游戏到底怎么玩) |
| 把给定的问题算到底 | 判断这个问题 / 结果本身对不对 |
| 诚实记录、复盘留痕 | 坚持简单、不被局部最优困住 |
在 AI coding 时代,人类的价值已经不在敲代码 —— AI 敲得又快又多。人类的价值在于判断 AI 算得对不对、方向走得偏不偏:一句"搞复杂了"省下一堆过度设计,一句"游戏本来能出多张牌"掀翻一堵反复撞的墙,一句"难以置信,给我证据"挡住一个可疑数字带偏整个项目。最好的协作,既不是 AI 取代人、也不是人不信 AI,而是 —— AI 把活干透,人在关键节点把判断按住。这套最终打到超过中高段位人类的牌局 AI,就是这么一起做出来的。