代码写久了,人会形成一种错觉:程序是确定性的,逻辑是完备的,只要给足时间和耐心,一切问题都能被优雅的抽象化解掉。我一度也是这么认为的,直到我把大量业余时间砸进一个棋盘程序,又在日常工作中被各种业务系统反复“教育”,才被迫承认一件事——代码、棋局与不可压缩的现实,这三样东西放在一起,恰恰是这几年给我最狠的几记耳光,也是最值钱的认知。
这篇文章不是一篇纯算法教程,也不是一篇哲学散文。我想用自己开发棋类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本身。那套程序最后并没有达到多高的棋力,但它教会了我如何面对一个有上限的世界。我不再幻想写一段“完美的搜索代码”就能赢遍所有人,而是学会了在有时间限制、有规则约束、有知识盲区的条件下,选择最不坏的那一步。
人生和项目其实也一样。我们都想找到一个能把现实完全压扁的万能解法,但现实总在告诉你:有些信息就是压不掉,有些规则就是绕不开,有些对手就是会用你不擅长的那套打法。唯一靠谱的应对,不是逃避这些不可压缩的部分,而是把它们请到桌面上来,一个一个看清楚,然后分别给一个诚实的处理方案。
写在最后
我下了很多年棋,也写了十几年代码。棋局教会我的是,再精妙的战术也得在钟声催促下执行;代码教会我的是,再漂亮的抽象也得被现实按在地上摩擦。把这两个体会合在一起,就是我对“不可压缩的现实”的全部理解:别总想着把一切变得简单,先分清楚哪些复杂度是能消掉的,哪些复杂度你必须正面扛起来。能消掉的,用工具、用规范、用重构去消;必须扛的,就把它写清楚、测清楚、注释清楚,让下一个接手的人(包括未来的你自己)不用重新踩一遍同样的坑。
最后分享一个很小但很实用的习惯:当你觉得一段代码怎么抽象都不对劲、怎么整理都还是很乱的时候,先停下来问一句——这是偶发复杂度,还是本质复杂度?如果是后者,那它不该被藏起来,而应该被显式地写在代码最显眼的位置。我用这个办法免掉了无数次无效重构,也帮自己省掉了大量“为什么我就是写不优雅”的内耗。不可压缩不代表失败,它只代表你终于开始面对真实的问题了。