Optimise Prompts with DSPy: Signatures, Metrics, Few-Shot Compilers and Held-Out Tests
Hands-on lab · IDE in your browser

Optimise Prompts with DSPy: Signatures, Metrics, Few-Shot Compilers and Held-Out Tests

Declare a ticket-triage task as a DSPy signature, score it with a metric, read the misses, compile it with labelled and bootstrapped demos, search demo sets on dev and catch the flattering score on a held-out test, then add the notes no label contains.

Time
60 min
Checked steps
5
Level
Intermediate
Setup
None
Read step 1

Hands-on labs require Pro · $29.99/mo · cancel anytime

The job

Tidewell's support leads labelled 162 tickets with a team and a priority. You turn triage into a DSPy program, find out where it goes wrong, and measure which optimisations hold up on tickets it has never seen.

5 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Declare the task

    Tidewell's support leads have labelled 162 tickets with the team that should handle each one and its priority (tickets.json: train, dev and test).

    You writemetric()
  2. 2

    Measure, then read the misses

    A score says how good the prompt is; reading the misses says why.

    You writeevaluate()breakdown()confusion()
  3. 3

    Compile with examples

    DSPy's optimisers compile a program: they choose the demos (worked examples) and wording its prompt carries.

    You writefew_shot()bootstrap()prompt_tokens()
  4. 4

    Search on dev, check on test

    BootstrapFewShotWithRandomSearch tries several demo sets and keeps the one with the best dev score.

    You writesearch()held_out()
  5. 5

    Say what the labels cannot

    Step 2 showed that the model over-escalates Starter and Pro tickets.

    You writewith_notes()choose()

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Declare the task

Tidewell's support leads have labelled 162 tickets with the team that should handle each one and its priority (tickets.json: train, dev and test). In DSPy you do not write the prompt. You declare what goes in and what comes out, give a metric, and let DSPy write and tune the prompt against it.

Do this

1. Finish the Triage signature in triage.py: add team and priority as dspy.OutputField()s. Type them with Literal[...] (the six TEAMS, the three PRIORITIES), so DSPy rejects any other answer.

2. Write metric(example, pred, trace=None). When scoring, give 1.0 if team and priority are both right, 0.5 if one is and 0.0 if neither is. When an optimiser passes a trace, return True only if both are right.

3. Run. It triages three dev tickets with dspy.Predict(Triage).

triage.py, the file you edit104 lines
"""Tidewell's ticket triage, written as a DSPy program instead of a hand-tuned prompt."""
import json
import re
from collections import Counter
from typing import Literal

import dspy
import tiktoken

import harness as H

TEAMS = ["billing", "cancellation", "account_access", "security", "bug", "feature_request"]
PRIORITIES = ["P1", "P2", "P3"]


def load(split):
    """The labelled tickets of one split ("train", "dev" or "test") as dspy.Examples whose input is the ticket."""
    return [dspy.Example(**r).with_inputs("ticket") for r in json.load(open("tickets.json"))[split]]


def plan_of(ticket):
    """The customer's plan, from the ticket's signature line."""
    m = re.search(r"(Starter|Pro|Enterprise)", ticket.strip().split("\n")[-1])
    return m.group(1) if m else "unknown"


# ---------- Step 1: declare the task ----------

# TODO (Step 1): add the two outputs, team and priority, as dspy.OutputField()s typed with Literal[...] so DSPy
# only accepts one of TEAMS and one of PRIORITIES. The docstring becomes the model's instructions.
class Triage(dspy.Signature):
    """Route a Tidewell support ticket to a team and set its priority."""
    ticket: str = dspy.InputField()


def metric(example, pred, trace=None):
    """How right a prediction is. When scoring (trace is None): 1.0 for team and priority right, 0.5 for one of them,
    0.0 for neither. When an optimiser asks (trace is not None): True only if both are right, because a demo that is
    half right teaches the model half a mistake."""
    # TODO (Step 1): compare pred.team and pred.priority with the example's; a score, or a bool when trace is set.
    raise NotImplementedError("Step 1: write metric()")


# ---------- Step 2: measure, then read the misses ----------

def evaluate(program, data):
    """(score, rows): the program's mean metric on data as a percentage, and one (example, prediction, score) row per
    example. Use dspy.Evaluate with H.THREADS threads."""
    raise NotImplementedError("evaluate() arrives in Step 2")


def breakdown(rows, field, group):
    """{group value: (right, total)}: how often `field` ("team" or "priority") was right, per value of
    group(example), e.g. group=lambda e: plan_of(e.ticket)."""
    raise NotImplementedError("breakdown() arrives in Step 2")


def confusion(rows, field):
    """Counter of (right answer, predicted answer) for the rows where `field` was wrong."""
    raise NotImplementedError("confusion() arrives in Step 2")


# ---------- Step 3: compile with examples ----------

def few_shot(k, signature=None):
    """A Predict(signature or Triage) program compiled with LabeledFewShot: k labelled train tickets as demos."""
    raise NotImplementedError("few_shot() arrives in Step 3")


def bootstrap():
    """A Predict(Triage) program compiled with BootstrapFewShot: up to 4 demos the model itself got right on train
    (by metric with a trace) plus labelled ones, 8 demos at most."""
    raise NotImplementedError("bootstrap() arrives in Step 3")


def prompt_tokens(program, ticket):
    """Tokens in the prompt the program sends for one ticket: every message DSPy's ChatAdapter formats from the
    program's signature, demos and the ticket, counted with tiktoken's cl100k_base."""
    raise NotImplementedError("prompt_tokens() arrives in Step 3")


# ---------- Step 4: search on dev, check on test ----------

def search():
    """A Predict(Triage) program compiled with BootstrapFewShotWithRandomSearch: 1 random candidate demo set (plus
    the ones it always tries), each scored on dev, the best kept. Same demo limits as bootstrap()."""
    raise NotImplementedError("search() arrives in Step 4")


def held_out(programs):
    """{name: {"dev", "test", "gap"}} for {name: program}: the dev and test scores and dev minus test."""
    raise NotImplementedError("held_out() arrives in Step 4")


# ---------- Step 5: say what the labels cannot ----------

def with_notes(notes):
    """Triage with the notes added after its instructions (Signature.with_instructions)."""
    raise NotImplementedError("with_notes() arrives in Step 5")


def choose(dev_scores):
    """The name with the best dev score; on a tie, the first one listed (list cheaper programs first)."""
    raise NotImplementedError("choose() arrives in Step 5")
Provided for you:harness.pysupport_notes.mdtickets.jsontry_it.py

Frequently asked questions

What does DSPy optimise?

The prompt a module sends: which worked examples (demos) it includes and, with some optimisers, the instruction text. It chooses them by running your program against a metric on labelled examples.

Why hold out a test set when optimising prompts?

Optimisers choose the candidate with the best score on the examples they are given, so that score is optimistic. A test set used once, ideally phrased differently, shows how much of the gain is real.

Can a prompt optimiser learn rules that are not in the data?

No. It can only pick demos and wording that the labelled examples support. Definitions that live in a team's notes have to be written into the program, and then the optimiser tunes what they leave.

DSPy prompt optimisation, measured

DSPy replaces hand-tuned prompts with a signature, a metric and an optimiser that compiles the prompt against labelled examples. The gains are only as real as the evaluation behind them. You declare a typed signature, write a metric with a strict mode for optimisers, break the score down by field and customer plan, compile with LabeledFewShot and BootstrapFewShot, weigh demos against prompt tokens, run a random search and compare dev with a held-out test, then add the domain notes that no optimiser could find.