返回首页

LLM 写代码更稳的方法是先写 DSL

可执行的语义层比长提示词更能收住输出

前阵子反复见到同一类情况:把需求直接扔给模型,生成速度很快,返工也快;先把需求收进一层 DSL,再让模型围着这层语义模型写实现,输出就明显稳定下来。变化很直接,关键不在模型会不会写,而在可选空间有没有先收住。

自然语言适合把方向讲开,不适合承载太多隐含决定。一个看起来很简单的需求,落到实现时会分裂成很多小判断:状态怎么命名,失败算不算完成,重试算几次,时间窗口按谁的时区,日志写在哪一层。只要这些判断还藏在生成过程里,模型就会一边补细节,一边改边界,最后写出来的东西可以跑,却很难复查。

自然语言只适合把问题说开

LLM 很擅长把一段模糊描述展开成完整文本,也很擅长把一个意图补成一份能看的草稿。它不擅长的是替一套业务规则做长期稳定的命名和约束。需求一旦涉及状态流转、异常分支、时间边界、权限边界,这些词在脑子里看起来已经清楚,真正落到代码里时却常常还没定型。模型面对的是一大团开放问题,输出自然也会跟着飘。

这也是为什么长提示词经常越写越累。提示词继续加长以后,模型拿到的还是一段自由文本,只不过自由文本更长了。它可以记住更多上下文,却没有得到更多边界。边界没收住,模型就只能继续猜。

DSL 把隐含判断变成显式输入

把输入改成 DSL 以后,情况就变了。模型不再对着散句猜业务,而是对着一个明确的语义模型填空。一个测试场景、一个发布流程、一个图形说明,先由 DSL 定义节点和关系,再由模型补文字、代码、图表,结果会更像同一套系统长出来的东西。

scenario: payment_timeout
steps:
  - send: order.created
  - wait: 3s
  - if: payment_missing
    then: cancel_order
    audit: required

这类定义最大的价值不在写起来好看,而在能被版本控制、被 diff、被 review。代码生成、文档、测试、图表都可以从同一份语义模型长出来。模型在这里更像执行器,不像发明者。它负责把已经定下来的边界落地,不负责临场改写边界。

这层边界一旦成立,很多原本会吵半天的事情就会安静下来。测试到底该覆盖什么、失败算不算预期、回滚条件是谁说了算,这些都不再靠一段自然语言临场解释,而是写进了可执行的结构里。人看的是语义,机器执行的是语义,排障时回到的还是同一份语义。

LLM 适合参与 DSL 的生长

LLM 不是只能站在 DSL 外面执行。更合适的用法,是先让它参与 DSL 的生长。把几条真实场景喂进去,让它把边界词、枚举值、异常分支、反例补齐,再由人收口。等 DSL 定下来,后续交给模型的就不是无限制的自然语言,而是受约束的输入。

这个阶段里,LLM 还挺有用。它能帮忙把分散的说法对齐成一套术语,也能在草稿阶段找出命名不一致、状态缺口、异常路径缺失这些问题。等语义模型稳定以后,模型再去生成实现、示意图、测试样例,输出就更像是在一条轨道上跑,不会每次都拐向别的地方。

我更愿意把 LLM 放在 DSL 后面。前面先把边界说清楚,后面让它帮忙把边界填满,系统就不容易被一次次临场发挥拖散。

语义层也会变得笨重

DSL 也不是越多越好。领域还在剧烈变化时,过早固化会把错误假设一起锁进去;语义层设计得太通用,又会变成另一种厚重框架。真正划算的场景,通常是同一类动作反复出现、review 成本很高、行为必须可追踪的时候。到了这一步,DSL 才不是额外负担,而是把分散判断收拢成一个稳定入口。

所以更实用的判断不是“能不能给 LLM 配一层 DSL”,而是“这件事有没有必要把语义先固定下来”。答案一旦是肯定的,提示词就不该继续承担全部职责。提示词负责解释意图,DSL 负责承载约束,模型负责把约束变成可执行结果。这样写出来的东西,才更像工程系统,而不是一串临场发挥。