教会 LLM 编写汇编:针对自定义 8 位 CPU 的 GBNF 约束生成
Source: Dev.to
介绍
在过去的几周里,我从零开始构建了一个完整可玩的 8 位虚拟主机——CPU、指令集、汇编器、精灵系统、IDE,全部都有。比较有趣的副任务之一是教会大模型(LLM)生成针对我的 CPU 的有效汇编代码。你可以在我的 YouTube 系列 使用 AI 助手构建虚拟 8 位主机(截至第 14 部)中看到完整的构建过程。项目的源代码在 GitHub 上——请查看我添加此工作的标签对应的快照。
当你让模型生成特定领域的代码时,它往往“看起来对”,但解析器会拒绝它。我需要模型只输出我的汇编器能够接受的 token,这促使我使用了 GBNF——一种 llama.cpp 和其他推理运行时支持的紧凑语法表示法,能够强制模型输出语法上有效的内容。
GBNF 实际是什么
GBNF(Grammar‑Based Neural‑Fusion)是一种小型语法格式,用来描述你的语言中哪些 token 序列是合法的。多个推理运行时(包括 llama.cpp 和 vLLM)可以在解码时使用它来屏蔽任何会违反语法的 token。工作流程如下:
- 使用 GBNF 规则、终结符和非终结符 描述你的语言。
- 运行时 加载 该语法。
- 生成过程中它 屏蔽 任何会破坏语法的 token,迫使 LLM 只沿合法路径前进。
这 并不 让模型理解 DSL;它仅仅防止了语法错误。例如,如果语法把操作码列为 LOAD | STORE | ADD | SUB,模型就不能凭空 invent LOADX 或 MOVE。
为类似汇编的 DSL 设计语法
我先准备了汇编器和 CPU 的完整规格说明,以及我一直喂给 Claude 的 AI 速查表。利用这些材料,我让 Claude 起草了一份 GBNF 语法,然后逐行对照我的示例程序进行审查。最终得到的语法完全匹配汇编器的期望。
简化语法摘录
root ::= line*
line ::= ws* statement? comment? eol
statement ::= instruction | directive
instruction ::= opcode-noarg
| opcode-single ws+ operand
| opcode-double ws+ operand ws* "," ws* operand
opcode-noarg ::= "NOP"i | "RET"i | "RTI"i | "SEI"i | "CLI"i
opcode-single ::= "PUSH"i | "POP"i | "INC"i | "DEC"i | "JMP"i | "CALL"i
opcode-double ::= "LD"i | "ST"i | "MOV"i | "ADD"i | "SUB"i | "AND"i | "OR"i | "XOR"i | "CMP"i
operand ::= immediate | register | memory-ref | identifier
immediate ::= "#" (number | identifier)
register ::= "R"i [0-5] | "SP"i | "PC"i
memory-ref ::= "[" ws* (number | identifier) ws* "]"
number ::= "$" [0-9a-fA-F]+ | [0-9]+
identifier ::= [a-zA-Z_] [a-zA-Z0-9_]*
comment ::= ws* ";" [^\r\n]*
ws ::= [ \t]+
eol ::= "\r"? "\n" | "\r"
i 后缀使匹配不区分大小写,因此 LD、ld、Ld 都是合法的。语法的严格性消除了之前常见的“幻觉操作码”、缺失逗号、杂散标点以及不存在的寄存器等问题。
完整的语法文件可在 此处 找到。
将语法接入 llama.cpp
llama.cpp 提供了一个 /completion 接口,接受包含 GBNF 的 grammar 参数(字符串形式)。下面是我在 IDE 中使用的 TypeScript 辅助函数,用于请求受语法约束的生成。
async function generateWithGrammar(prompt: string, grammar: string): Promise {
const response = await fetch(`${this.codegenUrl}/completion`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt,
n_predict: 4096,
grammar,
temperature: 0.7,
stop: ['\n\n\n'],
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`llama.cpp grammar generation failed: ${response.status} ${errorText}`);
}
const result = await response.json();
return result.content;
}
关键参数
| 参数 | 用途 |
|---|---|
prompt | 包含上下文和指令的完整提示 |
grammar | GBNF 语法字符串(从文件加载) |
n_predict | 最大生成 token 数(4096 足够宽裕) |
temperature | 0.7 在创造性与连贯性之间取得平衡 |
stop | 三个换行符 (\n\n\n) 表示生成结束 |
结果与观察
模型第一次就一次性生成了完全合法的汇编代码,效果立竿见影:无需后处理、无需语法错误处理,汇编器直接接受输出。与之前的实验相比:
| 模型 / 设置 | 常见问题 |
|---|---|
| Qwen(无语法) | 幻觉操作码、指令格式错误、缺少逗号 |
| Claude Sonnet 4.5(无语法) | 大多数正确,但偶有杂散标点 |
| Claude + GBNF | 零语法错误;每行都符合汇编器的预期 |
该方法也具备可扩展性:即使使用更小、更便宜的本地模型,也能保持可靠、结构化的输出。唯一剩下的挑战是 语义正确性(生成的程序是否实现了你的意图?),我将在后续文章中探讨。
结论
使用 GBNF 的语法约束生成把“事后清理混乱”的问题转变为“让混乱不可能出现”。通过描述 DSL 接受的精确 token 序列,你可以消除整类语法错误,使即使是普通的本地 LLM 也能为极其脆弱的语言(如汇编)生成可用代码。该技术是通用的,可应用于任何需要保证输出有效的领域:配置文件、结构化数据、测试脚本、游戏引擎脚本等。