AI Coding 实战 · 强化学习

用 AI Coding + 强化学习,打造一个漫威终极逆转对战 AI
—— 算法、工程,与驾驭 Claude

一套用 AI Coding、从零训练出一个卡牌对战 PVP AI 的完整记录,分三部分:算法 —— Transformer 状态编码、决策注意力指针头、公平对手与课程学习,如何让它在一个不完全信息、动作组合爆炸的对抗环境里打到超过中高段位人类玩家;工程 —— 几十台机器的分布式训练、模型热替换、可信度量,怎么把它稳稳跑起来、数字信得过;驾驭 Claude(人在回路) —— 在 AI coding 协作里,人类的几次关键判断怎么把走偏的 AI 拉回正轨。中间还穿过一堵困了我们很久、最后发现根本不是"算法墙"的墙。

这篇分享分三部分。第一部分 · 算法讲怎么把一个会打牌的 AI 训出来 —— 重点是两件容易被忽略的事:为什么对手的"公平性"比"强度"更重要,以及一个看似无关紧要的动作建模缺陷如何伪装成一堵"能力上限"。第二部分 · 工程讲怎么让它在几十台机器上稳稳跑起来、数字信得过。第三部分 · 驾驭 Claude讲在 AI coding 时代,人类怎么把走偏的 AI 在关键节点掰回正轨 —— 也就是 AI coding 里的"人在回路"。下面是目录。

PART I

算法:把一个会打牌的 AI 训出来

这一部分讲算法本身:从把一局牌面编码成什么、用什么网络,到为什么对手的"公平性"比"强度"更重要,再到那堵伪装成"能力上限"、其实是动作建模缺陷的"墙"是怎么被穿过去的 —— 最终打到超过中高段位人类玩家。

01

为什么卡牌对战是个难啃的 RL 问题

训练的目标环境是一个回合制集换式卡牌对战:双方各用一套 12 张的卡组,对局共 6 个回合,每回合能量从 1 递增;场上有 3 个地点(路),每回合双方同时把牌面朝下打到某个地点,然后一起翻开结算;占下 2 路者赢。听上去简单,但对 RL 来说它同时把几个最棘手的特性叠在了一起:

不完全信息(POMDP)
看不到对手的手牌、牌库,也看不到对手本回合刚打下、尚未翻开的牌。决策必须建立在"对对手的信念"之上。
组合爆炸的动作
一个回合不是"出一张牌",而是在能量允许下"出若干张牌、各自到哪个地点、还能移动场上的牌"。动作是一棵带顺序的组合树。
强随机性
抽牌顺序、地点的随机效果都会大幅改变局面。胜负里有相当一部分是方差,信号噪声大。
长程连招与延迟回报
很多卡组靠跨回合的连招(combo)在第 5、6 回合爆发。"本回合轮空、留牌到后面更好"这种反即时收益的策略,是 RL 最难学的一类。
一句话

它是一个 不完全信息 + 组合动作 + 高方差 + 长程依赖 的对抗博弈。任何一条单独都不算新鲜,难的是四条同时在一个不到 60 维动作空间里纠缠。

02

系统总览:训练端、数据通道、训练集群

整体是一套经典的分布式 PPO:少量 GPU 在中心做策略更新,大量纯 CPU 机器在边缘并行产对局数据,模型以 ONNX 为统一载体在两端流转。

副机集群 · N 台纯 CPU
每台并行跑几十局训练对局:加载最新模型 ONNX,与课程指定的对手对打,把每一步的 (状态, 动作, 回报) 轨迹打包。
▼  trajectory · HTTP POST(worker-pull,副机单向出站)
训练主机 · 6×A800 GPU(DDP)
单端口集成 server 接收轨迹 → 入队 → PPO 多卡更新;定期把新策略导出为单段 ONNX。训练与数据消费在同一进程内零 IPC 衔接。
▼  latest.onnx(etag 增量 + sha 校验 + 原子写)
镜像中继节点 · Model Mirror
缓存中心模型,副机就近拉取,把"中心一对多发模型"的出口压力收成"一对一"。副机启动时自动探测最近的源。
▲  副机按 etag 拉取,有更新才下载
评估专机(旁路)
独立机器定期拉 best 模型,跑固定对手梯队,给出与训练链路解耦的"干净"战力读数。
监控面板
浏览器实时看 PPO 健康曲线、进度、各对手胜率、集群带宽,秒级刷新。
图 1 · 端到端拓扑。数据"由边缘流向中心"、模型"由中心流向边缘",两条单向通道闭环。

几个值得强调的工程选择:

  • 双语言、单载体。训练侧是 Python(PyTorch + PPO),推理侧(训练对局、评估、线上)是 C#,两端通过 ONNX 对齐。轨迹数据有一套跨语言序列化契约,保证 C# 写出的样本 Python 能逐字段还原。
  • worker-pull 数据通道。副机只做"单向出站 HTTP POST"上报轨迹、并主动"拉"模型,不在副机上开服务端口。新机器自注册、撤机靠心跳自动摘除,集群规模可弹性增减。
  • 推理用游戏引擎当"免费的、零误差的环境模型"。这点在第 4、7 节会变成关键 —— 训练对局里每打一张牌都真正落进引擎、刷新状态,而不是让网络去"脑补"出牌后的局面。
03

状态表示与神经网络

