← Kembali ke Blog

Berhenti Tune Prompt Manual: Optimasi Pakai DSPy GEPA

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, atau uv 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_lm nggak 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.
  • auto itu 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_threads mempercepat 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_key tidak pernah ikut tersimpan. Sisi yang memuat file mengatur kredensialnya sendiri.
  • api_base, base_url, dan model_list dibuang saat load, kecuali kamu mengisi allow_unsafe_lm_state=True.
  • File .pkl atau full-program save butuh allow_pickle=True saat 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.

Referensi

Butuh Bantuan Implementasi?

Saya membantu tim mendesain dan membangun infrastruktur cloud scalable, pipeline DevOps, dan sistem production-grade.

Konsultasi Gratis