DSPy — Declarative Optimization
Programming, not prompting. Treating prompts as tunable weights à la PyTorch: signatures as data contracts, modules the compiler can open, Predict vs. ChainOfThought, configuring the LM as "hardware," inspecting the compiled prompt, and the AlphaFund synthesizer that fuses SQL + FAISS + Neo4j into one executive summary.
Assumes you know
After this you should be able to
- Explain the core idea in DSPy — Declarative Optimization
- Apply DSPy — Declarative Optimization in a representative exercise
Declarative optimization for LLM pipelines, taught from the ground up, plus the AlphaFund executive synthesizer built live.
Predict vs ChainOfThought, global config, telemetry, and the
zero-shot AlphaFund synthesizer. Multi-hop reasoning / ReAct and autonomous agents are a
separate, later topic. Foundational overlaps that add precision (Trinity Router,
docstrings-as-weights, LangChain brittleness) are folded in and tagged [+]; the
ReAct-specific agent material is left for its own note.
Notebook Summary#
Hand-copyable revision notes. Terms first, terse rules, arrows. Boxed markers in the margin: ★ trick ⚠ pitfall 💡 tip.
The Problem — why prompts break#
- Prompt eng. = “vibe-based,” not software eng. → brittle
- Model drift → deprecate / swap model → mega-prompt breaks
- Context trap → doc #101 differs → attention shifts → crash
- Debug loop = capitalize words, “DO NOT…” → unscientific
The PyTorch Analogy#
- Era 1 rules (if–then) → brittle
- Era 2 feature eng. (SIFT/HOG) → humans guess features
- Era 3 declarative (PyTorch): define architecture + metric → optimizer sets weights
- Doodle: 3 stacked boxes labeled RULES / FEATURES / DECLARATIVE, arrow climbing up-right
DSPy Revelation — the mapping#
| PyTorch | DSPy |
|---|---|
| architecture | Python code (data flow) |
| weights | English words in prompt + few-shot |
| optimizer | compiler / teleprompter |
- ⇒ We stop writing prompts. Define architecture → compiler writes prompt.
Logic vs. Language (the wall)#
| Pipeline logic | Language instr. | |
|---|---|---|
| = | the WHAT | the HOW |
| state | immutable | mutable |
| who | human writes | compiler writes |
- Core belief: humans great at LOGIC, terrible at optimizing LANGUAGE at scale
Signatures — the data contract#
- Signature = declares semantic inputs → outputs of a task (WHAT, not HOW)
- Inline:
"question -> answer"(rapid prototyping) - Class-based: inherit
dspy.Signature, InputField/OutputField (enterprise) - class docstring = system prompt;
desc== per-field boundary - Doodle: a labeled box "SIGNATURE" with arrows IN (question, context) and arrow OUT (summary)
Modules — the engine#
- Module = Python class, inherit
dspy.Module→ compiler can look inside & tune weights - 2 mandatory methods:
__init__(declare engines) +forward(data flow) - ★Predict = zero-shot, answers immediately (no thinking)
- ★ChainOfThought = injects hidden rationale field → reason then answer → fewer hallucinations
- ⚠Upgrade reasoning by changing the module, NOT by writing “think step by step”
Config & Global State#
lm = dspy.LM(model, temperature=0.0, max_tokens=1024)dspy.settings.configure(lm=lm)→ set once at top; every module inherits it- Analogy: LM = CPU, signature = source code, compiler = compiler-for-that-CPU
- 💡Swap model = change one line; no rewriting 50 classes
Telemetry#
lm.inspect_history(n=1)→ prints the exact compiled prompt sent to API- Shows: docstring ripped to top → “Follow the following format”
→ “Let's think step by step” → runtime data injected after
---
AlphaFund Synthesizer (hands-on)#
- 3 inputs: SQL math / FAISS docs / Neo4j graph + user_query → 1 exec summary
- Files:
synthesis_engine.py·test_engine.py·app.py(Streamlit) - 💡test_engine = smoke test: check output contains valuation / name / competitor keywords
- ⚠Zero-shot output = good, not perfect (too long, misses nuance) → needs Optimizer (a natural next step)
Detailed Study Reference#
The complete lesson, taught from first principles. Every term introduced in plain words before jargon.
Imagine you spent three weeks hand-crafting a giant prompt. It works beautifully. Then leadership announces the model you built it on is being retired, and you must move to a cheaper open-source model by Friday. You swap the model name… and the whole pipeline collapses. The instructions the old model obeyed perfectly are now ignored, formatted wrong, or hallucinated. You didn't change your logic. You changed your hardware, and your carefully tuned English fell apart.
That fragility is the whole reason DSPy exists. The opening claim here is deliberately provocative: manual prompt engineering is not software engineering. It is unscientific, “vibe-based” work, capitalizing words and adding “DO NOT do this” until the output looks right, hardcoded to one exact model's quirks. DSPy's answer is to stop writing prompts by hand entirely and let a compiler write them for you.
1. Why DSPy exists The unscalability of hand-written prompts #
DSPy is a framework that treats an LLM prompt like a set of tunable weights instead of a fixed script you write by hand. You describe what the task is; DSPy figures out how to phrase it for the specific model you're using.
Three specific failure modes motivate this. First, model drift: when a model is deprecated or you downgrade to a cheaper one, the mega-prompt that mapped perfectly onto the old model's tokenization and attention behavior no longer holds, and the pipeline breaks. Second, the context-sensitivity trap: if you feed a system 100 documents and the 101st arrives in a slightly different shape, the model's attention shifts and your prompt instructions are no longer followed. Third, the vibe-based debugging loop, where the only tool you have is to play with verbs, CAPITALIZE for emphasis, and beg the model in prose.
The historical analogy: three eras of machine learning#
One useful way to frame DSPy is through how computer vision evolved, an analogy that makes the “why” click.
- Era 1 — rule-based systems. Hand-coded
if–thenrules: “if two pointy shapes on top and a long shape at the back, then it's a cat.” Every rule is brittle and manual. - Era 2 — feature engineering. Engineers wrote clever algorithms (SIFT, HOG) to extract features from an image, then fed those into a classifier. Better, but a human still had to guess which features mattered. This is the equivalent of building elaborate LangChain prompt templates.
- Era 3 — the declarative paradigm (PyTorch, ~2010–now). You stop writing rules and stop extracting features by hand. You define the architecture and a loss metric (“is this a cat, yes/no?”), and an optimizer automatically sets the billions of internal weights.
2. The core split: pipeline logic vs. language instructions The immutable WHAT vs. the mutable HOW #
DSPy draws a hard wall between the steps your data flows through (which never change) and the English sent to the model (which changes constantly). You own the first; the framework owns the second.
Pipeline logic is the WHAT, a deterministic, step-by-step flow of data that represents your business process. For the executive dashboard it is: (1) take the user's query, (2) pass it to the vector index to find the entry node, (3) run a 2-hop graph query, (4) hand the raw graph data plus the user's question to an LLM for a summary. These steps stay identical whether you run Gemini 2.5 Flash, an outdated GPT-3, or a local Llama. This logic is immutable.
Language instructions are the HOW, the actual English string packaged with the data and sent to the API, e.g. “given the following topographical context, write a highly professional three-sentence summary for a financial executive.” This string is highly mutable: it must change with the model's behavior, the variance in incoming data, and pipeline failure rates.
3. Signatures — the data contract Declaring inputs and outputs without writing the prompt #
A signature is a short declaration of what goes in and what comes out of one cognitive task, like a function's type signature but for an LLM step. It tells the framework what must be done and leaves how to the compiler.
Analogy: think of a signature as a contract at a service desk. You state “I hand you a question and some context; you hand me back an executive summary.” You never explain how the clerk should word things, that's their job. If prompts are no longer written by you, the signature is how the model knows what to do.
Inline signatures — for rapid prototyping#
The simplest form is a string of inputs -> outputs:
import dspy
# declare intent: give a question, get an answer
qa = dspy.Predict("question -> answer")When this string is passed into an execution module, DSPy reads the two words, inspects the configured model, and auto-generates a zero-shot prompt formatted for that model's optimal input structure. Great for prototyping, not enough rigor for production.
Class-based signatures — the enterprise standard#
Real systems have multiple, highly specific data streams (SQL calculations, FAISS document chunks, Neo4j
graph paths). For those you write a class that inherits dspy.Signature, giving you structural
control:
class AlphaFundExecutiveSynthesizer(dspy.Signature):
"""Synthesizes raw data from three distinct database
engines into a cohesive, highly professional executive
summary. Reconciles contradictions by prioritizing SQL
for math, graph data for relationships, FAISS for context."""
sql_math_context = dspy.InputField(desc="exact financial metrics from the text-to-SQL engine")
faiss_doc_context = dspy.InputField(desc="qualitative intelligence from the document store")
neo4j_context = dspy.InputField(desc="relational context from the knowledge graph")
user_query = dspy.InputField(desc="the strategy question asked by the executive")
executive_summary = dspy.OutputField(desc="2-3 paragraph formal strategic summary, hedge-fund tone")Where the “hidden prompt” lives: docstrings & descriptions#
If you never write a prompt, how does the model know what input_one means, or the desired tone?
Two Python-native mechanisms carry that meaning:
- The class docstring = the overarching nature of the subtask. It replaces the classic “You are a helpful assistant who does XYZ” system prompt.
- The
desc=on each field = a semantic boundary for that variable, exactly like Pydantic field descriptions. At compile time DSPy reads these and translates them into optimized English for the target model.
dspy.InputField, and on the next compilation DSPy rewrites
the entire underlying prompt architecture for you. The class is pure abstraction, business logic plus data
constraints, with zero prompt text.
4. Modules — the execution engine A Python class the compiler can open up and tune #
A DSPy module is a Python class (inheriting dspy.Module) that wraps one or
more LLM calls, so the compiler can look inside, find those calls, and update their
“weights” automatically. The weights are the English instructions and few-shot examples.
Analogy (from PyTorch): every neural network is a class inheriting torch.nn.Module, and
that inheritance is what lets PyTorch peer inside, find the math layers, and update weights during training.
DSPy replicates this exactly: inherit dspy.Module and the DSPy compiler gains permission to
inspect your code and tune the prompts.
Every DSPy pipeline is written with strict OOP and has two mandatory methods:
| Method | Purpose |
|---|---|
__init__ | Declare the internal modules / engines you'll use (e.g. a ChainOfThought over your
signature). Call super().__init__() to run the parent's setup. |
forward | Define the flow of data through those modules, the order of execution, and return the prediction. |
class DashboardPipeline(dspy.Module):
def __init__(self):
super().__init__() # run parent Module setup
self.synthesize = dspy.ChainOfThought(AlphaFundExecutiveSynthesizer)
def forward(self, sql_math_context, faiss_doc_context, neo4j_context, user_query):
return self.synthesize(
sql_math_context=sql_math_context,
faiss_doc_context=faiss_doc_context,
neo4j_context=neo4j_context,
user_query=user_query,
).forward() directly
Like PyTorch, calling the instance (pipeline(...)) triggers the module's __call__,
which runs forward under the hood. Don't invoke forward yourself.
5. Predict vs. ChainOfThought Two execution engines, one word apart #
dspy.Predict — the basic engine#
dspy.Predict is a translation layer that takes your signature, looks at the
configured model, and builds an optimized zero-shot prompt under the hood. No
.format(), no .invoke(), you treat the LLM call like a native Python function.
response = qa(question="What is the capital of France?")
# no LangChain template, no manual prompt stringPredict forces the model to answer immediately. For complex, multi-step queries this is
a real limitation, it can't reason its way through, so answer quality suffers and hallucination risk rises.
dspy.ChainOfThought — the reasoning upgrade#
dspy.ChainOfThought is a drop-in upgrade that forces the model to reason
step-by-step before committing to the final answer. It structurally intercepts execution and injects
a hidden “cognitive workspace” field, called the rationale, into the
API payload before the model produces the real output field.
dspy.Predict for dspy.ChainOfThought. One word, and the framework mathematically
forces reasoning-before-answer, which drastically reduces hallucinations.
6. Model configuration & global state Wiring your pipeline to the “hardware” #
You declare the target model once with dspy.LM and register it globally with
dspy.settings.configure, so every module in the pipeline inherits it automatically.
Analogy: a C++ programmer must tell the compiler which CPU architecture the code runs on; the compiler then emits machine instructions optimized for that exact processor. In DSPy, the signature is your human-readable source code and the language model is the CPU. Target Llama 3 and DSPy compiles the signature one way; target Gemini 2.5 Flash and it compiles differently, optimizing for that model's attention heads and tokenization.
# 1) define the LM (the "CPU")
lm = dspy.LM("gemini/gemini-2.5-flash", temperature=0.0, max_tokens=1024)
# 2) set it once, at the top of the script — global state
dspy.settings.configure(lm=lm)Note DSPy uses its own lightweight dspy.LM abstraction, designed for high-throughput generation
and caching, rather than LangChain's chat wrappers. LangChain is an orchestration framework; DSPy is
a compiler framework, and it handles API calls differently.
Predict/ChainOfThought inherits the “hardware connection.” When
leadership demands a cheaper staging model, you change one line and the whole ecosystem recompiles,
no rewriting 50 classes.
7. Telemetry — inspecting the compiled prompt Seeing the mega-prompt DSPy secretly wrote #
lm.inspect_history(n=1) prints the exact prompt DSPy constructed and sent to
the API, run it right after a forward pass to see what the compiler actually did.
pipeline(sql_math_context=..., faiss_doc_context=..., neo4j_context=..., user_query=...)
lm.inspect_history(n=1) # dumps the full compiled zero-shot promptInspecting it reveals how the compiler assembles four distinct pieces, none of which you wrote by hand:
- Task definition (header) — DSPy rips the class docstring straight out of your signature and places it at the top as the master system directive.
- Formatting strictness — a “Follow the following format” block. It takes your
Python variable names (
sql_math_context…), capitalizes them, and maps each to itsdesc=. - Cognitive injection (reasoning) — because you used
ChainOfThought, it inserts aReasoning: Let's think step by step in order to…field that never existed in your signature. - Execution data — after a
---separator, it injects the actual runtime values (the $3.1T market cap, the Neo4j string) aligned to the prefixes above.
8. Hands-on: the AlphaFund executive synthesizer Fusing SQL + FAISS + Neo4j into one summary, no prompt hacking #
The lab reuses the “Trinity Router” from a prior class, three tools the pipeline can draw on: a SQL engine for hard financial math, a FAISS engine [+] (a vector-search index over unstructured docs, for news and quotes), and a graph engine (Neo4j) for multi-hop relationship paths. The task: feed all three raw data strings plus the user's question into one LLM and get a polished executive summary.
The three files#
| File | Role |
|---|---|
synthesis_engine.py | Loads env, defines the signature class (big docstring + input/output fields) and the
DashboardPipeline module; configures Gemini 2.5 Flash; exposes a master synthesis node.
|
test_engine.py | Mocks the three raw data strings, runs the pipeline locally, and validates the output against a keyword checklist (a smoke test). |
app.py | Streamlit UI: text box for the executive query, an “execute” button, then displays the summary and the reasoning trace in an expander. |
The smoke test#
Note that test_engine.py is a smoke test (a functional test), not a
general-purpose validator. It mocks fixed data (Microsoft's ~$3.1T market cap, Satya Nadella eyeing an Alpha
AI acquisition, Alpha AI competing with NeuralNet Labs) and checks that the generated summary contains the
expected keywords, valuation figures, the CEO's name, the competitor. Each check stores a boolean; if all
pass, the pipeline is verified.
checks = {
"valuation": "3.1" in summary,
"acquisition": "Nadella" in summary or "Satya" in summary,
"competitor": "NeuralNet" in summary,
}
all_passed = all(checks.values())9. DSPy vs. LangChain Where control actually lives #
A recurring student worry: DSPy “feels like less control” than a fine-tuned LangChain pipeline. The reframe: DSPy doesn't remove control, it shifts where and how you exert it. You control the architecture and the metric; the optimizer controls the wording.
| Dimension | LangChain | DSPy |
|---|---|---|
| What you write | Mega-prompt templates, .format(), .invoke(), tool-calling glue | Signature + module; no prompt text |
| Optimization | Manual trial-and-error on the prompt | Algorithmic, optimizer bootstraps few-shot examples |
| Model swap | Fragile, re-tune the prompt for the new model's latent space | Change one line; framework recompiles |
| Resilience | Brittle, breaks on model/pipeline change [+] (regex parsing crashes) | Resilient, adapts via the compiler |
| Nature | Orchestration framework | Compiler framework |
10. Interview & practice Q&A The follow-ups this material invites #
In one sentence, what problem does DSPy solve?
Explain the PyTorch analogy DSPy is built on.
What is a signature, and how do inline and class-based signatures differ?
"question -> answer" for prototyping. Class-based signatures inherit
dspy.Signature and use a docstring plus InputField/OutputField
with descriptions for enterprise-grade structural control over multiple data streams.If you don't write prompts, how does the model know the task and format?
desc= defines that variable's semantic boundary. At compile time DSPy translates
both into optimized English for the target model.What's the difference between dspy.Predict and dspy.ChainOfThought?
Predict builds a zero-shot prompt and forces an immediate answer, weak on
complex reasoning. ChainOfThought injects a hidden rationale field so the model
reasons step-by-step before the final output, reducing hallucinations. You upgrade by changing the
module, not by adding “think step by step” to a prompt.What are the two mandatory methods of a DSPy module and what does each do?
__init__ declares the internal engines you'll use (and calls
super().__init__() so the parent Module setup runs, letting the compiler
inspect the class). forward defines the flow of data through those engines and returns the
prediction. You never call forward directly, calling the instance triggers it.Why configure the LM globally, and how does the CPU analogy apply?
dspy.settings.configure(lm=...) and every module inherits it. The analogy: the
signature is human-readable source code and the LM is the CPU; DSPy compiles the signature into a
model-specific payload, just as a C++ compiler targets a specific processor. Swapping models is a
one-line change.What does inspect_history show, and what four parts make up the compiled prompt?
--- separator.The zero-shot AlphaFund summary is decent but imperfect. How do you actually improve it?
Does DSPy give up the control that a fine-tuned LangChain pipeline offers?
Key takeaways#
- Prompts are weights, not scripts. DSPy's whole premise is treating English instructions and few-shot examples as tunable parameters a compiler optimizes.
- Separate logic (WHAT) from language (HOW). You write immutable Python data-flow; DSPy owns the mutable, model-specific prompt.
- Signature = data contract. Docstring becomes the system directive; each field's
desc=becomes a semantic boundary. Inline for prototypes, class-based for production. - Module = a class the compiler can open. Inherit
dspy.Module; implement__init__(engines) andforward(flow). - Upgrade reasoning by swapping the module, not by editing prompt text:
Predict→ChainOfThoughtinjects a hidden rationale. - The model is the CPU. Configure it globally once; swapping models is a one-line, recompile-safe change, DSPy's biggest resilience advantage over LangChain.
- Use
inspect_historyto see the four-part compiled prompt DSPy wrote for you. - Zero-shot is the floor, not the ceiling. Real quality comes from an Optimizer, a natural next step.
Build it · Chapter 08 · Atlas
Build it — a 60-line DSPy#
Program the model, don't hand-write the prompt. A signature is a typed inputs -> outputs contract; a module renders a prompt and parses the reply; compile bootstraps few-shot demos from examples that pass a metric. The prompt becomes a build artifact you can print and inspect.
stub_lm lets this run offline by echoing the topology it sees. Point __call__ at a real client and the signature, parsing, and compile step are untouched.atlas/dspy_lite.py — signatures, modules, compile:
"""Chapter 08 — program the model, don't hand-write prompts.
A 60-line take on DSPy's core idea: you declare a *signature* (a typed
"inputs -> outputs" contract), wrap it in a *module*, and let a *compiler*
fill in the few-shot examples. The prompt becomes a build artifact you can
inspect, not a string you babysit.
"""
def parse_signature(sig):
ins, outs = sig.split("->")
return ([f.strip() for f in ins.split(",")],
[f.strip() for f in outs.split(",")])
class Predict:
"""The base module: render a prompt from the signature, call the LM, parse."""
def __init__(self, signature, instructions=""):
self.inputs, self.outputs = parse_signature(signature)
self.instructions = instructions
self.demos = [] # filled in by compile()
def render(self, **kwargs):
lines = [self.instructions] if self.instructions else []
for demo in self.demos: # bootstrapped few-shot examples
for f in self.inputs + self.outputs:
lines.append(f"{f}: {demo[f]}")
lines.append("---")
for f in self.inputs:
lines.append(f"{f}: {kwargs[f]}")
lines.append("Produce: " + ", ".join(self.outputs))
return "\n".join(lines)
def __call__(self, lm, **kwargs):
raw = lm(self.render(**kwargs))
fields = {}
for line in raw.splitlines():
if ":" in line:
k, v = line.split(":", 1)
if k.strip() in self.outputs:
fields[k.strip()] = v.strip()
return fields
class ChainOfThought(Predict):
"""Same contract, but asks the model to reason before it answers."""
def __init__(self, signature, instructions=""):
super().__init__(signature, instructions)
self.outputs = ["reasoning"] + self.outputs
def compile(module, trainset, metric):
"""Bootstrap few-shot demos: keep training examples the module gets right."""
demos = []
for ex in trainset:
pred = module(ex["lm"], **{f: ex[f] for f in module.inputs})
if metric(ex, pred):
demos.append(ex)
module.demos = demos
return module
# ---- a stub LM so this runs offline; swap for any real client -------------
import re
def stub_lm(prompt):
"""Pretends to be a model: names every entity in the topology, in order.
A real model would phrase the chain; this echoes it so the demo is
deterministic. Point a real client at the same prompt and parsing is
identical — that's the whole promise of a signature.
"""
entities = []
for name in re.findall(r"\(([^)]+)\)", prompt): # (Entity) tokens in triples
if name not in entities:
entities.append(name)
answer = "The graph connects " + ", ".join(entities) + "."
return f"reasoning: walked the topology\nanswer: {answer}"
if __name__ == "__main__":
synth = ChainOfThought("context, question -> answer",
instructions="Answer only from the graph context.")
out = synth(stub_lm,
context="(Microsoft)-[acquired]->(Alpha)",
question="What did Microsoft acquire?")
print("compiled prompt:\n" + synth.render(context="...", question="...") + "\n")
print("prediction:", out)Run it — you'll see:
compiled prompt:
Answer only from the graph context.
context: ...
question: ...
Produce: reasoning, answer
prediction: {'reasoning': 'walked the topology', 'answer': 'The graph connects Microsoft, Alpha.'}This is one checkpoint of Atlas — a single project built across notes 04–14. Full code, setup, and the map of all checkpoints: Atlas — Build the notes ↗.