第一时间捕获有价值的信号
本文译自 Martin Fowler 网站上的深度对话 Conversation: LLMs and the what/how loop。参与者包括 ThoughtWorks 首席技术官 Rebecca Parsons、首席架构师 Unmesh Joshi,以及软件工程领域最具影响力的思想家 Martin Fowler。本文深入探讨了 LLM 如何重塑软件抽象的构建过程,以及「是什么」与「怎么做」之间的认知循环。
软件设计的本质挑战
软件开发的主要挑战不是将需求转换为代码,而是构建能够经受变化的系统。真正让系统易于改变的关键在于管理认知负载——通过模块化结构和反映领域概念的熟悉性来降低理解成本。
Unmesh:软件开发的主要挑战是构建能经受变化的系统。编程新手,以及那些不靠编程谋生但雇佣程序员的人,通常认为编程是将需求线性转换为编程语言语法。这种观点误导人们要么花大量时间把需求或规格说明做对,要么在编程语言语法上下功夫。在 LLM 使用的背景下,这种观点体现在「Human in the loop」这样的短语中。这个术语暗示将需求转换为代码的主要工作由 LLM 完成,人类只在机器失败时进行清理。但正如任何经验丰富的程序员所知,真正的挑战不是将需求转换为代码,而是构建能经受变化的系统。什么让系统在变化发生时更容易管理?
Martin:为了让事情更容易改变,我们需要为那些需要做出改变的人管理认知负载。让事情更容易改变的一个关键是管理认知负载。我可能无法把一百万行代码装进脑子里,但如果系统被很好地结构化为模块,我可能只需要理解其中的几百行——然后我就能取得进展。另一个关键是当代码反映一些我已经熟悉的东西时。如果我在做一个航运系统,而代码有像船舶、港口和集装箱这样的元素——并且这些元素的行为符合我从领域知识中期望的方式——那么这有助于我推理代码如何工作。
Rebecca:管理认知负载还需要在各种粒度级别上理解领域。这种分解让我们能够通过我们的抽象来推理属性,而不必总是陷入细节。与设计过程的其他方面一样,获得正确的抽象级别是迭代的。
「是什么」与「怎么做」的映射关系
编程本质上是将「真实」领域(是什么)映射到计算模型(怎么做),两者形成持续反馈循环而不是独立的两个阶段。这种映射发生在系统、类和函数多个层次上,「是什么」的答案决定逻辑分组和命名,「怎么做」的选择塑造解决方案结构。
Unmesh:从本质上讲,编程行为是将「真实」领域(是什么)映射到计算模型(怎么做)。如果你需要处理英语提示或图表,认知负载不一定会降低。从本质上讲,编程行为是将「真实」领域(是什么)映射到计算模型(怎么做)。我们必须在将领域转换为机器可以执行的形式时保留领域的本质。关键是,这不是单向的;这是一个持续的反馈循环,「是什么」和「怎么做」彼此提供深刻的洞察。它们一起揭示了系统的稳定部分以及系统未来可能变化的轴线,让我们能够使用编程范式为这些变化提供钩子。
Martin:我经常听到人们用「是什么」和「怎么做」来描述需求分析和编程之间的区别。需求是关于「是什么」,编程是关于「怎么做」。但我从不喜欢那种框架,它试图将「是什么」和「怎么做」分离到独立的宇宙中,我不认为这是思考世界的有效方式。
Unmesh:「是什么」和「怎么做」不是独立的宇宙,而是相互交织的。我同意。表面上看,这似乎是一个很好的区分。我们从白板和高级用户旅程开始讨论系统目标。我们将这些映射到实现领域——数据库、服务和 UI。但「是什么」问题在每个级别都存在:系统级别:用户试图实现什么?类级别:这个组件应该做什么?函数级别:这个特定块是做什么的?影响:「是什么」的答案决定了逻辑分组,最重要的是,命名。一旦意图被命名(并通过我们的实现洞察进行完善),我们就面临执行的机制。我们在这里做出的决定塑造了解方案的结构。
计算模型与编程范式
选择如何在计算机中表示真实领域涉及将领域映射到计算模型(如状态机、流、日志等),不同映射会导致不同的推理方式。社区围绕编程语言形成共享的习语和模式词汇,LLM 可以利用这些习语作为转换层帮助表达意图。
Rebecca:「怎么做」的一个关键部分是选择如何在计算机可以执行的东西中表示真实领域。「怎么做」的一个关键部分是选择如何在计算机可以执行的东西中表示真实领域。我们经常通过将领域映射到熟悉的计算模型来做到这一点——比如状态机、表格、流或日志。这种映射不是中性的:以状态机为例,如果我们将某些东西映射到状态,我们开始对它的推理方式就会不同于将其映射到转换。这种映射的形状通常是解决方案结构开始浮现的地方。如果我们不使用带有内置语义的模型,那么我们必须开发一个可以在代码中表示的心智模型(抽象)。在这一点上,我们对语言范式的选择将非常重要,因为有些抽象在特定范式中很容易表示,而其他的则更困难。例如,一个无界但有限的列表在函数式语言中更容易表示。
Martin:是的……只是我不认为把 OO 和函数式看作独立范式是有帮助的。我把对象(绑定数据和行为)、一等函数、多态、管道等——看作工具。一个理想的编程环境允许我根据需要使用这些工具中的任何一个。函数管道经常被视为函数式编程的东西,但我第一次遇到它们是在我使用 Smalltalk 时。关键是这些都是构建抽象的工具。当人们谈论从汇编语言到第一批高级语言的极其重要的转变时,他们通常会谈论它如何提高了抽象级别。这是真的,但更重要的是,高级语言也给了我们第一批创建我们自己抽象的工具。早期的通常很粗糙(比如 Fortran IV 中的子程序),但有些更强大(比如 Lisp)。从那以后我们一直在稳步发现和完善更多工具。
Unmesh:随着抽象稳定下来,编程看起来就像使用完善的抽象表达意图。一旦我们构建的抽象稳定下来,我们就用它们以声明式编程的形式进行编程。一些领域,那里有一套标准的抽象,特别适合声明式语言——例如,用于数据库查询的 SQL 或用于构建的 makefile。随着抽象稳定下来,编程越来越看起来像使用完善的抽象表达意图。
Martin:我把这些看作替代计算模型,这是 Rebecca 和我在我们的 DSL 书中探索的核心。这仍然是我想更多探索的领域,我想知道与 LLM 合作是否可能在那个方向帮助我们。顺便说一句,我会提到我不是 SQL 的忠实粉丝。我更喜欢使用函数管道来查询表格数据,比如 R 的 dplyr 包。
Unmesh:社区给了我们组织抽象的人类词汇。虽然创建像替代计算模型这样精确的东西很困难,因此也很少见,但开发者社区会围绕他们共同的实现关注点提出一套习语和模式的人类词汇。编程毕竟是一项人类活动,社区自然会围绕编程语言和范式形成。正如 Bjarne Stroustrup 所说:「设计和开发是人类活动,忘记这一点,一切都将失去。」我们看到这样的社区自然形成,因为人们使用语言来解决现实世界的问题。使用 Ruby 和 Ruby on Rails 进行 Web 开发,使用 Python 进行数据科学和基础设施开发,在数据工程社区中使用 Java 和基于 Java 的工具,以及使用 Go 进行系统开发,都是显著的例子。这些习语和模式不仅使做出实现决定更容易,它们还提供了词汇来传达设计的「怎么做」部分。像「只需使用 List-and-Watch 模式来处理 etcd」或「在 Go 中使用带有 select 循环的消息传递通过通道」这样的陈述,让理解如何做一个健壮的实现变得容易。这些习语还使在意图和实现之间转换变得更容易。当与这种理解一起使用时,LLM 工作得非常出色。它们充当转换层,让我们能够用自然语言表达意图。
Martin:由于 LLM 可以在较低的精度级别上运行,它们让我们能够更流畅地探索抽象。这是 LLM 的一个机会。构建抽象需要精度,这就是为什么它很难。由于 LLM 可以在较低的精度级别(模式和习语的级别)上运行,这让我们能够用它们更流畅地探索想法。我很感兴趣看看这是否有助于更容易地转向替代计算模型的精度。
Rebecca:表示的选择由领域及其到编程范式的映射驱动。一旦我们有了这个映射——即使它仍然是非正式的——我们就可以使用 TDD 和 LLM 来驱动适合范式的「怎么做」。
TDD:操作化反馈循环
TDD 是一种设计策略,它将「是什么」与「怎么做」之间的反馈循环操作化。通过先编写测试锁定「是什么」,然后迭代「怎么做」,最后重构完善结构,TDD 让反馈循环变得明确。
Unmesh:当我们在「是什么」和「怎么做」之间迭代时,一旦我们表达了意图或描述了场景,LLM 可以快速显示代码。重要的是要记住,生成的代码通常是使测试通过的第一个版本。我们仍然需要使用编程范式和语言提供的工具,并使用社区的习语来构建解决方案的结构。好的抽象使意图容易转换为实现。当抽象稳定下来并且编程主要是关于在声明式语法中使用这些抽象时,LLM 在语言表示方面的优势变得特别有用。几个提示可以产生一个工作实现。因为抽象构成了提示的基本词汇,生成的程序更可预测且更容易审查。「按交付时间戳在优先队列中排序请求」或「使用存储库存储此客户记录」往往会生成遵循理解良好的习语的代码,使其更容易审查和管理。
Unmesh:TDD 是一种设计策略,它将「是什么」和「怎么做」之间的反馈循环操作化。当我第一次遇到测试驱动开发(TDD)时,我发现它很有吸引力,因为它感觉像是一种将「是什么」和「怎么做」之间的反馈循环操作化的设计策略。TDD 有效是因为它让这个是什么/怎么做循环变得明确:锁定「是什么」:通过先编写测试,你迫使自己在被实现细节分心之前回答「是什么」问题(命名、输入、输出)。你暂时充当自己代码的客户端。迭代「怎么做」:一旦测试存在(红),你就可以自由实现解决方案(绿)。完善结构:然后你进行重构。这是「怎么做」经常告诉你,你的「是什么」(你的 API 设计或测试场景)很尴尬或有漏洞,从而导致更好的设计的地方。
Rebecca:TDD 和用例之间的这种联系很重要,因为它们都是专注于「是什么」的方式。测试应该,但通常不是,专注于那个「是什么」。
Martin:编写测试鼓励我们思考接口,而不将其耦合到实现。我经常注意到,人们在做好封装方面很挣扎,因为他们发现很难在不将接口耦合到实现的情况下思考接口。测试对此有帮助,因为在实现之前编写测试鼓励我只思考 API。这也是一个即时的可用性测试。当我编写测试时,我作为 API 的用户来处理问题,专注于低级别的「是什么」框定了我思考 API 的方式。现在在重构步骤中,我用我从使测试工作中学到的东西重新考虑实现和接口。这有时会引导我产生关于如何表示「是什么」的新想法,既为了直接的 API,也为了我对整个模型的更广泛理解。这不会在每次测试时都发生,甚至不会在大多数测试时发生,但它发生得足够多,足以产生影响。
LLM 的局限与创造边界
LLM 依赖于成熟的训练数据,在前沿领域训练不足。它们可以提出随机的新想法(由于随机创造性),但不能发展新创造,因为缺乏所需训练数据。人类可以使用 LLM 来「幻觉」奇怪但有潜力的东西,然后在此基础上构建。
Rebecca:LLM 依赖于成熟的训练数据——谁构建接下来的东西?然而,这里有一个隐藏的依赖:LLM 依赖于成熟度。如果一种语言不够成熟,或者如果我们正在探索一种新颖的范式,LLM 的训练将不足。在这些前沿领域,工具稀缺,我们实际上只能靠自己。这个约束提出了一个关键问题:由于 LLM 只能在有丰富训练数据的地方运行,在 AI 时代,新语言和范式将从哪里来?如果它们永远不来,我们还会在乎吗?
Martin:这让我想起了作家 Will Wilkinson 说的话:「最好的写作『去熟悉化』熟悉的东西——让它新鲜,重新唤起它,等等。但 LLM 输出是熟悉的平均重新哈希,这是设计使然。」如果我们在与一个常见(熟悉)的问题作斗争,一个好的解决方案必然是不熟悉的。LLM 可以提出随机的新想法——然后人类可以选择并发展它们。另一方面,我确实认为 LLM 可以提出不熟悉的想法,无论是在写作还是编程中。本质上这是由于它们的随机创造,它们在幻觉不熟悉的东西。我的第三只手,它们不能做的是发展那个新创造,因为它们没有所需的训练数据。人类可以使用 LLM 来幻觉一些奇怪但有潜力的东西,然后在此基础上构建。这就是为什么要求 LLM 生成三个不同的替代方案通常很有用,这样人类可以选择最好的一个,或者从所有三个中组装 bits 成更好的东西。
Unmesh:这就是为什么,当我们在演进我们的理解并构建稳定结构时,我们不能简单地「提示」LLM 来做工作。我们当然利用它们,但我们必须迭代地驱动代码来自己构建稳定结构。参考领域特定语言一书,这是我们构建语义模型的阶段。LLM 应该被视为这个阶段的强大工具,但开发者必须保持模型的主要架构师。
Martin:就个人而言,我觉得这是一个令人欣慰的想法。我最喜欢编程的事情之一是模型构建。我希望 LLM 不会把它拿走——实际上会让它更容易和更充实。
结语
编程从来没有魔法,LLM 也没有改变这一点。软件设计的核心挑战仍然是如何在管理认知负载的同时构建能够适应变化的系统。LLM 是这个旅程中的新伙伴,但旅程本身——以及我们对卓越软件的追求——永远不会结束。
编程从来没有魔法,LLM 也没有改变这一点。软件设计的核心挑战仍然是:如何在管理认知负载的同时,构建能够适应变化的系统。LLM 为我们提供了新的工具来探索「是什么」与「怎么做」之间的循环。它们可以快速生成代码、提供替代方案、帮助我们从不同角度思考问题。但它们不能替代人类在抽象设计、架构决策、以及对「好」的判断方面的角色。真正的价值不在于让 LLM 生成完美的代码,而在于利用它们来加速我们的思考循环,让我们能够更快地探索可能性、更快地发现问题、更快地迭代改进。最终,编程仍然是一门持续迭代的艺术。我们需要在「是什么」与「怎么做」之间不断振荡,在抽象与实现之间不断调整,在探索与交付之间不断平衡。LLM 是这个旅程中的新伙伴,但旅程本身——以及我们对卓越软件的追求——永远不会结束。