LLMs & Prompt Engineering

Working with large language models and effective prompting techniques.

What Are Large Language Models?

Large Language Models (LLMs) like Claude, ChatGPT, 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.

It is worth being precise about what "predicts the next token" means, because it is not a metaphor. At every step the model outputs a score for every single token in its vocabulary (roughly 150,000 of them for a modern model), and those scores are normalized into a probability distribution that sums to 100%. The model does not pick a word. It produces a ranked distribution, and a separate sampling step chooses from it.

Here is that distribution, measured from a real model rather than illustrated. Notice how the shape changes depending on whether the question has one right answer:

Step 1: one obvious answer
Context so far: The capital of France is
" Paris"
99.98%
"巴黎"
0.019%
"Paris"
0.001%
" Brussels"
0.000%
Step 2: after appending "Paris"
Context so far: The capital of France is Paris
"."
99.97%
","
0.021%
" ("
0.003%
"\n"
0.002%

Both steps are near certainties, because there is exactly one correct capital and one natural way to end that sentence. Ask something open-ended and the same model spreads its probability across genuinely competing options:

Open-ended: many valid continuations
Context so far: The ocean is
" a"
89.04%
" vast"
7.69%
" an"
2.01%
" the"
1.05%
" home"
0.03%

Why this matters for prompting:

  • A peaked distribution means the model is effectively locked in. Temperature barely changes the output, because the runner-up is a rounding error.
  • A flat distribution is where temperature actually bites, and where two runs of the same prompt diverge.
  • A good prompt flattens fewer distributions. Every ambiguity you leave in the prompt shows up here as probability mass on continuations you did not want.
  • The model has no separate notion of "being sure". Confidence is the shape of this distribution, which is why it can be fluently and confidently wrong.
About that second candidate: "巴黎" is Chinese for "Paris". A model's vocabulary spans every language it was trained on, so the runner-up to an English answer is often the same concept in another script rather than a different fact.
Measure it yourself

None of the numbers above are estimates. You can reproduce them on a laptop CPU in a couple of minutes. The trick is that you never call .generate(): you run a single forward pass and read the distribution directly, before any sampling happens.

# pip install torch transformers
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM

MODEL = "Qwen/Qwen2.5-1.5B-Instruct"   # small enough to run on a laptop CPU
tokenizer = AutoTokenizer.from_pretrained(MODEL)

# float32 matters here: the weights ship as bfloat16, which has too few
# significant digits to tell 99.98% apart from 100% at the top of the list
model = AutoModelForCausalLM.from_pretrained(MODEL, dtype=torch.float32)
model.eval()


def next_token_probs(question, partial_answer, k=5):
    """Return the model's top-k candidates for the single next token."""
    # Instruction-tuned models expect their own chat format. Feeding a bare
    # string instead is off-distribution and badly skews the probabilities.
    prompt = tokenizer.apply_chat_template(
        [{"role": "user", "content": question}],
        tokenize=False, add_generation_prompt=True,
    ) + partial_answer

    ids = tokenizer(prompt, return_tensors="pt").input_ids

    # No sampling and no .generate(): one forward pass, then read the score
    # the model assigned to every token in its vocabulary as the next one.
    with torch.no_grad():
        logits = model(ids).logits[0, -1]

    probs = torch.softmax(logits, dim=-1)   # scores -> distribution summing to 1
    top = torch.topk(probs, k)
    return [(tokenizer.decode(i), float(p))
            for i, p in zip(top.indices, top.values)]


question = "What is the capital of France? Answer in one short sentence."

for partial in ["The capital of France is", "The capital of France is Paris"]:
    print(f"After {partial!r}:")
    for token, p in next_token_probs(question, partial):
        print(f"  {token!r:12} {p * 100:9.5f}%")
Expected Output:
After 'The capital of France is':
  ' Paris'      99.97990%
  '巴黎'           0.01902%
  'Paris'        0.00093%
  ' Brussels'    0.00008%
  ' Berlin'      0.00003%
After 'The capital of France is Paris':
  '.'           99.97250%
  ','            0.02093%
  ' ('           0.00292%
  '.\n'          0.00204%
  '<|im_end|>'   0.00093%

Measured with Qwen2.5-1.5B-Instruct in float32. A larger model would place even more mass on the top token; the shape of the distribution is what matters, not the exact digits. Frontier APIs (including Claude) do not expose these raw probabilities, which is why this uses a small open-weights model you can run locally.

