Lesson 5: Prompt Engineering Fundamentals | AI Engineering
Module 2: Prompt Engineering

Prompt Engineering Fundamentals

Master the art of communicating with language models through structured, intentional prompt design

Lesson 5 of 20 ⏱ 20 minutes 🎯 Intermediate

Why Prompt Engineering Matters

The difference between a mediocre AI application and a production-grade one often comes down to prompt quality. A well-engineered prompt can eliminate 90% of the post-processing code you'd otherwise write, reduce hallucinations, and produce consistent, structured outputs.

Key Insight: Prompt engineering isn't about "tricking" the model — it's about providing the right context, constraints, and examples so the model can do its best work. Think of it as writing a brief for a brilliant but literal-minded colleague.

Anatomy of a Perfect Prompt

Every production prompt should follow a consistent structure. Here's the flow from system-level instructions to final output format:

1. System Prompt

The system prompt defines the model's persona, capabilities, and boundaries. It persists across the entire conversation.

system_prompt = """You are a senior financial analyst at a Fortune 500 company.

CAPABILITIES:
- Analyze quarterly earnings reports
- Compare financial metrics across companies
- Identify trends and anomalies in financial data

CONSTRAINTS:
- Never provide investment advice or buy/sell recommendations
- Always cite specific numbers from the provided data
- If data is insufficient, say so explicitly
- Use professional, concise language"""

2. Context Block

Provide all relevant background information the model needs. Be explicit — don't assume the model "knows" your domain.

context = """COMPANY: Acme Corp (ACME)
QUARTER: Q3 2024
REVENUE: $4.2B (up 12% YoY)
NET INCOME: $890M (up 8% YoY)
OPERATING MARGIN: 21.2% (down from 22.1% in Q2)
HEADCOUNT: 45,200 (up 3,100 from Q2)
SECTOR AVERAGE MARGIN: 19.8%"""

3. Few-Shot Examples

Show the model exactly what you expect. Even 1-2 examples dramatically improve consistency.

4. Task Instruction

Be specific about what action the model should take. Use imperative verbs.

5. Output Format Specification

Define the exact structure of the expected response.

format_spec = """Respond in the following JSON format:
{
    "summary": "2-3 sentence executive summary",
    "metrics": [
        {"name": "metric name", "value": "current value", "trend": "up|down|flat", "concern_level": "low|medium|high"}
    ],
    "key_risks": ["risk 1", "risk 2"],
    "outlook": "1-2 sentence forward-looking statement"
}"""

Role-Based Prompting

Assigning a specific role to the model activates relevant knowledge patterns and adjusts response style. The key is specificity:

Weak vs. Strong Role Assignment

❌ Weak ✅ Strong
"You are a helpful assistant" "You are a staff backend engineer at a fintech startup, specializing in Python microservices and PostgreSQL optimization"
"You are a writing expert" "You are an editor at The Economist. Your style: concise, data-driven, slightly dry wit. Max sentence length: 25 words."
"You are a teacher" "You are a CS professor teaching a sophomore algorithms class. Use analogies from everyday life. Always provide Big-O complexity."
import openai

client = openai.OpenAI()

def create_expert_response(role: str, context: str, question: str) -> str:
    """Generate a response using role-based prompting."""
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {
                "role": "system",
                "content": role
            },
            {
                "role": "user",
                "content": f"CONTEXT:\n{context}\n\nQUESTION:\n{question}"
            }
        ],
        temperature=0.3  # Low temp for analytical tasks
    )
    return response.choices[0].message.content

Temperature Tuning by Task Type

Temperature controls randomness in token selection. Choosing the right value is critical for production quality:

Temperature Use Cases Behavior
0.0 Classification, extraction, math, code generation Deterministic, always picks highest-probability token
0.1 – 0.3 Summarization, Q&A, analysis, translation Mostly deterministic with slight variation
0.4 – 0.7 Conversational chat, explanations, email drafting Balanced creativity and coherence
0.8 – 1.0 Creative writing, brainstorming, story generation High variety, less predictable
1.2 – 2.0 Poetry, artistic text, wild brainstorming Very random, may lose coherence
TEMPERATURE_PRESETS = {
    "extraction": 0.0,
    "classification": 0.0,
    "summarization": 0.2,
    "analysis": 0.3,
    "conversation": 0.5,
    "creative_writing": 0.8,
    "brainstorming": 1.0,
}

