文章摘要
现代LLM能从自然语言描述生成大量代码,但存在两大局限:前期规范无法预知所有设计决策,且设计需通过实现过程逐步发现。规范只是初始假设,需通过迭代修正。
文章总结
现代大型语言模型(LLM)具备非凡能力,能从高层自然语言描述中生成大量代码,甚至整个系统。但这一过程依赖两个关键前提:一是“意图”需用精确词汇清晰表达,以便LLM映射到代码构建块;二是需注意前期规范的局限性,以及设计是通过实现过程逐步发现的。
前期规范的局限性
构建大型系统涉及大量细小的设计决策,这些无法预先全部知晓或完全由高层规范驱动。规范充其量只是一个初始假设:真正的约束、权衡和边界情况是在实现过程中迭代发现的。这并非否定规范的价值,而是强调初始规范是需要修订的假设,而非最终蓝图。自然的应对方式是迭代:细化规范、生成代码、审查结果,并将经验反馈到下一轮。当每轮产生小而可审查的变更时,这种循环效果最佳。
设计通过实现被发现
审查代码,尤其是在设计尚未定型时,与编写代码不同。审查生成的代码时,我们验证其是否映射到意图,并寻找潜在陷阱,但很少迫使自己深入思考设计决策。而编写代码则迫使我们思考具体决策——如职责归属或应暴露哪些边界以支持扩展。正是在做出这些决策的过程中,设计才得以最充分地展现。编程语言和范式会影响我们获得的设计洞察。函数式或面向对象的设计方法会揭示设计的不同方面,以及该范式自然的惯用法和模式。
那么LLM的角色是什么?我认为LLM扮演两个角色:在塑造设计及其词汇时,它们是出色的头脑风暴伙伴,帮助我们探索设计空间并发现正确的抽象;一旦词汇建立,LLM便成为优秀的自然语言接口。
领域抽象与领域特定语言(DSL)
通过领域驱动设计(DDD)的视角来理解这一点很有帮助。其核心思想是在代码中构建共享的领域概念模型,并使用该模型(DDD称为“通用语言”)来演化代码库,并为团队提供思考和沟通的词汇。在此模型之上构建领域特定语言(DSL)通常非常有效:这是一种受限的语法,用于表达领域的概念和操作。从这个角度看,大多数开发都是构建领域模型并使用它来演化系统的过程。LLM根据领域模型是否已存在而扮演两种不同角色。本文将重点探讨DSL如何与LLM协同工作。
DSL为何与LLM配合良好
常见经验表明,DSL与LLM配合良好。PlantUML、Mermaid和Graphviz是用于视觉建模的DSL;SQL是查询数据库的DSL;Kubernetes YAML是描述云基础设施的DSL。这些并非通用编程语言,而是经过刻意约束,旨在表达单一领域中的一组狭窄概念。LLM能根据自然语言描述出色地生成Mermaid图表、SQL查询或Kubernetes清单,这并不令人惊讶。
我的观察是,DSL使LLM更可靠,因为它们对少量上下文示例响应极佳。像Java这样的通用语言提供了多种表达同一意图的有效方式,而DSL则消除了这种变异性。给模型几个示例就足以可靠地生成正确语法。值得注意的是,前沿模型在训练过程中已大量接触PlantUML或Java流畅接口,因此并非从零开始。对于更小、更受限的模型在处理真正新颖的DSL时表现如何,将是一个有趣的问题。
对于智能体(即运行在自主生成-检查循环中的LLM,而非单次生成),还有一个额外好处。DSL几乎总是附带确定性验证器:解析器、JSON模式、类型检查器或编译器。智能体可以生成候选方案,通过验证器运行,并根据错误进行修复,整个过程无需人工干预。关键在于,错误信息以领域级别表述(如“在选择客户端之前无法选择操作”),而非深埋在生成代码中的堆栈跟踪。DSL的工具集本身就是一个出色的框架。我们将在下面的Tickloom示例中具体看到这一点,其中DSL的语法由宿主语言的编译器强制执行,运行结果自动检查。
需要注意的是,这并非万能解决方案。只有当DSL保持足够小且受限,以至于少量上下文示例就能传达其用法时,这种优势才成立。设计和维护语言及其语义模型也存在实际的前期成本。因此,回报集中在经过良好分解、真正受限且由验证器支持的DSL上。
示例:使用LLM生成包含丰富图表的PowerPoint演示文稿
LLM使构建自定义工具变得非常容易。在教授分布式系统时,我经常需要创建包含解释集群中分布式操作图表的演示文稿。UML序列图对此非常有用,但在解释消息流经集群的过程时,显示完整的序列图并不实用。我需要一个工具,能在PowerPoint演示文稿中逐步显示序列图。借助LLM,我构建了一个工具,它处理描述演示文稿结构(包含对PlantUML图的引用)的YAML,并生成PowerPoint演示文稿。PlantUML图标记了步骤,工具为每个步骤生成单独的幻灯片。这使得创建包含丰富图表的演示文稿变得非常容易。
生成一个PlantUML序列图,显示三个节点(athens、byzantium和cyrene)的集群。放置一个框来标记集群。参与者Alice向athens发送消息“title”、“After Dawn”,athens向自身发送消息。放置一个注释显示状态'title: After Dawn'。athens向byzantium发送消息,但失败。athens向cyrene发送消息。在cyrene右侧放置一个注释。athens随后检查isQuorumReached并返回同步的Success箭头给Alice。在每个消息后放置'[step]标记。
此提示生成带有步骤标记的PlantUML代码:
@startuml actor Alice
box "Cluster" #lightblue participant athens participant byzantium participant cyrene end box
'[step] Alice -> athens: "title", "After Dawn"
'[step] athens -> athens: save()
note right of athens state: title: After Dawn end note
'[step] athens -[#red]x byzantium: "title", "After Dawn"
'[step] athens -> cyrene: "title", "After Dawn"
note right of cyrene state: title: After Dawn end note
'[step] athens -> athens: isQuorumReached()
'[step] athens --> Alice: Success
@enduml
我使用它创建了一系列PowerPoint幻灯片。为此,我开发了一个小型YAML规范来描述演示文稿结构以及每张幻灯片中使用的图表。这使我能够使用LLM创建描述复杂分布式系统概念的演示文稿,而无需手动创建幻灯片动画。生成幻灯片YAML规范的示例提示非常简单。
创建一个引用图表'quorum-write'的幻灯片YAML,标题为'Quorum Write Example'
这会生成如下幻灯片规范YAML:
- slide:
title: "Quorum Write Example"
diagram: "quorum-write"
需要注意的是,即使提示说的是“创建幻灯片YAML”,它也不是任何随机的YAML规范。因为用于生成PowerPoint演示文稿的工具以及该工具理解的YAML规范被用作提示的上下文,LLM能够生成正确的YAML规范,该规范可直接用于工具生成PowerPoint演示文稿。
完整的YAML规范可在此GitHub仓库查看。
请注意,在这个单一示例中,LLM扮演了两个不同的角色。首先,它是共同设计师——帮助塑造带步骤标记的PlantUML扩展和基于现有PlantUML工具的幻灯片YAML。然后,一旦这个小DSL存在,它就变成了自然语言接口,将英语请求转换为有效的规范。我们将在文章末尾回到这种分工。
构建语义模型
上一节的示例相对简单。YAML被用作载体语法,我直接处理其解析后的语法树,实际上将语法树本身用作语义模型(尽管这会将语法与执行语义耦合)。但在更复杂的领域,如分布式系统,我们需要更复杂的语义模型来表示领域中的概念以及我们在代码库中做出的设计决策。让我们看一个基于我构建的小型框架的示例,该框架用于快速构建和测试分布式系统。
示例:Tickloom——分布式系统的语义模型
实现分布式系统,如基于仲裁的键值存储或Raft和Paxos等共识协议,是一项艰巨的任务。即使通过提示、规范或精心构建的.md技能文件逐步指导实现,异步运行时仍然暴露出大量可能的实现决策空间。线程模型、网络模式、存储协调、重试行为和时序语义都仍然纠缠在生成的代码中。问题不仅在于代码生成的复杂性,还在于验证的复杂性。由线程调度、网络延迟、进程暂停和时钟偏差的所有可能交错产生的状态空间变得如此巨大,以至于系统地审查和验证所有交互行为的正确性几乎不可能。这就是为什么我们看到Jepsen测试即使在经过最严格测试的分布式系统中也能发现错误。
这正是语义模型发挥作用的地方。Tickloom是我构建的一个小型框架,用于构建和测试分布式算法。其抽象并非通用运行时;而是一组关于分布式进程行为方式的设计决策。每个节点在单线程滴答循环中运行:每次调用tick()将逻辑时钟前进一个单位,并以固定的确定性顺序处理待处理工作(网络、消息总线、进程、存储)。时间以滴答为单位,而非毫秒。消息是普通的Java记录。跨副本的协调通过一个Replica基类表达,该基类已了解对等节点、广播和仲裁。
线程、时序、网络交付不再是每个提示中需要重新决定的开放问题。留给算法作者的是实际的协议逻辑。例如,一个仲裁副本只是一组用框架词汇表达的消息处理器。
由于框架提供了词汇——Replica、quorumRequest、countResponseIf、MessageType、Handler——提示可以停留在协议层面,而非管道层面:
使用Tickloom Replica抽象,实现一个基于仲裁的键值存储。客户端GET应收集多数节点的值,并返回时间戳最高的那个(最后写入者获胜)。仅当写入的时间戳比存储的新时,才在本地应用写入。
这个高层描述产生如下代码:
@Override
protected Map
private void handleClientGetRequest(Message message) { var req = deserializePayload(message.payload(), ClientGetRequest.class); var internalReq = new InternalGetRequest(req.key());
this.<InternalGetResponse>quorumRequest(LWWMessageType.INTERNAL_GET_REQUEST, internalReq)
.countResponseIf(r -> true) // 任何响应都可以,只需要多数
.send()
.whenComplete((responses, error) -> {
if (error != null) {
send(createMessage(message.source(),
message.correlationId(),
new ClientGetResponse(req.key(), null, false),
LWWMessageType.CLIENT_GET_RESPONSE));
return;
}
byte[] highestValue = null;
long highestTimestamp = -1;
for (InternalGetResponse r : responses.values()) {
if (r.value() != null && r.timestamp() > highestTimestamp) {
highestTimestamp = r.timestamp();
highestValue = r.value();
}
}
boolean found = highestValue != null;
send(createMessage(message.source(),
message.correlationId(),
new ClientGetResponse(req.key(), highestValue, found),
LWWMessageType.CLIENT_GET_RESPONSE));
});
}
语义模型本身充当上下文。提示命名了代码库中作为具体类型存在的概念,因此LLM并非在发明线程模型或网络层,而是在固定的、理解充分的基座上填充协议逻辑。
即使没有DSL,好的抽象也有帮助
DSL是频谱的一端,构建起来并不容易。在构建自己的语言之前,值得注意一组干净的抽象已经是同一想法的轻量版本——正如上述框架为仲裁存储提供了词汇一样,库的命名类型和方法本身也是模型可以基于的词汇。Tickloom的语义模型实际上只有四个这样的接缝:Process/Replica用于计算和消息处理,Network用于通信,Storage用于持久化,以及逻辑Clock用于时间——这种分解在没有新语法的情况下完成了大部分工作。
这就是为什么抽象(而不仅仅是DSL)与LLM配合良好。提示“将Raft实现为Tickloom Replica”具有有限的状态空间需要探索。现有的QuorumReplica可以作为工作示例在上下文中使用。
示例:为测试分布式系统场景构建DSL
实现算法是一回事;执行它是另一回事。分布式系统中的微妙错误存在于特定的顺序中:在读取器的仲裁转移之前写入复制到一个节点、分区在错误时刻恢复、两个协调器的时钟漂移。直接针对测试工具编写这样的场景涉及处理future和手动tick()循环。以下是一个以这种方式编写的时钟偏差场景:
Cluster cluster = new Cluster() .withProcessIds(Arrays.asList(ATHENS, BYZANTIUM, CYRENE)) .useSimulatedNetwork() .build(QuorumReplica::new);
cluster.start();
try { cluster.tickUntil(cluster::areAllNodesInitialized);
cluster.setTimeForProcess(ATHENS, 1000L);
cluster.setTimeForProcess(BYZANTIUM, 2000L);
QuorumReplicaClient alice = cluster.newClientConnectedTo(ALICE, ATHENS, QuorumReplicaClient::new);
QuorumReplicaClient bob = cluster.newClientConnectedTo(BOB, BYZANTIUM, QuorumReplicaClient::new);
QuorumReplicaClient reader = cluster.newClientConnectedTo(READER, ATHENS, QuorumReplicaClient::new);
TickCompletableFuture<SetResponse> bobWrite = bob.set(KEY.getBytes(StandardCharsets.UTF_8), "B".getBytes(StandardCharsets.UTF_8));
cluster.tickUntilComplete(bobWrite);
TickCompletableFuture<SetResponse> aliceWrite = alice.set(KEY.getBytes(StandardCharsets.UTF_8), "A".getBytes(StandardCharsets.UTF_8));
cluster.tickUntilComplete(aliceWrite);
TickCompletableFuture<GetResponse> read = reader.get(KEY.getBytes(StandardCharsets.UTF_8));
cluster.tickUntilComplete(read);
assertEquals("B", new String(read.getResult().value(), StandardCharsets.UTF_8));
} finally { cluster.close(); }
意图——“Bob通过Byzantium写入,Alice通过Athens写入,读取器看到Bob的值,因为Byzantium的时钟更快”——被埋没在机制之下。这也是一个难以验证的代码:有数十个偶然决策(何时滴答、如何编码字节、调用哪个工厂重载)可能被LLM微妙地搞错,审查者必须逐一检查每个决策。
因此,在语义模型之上,我构建了一个内部DSL,其词汇是场景本身的词汇——服务器、客户端、谁连接到谁、每个客户端做什么,以及执行时哪些故障生效。相同的场景变为:
Scenario<QuorumReplicaClient> scenario =
QuorumStepBuilder.scenario("LWW lost update via server clock skew")
.servers(ATHENS, BYZANTIUM, CYRENE)
.clients(ALICE, BOB, READER)
.client(ALICE).connectedTo(ATHENS)
.client(BOB).connectedTo(BYZANTIUM)
.given(g -> g.serverTimeAt(ATHENS, 1_000L)
.serverTimeAt(BYZANTIUM, 2_000L))
.steps(s -> {
s.client(BOB).writes(KEY, "B").expectSuccess();
s.client(ALICE).writes(KEY, "A").expectSuccess();
s.client(READER).reads(KEY)
.expectResponse(v -> "B".equals(v));
});
DSL是一个薄薄的声明式表面,编译为纯中间表示——一个由Step组成的Scenario,每个步骤携带一个Action(读取或写入)和可选的ClusterEvent(如分区和消息延迟等故障)。故障也读起来像英语:partition(BYZANTIUM).from(CYRENE)、reconnect(BYZANTIUM)、delay(INTERNAL_SET_REQUEST).from(ATHENS).to(BYZANTIUM, CYRENE).byTicks(100)。语法通过渐进式接口由类型系统强制执行——你不能在拓扑之前声明步骤,也不能在选择客户端之前声明操作——因此整类格式错误的场景根本无法编译。由于DSL是用Java构建的内部DSL,宿主编译器免费验证语法,格式错误的生成结果会作为编译错误返回,精确定位到非法步骤,而不是运行时意外。
一旦DSL存在,故障场景的自然语言描述几乎直接映射到它上面。一个提示如:
使用Tickloom场景DSL,编写一个重现DDIA §10.6非可线性化仲裁读取的场景。连接到Athens的写入者设置键,然后在从Athens到其他副本的复制延迟时更新它。通过Byzantium读取的Alice被迫进入一个包含Athens的仲裁,并看到新值;Bob稍后通过包含Byzantium和Cyrene的仲裁读取,仍然看到旧值。
产生一个完全停留在DSL受限词汇内的场景:
Scenario
// 写入者更新为VNEW,但从Athens的复制被延迟
s.client(WRITER).writes(KEY, VNEW)
.whileClusterEvent(delay(QuorumMessageTypes.INTERNAL_SET_REQUEST)
.from(ATHENS).to(BYZANTIUM, CYRENE).byTicks(100))
.expectSuccess();
// Alice通过Byzantium读取。通过分区Cyrene强制仲裁包含Athens。
// 她将从Athens读取VNEW。
s.client(ALICE).reads(KEY)
.whileClusterEvent(partition(BYZANTIUM).from(CYRENE))
.expectResponse(v -> VNEW.equals(v));
// Bob稍后通过Byzantium读取。强制仲裁包含Cyrene(并排除Athens)。
// 延迟的VNEW复制尚未到达Cyrene,因此Bob读取VOLD。
s.client(BOB).reads(KEY)
.whileClusterEvent(reconnect(BYZANTIUM))
.whileClusterEvent(partition(BYZANTIUM).from(ATHENS))
.expectResponse(v -> VOLD.equals(v));
});
ScenarioResult result = scenario.run();
由于表面如此之小——并且它可以生成的有效代码空间远小于有效Java程序的空间——LLM几乎没有幻觉空间,审查者可以将结果读作实验描述,而不是需要逐行审计的代码。即使LLM产生幻觉,内部DSL也会编译失败,允许LLM纠正错误。
与LLM合作的两个阶段
从上述示例中浮现出一个模式:在每个示例中,LLM以两种截然不同的方式发挥作用。
第一阶段是设计抽象或DSL本身。这里,LLM最好被视为头脑风暴伙伴,而非代码生成器。正如文章开头所述,构成语义模型的设计决策无法全部预先指定——我们在实现过程中发现约束、权衡和边界情况。因此,这一阶段本质上是迭代和反馈驱动的:你提出一个结构,针对真实案例进行测试,发现哪里变得笨拙,并将经验反馈到下一轮。LLM加速了这个循环——它勾勒替代方案、批评设计、将想法从一种语言移植到另一种语言——但你牢牢掌握主导权,因为这些正是你需要理解和拥有的决策。使DSL使用起来愉悦的结构,比如使非法场景无法编译的渐进式接口,或与生成它的构建器保持分离的语义模型,都是通过迭代而非编写规范和生成代码来收敛的。
第二阶段在抽象或DSL就位后开始。现在LLM的角色发生了变化:它成为你所构建内容的自然语言接口。本文中的提示就是例子——“将仲裁存储实现为Tickloom Replica”、“编写重现DDIA §10.6读取的场景”、“为此图表创建幻灯片YAML”。在每个案例中,英语描述几乎直接映射到你定义的词汇上,LLM成为可靠的生成器,正是因为抽象既提供了接地提示的上下文,也提供了检查结果的框架。
DSL作为真相来源
将提示视为主要真相来源的趋势正在增长。一个设计良好的DSL从根本上改变了这种动态。我观察到使用DSL的关键优势之一是,生成的程序本身通常成为人类维护的工件。因为DSL密集、富有表现力,并且基本没有偶然的样板代码,它以生成后长期可读的形式捕获了解决方案的基本意图。如果LLM从自然语言请求生成了一个Tickloom故障场景,结果场景已经用领域的词汇表达。如果下个月需要更改场景,无需恢复原始提示并重新生成所有内容。DSL拥有足够的上下文让LLM理解意图并与之协作。持久的资产不是提示,而是DSL和语义模型。
评论总结
根据评论内容,主要观点和论据总结如下:
支持DSL与LLM结合的观点: - 评论2(williamcotton)强调DSL工具(如linter、LSP)对LLM提供上下文的重要性:“What is missing from the discussion about DSLs are the importance of tooling such as linters, LSPs, etc, to give the LLMs further context.” - 评论3(lelanthran)指出短规格DSL对LLM有效:“Even Chatbots are able to work with a 200 line spec for the DSL... a short spec is usually enough.” - 评论16(BatteryMountain)分享实践:“create claude skills (bash scripts and dotnet console apps) that claude can call... It feels like a DSL on steroids.”
质疑与批评观点: - 评论5(codegladiator)指出DSL有效性依赖训练数据:“PlantUML has millions of examples in the training data, my new dsls are not... you are basically looking at a whole system prompt just describing the new language.” - 评论8(voidhorse)批评泛化结论:“You cannot extrapolate from one-off behavioral successes. LLMs are not understanding anything in the way humans do.” - 评论18(rudedogg)基于SwiftUI经验反驳:“The LLM isn’t any better about generating/understanding it... typical languages compiler ‘catches’ more mistakes, versus a DSL that’s easy to write but has lots of implicitness.”
其他重要观点: - 评论4(OsamaJaber)强调验证需包含性能目标:“Speed targets have to be part of the check, not just correctness” - 评论12(giovannibonetti)引用Bjarne Stroustrup规则,认为LLM类似专家偏好简洁语法:“LLMs are the experts” - 评论22(leemoore)建议聚焦抽象和系统设计:“The key take away is we all have to both get good at system and program design especially around abstractions, shapes, responsibility”
平衡总结: 评论对DSL与LLM结合的有效性存在分歧。支持者认为短小、有工具支持的DSL能提升LLM表现;质疑者指出DSL效果依赖训练数据、难以泛化,且LLM本质是统计机器,不能从个别成功案例推导普遍规律。多数评论强调验证、工具和抽象设计的重要性。