Study Notes
Notecore

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.

30 min read·verified Content review 2026·reviewed 2026-08-30

After this you should be able to

  • Explain the core idea in DSPy — Declarative Optimization
  • Apply DSPy — Declarative Optimization in a representative exercise
Signatures · ModulesChainOfThoughtCompiler, not prompt
Applied GenAI · Session Notes

Declarative optimization for LLM pipelines, taught from the ground up, plus the AlphaFund executive synthesizer built live.

Framework: DSPy (Stanford) Model: Gemini 2.5 Flash Core idea: prompts as trainable weights Scope: lesson-as-taught
[!] A note on scope. This guide covers the DSPy foundations: signatures, modules, 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.
PART 1

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#

PyTorchDSPy
architecturePython code (data flow)
weightsEnglish words in prompt + few-shot
optimizercompiler / teleprompter
  • ⇒ We stop writing prompts. Define architecture → compiler writes prompt.

Logic vs. Language (the wall)#

Pipeline logicLanguage instr.
=the WHATthe HOW
stateimmutablemutable
whohuman writescompiler 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)

PART 2

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–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.
  • 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.
ERA 1 · Rules hand-coded if–then brittle ERA 2 · Features SIFT / HOG, human picks features ERA 3 · Declarative define architecture optimizer sets weights
The same climb happened to prompting: DSPy is PyTorch for LLM instructions.
💡 Tip · interview-relevant DSPy was created by researchers at Stanford. If asked “what is DSPy in one sentence,” the strongest answer names the analogy: it brings PyTorch's declarative, optimizer-driven paradigm to LLM pipelines, treating prompts and few-shot examples as learnable parameters rather than fixed strings.

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.

PIPELINE LOGIC — the WHAT immutable · you write it 1  take user query 2  vector index → entry node 3  run 2-hop graph query 4  raw data + query → LLM the wall LANGUAGE — the HOW mutable · compiler writes it
“Given the following context, write a highly professional three-sentence summary for a financial executive…”



— rephrased per model, per data drift, per failure rate.
You define the left side once. DSPy regenerates the right side for whatever model you point at.
★ Why it matters The single sentence to remember: “Humans are great at defining pipeline logic and terrible at optimizing language instructions at scale.” DSPy forces you to write clean declarative Python for the logic, then abstracts away and mathematically optimizes the language entirely under the hood.

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.
sql_math_context faiss_doc_context neo4j_context user_query SIGNATURE docstring = system prompt desc= = field boundaries executive_summary
Inputs and outputs are declared as fields; the docstring and per-field descriptions become the compiled prompt.
★ Trick · adding a data source is trivial Adding a fourth engine (say a live web-search tool) does not mean rewriting a mega-prompt or hunting formatting bugs. You add one more 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:

MethodPurpose
__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.
forwardDefine 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,
        )
💡 Tip · you never call .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 string
⚠ Pitfall · Predict can't “think” 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 — 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.

★ Trick · change the module, not the prompt The old way to get reasoning was appending “think step by step” to your prompt. In DSPy you don't change the instructions, you change the module architecture: swap dspy.Predict for dspy.ChainOfThought. One word, and the framework mathematically forces reasoning-before-answer, which drastically reduces hallucinations.
Predict question answer immediate ChainOf­Thought question rationale(hidden reasoning) answer
Predict goes straight to the answer; ChainOfThought inserts a reasoning step first.

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.

★ Trick · global state kills boilerplate In plain Python you'd pass a DB connection into every function as an argument, painful with deeply nested modules. DSPy instead uses global state: configure the LM once at the root, and every 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 prompt

Inspecting it reveals how the compiler assembles four distinct pieces, none of which you wrote by hand:

  1. Task definition (header) — DSPy rips the class docstring straight out of your signature and places it at the top as the master system directive.
  2. Formatting strictness — a “Follow the following format” block. It takes your Python variable names (sql_math_context…), capitalizes them, and maps each to its desc=.
  3. Cognitive injection (reasoning) — because you used ChainOfThought, it inserts a Reasoning: Let's think step by step in order to… field that never existed in your signature.
  4. Execution data — after a --- separator, it injects the actual runtime values (the $3.1T market cap, the Neo4j string) aligned to the prefixes above.
1 · Task header docstring, ripped to the top 2 · “Follow the following format” field names capitalized + descriptions 3 · Reasoning field “Let's think step by step…” (from CoT) --- separator --- 4 · Execution data runtime values injected here
The full payload DSPy sends, assembled automatically from your signature + module.

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.

💡 Tip · what FAISS actually is [+] FAISS (Facebook AI Similarity Search) is a library for fast nearest-neighbor search over vector embeddings, the “vector index” step. In this pipeline it retrieves the qualitative document context (CEO quotes, strategic weaknesses) that the SQL and graph engines can't provide.

The three files#

