DSPy: Programming, Not Prompting
DSPy mengubah prompt string yang rapuh menjadi typed signatures, composable modules, dan optimizable programs; ORM untuk pipeline LLM.
Kalau kamu pernah mengalami prompt chain yang collapse waktu ganti dari GPT-4 ke Claude, atau menghabis sore hari Sabtu untuk tweak "You are a helpful assistant..." untuk ke-47 kalinya, kamu pasti tahu rasanya. Template prompt adalah solusi duct tape di era LLM: mereka works sampai tidak works, dan waktu mereka break, tidak ada compiler yang memberitahu kamu kenapa.
Di production, gambarannya familiar: kamu punya pipeline RAG dengan lima template prompt, masing-masing di-tune untuk model tertentu oleh engineer yang berbeda di waktu yang berbeda. Yang satu pakai """, yang lain pakai '''', yang ketiga bungkus instruksi dalam XML tags karena seseorang baca blog post. Waktu model baru turun dengan performa lebih baik di 10× biaya lebih rendah, kamu tidak bisa tinggal ganti nama model; kamu harus re-validasi setiap template terhadap model baru yang baru. Tidak ada yang mau itu, jadi kamu tetap pakai model yang mahal.
DSPy (Declarative Self-improving Python) dari Stanford NLP membalik modelnya sepenuhnya. Alih-alih menempel raw prompt strings berharap yang terbaik, kamu declare task-mu sebagai typed Signature, compose ke dalam Module, dan biarkan optimizer mengatur prompt (atau bahkan weights) terhadap metric. Shift yang sama seperti dari raw SQL strings ke ORM; ORM juga rewrite query-mu agar lebih cepat.
Kenapa Prompt Tulisan Tangan Tidak Scalable
Ada empat masalah nyata yang template prompt selalu bawa:
- Model coupling. Ganti dari
gpt-4okeclaude-3-haikusering butuh rewrite semua template. Model yang berbeda merespon phrasing yang berbeda, system prompt yang berbeda, few-shot layout yang berbeda. Yang works untuk satu model bisa menghasilkan garbage dari model lain. - Tidak ada optimasi systematic. Kamu tidak bisa jalankan grid search atas prompt phrasing. Kamu coba sesuatu, lihat hasilnya, tweak, ulangi. Itu bukan engineering; itu prayer dengan syntax. Dan waktu kamu menemukan prompt yang works, kamu tidak tahu kenapa ia works, jadi kamu tidak bisa ubah sesuatu dengan aman.
- Zero validation. Tidak ada type checking atas input atau output. Satu curly brace yang salah dan kamu dapat
{{alih-alih variable. Satu field name yang salah dan kamu dapat dinding text hallucinated. Unit test-mu pass karena mereka tidak pernah benar-benar panggil LLM; kegagalan production adalah yang kamu tangkap hari Jumat. - Tidak composable. Menggabungkan dua prompt step berarti string concatenation. Debugging berarti baca raw LLM output logs. Refactoring berarti berdoa supaya kamu tidak merusak magic incantation yang butuh tiga minggu untuk benar.
LangChain dan LlamaIndex abstract perbedaan provider, tetapi mereka tetap beroperasi atas raw prompt strings. DSPy menggantikan string dengan program.
Abstraksi Inti DSPy
DSPy punya tiga layer yang penting: Signatures, Modules, dan Optimizers.
Signature adalah typed contract antara kamu dan LLM. Alih-alih tulis f"Classify this email: {email}", kamu declare "email: str -> sentiment: str". Field names dan docstrings menjadi instruksi prompt yang sebenarnya; framework menangani formatting, routing, dan adapter selection. Dibangun di atas Pydantic di bawah hood, jadi kamu dapat pattern validasi yang sudah kamu kenal.
Module adalah executable strategy yang mengkonsumsi Signature. Predict passing input langsung. ChainOfThought menambah reasoning steps sebelum output. ReAct menjalankan reasoning-and-tool-use loop. ProgramOfThought menghasilkan kode, menjalankannya, dan return hasilnya. Semua module share interface yang sama; tukar satu dengan lainnya tanpa menyentuh task definition-mu.
Optimizer (DSPy menyebutnya “teleprompter”) compile program-mu terhadap metric. Dia rewrite few-shot examples, tune instructions, atau bahkan fine-tune weights, secara otomatis. Artifact yang di-compile bisa dipindah, di-version, dan murah untuk serve. Yang penting: kamu bayar sekali di compile time, dan setiap inference call setelahnya semurah LLM call biasa.
Before/After: Pipeline Tiga Langkah
Ambil contoh konkret: workflow support ticket: classify tiket, extract priority dan category, format response. Pertama dengan raw prompting:
Before: Manual Prompting
import openai
client = openai.OpenAI()
CLASSIFY_PROMPT = """Classify the following support ticket as positive, negative, or neutral.
Ticket: {ticket}
Respond with exactly one word: positive, negative, or neutral."""
EXTRACT_PROMPT = """Extract priority (high/medium/low) and category (billing/technical/general)
from this support ticket.
Ticket: {ticket}
Respond in this exact format:
Priority: <priority>
Category: <category>"""
FORMAT_PROMPT = """You are a helpful support agent. Generate a response to this ticket.
Ticket: {ticket}
Sentiment: {sentiment}
Priority: {priority}
Category: {category}
Response:"""
# Step 1: classify
classify_response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": CLASSIFY_PROMPT.format(ticket=ticket)}]
)
sentiment = classify_response.choices[0].message.content.strip().lower()
# Step 2: extract
extract_response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": EXTRACT_PROMPT.format(ticket=ticket)}]
)
lines = extract_response.choices[0].message.content.strip().split("\n")
priority = lines[0].split(": ")[1].strip()
category = lines[1].split(": ")[1].strip()
# Step 3: format
format_response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": FORMAT_PROMPT.format(
ticket=ticket, sentiment=sentiment, priority=priority, category=category
)}]
)
response = format_response.choices[0].message.content
Tiga API calls, tiga prompt handmade, zero type safety. Kalau priority pernah kembali sebagai "PRIORITY: high" alih-alih "high", kode downstream-mu explode diam-diam. Dan perhatikan: variabel response memegang raw text. Tidak ada schema, tidak ada validasi, tidak ada contract. Kamu parsing strings dengan .split() dan berharap yang terbaik.
After: DSPy Program
import dspy
lm = dspy.LM("openai/gpt-4o-mini")
dspy.configure(lm=lm)
class ClassifyTicket(dspy.Signature):
"""Classify support ticket sentiment."""
ticket: str = dspy.InputField()
sentiment: str = dspy.OutputField(desc="positive, negative, or neutral")
class ExtractMetadata(dspy.Signature):
"""Extract priority and category from a support ticket."""
ticket: str = dspy.InputField()
priority: str = dspy.OutputField(desc="high, medium, or low")
category: str = dspy.OutputField(desc="billing, technical, or general")
class FormatResponse(dspy.Signature):
"""Draft a support response given ticket context."""
ticket: str = dspy.InputField()
sentiment: str = dspy.InputField()
priority: str = dspy.InputField()
category: str = dspy.InputField()
response: str = dspy.OutputField()
class SupportPipeline(dspy.Module):
def __init__(self):
self.classify = dspy.Predict(ClassifyTicket)
self.extract = dspy.Predict(ExtractMetadata)
self.format = dspy.Predict(FormatResponse)
def forward(self, ticket):
sentiment = self.classify(ticket=ticket).sentiment
meta = self.extract(ticket=ticket)
return self.format(
ticket=ticket,
sentiment=sentiment,
priority=meta.priority,
category=meta.category,
)
pipeline = SupportPipeline()
result = pipeline(ticket="I was charged twice for my subscription!")
print(result.response)
Tiga langkah yang sama. Tidak ada prompt strings. Field names dan docstrings adalah instruksinya. Dan sekarang kamu bisa tukar dspy.Predict dengan dspy.ChainOfThought untuk menambah reasoning; satu kata berubah, zero refactoring. Atau tukar ke dspy.ReAct untuk menambah tool use. Interface module-nya seragam.
Then: Compile Terhadap Metric
Sekarang optimizer masuk. Alih-alih manual tune prompt, kamu define metric dan biarkan optimizer menemukan demonstrasi yang lebih baik:
def correctness(example, pred, trace=None):
"""Check that sentiment, priority, and category match."""
return (
pred.sentiment.lower().strip() == example.sentiment.lower()
and pred.priority.lower().strip() == example.priority.lower()
and pred.category.lower().strip() == example.category.lower()
)
# A few labeled examples — 50 to 200 is the sweet spot
trainset = [
dspy.Example(
ticket="I love this product!",
sentiment="positive",
priority="low",
category="general",
).with_inputs("ticket"),
dspy.Example(
ticket="My account was charged without authorization",
sentiment="negative",
priority="high",
category="billing",
).with_inputs("ticket"),
# ... more examples
]
optimizer = dspy.BootstrapFewShot(metric=correctness)
compiled_pipeline = optimizer.compile(SupportPipeline(), trainset=trainset)
# Save the compiled artifact — this is what you deploy
compiled_pipeline.save("support_pipeline_compiled.json")
BootstrapFewShot bootstrap successful traces dari training set-mu dan pilih demonstrasi terbaik. Tidak ada LLM calls tambahan selama optimasi; murah dan deterministik. Ini optimizer baseline-mu. MIPROv2 dan GEPA ada untuk saat kamu butuh instruction-level tuning, tapi mulai dari sini.
ReAct: Agents dengan Tools
Agents di DSPy bukan kelas khusus; mereka module biasa dengan interface yang sama. ReAct menjalankan reasoning loop dengan tool use tanpa perlu custom agent framework atau LangGraph state machines:
def search(query: str) -> list[str]:
"""Search knowledge base for relevant passages."""
return kb.query(query, k=3)
def calculator(expr: str) -> float:
"""Evaluate a mathematical expression."""
return eval(expr) # In production, use a safe evaluator
agent = dspy.ReAct("question -> answer", tools=[search, calculator])
result = agent(question="What's the GDP per capita of France?")
print(result.answer)
Tools-nya fungsi Python biasa; docstring menjadi deskripsi tool secara otomatis. Kamu dapat reasoning-loop agent yang sama seperti framework lain, tapi arsitekturnya konsisten dengan sisa pipeline DSPy-mu.
Trade-offs: Kapan Pakai DSPy (Dan Kapan Tidak)
DSPy berlebihan saat:
- Kamu punya satu prompt yang sudah works. Kalau
lm(prompt=...)menyelesaikan pekerjaannya, abstraksi menambah kompleksitas yang tidak perlu. Tidak semua butuh framework. - Task-mu tidak berubah. Kalau kamu nulis one-off script yang panggil GPT-4o sekali, kamu tidak butuh optimizer.
- Kamu sedang prototyping. Value DSPy ada di production programs, bukan quick experiments. Gunakan saat kamu sudah validasi approach dan butuh scale.
DSPy esensial saat:
- Pipeline-mu punya 3+ steps dan kamu butuh mereka survive model swaps. Typed interface berarti ganti
gpt-4o-minikeclaude-3-haikucukup edit satu baris. - Kamu maintain prompt templates antar anggota tim dan template-nya terus drift. Signatures enforce contract; docstring adalah spesifikasi.
- Kamu ingin improve performa secara systematic terhadap metric alih-alih eyeball hasil. Optimizers adalah jawaban systematic untuk “bagaimana saya improve ini?”
- Kamu deploy agents dengan tools dan ingin typed, composable modules alih-alih ad-hoc loops. ReAct adalah module, bukan framework-within-a-framework.
Matematika biaya: BootstrapFewShot jalankan dengan zero biaya LLM tambahan; ia bootstrap demonstrasi dari data yang sudah ada. MIPROv2 dan GEPA bisa habiskan ratusan dolar per compile run. Ekonominya works saat kamu compile sekali (mahal) dan serve artifact banyak kali (murah). Mulai dari BootstrapFewShot. Graduate ke instruction optimizers hanya saat kamu sudah ceiling performa dan punya evaluation set yang cukup.
Common Pitfalls
Optimizer cost shock. GEPA dan MIPROv2 bisa habiskan API credits dengan cepat. Selalu jalankan BootstrapFewShot dulu. Compile terhadap trainset kecil (50–200 examples) sebelum scale up. Biaya token untuk optimasi itu nyata dan tidak obvious.
Metric shape mismatch. Beberapa optimizers expect Prediction(score, feedback) dari metric function-mu, yang lain terima bare floats. Ini kesalahan config yang paling umum. Cek docs untuk optimizer spesifik-mu sebelum wiring.
Debugging compiled programs. Setelah .compile(), prompts di-generate otomatis dan opaque. Gunakan dspy.inspect_history(n=1) untuk lihat apa yang benar-benar terkirim, atau setup MLflow tracing dengan mlflow.dspy.autolog() untuk full visibility ke setiap module call.
Signature field names itu permanen. Optimizers rewrite docstrings tetapi tidak pernah rename fields. Pilih field names dengan hati-hati; mereka jadi public API program-mu. Kalau kamu rename field, kamu break compiled artifact.
Jangan fine-tune dulu. Prompt-only optimization biasanya mencapai 80–90% dari target. Fine-tuning (BootstrapFinetune) menambah gain marginal dengan biaya jauh lebih tinggi. Treat sebagai lever terakhir, bukan pertama.
Production Loop
Artifact yang di-compile adalah file JSON yang berisi optimized prompts, demonstrations, dan configuration. Load saat serve time, tanpa overhead optimizer atau training data:
# At serve time — fast and cheap
pipeline = SupportPipeline()
pipeline.load("support_pipeline_compiled.json")
result = pipeline(ticket="Can I get a refund?")
Polanya sama dengan serialize model weights; tapi untuk prompts. Compile sekali, version artifact-nya, deploy murah. Shopify, Dropbox, AWS, Databricks sudah pakai workflow ini di production, bukan sebagai eksperimen.
Apa yang Dibaca Selanjutnya
Kalau kamu sudah familiar dengan Pydantic models untuk validasi data, kamu akan langsung recognize pattern Signature DSPy; ia Pydantic di bawah hood. Cek post sebelumnya tentang Pydantic v2 untuk melihat bagaimana structured output dan type enforcement terhubung di kedua framework.
DSPy memindahkan pertanyaannya: bukan “string apa yang harus saya tulis”, tapi “program seperti apa yang saya butuhkan”. Kalau kamu bekerja dengan LLM pipelines di production, itu perbedaan yang cukup fundamental.