Autoregressive Token Generation

Prompt tokens"The capital of France is"LLMpredict next tokenNext token"Paris" (95%)1 tokenappend, repeat

Figure 1: The model generates one token at a time, appending each to the context and predicting again until the response is complete

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.

ModelContext windowMax response
Claude Opus 51,000,000128,000
Claude Sonnet 51,000,000128,000
Claude Haiku 4.5200,00064,000
GPT-5.51,050,000128,000
Gemini 3.1 Pro1,000,00064,000

Figures as of August 2026. These move every few months, and the smaller, cheaper models in a family are often an order of magnitude tighter than the flagship: always check the provider's own model documentation rather than assuming a number you read once still holds.

Measure the usage, don't estimate it

Guessing at token counts is a common source of production surprises, and it is unnecessary: providers expose an endpoint that tells you exactly what a request will cost you in context before you send it. It bills nothing, so you can call it freely.

from pathlib import Path

import anthropic
from environs import Env

ENV_PATH = Path(__file__).resolve().with_name(".env")

env = Env()
env.read_env(str(ENV_PATH), recurse=False)

API_KEY = env.str("CLAUDE_API_KEY", None) or env.str("ANTHROPIC_API_KEY", None)
if not API_KEY:
    raise SystemExit(
        f"No API key found.\n"
        f"  looked in: {ENV_PATH} (exists: {ENV_PATH.exists()})\n"
        f"  for keys:  CLAUDE_API_KEY, ANTHROPIC_API_KEY\n"
        f"  interpreter: {__import__('sys').executable}"
    )

client = anthropic.Anthropic(api_key=API_KEY)

SYSTEM = (
    "You are a helpful support assistant for Acme Cloud. Answer using only the "
    "documentation provided. If the answer is not in the documentation, say so "
    "and offer to open a support ticket. Never invent pricing or SLA figures."
)
QUESTION = "Why am I getting a 429 on the batch endpoint and how do I fix it?"


def context_used(messages):
    """Exactly how many tokens this request will occupy, before sending it."""
    return client.messages.count_tokens(
        model="claude-haiku-4-5", system=SYSTEM, messages=messages,
    ).input_tokens


# One question, no history
turn_1 = [{"role": "user", "content": QUESTION}]
print(f"System prompt + question:      {context_used(turn_1):>7,} tokens")

# The same question after ten turns of conversation
history = []
for i in range(5):
    history.append({
        "role": "user",
        "content": f"Follow-up {i}: can you show the retry header for that case?",
    })

    history.append({
        "role": "assistant",
        "content": "The response includes a `retry-after` header giving the "
                   "number of seconds to wait. Back off for that duration, "
                   "then retry the batch.",
    })

print(f"...after 10 turns of history:  {context_used(history + turn_1):>7,} tokens")
Expected Output:
System prompt + question:           73 tokens
...after 10 turns of history:      328 tokens

Note the shape of that growth: the conversation is the thing that expands, not the prompt. Ten short turns quadrupled the request. This is why the raw number is never the interesting part.

What actually matters is the fraction

A chatbot turn is a rounding error against a million-token window. A retrieval-augmented app is not. Below is one real request: the same system prompt and question, plus six retrieved documentation pages (about 540,000 characters), measured at 136,712 tokens. Identical request, two different models:

Claude Opus 51,000,000 window
Prompt (measured)136,712 · 13.7%
Reserved for response128,000 · 12.8%
Still free735,288
Claude Haiku 4.5200,000 window
Prompt (measured)136,712 · 68.4%
Reserved for response64,000 · 32.0%
Over the limit by712
The failure this causes: on Haiku the prompt alone fits comfortably at 68% of the window. But the response has to fit in the same window, and reserving Haiku's 64,000-token maximum puts the request 712 tokens over the limit. A request can be rejected even though everything you sent fit, which is exactly the case that gets missed in testing: short answers work, and then one long answer fails.

So the usable budget is context window - tokens reserved for the response, and you either cap max_tokens to something smaller than the model's ceiling or you retrieve fewer documents. On this request, dropping Haiku's cap to 63,000 is the one-line fix.

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.

Concretely, temperature divides the scores before they are normalized: softmax(logits / T). Dividing by a number below 1 exaggerates the gaps between candidates, and dividing by a number above 1 shrinks them. Here is the same open-ended distribution from earlier in this lesson, from one identical set of scores, at three temperatures:

T = 0.5 · sharpened
Context so far: The ocean is
" a"
99.20%
" vast"
0.74%
" an"
0.05%
" the"
0.01%
T = 1.0 · unchanged
Context so far: The ocean is
" a"
89.04%
" vast"
7.69%
" an"
2.01%
" the"
1.05%
T = 2.0 · flattened
Context so far: The ocean is
" a"
46.22%
" vast"
13.58%
" an"
6.94%
" the"
5.02%

At T = 1.0 you are sampling the model's raw distribution, untouched. Below it the leader crowds everything else out; above it the runners-up become genuine contenders. Temperature never changes what the model believes, only how sharply that belief is enforced when a token is drawn.

TemperatureBehaviorReach for it when
0.0Takes the top token every timeCode generation, extraction, classification, factual answers
0.3Top token almost always, occasional second choiceTechnical writing, summaries, analysis
0.7Real variety while staying coherentConversation, explanations, first drafts
1.0Samples the raw distribution, unmodifiedCreative writing, brainstorming, generating options
above 1.0Flattens the distribution; unlikely tokens become likelyRarely useful, degrades into incoherence

What that looks like in practice: the same tagline prompt sent five times to Claude Haiku 4.5 at each end of the range.

PROMPT = "Write a six-word product tagline for a coffee shop. Output only the tagline."

def run(temperature, n=5):
    outs = []
    for _ in range(n):
        r = client.messages.create(
            model="claude-haiku-4-5", max_tokens=32, temperature=temperature,
            messages=[{"role": "user", "content": PROMPT}],
        )
        outs.append(next(b.text for b in r.content if b.type == "text").strip())
    return outs

for t in (0.0, 1.0):
    outs = run(t)
    print(f"temperature={t}  ({len(set(outs))} distinct out of {len(outs)})")
    for o in outs:
        print(f"    {o}")
    print()
Expected Output:
temperature=0.0  (1 distinct out of 5)
    Wake up to your perfect brew.
    Wake up to your perfect brew.
    Wake up to your perfect brew.
    Wake up to your perfect brew.
    Wake up to your perfect brew.

temperature=1.0  (5 distinct out of 5)
    Brew bold. Start right. Stay awake.
    Wake up to your daily perfect cup.
    Wake up to pure coffee bliss.
    Wake up to something truly exceptional.
    Wake up to your perfect brew.

Look at the last line of the second block: the temperature-0 answer still shows up at temperature 1.0. Raising temperature does not forbid the safe choice, it just stops guaranteeing it.

Temperature 0 is not a promise of identical output. It removes sampling randomness, so repeated runs usually match, as they did above. It does not make inference bit-reproducible: floating-point non-determinism, server-side batching, and mixture-of-experts routing can all still shift a token. Treat temperature 0 as "as consistent as this API gets", and if your tests require exact strings, assert on parsed structure rather than on prose.
Check whether your model still accepts it

Temperature is being retired at the frontier. Anthropic's current models reject it outright rather than ignoring it, so a request carrying a temperature that worked last year now fails closed:

import anthropic

client = anthropic.Anthropic()

for model in ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"]:
    try:
        client.messages.create(
            model=model, max_tokens=16, temperature=0.7,
            messages=[{"role": "user", "content": "Say OK."}],
        )
        print(f"{model:18} accepted")
    except anthropic.BadRequestError as e:
        print(f"{model:18} 400: {e.body['error']['message']}")
Expected Output:
claude-opus-5      400: `temperature` is deprecated for this model.
claude-sonnet-5    400: `temperature` is deprecated for this model.
claude-haiku-4-5   accepted

The reasoning-first models replaced the knob rather than keeping it: depth of thinking is now controlled by output_config.effort, and stylistic variety is requested in the prompt ("vary your phrasing across responses") instead of dialed in numerically. Smaller and older models in the same family, such as Haiku 4.5 above, still take temperature. So it remains worth understanding, but check your specific model before reaching for it.

Message Roles: System, User, Assistant

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

RoleWho writes itWhat it is for
systemYou, once per requestBehavior, persona, constraints, and output rules. Carries the most weight of anything you send, and is the right home for instructions that should hold for the whole conversation.
userYou, every turnThe actual request, plus any data or documents it depends on.
assistantThe model (you replay it)What the model said previously. The API is stateless, so you resend the full history each turn: this is what gives the model its memory of the conversation.

