Minta LLM untuk menghasilkan objek JSON dan pada akhirnya ia akan mengeluarkan trailing comma, newline yang tidak di-escape di dalam string, atau bare word di mana seharusnya ada quoted key. Error ini bukan bug di model. Ini adalah konsekuensi dari cara kerja autoregressive sampling: di setiap langkah, model melihat setiap token di dalam vocabulary-nya sebagai kandidat, termasuk token yang akan membuat output parsial secara sintaksis invalid.
Anda bisa memperbaikinya dengan membatasi vocabulary token itu sendiri. Alih-alih membiarkan model memilih dari tiga puluh dua ribu token, berikan hanya subset yang menjaga partial parse dalam keadaan valid. Teknik ini disebut grammar-constrained decoding, dan mengubah LLM dari generator teks probabilistik menjadi synthesizer kode yang sadar sintaks.
Mengapa prompt engineering tidak bisa menyelesaikan validitas sintaks
Sebagian besar pengembang memulai dengan pendekatan yang jelas: tambahkan “Output valid JSON only” ke system prompt, ulangi schema di user message, dan berharap. Ini membantu, tetapi bukan jaminan.
Model tidak parse output-nya sendiri saat menghasilkannya. Ia memprediksi token berikutnya berdasarkan distribusi probabilitas di atas vocabulary, dikondisikan pada prompt dan semua yang telah dihasilkan sejauh ini. Token seperti , mungkin memiliki probabilitas tinggi setelah closing brace karena koma sering muncul di posisi tersebut dalam data training. Apakah koma tersebut secara sintaksis legal di titik persis ini dalam objek JSON spesifik ini bukan bagian dari proses pengambilan keputusan model.
Bahkan model yang di-fine-tune dengan RLHF hanya menggeser probability mass ke arah pattern yang umumnya benar. Mereka tidak menghilangkan path yang invalid. Jika Anda membutuhkan jaminan, bukan probabilitas tinggi, Anda perlu mengubah sampling mechanism itu sendiri.
Bagaimana grammar-constrained decoding bekerja
Ide intinya sederhana: pertahankan parser state di samping generasi LLM, dan di setiap posisi token, mask setiap token yang akan mentransisikan parser ke error state.
Pada langkah t, model menghasilkan vector logits di seluruh vocabulary. Biasanya Anda menerapkan temperature scaling dan sampling. Dengan constrained decoding, Anda terlebih dahulu bertanya ke grammar engine: mengingat token yang telah dihasilkan sejauh ini, token mana yang legal selanjutnya? Engine mengembalikan bitmask di atas vocabulary. Anda menetapkan logits dari token yang illegal ke negatif tak hingga, menjalankan softmax pada sisanya, dan sampling dari distribusi yang telah difilter.
Grammar engine tidak perlu parse output lengkap setelahnya. Ia parse secara incremental. Setelah setiap token diterima, ia memperbarui state machine internal-nya. Ketika parser mencapai accepting state, generasi bisa berhenti. Ketika berada dalam non-accepting state, generasi berlanjut.
Ini berarti model masih bisa memilih secara bebas di antara semua kelanjutan yang secara sintaksis valid. Ia mempertahankan kreativitas di dalam grammar. Ia hanya tidak bisa keluar darinya.
Seperti apa implementasinya
Berikut adalah sketsa Python yang disederhanakan menggunakan lark, sebuah library parsing modern, untuk membangun grammar mask. Di produksi Anda akan menggunakan engine yang lebih cepat seperti outlines, guidance, atau dukungan GBNF bawaan llama.cpp, tetapi prinsipnya identik.
from lark import Lark, Token
from transformers import AutoTokenizer, AutoModelForCausalLM
import torch
# Define a tiny grammar for a simple config DSL.
grammar = r"""
start: pair+
pair: KEY "=" VALUE
KEY: /[a-z_]+/
VALUE: /"[^"]*"/
"""
parser = Lark(grammar, parser="lalr")
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf")
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-hf")
def legal_next_tokens(partial_text: str) -> set[int]:
"""Return token IDs that do not cause a parse error."""
legal = set()
for token_id in range(tokenizer.vocab_size):
candidate = tokenizer.decode([token_id], skip_special_tokens=True)
try:
# Try parsing partial_text + candidate as a prefix.
parser.parse(partial_text + candidate)
legal.add(token_id)
except Exception:
# Some exceptions are expected mid-parse.
# A production engine tracks parser state, not exceptions.
pass
return legal
# Generate one token at a time with grammar masking.
prompt = "Generate a config: "
input_ids = tokenizer.encode(prompt, return_tensors="pt")
generated = input_ids.clone()
for _ in range(50):
outputs = model(generated)
logits = outputs.logits[:, -1, :]
partial = tokenizer.decode(generated[0], skip_special_tokens=True)
legal_ids = legal_next_tokens(partial)
mask = torch.full_like(logits, float("-inf"))
for tid in legal_ids:
mask[0, tid] = logits[0, tid]
next_token = torch.argmax(mask, dim=-1).unsqueeze(0)
generated = torch.cat([generated, next_token], dim=-1)
# Stop if the parser is in an accepting state.
if parser.parse(partial):
break
print(tokenizer.decode(generated[0], skip_special_tokens=True))
Ini adalah implementasi naif. Sistem nyata tidak mengiterasi seluruh vocabulary di setiap langkah. Ia membangun finite-state automaton dari grammar terlebih dahulu dan memprekomputasi token vocabulary mana yang valid dari setiap automaton state. Pada waktu generasi, mask adalah satu table lookup.
Di mana constrained decoding cocok dalam bounded context
Bounded contexts dalam Domain-Driven Design didefinisikan oleh ubiquitous language-nya. Bahasa itu sering diformalisasikan sebagai DSL kecil: sebuah query syntax, rule grammar, format config, atau expression language. Ketika Anda meminta LLM untuk menghasilkan atau mengedit artefak di dalam konteks itu, Anda ingin ia berbicara bahasa dengan benar.
Grammar constraints menegakkan boundary. LLM tidak bisa menemukan field yang tidak ada di DSL Anda, tidak bisa mengeluarkan mismatched brackets, dan tidak bisa menghasilkan nilai di luar enum set yang diizinkan. Sintaks menjadi jaminan compile-time, bukan doa runtime.
Ini sangat berharga ketika output LLM langsung masuk ke parser atau interpreter. Jika DSL Anda diparse oleh recursive descent parser yang ketat, satu unexpected token saja akan membatalkan seluruh pipeline. Constrained decoding menghilangkan failure mode itu.
Trade-off yang harus Anda ketahui
Constrained decoding tidak gratis. Overhead-nya bergantung pada bagaimana Anda mengimplementasikan grammar engine.
Throughput. Membangun mask di setiap forward pass menambah latensi. Engine cepat seperti outlines atau llama.cpp dengan GBNF memprekomputasi mapping token-ke-state, mengurangi biaya per langkah menjadi sekitar 5-10% overhead. Implementasi naif yang re-parse di setiap token kandidat bisa memperlambat generasi berkali-kali lipat.
Grammar expressiveness. Tidak setiap sintaks mudah diekspresikan dalam grammar yang memetakan dengan bersih ke token masks. Aturan konteks-sensitif, seperti “identifier ini harus dideklarasikan sebelumnya di dalam scope”, tidak ditangkap oleh context-free grammars standar. Anda bisa membatasi sintaks dengan grammar. Anda tidak bisa membatasi semantik tanpa mekanisme tambahan.
Model compatibility. Beberapa inference API, terutama API cloud yang dihosting, tidak mengekspos logits atau mengizinkan custom masking. API OpenAI menawarkan JSON mode, yang merupakan grammar constraint hardcoded khusus untuk JSON, tetapi Anda tidak bisa membawa grammar sendiri. Untuk DSL kustom, Anda biasanya perlu menjalankan inference secara lokal atau menggunakan framework yang mengekspos logits.
Partial token problems. Sebuah token boundary tidak selalu sejalan dengan grammar boundary. Sebuah grammar mungkin mengharapkan quoted string, tetapi token berikutnya bisa jadi "hel diikuti oleh lo". Grammar engine harus berpikir tentang partial token matches, bukan hanya token utuh. Library produksi menangani ini dengan memetakan token ke character prefixes dan memeriksa prefix validity terhadap grammar.
Bagaimana benar-benar mengimplementasikan ini hari ini
Anda tidak perlu menulis grammar engine dari nol. Beberapa library menangani bagian yang sulit.
outlines adalah yang paling mudah diakses untuk pengguna Python. Anda mendefinisikan model Pydantic atau ekspresi reguler, dan ia mengompilasi constraint menjadi finite-state automaton yang efisien yang terintegrasi dengan Hugging Face transformers dan vLLM.
from outlines import models, generate
model = models.transformers("microsoft/Phi-3-mini-4k-instruct")
generator = generate.regex(model, r'\{[a-z_]+\}')
result = generator("Extract the key: ")
guidance dari Microsoft menawarkan sistem templating yang lebih kaya. Anda menyelipkan grammar constraints dengan prompt templates, dan ia menangani masking secara internal.
llama.cpp mendukung GBNF (GGML BNF), sebuah format grammar mirip BNF. Anda melewatkan file .gbnf pada waktu inference, dan engine menegakkannya di level C++. Ini adalah opsi tercepat untuk inference lokal.
jsonformer dan instructor adalah alternatif yang lebih ringan khusus untuk JSON. Mereka menggunakan constraints mirip grammar tetapi dibatasi pada JSON schema. Jika DSL bounded context Anda kebetulan berbentuk JSON, mereka adalah titik awal termudah.
Apa yang tidak diperbaiki oleh constrained decoding
LLM yang grammar-constrained masih bisa menghasilkan output yang salah secara semantik. Ia bisa mengeluarkan query SQL yang valid yang mereferensikan tabel yang tidak ada, atau config yang valid yang mengatur nomor port yang invalid. Sintaks adalah guardrail yang diperlukan, bukan yang cukup.
Anda masih membutuhkan validation layers di bawah LLM. Parse output-nya, lakukan type-check terhadap domain model Anda, dan tolak atau coba ulang jika semantiknya salah. Constrained decoding mengurangi failure modes dari “error sintaksis” menjadi “error logika”. Itu adalah peningkatan besar, tetapi bukan free pass.
Selain itu, grammar constraints tidak membuat model lebih pintar. Jika grammar terlalu permissive, model masih bisa berkeliaran ke wilayah yang tidak masuk akal tetapi sintaksis valid. Jika grammar terlalu restrictive, model tidak memiliki path yang valid untuk mengekspresikan jawaban yang benar, dan Anda mendapatkan garbage dengan low probability atau loop berulang. Mendesain grammar adalah bagian dari pekerjaan engineering.
Mulailah dari format output, bukan dari model
Sebelum Anda men-tune prompt atau beralih ke model yang lebih besar, tanyakan apakah masalahnya sebenarnya tentang reasoning atau sintaks. Jika LLM memahami apa yang Anda inginkan tetapi kadang memformatnya dengan salah, grammar constraints adalah perbaikan yang tepat. Mereka lebih murah daripada fine-tuning, lebih andal daripada prompt engineering, dan mereka memberikan jaminan yang sampling sendiri tidak bisa.
Definisikan grammar untuk DSL bounded context Anda, pasangkan ke constrained decoder, dan biarkan model menghasilkan di dalam garis. Output-nya masih akan sesekali mengejutkan Anda, tetapi tidak akan pernah menjadi error sintaksis.
FAQ
Apa itu grammar-constrained decoding?
Grammar-constrained decoding adalah teknik yang menyaring vocabulary LLM di setiap langkah generasi menggunakan grammar formal. Hanya token yang menjaga output parsial tetap sintaksis valid yang dipertimbangkan, menjamin bahwa output akhir sesuai dengan grammar.
Apakah constrained decoding mengurangi kualitas output?
Tidak untuk tugas yang terikat sintaks. Model masih memilih secara bebas di antara semua kelanjutan yang secara gramatikal valid. Untuk penulisan kreatif terbuka, constraints akan merusak kualitas. Untuk kode, config, atau generasi DSL, constraints meningkatkan baik correctness maupun reliability.
Bisakah saya menggunakan ini dengan API OpenAI atau Claude?
OpenAI menawarkan JSON mode, yang merupakan grammar constraint bawaan khusus untuk JSON. Anda tidak bisa membawa grammar sendiri. Anthropic saat ini tidak mengekspos grammar constraints. Untuk DSL kustom, Anda biasanya perlu inference lokal dengan vLLM, llama.cpp, atau engine serupa.
Format grammar apa yang didukung?
Format umum meliputi EBNF, BNF, PEG, dan GBNF. Library seperti outlines menerima ekspresi reguler dan model Pydantic. llama.cpp menggunakan GBNF. Pilih format yang didukung oleh inference engine Anda.