LLMs & Prompt Engineering

Working with large language models and effective prompting techniques

What Are Large Language Models?

Large Language Models (LLMs) like Claude, GPT-4, and Gemini are AI systems trained on vast amounts of text data. They learn patterns in language and can generate human-like text, answer questions, write code, and perform complex reasoning. But they're not magic, they're sophisticated pattern-matching systems that predict "what text should come next" based on your input (the prompt). Understanding how they work helps you use them effectively.

How LLMs Work (Simplified)

Understanding the basics of how LLMs work helps you write better prompts and understand their limitations:

1. Training

LLMs are trained on billions of text documents: books, websites, code, articles. They learn statistical patterns, which words and concepts tend to appear together.

2. Prediction

Given your prompt, the model predicts the most likely next tokens (words/pieces) based on patterns learned during training. It generates one token at a time.

3. Context

The model considers your entire prompt (the "context window") when generating each response. Better context = better responses.

# Conceptual view of how LLMs generate text

prompt = "The capital of France is"

# Model internally calculates probabilities:
# "Paris" -> 95% probability
# "a"     -> 2% probability
# "the"   -> 1% probability
# ...

# Model selects based on probability (and temperature setting)
output = "Paris"

# Then continues:
# "The capital of France is Paris" -> next token?
# "."     -> 70% probability
# ","     -> 15% probability
# "and"   -> 5% probability

# Final output: "The capital of France is Paris."

# This happens token by token, hundreds of times per response
Key insight: LLMs don't "know" things like humans do. They've learned patterns that help them predict useful responses. They can be confidently wrong (hallucinations) because the wrong answer might fit the pattern well.

Key LLM Concepts

Before diving into prompt engineering, understand these fundamental concepts:

Tokens: The Building Blocks

LLMs don't read words, they read tokens. A token is typically 3-4 characters or about 0.75 words. Understanding tokens helps you estimate costs and context limits.

# Example tokenization (simplified)
text = "Hello, how are you doing today?"

# Might be tokenized as:
tokens = ["Hello", ",", " how", " are", " you", " doing", " today", "?"]
# 8 tokens for 7 words

# Code often uses more tokens:
code = "def calculate_sum(a, b):"
tokens = ["def", " calculate", "_sum", "(", "a", ",", " b", "):", ]
# 8 tokens

# Common word = 1 token, rare/complex words = multiple tokens
"Python"        # 1 token
"tokenization"  # 3 tokens: "token", "ization" (or similar split)

# Rule of thumb:
# ~750 words ≈ 1,000 tokens
# 1 page of text ≈ 500-800 tokens

Why it matters: API costs are per token, and context windows have token limits. Efficient prompts = lower costs and better performance.

Context Window: The Model's Memory

The context window is the maximum amount of text (in tokens) the model can consider at once. This includes your prompt AND the model's response.

# Context window sizes (as of 2025)
models = {
    "GPT-4o": "128,000 tokens",                     # ~96,000 words
    "Claude Sonnet 4.5": "200,000 tokens",          # ~150,000 words
    "Claude Opus 4": "200,000 tokens",
    "Gemini 2.0 Pro": "2,000,000 tokens",           # Experimental
}

# Example context usage:
system_prompt = "You are a helpful assistant..."    # ~20 tokens
user_message = "Explain Python decorators..."       # ~50 tokens
conversation_history = [...]                        # ~2,000 tokens
# Total context used: ~2,070 tokens

# Remaining for response: 128,000 - 2,070 = 125,930 tokens
Important: When context fills up, older messages get "forgotten" (truncated). For long conversations, summarize periodically or use retrieval systems.

Temperature: Creativity vs Consistency

Temperature controls how "random" the model's outputs are. It affects which tokens get selected from the probability distribution.

# Temperature settings and their effects

temperature = 0.0  # Deterministic
# Always picks the highest probability token
# Same input = same output every time
# Best for: Code generation, factual Q&A, structured outputs

temperature = 0.3  # Low creativity
# Mostly picks high probability, slight variation
# Best for: Technical writing, summaries, analysis

temperature = 0.7  # Balanced (common default)
# Good mix of coherence and variety
# Best for: General conversation, explanations

temperature = 1.0  # High creativity
# More willing to pick lower probability tokens
# Best for: Creative writing, brainstorming, poetry

temperature = 1.5+  # Very high (often incoherent)
# Picks from wide distribution, can be random
# Usually not recommended

Rule of thumb: Use low temperature (0-0.3) for code and facts, medium (0.5-0.7) for general tasks, higher (0.8-1.0) for creative work.

