从零构建扑克AI代理:蒙特卡洛胜率计算与对手建模实战
发布时间:2026/10/12 13:14:23 锦皓数字建站

简介这份资源是斯坦福大学 CS 221《人工智能原理与技术》课程的最终项目实现主题为德州扑克 AI 代理面向对强化学习与博弈决策感兴趣的学习者适合具备一定 Python 基础、希望理解 MDP 建模与 Q-Learning 实战应用的中高级开发者。项目将扑克游戏抽象为马尔可夫决策过程结合 Q-Learning、蒙特卡罗模拟与 epsilon-greedy 策略训练代理先与紧攻击型、松散攻击型、紧被动型、松散被动型及随机对手对战学习再实现自我对弈或与人类玩家对战。压缩包共 8 个文件约 26KB以 3 个 py 源码文件为核心辅以 2 个 pyc 编译文件、1 个 md 说明文档及 gitignore、gitattributes 等配置项结构紧凑、便于直接运行与二次修改。目前已有 1551 人学习下载读者可从中获取完整的课程项目方案、MDP 建模思路、Q-Learning 与蒙特卡罗结合的算法实现细节以及多策略对手训练与自博弈的代码组织方式是研究强化学习在非完全信息博弈中落地的实用参考。1. 扑克 AI 代理到底在做什么从「会打牌」到「会算牌」的分水岭很多人第一次听到「扑克 AI 代理」脑子里浮现的是那种能自动帮你出牌的外挂。但真正做过一轮之后你会发现这件事的核心根本不是「替人点鼠标」而是让一个程序在信息不完整的博弈里持续做出接近最优的决策。扑克和象棋、围棋最大的区别在于你看不到对手的底牌甚至看不到发牌堆里剩下的牌。这种「不完美信息」让传统搜索类 AI 直接失效——你没法像 AlphaGo 那样把整棵树展开因为树的很多分支对你来说是隐藏的。扑克 AI 代理要解决的就是这个问题在只知道部分信息的条件下通过概率推断、对手建模和博弈论均衡计算输出一个长期期望值为正的策略。它适合两类人一类是想把强化学习、博弈论落地到具体场景的算法工程师另一类是喜欢打牌、想用代码验证自己策略到底是不是「送钱」的开发者。我见过太多人一上来就写蒙特卡洛树搜索结果跑了几十万手牌还是负收益问题就出在没搞清扑克 AI 的决策链条和参数边界。2. 从零搭一个可运行的扑克 AI 代理状态机、概率推断与决策输出2.1 先定义牌局状态别让「手牌」和「公共牌」混在一个变量里做扑克 AI 代理的第一步不是写模型而是把牌局状态抽象干净。我一般会把状态拆成四块自己的手牌、公共牌、下注轮次与筹码深度、对手的历史动作序列。很多新手会把所有牌塞进一个列表结果到了转牌圈分不清哪些是已知、哪些是未知概率计算直接崩掉。下面是一个最小可用的状态定义用 Python 的 dataclass 写字段含义我逐行注释from dataclasses import dataclass, field from typing import List, Optional dataclass class PokerState: # 自己的两张底牌用字符串表示如 As 代表黑桃A hole_cards: List[str] field(default_factorylist) # 公共牌翻牌3张、转牌1张、河牌1张按顺序追加 board_cards: List[str] field(default_factorylist) # 当前下注轮次0翻前, 1翻牌, 2转牌, 3河牌 street: int 0 # 自己的剩余筹码 my_stack: float 1000.0 # 对手剩余筹码 opp_stack: float 1000.0 # 底池大小 pot: float 0.0 # 对手本街动作序列如 [check, raise_50] opp_actions: List[str] field(default_factorylist) # 当前需要跟注的金额 to_call: float 0.0这段代码的关键在于street和board_cards的联动翻牌圈board_cards长度必须是 3转牌圈是 4河牌圈是 5。如果你在翻牌圈就拿了 5 张公共牌去算胜率那结果一定是错的。参数上my_stack和opp_stack建议用浮点数而不是整数因为后面算底池赔率时会涉及除法整数除法在 Python 里会丢精度。2.2 用蒙特卡洛模拟算胜率手牌对抗随机范围的 10 万次迭代状态定义好之后下一步是算「我这手牌现在赢的概率有多大」。最直接的方法是蒙特卡洛模拟把对手的手牌和剩余公共牌随机补全然后比大小重复十万次取频率。这里有个坑——对手的手牌不能完全随机要根据他的动作范围来加权否则算出来的胜率会虚高。先看基础版假设对手是随机手牌import random from itertools import combinations # 一副牌去掉已知牌后的剩余牌堆 def remaining_deck(known_cards): ranks 23456789TJQKA suits shdc full [r s for r in ranks for s in suits] return [c for c in full if c not in known_cards] # 简化的牌力比较只比牌型等级同等级再比关键牌 def hand_rank(cards): # 这里用简化的点数映射实际项目要写完整的5张牌评估器 rank_map {r: i for i, r in enumerate(23456789TJQKA, start2)} values sorted([rank_map[c[0]] for c in cards], reverseTrue) suits [c[1] for c in cards] is_flush len(set(suits)) 1 # 判断顺子 unique_vals sorted(set(values), reverseTrue) is_straight len(unique_vals) 5 and unique_vals[0] - unique_vals[-1] 4 if is_flush and is_straight: return (8, unique_vals) if is_flush: return (5, values) if is_straight: return (4, unique_vals) # 其余牌型简化处理按点数元组比较 return (0, values) def monte_carlo_winrate(state, iterations100000): known state.hole_cards state.board_cards deck remaining_deck(known) wins 0 for _ in range(iterations): random.shuffle(deck) # 补全对手两张手牌 opp_hole deck[:2] # 补全公共牌到5张 need_board 5 - len(state.board_cards) board state.board_cards deck[2:2 need_board] my_best hand_rank(state.hole_cards board) opp_best hand_rank(opp_hole board) if my_best opp_best: wins 1 elif my_best opp_best: wins 0.5 # 平局算半胜 return wins / iterations逻辑说明remaining_deck把已知牌剔除保证不会重复发牌hand_rank是一个极简的牌力评估器只处理同花顺、同花、顺子和高牌实际项目里你需要补上对子、两对、三条、葫芦等牌型否则胜率会偏差很大。monte_carlo_winrate每次迭代都重新洗牌模拟对手随机手牌和剩余公共牌最后用胜场数除以总迭代数。参数方面iterations设 10 万次在普通笔记本上大约跑 1 到 2 秒精度已经够用。如果你要做实时决策可以降到 1 万次误差大约在 1% 到 2% 之间。注意平局的处理算 0.5 胜比算 0 胜更接近真实期望值尤其在河牌圈双方都是高牌时平局概率不低。2.3 对手建模用动作频率反推范围而不是猜他「有没有牌」胜率算出来只是第一步真正让代理盈利的是对手建模。我见过太多人把对手当成随机手牌结果在低级别局里被跟注站打得怀疑人生。对手建模的核心是根据他历史动作的频率反推他当前手牌的范围分布。一个可落地的做法是维护一个简单的频率表记录对手在翻前加注、翻后持续下注、被加注后弃牌的次数。每打一手牌更新一次然后用这些频率去调整蒙特卡洛模拟里的对手手牌权重。class OpponentModel: def __init__(self): # 各动作的计数 self.action_counts { preflop_raise: 0, preflop_call: 0, flop_bet: 0, flop_check: 0, fold_to_raise: 0, call_raise: 0, } self.total_hands 0 def update(self, action, street): self.total_hands 1 key f{preflop if street 0 else flop}_{action} if key in self.action_counts: self.action_counts[key] 1 def get_raise_frequency(self): raises self.action_counts[preflop_raise] calls self.action_counts[preflop_call] total raises calls if total 0: return 0.5 # 没有数据时用中性先验 return raises / total这个模型很粗糙但比「完全随机」强得多。get_raise_frequency返回的数值可以直接用来加权对手的手牌范围如果对手翻前加注频率是 0.8那他在翻前加注时的手牌范围应该偏向强牌蒙特卡洛模拟里就要减少随机弱牌的比例。参数上total_hands少于 20 时建议不要过度依赖频率因为样本太小频率波动极大这时候用中性先验更稳。2.4 决策输出把胜率、底池赔率和对手频率合成一个动作有了胜率和对手模型最后一步是输出动作。最常用的框架是「期望值比较」对每个可选动作弃牌、跟注、加注估算期望值选最高的那个。跟注的期望值等于胜率乘以底池加上跟注金额再减去失败时损失的跟注金额。def decide_action(state, winrate, opp_model): pot_odds state.to_call / (state.pot state.to_call) if state.to_call 0 else 0 # 基础策略胜率高于底池赔率就跟注否则弃牌 if winrate pot_odds 0.05: # 加5%的安全边际 # 如果胜率很高且对手弃牌率高考虑加注 if winrate 0.7 and opp_model.action_counts[fold_to_raise] 3: return raise return call else: return fold这里的pot_odds是底池赔率意思是「我跟注需要付出的金额占跟注后总底池的比例」。如果胜率高于这个比例长期来看跟注是正期望。加 5% 的安全边际是为了抵消模型误差和抽水。winrate 0.7且对手弃牌次数多时加注是利用对手的弃牌倾向榨取额外价值。参数上安全边际可以根据对手的松紧程度调整对手越松边际可以越小对手越紧边际要越大。3. 避坑指南扑克 AI 代理最容易翻车的五个地方3.1 胜率算得准但动作选错底池赔率用错了分母现象代理在翻牌圈胜率 40%底池 100对手下注 50按理说跟注需要 33% 胜率应该跟但代理却弃牌了。原因pot_odds的分母写成了state.pot而不是state.pot state.to_call。底池赔率的正确公式是「跟注金额 / (当前底池 跟注金额)」漏掉跟注金额会让赔率虚高导致该跟的牌被弃掉。解决把分母补全并且在代码里加一行断言确保pot_odds永远在 0 到 1 之间。3.2 蒙特卡洛迭代次数太少河牌圈决策抖动现象同一个局面连续跑两次一次输出跟注一次输出弃牌。原因iterations设了 1000 次胜率的标准误大约在 1.5% 左右刚好卡在决策阈值附近导致动作来回跳。解决把迭代次数提到 5 万次以上或者在决策层加一个「迟滞区间」——胜率在阈值上下 2% 以内时沿用上一轮的动作避免频繁翻转。3.3 对手模型被少数极端牌局带偏现象对手连续三把翻前加注模型把raise_frequency拉到 1.0代理开始疯狂弃牌结果对手其实是在用垃圾牌偷盲。原因total_hands太小频率估计的方差极大。解决给频率加一个贝叶斯先验比如初始raise5, call5相当于先假设对手是中性玩家随着数据积累再慢慢更新。这样前 20 手牌不会因为几次极端动作就崩掉。3.4 牌力评估器漏掉踢脚牌比较导致平局误判现象双方都是顶对但代理的踢脚牌更大却被判平局。原因hand_rank只返回了牌型等级和点数元组没有在点数元组里正确排序踢脚牌。解决在比较同牌型时确保点数元组按「先比主牌、再比踢脚」的顺序排列。比如两对要返回(2, [高对, 低对, 踢脚])而不是简单地把所有点数排序。3.5 忽略位置因素按钮位和枪口位用同一套策略现象代理在枪口位用同一种范围加注被后面的人频繁反加。原因决策函数没有把位置作为输入。解决在PokerState里加一个position字段翻前范围根据位置调整——按钮位可以放宽到 40% 的手牌枪口位收紧到 15%。位置的影响在翻前尤其大翻后可以通过对手动作部分抵消但翻前必须单独处理。4. 把代理跑起来从单机模拟到批量对战的验证方法4.1 用自对战跑 10 万手牌看长期收益曲线代理写完之后最直接的验证方法是让它和自己打或者和一个固定策略的机器人打。我一般会写一个简单的循环跑 10 万手牌每 1000 手记录一次累计收益。如果曲线在 5 万手之后仍然向下说明策略有系统性问题不用再跑更多了。def self_play(agent, opponent, num_hands100000): total_profit 0 profit_curve [] for i in range(num_hands): # 模拟一手牌返回本手盈亏 profit play_one_hand(agent, opponent) total_profit profit if (i 1) % 1000 0: profit_curve.append(total_profit) return profit_curveplay_one_hand需要你根据前面的状态机和决策函数补全核心是让两个代理交替行动直到一手牌结束。profit_curve用来画图横轴是手数纵轴是累计收益。参数上num_hands至少 5 万因为扑克的方差很大短期收益曲线可能完全被运气主导。4.2 用固定种子复现问题别让随机性掩盖 bug调试扑克 AI 最头疼的是「这次输了但不知道是策略问题还是运气问题」。我的习惯是给每一手牌设一个随机种子把种子和动作序列一起打到日志里。一旦发现某手牌决策异常可以用同一个种子重放排除随机性干扰。import random def play_one_hand(agent, opponent, seedNone): if seed is not None: random.seed(seed) # 后续发牌和决策都基于这个种子 # ...参数上seed建议用「手数编号」而不是完全随机这样每一手牌都能独立复现。日志里至少记录手数、种子、自己的手牌、公共牌、每个街的动作、最终盈亏。有了这些排查问题时不用靠猜。4.3 用对手池测试泛化能力别只跟一个机器人打只跟一个固定策略对打代理很容易过拟合。我一般会准备三到五个不同风格的机器人一个超级紧的、一个超级松的、一个喜欢诈唬的、一个只会跟注的。让代理轮流跟每个机器人打 2 万手看收益曲线是否都为正。如果只对松的机器人盈利对紧的机器人亏损说明策略在对手范围收窄时没有及时调整。对手类型翻前加注频率翻后持续下注频率代理预期收益紧凶型15%70%小幅正松被动型40%30%大幅正疯狂诈唬型60%80%中等正跟注站10%20%需要调整这张表不是标准答案而是我用来判断代理是否「偏科」的参考。如果代理对跟注站的收益是负的说明它在胜率不够高时还在频繁诈唬需要把诈唬阈值调高。4.4 一个容易被忽略的细节筹码深度改变最优策略很多新手写代理时假设筹码深度固定 100BB但实际牌局里筹码深度从 20BB 到 300BB 都有。短筹码时翻前全下或弃牌的决策更常见深筹码时翻后的隐含赔率变得重要。我的做法是在PokerState里加一个stack_to_pot_ratio字段当这个比值小于 3 时启用短筹码策略模块减少翻后复杂操作优先考虑全下或弃牌。def is_short_stack(state): return state.my_stack / max(state.pot, 1) 3 def decide_action(state, winrate, opp_model): if is_short_stack(state): # 短筹码胜率超过 50% 就全下否则弃牌 return all_in if winrate 0.5 else fold # 深筹码走正常逻辑 # ...这个阈值 3 不是绝对的你可以根据自己常打的局调整。关键是意识到筹码深度是一个独立变量不能一套策略打天下。4.5 最后一步把代理放进真实牌局前先做「影子模式」在让代理真正参与决策之前我强烈建议先跑一段「影子模式」代理照常计算动作但不实际执行只把它的建议和人类玩家的实际动作做对比。跑几百手之后你会看到代理在哪些局面和人类分歧最大。这些分歧点往往就是策略需要微调的地方——要么是代理太激进要么是人类太保守但至少你能知道问题出在哪。影子模式的日志格式建议包含局面描述、代理建议动作、人类实际动作、最终结果。跑完之后按「分歧类型」分类统计如果代理在某个类型上长期跑输人类那就要回头检查那个模块的参数。我自己做扑克 AI 代理最大的教训是别一上来就追求「打败人类」先把「不犯低级错误」做到。胜率算对、底池赔率算对、对手模型别被几手牌带偏这三件事做到代理在低级别局里已经能站住了。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。