程序员不是为了消灭复杂度,而是为了把复杂度关进笼子里

AI用的越多,错误越多
最近我开始重度依赖 AI 编程,效率确实呈指数级提升,但一个诡异的现象也随之而来:Bug 变多了,而且是那种"莫名其妙的崩" 。
就在上周,我用 AI 完成了一个在网站所有页面页头添加购物车按钮的逻辑。AI 很快给出了代码,我做了几个简单的正向测试------加购、打开购物车、点击支付,链路跑通了。我放心地合代码、上线。上线之后,没有任何问题,结果上线几天后,在一个周末的下午(当时我还在外面打台球),突然客服部门收到反馈,线上的网页都崩溃了,原因是购物车中的一个接口报错了,我没有给购物车按钮添加ErrorBoundary,导致错误扩散到了全局,整个 Web 应用直接展示页面错误UI。
当时的代码大概是这样的:
javascript
// layout.tsx
<Suspense>
<CartButton />
</Suspense>
// api 会在接口异常时 throw error
const CartButton = () => {
const { data } = useSuspenseQuery({
queryFn: () => Promise.all([
api.getCartVideoList(),
api.getCartMusicList(),
api.getCartPhotoList()
])
})
...
}
少包了一个 ErrorBoundary
javascript
// layout.tsx
<ErrorBoundary> // <-- 缺少这个,错误扩散到全局了
<Suspense>
<CartButton />
</Suspense>
</ErrorBoundary>
这在过去的"古法编程"时代是极少发生的。以前我逐行手敲代码,每个条件分支和错误边界都在我的心智模型里。而现在,AI 写的代码能工作,但它的结构、命名习惯、边界处理方式和我迥异。我在测试时只验证了"快乐路径",却完全没意识到 AI 在某个角落遗漏了"失败边界"。
这让我陷入深深的反思:到底是我的测试太草率,还是 AI 写的代码太复杂、不符合我的阅读习惯?又或者,AI 写的其实是对的,只是我自己的认知存在缺失,看不懂它在做什么?
软件工程的本质,解决软件的复杂度
带着这些困惑,我回到软件工程最本质的话题------复杂度。我想借此厘清:在 AI 编程时代,哪些复杂度可以放心交给 AI,哪些复杂度必须由程序员亲手"关进笼子里"?
复杂度通常分为两种,一种叫做偶然复杂度 ,另一种叫本质复杂度。
比如你可能需要处理一些语言的历史问题:
比如 typescript 为了兼容老版本的 javascript 中的对象键值,允许写出这样的代码
vbnet
const object: Record<string, string> = {}
// type object.d is string, but it's undefined
console.log(object.d)
const NAME = 'name'
console.log(object[NAME])
这段代码很奇怪,因为这个 obj 上并没有一个 d 的属性,这里的类型推断为 string,但运行时是 undefined,这种类型与运行时的脱节容易引发下游逻辑错误。
这段 TypeScript 代码,AI 很容易因为训练数据里常见而生成这种写法,它语法没错,但 类型即文档 的意图被破坏了。这种类型与运行时的脱节,就是典型的偶然复杂度,AI 感知不到其中的隐患,只能靠人来把关。
比如 javascript 中,存在隐式类型转化的场景
csharp
function test() {
const a = 0
// const a = []
// const a = ''
if(a) { // a被隐式转化为了boolean值
// true
} else {
// false
}
}
这些都是偶然复杂度 ,人需要对这个语言,框架足够了解,才能真的避免或者控制这些复杂度。因此,偶然复杂度真正的解药不是让 AI 写得更好,而是让程序员在代码审查时,具备识别"类型脱节"和"边界漏洞"的肌肉记忆。 AI 负责铺路,人负责检查路边的护栏。
第二部分的复杂度是本质复杂度,这代表着,业务需求本身就很复杂,用户的搜索、筛选、下单、支付、权限、风控、埋点、兼容历史数据,这些东西天然就复杂。好的软件设计不是让这些事情变得简单,而是让复杂度有归属、有边界、有局限性。
面对本质复杂度,AI 的表现就更力不从心了。因为本质复杂度中存在着大量隐性的上下文 ,很多模块的功能,存在着历史原因和业务妥协,但是AI并不清楚,如果这些模块本身就相互影响,复杂度外泄 ,那么AI 能做的也只是把这坨**"粪山"代码变得更大更难以理解**,以至于你在修改时已经完全看不懂它的逻辑了------但代码却依然能"神奇地"工作。
因此,AI 编程时代,定义"变化边界"成了程序员的必修课。 我们不是在和 AI 比谁敲键盘快,而是在比谁更能看清业务背后那条隐形的"边界线"。
那么如何让复杂度有归属呢?如何判断这一堆文件是否属于某一个模块呢?
比较核心的观点就是,按照它们变化的原因,询问这个东西为什么会变?
比如:
- 搜索筛选会变,是因为产品调整搜索规则(search module)
- 分页会变,是因为展示策略调整 (UI component)
- 埋点会变,是因为数据库分析口径调整(stat module)
- 支付会变,是因为交易链路调整(buy module)
如果变化原因不同,却被揉在同一个模块里,那以后每次改动都会互相污染。
明确了模块边界之后,下一个问题自然浮现:模块之间的边界靠什么来守护?答案是接口。 但接口设计的核心目的是什么?
答案是收敛复杂度。 我们一直都在强调,模块自身应该是高类聚的,它应该处理好业务本身的复杂度,不让业务本身的复杂度扩散,模块与模块之间应该低耦合的,双方应该按照约定,使用设计好的接口进行通信和数据交换。
这里引发出一个新的思考,不同模块之间的接口是因为外界模块需要调用,才进行抽象和设计的吗?
我们常误以为抽象接口是为了方便多处调用,但这是本末倒置的。其实核心目的不是为了复用,而是为了隔离。
如果只是因为复用,外界模块完全可以直接依赖实现,但是这不够好,因为实现容易因为各种原因而变化,比如换了一种新的包来解决相似问题,但是其他业务模块却依赖老包的特定能力,导致切换之后无法保证模块运行正常。
因此,这也是依赖倒置原则的体现,不同模块之前,应该依赖接口,而不依赖实现。
另外,复用经常是伪命题,今天可能复用,但是明天可能某一块的功能就被砍掉了。导致后来再看这里,可能就变成了过度设计。
因此,提供接口更重要的是去思考:
- 这个东西改的时候,会不会影响不该影响的地方?
- 这个业务规则能不能只在一个地方出现?
- 这个模块能不能独立测试?
- 外面能不能不用理解它的内部细节?
所以"低耦合"的真正价值不是优雅,而是降低心智负担。
我们可以形成一个自己的软件设计判断框架:
第一层:业务边界
这个模块解决那个业务问题?
比如搜索、购物车、订单、支付、用户、埋点、推荐。
如果一句话说不清这个模块是干什么的,说明边界已经模糊了。
第二层:变化边界
它未来会因为什么原因变化?
如果两个东西变化原因不同,就不要轻易绑死。
两个变化原因不同的东西,不要绑死在同一个文件里。
第三层:数据边界
这个模块使用什么数据?
谁可以读?谁可以改?状态从哪里来?在哪里落地?
数据流向不清晰,复杂度就已经开始泄漏了。
第四层:接口边界
外部最多需要知道什么?
哪些细节应该被隐藏起来?
接口不是越丰富越好,而是越少越稳定。
第五层:失败边界
它失败了,影响的范围是什么?
能不能局部失败,而不是整页崩?
一个模块的错误如果能让整个应用白屏,说明它的边界是失控的。
回到最初那个让我困惑的问题:AI写的代码能跑,但为什么一上线就崩?
现在我的答案是: AI 是我们的执行者,我们是它的架构师。
我们应该先设计好架构、划分好边界、抽象出接口,然后再把具体的实现交给 AI 去填充。但有一道底线必须守住------永远不要合入一段你自己看不懂的代码。 如果代码已经难以理解,说明复杂度没有做好隔离,此时必须停下来重构,而不是让 AI 继续往上堆砌。
好的软件设计,从来不是追求代码洁癖式的优雅,而是追求:可理解、可修改、可验证、可替换、可局部失败。
程序员不是为了消灭复杂度------那是不可能的。我们的工作,是把复杂度关进笼子里。