核心网络是一个把"局面"编码成实体 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 序列(约 122–123 个) 卡牌 token · 114 双方所有牌:牌库 + 手牌 + 场上 地点 · 6 3 场地×双方 玩家·2 全局·1 放大「卡牌 token」(每侧 57 槽,共 114): 对手 57 槽 side=0 · 受迷雾遮挡 牌库 30 迷雾下打乱顺序 手牌 7 #87 场上 12 3地点×4槽 · #94 留空 8 ↑ 我方 57 槽(side=1)· 地点 #117 · 全局 #122 → Transformer 编码器(10 层 · 12 头 · hidden 384)→ 每个 token 一个上下文向量 决策头随后只取「我方手牌 7 + 场上 12」当主体、「我方地点 3」当目标(见图 3)
图 2 · 局面编码为约 122–123 个实体 token:双方全部卡牌(各 57 槽 = 牌库 30 / 手牌 7 / 场上 12 / 留空 8)、地点、玩家、全局;token 索引与动作语义严格对齐。

关键不是"有多少 token",而是它们的索引和后面的动作语义一一对齐:场上卡槽用 loc×4+c 编址,这套编址在状态编码器和动作解码器里是同一套坐标。这个对齐让下一节的"动作即注意力"成立。

两个输出头:策略 + 价值

  • 价值头(critic)从全局 token 读出当前局面的胜率估计 V,用于 PPO 的优势估计;它同时也是对局中"要不要加倍/撤退"这类元决策的天然信号。
  • 策略头(actor)不是普通的 Linear(58),而是一个"决策注意力指针头"—— 这是这套方案在网络结构上最不一样的地方,单独拎到下一节讲。
04

决策注意力指针头:动作空间即注意力图

这个游戏的合法动作天然是"主体 × 目标"的结构:把某张牌(主体)打到某个地点(目标),或把场上某张牌(主体)移动到某个地点(目标)。既然结构本身就是一张"谁指向谁"的图,那它就该用注意力来表达,而不是把它拍平、外挂一个全连接输出层去硬背。

于是策略头直接复用 Transformer 的 scaled dot-product attention,做成一个 pointer network

Query · 主体 token(19) 手牌 7 + 场上卡 12 Key · 目标地点(3) L0 L1 L2 手牌×7 Play 动作 7 × 3 = 21 场上卡×12 Move 动作 12 × 3 = 36 EndTurn 全局 token → 标量 · 1 logit = Q · Kᵀ(点积注意力) →  21 + 36 + 1 = 58 个动作
图 3 · 动作空间就是一张注意力图。主体 token 当 Query、地点 token 当 Key,点积得到的注意力分数直接当成动作 logit;attention map 的每个格子精确对应一个合法动作索引。

这样设计有三个好处:

  • 结构归纳偏置正确。"哪张牌去哪条路"本就是"主体 × 目标"的配对,注意力天生就在算这种配对。早期版本恰恰相反 —— 把 19 个主体和 3 个目标先 mean-pool 压平、再用一个全连接层硬背 58 个动作,这正是第 3 章说的信息瓶颈;换成 attention pointer 才把它拆掉。
  • 不丢 per-token 信息。早期一版用 mean-pool 把所有 token 压成一个向量再过 Linear(58),这一步把每个 token 的细粒度信息压平了,事后复盘认为它是一个长期存在的信息瓶颈。换成 per-token 注意力后瓶颈解除。
  • 合法性由引擎兜底。每一步出牌前,从游戏引擎取一份合法动作掩码(地点满了、移动是否合法……),盖在 logit 上。网络只在真正合法的动作里选。

一回合出多张牌:step-wise 自回归

一个回合可以连续出好几张牌。我们不让网络"一次吐出一整套组合"(那是带顺序的排列爆炸,训练算力撑不住),而是 逐步决策

编码局面 含已落下的牌 选 1 个动作 注意力头 + 掩码 落进引擎 真实更新状态 EndTurn? 否则回到第一步 循环,直到结束回合 / 无合法动作 / 触顶(每回合最多 8 步)
图 4 · step-wise 多动作。每出一张牌就真正落进游戏引擎、重新编码,让下一步基于"牌已经在桌上"的真实局面决策。
为什么这步很关键

连招的收益依赖"前一张牌已经生效"的真实局面。让引擎做这个状态推进,等于用零误差的真环境替代了网络对动力学的脑补。网络只需回答"下一张打哪",combo 的因果链由引擎保证。代价是一回合要多次前向,但每次都是一次干净的小决策。

05

公平性设计:战争迷雾 + 公平对手

这套方案最核心的一条原则不是某个网络技巧,而是:训练价值 = 对手的公平性 > 对手的强度。模型最终面对的是公平、同时决策的真人;如果拿一个"会偷看你出牌"的对手来训练,会同时坏两件事 —— 既把策略逼崩,又教出一身在真实对局里用不上的歪招。

① 战争迷雾:把观测做成真实视野

观测编码强制走"部分可观测"(POMDP)口径,把真实对局里玩家本就看不到的信息从神经网络输入中抹掉:

可见(进入观测)
• 自己的手牌
• 双方已翻开、生效的场上牌
• 3 个地点及其效果
• 当前回合、双方能量、总分
隐藏(不进观测)
✕ 对手的手牌
✕ 对手的牌库
✕ 对手本回合刚打下、尚未翻开的牌
✕ 自己牌库里未抽牌的顺序
图 5 · 观测的信息边界。迷雾默认恒开,关闭它的入口仅对测试可见、生产代码根本构造不出 —— 从源头杜绝"不小心"训练出一个偷看牌的版本。

② 公平对手:强,但不偷看

训练对手用的是脚本化的贪心基线(scripted greedy)。同一套贪心引擎、同样 80% 概率选最优招(probDist = {0.8, 0.1, 0.05, 0.05}),唯一被改掉的是它的"出牌时机假设":

对手类型决策方式是否公平
看牌型(AfterOpponent)等你把牌打下、看到你已 staged 的牌,再决定自己怎么出✕ 作弊(真实对局双方同时下牌,看不到)
公平型(OnStart)每回合假设对手不出牌,只基于公开局面暴力贪心,不偷看你本回合的牌✓ 公平(即真实 PVP 的同时决策)
这是一条踩出来的教训

