indiantinker's blog

DSPy 101: Programming Prompts (sort of)

If you’ve been building with Large Language Models (LLMs), you know the pain: you tweak a prompt string, your output breaks. You change the model from GPT-4 to Claude, and suddenly your carefully crafted instructions stop working.

img

This is not a bug, it's a feature. When we talk about LLMs in a context of a larger app, LLMs add a lot of value but also a lot of non-deterministic elements to the business logic. It is like, having a function that may return something now, but entirely different thing when the semantic network inside its weight network computes a different output. Its probabilistic.

Many people know about it (not as much as we would like). Hence, there are lot of strategies that minimise this. One of the main strategies is to write bullet proof prompts. We can call this strategy as "Prompt Engineering" or "Context Engineering". I am not sure, what it will be called in the future. img

Now, prompts... "You are a helpful assistant". Prompt engineering is like Graphic Design. Everyone is a prompt engineer and no one is the prompt engineer. Since, prompts are language (structured), and language is subjective, two people will write different prompts for the same thing. It is ok, if you are writing a prompt to ask ChatGPT to decipher a confusing date-night conversation, but not good when your app's business logic depends on it. This act of writing direct human like prompts is called "String Manipulation".

Imagine a receipt classification app, which gets in the slips and churns out tax and amount. Then you report them to the tax module and tax gets deducted. A small error over time could land a less vigilant user in trouble with tax authorities.

This is where DSPy (Declarative Self-improving Python) shines.

DSPy shifts your focus from writing prompt strings to writing code modules, resulting in LLM applications that are far more reliable and easier to optimize.

In this first post, we’ll set up the environment and define the three foundational components of any DSPy application.

1. The Setup

First, let's get connected. DSPy works with almost any LLM backend. I use openrouter to keep things simple. Here is a quick way to set it up. When generating prompts DSPy does not use LLMs but when querying or optimising (we will see this later), it does use LLMs. Make sure to have one setup before proceeding.

import dspy

# Configure the language model
# (Replace 'your-api-key-here' with your actual API key)
lm = dspy.LM(
    model="openrouter/anthropic/claude-3-haiku",
    api_base="https://openrouter.ai/api/v1",
    api_key='your-api-key-here',
)

# Globally configure DSPy to use this model
dspy.settings.configure(lm=lm)

2. DSPy Ingredients

img

In the world of traditional prompt engineering, creating an AI workflow is like trying to manage a restaurant using only hand-written, messy order slips that change every time you write them. DSPy replaces this chaotic process with a structured system.

A. The Signature: The Recipe

A Signature is the Recipe for your LLM task. It defines exactly what ingredients (inputs) are needed and the specific final dish (structured output) that must be delivered.

You are not telling the LLM how to cook; you are only defining the strict requirements for the final dish.

class BasicQA(dspy.Signature):
    """Answer questions with short factoid answers."""
    
    # Input: The ingredient the LLM receives
    question = dspy.InputField()
    # Output: The mandatory format for the answer
    answer = dspy.OutputField(desc="often between 1 and 5 words")

B. The Module: The Chef

If a Signature is the Recipe, a Module is the Chef hired to execute that recipe. The Module takes the raw input, delegates the task to the LLM using a DSPy predictor (like dspy.Predict), and serves the final, structured output.

class BasicQAModule(dspy.Module):
    def __init__(self):
        super().__init__()
        # We assign a Predictor (a tool for the Chef) to our BasicQA recipe
        self.generate_answer = dspy.Predict(BasicQA)
    
    def forward(self, question):
        # The Chef executes the task
        return self.generate_answer(question=question)

# Let's run it
qa_module = BasicQAModule()
result = qa_module(question="What is the capital of France?")
print("Answer:", result.answer)

3. Predict vs. ChainOfThought

This is where you gain granular control over the LLM’s cognitive process. By changing one word in your Module's initialization, you choose between a fast, direct answer and a thoughtful, step-by-step analysis.

dspy.Predict (Direct) vs. dspy.ChainOfThought (Reasoning)

Feature dspy.Predict dspy.ChainOfThought
Process Direct question → answer Adds a reasoning step before the answer
Output Fields Only the Signature fields Signature fields plus reasoning
Trade-Off Faster, fewer tokens Better accuracy, more tokens

The Code Difference:

# Predict: Direct answer
self.generate_answer = dspy.Predict(BasicQA)

# ChainOfThought: Automatically adds a 'reasoning' output field
self.generate_answer = dspy.ChainOfThought(BasicQA)

Side-by-Side Comparison

Let’s use a simple math problem to see the difference in the output structure.

