简介这是一份面向人工智能与强化学习学习者的德州扑克 AI 代理实现项目源自斯坦福大学 CS 221 课程最终项目。项目将扑克博弈建模为马尔可夫决策过程结合 Q-Learning、蒙特卡罗模拟与 epsilon-greedy 策略训练智能体先与紧攻击型、松散攻击型、紧被动型、松散被动型及随机对手对战学习再实现自我对弈或与人类玩家对战适合想深入理解 MDP 与强化学习落地的中高级开发者参考。资源包共 8 个文件约 26KB以 Python 源码为主包含 3 个 py 脚本、2 个 pyc 编译文件以及 1 个 md 说明文档和 gitignore、gitattributes 等工程配置文件结构紧凑、便于直接运行与二次修改。目前已有 1551 人学习下载。通过阅读源码读者可掌握状态抽象、动作空间设计、奖励函数设定与 Q 表更新等关键环节并借鉴蒙特卡罗模拟在不确定信息博弈中的采样思路为自身强化学习项目提供可复用的实现范式与排错参考。1. 从零拆解一个扑克 AI 代理Python 实现里到底藏着什么很多人第一次听到“扑克 AI 代理”脑子里浮现的是赌场里那种能算牌的机器或者某个能横扫牌桌的玄学模型。但真正拆开来看它其实是一个典型的不完全信息博弈问题你看不到对手的底牌对手也看不到你的双方只能通过下注动作来推断彼此的范围。这和象棋、围棋那种双方信息完全透明的游戏有本质区别——后者可以用搜索树暴力推演前者不行你必须建模“对手在想什么”。这份资源给出的是一套用 Python 实现的扑克 AI 代理核心思路是围绕状态表示、动作空间、策略网络和对手建模四个模块展开。它适合两类人一类是想学博弈论与强化学习结合落地的开发者另一类是想拿一个完整可跑的项目来理解“不完全信息下怎么做决策”的工程师。你不需要先成为德扑高手但得能看懂 Python 类结构和基本的概率计算。下面我从代码结构一路拆到参数调优把能复现的细节都摆出来。2. 拆开代理的骨架状态编码、动作空间与决策循环2.1 状态编码把牌桌信息压成网络能吃的张量扑克 AI 的第一个难点不是模型多大而是怎么把一手牌、公共牌、筹码、位置、历史动作这些异构信息塞进一个固定维度的向量。常见做法是分通道编码手牌用 52 维的 one-hot 表示公共牌同理筹码和底池做归一化位置和动作历史用 embedding 或者直接拼 one-hot。我一般会按下面这个结构来组织状态编码器代码不复杂但每个维度的含义必须写清楚不然后面调参就是黑匣子。import numpy as np class StateEncoder: 把一局牌的状态编码成固定长度向量 def __init__(self): self.card_dim 52 # 一副牌 52 张one-hot self.action_dim 10 # 动作历史窗口最近 10 步 self.scalar_dim 6 # 底池、己方筹码、对手筹码、位置、轮次、有效下注 def encode(self, hole_cards, community_cards, pot, my_stack, opp_stack, position, street, last_actions): # 手牌 one-hot两张牌置 1 hole np.zeros(self.card_dim, dtypenp.float32) for c in hole_cards: hole[self._card_index(c)] 1.0 # 公共牌 one-hot翻牌 3 张、转牌 1 张、河牌 1 张 board np.zeros(self.card_dim, dtypenp.float32) for c in community_cards: board[self._card_index(c)] 1.0 # 标量归一化底池和筹码除以初始筹码位置和轮次除以最大值 scalars np.array([ pot / 200.0, my_stack / 200.0, opp_stack / 200.0, position / 6.0, street / 3.0, last_actions[-1] / 5.0 if last_actions else 0.0 ], dtypenp.float32) # 动作历史取最近 10 步不足补零 hist np.zeros(self.action_dim, dtypenp.float32) for i, a in enumerate(last_actions[-self.action_dim:]): hist[i] a / 5.0 return np.concatenate([hole, board, scalars, hist]) def _card_index(self, card): # card 格式如 As、Kh转成 0-51 rank_map {r: i for i, r in enumerate(23456789TJQKA)} suit_map {s: i for i, s in enumerate(shdc)} return rank_map[card[0]] * 4 suit_map[card[1]]这段代码里最容易被忽略的是归一化。底池和筹码如果不除以初始筹码数值可能到几千而 one-hot 只有 0/1两者拼在一起送进网络梯度会被大数值主导训练时 loss 震荡得厉害。另一个坑是动作历史的窗口长度设 10 是经验值太短记不住对手的加注节奏太长则稀疏且增加输入维度。位置编码用 0 到 6 表示枪口到按钮这个顺序不能乱否则模型学到的位置优势和实际相反。2.2 动作空间为什么不是简单的“跟注或加注”扑克的动作空间看起来只有弃牌、跟注、加注三种但加注的尺度是连续的。如果直接把加注金额作为连续动作输出策略网络很难收敛因为不同底池下的合理加注范围差异巨大。常见做法是离散化把加注分成底池的 0.5 倍、0.75 倍、1 倍、1.5 倍、2 倍再加上全下一共 6 档加上弃牌和跟注总共 8 个离散动作。class ActionSpace: 离散化加注尺度输出 8 个动作 def __init__(self, pot_fractions(0.5, 0.75, 1.0, 1.5, 2.0)): self.fractions pot_fractions self.num_actions 2 len(pot_fractions) 1 # 弃牌、跟注、5 档加注、全下 def get_legal_actions(self, pot, my_stack, to_call): 返回当前合法动作的掩码和对应金额 actions [] # 0: 弃牌 actions.append((fold, 0)) # 1: 跟注 call_amount min(to_call, my_stack) actions.append((call, call_amount)) # 2-6: 按底池比例加注 for f in self.fractions: raise_amount int(pot * f) total to_call raise_amount if total my_stack: actions.append((raise, total)) else: actions.append((allin, my_stack)) # 7: 全下 actions.append((allin, my_stack)) return actions def action_mask(self, legal_actions): 生成掩码非法动作为 0 mask np.zeros(self.num_actions, dtypenp.float32) for i, (name, amount) in enumerate(legal_actions): if i self.num_actions: mask[i] 1.0 return mask这里的关键参数是pot_fractions。我试过只留 0.5 和 1.0 两档模型学出来的策略非常死板翻牌前只会迷你加注容易被对手读死。加到 5 档之后策略明显丰富但训练时间也上去了。另一个细节是全下动作要单独处理当加注金额超过剩余筹码时必须截断成全下否则会出现筹码为负的非法状态。动作掩码在推理时一定要用不然模型可能输出一个筹码不够的加注直接翻车。2.3 决策循环从发牌到动作输出的完整链路把状态编码和动作空间串起来就是一个完整的决策循环。下面这段是代理的主循环每到一个决策点就调用一次。class PokerAgent: def __init__(self, model, encoder, action_space): self.model model self.encoder encoder self.action_space action_space def act(self, game_state): # 1. 编码状态 state_vec self.encoder.encode( game_state[hole_cards], game_state[community_cards], game_state[pot], game_state[my_stack], game_state[opp_stack], game_state[position], game_state[street], game_state[action_history] ) # 2. 获取合法动作 legal self.action_space.get_legal_actions( game_state[pot], game_state[my_stack], game_state[to_call] ) mask self.action_space.action_mask(legal) # 3. 模型前向输出动作概率 logits self.model.predict(state_vec[np.newaxis, :]) masked_logits logits (1 - mask) * (-1e9) # 非法动作置极大负值 probs softmax(masked_logits) # 4. 按概率采样或取 argmax action_idx np.random.choice(len(probs), pprobs) return legal[action_idx]逻辑说明第 1 步编码状态注意game_state里的字段名要和编码器参数一一对应少一个就会报 KeyError。第 2 步拿合法动作这一步不能省因为不同轮次、不同筹码深度下合法动作集合是变化的。第 3 步的掩码操作是推理阶段必须做的训练时可以在 loss 里加 mask但推理时如果忘了加模型可能输出一个弃牌以外的非法动作。第 4 步采样还是 argmax 取决于场景训练时用采样增加探索实战时用 argmax 保证稳定。我一般会在前 1000 手用采样之后切 argmax。3. 训练策略网络用 CFR 还是深度强化学习3.1 CFR 与深度 RL 的选型对比扑克 AI 的训练方法主要有两条路反事实遗憾最小化CFR和深度强化学习DQN/PPO。CFR 是博弈论里的经典方法理论上能收敛到纳什均衡但它的计算量随状态空间指数增长适合抽象后的有限状态。深度 RL 则用神经网络拟合价值函数能处理更大状态空间但训练不稳定容易过拟合到对手的特定策略。我一般会这样选如果目标是单挑无限注德州扑克且算力有限先用 CFR 做抽象把牌力分成若干桶再在抽象博弈上求解。如果目标是多桌、多对手且能拿到大量对局数据用深度 RL 更合适。这份资源里的实现是深度 RL 路线用 PPO 做策略优化因为它的动作空间是离散的PPO 的 clip 机制比 DQN 的 Q 值估计更稳。3.2 PPO 训练脚本与关键超参下面是一个简化的 PPO 训练循环重点看超参设置和优势估计。import torch import torch.nn as nn import torch.optim as optim class PolicyNet(nn.Module): def __init__(self, input_dim, hidden_dim, action_dim): super().__init__() self.fc nn.Sequential( nn.Linear(input_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, action_dim) ) def forward(self, x): return self.fc(x) def train_ppo(agent, env, epochs1000, batch_size256, clip_eps0.2, lr3e-4, gamma0.99, lam0.95): optimizer optim.Adam(agent.model.parameters(), lrlr) for epoch in range(epochs): states, actions, rewards, log_probs, values [], [], [], [], [] # 采样一批对局 for _ in range(batch_size): traj env.rollout(agent) states.extend(traj[states]) actions.extend(traj[actions]) rewards.extend(traj[rewards]) log_probs.extend(traj[log_probs]) values.extend(traj[values]) # 计算 GAE 优势 advantages compute_gae(rewards, values, gamma, lam) returns advantages values # PPO 更新 for _ in range(4): # 每批数据重复利用 4 次 new_logits agent.model(torch.FloatTensor(states)) new_log_probs log_softmax(new_logits, actions) ratio torch.exp(new_log_probs - torch.FloatTensor(log_probs)) surr1 ratio * torch.FloatTensor(advantages) surr2 torch.clamp(ratio, 1 - clip_eps, 1 clip_eps) * torch.FloatTensor(advantages) policy_loss -torch.min(surr1, surr2).mean() value_loss nn.MSELoss()(agent.model(torch.FloatTensor(states)), torch.FloatTensor(returns)) loss policy_loss 0.5 * value_loss optimizer.zero_grad() loss.backward() optimizer.step()参数说明clip_eps0.2是 PPO 的默认值控制新旧策略的偏离程度调大到 0.3 会让更新更激进但容易崩调小到 0.1 则学习慢。gamma0.99是折扣因子扑克一局通常几十步0.99 足够看到长期收益。lam0.95是 GAE 的平滑参数越高方差越大偏差越小。lr3e-4是 Adam 的常见起点如果 loss 震荡就降到 1e-4。batch_size256是每轮采样的对局数太小梯度噪声大太大则更新频率低。我一般会先跑 100 轮看 reward 曲线如果一直不涨先检查状态编码有没有归一化再检查动作掩码有没有漏。3.3 对手建模用历史动作估计范围纯策略网络只学自己的最优策略但扑克是对抗性的你必须根据对手的动作调整。常见做法是维护一个对手范围表根据对手在每个动作节点的频率反推他手里的牌力分布。比如对手在翻牌前加注他的范围可能是强牌加部分诈唬如果他在转牌继续下注范围进一步收窄。class OpponentModel: def __init__(self): self.range_table {} # 节点 - 手牌概率分布 def update(self, node, action, hand_strength): 根据对手动作更新范围 if node not in self.range_table: self.range_table[node] np.ones(1326) / 1326 # 1326 种起手牌组合 # 贝叶斯更新动作概率乘以先验 likelihood self._action_likelihood(action, hand_strength) self.range_table[node] * likelihood self.range_table[node] / self.range_table[node].sum() def _action_likelihood(self, action, strength): # 强牌更可能加注弱牌更可能弃牌 if action raise: return 0.8 if strength 0.7 else 0.2 elif action call: return 0.5 else: return 0.1 if strength 0.7 else 0.9这个模型的精度取决于_action_likelihood的设计。我一般会先用一个粗略的阈值跑起来然后根据实际对局数据做校准。注意范围表的大小是 1326对应 52 张牌里选 2 张的组合数不要写成 52×51那样会重复计算。4. 避坑与排查训练不收敛、动作非法、胜率虚高4.1 现象loss 震荡不下降reward 长期为负原因最常见的是状态编码里混入了未归一化的大数值比如底池直接用了原始筹码数而 one-hot 是 0/1两者量级差几百倍网络梯度被大数值主导。另一个原因是动作掩码在训练时没加模型输出了非法动作环境返回负奖励策略被带偏。解决把所有连续变量除以一个固定基准初始筹码或最大底池确保输入向量每个维度都在 0 到 1 附近。训练时在 loss 里加动作掩码非法动作的 logit 置 -1e9。跑之前先打印一批状态向量看有没有超过 10 的数值。4.2 现象推理时输出“加注 300”但筹码只有 200原因动作空间离散化时加注金额是按底池比例算的但没和剩余筹码比较。当底池很大、筹码很少时算出来的加注额会超过筹码。解决在get_legal_actions里加判断如果total my_stack就把动作改成全下金额设为my_stack。同时动作掩码要对应更新确保全下动作合法。4.3 现象训练胜率 70%实战一局就输原因过拟合到训练对手的特定策略。训练时对手可能是固定风格的脚本模型学会了针对它的打法但实战对手风格不同策略失效。解决训练时用对手池每轮随机从多个不同风格的脚本里选一个作为对手包括紧凶、松凶、跟注站、诈唬狂。对手池越大策略泛化越好。我一般会准备 5 到 10 个脚本对手每 100 轮换一次。4.4 现象CFR 抽象后策略和实际牌力脱节原因牌力分桶太粗比如把 AA 和 22 分到同一桶策略会把 22 当 AA 打。解决分桶时用手牌胜率而不是简单的高牌点数。常见做法是用蒙特卡洛模拟每种起手牌对随机手牌的胜率然后按胜率分位数分桶。桶数一般 10 到 20 个太少丢信息太多则抽象失去意义。4.5 现象训练到后期 reward 突然崩掉原因PPO 的 clip 参数太小或学习率太高策略更新步子太大直接跳出合理区域。也可能是优势估计的方差太大导致梯度方向错误。解决把clip_eps从 0.2 降到 0.1学习率从 3e-4 降到 1e-4同时增大 batch_size 到 512。如果还崩检查 GAE 的lam是不是设成了 1.0那样方差会很大改成 0.95。5. 进阶技巧用抽象加速训练与蒙特卡洛胜率验证5.1 牌力抽象把 1326 种组合压到 20 个桶深度 RL 直接处理 1326 种起手牌组合状态空间太大训练慢且容易过拟合。常见做法是先做牌力抽象用蒙特卡洛模拟每种组合对随机手牌的胜率然后按胜率分位数分成 20 个桶。这样输入维度从 1326 降到 20训练速度能快一个数量级。def build_abstraction(num_buckets20, samples10000): 用蒙特卡洛胜率做牌力分桶 combos list(itertools.combinations(range(52), 2)) win_rates [] for combo in combos: wins 0 for _ in range(samples): opp random.sample([c for c in range(52) if c not in combo], 2) board random.sample([c for c in range(52) if c not in combo and c not in opp], 5) if evaluate(combo board) evaluate(opp board): wins 1 win_rates.append(wins / samples) # 按胜率排序后等频分桶 sorted_idx np.argsort(win_rates) bucket_map {} for rank, idx in enumerate(sorted_idx): bucket_map[combos[idx]] rank * num_buckets // len(combos) return bucket_map参数说明samples10000是每种组合的模拟次数10000 次后胜率的标准误大约 0.5%足够分桶。num_buckets20是经验值我试过 10 个桶AA 和 KK 会分到一起策略区分度不够30 个桶则抽象收益递减训练时间反而增加。分桶后状态编码里的手牌 one-hot 改成桶的 one-hot维度从 52 降到 20。5.2 蒙特卡洛胜率验证别只看 reward 曲线训练时 reward 上涨不代表策略真的变强可能只是对手变弱了。我一般会每 500 轮做一次蒙特卡洛胜率验证让当前策略和一组固定基准策略比如随机策略、紧凶策略各打 1000 局统计胜率。如果对随机策略胜率低于 60%说明策略还没入门对紧凶策略胜率低于 45%说明对抗性不够。def evaluate_against_baseline(agent, baseline, num_hands1000): wins 0 for _ in range(num_hands): env PokerEnv(opponentbaseline) result env.play_hand(agent) if result 0: wins 1 return wins / num_hands # 每 500 轮调用一次 if epoch % 500 0: wr_random evaluate_against_baseline(agent, RandomPolicy()) wr_tight evaluate_against_baseline(agent, TightAggressivePolicy()) print(fEpoch {epoch}: vs Random {wr_random:.2%}, vs Tight {wr_tight:.2%})这个验证方法比看 reward 曲线靠谱得多因为 reward 受对手风格影响大而固定基准策略是稳定的。我一般会设三个基准随机、紧凶、松凶三个胜率都达标才算训练合格。从那以后我每次训练新模型都强制走一遍这个验证流程不再只看 loss 和 reward。希望帮到你。本文还有配套的精品资源点击获取