在更早的阶段,训练对手会偷看本回合出牌。结果是:大部分对局变成"信息劣势下怎么打都输",策略梯度长期拿不到"哪个方向更好"的有效信号,理性的收敛结果就是熵塌缩(孤注一掷某一路)。而且这种"防全知对手"的扭曲策略,搬到真实对局完全用不上。把偷看从训练里剔除,是后续一切突破的前提。

当然,公平也意味着"上限有限"—— 一个 80% 选最优、但不偷看、也不做跨回合规划的贪心脚本,棋力天花板大致就在五成胜率附近。突破它的常规思路是自博弈(让模型和自己的历史版本互搏,制造"够得着又打得过"的对手阶梯)。不过在当前这个单卡组的正式训练里,对手池全部是公平脚本梯队(Random + 三档公平贪心),自博弈作为保留的后续方向尚未启用 —— 而结果证明,仅靠"公平对手 + 把动作建模修对"就已经把那堵墙穿过去了(见第 7、8 节)。这本身是个意外却有价值的结论:墙不在对手强度上。

06

训练方法:PPO + 课程学习 + 双轨评估

PPO 主循环

策略优化用 PPO(clipped surrogate + GAE 优势估计 + 价值/策略联合损失;AdamW 优化器、BF16 混合精度、6 卡 GPU 数据并行)。关键超参(生产实际值):

超参超参
裁剪系数 clip ε0.15学习率1e-4
GAE γ / λ0.99 / 0.95batch / minibatch512 / 512
每批 update epochs4价值损失系数0.5
熵正则 ent_coef0.045(固定)target KL0.04
梯度裁剪范数0.5总训练步6 亿
一个反直觉的发现

我们曾上线一个"自适应加熵"机制:熵一低就自动加大熵奖励、想把探索顶回去。实测它两次把训练推向死亡螺旋 —— 在连招/对抗阶段,低熵其实是"策略在收敛到确定的正确打法",强行注熵反而用大梯度搅碎已学到的策略,KL 失控、胜率崩盘。最后改回固定熵系数(0.045)+ KL 自适应学习率兜底。教训:熵低不一定是探索枯竭,要先分清是"塌缩"还是"收敛"。

课程学习:让对手难度跟着进度爬坡

对手不是一上来就最强。课程按训练进度(已消耗的样本步数 / 目标步数)推进,逐阶段提高强对手(Expert)占比,把模型推向真实对局里最常见、也最难赢的分布。下面是当前单卡组正式训练采用的 4 阶段配置:

各阶段对手类型占比 WARMUP 8% 进度 MAIN 20% 进度 HARD 44.5% 进度 LATE 27.5% 进度 Random Beginner Intermediate Expert(公平)
图 6 · 课程对手占比随训练进度演进(4 阶段,按已消耗样本占总量的比例切换)。公平强对手 Expert 从 10% 一路加压到 75%,把训练分布逐步逼向真实对局里最硬的部分;当前 run 全程为公平脚本对手,自博弈为保留的后续方向。

best checkpoint:一个抗噪声的"对 Expert 加权"指标

"哪个 checkpoint 最好"由训练侧自己评出,而不是手动挑。指标对 4 档公平基线的胜率加权,且每一项都取 Wilson 95% 置信区间下界(而非裸胜率,抗采样噪声):

metric = 0.10·R + 0.10·B + 0.20·I + 0.60·E
R/B/I/E = vs Random / Beginner / Intermediate / Expert(均为公平版)各自 1000 局滑窗的 Wilson CI 下界
图 7 · Expert 权重占 60%,让"最佳模型"的定义与最终目标(打赢强对手)对齐,避免靠虐菜刷分。两道闸门:各基线样本量够(≥ 800 局)才评估,且新指标要比旧值高出 1 个百分点才更新。
双轨评估:把"训练监控"和"对齐人类"分开

轨道 ①(公平锚):只用公平对手,进入 best 指标,给一个不掺水的训练进度读数。轨道 ②(人类对齐):单独跑一个会偷看牌的满血强对手,进 best,仅用来和"人类玩家在同样劣势下的胜率"做横向对标。一句话:偷看牌是"量人类水平的标尺",不是"训练材料"。

07

关键突破:那堵墙其实是"动作残废墙"

在很长一段时间里,无论怎么调,模型对强对手的胜率都卡在 五成 上不去。两条完全相反的技术路线 —— "大模型多任务通才"和"小模型单任务专才"—— 本该一个赢在容量、一个赢在专注,结果撞在同一个数字上,而且都在能力接近上限时熵塌缩。

当所有路线撞同一堵墙

一个 bug、一次调参失误,不会让两条对立的架构长期停在同一个数字上。我们一度把它归因为"任务定义本身的上限"。直到回头审视一个最基础、却从没被怀疑过的环节:动作到底是怎么映射的。

真凶:出牌和"结束回合"被捆死了

排查发现,早期的动作映射里,"出一张牌"这个动作被和"结束回合"绑定在了一起。后果是致命的:

残废前:每回合最多 1 张
T1 T2 T3 T4 T5 6 回合 ≤ 6 张 能量大量浪费
第 5、6 回合有 5、6 点能量,却只能出 1 张牌,剩下的能量白白蒸发。等于被绑住一只手和出满牌的对手打。
修复后:能量允许就出满
T1 T2 T3 T4 T5 连招打满 能量曲线用尽
step-wise 多动作(第 4 节)让每回合在能量与合法性允许下连续出牌,连招链条得以成立。
图 8 · "动作残废"示意。把"出牌"和"结束回合"解耦、配合 step-wise 多动作,模型才第一次能正常地把一回合打满。
重新定性那堵墙

