教会 LLM 编写汇编:针对自定义 8 位 CPU 的 GBNF 约束生成

发布: (2025年12月4日 GMT+8 16:03)
7 min read
原文: Dev.to

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。工作流程如下:

  1. 使用 GBNF 规则、终结符和非终结符 描述你的语言。
  2. 运行时 加载 该语法。
  3. 生成过程中它 屏蔽 任何会破坏语法的 token,迫使 LLM 只沿合法路径前进。

并不 让模型理解 DSL;它仅仅防止了语法错误。例如,如果语法把操作码列为 LOAD | STORE | ADD | SUB,模型就不能凭空 invent LOADXMOVE

为类似汇编的 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 后缀使匹配不区分大小写,因此 LDldLd 都是合法的。语法的严格性消除了之前常见的“幻觉操作码”、缺失逗号、杂散标点以及不存在的寄存器等问题。

完整的语法文件可在 此处 找到。

将语法接入 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包含上下文和指令的完整提示
grammarGBNF 语法字符串(从文件加载)
n_predict最大生成 token 数(4096 足够宽裕)
temperature0.7 在创造性与连贯性之间取得平衡
stop三个换行符 (\n\n\n) 表示生成结束

结果与观察

模型第一次就一次性生成了完全合法的汇编代码,效果立竿见影:无需后处理、无需语法错误处理,汇编器直接接受输出。与之前的实验相比:

模型 / 设置常见问题
Qwen(无语法)幻觉操作码、指令格式错误、缺少逗号
Claude Sonnet 4.5(无语法)大多数正确,但偶有杂散标点
Claude + GBNF零语法错误;每行都符合汇编器的预期

该方法也具备可扩展性:即使使用更小、更便宜的本地模型,也能保持可靠、结构化的输出。唯一剩下的挑战是 语义正确性(生成的程序是否实现了你的意图?),我将在后续文章中探讨。

结论

使用 GBNF 的语法约束生成把“事后清理混乱”的问题转变为“让混乱不可能出现”。通过描述 DSL 接受的精确 token 序列,你可以消除整类语法错误,使即使是普通的本地 LLM 也能为极其脆弱的语言(如汇编)生成可用代码。该技术是通用的,可应用于任何需要保证输出有效的领域:配置文件、结构化数据、测试脚本、游戏引擎脚本等。

Back to Blog

相关文章

阅读更多 »

🌑 进入黑暗:Soulbound Codex

演示图片 https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2...