Message Roles: System, User, Assistant

Most LLM APIs use a message-based format with different roles that serve different purposes:

# Typical message structure for LLM APIs
messages = [
    {
        "role": "system",
        "content": """You are an expert Python developer.
        Always write clean, well-documented code.
        Include error handling in all examples."""
    },
    {
        "role": "user",
        "content": "Write a function to read a JSON file."
    },
    {
        "role": "assistant",
        "content": "Here's a function to read JSON files safely..."
    },
    {
        "role": "user",
        "content": "Add support for YAML files too."
    }
]

# Roles:
# - system: Sets behavior, personality, constraints (high influence)
# - user: Your inputs and questions
# - assistant: Model's previous responses (for context)

What is Prompt Engineering?

Prompt engineering is the art and science of crafting inputs that get the best outputs from LLMs. It's about communicating clearly and providing the right context.

The Fundamental Rule of Prompting

Clear input = Clear output. Vague prompts get vague results. The more specific and structured your prompt, the better the response.

Poor Prompts

  • "Write some code"
  • "Explain this"
  • "Make it better"
  • "Fix the bug"
  • "Help me with Python"

Problem: Too vague, no context, unclear expectations

Effective Prompts

  • "Write a Python function that validates email addresses using regex"
  • "Explain how async/await works in Python with a simple example"
  • "Refactor this function to use list comprehension instead of loops"
  • "This code throws IndexError on empty lists, add error handling"

Better: Specific, contextualized, clear expectations

Core Prompting Techniques

Master these fundamental techniques to dramatically improve your results:

1. Be Specific and Explicit

Don't make the model guess what you want. State your requirements clearly.

# Vague prompt
"Write a sorting function"

# Specific prompt
"""Write a Python function that:
- Sorts a list of dictionaries by a specified key
- Handles missing keys gracefully (puts them at the end)
- Supports both ascending and descending order
- Returns a new list (doesn't modify the original)
- Includes type hints and a docstring

Example usage:
data = [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}]
result = sort_by_key(data, "age", descending=True)
"""

# Result: Much more useful, targeted response

2. Provide Context

Give the model the background information it needs to give a relevant response.

# No context
"How do I fix this error: Connection refused"

# With context
"""I'm building a Flask API that connects to PostgreSQL using psycopg2.
When I start the server, I get this error:

psycopg2.OperationalError: connection refused
    Is the server running on host "localhost" and accepting
    TCP/IP connections on port 5432?

My connection code:
conn = psycopg2.connect(
    host="localhost",
    port=5432,
    database="myapp",
    user="admin"
)

I'm running this in a Docker container.
PostgreSQL is running in a separate container.

What's causing this and how do I fix it?
"""

# Result: Can now give specific, actionable advice about Docker networking

3. Use Examples (Few-Shot Learning)

Show the model what you want with examples. This is incredibly powerful for formatting, style, and complex transformations.

# Few-shot prompting for consistent formatting
"""Convert these natural language descriptions to SQL queries.

Example 1:
Input: "Get all users who signed up in 2024"
Output: SELECT * FROM users WHERE YEAR(created_at) = 2024;

Example 2:
Input: "Count orders by status"
Output: SELECT status, COUNT(*) as count FROM orders GROUP BY status;

Example 3:
Input: "Find products cheaper than $50 sorted by price"
Output: SELECT * FROM products WHERE price < 50 ORDER BY price ASC;

Now convert this:
Input: "Get the top 5 customers by total order amount"
"""

# Result: Model follows the pattern exactly
# Output: SELECT customer_id, SUM(amount) as total
#         FROM orders
#         GROUP BY customer_id
#         ORDER BY total DESC
#         LIMIT 5;

Tip: 2-3 examples usually enough. More examples help with complex patterns but use more tokens.

4. Chain of Thought (Step-by-Step Reasoning)

For complex problems, ask the model to think through the problem step by step. This dramatically improves accuracy on reasoning tasks.

# Direct question (often wrong on complex problems)
"What's the time complexity of this function?"

# Chain of thought prompting
"""Analyze the time complexity of this function step by step:

def find_duplicates(arr):
    seen = set()
    duplicates = []
    for item in arr:
        if item in seen:
            duplicates.append(item)
        seen.add(item)
    return duplicates

Please:
1. Identify all operations in the function
2. Determine the complexity of each operation
3. Consider how they combine
4. State the final time and space complexity with explanation
"""