反复撞的五成墙,很可能根本不是"任务难度墙",而是"动作残废墙" —— 一个每回合只能出一张牌的智能体,结构上就打不过出满牌的对手,再怎么调网络、调超参都没用。把动作建模修对、配上前面讲的"决策注意力指针头 + step-wise 多动作"之后,胜率立刻开始单调往上爬,一路穿过那个数字。

这也解释了为什么之前的所有努力像在原地打转:我们一直在错误的地基上换姿势。网络结构、对手公平性、课程设计都没错,但底层的动作语义残缺,让上层一切优化都被封顶。

08

结果:单调穿墙,打到超人类

修复后的模型(决策注意力 + step-wise 多动作 + 公平对手 + 战争迷雾)以冷启动从零开始训练,对公平强对手的胜率单调爬升、不卡顿、不塌缩,干净利落地穿过了那堵五成墙。

20% 40% 50% 60% 80% 五成墙 27% 76% 穿过五成墙 ✓ 冷启动 当前 best 训练进度 →(对"公平 Expert"胜率 · best / argmax · 迷雾口径)
图 9 · 对公平强对手的胜率(best / argmax 口径)从冷启动的 27% 单调爬到 76%,无塌缩、无停滞地穿过五成墙。健康收敛的三个同步信号:熵平缓下降、胜率同步上升、KL 稳定。

对标人类:同口径下超过中高段位人类约 12 个百分点

把模型放进"和人类完全相同的劣势"下(自己看不到对手 = 迷雾,对手会偷看自己 = 满血看牌强对手),它的胜率超过了会和 AI 对战的中高段位人类玩家:

0 40% 80% 61.6% 本方案 RL vs 满血看牌强对手 49.3% 中高段位人类 同样的劣势口径 +12pp 超过中高段位人类
图 10 · 同口径对比。两边都处在"自己看不到对手、对手偷看自己"的相同劣势下,模型仍高出约 12 个百分点。注意这是"超过会打 AI 的中高段位人类",不是"超过世界顶尖"。

这把"中高段位人类 49.3%"不是估出来的,而是从大规模线上真实对局里、用相同对手相同信息劣势测出来的锚点。把它和模型的完整战力谱并排,更清楚它站在什么位置:

模型完整战力谱(best / argmax · 迷雾口径)

对手档位胜率
Random~97%
公平 Beginner~84%
公平 Intermediate81%
公平 Expert76%
满血看牌 Expert(最难)61.6%

同一对手下,线上人类的真实胜率

对战方 · vs 满血看牌 Expert胜率
线上新手段人类~45%
线上无限段 · 中高段位人类49.3%
本方案 RL61.6%

口径与诚实标注:① 两表胜率都在"自己看不到对手、对手偷看自己"的相同迷雾劣势下,对手也是同一个"满血看牌 Expert",故可比;② 线上人类这条胜率其实是非单调的 —— 对手卡组会随玩家段位变强,段位较低的 rank 50–99 玩家面对的 AI 卡组偏弱、一度能到约 63%,这里取段位更高、对手同为满血强档的"无限段中高段位"玩家作锚;③ 因此结论是"超过会和 AI 对战的中高段位人类约 12pp",而非"超过世界顶尖"(顶尖玩家在匹配机制下基本不与 AI 对战)。

76%
对公平 Expert
(best / argmax)
+12pp
同口径下
超过中高段位人类
0.50→穿
扛过历史上
反复塌缩的死亡点
单段
ONNX 模型
~60MB

扛过历史"鬼门关"

历史上每次熵塌缩都精确发生在训练进度约 50%(强对手占比把期望胜率压破临界点的那个位置)。这一次,模型在同一个进度点上 熵 0.97、KL 0.007,对公平 Expert 仍稳在七成以上的高位,没有任何回落。这从反面印证了一件事:之前的塌缩是"任务被定义成怎么都赢不了"逼出来的数学必然,而不是 PPO 本身不稳定。把对手修公平、把动作修完整,塌缩就消失了。

推理性能(线上部署参考)

硬件单次推理单核 QPS
服务器 CPU 单核(Broadwell 至强)60.5 ms16.5
桌面 CPU 单核(i7-12700F)44.0 ms23
入门 GPU(RTX 3050 · batch=1)9.2 ms108

单段 Transformer ONNX,约 60MB。一回合多动作(出 3–4 张)= 4–5 次前向,CPU 端约 250–300ms 完成整回合决策;GPU 的真正优势在多对局并发 batch。

09

方法论沉淀

如果说这套方案有什么能迁移到别的 RL 项目的经验,大概是这三条 —— 它们都不是"用了什么 SOTA 技巧",而是"避免了什么自欺"。

① 度量先于优化:别在失真的尺子上调参
最危险的不是模型弱,而是"以为它在变强"。我们吃过的最大亏,是一个系统性高估真实战力的胜率指标,让团队在一个假信号上盲调了很久。后来用"训练对手不放水 + 评估口径与部署对齐 + 多条独立链路交叉验证"把度量钉死,才敢相信曲线。先保证尺子是准的,再谈优化。
② 对手的公平性 > 对手的强度
一个会作弊(偷看信息)的强对手,会同时把策略逼崩、把模型教歪。真正有训练价值的,是"公平、能力可达、且能随智能体一起变强"的对手 —— 这几乎必然指向自博弈,而不是固定脚本。强度可以靠自博弈卷出来,公平性一旦丢了就回不来。
③ 假设驱动,而非调参驱动
"撞墙就调一个参数再试"是最容易陷进去的循环。当两条对立路线撞同一堵墙,该停下来问的不是"下一个参数调什么",而是"我有没有哪条从没验证就当地基的假设错了"。最后掀翻那堵墙的,不是更大的模型或更精的超参,而是回头质疑了一个所有人都默认正确的底层环节 —— 动作到底是怎么建模的。
收尾

