跳转到正文
莫尔索随笔
返回

对话:LLM 与「是什么/怎么做」循环

预计 19 分钟

第一时间捕获有价值的信号

本文译自 Conversation: LLMs and the what/how loop。这是 Unmesh Joshi、Rebecca Parsons 和 Martin Fowler 三人关于 LLM 如何帮助塑造软件抽象的深度对话。核心观点:编程本质上是将「真实」领域(是什么)映射到计算模型(怎么做),两者形成持续反馈循环;TDD 将这个循环操作化,而 LLM 让我们能以更非正式、更流畅的方式探索这个循环。

Unmesh、Rebecca 和 Martin 关于 LLM 如何帮助我们塑造软件抽象的对话。我们认为挑战在于构建能经受变化的系统,这要求我们管理认知负载。我们可以通过将软件「是什么」映射到编程语言的「怎么做」来实现这一点。这个「是什么」和「怎么做」是在一个反馈循环中构建起来的。TDD 帮助我们将这个循环操作化,而 LLM 让我们能以更非正式、更流畅的方式探索这个循环。

Unmesh

软件开发的主要挑战是构建能经受变化的系统。

编程新手,以及那些不靠编程谋生但雇佣程序员的人,通常认为编程是将需求线性转换为编程语言语法。这种观点误导人们要么花大量时间把需求或规格说明做对,要么在编程语言语法上下功夫。

在 LLM 使用的背景下,这种观点体现在「Human in the loop」这样的短语中。这个术语暗示将需求转换为代码的主要工作由 LLM 完成,人类只在机器失败时进行清理。

但正如任何经验丰富的程序员所知,真正的挑战不是将需求转换为代码,而是构建能经受变化的系统。

什么让系统在变化发生时更容易管理?

Martin

为了让事情更容易改变,我们需要为那些需要做出改变的人管理认知负载。

让事情更容易改变的一个关键是管理认知负载。我可能无法把一百万行代码装进脑子里,但如果系统被很好地结构化为模块,我可能只需要理解其中的几百行——然后我就能取得进展。

另一个关键是当代码反映一些我已经熟悉的东西时。如果我在做一个航运系统,而代码有像船舶、港口和集装箱这样的元素——并且这些元素的行为符合我从领域知识中期望的方式——那么这有助于我推理代码如何工作。

Rebecca

管理认知负载还需要在各种粒度级别上理解领域。这种分解让我们能够通过我们的抽象来推理属性,而不必总是陷入细节。与设计过程的其他方面一样,获得正确的抽象级别是迭代的。

Unmesh

从本质上讲,编程行为是将「真实」领域(是什么)映射到计算模型(怎么做)。

如果你需要处理英语提示或图表,认知负载不一定会降低。从本质上讲,编程行为是将「真实」领域(是什么)映射到计算模型(怎么做)。我们必须在将领域转换为机器可以执行的形式时保留领域的本质。关键是,这不是单向的;这是一个持续的反馈循环,「是什么」和「怎么做」彼此提供深刻的洞察。

它们一起揭示了系统的稳定部分以及系统未来可能变化的轴线,让我们能够使用编程范式为这些变化提供钩子。

Martin

我经常听到人们用「是什么」和「怎么做」来描述需求分析和编程之间的区别。需求是关于「是什么」,编程是关于「怎么做」。但我从不喜欢那种框架,它试图将「是什么」和「怎么做」分离到独立的宇宙中,我不认为这是思考世界的有效方式。

Unmesh

「是什么」和「怎么做」不是独立的宇宙,而是相互交织的。

我同意。表面上看,这似乎是一个很好的区分。

我们从白板和高级用户旅程开始讨论系统目标。我们将这些映射到实现领域——数据库、服务和 UI。但「是什么」问题在每个级别都存在:

  • 系统级别:用户试图实现什么?
  • 类级别:这个组件应该做什么?
  • 函数级别:这个特定块是做什么的?

影响:「是什么」的答案决定了逻辑分组,最重要的是,命名。一旦意图被命名(并通过我们的实现洞察进行完善),我们就面临执行的机制。我们在这里做出的决定塑造了解决方案的结构。

Rebecca

「怎么做」的一个关键部分是选择如何在计算机可以执行的东西中表示真实领域。

「怎么做」的一个关键部分是选择如何在计算机可以执行的东西中表示真实领域。我们经常通过将领域映射到熟悉的计算模型来做到这一点——比如状态机、表格、流或日志。这种映射不是中性的:以状态机为例,如果我们将某些东西映射到状态,我们开始对它的推理方式就会不同于将其映射到转换。