# Result: Model walks through analysis systematically
# 1. for loop: iterates n times
# 2. "item in seen": O(1) average for set lookup
# 3. seen.add(item): O(1) average for set insertion
# 4. duplicates.append(item): O(1) amortized
# Final: O(n) time, O(n) space
Magic phrases that help: "Let's think step by step", "Walk me through your reasoning", "Explain your thought process", "Break this down into steps"

5. Role Assignment

Assign the model a specific role or persona to get responses from that perspective.

# Role-based prompting examples

# Security reviewer
"""You are a senior security engineer reviewing code for vulnerabilities.
Analyze this authentication function and identify:
- Security vulnerabilities (OWASP Top 10)
- Potential attack vectors
- Specific recommendations to fix each issue

Code to review:
[paste code here]
"""

# Code reviewer
"""You are a strict code reviewer at a top tech company.
Review this pull request with focus on:
- Code quality and readability
- Potential bugs or edge cases
- Performance implications
- Suggestions for improvement

Be specific and cite line numbers.
"""

# Teacher/Explainer
"""You are an experienced Python instructor teaching beginners.
Explain decorators using:
- Simple, everyday analogies
- A progression from basic to more complex
- Runnable code examples after each concept
- Common mistakes beginners make
"""

6. Output Format Specification

Tell the model exactly how you want the response formatted. This is crucial for integrating LLM outputs into applications.

# Specifying JSON output
"""Analyze this customer review and extract information.

Review: "Great product but shipping took forever. The quality is amazing
though, totally worth the wait. Would buy again!"

Return a JSON object with this exact structure:
{
    "sentiment": "positive" | "negative" | "mixed",
    "score": <float 0-1>,
    "aspects": {
        "product_quality": "positive" | "negative" | "neutral" | "not_mentioned",
        "shipping": "positive" | "negative" | "neutral" | "not_mentioned",
        "value": "positive" | "negative" | "neutral" | "not_mentioned"
    },
    "would_recommend": true | false,
    "key_phrases": [<list of important phrases>]
}

Return only valid JSON, no additional text.
"""

# Result:
{
    "sentiment": "mixed",
    "score": 0.75,
    "aspects": {
        "product_quality": "positive",
        "shipping": "negative",
        "value": "positive"
    },
    "would_recommend": true,
    "key_phrases": ["great product", "shipping took forever", "quality is amazing", "worth the wait"]
}

Pro tip: For reliable JSON output, use "Return only valid JSON" and consider using the model's JSON mode if available.

Advanced Prompting Techniques

Once you've mastered the basics, these advanced techniques can help with more complex tasks:

Self-Consistency: Multiple Attempts

For important decisions, ask the model multiple times and look for consensus.

import anthropic

def get_consensus_answer(client, prompt, num_attempts=5):
    """Get multiple responses and find the most common answer."""
    answers = []

    for _ in range(num_attempts):
        response = client.messages.create(
            model="claude-sonnet-4-20250514",
            max_tokens=100,
            temperature=0.7,  # Some variation
            messages=[{"role": "user", "content": prompt}]
        )
        answers.append(response.content[0].text.strip())

    # Find most common answer
    from collections import Counter
    answer_counts = Counter(answers)
    most_common = answer_counts.most_common(1)[0]

    return {
        "answer": most_common[0],
        "confidence": most_common[1] / num_attempts,
        "all_answers": answers
    }

# Usage
result = get_consensus_answer(
    client,
    "Is this SQL vulnerable to injection? SELECT * FROM users WHERE id = " + user_id,
    num_attempts=5
)
print(f"Answer: {result['answer']}")
print(f"Confidence: {result['confidence']:.0%}")
# Answer: Yes, this is vulnerable to SQL injection
# Confidence: 100%

Iterative Refinement

Build up complex outputs through multiple steps, refining at each stage.

# Step 1: Generate initial draft
prompt_1 = """Write a Python function to parse log files.
Requirements: Extract timestamp, level, and message from each line.
Log format: [2024-01-15 10:30:45] ERROR: Database connection failed
"""

# Step 2: Review and improve
prompt_2 = """Review this code and add:
1. Error handling for malformed lines
2. Support for multiline log entries
3. Performance optimization for large files
4. Type hints

[paste code from step 1]
"""

# Step 3: Add tests
prompt_3 = """Write comprehensive unit tests for this function:
- Test normal cases
- Test edge cases (empty file, malformed lines, unicode)
- Test performance with large inputs

[paste code from step 2]
"""

