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:
The capital of France is" Paris"99.98%"巴黎"0.019%"Paris"0.001%" Brussels"0.000%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:
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.
"巴黎" 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
Figure 1: The model generates one token at a time, appending each to the context and predicting again until the response is complete
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 tokensWhy 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.
| Model | Context window | Max response |
|---|---|---|
| Claude Opus 5 | 1,000,000 | 128,000 |
| Claude Sonnet 5 | 1,000,000 | 128,000 |
| Claude Haiku 4.5 | 200,000 | 64,000 |
| GPT-5.5 | 1,050,000 | 128,000 |
| Gemini 3.1 Pro | 1,000,000 | 64,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:
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.
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:
The ocean is" a"99.20%" vast"0.74%" an"0.05%" the"0.01%The ocean is" a"89.04%" vast"7.69%" an"2.01%" the"1.05%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.
| Temperature | Behavior | Reach for it when |
|---|---|---|
| 0.0 | Takes the top token every time | Code generation, extraction, classification, factual answers |
| 0.3 | Top token almost always, occasional second choice | Technical writing, summaries, analysis |
| 0.7 | Real variety while staying coherent | Conversation, explanations, first drafts |
| 1.0 | Samples the raw distribution, unmodified | Creative writing, brainstorming, generating options |
| above 1.0 | Flattens the distribution; unlikely tokens become likely | Rarely 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.
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:
| Role | Who writes it | What it is for |
|---|---|---|
| system | You, once per request | Behavior, 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. |
| user | You, every turn | The actual request, plus any data or documents it depends on. |
| assistant | The 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.
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.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."},
],
)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."},
],
)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 response2. 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 networking3. 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) space5. 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.
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 ALLOWEDYour 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 resultComplete 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.
- 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