这种映射的形状通常是解决方案结构开始浮现的地方。如果我们不使用带有内置语义的模型,那么我们必须开发一个可以在代码中表示的心智模型(抽象)。在这一点上,我们对语言范式的选择将非常重要,因为有些抽象在特定范式中很容易表示,而其他的则更困难。例如,一个无界但有限的列表在函数式语言中更容易表示。

Martin

这就是为什么当我们思考领域建模时,它不只是关于建模数据结构,还关于建模如何用这些数据结构进行计算。面向对象建模在这方面迈出了一步,将行为绑定到数据结构上,但这只是第一步。在处理更复杂的领域时,你必须将计算设计到模型的结构中。

Unmesh

这些决定不是实现需求以使其运行;它们是关于选择正确的结构。

当我们在这两种模式之间振荡时,一些重要的事情发生了:

  • 是什么 → 怎么做:在完善意图时,机制会自我揭示。「啊,这些请求是严格根据交付时间优先排序的。我可以在这里使用优先队列。」
  • 怎么做 → 是什么:在实现机制时,系统的真实本质会自我揭示。「哦,看看我们如何持久化这些事件,我们不只是在保存数据;我们实际上是在实现预写日志机制。」

Martin

从场景开始,用它们来驱动连接「怎么做」和「是什么」的抽象。

这提出了一个问题,即我们应该如何最好地理解我们如何理解和思考更高级别的「是什么」。我们无法提出一个广泛、抽象的需求陈述,因为我们无法在不理解实现它的机制的情况下谈论那个抽象陈述。「是什么」和「怎么做」的交织使我们陷入循环依赖。

一直吸引我的前进方式是从人们如何使用系统的具体例子开始。采用这些场景/用例,并用它们来驱动支持它们的抽象。

Unmesh

这些场景在两个级别上运作:

  • 在系统级别(用例):我们定义叙事。「用户将商品添加到购物车」定义了系统边界。
  • 在模块级别(测试用例):我们定义契约。单元测试只是一个微场景(Given → When → Then),它为单个函数巩固了「是什么」。

TDD 是一种设计策略,它将「是什么」和「怎么做」之间的反馈循环操作化。

当我第一次遇到测试驱动开发(TDD)时,我发现它很有吸引力,因为它感觉像是一种将「是什么」和「怎么做」之间的反馈循环操作化的设计策略。

TDD 有效是因为它让这个是什么/怎么做循环变得明确:

  • 锁定「是什么」:通过先编写测试,你迫使自己在被实现细节分心之前回答「是什么」问题(命名、输入、输出)。你暂时充当自己代码的客户端。
  • 迭代「怎么做」:一旦测试存在(红),你就可以自由实现解决方案(绿)。
  • 完善结构:然后你进行重构。这是「怎么做」经常告诉你,你的「是什么」(你的 API 设计或测试场景)很尴尬或有漏洞,从而导致更好的设计的地方。

Rebecca

TDD 和用例之间的这种联系很重要,因为它们都是专注于「是什么」的方式。测试应该,但通常不是,专注于那个「是什么」。

Martin

编写测试鼓励我们思考接口,而不将其耦合到实现。

我经常注意到,人们在做好封装方面很挣扎,因为他们发现很难在不将接口耦合到实现的情况下思考接口。测试对此有帮助,因为在实现之前编写测试鼓励我只思考 API。这也是一个即时的可用性测试。当我编写测试时,我作为 API 的用户来处理问题,专注于低级别的「是什么」框定了我思考 API 的方式。

现在在重构步骤中,我用我从使测试工作中学到的东西重新考虑实现和接口。这有时会引导我产生关于如何表示「是什么」的新想法,既为了直接的 API,也为了我对整个模型的更广泛理解。这不会在每次测试时都发生,甚至不会在大多数测试时发生,但它发生得足够多,足以产生影响。

Unmesh

即使我们使用 LLM 并提示它们,这一切仍然很重要。我最近尝试编写一个受 MinIO 代码库启发的微型对象存储。当我要求 LLM 推导 MinIO 的实现时,它产生的东西太过过程化且难以理解。当我自己一步步编写时,我最终得到了更少、更清晰的抽象,代码也更容易阅读和演进。

这与我不断看到的模式一致。没有稳定的抽象词汇表,LLM 生成的代码往往是过程化的。如果我推动它「重构」,它经常会走向另一个极端,创建太多的类和层,使设计变得不必要地复杂。

这就是为什么我更喜欢将 LLM 用作我的是什么/怎么做循环中的转换层。我用它们快速勾勒出第一个版本,但我仍然依赖编写和重构来塑造结构——因为我保留的代码是我可以解释、测试并充满信心地更改的代码。

单靠提示满足了一个场景,但没有构建解决方案的结构来适应未来的场景。

这也是为什么我不觉得用 LLM 生成测试用例来提高「测试覆盖率」很有用。