从"卡在五成"到"超过中高段位人类",中间真正起作用的不是某个漂亮的网络模块,而是把三件朴素的事做对:把动作建模建完整、把对手做公平、把尺子量准。剩下的,交给 PPO 和时间。

PART II

分布式工程实现

第一部分讲的是"AI 怎么学会打牌"。但要让它真的学得动,背后是一整套分布式工程:这套训练的本质,是用一大批廉价 CPU 不停产对局、喂给少数几张 GPU 更新策略 —— 这套基础设施决定了能不能、以及多快喂饱 GPU。下面是 5 块工程硬骨头,以及途中几次"从复杂退回简单"的取舍。

10

副机集群:两层进程栈 + 弹性自注册

N 台纯 CPU 副机要做到"插上电就自动加入、撤掉自动退出、崩了不连累训练"。做法是把每台副机的进程拆成控制面 + 数据面两层、全部对主机单向出站,主机端只维护一张内存里的活机表。

一台副机 L1 任务层 supervisor 看护子进程 + 蓝绿换模型 selfplay × N(dotnet) 跑对局、产轨迹 L0 接入层 agent · 控制面 心跳 / 拉模型 / 拉进度 采集机器指标 main · 数据面 Unix socket 收轨迹 出站 POST 转发 socket 主机 单端口 HTTP :8804/:15374 POST GET 控制面 / 数据面拆两个进程:采指标卡顿绝不拖累轨迹吞吐 副机全程单向出站,本机不监听任何端口
图 11 · 一台副机的两层进程栈。L1 跑对局产数据,L0 把数据出站、把模型拉进来;控制面与数据面拆成独立进程,互不拖累。

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 和系统。
故意不做开机自启
副机不配 crontab、不开机自启,整进程崩了靠看板发现、人工 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、查全局状态)
11

模型 A/B 热替换:进程级蓝绿 + 一局一致性

主机每 ~30 秒导出一版新模型,副机要在不中断对局的前提下换上去。我们没有在推理进程里"重载 session",而是做了一套进程级蓝绿(blue/green)双代切换 —— 这背后还藏着一条 PPO 算法正确性的硬约束。

落盘:单份文件 + 原子替换

副机 agent 每 5 秒用 ETag(= 文件大小-修改时间,只 stat 不读内容)问主机有没有新模型:没变返 304 不下载,变了才拉、sha256 校验通过后写到 latest.onnx.tmp,再 os.replace 原子改名成 latest.onnx。单份文件、原子替换 —— 这一步只保证"推理进程永远读不到写一半的文件",真正的 A/B 不在文件层,而在进程层

切换:先起新代、再杀旧代、旧代跑完当前局

时间 → supervisor 检测到 latest.onnx 更新 重叠 5–30s gen A · 旧模型 SIGTERM→跑完当前局退 gen B · 新模型,继续产数据 ① 先起新代 两次切换冷却 ≥ 180s
图 12 · 进程级蓝绿。推理进程内的 ONNX session 启动后永不重建;换模型 = 先用新模型起一代新进程,再让旧代跑完手头这局自然退出。

推理进程(dotnet selfplay)里的 ONNX session 只在启动时加载一次、整个生命周期不变,进程内没有任何"换模型"逻辑。真正盯着 latest.onnx 变化的是 shell supervisor:每 5 秒 stat 一次修改时间,变了就 ——

  1. 用新模型起一整代新进程(gen A↔B 交替);
  2. 给旧代发 SIGTERM,旧代跑完手头这一局再退出(30 秒宽限,超时才强杀,避免丢正在写的轨迹);
  3. 新旧重叠 5–30 秒(这段副机短暂跑 2× 进程,靠内存富余扛);两次切换间至少冷却 180 秒,防止模型频繁更新导致旧代还没退完就被下一轮打断、进程越积越多。
为什么一局中途绝不换模型 —— 这是 PPO 的硬要求

换模型只发生在局与局之间,一局自始至终用同一个模型版本。深层原因不是工程图省事,而是算法正确性:PPO 的每条轨迹都记录了"旧策略在每一步选这个动作的概率 log πold(a|s)",更新时要算 πnewold 这个比值。如果一局前半段用旧模型、后半段用新模型,记录的 log_prob 就和实际动作来自两个不同的策略,比值算错、梯度学歪。所以"一局一致性"是被 PPO 逼出来的,而进程级切换天然满足它。

版本闸门:让"模型版本"和"数据新鲜度"闭环

每个推理进程在加载模型时算一个版本号 sha256前16位_时间戳,盖进它产出的每一条轨迹。主机这边维护"最近 100 个导出版本"的集合,轨迹的版本号不在这个集合里就丢弃。于是形成闭环:主机导出新模型 → 副机换代 → 新代轨迹带新版本号 → 主机版本窗口右移 → 旧代轨迹的版本号滑出窗口、被丢。这套以"版本成员资格"(而非步数算术)对齐的闸门,正好挡掉卡死的旧进程产的陈旧 off-policy 数据 —— 下一章你会看到它有多重要。

12

数据通道的三代演进

"副机怎么把轨迹送到主机"这件事,我们换了三代方案。每一代都是被上一代的真实痛点逼着改的 —— 这段演进本身就是一份"分布式数据通道避坑指南"。

① PUSH(失败) 副机 主机 副机主动 push 入站被防火墙 RST ② streaming_pull 副机 主机 主机反向去拉 每台建隧道、手动登记 ③ worker-pull(终态) 副机 主机 副机单向出站 · 单端口 心跳自注册,零运维 谁先握手:副机 → 主机 → 又回到副机,但这次彻底单向出站 + 单端口收口
图 13 · 三代数据通道。关键不在"数据往哪流"(始终是副→主),而在"谁先发起连接"和"开几个端口"。

