Prompt yang menghasilkan JSON rapi di satu model bisa hancur begitu kamu pindahkan ke model lain yang lebih murah atau model lokal. Kamu tambahin tiga baris "PENTING: keluarkan JSON yang valid", deploy, dan errornya cuma pindah ke tiket yang lain. Dua putaran kayak gitu, prompt kamu jadi tumpukan kasus khusus yang nggak ada yang mau sentuh.
Ada cara yang lebih membosankan buat beresin ini. Tentukan metrik di atas contoh berlabel milikmu sendiri, lalu biarkan optimizer nulis ulang teks instruksinya sampai skornya berhenti naik. Itulah yang dilakukan GEPA di dalam DSPy, dan panduan ini jalanin seluruh loop-nya di program triage tiket yang kecil: baseline, metrik dengan feedback, optimasi, simpan, lalu jalankan program hasil optimasi di model lokal lewat Ollama.
Yang perlu kamu siapkan
- Python 3.10 atau lebih baru
- dspy 3.3.1 (
pip install dspy, atauuv add dspy) - 40 sampai 100 contoh berlabel untuk tugasmu. Kecil pun cukup, hitungan budget di bawah nunjukin biayanya
- API key model kuat sebagai reflection model
- Opsional: Ollama jalan di localhost:11434, kalau model yang kamu pakai nanti berjalan lokal
Semua panggilan API di kode bawah ini sudah dijalankan di dspy 3.3.1 buat memastikan signature-nya benar.
Step 1: Tulis programnya, bukan prompt-nya
Program DSPy itu class dengan input dan output yang dideklarasikan. Docstring di signature-nya jadi instruksi yang nanti ditulis ulang GEPA.
# triage.py
import dspy
class Triage(dspy.Signature):
"""Route a support ticket to the right team and set its priority."""
ticket: str = dspy.InputField()
category: str = dspy.OutputField(desc="billing, bug, or howto")
priority: str = dspy.OutputField(desc="low, normal, or urgent")
class TriageProgram(dspy.Module):
def __init__(self):
super().__init__()
self.classify = dspy.Predict(Triage)
def forward(self, ticket):
return self.classify(ticket=ticket)
Dua output field, dua-duanya string pendek. Skema output ini harus tetap sama selama proses optimasi. Kalau optimizer dibolehkan ngubah definisi tugasnya, dia akan ngubah.
Step 2: Labeli dataset kecil
train = [
dspy.Example(ticket="I was charged twice this month.", category="billing", priority="urgent"),
dspy.Example(ticket="How do I export my notes?", category="howto", priority="low"),
# ... 30 sampai 60 lagi
]
val = [
dspy.Example(ticket="App crashes when I hit export.", category="bug", priority="normal"),
# ... set terpisah, tidak dipakai untuk training
]
trainset = [ex.with_inputs("ticket") for ex in train]
valset = [ex.with_inputs("ticket") for ex in val]
with_inputs("ticket") nandain field yang dilihat model. Label-nya tetap tersimpan sebagai ground truth. Pisahkan dua set ini. Kalau baris yang sama kamu pakai untuk trainset dan valset, DSPy ngeluarin warning bukan tanpa alasan: optimizer akan nyocokin instruksi ke baris itu persis, dan kamu deploy prompt yang cuma jalan di contohmu sendiri.
Step 3: Bikin metrik yang menjelaskan kegagalan
Bagian inilah yang bedain GEPA dari optimizer yang cuma baca skor. Metriknya bisa mengembalikan teks, dan teks itu masuk apa adanya ke reflection prompt.
def triage_metric(gold, pred, trace=None, pred_name=None, pred_trace=None):
got_cat = (pred.category or "").strip().lower()
got_pri = (pred.priority or "").strip().lower()
score = (int(got_cat == gold.category) + int(got_pri == gold.priority)) / 2
feedback = (
f"Predicted category={got_cat}, expected {gold.category}. "
f"Predicted priority={got_pri}, expected {gold.priority}. "
"Write a general rule that covers tickets like this one. Do not copy "
"this example's words into the instructions."
)
return dspy.Prediction(score=score, feedback=feedback)
DSPy memanggil metrik ini dua kali per contoh: sekali untuk program secara keseluruhan, sekali lagi per predictor saat predictor itu sedang ditulis ulang. Satu fungsi ini melayani dua panggilan tersebut.
Step 4: Catat angka baseline
import os
import dspy
from triage import TriageProgram
from triage_metric import triage_metric, trainset, valset
student_lm = dspy.LM("openai/gpt-5-nano", api_key=os.environ["OPENAI_API_KEY"])
dspy.configure(lm=student_lm)
program = TriageProgram()
evaluate = dspy.Evaluate(
devset=valset,
metric=triage_metric,
num_threads=8,
display_progress=True,
display_table=False,
)
baseline = evaluate(program)
print(f"baseline score: {baseline.score:.1f}%")
dspy.Evaluate mengembalikan EvaluationResult, dan .score sudah dalam bentuk persen. Perhatikan student model di sini adalah model murah yang mau kamu pakai produksi, bukan model terbaik yang kamu punya. Ngoptimasi buat model yang nggak akan kamu deploy itu buang-buang budget.
Step 5: Jalankan GEPA
reflection_lm = dspy.LM("openai/gpt-5.4", temperature=1.0, max_tokens=32000)
optimizer = dspy.GEPA(
metric=triage_metric,
reflection_lm=reflection_lm,
auto="light",
num_threads=2,
track_stats=True,
)
optimized = optimizer.compile(program, trainset=trainset, valset=valset)
optimized_eval = evaluate(optimized)
print(f"optimized score: {optimized_eval.score:.1f}%")
Tiga hal yang sebaiknya kamu tahu sebelum menjalankannya.
- Reflection model itu wajib. Kalau
reflection_lmnggak diisi, GEPA langsung ngelempar assertion, bukan gagal di tengah run yang sudah kebayar. Reflection model membaca trace yang gagal lalu mengusulkan instruksi baru, jadi kasih dia model yang kuat. Dia dipanggil cuma beberapa kali, jadi biayanya kecil. autoitu budget. Setting light, medium, dan heavy setara 6, 12, dan 18 kandidat prompt. Di program dengan satu predictor dan valset 40 contoh,auto="light"jadi 540 metric call, sekitar 13,5 evaluasi penuh program. Medium 890, heavy 1315. Kalau valset-nya 100 contoh, angkanya naik proporsional.num_threadsmempercepat student, bukan tahap reflection. Proposal refleksi berjalan serial memang desainnya, jadi dua thread itu default yang aman sambil kamu pantau rate limit.
Step 6: Simpan hasilnya dan baca diff-nya
optimized.save("triage_optimized.json")
fresh = TriageProgram()
fresh.load("triage_optimized.json")
print(fresh.classify.signature.instructions)
Hasil optimizer itu teks instruksi yang tinggal di signature tiap predictor, jadi save() menulis file JSON yang bisa kamu diff di pull request. Beberapa perilaku yang perlu diingat:
api_keytidak pernah ikut tersimpan. Sisi yang memuat file mengatur kredensialnya sendiri.api_base,base_url, danmodel_listdibuang saat load, kecuali kamu mengisiallow_unsafe_lm_state=True.- File
.pklatau full-program save butuhallow_pickle=Truesaat load, karena deserialisasi pickle bisa menjalankan kode. File JSON state-only tidak punya risiko itu, makanya jadi rekomendasi default.
Step 7: Jalankan program hasil optimasi di model lokal
Pola yang benar-benar menghemat biaya itu asimetris. Optimasi pakai model frontier sebagai reflection model, lalu jalankan program hasilnya di model kecil yang kamu host sendiri.
dspy.configure(
lm=dspy.LM("ollama_chat/qwen3:8b", api_base="http://localhost:11434", api_key="")
)
Pakai prefix ollama_chat/, bukan ollama/. Docs LiteLLM menyarankan itu karena jalurnya lewat chat completions, hasilnya lebih baik. DSPy menormalkan string model lewat LiteLLM, jadi semua provider yang didukung LiteLLM jalan dengan cara yang sama.
Di mana ini menguntungkan, dan di mana bikin repot
Pakai angka publik sebagai pemeriksaan kewajaran, bukan janji. Dropbox menjalankan GEPA di relevance judge mereka saat pindah dari o3 ke model open-weight: NMSE terhadap penilaian manusia turun 45 persen (dari 8,83 ke 4,86), waktu adaptasi model turun dari satu sampai dua minggu iterasi manual jadi satu sampai dua hari, dan karena judge-nya lebih murah mereka bisa melabeli 10 sampai 100 kali lebih banyak data dengan biaya yang sama. Walkthrough DSPy melaporkan model student naik dari 78,1 persen ke 90,1 persen di tugas haiku mereka setelah GEPA, melewati baseline 82,4 persen dari model frontier yang belum dioptimasi. Dua angka itu milik tugas orang lain. Valset kamu sendiri satu-satunya angka yang berlaku.
Dua mode kegagalan muncul di praktik.
Yang pertama, feedback yang justru mengundang hafalan. Dropbox melihat kandidat prompt menyerap username spesifik dan frasa dokumen apa adanya. Kandidat seperti itu skornya bagus di data training tapi gagal generalisasi. Solusinya ada di string feedback: suruh reflection model menulis aturan umum, dan larang kata-kata spesifik dari contoh masuk ke instruksi. Kalau tugasmu punya label tetap atau skala penilaian, tulis eksplisit bahwa itu nggak boleh berubah. Dropbox sempat melihat kandidat diam-diam mempersempit skala 1 sampai 5 jadi 1 sampai 3, dan itu merusak semua perbandingan di hilir.
Yang kedua, optimasi di baris yang nanti dipakai menilai. Tanpa valset terpisah, kamu mengukur kecocokan ke data yang kamu labeli, bukan generalisasi ke tiket baru. Pareto sampling di GEPA lebih memaafkan dibanding greedy search karena menyimpan kandidat yang menang di minimal satu contoh validasi, tapi nggak ada optimizer yang bisa menciptakan sinyal yang tidak ada di datamu.
Kapan pakai GEPA dan kapan pakai alternatif lain
| Situasi | Lebih cocok |
|---|---|
| Di bawah 20 contoh berlabel, metrik belum ada | Prompt tulis tangan. Labeli data dulu |
| Contoh banyak, pola kegagalan sulit dijelaskan dengan kata | BootstrapFewShot atau MIPROv2 |
| Kegagalan bisa dijelaskan satu kalimat, dan tiap rollout ada biayanya | GEPA |
| Rollout murah sampai jutaan, dan bobot modelnya juga mau diubah | Fine-tuning atau RL, opsional setelah optimasi prompt |
GEPA dirancang untuk kasus di mana setiap rollout punya biaya dan ada feedback tekstual, dan itu menggambarkan sebagian besar tugas LLM di produksi. BootstrapFewShot tetap pilihan yang lebih pas kalau yang kamu butuhkan cuma demo few-shot yang lebih baik.
Langkah selanjutnya
Mulai dari 40 contoh berlabel, satu metrik penilaian, dan auto="light". Catat baseline-nya sebelum optimasi, kalau nggak kamu nggak akan pernah tahu apakah optimizer-nya layak dibayar. Commit file JSON hasilnya supaya riwayat prompt ikut masuk git bareng kodenya. Setelah itu jalankan loop yang sama setiap kali kamu mau ganti ke model yang lebih murah, dan biarkan valset-mu yang memutuskan apakah penggantian itu aman.