DSLs Enable Reliable Use of LLMs
TL;DR · AI 摘要
DSLs通过明确边界和抽象确保LLMs生成代码的准确性,Tickloom案例展示其与LLMs的协同效应。
核心要点
- DSLs提供清晰边界,使LLMs生成代码符合预期意图
- Tickloom案例证明DSLs可作为分布式系统语义模型
- LLMs与DSLs结合可分两阶段:生成代码与验证语义
结构提纲
按章节快速跳转。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- DSLs与LLMs协同
- 核心价值
- 确保生成代码准确性
- 作为语义模型
- 实现方法
- 两阶段工作法
- Tickloom案例
金句 / Highlights
值得收藏与分享的关键句。
DSLs作为语义模型是LLMs时代软件系统的关键真相源
生成代码与验证语义的两阶段工作法提升可靠性
Tickloom通过DSL实现分布式系统行为的精确建模
DSL 使 LLM 的使用更加可靠
LLM 能够以惊人的速度生成代码,但要确保生成的内容完全符合预期,就需要明确的边界。抽象概念和领域特定语言(DSL)提供了一种强有力的框架,从一开始就引导 LLM 的行为。以 Tickloom 为例——这是一个用于演示分布式系统行为的领域模型和 DSL——它展示了我们如何将 LLM 作为合作伙伴,通过迭代构建 DSL,并将其作为自然语言接口来使用。在 LLM 的世界中,这样的 DSL 可以成为软件系统的权威信息来源。
2026 年 7 月 14 日
Unmesh Joshi
Unmesh 是 Thoughtworks 的高级工程师,总部位于印度浦那。他是《分布式系统模式》一书的作者。
目录
- 预先指定的局限性
- 设计通过实现被发现
- 领域抽象与 DSL
- 为什么 DSL 与 LLM 配合得如此出色
- 示例:使用 LLM 生成内容丰富的 PowerPoint 演示文稿
- 构建语义模型
- 示例:Tickloom —— 一个分布式系统的语义模型
- 即使有良好的抽象,没有 DSL 也难以实现
- 示例:构建用于测试分布式系统场景的 DSL
- 与 LLM 合作的两个阶段
- DSL 作为权威信息来源
现代 LLM 具备令人惊叹的能力。它们可以从高层次的自然语言描述中生成大量代码,有时甚至能生成整个系统。这里的一个重要假设是,需要构建的内容的“意图”已被清晰地表达出来,使用 LLM 能够映射到编码构建块的精确词汇。然而,有两点值得注意:预先指定的局限性,以及设计如何通过实现被发现。
预先指定的局限性
构建大型系统涉及大量微小的设计决策,这些决策无法在一开始就全部知晓或完全由高层次的规格说明驱动。规格说明最多只是一个初步假设:真正的约束条件、权衡取舍和边界情况是在我们推进实现的过程中通过迭代逐步发现的。我们在之前的一篇文章中曾详细讨论过这一点,当时我们称之为“预先指定的不可能性”。重点并不是规格说明毫无价值,而是第一个规格说明只是一个需要修订的假设,而非最终的蓝图。
自然的反应是进行迭代:完善规格说明,生成代码,审查返回的结果,并将所学到的内容反馈到下一轮迭代中。当每一轮迭代都能产生一个可审查的小变更时,这种循环效果很好。
所以,大型语言模型(LLMs)在其中扮演什么角色呢?我认为LLMs可以发挥两种作用。在我们塑造设计及其词汇表的过程中,它们是非常有用的帮手,作为头脑风暴的合作伙伴,帮助我们探索设计空间并发现合适的抽象概念。一旦词汇表建立起来,LLMs就可以作为其出色的自然语言接口。
领域抽象与领域特定语言(DSLs)
一个有用的框架方式是通过领域驱动设计(DDD)。其核心洞察是:在代码中构建一个共享的领域概念模型,然后使用该模型——DDD称之为“通用语言”——既用于推动代码库的演进,也为团队提供一个思考和沟通的词汇表。通常,基于该模型构建一个领域特定语言(DSL)是非常有效的:一种用于表达领域概念和操作的受限语法。从这个角度看,大多数开发过程都是构建领域模型并利用它来推动系统演进的过程。LLM在领域模型是否存在的情况下扮演着两种不同的角色。在本文中,我将重点探讨领域特定语言(DSLs)如何与LLMs协同工作。
为什么DSLs与LLMs配合得如此出色
一个常见经验是,DSLs与LLMs配合得非常好。PlantUML、Mermaid和Graphviz是用于可视化建模的领域特定语言;SQL是用于查询数据库的DSL;Kubernetes YAML是用于描述云基础设施的DSL。这些都不是通用编程语言——它们被有意限制,专门用于表达某一领域中的一组狭窄概念。因此,LLMs从纯英文描述生成Mermaid图表、SQL查询或Kubernetes清单的能力令人印象深刻。
我的观察是,DSLs使LLMs更加可靠,因为它们对少量上下文示例的响应非常出色。像Java这样的通用语言提供了许多表达相同意图的有效方式。而DSL则剥离了这些变体。给模型几个示例就足以可靠地生成正确的语法。值得注意的是,一线模型在训练过程中已经大量接触过PlantUML或Java流畅接口,因此它们并不是从零开始。令人好奇的是,当被赋予真正新颖的DSL时,更小、更受约束的模型表现如何。
对于一个代理(agent)——一个在自主生成和检查循环中运行的LLM,而不是单次生成——还有一个额外的好处。DSL几乎总是附带一个确定性验证器:解析器、JSON模式、类型检查器或编译器。代理可以生成一个候选方案,将其通过验证器检查,然后在出现错误时进行修复,整个过程无需人工干预。关键的是,错误信息以领域层面进行表述——“在选择客户之前,你不能选择一个操作”——而不是作为深埋在生成代码中的堆栈跟踪。DSL的工具集本身就是一个出色的框架。我们将在下面的Tickloom示例中具体看到这一点,其中DSL的语法由宿主语言的编译器强制执行,而运行结果会自动进行检查。
需要强调的是,这不是一个适用于所有情况的解决方案。当DSL保持足够小且受限,以至于少量上下文示例能够传达其用法时,这种优势才成立。同时,设计和维护语言及其语义模型也存在实际的前期成本。因此,这种优势主要集中在那些由验证器支持、经过良好分解且真正受限的DSL上。
示例:使用大语言模型生成包含图表的PowerPoint演示文稿
大语言模型(LLMs)使得构建自定义工具变得非常容易。在教授分布式系统时,我经常需要创建包含图表的演示文稿,这些图表用于解释集群中的分布式操作。虽然UML序列图在这一方面表现良好,但在讲解消息通过集群流动时展示完整的序列图并不实用。我需要一个工具,能够按步骤在PowerPoint演示文稿中逐步显示序列图。借助大语言模型,我成功构建了一个工具,该工具可以处理描述演示文稿结构的YAML文件(其中包含对PlantUML图表的引用),并生成PowerPoint演示文稿。PlantUML图表被标记为步骤,工具会为每个步骤生成单独的幻灯片。这使得创建包含丰富图表的演示文稿变得非常简单。
生成一个PlantUML序列图,展示由三个节点(Athens、Byzantium和Cyrene)组成的集群。用一个框标记集群。参与者Alice向Athens发送消息"标题"、"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:
end note
'[step]
athens -[#red]x byzantium: "title", "After Dawn"
'[step]
athens -> cyrene: "title", "After Dawn"
note right of cyrene
state:
end note
'[step]
athens -> athens: isQuorumReached()
'[step]
athens --> Alice: Success
@enduml我使用它在PowerPoint演示文稿中创建了一系列幻灯片。为此,我开发了一个小型YAML规范来描述演示文稿结构以及每张幻灯片中使用的图表。这使我能够利用大语言模型创建描述复杂分布式系统概念的演示文稿,而无需手动在幻灯片上创建动画。生成幻灯片YAML规范的示例提示非常简单,如下所示。
创建一个引用图表'quorum-write'并标题为'Quorum Write Example'的幻灯片YAML
这将生成如下所示的幻灯片规范YAML:
- slide:
diagram: "quorum-write"需要特别注意的是,即使提示中说“创建一个幻灯片YAML”,生成的也不是任意的YAML规范。由于生成PowerPoint演示文稿的工具和该工具所理解的YAML规范在提示中被用作上下文,大语言模型能够生成正确的YAML规范,可以直接被工具用于生成PowerPoint演示文稿。
完整的YAML规范可在该GitHub仓库查看
/think
注意,在这个单一示例中,LLM扮演了两个不同的角色。首先,它作为协同设计者——帮助在现有PlantUML工具基础上设计带有步骤标记的PlantUML扩展和幻灯片YAML。随后,当这个小型DSL存在后,它又成为自然语言接口,将英文请求转化为有效的规范。我们将在文章结尾再次回到这种分工方式。
构建语义模型
上一节的示例相对简单。YAML被用作载体语法,我直接处理其解析后的语法树,实际上将语法树本身作为语义模型(尽管这会将语法与执行语义耦合)。但在更复杂的领域(如分布式系统)中,我们需要更复杂的语义模型来表示领域中的概念以及代码库中所做的设计决策。让我们来看一个基于我构建的小型框架的示例,该框架用于快速构建和测试分布式系统。
示例:Tickloom — 用于分布式系统的语义模型
实现基于多数投票的键值存储或Raft、Paxos等共识协议是一项艰巨的任务。即使通过提示、规范或精心构建的.md技能文件逐步指导实现,异步运行时仍会暴露大量可能的实现决策空间。线程模型、网络模式、存储协调、重试行为和时间语义仍然交织在生成的代码中。问题不仅在于代码生成的复杂性,更在于验证的复杂性。所有可能的线程调度、网络延迟、进程暂停和时钟偏差的交错组合所产生的状态空间如此庞大,以至于系统性地审查和验证所有交互行为的正确性几乎不可能。这就是为什么我们看到Jepsen测试甚至能在最经过实战检验的分布式系统中发现漏洞。
这正是语义模型发挥作用的地方。Tickloom是我构建的一个小型框架,用于构建和测试分布式算法。它的抽象不是通用运行时,而是一组关于分布式进程行为方式的设计决策。每个节点都在单线程的tick循环中运行:每次调用tick()会使逻辑时钟前进一步,并按固定确定的顺序处理待处理工作(网络→消息总线→进程→存储)。时间以tick为单位衡量,而非毫秒。消息是普通的Java记录。通过Replica基类表达副本间的协调,该基类已了解对等节点、广播和多数投票机制。
线程、定时和网络传输不再是每个提示中需要重新决定的开放问题。算法作者需要关注的只剩下实际的协议逻辑。例如,一个多数投票副本只是用框架词汇表达的一组消息处理器。
由于框架提供了词汇——Replica、quorumRequest、countResponseIf、MessageType、Handler——提示可以停留在协议层面而非实现细节层面:
使用Tickloom的Replica抽象,实现一个基于多数投票的键值存储。客户端GET请求应从多数节点收集值,并返回时间戳最高的那个(最后写入者胜出)。仅当写入的时间戳比存储的更新时,才在本地应用写入。
这种高层次的描述会生成如下代码:
@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.<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));
});
}source
语义模型本身充当上下文。提示中提到的概念在代码库中都以具体类型存在,因此LLM不会发明线程模型或网络层——它只是在固定且易于理解的底层结构上填充协议逻辑。
即使没有DSL,良好的抽象也有帮助
DSL是光谱的一端,构建起来并不容易。在创建自己的语言之前,值得注意到一组清晰的抽象本身就是同一理念的轻量级实现——就像上面的框架为quorum存储提供了词汇表一样,库中命名的类型和方法本身也是模型可以扎根的词汇表。Tickloom的语义模型实际上只是四个这样的接口——用于计算和消息处理的Process/Replica,用于通信的Network,用于持久化的Storage,以及用于时间的逻辑时钟Clock——这种分解已经完成了大部分工作,完全不需要新的语法。
这就是为什么抽象(而不仅仅是DSL)与LLMs配合得很好。"以Tickloom Replica实现Raft"这样的提示具有有限的状态空间可供探索。现有的QuorumReplica可以在上下文中作为示例使用。
示例:为测试分布式系统场景构建DSL
实现算法是一回事,实际运行它又是另一回事。分布式系统中微妙的错误往往存在于特定的顺序中:一个写操作在读者的多数派节点切换前复制到某个节点,一个分区在错误的时机恢复,两个协调器的时钟出现偏差。直接使用测试工具编写这样的场景需要处理异步操作和手动的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节点写入,由于Byzantium的时钟超前,读者看到的是Bob的值”——被实现细节所掩盖。这段代码也难以验证:有数十个偶然的决策(何时触发tick、如何编码字节、调用哪个工厂方法重载)可能被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 建立,故障场景的自然语言描述几乎可以直接映射到 DSL 上。例如如下提示:
使用 Tickloom 场景 DSL,编写一个场景重现 DDIA §10.6 中的不可线性化多数读取。一个连接到 Athens 的写入者设置键值,随后在从 Athens 到其他副本的复制延迟时更新该键。Alice 通过 Byzantium 读取时,必须访问包含 Athens 的多数副本并看到新值;Bob 后续通过 Byzantium 和 Cyrene 的多数副本读取时,仍能看到旧值。
将生成一个完全使用 DSL 约束词汇的场景:
Scenario<QuorumReplicaClient> scenario =
QuorumStepBuilder.scenario("Non-linearizable quorum read")
.servers(ATHENS, BYZANTIUM, CYRENE)
.clients(WRITER, ALICE, BOB)
.client(WRITER).connectedTo(ATHENS)
.client(ALICE).connectedTo(BYZANTIUM)
.client(BOB).connectedTo(BYZANTIUM)
.steps(s -> {
// 写入者首先将键设置为 VOLD 并完成完整复制
s.client(WRITER).writes(KEY, VOLD).expectSuccess();
// 写入者更新为 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 读取。通过隔离 Athens 强制多数副本包含 Cyrene
// 延迟的 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 的作用发生了变化:它成为你所构建内容的自然语言接口。本文中的提示语就是例子——“将 quorum store 实现为 Tickloom 副本”、“编写一个场景重现 DDIA §10.6 的读取”、“为这个图表创建一个 slide YAML”。在每种情况下,英文描述几乎可以直接映射到你定义的词汇表,而 LLM 正是由于抽象提供了两个关键要素——既为提示语提供了上下文基础,又为结果验证提供了工具——才成为可靠的生成器。
DSL 作为真理的来源
目前存在一种日益增长的趋势,即将提示语视为主要的真理来源。精心设计的 DSL 彻底改变了这种动态。我观察到的与 DSL 合作的一个关键优势是,生成的程序本身通常成为人类维护的产物。由于 DSL 精炼、表达能力强且几乎不含偶然的样板代码,它以一种在生成后仍能长期保持可读性的形式,捕捉了解决方案的核心意图。如果 LLM 根据自然语言请求生成一个 Tickloom 故障场景,生成的场景已经使用了领域词汇。如果下个月需要修改这个场景,无需恢复原始提示语并重新生成所有内容。DSL 为 LLM 提供了足够的上下文,使其能够理解意图并进行协作。持久的资产不是提示语,而是 DSL 和语义模型。
致谢
我要感谢 Martin Fowler 和 Rebecca Parsons 提供的宝贵反馈和建议。
重大修订
2026 年 7 月 14 日:发布