资讯动态

从棋类AI看代码的边界:不可压缩的现实与本质复杂度

发布时间:2026/10/17 1:41:37 来源:尧图企业网站定制
代码写久了人会形成一种错觉程序是确定性的逻辑是完备的只要给足时间和耐心一切问题都能被优雅的抽象化解掉。我一度也是这么认为的直到我把大量业余时间砸进一个棋盘程序又在日常工作中被各种业务系统反复“教育”才被迫承认一件事——代码、棋局与不可压缩的现实这三样东西放在一起恰恰是这几年给我最狠的几记耳光也是最值钱的认知。这篇文章不是一篇纯算法教程也不是一篇哲学散文。我想用自己开发棋类AI的真实经历从一个很具体的切面聊聊为什么代码总是无法完全“驯服”现实那些经典的搜索算法、教科书式的干净代码为什么一碰到真实对局、真实需求、真实环境就开始失灵以及我们可以用什么姿态去写代码才能在妥协和优雅之间找到一个真正能落地的位置。适合谁看如果你正在学算法会被“为什么我写的代码和示例代码差距这么大”困扰如果你在工作里维护老项目被各种边界条件、环境问题、需求变更折磨如果你只是对“代码的边界在哪里”这件事感兴趣这篇都值得你泡杯茶慢慢翻。1. 为什么棋类AI是一块最诚实的试金石1.1 棋类算法是程序员的理想国棋类AI在教科书里一直是个近乎完美的编程课题。规则极其明确棋子怎么走、胜负怎么判都有严格定义不存在产品经理拿着原型图说“我觉得这里差点意思”。状态空间虽然大但每一步都是有限枚举走法生成器可以在一毫秒内跑完。评价函数有章可循子力价值、位置价值、攻击性都能量化为数字。加上经典的Minimax搜索配上Alpha-Beta剪枝看起来就像一套自洽的数学体系——你给计算机规则它给你最优解。我最初也是带着这种“理想国”心态入场的。觉得一个象棋AI的核心逻辑无非三块棋盘表示、走法生成、搜索评估。棋盘就一个二维数组走法生成按兵种规则遍历搜索就递归剪枝评估函数套几个棋子的分值表。网上随手一搜全是示例代码从快速排序到Minimax每条都像是可以直接抄作业的标准答案。1.2 理想国只在教科书里存在真正动手之后我很快发现两件事。第一示例代码确实能跑但能跑和能赢之间隔着一条巨大的现实鸿沟。第二棋类AI之所以是经典教学案例恰恰是因为它足够简单简单到可以忽略现实世界的大部分噪音——可一旦你想让它跟真人下棋那些被教科书省略掉的“噪音”就全回来了。比如你写出一个能做出合法走子的程序它可能每一步都在“合法地送子”。搜索引擎里那些漂亮的剪枝代码默认了评估函数是有效的可如果你的评估函数本身就没理解棋局剪枝再快也只是在错误的方向上加速。棋局最诚实的地方就在这赢不了就是赢不了没有任何借口。算法再华丽UI再好看日志再规范上棋盘走三十步就崩。这种“结果导向”的残酷性和现实里的业务系统如出一辙——用户不会因为你用了微服务架构就原谅一个崩溃的登录框。所以我一直觉得棋类AI是程序员最好的试金石。它不会骗你你的代码对现实的理解到底有多深拉出来下三盘就全清楚了。2. 一次差点劝退我的象棋AI开发实录2.1 第一版5分钟写出核心搜索然后输得体无完肤我的第一版象棋AI非常典型一个Python脚本包含走法生成、负极大值搜索Negamax、Alpha-Beta剪枝和一个手写的子力评估函数。核心代码长这样def search(board, depth, alpha, beta): if depth 0 or board.is_game_over(): return evaluate(board) for move in board.legal_moves(): board.make_move(move) score -search(board, depth - 1, -beta, -alpha) board.undo_move(move) if score beta: return beta if score alpha: alpha score return alpha棋子分值表也简单直接PIECE_VALUE { K: 10000, # 帅 A: 120, # 仕 B: 120, # 相 N: 270, # 马 R: 600, # 车 C: 285, # 炮 P: 30 # 兵 }配上简单的局面加成比如马在河沿加分、兵过河加分、车占肋道加分我以为这至少能打过业余初级玩家。结果呢我亲自跟它对下前三盘我一边喝茶一边赢后三盘我故意用一些很粗糙的套路还是赢。最打击人的是我发现自己根本不需要什么高深布局只要会“兑子”——用我的马换它的炮用我的车威胁它的无根马——这个程序就完全不会应对了。问题出在哪评估函数只有静态子力价值完全不理解“棋型”。它不知道一个被牵制的车和一条通畅的车差别有多大不知道兵卒的协同形状比单个兵过河更重要更不知道开局时出子速度的价值。搜索深度到了4层看起来够用但每层都基于一个“近视”的评估函数推得再深也是在近视的基础上做远眺。2.2 “残局知识”缺失评估函数的天花板如果说中盘下得差还能归咎于搜索深度那残局下得差就完全是评估函数的锅。我的程序在子力大幅减少后经常出现非常滑稽的局面它拿着单车对士象全明明理论上可以形成杀势却因为评估函数只会数子力分数把车追着老将满场跑完全不知道要“定将”“占位”“逼宫”。这段经历让我想到一个很常见的技术场景很多开发者复现论文代码时模型结构、损失函数、训练循环都写得和论文一模一样但指标就是差一截。原因往往藏在数据处理细节、初始化方式、学习率调度、设备间的浮点差异里——这些细节在论文里要么没写要么写得很隐晦。代码示例无法传递的恰恰是这些“没有规律的经验知识”。棋类的残局知识也是这种经验它很难被简单地公式化更不可能靠几行示例代码就学会。后来我硬着头皮加了些残局特判比如对“车对士象全”单独写逻辑程序才终于像样了一点。但这个补丁过程让我非常不爽优雅的搜索树变得不再优雅代码里到处都是“如果对方士象俱全且我方只有车”这种脏判断。可现实就是这样你想让程序达到人类棋力的及格线就必须往里塞这些“不可压缩”的经验碎片。2.3 开局库、长将和时间控制三个被低估的现实问题除了残局知识还有三个问题是我最初完全没想到的。第一个是开局。程序前几回合没有书可背随机走子经常几步就掉入陷阱。我后来导入了一个简单的开局库程序才开始有模有样地出子。但是开局库本身也是现实的一部分——它存储的是千百盘对局总结出来的经验规律不是推导出来的定理。第二个是长将。按中国象棋规则长将属于禁止着法连续将军循环三次判负。我的程序在占优时特别喜欢“将军将军再将军”因为它只看到每一秒都有“将军”的即时收益完全不知道这是循环。结果跟人下棋时对面只说了一句话“你再这么将我按棋规判和了哦。”我这才意识到程序对这个世界的理解里根本不存在“循环检测”这个概念。第三个是时间控制。真实对局是有时钟压力的AI如果每一步都思考到固定深度到了中盘复杂局面很可能会超时。我为此加了一个简单的迭代加深策略先搜索1层看看时间还剩多少再搜2层逐步加深直到逼近时限。这一套逻辑虽然不难写但它在教科书里几乎不会被提及——因为教科书只研究“如何搜索最优”不研究“如何在50秒内搜索最优且不能超时”。这三个问题没有一个需要新的剪枝算法也没有一个是高深的人工智能理论。它们全部来自现实对局的各种“不成文规则”和资源约束。如果说搜索算法是在处理棋局本身的内在规律那这三个问题就是在处理棋局外围的现实环境。代码想赢棋两个层面都得顾。3. “不可压缩”不是修辞是信息论事实3.1 理解Kolmogorov复杂度讲完象棋AI的惨痛经历我想把“不可压缩的现实”这句话从一个被滥用的比喻变成一个有技术依据的判断。信息论里有个概念叫Kolmogorov复杂度说的是一段数据不可压缩的程度取决于能够生成这段数据的最短程序有多长。如果一段数据完全没有规律那描述它的最短程序就只能是把数据本身逐字逐句地抄进去长度和数据本身差不多——这就是不可压缩。反过来如果数据里充满了重复模式和规律你就能用一段很短的压缩程序描述它比如一个全是“AAAAAA”的字符串你写“6个A”就完了。这个视角套到软件开发上非常有意思。我见过太多团队包括我自己都犯过一个错误把代码质量的差等同于代码的“不可压缩性”高。总想着换了更好的框架、做了更彻底的抽象、用了更优雅的设计模式项目就一定能变清爽。但真相是代码里有相当一部分复杂度根本不是你引入的冗余而是问题域本身自带的。你不管用什么语言、什么架构这部分复杂度都像Kolmogorov复杂度里的“最短程序”一样降不下去。3.2 本质复杂度和偶发复杂度早在《人月神话》里Fred Brooks就把软件复杂度分成两类。一类叫本质复杂度是问题本身固有的比如你要做一个航空订票系统并发控制、座位分配、价格计算、乘客状态机这些复杂性不管你多厉害都写不掉的。另一类叫偶发复杂度是你选型不当、代码混乱、工具不好用带来的额外负担比如因为用了某个反人性的构建工具导致部署要跑半小时。很多“代码整洁”的话题还有我前面说的Git工作流、代码整理、代码诊断插件能消灭的都只是偶发复杂度。它们让噪音变小让代码更接近问题域本身。但它们无法消灭本质复杂度。你得清楚自己是在跟哪种复杂度搏斗。我在上面象棋AI里遇到的残局知识、长将规则、时间控制就是典型的本质复杂度。你不可能用一段更短的代码覆盖掉“车对士象全”的全部赢棋路径除非你亲自把那些残局定式一条条写进去。如果你写不进去你的AI就在这个局面下像个傻子。这种“写不进去”的感觉就是不可压缩现实最具体的体现。3.3 量化交易、模型复现与“现实的验收”举两个更工程化的例子。很多人拿着网上复现的量化交易策略代码回了测曲线漂亮得能上墙一上实盘就脸色发绿。为什么因为回测代码能压缩掉滑点、手续费、停牌、盘中流动性变化、黑天鹅事件这些信息而实盘系统必须把它们全部接收下来。代码不是不想压缩它们是真的压不动。多模态模型复现也是一样。模型结构可以完全复刻但数据清洗的偏差、GPU卡数的差异、随机种子不同带来的训练波动、甚至框架版本之间算子实现的细微差别都会让最终指标发生跳动。你也许能把训练脚本压到很短但你压不掉“为什么这个batch size下loss就是不降”的现实。一旦你接受了这个判断你对代码的态度会发生一个根本性的变化不再追求“把复杂问题写得简单”而是追求“把不可压缩的问题写得清晰、尊重它天然的体积”。这是两件完全不同的事。4. 工具链的边界Git、诊断插件、示例代码能修的和修不了的4.1 VSCode 写C没有代码提示是工具问题还是现实问题我在社区里经常看到有人问“VSCode 写C为什么没有代码提示”其实这个热搜词背后是个很典型的现实案例。大部分情况不是你代码写得有问题而是VSCode这个编辑器在默认配置下根本没有办法知道你包含的头文件在哪、编译参数是什么。它需要一份编译数据库compile_commands.json或者至少一个c_cpp_properties.json来补全这些信息。很多人折腾半天以为是插件的锅其实问题出在一个程序员对“代码”的理解太窄你只盯着编辑器里的那几行代码而现实是一个C工程的构建系统、头文件搜索路径、宏定义、平台差异是一整套你无法通过“看代码”得到的信息。代码诊断插件能帮你抓拼写错误、括号不匹配、未定义变量但抓不到“这个宏在你的交叉编译环境里根本没有被定义”这种语义级问题。所以工具链的价值要认清它们能把一部分隐式复杂度显式化比如帮你索引符号、自动跳转定义、静态检查潜在bug但它们替不了你真正理解项目的构建方式和运行环境。你还是要自己补齐环境信息就像给象棋AI补开局库一样。4.2 Git、代码整理和示例代码到底在压缩什么Git的作用经常被误解。很多人学Git是为了“上传代码到仓库”以为把代码往GitHub或Gitee一推这事就完了。但Git真正有意义的用法是把“变化”记录下来、把“历史”管理起来。一个能写清楚提交信息、能合理做分支合并的Git工作流压缩的是“这次改动到底改了什么、为什么改”这个认知成本而不是代码本身。代码整理也是同理。你重命名变量、抽取函数、把魔法数字改成常量、把重复代码合并成公共模块这些动作是在消除偶发复杂度。它们让后来读代码的人不必再花大量精力去辨认一堆毫无信息量的命名和结构。但代码整理解决不了“这段业务本身就很绕”的问题。你抽函数抽得再干净财务管理系统的对账逻辑该绕还是绕。至于网上铺天盖地的示例代码它们的价值是“教学”和“启动速度”不是“直接覆盖现实”。拿快速排序的示例代码来说教科书版本默认数组元素是可简单比较的、内存充足、数据均匀分布。可一旦你把数据换成几乎有序的大数组、海量重复元素或者跑在极端内存受限的嵌入式环境里你就得改造算法加三路划分、加插入排序兜底、控制递归栈深度。示例代码是被“压缩”过的理想版本你需要还原出它在现实里的完整形态这就是示例代码与生产代码之间最遥远的距离。4.3 一套我目前觉得合适的工具使用姿势说了这么多边界并不是要否定工具链的作用。我现在的姿势很简单继续认真用Git做版本管理继续定期做代码整理继续装诊断插件但我不会再指望它们解决一切。具体来说我有几条不成文的规定第一提交信息必须写清“为什么”而不是“改了什么”因为“改了什么”Git diff里自己能看到第二每天至少花10分钟做代码整理哪怕只是改掉一个难看的名称这种小动作长期收益极高第三遇到IDE行为异常比如没提示、跳转不了第一反应是去查项目的配置和上下文而不是直接换编辑器、重装系统因为大概率是环境信息没有给足。这套姿势的底层逻辑其实是我的时间和注意力才是最不可压缩的资源。工具链把偶发复杂度压到足够低之后我才能把多余的时间挤出来去面对本质复杂度——也就是那些“写不进去但必须写进去”的业务知识和领域规则。5. 面对本质复杂度我现在的写码方式5.1 把核心逻辑做成纯函数把边界交给适配层象棋AI的开发经历最后逼着我做了一个妥协接受程序面对残局时没那么聪明但它至少保证在时间、规则、合法性上不犯低级错误。为此我重新调整了代码结构把核心AI逻辑设计成一组不依赖UI、不依赖时间控制的纯函数输入局面输出走法。然后单独加一个对弈适配层去处理读钟、长将检测、规则判定、开局库查表这些“现实接口”。这个“核心纯函数边界适配层”的结构对我的日常开发也产生了直接影响。现在我在接到一个新需求时第一件事不是画架构图而是先分辨这个需求里哪些部分是稳定的、可推导的、适合做成纯函数的哪些部分是跟外部世界打交道的、充满不确定的、必须留在边界层处理的。举个例子一个报表系统计算逻辑本身是纯函数输入原始数据、过滤规则、聚合方式输出结果表。这部分我敢写出高覆盖率的单元测试。但读取上游数据、处理格式不规范的文件、对接不同数据库的驱动、处理超时重试这些就都是边界层的事。现实世界的数据格式总是不按文档出牌就像对手总不按经典棋谱出牌。我不再试图把所有情况都抽象成一个“万能解析器”而是承认每一种异常文件格式都得单独处理这就是它的“不可压缩体积”。5.2 用测试锁住现实用显式承认消解意外第二个变化是我越来越看重测试的“记录”作用而不是它的“验证”作用。很多人写测试是为了证明代码是对的我写测试更多是为了把一次调通的现实行为固定下来防止未来在重构时被不小心破坏。尤其是那些让新手程序员最头疼的现实问题字符编码、时区、浮点精度、Windows和Linux的路径分隔符差异。这些问题的规律并不是完全没有而是每个都藏在不同的角落里靠记忆去维护它们完全不现实。最有效的办法就是给它们各写一个针对性测试让测试用例本身成为“这部分现实行为”的文档。以后谁改坏了测试马上报警连排查方向都省了。这跟我处理“找不到msvcp140.dll无法继续执行代码”那种环境问题也类似。这种问题的现实根源是程序依赖的VC运行库在当前机器上缺失或版本不匹配。你把它当偶发复杂度去查会发现它反复出现你把它当“部署环境必须补齐运行库”这个不可压缩事实来处理在文档里写明检查项并写一个启动前自检脚本问题就再也不会占用你的心智。5.3 给棋局、也给自己的最后一步棋回到象棋AI本身。那套程序最后并没有达到多高的棋力但它教会了我如何面对一个有上限的世界。我不再幻想写一段“完美的搜索代码”就能赢遍所有人而是学会了在有时间限制、有规则约束、有知识盲区的条件下选择最不坏的那一步。人生和项目其实也一样。我们都想找到一个能把现实完全压扁的万能解法但现实总在告诉你有些信息就是压不掉有些规则就是绕不开有些对手就是会用你不擅长的那套打法。唯一靠谱的应对不是逃避这些不可压缩的部分而是把它们请到桌面上来一个一个看清楚然后分别给一个诚实的处理方案。写在最后我下了很多年棋也写了十几年代码。棋局教会我的是再精妙的战术也得在钟声催促下执行代码教会我的是再漂亮的抽象也得被现实按在地上摩擦。把这两个体会合在一起就是我对“不可压缩的现实”的全部理解别总想着把一切变得简单先分清楚哪些复杂度是能消掉的哪些复杂度你必须正面扛起来。能消掉的用工具、用规范、用重构去消必须扛的就把它写清楚、测清楚、注释清楚让下一个接手的人包括未来的你自己不用重新踩一遍同样的坑。最后分享一个很小但很实用的习惯当你觉得一段代码怎么抽象都不对劲、怎么整理都还是很乱的时候先停下来问一句——这是偶发复杂度还是本质复杂度如果是后者那它不该被藏起来而应该被显式地写在代码最显眼的位置。我用这个办法免掉了无数次无效重构也帮自己省掉了大量“为什么我就是写不优雅”的内耗。不可压缩不代表失败它只代表你终于开始面对真实的问题了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