The stateless part surprises people. There is no session on the server. "Continuing a conversation" means sending the entire transcript again on every request, which is why conversation history is the thing that grows your token bill.

Providers disagree about where system goes. OpenAI puts it in the messages array as the first entry. Anthropic makes it a separate top-level parameter, and rejects the OpenAI shape rather than quietly accepting it. This is the single most common porting error between the two APIs.
system inside messages
# 400 on every current Claude model
client.messages.create(
    model="claude-opus-5",
    max_tokens=1024,
    messages=[
        {"role": "system",
         "content": "You are an expert Python developer."},
        {"role": "user",
         "content": "Write a function to read a JSON file."},
    ],
)
messages.0: use the top-level 'system' parameter for the initial system prompt
system as its own parameter
client.messages.create(
    model="claude-opus-5",
    max_tokens=1024,
    system=(
        "You are an expert Python developer. "
        "Always write clean, well-documented code. "
        "Include error handling in all examples."
    ),
    messages=[
        {"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."},
    ],
)
system is hoisted out; messages carries only the user/assistant turns

A system entry is not universally banned from messages, though: the newest Anthropic models accept one mid-conversation, after a user turn, to inject an operator instruction without rewriting the cached system prompt. It is the position at index 0 that is rejected, and support varies by model:

import anthropic

client = anthropic.Anthropic()

SYS = "You are an expert Python developer."
Q = "Write a function to read a JSON file."


def attempt(label, model, **kwargs):
    try:
        client.messages.create(model=model, max_tokens=16, **kwargs)
        print(f"{label:38} OK")
    except anthropic.BadRequestError as e:
        print(f"{label:38} 400  {e.body['error']['message'][:78]}")


# The OpenAI shape: system as the first message
attempt("system as messages[0]", "claude-opus-5",
        messages=[{"role": "system", "content": SYS},
                  {"role": "user", "content": Q}])

# The Anthropic shape
attempt("system as top-level param", "claude-opus-5",
        system=SYS, messages=[{"role": "user", "content": Q}])

# Mid-conversation operator instruction, after a user turn
for model in ("claude-opus-5", "claude-haiku-4-5"):
    attempt(f"system after a user turn ({model[7:]})", model, system=SYS,
            messages=[{"role": "user", "content": Q},
                      {"role": "system", "content": "Answer in one line."}])
Expected Output:
system as messages[0]                  400  messages.0: use the top-level 'system' parameter for the initial system prompt
system as top-level param              OK
system after a user turn (opus-5)      OK
system after a user turn (haiku-4-5)   400  role 'system' is not supported on this model

The takeaway is not to memorize which models allow what, but to notice that these fail loudly. A malformed message array returns a 400 with a message naming the offending index, so this class of mistake shows up the first time you run the code rather than as degraded output you have to hunt for later.

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:

This lesson teaches you to write a good prompt. Keeping one good across model upgrades, prompt edits, and real user input is a separate discipline: evaluation datasets, model-as-judge scoring, and CI pipelines that fail a build when a prompt regresses. That is the subject of Lesson 12: Prompt Engineering in Production.

Self-Consistency: Multiple Attempts

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

The catch that sinks most first attempts: you cannot vote on free-form prose. Two answers that say the same thing in different words count as two different answers, so the tally is almost always a five-way tie at 20% each. Force the model to answer in a fixed vocabulary first, then count.
from collections import Counter

import anthropic

client = anthropic.Anthropic()

# Force a one-word verdict, so the votes are actually comparable.
TEMPLATE = """Is the following Python line vulnerable to SQL injection?
Reply with exactly one word: VULNERABLE or SAFE.

{snippet}"""


def consensus(snippet, attempts=9):
    """Ask the same question N times and vote on the normalized answers."""
    votes = Counter()
    for _ in range(attempts):
        response = client.messages.create(
            model="claude-haiku-4-5",   # a model that still accepts temperature
            max_tokens=5,
            temperature=1.0,            # variation is the point here
            messages=[{"role": "user",
                       "content": TEMPLATE.format(snippet=snippet)}],
        )
        verdict = next(b.text for b in response.content if b.type == "text")
        votes[verdict.strip().upper()] += 1

    answer, count = votes.most_common(1)[0]
    return answer, count / attempts, dict(votes)


