DSLs 使得 LLMs 的可靠使用成为可能
摘要
本文探讨了领域特定语言(DSL)如何在使用 LLMs 进行代码生成时提高可靠性和设计洞察力,强调迭代设计和受限语法。
暂无内容
查看缓存全文
缓存时间: 2026/07/15 13:43
# DSL 使 LLM 的可靠使用成为可能
来源:https://martinfowler.com/articles/llm-and-dsls.html
现代 LLM 拥有令人难以置信的能力。它们能够仅凭高级自然语言描述生成大量代码,有时甚至生成整个系统。这里有一个重要前提:需要构建的“意图”必须用精确的词语表达清楚,以便 LLM 能够映射到编程构建块。然而,有两点值得注意:前期规范的局限性,以及设计如何通过实现来发现。
## 前期规范的局限性
构建大型系统涉及大量细小的设计决策,这些决策无法全部提前知晓,也无法完全由高层规范驱动。规范充其量只是一个起始假设:真正的约束、权衡和边界情况是在实现过程中迭代发现的。我们在之前的一篇文章中详细讨论过这一点,称之为“前期规范的不可能性”(https://martinfowler.com/articles/convo-llm-abstractions.html)。关键不在于规范毫无价值,而在于最初的规范是一个需要修订的假设,而不是一份完成的蓝图。自然的应对方式是迭代:细化规范、生成代码、审查返回结果,并将学到的经验反馈到下一轮。当每一轮产生小而可审查的变更时,这个循环效果良好。
## 设计是通过实现发现的
审查代码,尤其是在我们仍在发现设计的过程中,与编写代码并不相同。在审查生成的代码时,我们通过代码片段来验证它是否符合我们的意图,并寻找潜在的陷阱。但审查很少迫使我们与设计决策斗争。相比之下,编写代码迫使我们去思考具体的决策——比如某个职责应该归属何处,或者应该暴露哪些边界才能使设计进一步扩展。正是在做出这些决策的过程中,设计才最充分地展现出来。
我们编写代码所用的编程语言和范式塑造了我们获得的设计洞察(https://martinfowler.com/articles/what-is-code.html)。函数式设计方法或面向对象设计方法会揭示设计的不同方面,以及范式自然的惯用法和模式。
那么 LLM 在其中的角色是什么?我认为 LLM 扮演着两个角色。在我们塑造设计及其词汇时,它们是很好的帮手,作为头脑风暴伙伴帮助我们探索设计空间并发现正确的抽象。一旦词汇建立起来,LLM 就成为其优秀的自然语言接口。
## 领域抽象与 DSL
一个有用的框架是领域驱动设计(http://martinfowler.com/bliki/DomainDrivenDesign.html)。其核心见解是在代码中构建领域的概念模型,然后使用该模型——DDD 称之为通用语言(https://martinfowler.com/bliki/UbiquitousLanguage.html)——来演变代码库,并为团队提供思考和交流的词汇。通常,在该模型之上构建领域特定语言是非常有效的:一种用于表达领域概念和操作的受限语法。
从这个角度来看,大多数开发工作就是构建领域模型并使用它来演进系统的过程。LLM 根据领域模型是否已存在扮演两个截然不同的角色。在本文中,我将重点讨论领域特定语言(https://martinfowler.com/dsl.html)(DSL)如何与 LLM 配合使用。
## 为什么 DSL 与 LLM 配合得这么好
一个普遍的体验是 DSL 与 LLM 配合得很好。PlantUML、Mermaid 和 Graphviz 是用于可视化的领域特定语言;SQL 是用于查询数据库的 DSL;Kubernetes YAML 是用于描述云基础设施的 DSL。这些不是通用编程语言——它们故意受限,旨在表达一个领域中狭窄的一组概念。毫不奇怪,LLM 非常擅长从简单的英语描述生成 Mermaid 图、SQL 查询或 Kubernetes 清单。
我的观察是,DSL 使 LLM 更加可靠,因为它们对少量上下文示例响应得非常好。像 Java 这样的通用语言提供了许多有效的方式来表达相同的意图。而 DSL 则消除了这些变化。给模型几个示例就足以可靠地生成正确的语法。
值得注意的是,前沿模型在训练过程中已经大量接触了 PlantUML 或 Java 流畅接口,因此它们并非从零开始。如果让更小、更受限的模型处理一个真正新颖的 DSL,它们的表现会如何,这将是一件有趣的事情。
对于智能体(agent)——一种在自动生成并检查循环中运行的 LLM,而非一次性生成——还有一个额外的好处。DSL 几乎总是附带一个确定性的验证器:解析器、JSON 模式、类型检查器或编译器。智能体可以生成候选方案,通过验证器检查,然后根据错误进行修复,整个过程无需人工介入。关键在于,错误是以领域级别的语言表述的——“你不能在选择客户端之前选择一个动作”——而不是生成代码深处埋藏的堆栈跟踪。DSL 工具集本身就是一个极好的框架。我们将在下面的 Tickloom 示例中具体看到这一点:DSL 的语法由宿主语言的编译器强制执行,并且生成的运行结果会自动检查。
需要注意的是,这并非万能的解决方案。只有当 DSL 保持足够小且受限,以至于少量上下文示例就能传达其用法时,这种优势才存在。此外,设计和维护语言及其语义模型也有实实在在的前期成本。因此,回报集中在架构良好、真正受限且有验证器支撑的 DSL 上。
## 示例:使用 LLM 生成包含丰富图表的 PowerPoint 演示文稿
LLM 使得构建自定义工具变得非常容易(https://www.martinfowler.com/articles/exploring-gen-ai/16-building-custom-tooling-with-llms.html)。在教授分布式系统时,我经常需要创建演示文稿,其中大部分是解释集群中分布式操作的图表。UML 序列图在这方面很好,但在解释消息在集群中的流动时,显示完整的序列图并不太有用。我需要在 PowerPoint 演示文稿中逐步显示序列图的工具。借助 LLM,我能够构建一个工具,该工具处理描述演示文稿结构并引用 PlantUML 图的 YAML,然后生成 PowerPoint 演示文稿。PlantUML 图带有步骤标记,工具会为每个步骤生成一张单独的幻灯片。这使得创建包含丰富图表的演示文稿变得非常容易。
生成一个 PlantUML 序列图,显示一个包含三个节点 athens、byzantium 和 cyrene 的集群。用一个框标记集群。Actor Alice 向 athens 发送消息 "title", "After Dawn",athens 向自身发送消息。添加一个笔记显示状态 'title: After Dawn'。Athens 向 byzantium 发送消息,但失败。Athens 向 cyrene 发送消息。在 cyrene 右侧放置一个笔记。然后 athens 检查 isQuorumReached 并向 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 规范的提示示例如下:
创建一个幻灯片 YAML,引用图表 'quorum-write',标题为 'Quorum Write Example'
这会生成如下幻灯片规范 YAML:
```
- slide:
title: "Quorum Write Example"
diagram: "quorum-write"
```
值得注意的是,即使提示说“创建一个幻灯片 YAML”,它也不是任何随机的 YAML 规范。因为用于生成 PowerPoint 演示文稿的工具以及该工具理解的 YAML 规范被用作提示中的上下文,所以 LLM 能够生成正确的 YAML 规范,该规范可直接用于工具生成 PowerPoint 演示文稿。
完整的 YAML 规范可在此 Github 仓库(https://github.com/unmeshjoshi/MADSPlantUMLSteps/blob/main/src/presentation/deterministic-simulation-testing.yaml)中查看。
请注意,在这个单一示例中,LLM 扮演了两个不同的角色。首先,它是一个共同设计者——帮助塑造带有步骤标记的 PlantUML 扩展以及基于现有 PlantUML 工具之上的幻灯片 YAML。然后,一旦这个小型 DSL 存在,它就变成了自然语言接口,将英语请求转换为有效的规范。我们将在文章末尾回到这种分工。
## 构建语义模型
我们在上一节中介绍的示例很简单。YAML 被用作载体语法,我直接处理其解析后的语法树,有效地将语法树本身用作语义模型(https://martinfowler.com/dslCatalog/semanticModel.html)(尽管这会将语法与执行语义耦合在一起)。但在更复杂的领域(如分布式系统)中,我们需要更复杂的语义模型来表示领域中的概念以及我们在代码库中做出的设计决策。让我们看一个基于我构建的小型框架的示例,该框架用于快速构建和测试分布式系统。
## 示例:Tickloom——分布式系统的语义模型
实现分布式系统(如基于仲裁的键值存储或 Raft 和 Paxos 等共识协议)是一项艰巨的任务。即使实现是通过提示、规范或精心构建的 .md 技能文件逐步指导的,异步运行时仍然暴露出一个巨大的可能实现决策空间。线程模型、网络模式、存储协调、重试行为和时序语义仍然纠缠在生成的代码中。问题不仅仅是代码生成的复杂性,还包括验证的复杂性。由于线程调度、网络延迟、进程暂停和时钟偏移的所有可能交错所产生的状态空间如此之大,以至于系统地审查和验证所有交互行为的正确性几乎是不可能的。这就是为什么我们看到 Jepsen(https://jepsen.io/)测试即使在经过最严格考验的分布式系统中也能发现错误。
这正是语义模型发挥作用的地方。Tickloom(https://github.com/unmeshjoshi/tickloom)是我构建的一个小型框架,用于构建和测试分布式算法。它的抽象不是通用的运行时;它们是一组关于分布式进程行为方式的设计决策。每个节点运行在一个单线程的滴答循环中:每次调用 `tick()` 都会使逻辑时钟前进一个单位,并以固定的确定性顺序处理待处理工作(先网络,然后消息总线,然后进程,最后存储)。时间以滴答为单位测量,而不是毫秒。消息是普通的 Java 记录。副本之间的协调通过一个已经了解对等节点、广播和仲裁的 `Replica` 基类来表达。线程、定时、网络交付不再是每个提示中需要重新决定的问题。留给算法作者的是实际的协议逻辑。
例如,一个仲裁副本只是一组用框架词汇表达的消息处理器。因为框架提供了词汇——`Replica`、`quorumRequest`、`countResponseIf`、`MessageType`、`Handler`——提示可以停留在协议层面,而不是底层管道:
使用 Tickloom 的 Replica 抽象,实现一个基于仲裁的键值存储。客户端 GET 应该从多数节点收集值,并返回时间戳最高的那个(最后写入者获胜)。仅当写入的时间戳比存储的时间戳更新时才在本地应用写入。
这个高层描述产生如下代码:
```java
@Override
protected Map<MessageType, Handler> initialiseHandlers() {
return Map.of(
LWWMessageType.CLIENT_SET_REQUEST, this::handleClientSetRequest,
LWWMessageType.CLIENT_GET_REQUEST, this::handleClientGetRequest,
LWWMessageType.INTERNAL_SET_REQUEST, this::handleInternalSetRequest,
LWWMessageType.INTERNAL_GET_REQUEST, this::handleInternalGetRequest,
LWWMessageType.INTERNAL_SET_RESPONSE, this::handleInternalSetResponse,
LWWMessageType.INTERNAL_GET_RESPONSE, this::handleInternalGetResponse
);
}
private void handleClientGetRequest(Message message) {
var req = deserializePayload(message.payload(), ClientGetRequest.class);
var internalReq = new InternalGetRequest(req.key());
this.quorumRequest(LWWMessageType.INTERNAL_GET_REQUEST, internalReq)
.countResponseIf(r -> true) // any response is fine, just need a majority
.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));
});
}
```
来源(https://github.com/unmeshjoshi/tickloom/blob/3829bd158b2ea848affc65c0ea8a6ab1bea4fb2c/src/main/java/com/tickloom/algorithms/replication/quorum/QuorumReplica.java)
语义模型本身充当上下文。提示中命名的概念在代码库中作为具体类型存在,因此 LLM 不是在发明线程模型或网络层——它是在一个固定且良好理解的基座上填充协议逻辑。
## 即使没有 DSL,好的抽象也有帮助
DSL 是频谱的一端,构建起来并不容易。在构建自己的语言之前,值得注意的是,一组干净的抽象已经是相同理念的轻量级版本——就像上面的框架为仲裁存储提供了词汇一样,库中的命名类型和方法本身就是模型可以扎根的词汇。Tickloom 的语义模型实际上只有四个这样的接缝——用于计算和消息处理的 `Process`/`Replica`、用于通信的 `Network`、用于持久化的 `Storage` 和用于时间的逻辑 `Clock`——这种分解在没有新语法的情况下完成了大部分工作。这就是为什么不仅仅是 DSL,抽象也能很好地与 LLM 配合使用。提示“将 Raft 实现为 Tickloom 的 Replica”具有有限的状态空间需要探索。现有的 `QuorumReplica` 可以作为已完成的示例在上下文中使用。
## 示例:为测试分布式系统场景构建 DSL
实现算法是一回事;执行算法是另一回事。分布式系统中微妙的错误存在于特定的顺序中:一次写入
相似文章
新DSL如何在LLM时代生存
本文探讨了新的领域特定语言(DSL)如何通过强调强大的文档、交互式着陆页以及稳健的工具(如语言服务器和诊断)在LLM时代保持相关性,并以作者的项目Web Pipe和Datafarm为例。
镜头下的Auto-DSM:面向基于LLM的DSM生成的黑盒评估框架
本文提出了一个黑盒评估框架,用于评估大型语言模型从结构化技术文档生成设计结构矩阵(DSM)的能力。该框架引入了可复现的度量指标和复合质量评分,结果表明,虽然LLM能够生成看似合理的DSM,但它们仍然对歧义和提示措辞敏感。
LLM的有效用例
本文分享了LLM在软件工程中的实际应用案例,包括通过RAG搜索客户对话、从日志中排查API故障以及内容精简。重点强调了效率提升和减少手动筛选工作。
通过最小充分表示学习实现的大语言模型领域特定数据合成
DOMINO 是一个新颖的框架,它从参考示例中学习最小充分的领域表示,为LLMs合成领域特定数据,从而在不要求显式领域描述的情况下提升代码基准性能。
用 LLM 优化 LLM:面向测试时扩展的智能体发现方法
本文提出了 AutoTTS,这是一种环境驱动的框架,通过将测试时扩展(TTS)策略的发现过程形式化为控制器合成,自动发现用于大型语言模型(LLM)的测试时扩展策略。该框架在数学推理基准测试上展示了更优的准确率-成本权衡,且计算开销极小。