def get_completion(task_type: str, prompt: str, **kwargs) -> str:
    """Route to appropriate temperature based on task type."""
    temp = TEMPERATURE_PRESETS.get(task_type, 0.5)
    
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": prompt}],
        temperature=temp,
        **kwargs
    )
    return response.choices[0].message.content
Pro tip: In production, also consider top_p (nucleus sampling). Setting temperature=0 with top_p=1 is deterministic. For most tasks, adjust temperature OR top_p, not both simultaneously.

Instruction Clarity Techniques

Ambiguous instructions produce ambiguous outputs. Here are battle-tested patterns for precise instructions:

The CLEAR Framework

  • Concrete — Use specific numbers, lengths, formats ("Return exactly 3 bullet points")
  • Limited — Define boundaries ("Only use information from the provided context")
  • Explicit — State what to do AND what not to do
  • Actionable — Use imperative verbs ("Analyze", "Extract", "Compare")
  • Referenceable — Point to specific data ("Using the revenue figures in Table 2")

Before & After: Weak vs. Strong Prompts

❌ Before (Weak):
"Summarize this article about AI."

Problems: No length constraint, no audience, no format, no focus area.

✅ After (Strong):
"Summarize this article in exactly 3 bullet points for a technical PM audience. Each bullet should: start with a bolded key term, be ≤ 25 words, and focus on business impact rather than technical details. Do not include information not present in the article."
❌ Before (Weak):
"Write me some Python code for a web scraper."
✅ After (Strong):
"Write a Python async web scraper using aiohttp and BeautifulSoup that: 1) Takes a list of URLs from a CSV file, 2) Extracts all <h1> and <h2> headings, 3) Respects robots.txt via the robotparser module, 4) Rate-limits to 2 requests/second, 5) Outputs results as JSON Lines. Include error handling for timeouts and 4xx/5xx responses. Use Python 3.11+ syntax."

Output Format Specification

Reliable AI applications need predictable output formats. Here are patterns that work:

# Pattern 1: JSON with schema enforcement
json_prompt = """Extract entities from the text below.

OUTPUT FORMAT (strict JSON, no markdown):
{
    "entities": [
        {
            "text": "exact text span",
            "type": "PERSON | ORG | LOCATION | DATE | MONEY",
            "confidence": 0.0-1.0
        }
    ],
    "entity_count": integer
}

TEXT: {user_text}"""

# Pattern 2: Structured markdown for human-readable output
markdown_prompt = """Analyze the code and provide a review.

FORMAT YOUR RESPONSE EXACTLY AS:
## Summary
[1-2 sentences]

## Issues Found
| # | Severity | Line | Description | Suggestion |
|---|----------|------|-------------|------------|
[table rows]

## Overall Score
[X/10] - [one sentence justification]"""

# Pattern 3: Delimiter-based for easy parsing
delimiter_prompt = """Generate 3 product descriptions.

Separate each description with ===SEPARATOR===
Each description must be exactly 2 sentences.
First sentence: feature highlight.
Second sentence: benefit to user."""

Common Failure Modes & Fixes

Failure Mode Symptom Fix
Hallucination Model invents facts not in context Add: "Only use information from the provided context. If unsure, say 'insufficient data'."
Format drift Output structure varies between calls Provide a concrete example. Use JSON mode. Add: "Follow this format exactly."
Verbosity Model adds unnecessary preamble/caveats Add: "Respond with only the requested output. No preamble, no explanation."
Refusal Model refuses safe requests Rephrase to clarify intent. Add context about why the request is legitimate.
Instruction ignoring Model skips constraints Move critical instructions to end of prompt. Use ALL CAPS for must-follow rules.
Context confusion Model mixes up entities in long context Use XML tags or clear delimiters: <document id="1">...</document>

🛠 Mini-Project: Prompt Testing Harness

Build a reusable harness that tests prompts against YAML-defined test cases and reports pass/fail rates.

Step 1: Define Test Cases (YAML)

