Tutorial Lengkap Guidance: Constrained Generation dan Structured Output dari LLM
Halo temen-temen, di tutorial kali ini aku mau ngajak kalian kenalan sama library yang menurutku sering banget kelewatan padahal powerful banget buat kerja bareng LLM, namanya Guidance. Ini library bikinan Microsoft yang fokusnya di satu hal yang bikin hidup kita jauh lebih gampang: ngontrol dan nyetir output dari model bahasa biar bener-bener sesuai sama struktur yang kita mau. Bukan cuma minta dengan sopan lewat prompt terus berdoa modelnya nurut, tapi beneran maksa outputnya ikut aturan yang kita tentuin.
Kalau kalian pernah frustrasi karena LLM kalian kadang ngasih jawaban yang formatnya berantakan, kadang nambahin kata-kata yang gak diminta, kadang milih opsi di luar daftar yang udah kita sediain, nah Guidance ini bakal jadi solusi yang elegan banget. Aku bakal bahas dari dasar banget, mulai dari kenapa constrained generation itu penting, cara instalasi, penggunaan dasar gen() dan select(), sampai ke fitur-fitur canggih kayak regex constraint, grammar atau CFG, token healing, dan bikin fungsi reusable pakai decorator @guidance. Semua bakal aku kasih contoh kode Python yang beneran bisa kalian jalanin. Yuk kita mulai.
Introduction
Sebelum masuk ke kodenya, aku mau cerita dulu masalah yang bikin library ini lahir. Bayangin temen-temen lagi bikin aplikasi yang butuh LLM buat ngeklasifikasi sentimen sebuah review jadi "positif", "negatif", atau "netral". Cara paling naif adalah kita nulis prompt kayak "Klasifikasikan sentimen review ini, jawab dengan satu kata saja: positif, negatif, atau netral." Terus kita kirim ke model dan berharap yang balik cuma satu kata dari tiga pilihan itu.
Masalahnya, LLM itu pada dasarnya generator teks yang milih token berikutnya berdasarkan probabilitas. Dia gak punya jaminan bakal nurut sama instruksi kita. Kadang dia jawab "Sentimen dari review ini adalah positif." Kadang dia jawab "Positive" pakai bahasa Inggris padahal kita minta bahasa Indonesia. Kadang dia jawab "agak positif" yang mana gak ada di daftar pilihan kita. Nah, tiap kali outputnya keluar dari jalur, kode kita yang harus parsing hasilnya jadi ikutan rapuh dan gampang error.
Pendekatan tradisional buat ngatasin ini biasanya kita nambahin instruksi yang makin panjang dan makin detail di prompt, atau kita parsing manual pakai regex, atau kita bungkus semua pakai try-except gede-gedean. Tapi ini semua rapuh. Begitu modelnya ngeluarin output yang sedikit beda, kode kita langsung jebol.
Ide di balik Guidance itu beda dari sekadar prompting. Guidance kerja di level token generation. Jadi bukan cuma minta modelnya lewat teks, tapi Guidance beneran ngebatasin token apa aja yang boleh dikeluarin model di tiap langkah. Kalau kita bilang outputnya harus salah satu dari tiga pilihan, maka Guidance secara teknis cuma ngizinin token yang mengarah ke tiga pilihan itu. Model gak punya cara buat ngeluarin output di luar itu, karena token yang gak valid probabilitasnya di-nol-in.
Selain constrained generation, Guidance juga punya konsep yang aku suka banget: kita bisa nginterleave alur kontrol program kita sama proses generasi. Jadi di dalam satu template, kita bisa ngatur bagian mana yang teks statis, bagian mana yang di-generate model, bagian mana yang dibatasi pilihan, bahkan bisa ada percabangan if-else dan looping. Ini beda banget sama cara kita biasa manggil LLM yang model request-response satu shot. Dengan Guidance, kita jadi bisa nyusun program yang lebih deterministik dan terkontrol.
Guidance ini juga hemat token dan sering lebih cepet, karena bagian yang udah kita tentuin sebagai teks statis gak perlu di-generate ulang oleh model. Model cuma fokus ngisi bagian yang emang perlu di-generate. Ini beda sama pendekatan chat biasa yang harus generate ulang semua struktur JSON atau format tiap kali.
Oke, sekarang biar gak cuma teori, kita langsung praktik ya temen-temen.
Instalasi
Instalasinya gampang banget, cukup satu baris pakai pip.
pip install guidance
Guidance secara otomatis udah bawa dependency inti yang dibutuhin. Tapi tergantung model apa yang mau kalian pakai, kalian mungkin butuh library tambahan. Kalau kalian mau pakai model dari OpenAI, install juga library openai-nya.
pip install guidance openai
Kalau kalian mau jalanin model lokal pakai transformers dari HuggingFace, install torch sama transformers.
pip install guidance transformers torch
Dan kalau kalian mau pakai model dalam format GGUF lewat llama.cpp, yang mana ini pilihan favoritku buat model lokal karena ringan dan bisa jalan di CPU, install llama-cpp-python.
pip install guidance llama-cpp-python
Saran dari aku, selalu pakai virtual environment biar dependency project kalian gak campur aduk sama project lain. Ini pola yang aku pakai tiap kali mulai project baru.
python -m venv venv
source venv/bin/activate # kalau di Windows: venv\Scripts\activate
pip install guidance llama-cpp-python
Salah satu hal penting yang perlu temen-temen paham dari awal: constrained generation yang beneran, kayak regex dan grammar constraint, itu paling optimal jalan di model lokal yang kita punya akses ke level token-nya. Model lewat API kayak OpenAI juga didukung, tapi karena kita gak punya akses penuh ke token logits-nya, beberapa fitur constraint jadi lebih terbatas. Jadi buat belajar fitur lengkapnya, aku saranin coba pakai model lokal dulu.
Basic Usage
Oke sekarang kita mulai dari yang paling dasar: loading model. Guidance punya modul guidance.models yang jadi pintu masuk buat semua jenis model. Kita mulai dari model lokal pakai llama.cpp karena ini yang paling gampang buat eksperimen.
from guidance import models
Load model GGUF lokal lewat llama.cpp
lm = models.LlamaCpp(
"models/llama-2-7b.Q4KM.gguf",
nctx=2048,
echo=False,
)
Kalau kalian mau pakai model dari HuggingFace transformers, caranya kayak gini.
from guidance import models
lm = models.Transformers("microsoft/Phi-3-mini-4k-instruct")
Dan kalau kalian mau pakai OpenAI, tinggal set API key kalian di environment variable OPENAIAPIKEY terus load kayak gini.
from guidance import models
lm = models.OpenAI("gpt-4o-mini")
Yang menarik dari Guidance, object lm ini immutable dan bisa kita "tambahin" pakai operator +. Tiap kali kita tambahin sesuatu, kita dapet object baru dengan state yang udah ke-update. Ini konsep yang penting banget dipahami. Kita bisa nambahin teks biasa ke model.
from guidance import models
lm = models.LlamaCpp("models/llama-2-7b.Q4KM.gguf")
Nambahin teks statis ke context model
lm = lm + "Ibukota Indonesia adalah "
print(lm)
Teks statis kayak gitu gak di-generate sama model, cuma dimasukin ke context. Nah, buat minta model beneran generate sesuatu, kita pakai fungsi gen().
Fungsi gen()
Fungsi gen() ini inti dari Guidance. Dia yang nyuruh model buat generate token. Kita bisa kasih nama biar hasilnya gampang diambil lagi, dan kita bisa batasi berapa maksimal token yang di-generate.
from guidance import models, gen
lm = models.LlamaCpp("models/llama-2-7b.Q4KM.gguf")
lm = lm + "Ibukota Indonesia adalah " + gen("ibukota", maxtokens=10, stop="\n")
Ambil hasil generasi lewat nama yang kita kasih
print(lm["ibukota"])
Perhatiin di situ kita kasih nama "ibukota" ke gen(). Setelah generasi selesai, kita bisa akses hasilnya lewat lm["ibukota"]. Ini salah satu hal yang aku suka dari Guidance, hasil tiap bagian generasi bisa kita ambil terpisah pakai nama, jadi gak perlu parsing manual dari output gede.
Parameter stop itu buat nentuin kapan generasi berhenti. Di contoh di atas, model bakal berhenti generate begitu ketemu newline. Kita juga bisa kasih list beberapa stop string.
from guidance import models, gen
lm = models.LlamaCpp("models/llama-2-7b.Q4KM.gguf")
prompt = "Tuliskan satu kalimat motivasi singkat: "
lm = lm + prompt + gen("motivasi", maxtokens=50, stop=[".", "\n"])
print(lm["motivasi"])
Fungsi select()
Nah ini yang paling sering aku pakai dan paling ngasih efek "wow" pertama kali. Fungsi select() buat maksa model milih dari daftar pilihan yang udah kita tentuin. Model gak bisa keluar dari daftar itu, titik. Ini beneran solusi buat masalah klasifikasi yang aku ceritain di awal tadi.
from guidance import models, select
lm = models.LlamaCpp("models/llama-2-7b.Q4KM.gguf")
review = "Produknya bagus banget, pengiriman cepat, aku puas!"
lm = lm + f"""Klasifikasikan sentimen review berikut.
Review: {review}
Sentimen: """ + select(["positif", "negatif", "netral"], name="sentimen")
print(lm["sentimen"]) # dijamin salah satu dari tiga pilihan
Coba temen-temen resapi ini. Dengan select(), kita gak perlu lagi ngasih instruksi panjang lebar "jawab dengan satu kata saja ya, pilih dari positif negatif netral, jangan tambahin apa-apa". Kita cukup kasih pilihannya, dan secara teknis model gak punya kemampuan buat ngeluarin output di luar tiga pilihan itu. Karena di level token, Guidance cuma ngizinin path token yang mengarah ke salah satu pilihan yang valid.
Ini bedanya sama naive prompting yang aku bilang di awal. Naive prompting itu kita berharap model nurut. Constrained generation itu kita bikin model gak punya pilihan selain nurut. Bedanya di jaminan. Yang satu probabilistik, yang satu deterministik di sisi struktur.
Nge-interleave Teks dan Generasi
Kekuatan sebenernya Guidance itu keliatan waktu kita mulai nge-mix teks statis, generasi, dan pilihan dalam satu alur. Misalnya kita mau ekstrak data terstruktur dari sebuah teks.
from guidance import models, gen, select
lm = models.LlamaCpp("models/llama-2-7b.Q4KM.gguf")
email = "Halo, nama saya Budi Santoso, saya mau komplain karena pesanan saya belum sampai padahal sudah 2 minggu."
lm = lm + f"""Ekstrak informasi dari email berikut.
Email: {email}
Nama: """ + gen("nama", stop="\n") + """
Kategori: """ + select(["komplain", "pertanyaan", "pujian", "lainnya"], name="kategori") + """
Urgensi: """ + select(["rendah", "sedang", "tinggi"], name="urgensi")
print("Nama:", lm["nama"])
print("Kategori:", lm["kategori"])
print("Urgensi:", lm["urgensi"])
Liat gimana rapinya. Kita nyusun template yang mencampur bagian statis (label "Nama:", "Kategori:", "Urgensi:") sama bagian yang di-generate atau dipilih model. Tiap hasilnya langsung bisa kita ambil pakai nama. Gak ada parsing JSON, gak ada regex, gak ada try-except. Struktur outputnya udah dijamin dari desain.
Yang keren juga, karena label-label statis itu gak di-generate model, kita hemat token dan waktu. Model cuma fokus ngisi bagian yang penting aja.
Advanced Usage
Oke, sekarang kita naik level. Bagian ini yang bikin Guidance beneran beda dari library lain.
Regex Constraint
Kadang kita butuh output yang formatnya spesifik banget, kayak nomor telepon, tanggal, atau angka dengan format tertentu. Guidance ngizinin kita batasi output pakai regular expression. Jadi model dipaksa ngeluarin token yang cocok sama pola regex kita.
from guidance import models, gen
lm = models.LlamaCpp("models/llama-2-7b.Q4KM.gguf")
Paksa output berupa angka 4 digit
lm = lm + "Tahun kemerdekaan Indonesia adalah " + gen("tahun", regex=r"\d{4}")
print(lm["tahun"]) # dijamin 4 digit angka
Contoh lain, misalnya kita mau ekstrak harga dalam format rupiah.
from guidance import models, gen
lm = models.LlamaCpp("models/llama-2-7b.Q4KM.gguf")
teks = "Laptop ini dijual seharga tiga juta lima ratus ribu rupiah."
lm = lm + f"""Teks: {teks}
Harga dalam angka (format Rp): Rp""" + gen("harga", regex=r"[\d\.]+")
print("Rp" + lm["harga"])
Dengan regex constraint, kita punya jaminan format di level karakter. Model gak akan bisa ngeluarin huruf di tempat yang harusnya angka, karena token yang gak cocok sama pola langsung ditolak. Ini powerful banget buat kasus di mana format output itu kritikal, misalnya buat masuk ke database atau di-parse sistem lain.
Grammar dan CFG (Context-Free Grammar)
Ini fitur yang paling advanced dan paling bikin aku kagum. Guidance ngizinin kita mendefinisikan grammar atau context-free grammar buat ngontrol struktur output yang kompleks dan rekursif. Grammar itu semacam aturan tata bahasa yang mendefinisikan struktur valid.
Kita bisa nyusun grammar dari komponen-komponen kecil pakai operator. Misalnya kita mau bikin generator yang cuma ngeluarin ekspresi matematika sederhana yang valid.
from guidance import models, gen, select, oneormore
lm = models.LlamaCpp("models/llama-2-7b.Q4
KM.gguf")
Bikin komponen grammar
def operator():
return select(["+", "-", "*", "/"])
def number():
return gen(regex=r"\d+")
Grammar: angka (operator angka)+
def expression():
return number() + one
ormore(operator() + number())
lm = lm + "Contoh ekspresi matematika: " + expression()
print(lm)
Dengan grammar, kita bisa mendefinisikan struktur output yang jauh lebih kompleks daripada sekadar pilihan atau regex. Kita bisa bikin grammar buat JSON yang valid, buat sintaks bahasa pemrograman tertentu, atau format data custom apapun. Yang penting, output yang di-generate dijamin secara struktural valid sesuai grammar yang kita tentuin.
Guidance juga nyediain building block bawaan buat nyusun grammar, kayak oneormore, zeroormore, dan operator penggabungan. Ini bikin kita bisa nyusun grammar yang kompleks dari potongan-potongan kecil yang reusable.
Buat kasus JSON, konsepnya kita definisiin grammar yang mendeskripsikan struktur objek: kurung kurawal buka, pasangan key-value, koma pemisah, kurung kurawal tutup. Model dipaksa ngikutin struktur ini token demi token, jadi mustahil dia ngeluarin JSON yang gak valid.
Token Healing
Ini konsep yang halus tapi penting banget, temen-temen. Namanya token healing. Buat ngerti ini, kita perlu paham dikit gimana model bahasa memproses teks. Model gak baca teks huruf per huruf, tapi per token. Token itu bisa berupa kata, potongan kata, atau bahkan gabungan kata sama tanda baca.
Masalah muncul waktu prompt kita berakhir di tengah-tengah batas token yang gak natural. Misalnya kita nulis prompt yang berakhir dengan "http:" terus kita minta model lanjutin. Nah, model biasanya melihat "http://" sebagai satu token utuh. Karena prompt kita udah "maksa" berhenti di "http:", model jadi bingung dan bisa ngeluarin lanjutan yang aneh, karena tokenisasinya jadi gak natural.
Token healing ngatasin ini dengan cara "mundur" satu token di ujung prompt, terus nge-generate ulang dengan constraint bahwa hasilnya harus tetep konsisten sama teks asli. Jadi batas token yang tadinya ke-potong paksa, di-"sembuhin" biar tokenisasinya natural lagi.
from guidance import models, gen
lm = models.LlamaCpp("models/llama-2-7b.Q4KM.gguf")
Tanpa masalah token boundary karena Guidance handle token healing otomatis
lm = lm + "Kunjungi website kami di http:" + gen("url", regex=r"//[\w\./]+", maxtokens=20)
print(lm["url"])
Kabar baiknya, Guidance ngelakuin token healing ini otomatis di belakang layar buat model lokal. Jadi temen-temen gak perlu mikirin detail teknisnya, tapi penting buat tau kenapa output kalian jadi lebih bagus dibanding pendekatan lain. Ini salah satu alasan kenapa constrained generation di Guidance hasilnya lebih rapi.
Bikin Fungsi Reusable dengan @guidance
Nah ini fitur yang bikin Guidance beneran scalable buat project besar. Kita bisa bikin fungsi reusable pakai decorator @guidance. Jadi pola generasi yang sering kita pakai bisa kita bungkus jadi fungsi, terus dipanggil berkali-kali kayak fungsi biasa.
from guidance import models, gen, select, guidance
@guidance
def klasifikasisentimen(lm, teks):
lm += f"""Klasifikasikan sentimen teks berikut.
Teks: {teks}
Sentimen: """ + select(["positif", "negatif", "netral"], name="sentimen")
return lm
lm = models.LlamaCpp("models/llama-2-7b.Q4KM.gguf")
Panggil fungsi reusable-nya
lm = lm + klasifikasisentimen("Barangnya jelek, aku kecewa banget.")
print(lm["sentimen"])
Perhatiin strukturnya. Fungsi yang di-decorate @guidance selalu nerima lm sebagai argumen pertama, terus argumen lain sesukanya. Di dalam fungsi, kita nambahin ke lm pakai +=, terus kita return lm-nya. Setelah itu, fungsi ini bisa dipanggil kayak building block biasa dan digabung ke model pakai +.
Kita bisa bikin fungsi-fungsi ini buat berbagai tugas: ekstraksi data, klasifikasi, generasi terstruktur, dan sebagainya. Terus kita bisa nge-compose fungsi-fungsi ini jadi alur yang lebih besar. Ini bikin kode kita jadi modular, gampang dibaca, dan gampang dites.
from guidance import models, gen, select, guidance
@guidance
def ekstrakorang(lm, teks):
lm += f"""Ekstrak data orang dari teks: {teks}
Nama: """ + gen("nama", stop="\n") + """
Umur: """ + gen("umur", regex=r"\d+") + """
Kota: """ + gen("kota", stop="\n")
return lm
lm = models.LlamaCpp("models/llama-2-7b.Q4KM.gguf")
lm = lm + ekstrakorang("Andi berumur 28 tahun tinggal di Bandung.")
print("Nama:", lm["nama"])
print("Umur:", lm["umur"])
print("Kota:", lm["kota"])
Menggabungkan Alur Kontrol dengan Generasi
Karena Guidance itu Python biasa, kita bisa nge-mix logika program kayak if-else dan loop sama generasi. Misalnya kita mau generate beberapa item dalam satu loop.
from guidance import models, gen, select, guidance
@guidance
def generatetodo(lm, jumlah):
lm += "Daftar tugas harian:\n"
for i in range(jumlah):
lm += f"{i+1}. " + gen(f"tugas{i}", stop="\n") + "\n"
return lm
lm = models.LlamaCpp("models/llama-2-7b.Q4KM.gguf")
lm = lm + generatetodo(3)
for i in range(3):
print(lm[f"tugas{i}"])
Ini yang aku maksud di awal dengan nginterleave alur kontrol sama generasi. Kita punya kontrol penuh dari sisi program, tapi tetep manfaatin kemampuan generatif model. Ini beda banget sama pola chat biasa yang satu request satu response gede.
Best Practices
Setelah cukup lama pakai Guidance, ada beberapa hal yang aku pelajarin dan pengen aku share biar temen-temen gak jatuh di lubang yang sama kayak aku dulu.
Pertama, pilih tool yang tepat buat masalahnya. Kalau outputnya cuma perlu milih dari beberapa opsi tertutup, pakai select(). Kalau formatnya spesifik kayak angka, tanggal, atau ID, pakai regex. Kalau strukturnya kompleks dan rekursif kayak JSON atau ekspresi, baru pakai grammar atau CFG. Jangan pakai grammar berat buat masalah yang sebenernya cukup diselesaikan pakai select(). Sesuaikan tingkat constraint sama kebutuhan.
Kedua, selalu kasih nama yang jelas ke tiap gen() dan select() pakai parameter name. Ini bikin kalian gampang ngambil hasil tiap bagian secara terpisah lewat lm["nama"]. Kode kalian jadi jauh lebih rapi dan gampang di-maintain dibanding harus parsing output gede.
Ketiga, buat constrained generation yang beneran (regex, grammar, token healing), pakai model lokal lewat llama.cpp atau transformers. Model lewat API kayak OpenAI dukungannya lebih terbatas karena kita gak punya akses ke token logits-nya. Jadi kalau kalian butuh jaminan struktur yang ketat, model lokal itu pilihan yang lebih aman.
Keempat, manfaatin @guidance buat bikin komponen reusable. Kalau kalian nemu pola generasi yang dipakai berulang, bungkus jadi fungsi. Ini bikin codebase kalian modular dan gampang dites. Aku sendiri biasanya punya semacam library fungsi Guidance sendiri buat tugas-tugas umum kayak ekstraksi, klasifikasi, dan formatting.
Kelima, batasi max_tokens dan set stop yang tepat di tiap gen(). Ini penting buat kontrol biaya dan kecepatan, sekaligus mencegah model ngelantur generate teks yang gak perlu. Kalau kalian tau outputnya harusnya pendek, batasin aja tegas.
Keenam, inget prinsip hemat token. Salah satu keunggulan Guidance itu bagian teks statis gak di-generate ulang model. Jadi manfaatin ini dengan nulis template yang jelas memisahkan bagian statis sama bagian generatif. Makin banyak struktur yang kalian tentuin sebagai teks statis, makin sedikit token yang perlu di-generate, makin cepet dan murah.
Ketujuh, tes fungsi Guidance kalian kayak tes kode biasa. Karena outputnya udah terstruktur dan bisa diambil pakai nama, kalian bisa nulis unit test yang ngecek tipe dan format hasilnya. Ini jauh lebih gampang daripada nge-test output LLM yang bebas formatnya.
Dan yang terakhir, jangan lupa constrained generation itu bukan sihir. Kita maksa strukturnya bener, tapi isi atau kualitas kontennya tetep tergantung kemampuan modelnya. Kalau modelnya salah milih kategori, select() cuma mastiin dia milih dari daftar yang valid, bukan mastiin pilihannya bener secara semantik. Jadi tetep pilih model yang cukup capable buat tugas kalian.
Conclusion
Oke temen-temen, kita udah keliling cukup jauh soal Guidance. Aku harap sekarang kalian punya gambaran yang jelas kenapa library ini menurutku salah satu tool paling underrated buat kerja bareng LLM.
Intinya, Guidance itu ngubah cara kita mikir soal ngontrol LLM. Bukan lagi soal nulis prompt yang makin panjang dan makin memohon biar modelnya nurut, tapi soal ngebatasin secara teknis apa yang boleh dan gak boleh dikeluarin model. Dengan gen() buat generasi bebas, select() buat pilihan tertutup, regex buat format spesifik, grammar buat struktur kompleks, token healing buat output yang lebih rapi, dan @guidance buat komponen reusable, kita punya toolkit lengkap buat bikin output LLM yang terstruktur dan bisa diandalkan.
Kenapa ini ngalahin naive prompting? Karena kita dapet jaminan. Naive prompting itu probabilistik, kita berharap. Constrained generation itu deterministik di sisi struktur, kita maksa. Buat aplikasi produksi yang outputnya harus masuk ke sistem lain, jaminan struktur ini bedanya antara sistem yang reliable sama sistem yang random error tiap beberapa request.
Saranku, mulai aja dari yang kecil. Coba pakai select() buat kasus klasifikasi kalian yang selama ini pusing sama output yang gak konsisten. Rasain sendiri bedanya. Habis itu naik ke regex, terus grammar kalau emang butuh. Dan begitu kalian nemu pola yang berulang, bungkus pakai @guidance biar reusable.
Selamat nyoba ya temen-temen, semoga tutorial ini ngebantu kalian bikin aplikasi LLM yang lebih terkontrol dan reliable. Kalau ada pertanyaan atau pengen aku bahas topik lain, jangan sungkan buat kasih tau. Sampai ketemu di tutorial berikutnya!