Prompt Engineering for Developers
The phrase “prompt engineering” has accumulated enough skepticism to obscure something real underneath it. Writing a prompt is writing a specification. The specification determines whether the output is what you need. The techniques involved are not tricks - they’re the same discipline that makes a good ticket or a good API contract: precision about what you want, honesty about the constraints, and enough example to remove ambiguity.
This article focuses on practical techniques for developers building systems that use LLMs - not chatbot interactions, but programmatic use where you need predictable, parseable output from a language model. In this context, the prompt is software: it should be written deliberately, version-controlled, and tested against known inputs before production.
Why Prompts Matter
A language model predicts the most likely continuation of its input. The way you structure the input changes what continuations are most likely. This isn’t magic - it’s pattern matching against training data.
When you write “Tell me about Python,” you get a broad overview because that’s the most statistically likely response to that kind of question in the training data. When you write “You are a senior Python developer. A junior engineer asked about Python’s GIL. Explain it in 3 paragraphs - what it is, why it exists, and when it matters in practice,” you’ve narrowed the distribution significantly toward the response you want.
Giving the Model a Role
Starting with a system prompt that establishes the model’s role constrains its behavior:
You are a code reviewer for a Python backend team. You review pull requests for:
- Correctness: does the code do what it claims to do?
- Security: are there any obvious vulnerabilities?
- Clarity: is the code readable by someone unfamiliar with this codebase?
When you find issues, explain specifically what's wrong and how to fix it.
If the code looks good, say so briefly.
A good role prompt specifies: who the model is, what domain it’s working in, what the output should focus on, and optionally what format to use. Vague role prompts (“You are a helpful assistant”) add little.
Structured Output
For programmatic use, you usually need structured output - JSON, a specific format, or parseable content. The most reliable approach is to specify the exact schema:
Analyze this customer feedback and return a JSON object with this exact structure:
{
"sentiment": "positive" | "negative" | "neutral",
"topics": ["string"],
"urgency": 1-5,
"requires_followup": boolean,
"summary": "one sentence"
}
Respond with only the JSON object, no explanation.
Feedback: "The app keeps crashing when I try to upload photos. I've tried three times
and lost my work each time. This is really frustrating."
Expected output:
{
"sentiment": "negative",
"topics": ["crash", "photo upload", "data loss"],
"urgency": 5,
"requires_followup": true,
"summary": "User experiencing repeated crashes during photo upload with data loss."
}
Modern models support JSON mode (enforcing valid JSON output) and function calling / tool use (producing output conforming to a defined schema). Use these when available - they’re more reliable than hoping the model formats correctly.
Few-Shot Examples
Showing the model examples of input-output pairs dramatically improves consistency. This is few-shot prompting:
Classify the following support tickets by priority. Use CRITICAL for data loss or
security issues, HIGH for features not working, MEDIUM for degraded performance,
LOW for cosmetic issues.
Examples:
Ticket: "Users can't log in - completely blocked"
Priority: HIGH
Ticket: "Production database is corrupted"
Priority: CRITICAL
Ticket: "The sidebar is slightly misaligned on mobile"
Priority: LOW
Now classify:
Ticket: "The export CSV feature is 10x slower than usual"
Priority:
The examples teach the model the distinction you care about - in this case, that “completely blocked” login is HIGH not CRITICAL (no data loss, no security issue), and that “slightly misaligned” is LOW despite being user-visible.
Two or three examples usually suffice. More than five shows diminishing returns in most cases.
Chain-of-Thought
For tasks requiring reasoning, asking the model to think step-by-step before answering improves accuracy. This is particularly useful for math, logic, and tasks with multiple steps:
A customer placed an order for 3 items: $29.99, $14.50, and $8.25.
They have a 15% discount coupon and their state has 8.5% sales tax.
The coupon applies before tax.
Think through this step by step, then give the final total.
The “think through this step by step” instruction causes the model to produce reasoning tokens before the answer. Those reasoning tokens serve as a scratchpad - each step constrains what comes next, reducing errors.
For use in production where you don’t want the reasoning in your output, have the model produce the reasoning first and the answer in a specific field:
Respond in this format:
REASONING: [your step-by-step work]
ANSWER: [the final number only]
Avoiding Common Pitfalls
Ambiguous instructions: “summarize this” is ambiguous about length, format, and what to preserve. “Summarize this in exactly 2 sentences, capturing the main conclusion and the key supporting evidence” is not.
Negatives that don’t work: “don’t include personal opinions” often fails because the model has to generate text that avoids opinions while producing other content, which is hard. Better: “stick to facts, cite specific data points, no interpretive language.”
Asking for too much at once: a single prompt that asks for 8 different analyses produces lower quality on each than 8 focused prompts. Complex tasks benefit from decomposition.
Assuming the model remembers: in a stateless API call, the model has no memory of previous calls. Every call must include all necessary context. Don’t reference “the code I showed you earlier” across separate API calls.
Temperature for Reliability
For programmatic use where you need consistent, accurate output: use temperature 0 or close to it. This makes the model deterministic (or near-deterministic) and maximizes the probability of getting the most likely token at each step.
For tasks where variety is the point - generating multiple options, creative writing, brainstorming - higher temperature (0.7-1.0) produces more diverse output.
Don’t use high temperature when you need structured output. Randomness plus structured format requirements equals malformed JSON.
Validating Output
Even well-engineered prompts produce occasional bad output. Always validate:
import json
from pydantic import BaseModel, ValidationError
class TicketAnalysis(BaseModel):
sentiment: Literal["positive", "negative", "neutral"]
urgency: int # 1-5
requires_followup: bool
summary: str
def analyze_ticket(ticket_text: str) -> TicketAnalysis | None:
response = call_llm(build_prompt(ticket_text))
try:
data = json.loads(response)
return TicketAnalysis(**data)
except (json.JSONDecodeError, ValidationError):
# Log the bad output, retry or return None
return None
Pydantic (Python) or Zod (TypeScript) make validation concise. Structured output APIs from model providers reduce but don’t eliminate the need for validation.
Prompt Versioning
Prompts are code. They should be version-controlled, tested, and changed deliberately. A prompt that worked well last month might not work well after a model update, or might work differently with a different model.
Store prompts in your codebase (not hardcoded in application logic), track changes with comments about why they changed, and test prompt changes against a set of known inputs and expected outputs before deploying.
Testing Your Prompts
A prompt in production that has never been tested is the same as a function in production that has never been tested. It will behave correctly on the inputs you thought about and incorrectly on the inputs you didn’t.
The minimum test suite for a production prompt: a handful of representative inputs with expected outputs, including at least one edge case and one case where the correct answer is “I don’t know” or a default.
PROMPT_TEST_CASES = [
{
"input": "The app keeps crashing on upload",
"expected_sentiment": "negative",
"expected_urgency_min": 4,
},
{
"input": "Love the new design!",
"expected_sentiment": "positive",
"expected_urgency_max": 2,
},
{
"input": "hi", # edge case: uninformative input
"expected_sentiment": "neutral",
},
]
Run these on any prompt change. Run them on any model update. The output of an LLM call is not deterministic (unless temperature=0), so test against ranges and constraints, not exact strings.
Prompt changes should go through the same review process as code changes: what did the prompt do before, what does it do now, do the test cases still pass? A prompt is a dependency. When it changes, things break. Version control and test coverage make this manageable rather than surprising.
The investment in prompt engineering pays off in proportion to how much you use LLMs in production. A one-off script doesn’t need this rigor. A system that classifies thousands of customer support tickets daily absolutely does.