# test_cases.yaml content (loaded as Python dict for demo)
test_config = {
    "prompt_template": "Classify the sentiment of this review: {text}\nRespond with exactly one word: POSITIVE, NEGATIVE, or NEUTRAL",
    "model": "gpt-4o",
    "temperature": 0.0,
    "test_cases": [
        {
            "input": {"text": "This product is amazing! Best purchase ever."},
            "expected": "POSITIVE",
            "tags": ["clear_positive"]
        },
        {
            "input": {"text": "Terrible quality. Broke after one day."},
            "expected": "NEGATIVE",
            "tags": ["clear_negative"]
        },
        {
            "input": {"text": "It's okay. Does what it says."},
            "expected": "NEUTRAL",
            "tags": ["subtle"]
        },
        {
            "input": {"text": "Not bad, but not great either. Shipping was fast though."},
            "expected": "NEUTRAL",
            "tags": ["subtle", "mixed_signals"]
        }
    ]
}

Step 2: Build the Harness

import openai
import yaml
import json
from dataclasses import dataclass
from typing import Optional
from pathlib import Path

client = openai.OpenAI()

@dataclass
class TestResult:
    input_data: dict
    expected: str
    actual: str
    passed: bool
    latency_ms: float
    tags: list[str]

def run_prompt_tests(config_path: str) -> list[TestResult]:
    """Run all test cases from a YAML config file."""
    import time
    
    with open(config_path) as f:
        config = yaml.safe_load(f)
    
    results = []
    
    for case in config["test_cases"]:
        # Format the prompt with test inputs
        prompt = config["prompt_template"].format(**case["input"])
        
        start = time.perf_counter()
        response = client.chat.completions.create(
            model=config["model"],
            messages=[{"role": "user", "content": prompt}],
            temperature=config["temperature"],
            max_tokens=50
        )
        latency = (time.perf_counter() - start) * 1000
        
        actual = response.choices[0].message.content.strip()
        passed = actual.upper() == case["expected"].upper()
        
        results.append(TestResult(
            input_data=case["input"],
            expected=case["expected"],
            actual=actual,
            passed=passed,
            latency_ms=latency,
            tags=case.get("tags", [])
        ))
    
    return results

def print_report(results: list[TestResult]) -> None:
    """Print a formatted test report."""
    total = len(results)
    passed = sum(1 for r in results if r.passed)
    
    print(f"\n{'='*60}")
    print(f"PROMPT TEST REPORT")
    print(f"{'='*60}")
    print(f"Total: {total} | Passed: {passed} | Failed: {total - passed}")
    print(f"Pass Rate: {passed/total*100:.1f}%")
    print(f"Avg Latency: {sum(r.latency_ms for r in results)/total:.0f}ms")
    print(f"{'-'*60}")
    
    for i, r in enumerate(results, 1):
        status = "✅" if r.passed else "❌"
        print(f"{status} Test {i}: expected={r.expected}, got={r.actual}")
        if not r.passed:
            print(f"   Input: {r.input_data}")
            print(f"   Tags: {r.tags}")
    
    # Tag-level analysis
    print(f"\n{'─'*60}")
    print("RESULTS BY TAG:")
    tag_results: dict[str, list[bool]] = {}
    for r in results:
        for tag in r.tags:
            tag_results.setdefault(tag, []).append(r.passed)
    
    for tag, passes in sorted(tag_results.items()):
        rate = sum(passes) / len(passes) * 100
        print(f"  [{tag}]: {rate:.0f}% ({sum(passes)}/{len(passes)})")

# Run it
if __name__ == "__main__":
    results = run_prompt_tests("test_cases.yaml")
    print_report(results)

Step 3: Iterate on Your Prompt

Run the harness, identify failing cases, adjust your prompt, and re-run. Track pass rates over time to catch regressions.

Extension ideas: Add support for regex matching, semantic similarity thresholds, multi-model comparison, and cost tracking per test run.

📌 Key Takeaways

  • Structure prompts with 5 layers: system → context → examples → task → format
  • Specific roles activate better knowledge patterns than generic "helpful assistant"
  • Match temperature to task type: 0 for extraction, 0.3 for analysis, 0.7+ for creative
  • Use the CLEAR framework: Concrete, Limited, Explicit, Actionable, Referenceable
  • Always specify output format — JSON schemas, markdown templates, or delimiters
  • Test prompts systematically with structured test cases, not ad-hoc playground experiments