第一代 · 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

为什么 worker-pull 是终态

防火墙友好:副机不开端口、只放主机一个外网端口;② 自注册弹性:心跳即注册、超时即摘,零主机介入,为镜像化批量扩容铺平路;③ 单端口集成:控制面 + 数据面一个 server,干掉隧道和独立同步通道;④ 零 IPC:rank0 收数据直接入队、主循环出队,没有多进程队列的拷贝开销。

13

吞吐优化的 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% 的数据。

0 50% 100% 8% GPU0(rank0) GPU1–5 92–100%(空等 rank0) G1G2G3G4G5
图 14 · rank0 是整个 6 卡 DDP 的瓶颈:接收 / 解压 / 导出都串在它身上,GPU0 被 CPU 活拖到 8%,其余 5 卡陪等。

两组优化,各有实测收益

A 组 · 异步导出 ONNX
把模型导出甩到一个常驻子进程,主循环只写权重快照。save 步从 5.5–6.8s 降到 1.4–2s,GPU0 利用率 8%→85%
B 组 · 延后解压
接收端只切帧入队,把 zstd 解压移到消费侧线程池。接收线程占用 92%→53%

两上两下:为什么还是全删了

节点动作结果
第 1 上A+B 两组同时上线吞吐指标漂亮
第 1 下winrate 阴跌,全面回滚B 组把"原本与训练重叠的解压"挪到消费侧变串行,消费率反降 33%
第 2 上只上 A 组 + 数字化 SLA 告警队列首次不再撑满,消费追平生产
第 2 下再次回滚、彻底删除代码根因锁定在数据新鲜度
真正的杀手:off-policy 漂移,而非吞吐

异步化 + 步速加快,让副机用的模型落后了 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% —— 因为不碰新鲜度,被保留了下来。

14

Model Mirror:把出口压力收成一对一

N 台副机每隔几秒就来主机拉一次模型,模型更新那一瞬间它们会同时涌来。一台镜像中继把这股扇出压力从主机卸到自己身上 —— 它几次"从复杂退回简单"的演进,比设计本身更有看头。

为什么要它

主机给副机发模型,出口 = N × 约60MB。真正烧带宽的不是稳态(95% 的请求因 ETag 没变直接返 304、不传字节),而是模型刚更新那一瞬间 N 台同时来取的尖峰。中继节点自己用一个 puller 每秒从主机拉一次最新模型、缓存到本地,副机改向它要 —— 主机的模型出口就从 N×60MB 收成 1×60MB。而副机的拉取协议一行都不用改(见下文"协议对齐")。

主机 · 独立 model server :15373(独立进程防 GIL 饿死) 1Hz 拉 puller_daemon(1 进程) 原子写 cache/latest.onnx + state.json server 1 server 2 server 3 server 4 5 5 × uvicorn · SO_REUSEPORT 同 bind :8804 · 内核轮询分发 N 台副机 GET /model/latest
图 15 · 当前(J215)拓扑:1 个 puller 独占地从主机拉、原子写本地缓存;5 个服务进程用 SO_REUSEPORT 绑同端口,内核把副机请求轮询分给它们。

进程拓扑:1 个拉取 + 5 个服务,靠内核分发

中继内部是 1 个 puller_daemon(独占地从主机拉、原子写本地缓存和状态)+ 5 个纯服务进程。5 个服务进程用 SO_REUSEPORT同一个端口、由内核按连接轮询分发 —— 既绕开单进程 GIL 的吞吐天花板,又对副机表现为一个端口。进程之间不用共享内存、不用 Redis,只用原子文件改名同步状态(写 .tmp 再 rename,读者要么看到旧的、要么看到新的,永不读到撕裂的半截)。

一个被回退的"理论最优":nginx 零拷贝

最初的设计想让 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 装依赖)。它是全链路里唯一破例配开机自启的节点 —— 因为它是中心服务,不像无状态的副机可以"挂了人工拉起"。

15

断点续训:让 6 亿步训练随时停、无损接

一次正式训练要跑 6 亿步,期间机器会重启、训练会异常、超参要调 —— 全程不可能一气呵成。所以"随时停下、无损接续"不是锦上添花,而是长跑的生命线。

训练中断 重启 / 异常 / 调参 state.pt · 一次接住 5 样 模型权重 · Adam 状态 · step timesteps · 课程进度 少一样,续训就出岔 HOT RESUME · 从 step=N 接 train.jsonl 接续、进度不归零 启动器验收后置条件(任一不过 → 非零退出) GPU 残留 ≤ 100MiB · 副机归零 · 新 PID 存活 · 60s 内有新指标
图 16 · 续训不是"重新加载权重"那么简单 —— 一个 state.pt 接住模型、优化器、step、timesteps、课程进度五样,启动器再用四条硬性后置条件确认真的起来了。

一个文件接住全部状态

恢复训练时,一个 state.pt 一次性接住五样东西:模型权重、Adam 优化器状态、全局 step、已消耗 timesteps、课程进度。少任何一样都会出岔 —— 丢了 Adam 动量会让 KL 短期飙高,丢了 timesteps 会让课程阶段算错、对手难度跳档。接续成功的标志很明确:日志打出 HOT RESUME: starting from step=Ntrain.jsonl 从 N 接着写而不是从 1 重来,课程进度不归零。

启动器带"验收后置条件"

启停训练不靠手敲 ssh + nohup torchrun(那是"看着起来了、其实静默失败"的经典坑),而是统一走一个启动器,把停旧 → 清 GPU 残留 → 拉新 → 验收做成一条流程。验收是硬性后置条件,任一不满足就非零退出、绝不假装成功(见上图)。万一 state.pt 缺失,还有三层 fallback:退化成"只恢复 step 计数"(从最新权重按 step 数字排序提取,Adam 重新开始、接受 KL 短期略高),保证训练能续上而不是从零再来。