通过测试,或让代码在场景下工作,只是基线。主要目标不只是满足当前的场景,而是巩固和构建解决方案的结构,以便它能适应未来的场景。

如果我们只是「让它工作」,我们会创建脆弱的代码。我们必须组织解决方案,使「怎么做」能够在不破坏「是什么」的情况下演进。我们通过以下方式实现这一点:

  • 内聚:将共享相同「是什么」(业务意图)的部分分组。
  • 解耦:将具有不同变化原因的部分分离。

我们将类似的行为提取到模块中,创建领域特定的抽象来帮助我们管理认知负载。

Martin

这是关于管理认知负载。我们可以把一个复杂的程序写成一个很长的函数,所有变体行为都通过简单的 if/else 条件处理。但这对某人来说太多了,无法理解。如果我们把重复代码的片段变成子函数,我们就捕获了行为的相似性并在代码中明确表示,使其更容易理解。如果两块代码几乎相同,我们可以将它们提取到函数中并通过参数捕获变化——再次清楚地标记变化发生的位置。然后我们可以将这些子函数排列成更广泛的模式,再次阐明变化发生的位置。这些模式形成了特定于我们特定领域的抽象。

Rebecca

一旦我们完成了 Martin 描述的重构,我们就需要决定如何通过这些不同模块的交互来实现我们想要的行为。同样,获得正确的粒度级别既有助于推理行为,也有助于我们管理认知负载。

Unmesh

编程范式提供了如何组织这些抽象的约定。

编程范式为这些决定提供了约定,有效地表示稳定部分同时为变化提供钩子。

面向对象编程专注于识别共同行为(接口)。变体是将数据与这些接口的实现分组在一起的特定类。当我们期望动作(接口)保持稳定而类型变化时,我们使用它。

函数式编程经常将数据结构视为稳定部分,而变体是操作(管道/过滤器)。我们打赌数据形状保持稳定而转换变化。

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 来驱动适合范式的「怎么做」。

Unmesh

当我们在「是什么」和「怎么做」之间迭代时,一旦我们表达了意图或描述了场景,LLM 可以快速显示代码。重要的是要记住,生成的代码通常是使测试通过的第一个版本。我们仍然需要使用编程范式和语言提供的工具,并使用社区的习语来构建解决方案的结构。

好的抽象使意图容易转换为实现。

当抽象稳定下来并且编程主要是关于在声明式语法中使用这些抽象时,LLM 在语言表示方面的优势变得特别有用。几个提示可以产生一个工作实现。因为抽象构成了提示的基本词汇,生成的程序更可预测且更容易审查。

「按交付时间戳在优先队列中排序请求」或「使用存储库存储此客户记录」往往会生成遵循理解良好的习语的代码,使其更容易审查和管理。

Rebecca

LLM 依赖于成熟的训练数据——谁构建接下来的东西?

然而,这里有一个隐藏的依赖:LLM 依赖于成熟度。如果一种语言不够成熟,或者如果我们正在探索一种新颖的范式,LLM 的训练将不足。在这些前沿领域,工具稀缺,我们实际上只能靠自己。这个约束提出了一个关键问题:由于 LLM 只能在有丰富训练数据的地方运行,在 AI 时代,新语言和范式将从哪里来?如果它们永远不来,我们还会在乎吗?

Martin

这让我想起了作家 Will Wilkinson 说的话:「最好的写作『去熟悉化』熟悉的东西——让它新鲜,重新唤起它,等等。但 LLM 输出是熟悉的平均重新哈希,这是设计使然。」如果我们在与一个常见(熟悉)的问题作斗争,一个好的解决方案必然是_不_熟悉的。

LLM 可以提出随机的新想法——然后人类可以选择并发展它们。

另一方面,我确实认为 LLM 可以提出不熟悉的想法,无论是在写作还是编程中。本质上这是由于它们的随机创造,它们在幻觉不熟悉的东西。我的第三只手,它们不能做的是发展那个新创造,因为它们没有所需的训练数据。人类可以使用 LLM 来幻觉一些奇怪但有潜力的东西,然后在此基础上构建。这就是为什么要求 LLM 生成三个不同的替代方案通常很有用,这样人类可以选择最好的一个,或者从所有三个中组装 bits 成更好的东西。

Unmesh

这就是为什么,当我们在演进我们的理解并构建稳定结构时,我们不能简单地「提示」LLM 来做工作。我们当然利用它们,但我们必须迭代地驱动代码来自己构建稳定结构。参考领域特定语言一书,这是我们构建语义模型的阶段。LLM 应该被视为这个阶段的强大工具,但开发者必须保持模型的主要架构师。

Martin

就个人而言,我觉得这是一个令人欣慰的想法。我最喜欢编程的事情之一是模型构建。我希望 LLM 不会把它拿走——实际上会让它更容易和更充实。