Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
The last lesson left deploy-answerer-v1.2-fix-b eligible for canary review after it passed a frozen suite. At 09:17, a canary request like req_205 changes the wording of the rollback question; the hand-edited template flips from ABSTAIN to SERVE without a citation while latency and HTTP errors stay green. That is a prompt regression, not a useful signal to an on-call engineer. The next question is whether you can search instructions and few-shot demos against those same frozen cases, instead of rewriting the string until it feels right.
DSPy[1] turns that release problem into a search you can inspect. You declare the task interface, write a metric, and let an optimizer propose instructions and demonstrations for one configured language model. A separate holdout test still decides whether the compiled candidate is worth shipping. Compilation is empirical search: it can overfit its data, and it doesn't prove the program is correct.
Manual templates don't search
Hand-tuned deploy-answerer prompts leave four questions unanswered:
- Model coupling. Wording that works on one hosted model can collapse on the next revision of the same alias.
- Fragility. A small instruction change can flip a serve/abstain decision on
req_205. - No search loop. Experiment tracking can compare two templates you already wrote. It doesn't propose the next instruction for you.
- Hidden state. Last Tuesday's release mixed a prompt string, a model alias, a metric, and a dataset. Which combination shipped?
A DSPy program keeps control flow relatively stable and searches prompt state for one LM and one metric. That separation gives you a candidate to inspect, not a release decision. You still own evaluation and promotion.
A program makes three contracts explicit:
- What you want the LM to do (signatures)
- How to compose LM calls (modules)
- What good looks like (metrics)
With those contracts in place, an optimizer can propose instructions and few-shot examples. You then compare that candidate with the uncompiled baseline on held-out tickets.
Signatures, modules, and metrics
A signature is the task interface, not the prompt text. It names inputs, outputs, and field meanings the way a Python function signature names arguments. Ask what should stay stable when req_205 changes wording: evidence and question still enter, and an answer still leaves. DSPy fills in formatting, while later compilation can rewrite instructions and demonstrations around that boundary.
For deploy-answerer, the job is: given admitted ReleaseOps evidence and an engineer's question, either serve a cited claim or abstain. Use that boundary while reading the API snippets below. They need the dspy package, and compilation needs a configured LM. Tests after them stay on the standard library so you can check decisions without calling a model.
1import dspy
2
3class GenerateAnswer(dspy.Signature):
4 """Answer a deploy question using only admitted evidence, or abstain."""
5 evidence: str = dspy.InputField(desc="Admitted ReleaseOps excerpts")
6 question: str = dspy.InputField(desc="The engineer's question")
7 answer: str = dspy.OutputField(desc="SERVE with a citation, or ABSTAIN")A module wraps a signature and holds compiled state such as demonstrations. dspy.ChainOfThought(GenerateAnswer) prepends a reasoning output field, calls the LM, and fills answer.[1] That rationale is model output, not the execution trace. A trace records predictor calls, inputs, and outputs while the program runs, so don't mistake a readable rationale for an audit log.
1import dspy
2
3class DeployAnswerer(dspy.Module):
4 def __init__(self):
5 super().__init__()
6 self.generate_answer = dspy.ChainOfThought(GenerateAnswer)
7
8 def forward(self, question: str, evidence: str) -> dspy.Prediction:
9 return self.generate_answer(evidence=evidence, question=question)If you later swap the LM or edit a field description, keep the module structure, recompile, and evaluate. A previous artifact can be a useful baseline, but don't assume it transfers.
Why is a DSPy signature more than a prompt template?
Answer
A signature defines the task interface: inputs, outputs, and field meanings. DSPy can then compile prompts and demonstrations for that interface, while the program logic stays stable.
Ask what a green check should mean for req_205: a correct route isn't enough if SERVE lacks a citation. A metric turns that product rule into a score. It takes a labeled example and a prediction, often returns 0.0 or 1.0, and may run hundreds of times, so keep it cheap and aligned with behavior you plan to ship.
Exact string match is fine for a short route label such as SERVE or ABSTAIN. It's the wrong tool for the full answer sentence. The metric below scores two things: did the model pick the right route, and did a SERVE answer reuse a fact from the admitted evidence?
1def route_of(text: str) -> str:
2 return text.split("|", 1)[0].strip().upper()
3
4def route_accuracy(gold: str, pred: str) -> float:
5 return 1.0 if route_of(gold) == route_of(pred) else 0.0
6
7def evidence_support(gold: str, evidence: str, pred: str) -> float:
8 if route_of(pred) == "ABSTAIN":
9 return 1.0 if route_of(gold) == "ABSTAIN" else 0.0
10 return 1.0 if "approval-4412" in pred.lower() and "approval-4412" in evidence.lower() else 0.0
11
12def answer_metric(gold: str, pred: str, evidence: str) -> float:
13 return 0.5 * route_accuracy(gold, pred) + 0.5 * evidence_support(gold, evidence, pred)
14
15def dspy_answer_metric(example, prediction, trace=None) -> float:
16 """Adapt DSPy's example/prediction callback to the product metric."""
17 return answer_metric(example.answer, prediction.answer, example.evidence)
18
19evidence = "ReleaseOps record approval-4412 authorizes rollback for payment-tests."
20gold = "SERVE|approval-4412 authorizes rollback"
21good = "SERVE|Per approval-4412, rollback is authorized."
22bad = "SERVE|The deploy is approved because tests look quiet."
23abstain = "ABSTAIN|No ReleaseOps record supports this claim."
24
25print(f"good: {answer_metric(gold, good, evidence):.1f}")
26print(f"unsupported serve: {answer_metric(gold, bad, evidence):.1f}")
27print(f"wrong route: {answer_metric(gold, abstain, evidence):.1f}")1good: 1.0
2unsupported serve: 0.5
3wrong route: 0.0A cited SERVE scores 1.0. An unsupported SERVE keeps the route half and loses the citation half. A wrong-route ABSTAIN scores 0.0. dspy_answer_metric adapts the product rule to DSPy: the optimizer passes a labeled example, a prediction, and an optional execution trace, not three standalone strings.
For open-ended wording, you can add an LLM-as-a-judge term after you calibrate it on held-out human labels. Judge scores can vary across runs, prefer verbose answers, and shift with candidate order.[2] Lower temperature can cut sampling variance. It doesn't remove those biases. Keep the deterministic route and citation checks for the behaviors you refuse to regress.
| Feature | Hand-written templates | DSPy compilation |
|---|---|---|
| Effort | Edit strings | Search over a program |
| Portability | Often model-specific | Recompile and re-eval per target LM |
| Evaluation | Only as good as your tracker | Explicit metric and splits |
| Few-shotting | Hand-picked demos | Bootstrapped or searched demos |
| What ships | A prompt file | A compiled artifact plus its eval contract |
Search on validation, promote on holdout
Now the optimizer (older DSPy papers say teleprompter) has enough information to search. It takes the program, labeled examples, and metric, then returns a compiled copy. .compile() deep-copies the student, so the original module stays uncompiled.[1] Current docs center the word optimizer; both names refer to the same search.
This is discrete search over prompt state, not gradient descent on model weights. The parameters are instruction strings and sets of demonstrations. Hosted LM calls don't expose a gradient through that text, so DSPy uses random search, bootstrap filtering, or a surrogate-guided trial loop depending on the optimizer. A higher validation score means the metric preferred that candidate on observed cases; it doesn't mean model weights moved.
Formally, we want the parameter set that maximizes the expected metric over a dataset :
here is the validation split the optimizer is allowed to see. In practice, the expectation is the sample mean over that split. Promotion still uses a separate test split, so selection and release measure different things.
What does represent in DSPy optimization?
Answer
It represents prompt-program choices such as instructions and demonstrations. The optimizer searches over those choices to maximize a validation metric, not to update model weights with gradients.
Split train, validation, and test before the optimizer sees any rows. Train supplies traces for bootstrapping, validation picks a candidate, and test makes the promotion decision. MIPROv2 will carve a validation split out of trainset if you omit valset. That's convenient for an experiment, but reusing that pool as holdout leaks the selection signal.
1train_ids = {"req-001", "req-002", "req-003"}
2validation_ids = {"req-101", "req-102"}
3test_ids = {"req-201", "req-202"}
4
5def assert_disjoint_splits(*splits: set[str]) -> None:
6 for index, left in enumerate(splits):
7 for right in splits[index + 1:]:
8 overlap = left & right
9 if overlap:
10 raise ValueError(f"leaked examples: {sorted(overlap)}")
11
12assert_disjoint_splits(train_ids, validation_ids, test_ids)
13
14try:
15 assert_disjoint_splits(train_ids, validation_ids, {"req-001"})
16except ValueError as exc:
17 print("leakage_rejected:", "req-001" in str(exc))
18
19print("clean_split_sizes:", [len(train_ids), len(validation_ids), len(test_ids)])1leakage_rejected: True
2clean_split_sizes: [3, 2, 2]Predict before reading the next output: the validation winner needn't be the release winner. With two frozen tickets, the optimizer might try a zero-shot instruction, a few bootstrapped demos, or a rewritten instruction plus those demos. It keeps the candidate with the highest validation mean, then holdout can still favor the uncompiled baseline.
1candidates = {
2 "baseline": {"validation": [0.7, 0.7], "test": [0.8, 0.8]},
3 "compiled_a": {"validation": [1.0, 0.9], "test": [0.6, 0.7]},
4}
5
6def mean(values: list[float]) -> float:
7 return sum(values) / len(values)
8
9selected = max(candidates, key=lambda name: mean(candidates[name]["validation"]))
10baseline_test = mean(candidates["baseline"]["test"])
11selected_test = mean(candidates[selected]["test"])
12
13print("validation_winner:", selected)
14print("selected_test_score:", round(selected_test, 2))
15print("promote_over_baseline:", selected_test >= baseline_test)1validation_winner: compiled_a
2selected_test_score: 0.65
3promote_over_baseline: Falsecompiled_a wins validation at 0.95 against the baseline's 0.70, then scores 0.65 on holdout against 0.80. The gap is useful evidence: selection found a validation fit, while promotion tested transfer to unseen tickets.

