前几天在群里看到一个吐槽:“换构建系统,AI 全绿也白干。”当时我正用 AI 生成一批单元测试,CI 上所有用例都绿得发光,看到这句话后背一凉——两年前我带着团队把一个用 Makefile 维护的中型 C++ 项目迁到 CMake,那段日子的痛我到现在还记得。如果你正在享受 AI 编程的红利,觉得写代码、补测试、修 bug 都能交给 AI,那我劝你先把“构建系统”四个字焊在脑子里。这篇文章不聊大模型选型,不聊提示词花活,就聊一件事:为什么换一次构建系统,能让 AI 帮你攒下的所有“全绿”在几小时内变“全红”,以及怎么才能不白干。
1. 为什么换构建系统会让AI成果归零
1.1 构建系统是被低估的“隐式契约”
很多人把构建系统理解成“一堆编译命令的集合”,这个认知在迁移之前没太大问题,等真正动手换的时候才会发现,构建系统是整个工程里最深的一层隐式契约。它不只是告诉编译器怎么编译源文件,还负责维护依赖图谱、增量编译、并行调度、产物布局、代码生成、测试编排,甚至决定了开发者每天在 IDE 里能不能拿到正确的智能提示。
AI 生成代码的时候,看起来是在“从零写一个函数”,实际上它的一切输出都默认运行在“当前构建系统的语义”之下。比如 AI 写了一个#include "utils/logger.h",它假设编译器会在某个 include 路径里找到这个头文件;AI 写了一个测试用例,它假设测试进程启动时的工作目录是项目根目录;AI 写了一个模块的接口,它假设这些符号在链接时会被正确解析。这些假设往往不是代码本身声明的,而是被现有构建系统的参数和流程“兜住”的。
我习惯把构建系统比作工厂里的流水线:AI 生成的代码是流水线上的零件,零件是按当前产线的接口和规格加工出来的。你说要换一条产线,那所有零件都得重新适配,而不是原封不动搬过去就能跑。问题在于,AI 只能看见零件的图纸,看不见产线的全部内部逻辑,所以它生成的代码天然带着对旧产线的隐性依赖。
1.2 “全绿”到底证明了什么
先说清楚一件事:AI 代码在旧构建系统里全绿,这个“绿”本身是真实的,不是假的。它至少证明了代码能编译、能链接、能通过已有的测试用例。但这个“绿”的范围非常有限,它只能证明代码在“当时那套编译器参数 + 头文件路径 + 链接顺序 + 测试运行环境下”是自洽的,不能证明代码是可移植的、自包含的、换一套构建系统依然成立的。
举个非常典型的例子。有一次我让 AI 写一个读取配置文件的测试,AI 直接用了相对路径config/app.yaml,在旧 Makefile 里测试任务是在项目根目录下启动的,所以跑起来完全没问题,CI 上绿油油一片。后来迁移到 CMake,用 ctest 跑测试,默认的工作目录是构建输出目录,所有用相对路径读文件的测试全部失败,红的整整齐齐。你说是 AI 写错了吗?从“在旧构建系统下能跑”这个角度看,它没错;但从“可迁移、可复现”这个角度看,它埋了一个巨大的雷。
这才是“AI 全绿也白干”的本质:AI 优化的目标是“通过当前检查”,而不是“在任何合理的构建系统下都能通过”。它不会主动去处理那些构建系统层面隐藏的魔法,你也不会因为全绿就去质疑它,于是迁移那天一次性爆雷。换构建系统之所以让 AI 成果看起来“归零”,不是 AI 生成的逻辑没用了,而是大量代码和测试被构建系统层面的隐式假设绑死了,绑得越紧,死得越惨。
2. 一次把 Makefile 迁到 CMake 的完整踩坑实录
2.1 当初为什么非换不可
说个背景。当时那个项目大概有 80 多个模块,底层是 C++,上层有一些 CUDA 代码,构建系统是团队早期用手写 Makefile 一点一点堆起来的。最开始模块少,Makefile 还能撑住,后来模块一多,问题就全来了:增量编译经常失效,有时候改一个头文件,整个项目要重编;新同事入职光是搞明白 make 的依赖关系就要一周;IDE 的智能提示经常因为 include 路径配置不全而到处报错。
更尴尬的是,当时团队已经开始大规模用 AI 辅助写代码,AI 生成新模块的速度非常快,但每次都要人工往 Makefile 里补编译规则。AI 生成的代码越多,这个手工维护构建脚本的瓶颈就越明显。于是我们决定迁到 CMake,理由是它跨平台支持好,CLion 和 VS Code 的体验都不错,还有内置的 ctest 和 CPack,依赖管理也能用 FetchContent 解决一部分。现在回头看,这个选择本身没错,但我们对迁移的难度严重低估了。
2.2 AI生成的代码在迁移中的典型“死法”
迁移第一周,我几乎天天盯着编译报错看,AI 生成的代码以各种姿势倒下,归纳起来大概有五类。
第一类:include 路径找不到。旧 Makefile 里有一个全局的-Isrc,所以项目里所有模块都习惯直接写#include "utils/convert.h",也不管当前模块在哪个目录。AI 训练过大量类似代码,自然就按这种“全局 include”的风格生成。CMake 要求每个 target 显式声明自己的target_include_directories,全局路径这个习惯直接失效,第一轮编译满屏找不到头文件,那叫一个壮观。
第二类:链接顺序和符号解析问题。手写 Makefile 时,链接顺序经常是“碰对了就不动”,团队里没人真去理解为什么这个库要放在那个库后面。AI 生成的模块引用了其他静态库的符号,旧构建系统恰好把库的顺序写对了,编译链接全过。迁到 CMake 用target_link_libraries声明依赖后,顺序一旦不匹配,undefined reference就冒出来了。这种问题最恶心,因为往往是好几个模块互相引用,形成了一个复杂的依赖环。
第三类:动态库导出宏缺失。项目里有几个模块需要以动态库形式对外提供接口,AI 生成这些接口类的时候,完全没有加__declspec(dllexport)或者__attribute__((visibility("default")))。在旧 Makefile 里,这些模块一直被编译成静态库,导出宏的问题被完全掩盖了。迁移后我们决定统一输出动态库,结果外部程序链接的时候,所有公共符号全都找不到,那段时间我一度怀疑人生。
第四类:测试写了,但新系统发现不了。项目里已经有很多 AI 生成的 gtest 用例,旧构建系统是手动在 Makefile 里加了一个make test目标,一个个二进制跑。CMake 迁过来之后,如果不加include(GoogleTest)或者gtest_discover_tests,ctest 根本不知道这些测试存在。看起来还在,实际上等于没跑。
第五类:工作目录和运行时依赖全线崩溃。这个前面提过,AI 生成的测试大量使用相对路径读配置文件、模型权重、临时文件目录。旧 Makefile 恰好是从项目根目录下启动测试二进制的,所以一直全绿。ctest 默认把工作目录设成构建目录,路径全错,几十个测试飘红。
2.3 迁移后复盘:白干与没白干的清单
迁移结束以后,我们花了一个周末做复盘,目标就一个:搞清楚这几个月里 AI 生产的东西到底哪些是白干的,哪些是没白干的。列完清单之后,团队好几个人的想法都变了。
白干的部分确实不少。第一,所有依赖隐式 include 路径和全局宏定义的代码,几乎都要改动。第二,所有跨动态库调用但没写导出宏的接口,全部要补一遍符号导出。第三,测试用例只要没接进统一的测试发现机制,等于没有。第四,有一批代码当初是靠-fpermissive这类宽松编译选项“惯”出来的,换到 CMake 后我们想顺便收紧编译参数,结果这些代码直接编不过,AI 得重新改。
但没白干的更重要。算法逻辑、领域模型、数据结构设计这些核心资产,虽然构建系统变了,但代码主体还是能保留下来,只是需要在 include 和链接层做适配。模块划分和接口语义只要设计得好,补上导出宏之后基本就通了。测试数据和测试意图也还有价值,改的是测试的启动方式和路径处理,不是测试内容本身。还有那些小脚本、小工具,稍微改改路径就能继续用。
复盘结论很简单:AI 产出的最大价值在“逻辑”,不在“胶水”。而换构建系统,恰恰最伤的就是胶水层。
3. 迁移前后,怎么让AI经得起构建系统更换
3.1 动手之前,先做三类盘点
如果你已经决定要换构建系统,别急着让 AI 去重写 CMakeLists,先把家底盘清楚。我给了一个硬性要求:动手前必须完成三类盘点。
第一是编译环境盘点。把所有编译器标志、宏定义、include 路径、库链接参数、RTTI 和异常开关、C/C++ 标准版本,一项项列出来。这个表不用多复杂,重要的是不能漏。第二是依赖盘点。项目里每一个第三方库,是系统自带的还是手动编译的,版本是什么,通过什么方式链接,有没有代码生成步骤,必须写清楚。第三是测试和运行环境盘点。当前用了什么测试框架,测试怎么被发现,测试运行的时候依赖哪些环境变量、外部服务、临时文件、相对路径,这些是迁移时最容易炸的地方。
| 盘点类别 | 需要确认的内容 | 迁移时的风险点 |
|---|---|---|
| 编译环境 | 标准版本、宏定义、编译选项、符号导出设置 | 宏定义丢失、编译语义变化 |
| 依赖管理 | 第三方库来源、版本、链接方式、代码生成器 | 链接顺序、版本兼容、生成文件路径 |
| 测试运行 | 测试框架、发现机制、工作目录、环境变量、外部依赖 | ctest 找不到测试、路径崩溃、服务连不上 |
这三类盘点做完,你手里就有了一张“隐式依赖地图”,后面迁移的时候每踩到一个坑,都能在这张地图上找到它属于哪一类。
3.2 AI改造实操:把“隐式约定”变成“显式声明”
盘点完之后,最重要的一件事就是让 AI 后续生成的代码不再依赖隐式约定。我的做法是给 AI 立了几条硬规则。
头文件引用一律走显式路径,要么用#include <module/header.h>配合target_include_directories,要么在项目里统一 header 目录。禁止写#include "../common/x.h"这类相对路径,因为它在不同构建系统下解析规则不一样,换个目录结构就崩。符号导出统一用项目自定义的宏,比如FOO_API,Windows 下展开成__declspec(dllexport),Linux 下展开成__attribute__((visibility("default"))),AI 生成的所有对外接口类都要带这个宏。测试代码禁止依赖std::filesystem::current_path(),改成通过__FILE__推导项目根目录,或者通过 CMake 在编译期传入资源目录宏。
这些规则落实之后,再看看 CMake 侧的实操。最基础的 CMakeLists 结构大概是这样的:
cmake_minimum_required(VERSION 3.20) project(demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_library(core src/logger.cpp src/convert.cpp ) target_include_directories(core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src ) add_executable(tool src/tool_main.cpp ) target_link_libraries(tool PRIVATE core) enable_testing() # 如果项目里已经有 GoogleTest,可以这样注册测试 include(GoogleTest) add_executable(test_core test/gtest_core.cpp) target_link_libraries(test_core PRIVATE core GTest::gtest_main) gtest_discover_tests(test_core)注意两个容易被忽略的细节:第一,target_include_directories最好按PUBLIC和PRIVATE分开,能避免很多依赖泄漏问题;第二,gtest_discover_tests的宏会去读生成二进制里的测试列表,如果测试运行需要特定工作目录,记得用set_tests_properties单独设WORKING_DIRECTORY。
3.3 给AI的提示词里,把构建约束写清楚
很多人用 AI 写代码,提示词就写“给我写个日志模块”,然后 AI 一顿输出,编不过再反复让它修。我的做法是给 AI 一个“构建上下文”区块,每次生成代码前先粘贴进去,相当于给 AI 戴上约束的笼头。
我常用的提示词模板大概是这样的:
项目构建系统是 CMake,目标平台是 Windows 和 Linux。生成的代码必须符以下规则:禁止硬编码绝对路径;禁止依赖当前工作目录;头文件引用使用
<module/xxx.h>格式;所有对外接口类必须带FOO_API导出宏;如果代码需要链接第三方库,在注释里明确写出库名;最后附上对应的 CMake target 定义代码片段。
这个模板的价值不只是减少报错,更关键的是让 AI 在生成阶段就主动把依赖关系显式化。比如第三方库,以前 AI 往往默认系统里自动有,现在它会明确告诉你这个模块需要链接libcurl或libssl,我们在 CMake 里就能提前配好。
还有一个很好用的技巧:让 AI 在生成完代码后,自己附带一行“验证命令”,比如cmake -S . -B build_verify && cmake --build build_verify --target xxx。AI agent 能执行命令的话,就让它自己跑一遍再交付;不能执行的话,这句验证命令也能提醒人类工程师去跑一次干净构建。实测下来,这一招能把交付质量拉高一大截。
4. 换构建系统后的常见问题与排查技巧
4.1 找不到头文件:先看编译命令,别急着改代码
换构建系统后最常遇到的就是fatal error: xxx.h: No such file or directory。很多人第一反应是去改代码,把 include 路径改成各种../,改得乱七八糟。我的建议是别急着动代码,先看真实编译命令。
CMake 构建时可以加--verbose查看完整命令:
cmake --build build --verboseMake 系的构建是:
make VERBOSE=1看到完整的编译命令后,你立刻就能明白编译器到底有没有把某个路径传进-I。然后去对应的 CMakeLists 里补target_include_directories,这才是根治。另外一个小技巧:用cmake -LAH build可以查看所有构建缓存变量,快速确认哪些配置被启用、哪些宏被定义,排查问题非常方便。
这个问题的根源往往是 AI 生成的 include 风格太随意。我见过最离谱的是 AI 写#include "../../../../shared/config.h",在旧构建系统里因为刚好目录结构匹配所以能编过,换到 CMake 后这种写法就跟定时炸弹一样。现在团队里约定,一律用#include <module/xxx.h>配合 target 级 include 目录,再也不准写多层相对路径。
4.2 链接失败:undefined reference 的常见诱因
链接阶段的undefined reference一般有四个常见原因。第一是链接顺序问题,尤其是静态库之间的相互依赖,GNU ld 在解析静态库时是从左到右,被依赖的库要放在后面,CMake 的target_link_libraries通常会自动处理,但如果你用变量手动拼链接参数,顺序就很容易出错。第二是符号导出缺失,前面说过的动态库导出宏,这就是最典型的例子。第三是漏掉系统库,比如多线程代码忘了加Threads::Threads,用了动态库函数没加CMAKE_DL_LIBS。第四是编译器版本或 ABI 不匹配,新旧目标文件混在一起。
排查时可以用nm -C看看某个库里到底有没有这个符号:
nm -C libfoo.a | grep requiredSymbol或者用readelf -Ws libfoo.so | grep requiredSymbol查看动态库导出符号。先确认符号存在,再看顺序和导出宏,一般十分钟内能定位。
这里多说一句,AI 写代码时特别喜欢默认“这个函数系统里肯定有”,但实际链接器什么都不知道。所以我在提示词里强制要求 AI 注释里写清楚外部依赖库名,这样排查时就能直接对应上,不用猜。
4.3 测试突然全红:先查工作目录和运行时依赖
换构建系统后测试批量失败,很多时候不是测试逻辑错了,是运行环境变了。最典型的就是工作目录。ctest 默认把测试进程的工作目录设为构建目录,而旧构建系统可能是项目根目录。处理方式有两种:一种是在add_test时指定WORKING_DIRECTORY:
add_test(NAME test_config COMMAND test_config_bin) set_tests_properties(test_config PROPERTIES WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} )另一种是让代码彻底不依赖工作目录,通过预处理宏拿到资源目录。比如在 CMake 里定义:
target_compile_definitions(test_config PRIVATE TEST_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}/testdata" )然后在 C++ 里直接用TEST_DATA_DIR拼接路径。这个方案更干净,测试无论在哪个目录启动都稳定。
另外还要注意环境变量。旧 Makefile 里如果有export LD_LIBRARY_PATH=...这类操作,CMake 不会自动继承,需要在 CMake 里用set_tests_properties的ENVIRONMENT属性重新设置。用ctest --test-dir build -V能打印每个测试的完整输出,排查这类问题效率很高。
4.4 一个容易被忽略的坑:头文件依赖让编译变慢
这个坑挺隐蔽。手写 Makefile 的时候,很多团队根本不会严格声明头文件依赖,甚至有的 Makefile 根本不追踪头文件变化,导致“改了头文件但没触发重编”,很多人是在这种残缺环境下“误以为增量编译很快”的。换到 CMake + Ninja 之后,头文件依赖追踪变得严格,AI 生成的那些大量使用内联函数和模板的头文件,一旦改动会拖着重编一大片目标。
直观感受就是:迁移后首次全量变慢,后面 AI 改一次头文件也要触发很多模块重编,团队的开发节奏被打乱。解法有几种:让 AI 生成代码时尽量把实现放进.cpp而不是堆在头文件里;对外接口多用前置声明减少头文件 include;如果项目体量真的很大,可以研究一下 unity build 或预编译头。但核心还是从 AI 生成端的习惯改起,别让头文件变成一个巨型依赖源。
5. 把“构建系统”当成AI工程的长期资产
5.1 选一个AI能“读得懂”的构建系统
经历过这次迁移,我在新项目选型时多了一个之前没太在意的维度:这个构建系统,AI 到底能不能“读懂”。AI 的生成质量高度依赖训练语料,语料里 CMake 相关内容海量,xmake、Meson 相对少一些,Bazel 虽然多但主要集中在大厂场景。对大多数中小团队来说,选 CMake 意味着 AI 生成的 CMakeLists 脚本普遍更可靠,出了报错也更容易在网上找到方案。
| 构建系统 | 声明式程度 | 依赖可见性 | AI 生成质量 | 学习成本 |
|---|---|---|---|---|
| CMake | 中 | 中(target 级可见) | 高(语料丰富) | 中 |
| Bazel | 高 | 高(严格依赖) | 中(语料集中在大厂场景) | 高 |
| xmake | 中 | 中 | 偏低(语料少) | 中低 |
| Meson | 高 | 中 | 偏低(语料少) | 中 |
我的观点是:小团队、快速迭代、强依赖 AI 辅助的,优先选 CMake 或 xmake,别为了炫技选一套团队没人熟、AI 也不熟的构建系统。大厂里已经有严格工程规范、需要通过构建系统强约束代码架构的,Bazel 这类严格依赖可见性的方案更有价值,但前提是团队有足够的构建工程人力去维护。
5.2 让AI在构建约束内工作,而不是事后补洞
换了新构建系统后,我们做了一件事:让 AI 生成的代码必须通过“干净环境构建 + 测试”才能合入。这个流程不能靠自觉,要靠 CI 和脚本强制。
我的做法是写了一个“构建医生”脚本,放在 CI 里对 AI 生成的代码做静态检查,大致规则是:扫描代码中是否有#include "../"这种跨目录相对引用;检查是否出现硬编码绝对路径;检查新增类是否忘了带导出宏;检查测试代码里有没有直接依赖当前工作目录的路径。一旦命中,直接打回重新生成。这个脚本一开始很简陋,但边走边补,现在已经成了团队里必不可少的关卡。
另外,CI 的构建矩阵也很重要。同一套代码,Debug 和 Release 都编一遍,Windows 和 Linux 都跑一遍,全绿才算数。AI 经常在一种配置下全绿,换个编译选项就崩,构建矩阵能把这些隐患在合入前全部暴露出来。
5.3 构建配置也要当成代码来维护
最后说一个理念问题:很多团队把构建脚本当成“一次性配置”,写完不管,等到环境变了或者迁移时才翻出来改。这是典型的债。我现在的原则是,CMakeLists.txt 和源码一样重要,一样要 review,一样要让 AI 帮忙维护。
具体操作上,我会把 build 目录相关的内容也纳入本地 git 管理范围,但策略地忽略掉具体的 build 产物。AI 提交代码时,我要求它同时检查构建脚本是否受影响,如果新代码需要新增依赖或者修改 target,必须附上对应的 CMake 改动。这样构建脚本和源码保持同步,不会再出现“代码能跑,但拉一个新环境编不过”的尴尬。
另外还有一个习惯:每周都跑一次“从零构建”验证。把 build 目录删掉,执行一遍完整的 CMake 配置和编译,确保整个流程不依赖任何开发者本机残留的缓存。很多项目问题都是“我机器上能编”造成的,这个动作能把这类问题压到最低。
最后再分享一点实在的
我个人在迁移构建系统这件事上踩过最大的坑,就是对“AI 全绿”过度信任。现在团队里每个人都知道:全绿是结果,不是保证;构建系统是约束,不是背景板。AI 最大的贡献是把你从重复劳动里解放出来,但工程里的那些边界条件、隐式依赖、平台差异,依然需要人脑去约束。如果你也在用 AI 写代码,建议从今天开始,把构建系统当成“一等公民”来维护。别等换构建系统那天,才知道 AI 的全绿是“温室里的绿”。