SNIPPETS = [
    'cursor.execute("SELECT * FROM users WHERE id = " + user_id)',
    'cursor.execute(f"SELECT * FROM users ORDER BY {sort}")  # sort checked against ALLOWED',
]

for snippet in SNIPPETS:
    answer, confidence, votes = consensus(snippet)
    flag = "" if confidence == 1.0 else "   <-- disputed, send to a human"
    print(f"{answer:11} confidence {confidence:>4.0%}  {votes}{flag}")
    print(f"            {snippet}\n")
Expected Output:
VULNERABLE  confidence 100%  {'VULNERABLE': 9}
            cursor.execute("SELECT * FROM users WHERE id = " + user_id)

SAFE        confidence  89%  {'SAFE': 8, 'VULNERABLE': 1}   <-- disputed, send to a human
            cursor.execute(f"SELECT * FROM users ORDER BY {sort}")  # sort checked against ALLOWED

Your vote counts will not match these exactly, and that is the point. Sampling is what generates the disagreement, so the confidence figure is itself an estimate: a second run of the code above returned 6 to 3 rather than 8 to 1 on the second snippet. What stays stable is the shape. The string-concatenation case is unanimous every time; the allowlisted-column case is contested every time, because whether it is safe genuinely depends on code the model cannot see.

That is the whole value of the technique: not the winning answer, which you could have gotten from one call, but the split. A unanimous verdict can be auto-applied; anything short of it is a queue for human review. Two limits worth keeping in mind: it costs N times as much and takes N times as long, so reserve it for decisions that justify that, and it measures agreement rather than correctness. A model can be confidently wrong nine times out of nine, which is exactly what happens on str(uuid.UUID(tok)) interpolated into a query: it votes VULNERABLE unanimously even though uuid.UUID() rejects anything that is not a well-formed UUID.

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
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.
    # Note: no temperature here. Current Claude models reject it (see the
    # Temperature section above); consistency comes from the prompt instead.
    message = client.messages.create(
        model="claude-sonnet-5",
        max_tokens=2000,
        system=system_prompt,
        messages=[
            {"role": "user", "content": user_prompt}
        ]
    )

    # Parse the response. content is a LIST OF BLOCKS, and on a thinking model
    # content[0] is a ThinkingBlock, not your text. Always select by type.
    response_text = next(b.text for b in message.content if b.type == "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')}")

    findings = result.get('findings', [])
    print(f"\nFindings ({len(findings)} total, showing 3):")
    for finding in findings[:3]:
        print(f"  [{finding['severity'].upper()}] Line {finding.get('line', '?')}")
        print(f"    Issue: {finding['issue']}")
        print(f"    Fix: {finding['recommendation']}\n")
Expected Output:
Security Analysis Results:
Score: 2/10
Summary: The code contains a critical SQL injection vulnerability due to unsanitized string interpolation in a database query, along with missing input validation and error handling.

Findings (7 total, showing 3):
  [CRITICAL] Line 3
    Issue: SQL Injection vulnerability: user_id is directly interpolated into the SQL query string using an f-string, allowing an attacker to inject arbitrary SQL code (e.g., '1 OR 1=1', '1; DROP TABLE users;--').
    Fix: Use parameterized queries/prepared statements instead of string interpolation. Example: query = "SELECT * FROM users WHERE id = %s"; db.execute(query, (user_id,)) — adjust placeholder syntax based on your DB driver (%s, ?, or :1).

  [MAJOR] Line 2
    Issue: No input validation on user_id. There is no check that user_id is of the expected type (e.g., integer) or within an acceptable range/format before use.
    Fix: Validate and sanitize user_id early, e.g., ensure it is an integer using int(user_id) with exception handling, or use type hints combined with a validation library (pydantic, marshmallow).

  [MAJOR] Line 4
    Issue: No error handling around the database call or the result access. If db.execute() raises an exception (e.g., connection failure, malformed query) or returns an empty result, result[0] will throw an unhandled IndexError.
    Fix: Wrap the database call in try/except blocks to handle DB errors gracefully, and check if result is non-empty before accessing result[0]. Return None or raise a custom NotFoundError if no user is found.

Findings vary between runs: the model is sampling, so the count and wording shift even without a temperature setting. The critical SQL injection is found every time; the lower-severity items come and go.

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