A Prompt Is Not Yet a Program
A Prompt Is Not Yet a Program
In chapter 1 we built a reasonable handwritten prompt and found the real problem: one prompt string was carrying the task semantics, role and behavioral framing, output request, and model assumptions at the same time.
The next move is not to make a more elaborate prompt. It is to decide what properties an LM component needs before we should reasonably call it a program.
A prompt is text:
"Rewrite this sentence..."
A program has a boundary:
inputs
โ
declared behavior
โ
execution strategy
โ
structured outputs
The boundary matters because it lets us change one part without pretending we changed everything.
1. What a program promises
A normal Python function has a weak but recognizable contract:
def slugify(title: str) -> str:
...
The signature says little about the full behavior, but it gives other code something stable to call. It names inputs and outputs. It creates a place for tests. It lets us replace the implementation.
An LM component becomes easier to engineer when these concerns are explicit:
| Property | Why it matters |
|---|---|
| Named inputs | Callers should not assemble task inputs as ad hoc prose |
| Declared behavior | The semantic job should be visible outside prompt wording |
| Execution strategy | We should know how the declared task is being attempted |
| Structured outputs | Downstream code needs stable fields when the task has structure |
| Inspectable structure | We should be able to identify the program’s components rather than infer them from one prompt string |
DSPy gives us handles for these concerns through signatures and modules.
A signature declares the task interface: what fields come in, what fields come out, and what semantic job connects them.
A module controls how that task is attempted. The same signature can be used with direct prediction, explicit reasoning, tool use, or a custom composition of several LM calls and ordinary Python.
That separation is the first important idea.
Signature: what behavior is requested?
Module: how is that behavior attempted?
2. A small program we can grow
We will keep the running example deliberately small. The program receives one sentence, an editorial goal, and nearby context. It returns one candidate rewrite with a rationale and confidence.
import dspy
class ImproveSentence(dspy.Signature):
"""Improve one sentence while preserving meaning, entities, and voice."""
sentence: str = dspy.InputField(desc="The exact sentence to improve")
editorial_goal: str = dspy.InputField(
desc="The local reason this sentence is being edited"
)
local_context: str = dspy.InputField(
desc="Nearby prose needed to preserve continuity and voice"
)
rewritten_text: str = dspy.OutputField(
desc="A replacement sentence, not a paragraph"
)
rationale: str = dspy.OutputField(desc="Short explanation of the edit")
confidence: float = dspy.OutputField(desc="Confidence from 0.0 to 1.0")
class SentenceImprover(dspy.Module):
def __init__(self) -> None:
super().__init__()
self.rewrite = dspy.Predict(ImproveSentence)
def forward(
self, sentence: str, editorial_goal: str, local_context: str = ""
) -> dspy.Prediction:
return self.rewrite(
sentence=sentence,
editorial_goal=editorial_goal,
local_context=local_context,
)
Assuming an LM is configured, execution is just a call:
program = SentenceImprover()
result = program(
sentence="She opened the door and then she laughed.",
editorial_goal="Sharpen rhythm without changing the event.",
local_context="The scene is tense but not comic.",
)
print(result.rewritten_text)
print(result.rationale)
print(result.confidence)
Current DSPy allows both string signatures and class-based signatures. The class form is better here because the field names and descriptions are part of the visible source, and later chapters will add typed outputs, metrics, examples, and optimization.
3. What changed from the prompt version
The handwritten version had one main artifact:
prompt string
The program version has several:
That extra structure is not ceremony. It gives us handles.
If the downstream editor needs risk in addition to confidence, we change the contract. If the model should reason before answering, we can change the execution policy in the module. If a local model cannot reliably produce floats, we can adjust validation or output design. If we later compile the program using examples, the optimizer has a program to compile rather than a blob of prompt text.
This is how Writer’s DSPy sentence-improvement layer is organized at the product boundary. It records candidate DTOs with candidate_text, provider, model, program, program_revision, prompt_hash, response_hash, evidence_packet_hash, used_real_llm, and fallback_state. That is not just bookkeeping. It reflects a design where generated text is evidence attached to a program run, not an anonymous string.
CoCoder pushes the same distinction further. Its EngineeringProgram architecture explicitly says DSPy is one possible optimizer/runtime adapter, not the architecture itself. The domain object is the program; DSPy may propose a candidate version.
4. Declared behavior is not implementation
A subtle mistake is to put too much implementation into the signature:
class BadImproveSentence(dspy.Signature):
"""Think step by step, compare three alternatives, choose the best one,
and rewrite the sentence using a terse Hemingway-like style."""
sentence: str = dspy.InputField()
rewritten_text: str = dspy.OutputField()
That docstring mixes three different kinds of statement:
5. Outputs are part of the program
The output fields are not decoration. They determine what the rest of the system can do.
For this editorial task, one undifferentiated field is too coarse:
Writer’s contextual preference judge uses this style. It asks for a decision, confidence, reason_codes_json, primary_reason, and explanation. It then parses and validates those fields, applies deterministic hard policies, and records the observation. The LM output enters a larger software system with explicit status and provenance.
That is what a program boundary buys us.
What Usually Goes Wrong
| Symptom | Likely cause | How to diagnose it | What to change |
|---|---|---|---|
| The signature reads like a long prompt | Task contract and tactic are fused | Highlight verbs that describe process rather than required behavior | Move process into a module |
Downstream code uses regexes on answer |
Outputs are too coarse | Search for parsers and string matching after LM calls | Split outputs into fields |
| Every caller passes slightly different context text | Inputs are not stable | Compare call sites | Name context fields explicitly |
| Swapping modules requires changing callers | Tactic-specific details leaked into the task interface | Look for fields named after reasoning steps, prompts, or models | Keep signature fields semantic and move tactics behind the module boundary |
Conclusion
We gained a program-shaped boundary around a language-model behavior. The prompt is still there somewhere, but it is no longer the thing the rest of the software has to understand.
We removed the assumption that one prompt string should simultaneously carry the task contract, execution tactic, output interface, and version identity.
What remains unsolved is the interface itself. We have a signature, but we have not yet studied how to design a good one. A weak contract can make every later stage worse.
So the next chapter asks:
What exactly is the LM being asked to consume and produce?