LLM에게 어셈블리 작성을 가르치기: 맞춤형 8비트 CPU를 위한 GBNF-제한 생성
Source: Dev.to
Introduction
지난 몇 주 동안 저는 CPU, 명령어 집합, 어셈블러, 스프라이트 시스템, IDE 등 모든 요소를 직접 구현한 8‑비트 가상 콘솔을 만들었습니다. 가장 흥미로운 부가 과제 중 하나는 LLM에게 제 CPU용 유효한 어셈블리를 생성하도록 가르치는 것이었습니다. 전체 과정을 제 YouTube 시리즈 AI 어시스턴트와 함께 가상 8‑비트 콘솔 만들기(14부까지)에서 확인할 수 있습니다. 프로젝트 소스 코드는 GitHub에 있으며, 이 작업을 추가한 태그의 스냅샷을 참고하세요.
도메인‑특화 코드를 생성하도록 모델에 요청하면 “형태는 맞아 보이지만” 파서가 거부하는 경우가 많았습니다. 어셈블러가 받아들일 수 있는 토큰만 모델이 출력하도록 해야 했고, 이를 위해 GBNF—llama.cpp와 기타 추론 런타임이 지원하는 간결한 문법 표기법—를 사용하게 되었습니다. GBNF는 모델이 구문적으로 올바른 출력만 하도록 강제합니다.
What GBNF Actually Is
GBNF(Grammar‑Based Neural‑Fusion)는 언어에서 허용되는 토큰 시퀀스를 기술하는 작은 문법 형식입니다. 여러 추론 런타임(llama.cpp, vLLM 등)이 디코딩 중에 이를 사용해 문법을 위반하는 토큰을 마스킹할 수 있습니다. 워크플로는 다음과 같습니다.
- Describe: GBNF 규칙, 터미널, 비터미널을 사용해 언어를 기술합니다.
- 런타임이 이 grammar을 load합니다.
- 생성 과정에서 문법을 깨는 토큰을 mask하여 모델이 유효한 경로만 따라가게 합니다.
이는 모델이 DSL을 이해하게 만드는 것이 아니라, 구문 오류만 방지합니다. 예를 들어 문법에 LOAD | STORE | ADD | SUB 라는 opcode만 명시하면 모델은 LOADX 나 MOVE 같은 잘못된 opcode를 만들어낼 수 없습니다.
Designing a Grammar for an Assembly‑like DSL
저는 어셈블러와 CPU에 대한 포괄적인 사양, 그리고 Claude에게 제공하던 AI cheatsheet을 바탕으로 시작했습니다. 이 자료들을 활용해 Claude에게 GBNF 문법 초안을 작성해 달라고 요청했고, 이후 예제 프로그램과 한 줄씩 비교 검토했습니다. 최종 문법은 어셈블러가 기대하는 형태와 정확히 일치합니다.
Simplified Grammar Excerpt
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 모두 유효합니다. 문법이 엄격하기 때문에 이전에 자주 발생하던 “허구의 opcode”, 누락된 콤마, 불필요한 구두점, 존재하지 않는 레지스터 등이 사라집니다.
전체 문법 파일은 **여기**에서 확인할 수 있습니다.
Plugging the Grammar into llama.cpp
llama.cpp는 grammar 파라미터에 GBNF 문자열을 전달할 수 있는 /completion 엔드포인트를 제공합니다. 아래는 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;
}
Key parameters
| Parameter | Purpose |
|---|---|
prompt | 전체 프롬프트(컨텍스트와 지시문 포함) |
grammar | GBNF 문법 문자열(파일에서 로드) |
n_predict | 생성할 최대 토큰 수(4096이면 충분히 넉넉) |
temperature | 0.7은 창의성과 일관성 사이의 균형 |
stop | 삼중 개행(\n\n\n)은 생성 종료 신호 |
Results and Observations
모델이 처음으로 완벽히 유효한 어셈블리를 한 번에 생성했을 때의 충격은 즉각적이었습니다. 사후 처리나 구문 오류 처리 없이 어셈블러가 바로 출력을 받아들였습니다. 이전 실험과 비교하면:
| Model / Setup | Typical Issues |
|---|---|
| Qwen (no grammar) | 허구의 opcode, 형식이 잘못된 명령, 누락된 콤마 |
| Claude Sonnet 4.5 (no grammar) | 대부분 정확하지만 가끔씩 불필요한 구두점 |
| Claude + GBNF | 구문 오류 0건; 모든 라인이 어셈블러 기대에 부합 |
이 방법은 규모 확장에도 유리합니다. 더 작고 저렴한 로컬 모델을 사용해도 구조화된 출력을 안정적으로 얻을 수 있습니다. 남은 과제는 의미적 정확성(생성된 프로그램이 의도한 동작을 하는가)이며, 이는 다음 포스트에서 다룰 예정입니다.
Conclusion
GBNF를 활용한 문법‑제한 생성은 “사후에 엉망을 정리한다”는 문제를 “엉망 자체를 만들 수 없게 만든다”는 접근법으로 바꿉니다. DSL이 허용하는 정확한 토큰 시퀀스를 기술함으로써 전체 구문 오류 클래스를 제거하고, 심지어 조악한 로컬 LLM도 어셈블리와 같이 취약한 언어에 대해 사용 가능한 코드를 만들 수 있게 됩니다. 이 기술은 구성 파일, 구조화된 데이터, 테스트 스크립트, 게임 엔진 스크립트 등 유효한 출력이 반드시 보장돼야 하는 모든 도메인에 적용할 수 있습니다.