大家好,下午好。我将以一个坦白开始我的演讲。我曾提交过自己并不完全理解的代码,生成它、测试它、部署它,却无法解释它是如何工作的。而事实是,我敢打赌你们每个人都这样做过。
承认现实
所以,现在我要承认,我们都在提交自己不再理解的代码。我想带大家踏上一段旅程,看看这一切是如何发生的。首先,回顾历史,我们会发现历史往往会重演。其次,我们陷入了一个陷阱:将‘容易’与‘简单’混淆了。最后,有一个解决方案,但这需要我们不外包自己的思考。
AI加速与风险
我在Netflix工作了几年,帮助推广AI工具的应用,我必须说,这种加速是真实存在的。过去需要几天才能完成的待办事项,现在只需几个小时;那些搁置多年的大型重构项目,也终于得以完成。但问题在于,大型生产系统总是会以意想不到的方式失败。就像最近发生的Cloudflare(云雀网络:一家美国公司,提供CDN、DNS、DDoS防护等网络服务)事件。当这种情况发生时,你最好能理解你正在调试的代码。而现在的问题是,我们生成代码的速度和数量如此之快,以至于我们的理解能力难以跟上。说实话,我自己也这样做过。我生成了一堆代码,看了看,心想:我完全不知道这玩意儿是干嘛的。但测试通过了,它能运行。于是,我就把它提交了。
历史的循环
关键在于,这并非新鲜事。每一代软件工程师最终都会遇到一个瓶颈,即软件的复杂性超出了他们管理的能力。我们不是第一批面临软件危机的。我们是第一批以这种无限的代码生成规模来面对它的人。所以,让我们回顾一下这一切是如何开始的。在20世纪60年代末、70年代初,一群当时的顶尖计算机科学家聚集在一起,说道:“嘿,我们正面临一场软件危机。我们对软件有着巨大的需求,但却无法跟上步伐,项目耗时过长,进展缓慢,我们做得不够好。”
于是,Edsger W. Dijkstra(埃兹赫尔·迪杰斯特拉:荷兰计算机科学家,图灵奖得主,在算法和结构化编程领域贡献卓著)说了一句非常深刻的话:“当我们的计算机还很弱小的时候,编程只是一个小问题;而现在,随着计算机变得无比强大,编程已经变成了一个巨大的难题。”他解释说,随着硬件能力增长千倍,社会对软件的需求也随之增长,这使得程序员必须在方法和手段上找到出路,以支持如此庞大的软件需求。
这种模式不断循环。70年代,我们有了C编程语言,以便编写更大的系统。80年代,个人电脑普及,人人都能编写软件。90年代,我们迎来了面向对象编程,以及“地狱般的继承体系”(感谢Java)。2000年代,敏捷开发、冲刺(sprints)和Scrum大师指导我们工作,瀑布模型不再流行。2010年代,我们拥有了云计算、移动开发、DevOps等等。软件真正地“吞噬了世界”。
AI时代的新挑战
而今天,我们有了AI。比如Co-pilot(微软推出的AI编程助手)、Cursor(一款AI优先的编辑器)、Claude(Anthropic公司开发的大型语言模型)、Codeex、Gemini(Google开发的多模态AI模型),等等。我们可以以描述它的速度来生成代码。这个模式仍在继续,但规模已经彻底改变——它现在是无限的。
那么,Fred Brooks(弗雷德里克·布鲁克斯:美国计算机科学家,以《人月神话》闻名),你可能从他的著作**《The Mythical Man-Month》**(《人月神话》)中认识他。他还在1986年发表了一篇题为“No Silver Bullet”(并非银弹)的文章。在文中,他论证了不会有单一的创新能够带来软件生产力数量级的提升。为什么?因为他说,困难的部分从来不是编码的机械操作——语法、输入、模板代码。困难在于理解实际问题并设计解决方案。没有任何工具能够消除这种根本性的难度。我们迄今为止创造的每一个工具和技术,都让机械操作变得更容易。然而,核心挑战——理解要构建什么、它应该如何工作——仍然一样困难。
混淆“容易”与“简单”
那么,如果问题不在于机械操作,我们为何还要不断优化它?经验丰富的工程师为何会写出自己不理解的代码?我认为,答案在于我们常常混淆的两个词:“简单”(simple)和“容易”(easy)。我们倾向于将它们互换使用,但它们实际上意味着完全不同的东西。在演讲者晚宴上,我被揭露是Closure(一种函数式编程语言)的爱好者,所以这里可能有点明显。但Rich Hickey(理查德·希基:Closure编程语言的创建者),在他2011年的演讲“Simple Made Easy”(简单即容易)中解释了这一点。他将“简单”定义为“单一折叠、单一编织、无纠缠”。每个部分只做一件事,不与其他部分交织。他将“容易”定义为“邻近”——触手可及,无需努力即可访问。复制粘贴,然后提交。“简单”关乎结构,“容易”关乎可及性。
关键在于,我们无法凭空让某件事变得简单。“简单”需要思考、设计和解耦。但我们总能让某件事变得“容易”。你只需把它放在手边——安装一个包,用AI生成它,或者从Stack Overflow(一个流行的程序员问答网站)复制一个解决方案。走“容易”的路是人之常情,我们天生如此。就像我说的,从Stack Overflow复制东西,它就在那里。或者使用一个神奇地为你处理一切的框架,安装即用。但“容易”不等于“简单”。“容易”意味着你可以快速地向系统中添加功能;“简单”意味着你能理解你所做的工作。每一次我们选择“容易”,都是在选择“现在的速度”和“未来的复杂性”。老实说,这种权衡过去是有效的。代码库中的复杂性积累得足够缓慢,使我们能够在需要时进行重构、反思和重建。我认为AI打破了这种平衡,因为它提供了终极的“容易按钮”。它让“容易”的路径如此顺畅,以至于我们不再考虑“简单”的选项。当代码瞬间出现时,谁还会去思考架构呢?
案例:AI与认证流程
让我展示一下这是如何发生的:一个简单的任务,如何通过我们都喜爱的对话式界面,演变成一团糟的复杂性。这是一个人为设计的例子,假设我们有一个应用,想添加一些认证功能。我们输入“添加OAuth”,得到一个干净的oauth.js文件。迭代几次后,它变成了一个message.js文件。你可能会想:“好的,很酷。”然后我们又说:“现在也添加OAUTH”,于是我们有了oauth.js和oauth_token.js。我们不断迭代,然后发现会话(sessions)出错了,出现了一堆冲突。到迭代20次时,你不再是进行讨论,而是在管理一个极其复杂的上下文,甚至你自己都不记得添加的所有约束。遗留了来自被放弃方案的死代码;测试被“修复”,只是为了让它们通过;可能还存在三个不同解决方案的碎片,因为你不断地说“等等,实际上……”。每一个新指令都在覆盖架构模式。我们说“让OAuth在这里工作”,它做到了。我们说“修复这个错误”,它也做到了。糟糕的架构决策没有任何阻力,代码只会为了满足你最新的请求而变形。每一次交互都是在选择“容易”而非“简单”。而“容易”总是意味着更多的复杂性。我们本应知道得更清楚,但当“容易”的路径如此轻易可得时,我们就会选择它。复杂性将不断累积,直到为时已晚。
AI将“容易”推向了逻辑的极致:决定你想要什么,瞬间获得代码。但危险在于,生成的代码会以同样的方式对待你代码库中的每一个模式。当一个AI代理分析你的代码库时,每一行都变成了一个需要保留的模式。第47行的认证检查是一个模式;那个奇怪的、表现得像GraphQL的gRPC代码(可能是我在2019年写过的),那也是一个模式。技术债务不会被识别为债务,它只是更多的代码。
定义复杂性
这里真正的核心问题是“复杂性”。我知道我在演讲中反复提到这个词,但没有真正定义它。理解它的最佳方式是:它是“简单”的反义词。它意味着“相互交织”。当事物复杂时,一切都相互关联。你无法改变一件事而不影响其他十件事。
回到Fred Brooks的“No Silver Bullet”论文。他指出了系统中两种主要的复杂性:“本质复杂性”(essential complexity),这是你试图解决的实际问题的根本性难度。例如,用户需要付款,订单必须被履行。这是你的软件系统存在的根本原因所带来的复杂性。其次,是**“偶然复杂性”(accidental complexity)**。这是我们在此过程中添加的一切:变通方法、防御性代码、曾经有意义的框架和抽象层——所有这些都是为了让代码本身能够工作的附加物。
在真实的 कोड库中,这两种复杂性无处不在,并且它们会纠缠在一起,以至于分离它们需要上下文、历史和经验。而AI生成的代码却无法做出这种区分,因此每一个模式都会被保留下来。
Netflix案例:授权重构
这是一个来自Netflix的真实案例。我有一个系统,在五年前编写的旧授权代码和一个新的集中式认证系统之间,存在一个抽象层。当时我们没有时间重建整个应用,所以我们只是在中间加了一个“垫片”(shim)。现在有了AI,这是一个重构代码以直接使用新系统的绝佳机会。听起来是个简单的请求,对吧?然而,事实并非如此。旧代码与它的授权模式紧密耦合。例如,权限检查渗透在业务逻辑中,认证假设被硬编码到数据模型中,认证调用散布在数百个文件中。AI代理开始重构,处理了几个文件后,就会遇到一个无法解耦的依赖,然后彻底失控并放弃。更糟糕的是,它可能会试图保留旧系统中的一些现有逻辑,并用新系统来重现它,我认为这也不是一个好结果。
关键是,AI看不到“缝隙”。它无法区分业务逻辑在哪里结束,认证逻辑在哪里开始。一切都如此纠缠在一起,即使拥有完美的信息,AI也找不到一条清晰的路径。当你的“偶然复杂性”变得如此纠缠时,AI并非最佳帮手,它只会增加更多的层级。
人类视角的重要性
我们能够区分这一点,或者说,当我们放慢速度去思考时,我们就能做到。我们知道哪些模式是“本质的”,哪些只是几年前某个解决方案的产物。我们拥有AI可以推断出的上下文信息,但这只有在我们花时间在开始之前做出这些区分的前提下才行。
解决方案:上下文工程
那么,我们该如何做到这一点?在面对庞大的代码库时,如何区分“偶然复杂性”和“本质复杂性”?我在Netflix工作的代码库有大约一百万行Java代码,其中主服务大约有500万个token。我所能访问的任何上下文窗口都无法容纳它。所以,当我需要处理它时,我首先想到:“也许我可以将代码库的大部分内容复制到上下文中,看看模式是否会浮现,看看它是否能自行理解发生了什么。”但就像之前的授权重构一样,输出结果完全迷失在自身的复杂性中。因此,我不得不采取不同的方法:我必须选择要包含的内容——设计文档、架构图、关键接口等等,并花时间写下组件应如何交互以及遵循何种模式的要求。我写了一个规范,将500万token浓缩成2000字的说明。更进一步,我将这个规范转化为一套精确的代码执行步骤。没有模糊的指令,只有精确的操作序列。我发现这产生了更清晰、更集中的代码,并且我能够理解它。当我先定义好并规划好执行过程后,这便是我之前称之为“上下文压缩”(context compression)的方法。你可以称之为“上下文工程”(context engineering)或“规范驱动开发”(spec-driven development),名字不重要。重要的是,思考和规划成为了工作的主体。
下面我将详细介绍实践中的具体操作。第一步,第一阶段:研究。我会将所有相关信息一次性输入给AI,包括架构图、文档、Slack讨论等。我反复强调这一点,但关键是尽可能多地提供与你正在进行的更改相关的上下文。然后,使用AI代理分析代码库,绘制出组件和依赖关系图。这不应是一次性的过程。我喜欢进行探究,比如“缓存是如何处理的?”“它如何处理失败?”当它的分析错误时,我会纠正它;如果它缺少上下文,我会提供。每一次迭代都会优化其分析。最终输出的是一份研究文档:它说明了现有内容、它们之间的连接关系,以及你的更改将影响哪些部分。数小时的探索被压缩成几分钟的阅读。
我知道Dex今天早上提到了这一点,但这里的“人类检查点”(human checkpoint)至关重要。这是你将AI的分析与现实进行验证的时刻。这是整个过程中杠杆率最高的时刻。在此处捕获错误,可以避免日后发生灾难。
进入第二阶段。在你掌握了有效的研究成果后,我们创建一个详细的实现计划。包括真实的代码结构、函数签名、类型定义、数据流。你希望这份计划详细到任何开发者都能遵循。我将其比作“数字填色”(paint by numbers)。你应该能够把它交给最初级的工程师,并说:“去照着做。”如果他们逐行复制,代码就应该能正常工作。这一步是我们做出许多重要架构决策的地方。确保复杂逻辑的正确性,确保业务需求遵循良好实践,确保有良好的服务边界、清晰的隔离,并防止任何不必要的耦合。我们能在问题发生前就发现它们,因为我们经历过。AI没有这个选项,它将每一个模式都视为一个需求。这一步真正的魔力在于评审速度。我们可以在几分钟内验证这个计划,并确切知道将要构建什么。为了跟上我们想要生成代码的速度,我们也需要同样快速地理解我们正在做的事情。
最后是实现阶段。现在我们有了清晰的计划和明确的研究支持,这一阶段应该相当简单。这就是重点。当AI遵循清晰的规范时,上下文保持干净且专注。我们避免了冗长对话带来的复杂性螺旋。我们不会得到50条迭代式代码的回复,而是得到三个专注的输出,每个输出在进行下一步之前都经过验证。没有被放弃的方案,没有冲突的模式,也没有那些导致死代码随处可见的“等等,实际上……”的时刻。对我来说,真正的回报是,你可以让AI代理完成大量工作,因为你已经提前完成了所有的思考和艰苦工作。它可以直接开始实现。你可以去做别的事情,然后再回来评审,并且可以快速评审,因为你只是在验证它是否符合你的计划,而不是试图理解它是否“发明”了什么。
AI是加速器,而非思考者
关键在于,我们不是让AI替我们思考。我们是利用它来加速机械部分的工作,同时保持我们理解它的能力。研究更快,规划更周全,实现更干净。而思考、综合和判断,仍然掌握在我们手中。
还记得我之前提到的那个AI无法处理的授权重构吗?现在我们实际上正在着手处理它,并取得了一些进展。但这并非因为我们找到了更好的提示词。我们发现,我们甚至无法直接进入研究、规划或实现阶段。我们不得不亲自动手进行这项更改。没有AI的介入,只是阅读代码、理解依赖关系,并进行更改以观察会发生什么。坦白说,那次手动迁移很痛苦,但至关重要。它揭示了所有隐藏的约束、必须保持不变的规则,以及如果认证发生变化会导致哪些服务崩溃。这些是任何代码分析都无法为我们揭示的。然后,我们将这次手动迁移的Pull Request输入到我们的研究流程中,让它作为后续研究的种子。AI随后就能看到一个干净的迁移示例。但关键是,这些实体(指代码中的各个部分)都略有不同。所以我们必须逐一审视,询问“我们该如何处理?”有些东西被加密了,有些则没有。每次都需要通过大量的迭代来提供额外的上下文。只有这样,我们才可能生成一个可能一次性成功的计划。而“可能”是这里的关键词——我们仍在验证、调整,并发现边缘情况。
这种三阶段方法并非魔法。它之所以有效,是因为我们手动完成了这次迁移。我们必须“赢得”理解,才能将其融入我们的流程。我仍然认为没有“银弹”。我不认为存在更好的提示词、更好的模型,甚至更好的规范。真正重要的是深入理解你的系统,以便能够安全地对其进行更改。
为何不直接与AI迭代?
那么,为什么要经历这一切呢?为什么不直接与AI迭代,直到它能工作为止?模型最终不会足够强大,以至于它就能直接工作吗?对我来说,“能工作”是不够的。能够通过测试的代码与能在生产环境中生存的代码之间存在差异;今天能运行的系统与未来能被他人修改的系统之间也存在差异。这里真正的核心问题是“知识鸿沟”(knowledge gap)。当AI能在几秒钟内生成数千行代码时,理解它可能需要你数小时,甚至数天(如果复杂的话),谁知道呢,如果它真的那么纠缠不清,可能永远也无法理解。
还有一点,我认为很多人甚至没有谈论过:每一次我们为了跟上生成速度而跳过思考,我们不仅在添加我们不理解的代码,我们还在失去识别问题的能力。那种“嘿,这变得太复杂了”的直觉,在你无法理解自己的系统时,就会萎缩。
模式识别来自于经验。当我发现一个危险的架构时,那是因为我曾凌晨三点还在处理它。当我推动更简单的解决方案时,那是因为我不得不维护别人留下的复杂替代方案。AI会生成你所要求的东西,但它不会编码过去的失败教训。
三阶段方法弥合了这一差距。它将理解压缩成我们可以以生成速度进行评审的产物。没有它,我们只会以比理解能力更快的速度积累复杂性。
结论
AI改变了我们编写代码的一切。但老实说,我认为它并没有改变软件本身失败的原因。每一代人都面临着自己的软件危机。Dijkstra那一代人通过创建软件工程这门学科来应对;而现在,我们则面临着无限代码生成带来的危机。
我认为解决方案不是另一个工具或方法论,而是要记住我们一直都知道的:软件是一项人类的事业。困难的部分从来不是输入代码,而是首先知道该输入什么。那些蓬勃发展的开发者,不仅仅是那些能生成最多代码的人,更是那些理解自己正在构建什么、能看到“缝隙”、能识别出自己是否在解决错误问题的人。这仍然是我们,而且只会是我们。
我想留下一个问题,我认为问题不在于我们是否会使用AI——这已是定局,船已经启航。对我来说,问题将是我们是否还能理解我们自己的系统,当AI编写我们的大部分代码时。
谢谢大家。 [掌声]
📌 文中提及的人物和组织
人物: Fred Brooks
公司/组织: Netflix, Cloudflare
产品/模型: AI, Co-pilot, Cursor, Claude, Codeex, Gemini
媒体/书籍: The Mythical Man-Month