
炸金花游戏做到第三篇最关键的东西总算登场了EV。前两篇我们把规则、牌型强度、胜率模拟跑通但那还属于静态评估这一篇要解决真正的问题——AI拿到一手牌面对底池和对手的下注到底该跟、该加、还是该弃。答案不在感觉里在数字里。我打算用一个基于EV期望收益的简单AI模型把每一步决策都拆成可计算的账并直接给出Python实现和完整决策流程。不管你是想研究棋牌类AI、练手策略算法还是纯粹想写个能陪你打牌的机器人这篇都可以直接用。1. 为什么AI要盯住EV从“猜牌”到“算账”1.1 赌徒思维 vs 程序员思维很多玩家打炸金花靠的是“这把我感觉能行”“他刚才这么下注肯定在偷鸡”这种直觉型判断在短局里偶尔很灵但拉长到几百手、几千手最后赢钱的往往是数学上站得住脚的那一方。程序员做AI要做的就是把这套“感觉”替换成一套可复用的数字框架。牌局里有大量随机因素对手手牌未知、剩余牌堆未知、对手的心理和习惯未知。面对不确定性AI最稳的决策依据不是“猜对方有没有大牌”而是“我这个动作长期重复做平均能赚多少、亏多少”。这个平均收益就是EVExpected Value期望收益。EV并不是一个能保证单局胜利的魔法数字它是一个统计量。你可以把它理解成一个投资的“长期年化收益率”某一手牌可能赢也可能输但只要每次都在正EV的时机出手长期累积下来利润就会向数学期望靠拢。反过来经常做负EV的决策哪怕偶尔赢几把大的最终也会被统计规律拉回去。1.2 EV核心公式与一次下注的拆解EV的核心公式并不复杂EV 胜率 × 赢到的钱 - 败率 × 本次投入的钱用符号写就是EV w × P - (1 - w) × C其中w 是你估算的胜率P 是当前底池里已有的筹码量C 是你需要跟注的筹码量1 - w 是失败概率。举个例子底池100对手下注20你需要跟注20才能继续。假设你通过模拟估算出胜率是30%那么EV 0.3 × 100 - 0.7 × 20 30 - 14 16EV是正数说明这个跟注动作长期看是赚钱的。哪怕你这把输了20只要类似局面重复100次每次赢的时候平均能赢回100底池中的相应份额整体依然有正收益。还有一种更精细的写法把你自己跟注进去的20也算进赢的时候能拿回来的部分EV w × (P C) - C因为当你赢下底池时你之前投入的C也包含在底池里会一起回来。代入上面的数字结果其实是相同的EV 0.3 × 120 - 20 36 - 20 16两种写法本质一致关键是定义清楚“P”是跟注前的底池还是跟注后的底池。代码实现时统一口径别混用。1.3 为什么EV模型能成为AI的决策依据只要给AI定义好所有可选动作的EVAI就可以用一条非常简单的规则做决策选择EV最大的动作。如果弃牌的EV是0跟注的EV是16那就跟注如果加注到50能算出EV是22那就比跟注更优如果所有动作的EV都小于0那就弃牌。这个规则看起来简单但它背后的逻辑很深在信息不完整的博弈中只要你的胜率估算足够准、对手行为预测足够合理最大化期望收益就是接近最优的策略。德州扑克里的GTO策略本质上也是在追求一种“不被剥削”的EV平衡炸金花虽然规则不同但EV这个分析工具完全通用。当然EV模型的精度完全取决于输入胜率估得准不准、对手弃牌率估得准不准。这也是为什么后面会花大量篇幅讲模拟和对手建模这两件事决定了EV模型到底是“印钞机”还是“计算器玩具”。2. 炸金花AI需要喂哪些信息2.1 手牌强度胜率怎么来EV公式里的w是胜率但胜率不是固定的“手牌等级表”。一手牌的胜率取决于你有几个对手、对手的手牌范围、剩余牌堆的随机性。比如一对A在单挑时胜率很高但在5人局里会因为更多人抽到更大牌而下降不少。计算胜率有两种常用方法我分别说下。第一种是枚举法。在炸金花里如果只有你和一名对手且你已看牌那么对手的牌是从剩余牌堆里随机组合的。理论上你可以把所有组合都遍历一遍得到精确胜率。第二种是蒙特卡洛模拟。随机给对手发几万次手牌每次比较牌型大小赢的次数除以总次数就是胜率。这个方法更通用适合多人局速度也够快。我在实际项目里优先用蒙特卡洛因为它的实现简单、错误率可控而且炸金花的牌型判断本身不复杂几万次模拟在现代CPU上也就几十毫秒。下面是一段简化版的手牌评估和胜率模拟代码import random from itertools import combinations RANKS 23456789TJQKA SUITS SHDC def card_value(card): rank, suit card[0], card[1] return RANKS.index(rank) 2 def evaluate(hand): 返回牌型等级和比较用的排序值数值越大牌越大 等级豹子同花顺同花顺子对子散牌 values sorted([card_value(c) for c in hand], reverseTrue) suits [c[1] for c in hand] unique set(values) is_flush len(set(suits)) 1 is_straight max(values) - min(values) 2 and len(unique) 3 if len(unique) 1: return (6, values) # 豹子 if is_flush and is_straight: return (5, values) # 同花顺 if is_flush: return (4, values) # 同花 if is_straight: return (3, values) # 顺子 if len(unique) 2: return (2, values) # 对子 return (1, values) # 散牌 def monte_carlo_win_rate(my_hand, num_opponents1, iterations10000): deck [r s for r in RANKS for s in SUITS] for c in my_hand: deck.remove(c) win 0 for _ in range(iterations): random.shuffle(deck) # 给每个对手发3张牌 opponents [ evaluate(deck[i * 3: i * 3 3]) for i in range(num_opponents) ] my_score evaluate(my_hand) if my_score max(opponents): win 1 return win / iterations代码里几个细节要注意枚举对手手牌时必须先把已知手牌从牌堆中移除否则会出现“自己和对手拿到同一张牌”的荒谬结果。牌型等级的比较直接放在元组里Python的元组比较是逐位比较的后面再接一个values是为了解决同等级牌型下谁更大的问题。如果你的本地规则包含A23这类的特殊顺子需要在判断顺子时单独处理很多人会在这里踩坑。2.2 行动空间与输入特征炸金花相比德州的一个特色是“闷牌”也就是玩家可以选择不看自己的牌直接下注。简单EV模型不考虑这个复杂情况先假设AI已经看牌只看牌后怎么决策。对于未看牌的阶段完全可以沿用固定策略比如“前两轮默认跟注后两轮随机决定看牌或弃牌”这样也能跑。一份完整的回合内输入信息包括当前手牌当前底池大小当前轮到AI时要跟注的金额可选的加注金额对手数量对手历史行为数据用于估算弃牌率。AI的动作空间包含弃牌、跟注、加注和比牌。比牌在炸金花里是单独支付一定费用直接跟你指定的一名对手比大小输的人支付底池对应的筹码。从EV角度看比牌的本质是“用一笔固定费用换取一次以胜率w赢下底池的机会”因此它的EV计算和跟注很像只是费用结构不同。2.3 EV能覆盖所有行动吗跟注的EV可以直接用前面的公式但加注的EV要复杂一些因为对手可能会弃牌也可能会再反加。一个简单但好用的处理方式是引入“对手弃牌率fold_rate”如果对手弃牌你直接赢下当前底池P收益是P如果对手不弃牌你和对手进入下一轮或者直接开牌此时你的收益按胜率w结算赢了拿回底池加注后的筹码输了失去加注额简化公式EV fold_rate × P (1 - fold_rate) × [w × (P R) - R]这里的R是你加注的金额。下面我会用这个公式实现AI。3. 用Python实现EV计算核心模块3.1 两个EV函数跟注和加注结合上面的分析我先写两个基础函数def ev_call(win_rate, pot, call_cost): 跟注的期望收益 pot跟注前的底池 call_cost需要跟注的金额 return win_rate * (pot call_cost) - call_cost def ev_raise(win_rate, pot, raise_to, fold_rate0.2): 加注到raise_to的期望收益 简化模型对手只有 弃牌/跟注 两种反应不考虑反加 win_if_called win_rate * (pot raise_to) - raise_to return fold_rate * pot (1 - fold_rate) * win_if_called两个函数都假设加注后能直接摊牌也就是“对手跟注后不会再施加额外压力”这在实际游戏里并不总是成立但作为简单模型已经够先跑起来。3.2 决策逻辑最大化EV决策模块要做的事是遍历所有可选动作计算每个动作的EV选最大的那个。def decide_action(win_rate, pot, to_call, min_raise10): actions [] # 动作1弃牌EV恒为0 actions.append((fold, 0, 0)) # 动作2跟注 if to_call 0: actions.append((call, to_call, ev_call(win_rate, pot, to_call))) else: actions.append((check, 0, 0)) # 不需要跟注时免费过牌 # 动作3加注到不同额度 for r in set([min_raise, min_raise * 2, min_raise * 5]): if r to_call: actions.append((raise, r, ev_raise(win_rate, pot, r, fold_rate0.25))) # 从所有动作里挑EV最大的 best max(actions, keylambda x: x[-1]) return best这里需要注意加注到不同额度EV计算里的fold_rate我固定取0.25实际中应该根据对手历史动态调整。加注额度与对手弃牌率通常是正相关的你加注越多对手越倾向弃牌但同时一旦被跟注你输得也越多。这两个效应之间存在一个平衡点后面会讲怎么调。3.3 完整AI模块把胜率估算和EV决策串起来做一个简单的AI类random.seed(42) class SimpleEVBot: def __init__(self, iterations10000, fold_rate0.2): self.iterations iterations self.fold_rate fold_rate def get_win_rate(self, hand, num_opponents): return monte_carlo_win_rate(hand, num_opponents, self.iterations) def act(self, hand, pot, to_call, num_opponents): win_rate self.get_win_rate(hand, num_opponents) actions [(fold, 0, 0.0)] if to_call 0: actions.append((check, 0, 0.0)) else: actions.append((call, to_call, ev_call(win_rate, pot, to_call))) # 设置几个加注档位 for r in [10, 30, 80]: if r to_call: ev ev_raise(win_rate, pot, r, self.fold_rate) actions.append((raise, r, ev)) best max(actions, keylambda x: x[-1]) return best, win_rate这个AI每次行动前都要跑一遍蒙特卡洛模拟实际对局中如果玩家多、手数多可能会感觉有点慢。优化方式有两个一是把相同手牌、相同对手人数的胜率缓存起来二是减少模拟次数到3000左右胜率精度略有下降但速度会快很多。3.4 一次演示AI面对顶对和垃圾牌的决策假设AI手牌是KKQ对手人数1人当前底池100对手下注30。跑一下hand [KS, KH, QD] pot 100 to_call 30 opponents 1 bot SimpleEVBot(iterations10000) action, wr bot.act(hand, pot, to_call, opponents) print(f胜率: {wr:.3f}, 最优动作: {action})如果模拟正常胜率大概在0.75以上跟注EV约0.75×130-30≈67.5而加注到80的EV还要算上对手弃牌带来的收益很可能比跟注更高。AI在这里选择加注是完全合理的因为它不仅牌强还能通过加注逼迫对手放弃一部分底池权益。4. 让EV模型更耐打对手建模、方差与风险控制4.1 对手弃牌率怎么来上一节的ev_raise函数最关键的一个外部参数就是fold_rate。它决定了加注动作的EV里有多少来自“直接偷走底池”。这个参数不能拍脑袋定。最简单的做法是给每个对手维护一个统计表对手面对加注时弃牌多少次跟注或反加多少次fold_rate 弃牌次数 / 总加注次数。假设你观察到某对手连续5次面对加注都弃牌那么下一次他面对加注的fold_rate可以暂时设成0.6以上反过来对方是个“加注必跟”的玩家fold_rate就要降到0.1以下。动态更新方式建议用滑窗统计只保留最近20次结果。原因是玩家的行为会变化用全量历史反而会被老策略拖累。4.2 别被方差骗了资金管理EV为正只是一个长期的数学结论短期内的波动可能远超你的预期。哪怕你有55%的胜率连续输10把也是完全可能发生的事。所以再好的EV模型也必须配上资金管理规则。我的经验有一条非常实用单次买入不要超过总资金的5%。如果你的总资金是1000那么单场游戏最多投入50。这样即使遇到连续20场下风期本金也不会清零还能留在牌桌上等待正EV机会回归。更进阶一点可以试试Kelly公式f* (b × p - q) / b其中b是净赔率赢时收益/输时损失p是胜率q1-p是败率。Kelly公式告诉你应该用多大比例的资金去下注但这个比例往往会比较激进实际中建议使用半Kelly也就是把算出来的f*再除以2降低波动。4.3 设置策略风格紧、松、稳EV决策虽然统一但可以通过阈值调整AI风格。紧只有EV超过当前底池5%时才行动否则弃牌。适合忌惮风险、希望减少波动的场景。松允许EV略负也跟注比如EV -0.02 × pot 就上。这种AI在乱局里能抓到更多机会但长期容易被高手利用。稳加注时fold_rate按最保守值算比如0.1避免过度估计加注收益实际对局中会更接近“不亏但赚得少”的状态。在代码里加一个threshold参数就能控制def decide_threshold(action_ev, pot, styletight): if style tight: return action_ev 0.05 * pot if style loose: return action_ev -0.02 * pot return action_ev 0这个设计非常适合写成一个配置文件方便在不同场景下测试对比。5. 实测结果与常见坑5.1 模拟次数不够胜率误差大蒙特卡洛的模拟次数直接决定胜率估算的误差。统计学上二项分布的标准误差是error sqrt(p × (1-p) / n)当真实胜率在50%附近时n500的误差约2.2%n5000的误差约0.7%n10000的误差约0.5%。如果你用500次模拟胜率可能在48%和52%之间跳动这会让EV的排序不稳定甚至导致AI在临界局面反复变脸。建议最低用3000次追求稳定用10000次。5.2 牌型判断的边界条件炸金花牌型判断最坑的几个点A23在很多规则里算最小顺子也有规则算顺子但输给普通顺子这会导致胜率计算失准。2、3、5这种散牌在部分规则里可以“吃”豹子如果做AI前没确认规则一定要写进代码并单独测试。同花顺和同花的判定要放在豹子之后、顺子之前顺序错了会出大问题。我建议在写完evaluate函数后立刻写几个单元测试把豹子、同花顺、同花、顺子、对子、散牌各构造一组手牌跑一遍确保等级顺序完全符合你的预期。5.3 随机性与可复现性做模拟时一定要固定随机种子或者保存每次模拟的随机状态否则你会发现前后两次决策结果不同无法定位问题。random.seed(42)这行代码在调试阶段至关重要。AI在测试环境里必须可复现等到正式上线后再把随机种子去掉让每次模拟引入更多随机性减少被对手针对性抓规律的可能。5.4 简单EV模型的边界这套模型能赢新手但遇到水平高的玩家会被针对。原因很简单fold_rate是粗粒度的常数或统计值对手可以观察你的加注规律并在你有强牌时弃牌、你示弱时偷底。真正的博弈还需要混合策略和对手动态建模而不是每次都选“单点EV最大”的动作。我的实际体会我自己用这套简单EV AI跑了上千局模拟最大的体会是胜率估算的准确性对结果的影响远大于决策算法的复杂度。很多AI在“牌型强度”上做得花里胡哨结果底池赔率算不准照样亏。炸金花这种短牌游戏一手牌的胜率波动很大但只要你把EV当作决策主线就已经能超过绝大多数凭感觉打牌的玩家。最后再分享一个小技巧在实现时先把加注动作的EV计算单独跑成一张表格看看加注金额从10到100EV曲线是怎么变的。你会发现在某个金额附近EV最高越过那个点后加注越多反而越亏。这个峰值的出现就是底池、胜率和对手弃牌率三者博弈的均衡点。理解了这一点再回到代码里调fold_rate你会对EV模型的理解深一大截。