配置即代码:SSOT + 冻结快照

训练超参由三份 yaml(trainer / worker / curriculum)做单一真源,全集群 sync 后 sha256 校验、不一致就中止。但有一个反直觉却关键的设计:续训时超参从 run 目录里的"冻结快照"加载,而不是当前 yaml

为什么续训要读冻结快照、而不是最新 yaml

因为训练中途万一有人改了 yaml(哪怕只是手滑),续训若直接读最新值,超参突变会让 PPO 发散。冻结快照在训练启动那一刻把超参定死,保证一次 run 全程超参自洽。要正经改超参,得显式"同步快照 + 热重启"才生效 —— 把"改配置"变成一个有意识、可追溯的动作,而不是随时可能污染长跑的暗雷。

16

可信的度量:把"尺子"当地基来建

第一部分讲过那个最痛的教训:项目前几个阶段栽在一把系统性高估的尺子上,长期在假信号上盲调。所以这套方案把"度量准"当成和算法同等重要的地基 —— 每一层都在防一种"自欺"。

胜率统计:宁可低估,不可高估

  • 滑动窗口 + Wilson 置信区间下界:胜率取最近 1000 局的 Wilson 95% CI 下界而非裸胜率 —— 样本少时下界自动保守,防"手气好"被当成"变强了"。
  • 平局算非赢:平局计入分母、不计入分子,只会拉低胜率、不会抬高 —— 一个一律往保守方向偏的口径。
  • 与训练解耦:三个独立窗口(总体 / 按己方卡组 / 按对手类型)每 3 秒刷一次,节奏和 PPO 更新分开,互不阻塞。

best checkpoint:一个只升不降的棘轮

metric 实时(波动) best(棘轮,只升不降) metric 回落时,best 平着不退 → 峰值模型已存住
图 17 · best 是一个棘轮:实时 metric 上下波动,best 只在创出足够新高时才上跳、回落时绝不后退。所以哪怕后面训崩,峰值模型也丢不了。

"哪个模型最好"由训练侧自动评、而非手挑。它沿用第 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、并让每个胜率数字都经得起推敲,比把某一段做到极致快重要得多 —— 这和第一部分"先把尺子量准再优化"是同一个道理。

PART III

驾驭 Claude:人在回路的决策与纠偏

前两部分讲"做了什么"。这一部分讲"是谁、在哪些节点、把方向掰对的"。我们翻遍了项目全过程、2858 条人类发言的原始对话记录 —— 高频出现的不是技术术语,而是几句很朴素的话:"搞复杂了""感觉不对劲""你别走歪方向了"。下面是中肯的复盘:AI 容易在哪儿栽,人类的判断在哪些节点救了场,以及在 AI coding 时代,人类的价值到底在哪。(下文引用均为对话原话,仅清理明显笔误。)

17

"搞复杂了":把 AI 一次次拉回简单

整个项目里,AI 反复表现出同一种倾向 —— 把事情做复杂:加一堆特殊逻辑、搭多套机制、追求"完备"。而人类几乎每隔一阵就得把它拉回来一次。最值得记下的是:项目里几个最终采用的核心架构,恰恰是这样"拉简单"拉出来的。

worker-pull 弹性集群(第 10 章)
"我觉得你搞复杂了 …… 全程可以是副集群,单向流动到 master。包括模型同步,副集群可以 http 轮询 master 检查。master 只需要对请求做合法性验证即可接受处理,自己根据心跳或上次更新时间之类的机制,来剔除和增加 worker。"
→ 这一段话几乎就是第二部分那套 worker-pull(副机单向出站、心跳自注册摘机)的设计说明。AI 当时在往更复杂的双向方案走,人类一段话定了方向。
公平对手做成"正规 AI 类型"
"这几个其实按照 AI 开发的思路去做,不需要太多特殊逻辑 …… 使用者最终按 AI 类型的方式去用即可,而不是一堆特殊逻辑。"
→ AI 本来用 override hook + 藏牌一堆特殊逻辑,被拉回"做成一等公民 AI 类型",hack 全删(第 5 章那个公平对手就是这么来的)。
多动作 = 输出一个动作数组
"我单次输出就是出的牌是那些,而不是只输出一张牌。换个简单说法,输出的 action 就是牌的数组。你能懂区别了吗?"
→ AI 一度想搞"自回归、出一张再推理一张"的复杂结构,人类点破"就是一次输出动作数组"。
容量公式(第 10 章)
"worker count 是根据机器的 CPU 线程来决定的吧?不是你说的固定值 …… worker 数量是 cpu 线程数量的 90%。"
→ 第 10 章那个 ⌊核数 × 90%⌋,是人类纠正出来的,不是 AI 拍的固定值。
中肯地说:AI 不是笨,是"太想做全"

AI 的本能是"完备、通用、不遗漏",于是容易过度工程化 —— 多搭一层抽象、多处理一个边界、多留一个开关。人类那句"搞复杂了"也不是图省事,而是抓住了"这个场景到底需要什么"。结果被反复验证:被拉简单的方案,往往更对、更稳、更好维护。这几乎是整个项目工程侧的一条隐形主线。

18

看见 AI 看不见的:常识、直觉与怀疑

AI 的视角是代码和数据。它常常看不见三样东西:这游戏到底怎么玩、这个数字凭直觉对不对、这个结果是不是"好得不正常"。而项目里几次最关键的转折,恰恰是人类用这三样东西换来的。

① 领域常识:一句话点破那堵反复撞的墙

困扰 phase1–4 的"五成墙",AI 和复盘都一度归因到"任务太难 / 对手作弊"。真正点破它的,是一句游戏常识 ——

人类原话