# Step 4: Final review
prompt_4 = """Do a final review of this code and tests.
Check for:
- Security issues
- Potential bugs
- Code style (PEP 8)
- Documentation completeness

Provide the final, production-ready version.
"""

Structured Decomposition

Break complex tasks into smaller, manageable subtasks.

# Complex task: Build a REST API endpoint

# Instead of: "Build an API endpoint for user registration"

# Break it down:
tasks = [
    "1. Define the request schema (what data does registration need?)",
    "2. Define the response schema (what should be returned?)",
    "3. List all validation rules for each field",
    "4. Write the database model for users",
    "5. Write the endpoint handler with validation",
    "6. Add error handling for each failure case",
    "7. Write tests for success and failure cases",
]

# Execute each task sequentially, using outputs as inputs
for i, task in enumerate(tasks):
    prompt = f"""
    Context: Building a user registration API endpoint.
    Previous work: {previous_outputs if i > 0 else "Starting fresh"}

    Current task: {task}

    Please complete this task, maintaining consistency with previous work.
    """
    # Execute and store result

Complete Python Example: Using the Claude API

Here's a complete, working example of using Claude's API with good prompting practices:

import anthropic
from typing import Optional
import json

# Initialize the client
client = anthropic.Anthropic()  # Uses ANTHROPIC_API_KEY env var


def analyze_code(
    code: str,
    language: str = "python",
    analysis_type: str = "review"
) -> dict:
    """
    Analyze code using Claude with structured output.

    Args:
        code: The source code to analyze
        language: Programming language
        analysis_type: "review", "security", or "performance"

    Returns:
        Structured analysis results
    """

    # Build a detailed, specific prompt
    system_prompt = f"""You are an expert {language} code analyzer.
    Provide thorough, actionable feedback.
    Always cite specific line numbers.
    Be constructive but direct about issues."""

    analysis_prompts = {
        "review": """Perform a comprehensive code review. Analyze:
1. Code quality and readability
2. Potential bugs or edge cases
3. Best practices violations
4. Suggestions for improvement

For each issue, provide:
- Severity (critical/major/minor/suggestion)
- Line number(s)
- Description of the issue
- Recommended fix""",

        "security": """Perform a security audit. Look for:
1. Injection vulnerabilities (SQL, command, etc.)
2. Authentication/authorization issues
3. Data exposure risks
4. Input validation problems
5. Cryptographic weaknesses

Rate each finding by severity (critical/high/medium/low).""",

        "performance": """Analyze performance characteristics:
1. Time complexity of key operations
2. Memory usage patterns
3. Potential bottlenecks
4. Optimization opportunities
5. Scalability concerns"""
    }

    user_prompt = f"""{analysis_prompts.get(analysis_type, analysis_prompts["review"])}

Return your analysis as JSON with this structure:
{{
    "summary": "Brief overall assessment",
    "score": <1-10>,
    "findings": [
        {{
            "severity": "critical|major|minor|suggestion",
            "line": <number or null>,
            "issue": "Description",
            "recommendation": "How to fix"
        }}
    ],
    "positive_aspects": ["List of things done well"]
}}

Code to analyze:
```{language}
{code}
```

Return only valid JSON."""

    # Make the API call
    message = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=2000,
        temperature=0.3,  # Low temperature for consistent analysis
        system=system_prompt,
        messages=[
            {"role": "user", "content": user_prompt}
        ]
    )

    # Parse the response
    response_text = message.content[0].text

    # Handle potential JSON parsing issues
    try:
        # Find JSON in response (in case of extra text)
        start = response_text.find('{')
        end = response_text.rfind('}') + 1
        json_str = response_text[start:end]
        return json.loads(json_str)
    except json.JSONDecodeError:
        return {
            "error": "Failed to parse response",
            "raw_response": response_text
        }


# Example usage
if __name__ == "__main__":
    sample_code = '''
def get_user(user_id):
    query = f"SELECT * FROM users WHERE id = {user_id}"
    result = db.execute(query)
    return result[0]
'''

    # Security analysis
    result = analyze_code(sample_code, "python", "security")

    print("Security Analysis Results:")
    print(f"Score: {result.get('score', 'N/A')}/10")
    print(f"Summary: {result.get('summary', 'N/A')}")
    print("\nFindings:")
    for finding in result.get('findings', []):
        print(f"  [{finding['severity'].upper()}] Line {finding.get('line', '?')}")
        print(f"    Issue: {finding['issue']}")
        print(f"    Fix: {finding['recommendation']}\n")