FileRole
synthesis_engine.pyLoads 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.pyMocks the three raw data strings, runs the pipeline locally, and validates the output against a keyword checklist (a smoke test).
app.pyStreamlit UI: text box for the executive query, an “execute” button, then displays the summary and the reasoning trace in an expander.
user query (Streamlit) SQL · math FAISS · docs Neo4j · graph DSPy ChainOfThought forward() exec summary
Four inputs (three engines + the query) flow through the DSPy module's forward pass to a single summary.

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())
⚠ Pitfall · the smoke test is not generic You might ask why the checks can't validate any arbitrary question. They can't, by design: the checks are tied to the mocked data, verifying the engine correctly weaves SQL + FAISS + Neo4j context into an answer. It proves integration works; it is not a scalable answer-correctness test.
★ Why it matters · the honest limitation The finished zero-shot pipeline is good but not perfect, the summary runs slightly too long and may miss a graph-data nuance. Crucially, you do not fix this by editing the docstring to say “make it shorter.” It's running uncompiled with no examples of a perfect hedge-fund summary. The real fix is an Optimizer (teleprompter), which bootstraps its own few-shot examples and mathematically tunes the prompt, a natural next step.

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.

DimensionLangChainDSPy
What you writeMega-prompt templates, .format(), .invoke(), tool-calling glueSignature + module; no prompt text
OptimizationManual trial-and-error on the promptAlgorithmic, optimizer bootstraps few-shot examples
Model swapFragile, re-tune the prompt for the new model's latent spaceChange one line; framework recompiles
ResilienceBrittle, breaks on model/pipeline change [+] (regex parsing crashes)Resilient, adapts via the compiler
NatureOrchestration frameworkCompiler framework
💡 Tip · the recommended stance Real-world advice: use a hybrid approach. LangChain has a large feature surface for orchestration; DSPy shines at defining a precise, low-hallucination architecture. Pick per business case. DSPy also handles provider differences (OpenAI vs. Anthropic vs. Gemini response shapes) internally and supports streaming.
⚠ Pitfall · why swapping models breaks LangChain It's not that the model literally “breaks”, it's that each model has different pre-training data and latent space, so the way you phrased the prompt and the response you get back differ. In production you can't just bump the model name; you'd have to re-test everything and re-tune the mega-prompt. DSPy's compilation is what makes the swap safe.

10. Interview & practice Q&A The follow-ups this material invites #

In one sentence, what problem does DSPy solve?
Hand-written prompts are brittle, model-specific, and unscientific, they break on model drift, context changes, and can't be optimized at scale. DSPy treats prompts and few-shot examples as tunable weights and lets a compiler generate and optimize them, so you write declarative logic instead of prompt strings.
Explain the PyTorch analogy DSPy is built on.
In PyTorch you define an architecture and a loss metric, and an optimizer sets the weights automatically. DSPy mirrors this: your Python data-flow is the architecture, the English in the prompt and few-shot examples are the weights, and the DSPy compiler (teleprompter) is the optimizer that writes and tunes those prompts to maximize a metric you define.
What is a signature, and how do inline and class-based signatures differ?
A signature declares a task's semantic inputs and outputs, the what, leaving the how to the compiler. Inline signatures are a quick string like "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?
Two Python-native mechanisms carry the meaning. The class docstring becomes the overarching system directive (replacing “you are a helpful assistant…”), and each field's 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?
Passing the model into every nested module would create massive boilerplate, so DSPy sets it once via 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?
It prints the exact prompt DSPy sent to the API after a forward pass. The compiled prompt has four parts: (1) the task header ripped from the docstring, (2) a “Follow the following format” block from capitalized field names + descriptions, (3) an injected reasoning field from ChainOfThought, and (4) the runtime execution data after a --- separator.
The zero-shot AlphaFund summary is decent but imperfect. How do you actually improve it?
Not by editing the docstring to say “make it shorter.” The pipeline is running uncompiled with no examples of a good hedge-fund summary. You pass it to an Optimizer (teleprompter), which bootstraps its own few-shot examples and mathematically tunes the prompt against a metric, a natural next step.
Does DSPy give up the control that a fine-tuned LangChain pipeline offers?
No, it shifts where control lives. You control the architecture and the success metric; the optimizer controls the wording. LangChain gives manual, prompt-level control but is fragile under model or pipeline changes; DSPy is resilient because the compiler regenerates optimized prompts. The pragmatic answer is often a hybrid of both.

Key takeaways#

  1. Prompts are weights, not scripts. DSPy's whole premise is treating English instructions and few-shot examples as tunable parameters a compiler optimizes.
  2. Separate logic (WHAT) from language (HOW). You write immutable Python data-flow; DSPy owns the mutable, model-specific prompt.
  3. Signature = data contract. Docstring becomes the system directive; each field's desc= becomes a semantic boundary. Inline for prototypes, class-based for production.
  4. Module = a class the compiler can open. Inherit dspy.Module; implement __init__ (engines) and forward (flow).
  5. Upgrade reasoning by swapping the module, not by editing prompt text: Predict → ChainOfThought injects a hidden rationale.
  6. 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.
  7. Use inspect_history to see the four-part compiled prompt DSPy wrote for you.
  8. 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.

File atlas/dspy_lite.pyRun python3 -m atlas.dspy_liteDeps Python 3 · stdlib only
The stub LM. 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 ↗.