"那必然的啊,游戏规则本来就可以出多个牌的。" 紧接着: "你在本机 eval replay 跑 100 局,看看是否真的最多只出一张牌。"

AI 把"每回合只出一张牌"当成既定事实分析了很久,却没意识到这违反游戏规则。人类一眼看穿,并立刻要求用 100 局实测验证 —— 真因(动作建模把"出牌"和"结束回合"绑死了)就此浮出水面,模型随后单调穿墙。这是整个项目最大的一次翻盘,靠的不是算法,是常识加实证。

② 直觉警报:"感觉不对劲"

人类对数字有一种 AI 没有的体感。项目里"感觉不对劲"这句话出现过很多次,每次几乎都指向一个真问题:

"这个值感觉不对劲,太小了"→ 吞吐真异常 "进度一直是 warmup 感觉不对"→ 进度口径错 "Mirror 这个数据感觉不对劲"→ 镜像故障

AI 倾向于相信自己刚算出来的数字、往前推进;人类的体感是一道独立校验 —— 两者冲突时,事后往往是体感对。AI 自己也把这条记进了日志:"用户的体感数字是有效信号,与实测矛盾时优先怀疑自己的采样方法。"

③ 科学怀疑:对"太好的结果"保持警惕

最难得的是对好消息的不轻信。当模型胜率突然变得很漂亮时,是人类先按住了刹车 ——

人类原话

"这个难以置信,训练白费了;所以我觉得你应该慎重诊断,给出实际证据,包括 eval 和训练的情况,你可以在副集群拿一台机器来打几场,看出的卡牌、动作是否真有问题。"

正是这种"好得不正常就先查清楚"的怀疑,逼出了对数据是否虚高的彻底溯源(最终证明数据真实、提升真实)—— 这恰好是第一部分那个"在失真度量上反复盲调"教训的反面:这一次,没有再让一个可疑的数字带着项目跑。

中肯地说:这是 AI 的盲区

AI 强在"把给定的问题算到底",弱在"判断这个问题、这个结果本身对不对"。领域常识、对数字的直觉、对异常结果的警惕 —— 这些不在代码和数据里,是 AI 的盲区,也正是人类不可替代的地方。

19

不只是纠错,更是主导

前两章讲人类怎么"纠 AI 的错"。但翻完整段对话记录会发现,人类做的远不止纠错 —— 更多时候是在主导:每一次重大改动前先审方案、控成本、定目标、盯根治、逼 AI 审查自己的代码。这才是 AI 时代项目主导者的真实工作方式。

① "先出方案我审查"

几乎每一次重大改动前,人类都先把住一道闸 —— 不准 AI 直接上手,先出方案:

"提案要详细,task 要足够呈现,方便人类审查" "你出具体动作方案我审查" "你出完整方案我审核"

AI 又快又能改,最危险的恰恰是"擅自动手、一改一大片"。人类用"先出方案"这道程序闸,守住了每一次重大改动的方向 —— 动手之前先讲清楚要改什么、为什么、影响多大。

② 控成本、定目标

人类始终有清晰的成本意识和量化标准,不让 AI 无止境堆规模:

人类原话

"我在想阶段二有必要 5 亿场么,1 亿场不行么 —— 卡组大部分还是类似的,场次足够多,泛化能力也强。" 
 "训练的 expert 达到 70% 胜率、打全卡组胜率能 55% 就足够了。"

"够了"是一个 AI 很难自己拍板的判断 —— AI 的本能是"更多、更全、更稳"。人类清楚这个项目到底要什么、到什么程度就该停,省下的是真金白银的算力和时间。

③ 盯根治,不留烂尾

AI 改完容易留下残留(旧配置、死代码、半完成的清理),人类盯得很紧:

"之前老的 yaml 都彻底删除,这样才能根治" "dead code 没清理干净,这种老问题你重新审查下为什么还会出"

"能跑就行"和"清干净了"之间的差距,往往就是下一个雷。人类要的不是"问题暂时不犯了",而是"根上没了"。

④ 逼 AI 审查自己

最关键的一道把关,是人类要求 AI 回头审查自己的代码和假设,而不只是看"跑通了没有":

人类原话

"你审查下 RL 的环境感知,输入参数是否都在工作、正常工作,有没有出现阶段三的问题。" 
 "你要审核下代码本身是否支持、留的余量是否足够。"

第一部分那个"救下 1 亿场训练"的观测验证,源头就是这类要求 —— 人类不满足于"模型在训练",而是逼 AI 去核实"它到底看到了什么、是不是和我们以为的一样"。让 AI 审查自己,是发现地基问题最有效的一招。

中肯地说:这是"决策层"的活

这些加起来,是"项目主导"—— 人类没有在执行层和 AI 拼速度(拼不过、也没必要),而是站在决策层:审方案、控成本、定目标、盯质量、逼复查。AI 负责把决策高效落地,人类负责保证"决策本身是对的、落地是干净的"。

20

关键转折: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 笨"

每一次 AI 的判断,当时都看着合理 —— PPO 指标确实正常、代码逻辑确实通、数字确实漂亮。但人类有 AI 当时没有的东西:对历史基线的记忆("阶段一到四都 500 g/s 跑得很稳")、对"合理却反常"的警觉("胜率涨着 PPO 怎么会崩")、对"读代码 ≠ 真相"的坚持("我要的是实际跑起来加日志")。几乎每一个关键转折,都卡在人类这么一句话上 —— 这就是"人在回路"四个字最实在的含义。

21

中肯的分工:谁该做什么

讲了这么多人类的纠偏,必须同样诚实地说清楚:这个项目里大量的硬活是 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,就是这么一起做出来的。

技术分享 · 强化学习卡牌对战 AI  |  文中所有架构细节、配置与胜率数字均来自项目代码与训练日志,非示意性虚构。