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.
Hand-copyable revision notes. Terms first, terse rules, arrows. Boxed markers in the margin: ★ trick ⚠ pitfall 💡 tip.
| PyTorch | DSPy |
|---|---|
| architecture | Python code (data flow) |
| weights | English words in prompt + few-shot |
| optimizer | compiler / teleprompter |
| Pipeline logic | Language instr. | |
|---|---|---|
| = | the WHAT | the HOW |
| state | immutable | mutable |
| who | human writes | compiler writes |
"question -> answer" (rapid
prototyping)dspy.Signature,
InputField/OutputField (enterprise)desc= = per-field boundarydspy.Module → compiler
can look inside & tune weights__init__ (declare engines) + forward (data flow)
lm = dspy.LM(model, temperature=0.0, max_tokens=1024)dspy.settings.configure(lm=lm) → set once at top; every module inherits it
lm.inspect_history(n=1) → prints the exact compiled prompt sent to API---synthesis_engine.py · test_engine.py ·
app.py (Streamlit)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.
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.
One useful way to frame DSPy is through how computer vision evolved, an analogy that makes the “why” click.
if–then rules: “if two
pointy shapes on top and a long shape at the back, then it's a cat.” Every rule is brittle and
manual.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.
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.
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.
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")
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:
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.
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.
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 string
Predict 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 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.
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.
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 prompt
Inspecting it reveals how the compiler assembles four distinct pieces, none of which you wrote by hand:
sql_math_context…), capitalizes them, and maps each to its
desc=.ChainOfThought, it inserts
a Reasoning: Let's think step by step in order to… field that never existed in your
signature.--- separator, it injects the actual runtime values
(the $3.1T market cap, the Neo4j string) aligned to the prefixes above.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.
| 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. |
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())
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 |
"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.desc= defines that variable's semantic boundary. At compile time DSPy translates
both into optimized English for the target model.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.__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.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.inspect_history show, and what four parts make up the compiled prompt?
--- separator.desc= becomes a semantic boundary. Inline for prototypes, class-based for production.
dspy.Module; implement
__init__ (engines) and forward (flow).Predict
→ ChainOfThought injects a hidden rationale.inspect_history to see the four-part compiled prompt DSPy wrote for you.
Build it · Chapter 08 · Atlas
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 ↗.