BootstrapFewShot: traces that pass become demos
If the metric accepts one evidence trace and rejects another, should the accepted trace become a future prompt example? LabeledFewShot attaches labeled train rows as demos and doesn't call the LM during .compile(). BootstrapFewShot goes one step further: it runs a teacher program (often a copy of the uncompiled student) on the training set, keeps traces the metric accepts, and writes those traces into the candidate's demonstrations.

- Bootstrap. Run the teacher on training tickets. Extra rounds copy the LM at
temperature=1.0to gather diverse traces. - Filter. Call
metric(example, prediction, trace). A truthy score, or a score at or abovemetric_threshold, accepts the trace. - Assign. Up to
max_bootstrapped_demosslots (default 4) get those accepted traces. Remaining slots up tomax_labeled_demos(default 16) fill with raw labeled rows.
That loop only proves a trace passed your metric once. Compare the compiled copy with the uncompiled baseline on holdout before you keep it; a polished demo can still encode a training-set accident.
The library call looks like this. Compilation itself needs dspy.configure(lm=...) and spends LM calls, so the lab below simulates only the filter.
1import dspy
2
3optimizer = dspy.BootstrapFewShot(
4 metric=dspy_answer_metric,
5 max_bootstrapped_demos=4,
6 max_labeled_demos=16,
7)
8# compiled = optimizer.compile(DeployAnswerer(), trainset=trainset)1traces = [
2 {"id": "req-001", "score": 1.0, "answer": "ABSTAIN|no ReleaseOps record"},
3 {"id": "req-002", "score": 0.0, "answer": "SERVE|ship it, tests look quiet"},
4 {"id": "req-003", "score": 1.0, "answer": "SERVE|approval-4412 authorizes rollback"},
5]
6
7accepted = [trace for trace in traces if trace["score"] >= 1.0]
8print("accepted_ids:", [trace["id"] for trace in accepted])
9print("rejected_ids:", [trace["id"] for trace in traces if trace["score"] < 1.0])1accepted_ids: ['req-001', 'req-003']
2rejected_ids: ['req-002']BootstrapFewShotWithRandomSearch (alias BootstrapRS) repeats that bootstrap with different seeds, scores each demo set on validation, and keeps the best. Reach for it when one bootstrap pass is too noisy and you can afford more compile budget, then retain the same holdout gate.
MIPROv2: joint instruction and demo search
BootstrapFewShot doesn't rewrite the instruction. If good demos still lead to the wrong route, MIPROv2 jointly searches instruction text and few-shot sets.[3] Official docs describe three stages:
- Bootstrap demo sets, the same rejection-sampling idea as above, producing
num_candidatesdemo bundles. - Propose instructions with a
prompt_model. The proposer sees a data summary, the program structure, the bootstrapped demos, and a sampled tip such as "be concise". - Search combinations with Bayesian optimization. The current DSPy implementation drives that loop with Optuna and a multivariate TPE sampler, so compile hosts need
dspy[optuna]. Trials can score minibatches, then re-check the leader on the full validation set.
Default auto="light" sets candidate count, trial count, and validation cap for you. Start there. Manual mode is auto=None plus num_candidates on the constructor and num_trials on compile(...). Mixing auto="light" with num_candidates raises, and omitting num_trials in manual mode raises too.
1import dspy
2
3optimizer = dspy.MIPROv2(metric=dspy_answer_metric, auto="light")
4# compiled = optimizer.compile(DeployAnswerer(), trainset=trainset, valset=valset)
5
6zero_shot_optimizer = dspy.MIPROv2(metric=dspy_answer_metric, auto="light")
7# zero_shot = zero_shot_optimizer.compile(
8# DeployAnswerer(),
9# trainset=trainset,
10# valset=valset,
11# max_bootstrapped_demos=0,
12# max_labeled_demos=0,
13# )1def compile_mipro(*, auto: str | None, num_candidates: int | None, num_trials: int | None = None) -> str:
2 if auto is None and num_candidates is not None and num_trials is None:
3 raise ValueError("If auto is None, num_trials must also be provided.")
4 if auto is None and (num_candidates is None or num_trials is None):
5 raise ValueError("If auto is None, num_candidates must also be provided.")
6 if auto is not None and (num_candidates is not None or num_trials is not None):
7 raise ValueError("If auto is not None, num_candidates and num_trials cannot be set.")
8 return "ok"
9
10try:
11 compile_mipro(auto=None, num_candidates=4)
12except ValueError as exc:
13 print("missing_num_trials_rejected:", "num_trials" in str(exc))
14
15print("light_ok:", compile_mipro(auto="light", num_candidates=None))1missing_num_trials_rejected: True
2light_ok: okThe explicit split is a release guardrail. Pass trainset, valset, and a held-out test set you never hand to compile. Current DSPy builds a validation split from trainset when valset is omitted; convenient for exploration, unsafe as a release set.
As a default, instruction search can transfer farther than a large demo set. Demo-heavy candidates can memorize the train tickets you bootstrapped from. If validation is tiny or skewed, prefer instruction-heavy settings and keep a real holdout. A MIPROv2 run is zero-shot only when both demo sources are disabled at compile time: max_bootstrapped_demos=0 and max_labeled_demos=0.[4] Setting only the bootstrapped limit to zero still permits labeled demonstrations.
Which optimizer to try first
DSPy doesn't pick an optimizer for you. The auto knobs on MIPROv2 and GEPA set budget inside one algorithm; they don't compare algorithms for you.
| Optimizer | Tunes | Typical setup | Compile cost |
|---|---|---|---|
| LabeledFewShot | Demos from labels | Small labeled train set | None (no LM calls) |
| BootstrapFewShot | Metric-accepted traces as demos | Representative train tickets plus a metric | Medium |
| BootstrapRS | Many demo sets, pick on val | Explicit validation split | High |
| MIPROv2 | Instructions and demos jointly | Separate val set, dspy[optuna], more metric budget | Very high |
| GEPA | Instructions via reflective rewrites | Reflection LM plus feedback-rich metric | Very high |
| BootstrapFinetune | Model weights from accepted traces | A tunable LM, after prompt-only plateaus | Training plus deploy cost |
GEPA maintains a population of programs, reads natural-language feedback from the metric, and samples parents from a Pareto frontier of per-example winners.[5] Across its six paper tasks, it reports beating GRPO by 6% on average (up to 20%) with up to 35× fewer rollouts, and beating MIPROv2 by over 10% (including +12% accuracy on AIME-2025). Those numbers select GEPA on that benchmark, not on deploy-answerer.
A GEPA metric can return a float, but the richer contract is dspy.Prediction(score=..., feedback="..."): the reflection LM can turn feedback into its next instruction proposal. A bare float still runs; the proposer then sees a generic "this trajectory got a score of n" caption. Compare measured holdout gain and compile cost with BootstrapFewShot or MIPROv2 before you adopt it.
Save JSON state, not a pickle from the internet
A compiled program is only useful if you can reload the prompt state later. DSPy has two save paths, and mixing them up makes an otherwise reproducible compile hard to deploy.
- State-only JSON.
program.save("compiled_a.json")writes predictor state: instructions, demonstrations, signature metadata, and sanitized reconstruction state for an attached LM. Current DSPy LM state includes its model identifier and safe configuration, but excludes API keys. Loading recreates that per-predictor LM, and a later globaldspy.configure(lm=...)doesn't replace it.[6] RecreateDeployAnswerer()in code, call.load("compiled_a.json"), and use the loaded LM unless this release deliberately changes the model. For an intentional override, callprogram.set_lm(runtime_lm)and evaluate that artifact-model pair before promotion. - Whole-program directory.
program.save("compiled_a/", save_program=True)writesprogram.pklplus dependency metadata. Restore withdspy.load("compiled_a/", allow_pickle=True). That path deserializes cloudpickle, so treat the directory like executable code and only load artifacts you trust.
Prefer JSON for ordinary releases. It's readable, diffable, and doesn't execute code on load. It still isn't a secret store or a complete release manifest. Current DSPy removes API keys when dumping LM state and strips unsafe endpoint fields during ordinary load; restoring custom LM classes or unsafe endpoint state requires an explicit trusted-state opt-in.[6]
1import json
2from pathlib import Path
3from tempfile import TemporaryDirectory
4
5saved = {
6 "instructions": "Cite a ReleaseOps record or abstain.",
7 "demos": [{"question": "Can we rollback payment-tests?", "answer": "SERVE|approval-4412"}],
8 "signature": ["evidence", "question", "answer"],
9 "lm": {
10 "class": "dspy.clients.lm.LM",
11 "model": "openai/gpt-5.4-mini-2026-08-01",
12 "temperature": 0.0,
13 },
14}
15
16with TemporaryDirectory() as temp_dir:
17 artifact = Path(temp_dir) / "compiled_a.json"
18 artifact.write_text(json.dumps(saved), encoding="utf-8")
19 restored = json.loads(artifact.read_text(encoding="utf-8"))
20 print("state_file_created:", artifact.exists())
21 print("restored_outputs:", restored["signature"][-1])
22 print("python_class:", "DeployAnswerer")
23 print("saved_model_id:", restored["lm"]["model"])
24 print("api_key_in_json:", "api_key" in restored["lm"])
25 loaded_predictor_lm = restored["lm"]["model"]
26 globally_configured_lm = "openai/gpt-5.4-nano-2026-08-01"
27 print("effective_after_load:", loaded_predictor_lm or globally_configured_lm)
28 explicit_override = globally_configured_lm
29 print("effective_after_set_lm:", explicit_override)1state_file_created: True
2restored_outputs: answer
3python_class: DeployAnswerer
4saved_model_id: openai/gpt-5.4-mini-2026-08-01
5api_key_in_json: False
6effective_after_load: openai/gpt-5.4-mini-2026-08-01
7effective_after_set_lm: openai/gpt-5.4-nano-2026-08-01The last two lines capture the precedence trap. A loaded predictor carries its saved LM, so changing the global default doesn't move it. set_lm is the explicit release action that recursively replaces predictor LMs. Treat that call as a new tested pairing, not as harmless runtime wiring.
Compile offline, serve a frozen artifact
.compile() can spend a large LM budget. Run it in an offline job, then let request handling load the tested artifact and answer. If an optimizer loop reaches the request path, latency, cost, and even prompt state now depend on traffic.