# Output:
# Security Analysis Results:
# Score: 2/10
# Summary: Critical SQL injection vulnerability detected
#
# Findings:
#   [CRITICAL] Line 2
#     Issue: SQL injection vulnerability, user input directly interpolated
#     Fix: Use parameterized queries: db.execute("SELECT * FROM users WHERE id = ?", (user_id,))
#
#   [MAJOR] Line 3
#     Issue: No error handling for empty results
#     Fix: Check if result exists before accessing index
Key practices demonstrated:
  • Clear system prompt defining the role and behavior
  • Structured user prompt with specific instructions
  • Requested JSON output format with exact schema
  • Low temperature for consistent, factual analysis
  • Error handling for API and parsing issues

Common Prompting Pitfalls

1. Being Too Vague

Problem: "Help me with my code", The model doesn't know what kind of help you need.

Solution: Be specific about what you want: review, debug, optimize, explain, extend, etc.

2. Assuming Context

Problem: "Fix the error" without showing the error or code.

Solution: Always include relevant code, error messages, and context. The model can't see your screen.

3. Overloading Single Prompts

Problem: Asking for 10 different things in one prompt, getting mediocre results for all.

Solution: Break complex requests into focused, sequential prompts. Quality over quantity.

4. Not Iterating

Problem: Accepting the first response without refinement.

Solution: Treat LLM interaction as a conversation. Ask follow-ups, request modifications, dig deeper.

5. Ignoring Model Limitations

Problem: Asking about events after the training cutoff, or expecting perfect accuracy.

Solution: Verify important facts. Provide current information when needed. Don't trust blindly.

6. Prompt Injection Vulnerabilities

Problem: Putting untrusted user input directly into prompts without sanitization.

Solution: Always validate and sanitize user inputs. Use separate system/user messages. Never trust user-provided "instructions."

Reusable Prompt Templates

Here are battle-tested templates for common development tasks:

Code Review Template
"""Review this {language} code for a {context/project type}.

Focus areas:
1. Bugs and potential errors
2. Security vulnerabilities
3. Performance issues
4. Code style and readability
5. Missing error handling

For each issue found:
- State the severity (critical/major/minor)
- Quote the problematic code
- Explain why it's an issue
- Provide a corrected version

Code:
```{language}
{code}
```
"""
Debugging Template
"""I have a bug in my {language} code.

**Expected behavior:** {what should happen}
**Actual behavior:** {what actually happens}
**Error message (if any):** {error}

**Relevant code:**
```{language}
{code}
```

**What I've tried:**
- {attempt 1}
- {attempt 2}

**Environment:** {Python version, OS, relevant packages}

Please:
1. Identify the root cause
2. Explain why this bug occurs
3. Provide a working fix
4. Suggest how to prevent similar bugs
"""
Concept Explanation Template
"""Explain {concept} for someone who knows {prerequisite knowledge}.

Please include:
1. A simple definition (1-2 sentences)
2. Why it matters / when to use it
3. A simple analogy or mental model
4. A basic code example with comments
5. A more advanced example showing real-world usage
6. Common mistakes to avoid
7. Related concepts to explore next

Use {language} for all code examples.
"""
Refactoring Template
"""Refactor this code with the following goals:

**Goals:** {e.g., improve readability, reduce complexity, add type hints}
**Constraints:** {e.g., maintain API compatibility, don't add dependencies}

**Current code:**
```{language}
{code}
```

Please:
1. Provide the refactored code
2. Explain each significant change
3. Note any trade-offs in your approach
4. Highlight if any behavior changed
"""

Key Takeaways

  • LLMs predict tokens based on patterns - They don't "know" things, they predict likely continuations
  • Context is everything - Provide relevant information, examples, and constraints
  • Be specific and explicit - Vague prompts get vague results
  • Use examples (few-shot) - Show what you want, especially for formatting
  • Chain of thought helps reasoning - Ask for step-by-step analysis on complex problems
  • Assign roles - "You are an expert X" frames the response appropriately
  • Specify output format - Essential for integrating LLM outputs into code
  • Iterate and refine - First response is a starting point, not final answer
  • Temperature matters - Low for code/facts, higher for creativity
  • Always verify - LLMs can be confidently wrong; check important outputs
What's Next?

You now understand how LLMs work and how to communicate with them effectively. In the next lessons, we'll cover:

  • AI-Powered Development Tools - Practical guide to GitHub Copilot, Cursor, and more
  • Building AI Applications - Integrating LLMs into your software projects
  • MLOps & Deployment - Running AI systems in production
  • Ethics & Best Practices - Responsible AI development
Software Engineering in AI EraLesson 3