# Module with Predict (no reasoning)
class PredictModule(dspy.Module):
    def __init__(self):
        super().__init__()
        self.generate_answer = dspy.Predict(BasicQA)
    def forward(self, question):
        return self.generate_answer(question=question)

# Module with ChainOfThought (with reasoning)
class CoTModule(dspy.Module):
    def __init__(self):
        super().__init__()
        self.generate_answer = dspy.ChainOfThought(BasicQA)
    def forward(self, question):
        return self.generate_answer(question=question)

predict_module = PredictModule()
cot_module = CoTModule()
question = "What is 15 + 23?"

# Predict result
result1 = predict_module(question=question)
print("--- Predict (Direct) ---")
print(f"Answer: {result1.answer}")
print(f"Has reasoning field: {hasattr(result1, 'reasoning')}")

# ChainOfThought result
result2 = cot_module(question=question)
print("\n--- ChainOfThought (With Reasoning) ---")
print(f"Reasoning: {result2.reasoning}")
print(f"Answer: {result2.answer}")
print(f"Has reasoning field: {hasattr(result2, 'reasoning')}")

OUTPUT

=== Comparing Predict vs ChainOfThought ===

Question: What is 15 + 23?

--- Predict (Direct) ---
Answer: 38
Has reasoning field: False

--- ChainOfThought (With Reasoning) ---
Reasoning: To calculate 15 + 23, I will add the two numbers together.
Answer: 38
Has reasoning field: True

4. Bonus Example

Signatures are especially useful when you need the LLM to perform creative or multi-step thinking while still adhering to a strict output schema.

Here, we force the LLM to output a single word and a poetic sentence based on the input text.

class EmotionDetectionSig(dspy.Signature):
    """Detect the emotion of a given text and express it in a single word and poeticially."""
    text = dspy.InputField()
    oneWordEmotion = dspy.OutputField(desc="A single word expressing the emotion of the text")
    poeticEmotion = dspy.OutputField(desc="A poetic expression of the emotion of the text")

class SayWhat(dspy.Module):
    def forward(self, text):
        predictor = dspy.Predict(EmotionDetectionSig)
        output = predictor(text=text)
        return output

say_what = SayWhat()
text_input = "There are only three ways to make this work. The first is to let me take care of everything. The second is for you to take care of everything. The third is to split everything 50 / 50. I think the last option is the most preferable, but I'm certain it'll also mean the end of our marriage."
output = say_what(text=text_input)

print(f"\nInput: {text_input}")
print(f"One Word Emotion: {output.oneWordEmotion}")
print(f"Poetic Expression: {output.poeticEmotion}")

OUTPUT :

Apprehensive
The mind wrestles with uncertainty,
Fearing the fragility of union,
As compromise hangs in the balance,
Casting a shadow of doubt upon the future.

5. But what is the prompt you are sending?

You can see what DSPy is telling the LLMs using the history command. In later posts, I will talk about how we can take this from DSPy and then use it in our typescript apps.

# Clear history to see only the latest call
dspy.settings.lm.history.clear()

# Make a call
qa_module(question="What is 5 + 7?")

# Pretty print the interaction
from dspy.utils.inspect_history import pretty_print_history
pretty_print_history(dspy.settings.lm.history, n=1)

OUTPUT

Question: What is 5 + 7?
Answer: 12

=================================================================

[2025-11-29T11:40:35.120947]

System message:

Your input fields are:
1. `question` (str):
Your output fields are:
1. `answer` (str): often between 1 and 5 words
All interactions will be structured in the following way, with the appropriate values filled in.

[[ ## question ## ]]
{question}

[[ ## answer ## ]]
{answer}

[[ ## completed ## ]]
In adhering to this structure, your objective is: 
        Answer questions with short factoid answers.

User message:

[[ ## question ## ]]
What is 5 + 7?

Respond with the corresponding output fields, starting with the field `[[ ## answer ## ]]`, and then ending with the marker for `[[ ## completed ## ]]`.

Response:
[[ ## answer ## ]]
12
[[ ## completed ## ]]

Next Steps

We've mastered the basics: Signatures, Modules, and the power of Chain of Thought. But we are still manually defining our logic.

In the next post, we will look at more complex examples like Multi level prompting, defining output syntax and take a shot at making a classifier.

Stay tuned!

If you need a hand with your AI projects or need a quick demo feel free to get in touch using the contact page

Images from Unsplash, xkcd, reddit

BTW, I am the founder of Studio-021, where I help clients make ideas real and solve complex challenges. If you are looking for someone who can help you get unstuck, give me a call :)