Before trusting average holdout lift, inspect critical slices. If rollback guidance drops below its floor, block the candidate even when the mean improved.
1baseline = {"unsupported_claim": 0.84, "rollback": 0.91}
2compiled = {"unsupported_claim": 0.95, "rollback": 0.83}
3minimum = {"unsupported_claim": 0.84, "rollback": 0.88}
4
5failed = {
6 task: score
7 for task, score in compiled.items()
8 if score < minimum[task]
9}
10improved_average = sum(compiled.values()) > sum(baseline.values())
11
12print("improved_average:", improved_average)
13print("failed_release_gates:", sorted(failed))
14print("promote:", not failed)1improved_average: True
2failed_release_gates: ['rollback']
3promote: FalseWhat must be versioned after a DSPy compile run?
Answer
Version the compiled artifact, target model, dataset split, metric, optimizer settings, and source program. Without those pieces, you can't explain or roll back a production quality change.
A stable DeployAnswerer interface lets you run a new compile when the underlying model changes. It doesn't make old JSON model-independent. Instruction phrasing and demos can be specific to one snapshot. A provider-side update is a new experiment, even when the alias string didn't change. Keep a compile record keyed by program version and exact model revision.
1artifacts = {
2 ("deploy-answerer-v3", "hosted-model@2026-04-01"): "compiled_a_hosted-2026-04-01.json",
3 ("deploy-answerer-v3", "local-model@rev7"): "compiled_a_local-rev7.json",
4}
5
6def artifact_for(program_version: str, model_revision: str) -> str:
7 key = (program_version, model_revision)
8 if key not in artifacts:
9 raise KeyError("compile and evaluate an artifact for this model revision")
10 return artifacts[key]
11
12print("known_artifact:", artifact_for("deploy-answerer-v3", "local-model@rev7"))
13try:
14 artifact_for("deploy-answerer-v3", "local-model@rev8")
15except KeyError:
16 print("uncompiled_revision_rejected:", True)1known_artifact: compiled_a_local-rev7.json
2uncompiled_revision_rejected: TrueThat record is what the next lesson packages into a release bundle. The JSON file is one field; compile run id, model pin, eval suite hash, and rollback target complete the release evidence.
When compilation burns budget
When compile burns budget, diagnose the symptom before increasing the budget. Each failure below points to a different contract.
Exact match on long answers
Almost every prediction scoring 0 means bootstrap keeps nothing and MIPROv2 wanders. The usual cause is scoring open-ended text as if it were a class label: "ABSTAIN because no ReleaseOps record exists" and "ABSTAIN|no ReleaseOps record" can express the same decision. Score route and citation deterministically, then add a calibrated judge only for wording rules can't check.
Waiting for thousands of labels
If you postpone .compile() until the dataset looks like a fine-tune set, you're importing weight-training habits into prompt search. Start with a small, representative split, watch slice coverage, and add tickets where evaluation is unstable. Don't invent a required sample count before measuring the task.
Never reading the compiled prompt
If holdout looks fine but production fails and nobody can say which demos shipped, you treated .compile() as a black box. After compilation, inspect recent LM calls with dspy.inspect_history(n=...). Edge-case demos or misleading instructions point back to coverage or metric design, so fix those and rerun the comparison.
Hidden setup mistakes
Finally, check setup before blaming search. Calling modules without a metric and .compile() is prompt formatting, not optimization. MIPROv2 rejects num_candidates while auto is still "light"; manual mode needs auto=None and num_trials. Its compile path imports Optuna, so install dspy[optuna] on compile hosts.
A five-ticket validation set can memorize: tiny val plus a large demo count is how compiled_a beats the baseline in-sample and loses on holdout. JSON state doesn't recreate DeployAnswerer, so load it into a compatible class. It can recreate an attached predictor LM from sanitized state; use set_lm when a reviewed release intentionally replaces that model. dspy.load(..., allow_pickle=True) executes cloudpickle; only load trusted directories. Heavy optimizers belong in batch jobs, never inside a request handler.
Key takeaways
- DSPy searches instructions and demonstrations for a declared program. It doesn't replace held-out evaluation.
- Signatures name the interface. Modules own control flow. Optimizers fill prompt state.
.compile()returns a new copy. BootstrapFewShotturns metric-accepted traces into demos.MIPROv2also rewrites instructions. Compare every candidate with the baseline on a split the optimizer never saw.- Save JSON state for ordinary releases. Recreate the Python class, then load its predictor and sanitized LM state. Keep credentials outside the artifact and use
set_lmonly for an evaluated model override. - Version the artifact with the model revision, metric, split hashes, optimizer settings, and source program. That's the object the next lesson will pin inside a release bundle.