AI EngineeringZero to ProductionHome·About·What’s new·Contact
Part I · Chapter 2

Prompting & Structured Output

The prompt is your program. In this chapter you'll learn to write prompts that are reliable and testable, then make the model return machine-parseable JSON you can trust downstream — the skill that turns a demo into a system.

⏱️ ~60 min🧪 4 labs🎯 Beginner→Intermediate
⚙️ To run this for realThe code here is complete and correct as written. To actually execute it you'll need an API key for whichever vendor you follow (toggle the Claude | OpenAI tabs on the code below):Either way, keep the key in a .env file (never in code) and let the zero-argument client pick it up from the environment.Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.

Learning objectives

  • Structure a system prompt with the five-part pattern.
  • Use few-shot examples to steer format and tone.
  • Control quality vs. cost with effort and adaptive thinking.
  • Force schema-valid JSON with Pydantic and parse it safely — no regex.
  • Build a robust text classifier as a real deliverable.

The five-part system prompt advanced

Here's a scene you'll recognize the moment you ship your first LLM feature. The demo worked beautifully — you typed a clever instruction into the playground, the model nailed it, everyone clapped. Then real traffic arrives, the instruction is buried in a wall of pasted-together strings, and the model starts ignoring half of what you asked. Nobody changed the model; you changed the prompt — or rather, you never gave it a structure in the first place. The five-part pattern is the structure that turns a lucky one-off into something that behaves the same on request number one and request number ten thousand.

Think of a system prompt the way you'd think of briefing a new contractor on their first morning. You don't lead with today's ticket. You start with who they are and what they're here to do ("you triage support tickets"), then the house rules ("always reply in this format, never guess"), then what tools are on the bench and when to touch them, then a couple of worked examples of a job done right — and only then do you hand them the actual task in front of them. Reverse that order and the contractor reads the specific task before they know the rules, and the rules land as an afterthought. Models read top-to-bottom the same way: whatever frames the work has to come before the work.

The common mistake is treating the prompt as one undifferentiated blob of text — role, rules, examples, and the user's message all mashed together in whatever order they were written. It feels fine because a strong model often copes anyway. The damage is invisible until it isn't: instructions get contradicted by later text, edits to one concern accidentally break another, and — the part that costs real money — you lose prompt caching. Caching only helps when a long, stable prefix stays byte-for-byte identical across calls; if your fixed instructions are tangled up with the per-request user input, nothing is stable enough to cache and you pay full price every time. Separating the stable parts from the volatile ones is what makes the discipline below pay for itself twice: better instruction-following and a cacheable prefix.

So the pattern is less a rigid template than a discipline of ordering by how often each part changes: role and objective almost never change, constraints rarely, tool rules occasionally, examples now and then — and the retrieved documents plus the user's message change on every single call. Stack them stable-to-dynamic and both goals fall out for free. A production system prompt almost always has these parts, in this order:

order matters — stable rules first, dynamic input last 1 · Role & objective 2 · Constraints 3 · Tools & when to use 4 · Few-shot examples 5 · Dynamic context (LAST) retrieved docs / user input go at the very end Stack it stable-to-dynamic. Fixed instructions (role, constraints, tools, examples) come first and can be prompt-cached; the changing per-request content (retrieved docs, the user's message) goes last. This ordering both prompts better and maximizes cache hits (Ch 6).
🗺️ How to read this diagram

This diagram shows the recommended order for building a system prompt. Order matters because the model reads top-to-bottom, and stable instructions should anchor it before variable input arrives.

  • Read it top to bottom: the most stable, fixed instructions (the assistant's role, hard rules, available tools, examples) come first.
  • The most dynamic content — retrieved documents and the user's actual input — goes at the very end.
  • The caption's rule of thumb: stable-to-dynamic. Fixed guidance up top gives the model a consistent frame; the changing details come last where they're freshest.

In short: Think of it like a job briefing: you explain someone's role and the rules first, then hand them today's specific task last. Same order for a prompt.

#PartExample
1Role & objective"You are a support-ticket triager. Classify and route incoming tickets."
2Constraints"Respond in JSON only. Never invent a category outside the enum."
3Tools & when to use them"Call escalate only when the user reports data loss." (Chapter 4)
4Few-shot examples2–5 input→output pairs showing the exact desired format.
5Dynamic context (last)Retrieved docs / the user's actual input — always at the end.
Modern-model promptingToday's models follow instructions literally. Older-style shouting ("CRITICAL!! YOU MUST ALWAYS…") now causes over-triggering. Write calm, precise instructions. State the reason behind a rule — the model uses intent to generalize correctly. Prefer positive examples of what you want over long lists of prohibitions.

Lab 2.1 · Build and iterate on a prompt advanced

You don't write a good prompt; you iterate one. The lab below walks a naive version to a specified one — each round adds structure, tested against the same fixed input so the comparison is fair. The diagram shows that loop.

v1 · naive "Summarize this." v2 · specified role + 3 bullets + <12 words + [BREAKING] add role, constraints, format fixed test input same input → both versions → eyeball the diff Iterate, don't guess. A vague v1 produces inconsistent output; adding role, constraints, and an explicit format gives v2. Running both on one fixed input turns "it feels better" into a fair, repeatable comparison.
🗺️ How to read this diagram

Read it left→right along the top arrow: the prompt evolves from naive to specified; then look down to the shared test input both versions run against.

  • The red box on the left is v1 — a one-line "Summarize this." It runs, but length, tone, and structure drift every time.
  • The solid brand arrow is the edit you make: adding a role, hard constraints, and an explicit output format produces the green box, v2.
  • The dashed lines feed the same fixed test input into both versions — that controlled comparison (not vibes) is how you tell which prompt is actually more consistent.

In short: Change one thing (the prompt), hold the input fixed, compare the outputs — the manual precursor to the automated evals in Chapter 5.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Lab 2.1

We'll build a "release-notes summarizer." Start naive, then improve it.

  1. Naive v1 — vague, you'll see the problem.
    summarize.pysystem = "Summarize this changelog."

    Run it on a messy changelog and you'll get inconsistent length, tone, and structure every time.

  2. v2 — add role, constraints, and format.
    summarize.pysystem = (
        "You are a release-notes editor for a developer audience.\n"
        "Summarize the changelog into exactly three bullet points.\n"
        "Each bullet: <12 words, start with a verb, no marketing language.\n"
        "If a change is breaking, prefix that bullet with [BREAKING]."
    )
  3. Test with a fixed input so you can compare prompt versions fairly. Same input, tweak the prompt, eyeball the diff. This is manual eval — Chapter 5 automates it.
Prompts are versioned artifactsGive each prompt an ID and version. Log the version on every request so that when quality shifts, you know which prompt produced it. Never bury prompts as anonymous string literals scattered through code.

Lab 2.2 · Few-shot examples advanced

Try to describe, in words, exactly how you'd like a date formatted. "Use the day, then the month, then the year, with dashes… no wait, sometimes the month is a word…" — you'll write a paragraph and still leave gaps. Now instead just show two dates the way you want them: 2026-03-09, 2026-11-21. Done. The reader infers the rule instantly. That gap — between how painful it is to specify a format and how easy it is to demonstrate one — is the entire reason few-shot prompting exists.

Few-shot means you hand the model a handful of worked examples (the "shots") before the real question, and let it infer the pattern rather than reading a description of it. It's the difference between teaching someone to make an omelette by reciting a recipe versus letting them watch you crack, whisk, and fold two of them first. Humans and models both learn a format far faster from a couple of concrete demonstrations than from prose that tries to name every rule. When you catch yourself writing a long, hedge-filled description of the output you want ("mostly lowercase, but capitalize proper nouns, and if there's no category use 'other', and…"), that's the signal to stop describing and start showing.

The mechanical detail that trips people up: the examples are not decoration you paste into the system prompt — they live as actual prior turns in the messages array, alternating user (the example input) and assistant (the ideal answer). You're literally faking a short conversation that already went well, so the model's most natural move is to continue the established pattern. The single most common beginner error is dumping the examples as one blob of text; formatting them as real turns is what makes the model treat them as a demonstration to imitate rather than trivia to read.

A couple of honest cautions before the lab. Examples are not free: every one of them is tokens you pay for and send on every call, so five carefully chosen examples beat twenty lazy ones. And the examples teach — including their mistakes. If every example you show happens to be a high-severity bug, don't be surprised when the model labels a trivial typo as "high." Pick examples that span the variety you actually expect, especially the tricky edge cases you most want handled correctly. When you need a specific format or edge-case handling, show don't tell. Examples live as prior turns in the messages array.

example turns (the "shots") user · "server crashed…" assistant · bug|high|auth user · "add dark mode" assistant · feature|low|ui user · "charged twice" (real) bug | high | billing model imitates the established pattern → Fake a conversation that already went well. The examples are real prior user/assistant turns, not prose. The final lone user turn has no answer, so the model's most natural move is to continue the pattern.
🗺️ How to read this diagram

Read the left column top to bottom as a transcript, then follow the arrow to the answer the model generates.

  • Each blue "user" box is an example input, immediately answered by a green "assistant" box in the exact type | severity | area format you want.
  • The amber box at the bottom is the real input — a lone user turn with no assistant reply following it.
  • The arrow is the completion: because the pattern is already established above, the model produces bug | high | billing in the same shape — a format you never described in words.

In short: Alternating user/assistant pairs teach the format by demonstration; the trailing lone user turn is the slot the model fills, imitating what it just saw.

Lab 2.2
fewshot.pymessages = [
    {"role":"user",      "content":"Server crashed on null input to /login"},
    {"role":"assistant", "content":"bug | high | auth"},
    {"role":"user",      "content":"Please add dark mode"},
    {"role":"assistant", "content":"feature | low | ui"},
    {"role":"user",      "content":"Billing charged me twice this month"},  # real input
]
# The model continues the pattern: "bug | high | billing"
▶ How this works

"Few-shot" prompting means teaching by example. Instead of describing the format in words, you show the model a few input→output pairs, then give it a real input and let it continue the pattern. It's often the fastest way to get a consistent format.

  1. The messages list alternates user (an example input) and assistant (the ideal answer for it). Here each answer is type | severity | area.
  2. Two complete examples establish the pattern: a crash → bug | high | auth, a request → feature | low | ui.
  3. The final user message ("Billing charged me twice") has no assistant answer — so the model produces one, imitating the examples: bug | high | billing.

What the output means: The model returns bug | high | billing — it copied the exact format from your examples without you ever describing the format in words.

Try this: Add a third example in a different area (say performance) and see the model's labels get more accurate. More, varied examples usually help — up to a point.

Few-shot is powerful but has a cost: those example tokens are sent every call. For strict formats, structured output (below) is usually cleaner and cheaper than many examples.

Effort & adaptive thinking expert

Imagine paying a senior consultant their full hourly rate to answer "is the office open on Monday?" They'd give you a perfect, thoroughly-reasoned answer — and you'd have wildly overpaid for a yes/no. Now imagine handing that same consultant a five-minute glance at a critical database migration. You'd get a fast, shallow answer to a question that deserved an afternoon of thought. Both are the same mistake in opposite directions: the amount of thinking didn't match the difficulty of the task. Effort and adaptive thinking are the dials that let you match the two, and getting this right is one of the highest-leverage cost skills in the whole course.

Here's what "thinking" actually is, plainly: modern models can do a stretch of hidden reasoning before they write their visible answer — scratch work, like you jotting on paper before committing to a solution. That scratch work genuinely improves answers on hard problems, but it costs tokens and time, and on an easy problem it's pure waste. So you're not choosing whether the model is "smart"; you're choosing how much scratch work it's allowed to do, which is really a choice about spend and latency versus depth.

The two dials do slightly different jobs, and confusing them is the common trip-up. Adaptive thinking (thinking={"type":"adaptive"}) hands the model the judgement call — it looks at how hard the question is and decides how much to reason on its own, so easy questions stay cheap and hard ones get more effort without you micromanaging each call. Effort (output_config={"effort": ...}) is your ceiling on top of that — a coarse low/medium/high/max dial that says how much you're willing to spend overall. Think of adaptive thinking as the model's own throttle and effort as the speed limit you post; the model reasons freely up to the limit you set.

The instinct to reach for the biggest, most-thinking setting "just to be safe" is the exact instinct to resist. On a classification or routing task, high effort buys you nothing but a bigger bill and a slower response — the answer was never in doubt. The skill is boring and valuable: cheap dials for cheap tasks, expensive dials only where correctness genuinely beats cost. One more thing worth flagging up front — the API for this changed, and old tutorials will lead you astray, which the callout below covers. Two dials trade cost/latency against quality:

effort = the ceiling you set · adaptive = the model reasons freely up to it effort:low medium high / max cost + latency → easy Q → little thinking hard Q → more thinking visible answer right depth, right price Two dials, one goal. effort (low→max) is the spending ceiling you set; adaptive thinking lets the model choose how much hidden reasoning it actually needs under that ceiling — cheap on easy questions, deeper on hard ones.
🗺️ How to read this diagram

Read left→right: your ceiling on the left, the model's own judgement in the middle, the answer on the right.

  • The left stack of blue boxes is the effort dial — low, medium, high/max. Lower down means more cost and latency; this is the ceiling you post.
  • The middle boxes are adaptive thinking deciding, per question, how much scratch work to do: little for an easy question (green), more for a hard one (amber) — always within your ceiling.
  • The arrows converge on the visible answer: match the two and you get the right depth at the right price — the whole skill of this section.

In short: effort is the speed limit you set; adaptive thinking is the model's throttle. Don't pay high effort for classification — spend it only where correctness beats cost.

Setting the effort dial — in Claude or OpenAI

Both vendors expose the same idea — let the model think, but cap how hard. In Claude you pass output_config={"effort": …} (with thinking={"type":"adaptive"} so the model self-throttles); in OpenAI the dial is reasoning={"effort": …} on a reasoning model, which adapts within that ceiling on its own. Both accept low | medium | high | max. Use the tabs to switch SDK — your choice applies across the whole page.

effort.pyresp = client.messages.create(
    model="claude-opus-4-8", max_tokens=1024,
    thinking={"type": "adaptive"},         # model decides how much to reason
    output_config={"effort": "medium"},  # low | medium | high | max
    messages=[{"role":"user","content":"Plan a database migration strategy."}],
)
SettingUse for
effort: "low"Classification, routing, simple extraction — fast & cheap
effort: "medium"Most application work — the balanced default
effort: "high" / maxHard reasoning, agents, code — when correctness beats cost
Deprecated patternYou may see thinking:{type:"enabled", budget_tokens:N} in old tutorials. On current models that's replaced by adaptive thinking + effort, and a fixed budget is rejected on the newest models. Don't learn the old way.
▶ How this works

Newer models can think before answering. These two settings control how much hidden reasoning the model does — more thinking helps on hard problems but costs more tokens and time.

  1. thinking={"type": "adaptive"} lets the model decide on its own how much to reason based on how hard the question is.
  2. output_config={"effort": "medium"} sets the overall effort dial — low for easy/cheap, up to max for the hardest problems.
  3. The prompt here ("Plan a database migration strategy") is genuinely hard, so spending effort produces a more careful plan than a quick answer would.

Try this: Set effort to "low" then "high" on the same hard prompt and compare the depth of the answer against the token count in resp.usage. That trade-off — quality vs cost — is a theme of the whole course.

Why structured output matters expert

Picture the 2am page. Your classifier has been humming along for weeks, its output scraped by a regex that plucks the priority out of the model's reply. Then one night the model, entirely reasonably, answers "Sure! The priority here is high 😊" instead of the "Priority: high" your regex expected — and the parse returns nothing, the ticket routes nowhere, and your phone lights up. Nothing broke on your side. The model just rephrased. That fragility — a system that depends on the model choosing the same words twice — is exactly what structured output exists to eliminate.

The core idea is a shift in who guarantees the shape of the data. With free text, you're hoping the model happens to phrase things the way your parser expects, then writing defensive code to cope when it doesn't — you're guaranteeing the shape after the fact, badly. With structured output you flip it: you declare a schema up front, and the shape becomes a constraint the model must satisfy while generating, not a pattern you fish for afterward. It's the difference between asking a stranger to "write down your details somewhere on this page" and handing them a form with labelled boxes. The form can't be filled in wrong shape; the blank page can be filled a thousand ways.

Why this suddenly matters so much: the instant the model's output stops being read by a human and starts feeding another program — a database insert, an API call, a UI that branches on the value, the next step in an agent — free text becomes a genuine liability rather than a mild annoyance. A human reader shrugs off an emoji or an extra sentence of preamble; a downstream function chokes on it. The regex you'd write to cope is brittle by nature: it encodes assumptions about phrasing that the model was never promised to keep, so it works in your tests (where you saw three phrasings) and fails in production (where it meets the fourth).

The most common mistake here isn't writing a bad regex — it's reaching for a regex at all. Every hour spent hardening a pattern to survive the model's rewordings is an hour spent solving a problem you could have deleted. Constrain the output to a schema and the whole class of "the model said it differently this time" bugs simply stops existing; you get back a typed object you can use directly, with no parsing step to break. The moment another program consumes the model's output — a database, an API, a UI — free text is a liability. You'll write brittle regex, it'll break on the model's next phrasing, and you'll page someone at 2am. Instead, constrain the output to a schema the model must satisfy.

✗ Free text → regex "Sure! The priority is high, category billing 😊" breaks on rephrasing, emoji, extra words ✓ Schema → parsed object {"priority":"high", "category":"billing"} validated, typed, safe to use directly

Lab 2.3 · Schema-constrained JSON with Pydantic expert

Here the abstract idea becomes concrete: you declare a Pydantic model, pass it as output_format, and get a real typed object back — no parsing step to break. The diagram traces that one-way path from your class to a validated instance.

Ticket(BaseModel) category: Literal[…] priority · summary … JSON schema enum + types the model MUST satisfy Ticket instance t.category, t.priority validated · typed SDK derives .parse() returns you define the shape once → the model can't return the wrong shape Declare the shape, don't fish for it. Your Pydantic class becomes a JSON schema the model is constrained to satisfy while generating; .parse() hands back a validated Ticket object you use directly — a Literal field literally cannot come back out of set.
🗺️ How to read this diagram

Read it left→right as a one-way pipeline from your code to a guaranteed-valid object.

  • The left blue box is your Ticket Pydantic model — the contract, including a Literal[...] field that pins category to a fixed set.
  • The middle box is the JSON schema the SDK derives from it: an enum plus types the model must satisfy while it generates, not a pattern you match afterward.
  • The green box is what .parse() gives back — a real, validated Ticket instance whose fields you read directly, with no regex and no surprise values.

In short: Model → schema → typed object. Constraining the output deletes the entire class of "the model phrased it differently this time" bugs.

Lab 2.3

Define the shape you want as a Pydantic model; the SDK enforces it and hands you a typed object.

Schema-constrained parsing — in Claude or OpenAI

The Ticket model is identical for both vendors — only the call differs. In Claude you call client.messages.parse(output_format=Ticket) and read resp.parsed_output; in OpenAI the same thing is client.responses.parse(text_format=Ticket) and you read resp.output_parsed. Either way the model is constrained to your schema. Toggle the tab to switch SDK.

extract.pyfrom dotenv import load_dotenv
from anthropic import Anthropic
from pydantic import BaseModel
from typing import Literal
load_dotenv()
client = Anthropic()

class Ticket(BaseModel):
    category: Literal["bug","feature","billing","other"]
    priority: Literal["low","medium","high"]
    summary: str
    needs_human: bool

resp = client.messages.parse(               # .parse() validates for you
    model="claude-opus-4-8", max_tokens=512,
    messages=[{"role":"user",
        "content":"I was charged twice and support hasn't replied in 3 days!"}],
    output_format=Ticket,
)

t = resp.parsed_output                      # a real Ticket instance
print(t.category, t.priority, t.needs_human)
print(type(t))                              # <class 'Ticket'>
billing high True
<class '__main__.Ticket'>
Why this is safeThe Literal types become an enum in the JSON schema — the model literally cannot return a category outside your set. No validation code, no regex, no surprise values. This is the backbone of every reliable pipeline.

Lab 2.4 · Ship a real classifier expert

Combine everything into a deliverable: a batch text classifier that returns typed results and never crashes on bad output. The diagram shows the flow every item takes — including the fail-safe branch that turns a bad response into data instead of a crash.

feedback text "Love it!" … messages.parse() haiku · try/except Sentiment label + confidence None caller decides valid → typed object · error/refusal → None (never a crash) Fail safe, return data. Each text is validated into a typed Sentiment; if the call errors or the output can't validate, the try/except returns None instead of throwing — so a bad response is a value the caller handles, not a crash.
🗺️ How to read this diagram

Follow one item left→right through the pipeline, then note the two possible endings on the right.

  • The left blue box is one piece of feedback going in ("Love it!").
  • The middle box is the messages.parse() call on a small/cheap model, wrapped in try/except — cheap because classification is easy work.
  • The two branches are the payoff: the green box is the happy path (a validated Sentiment with label + confidence); the red box, reached by the dashed error edge, is the fail-safe None the caller decides how to handle.

In short: Text → parse → either a typed object or a clean None. Because failure is data, the batch never crashes — the first property a production classifier needs.

Lab 2.4

A fail-safe typed classifier — in Claude or OpenAI

The Sentiment model, the try/except fail-safe, and the cheap-model choice are the same in both SDKs. In Claude you call client.messages.parse(output_format=Sentiment) → r.parsed_output and catch anthropic.APIError; in OpenAI it's client.responses.parse(text_format=Sentiment) → r.output_parsed catching openai.APIError. Toggle the tab to switch SDK.

classifier.pyfrom pydantic import BaseModel
from typing import Literal
import anthropic
from anthropic import Anthropic
client = Anthropic()

class Sentiment(BaseModel):
    label: Literal["positive","neutral","negative"]
    confidence: float

SYSTEM = (
    "You classify customer feedback sentiment. "
    "Confidence is your certainty from 0.0 to 1.0."
)

def classify(text: str) -> Sentiment | None:
    try:
        r = client.messages.parse(
            model="claude-haiku-4-5",      # small model — this is easy work
            max_tokens=128, system=SYSTEM,
            messages=[{"role":"user","content":text}],
            output_format=Sentiment,
        )
        return r.parsed_output
    except (anthropic.APIError, ValueError):
        return None                          # caller decides how to handle

for fb in ["Love it!", "It's fine I guess", "Worst update ever"]:
    s = classify(fb)
    print(f"{fb:20} -> {s.label} ({s.confidence:.2f})" if s else f"{fb}: FAILED")
Love it!             -> positive (0.98)
It's fine I guess    -> neutral (0.72)
Worst update ever    -> negative (0.99)
▶ How this works

This is a small but complete, production-shaped feature: classify feedback as positive/neutral/negative with a confidence score, guaranteed to come back as clean, validated data — not a paragraph you have to parse by hand.

  1. class Sentiment(BaseModel) uses Pydantic to declare the exact shape you want back: a label that must be one of three words (Literal[...]) and a confidence number. This is your contract.
  2. SYSTEM tells the model its job. classify(text) calls the SDK's .parse method — client.messages.parse(..., output_format=Sentiment) in Claude, client.responses.parse(..., text_format=Sentiment) in OpenAI (toggle the tab above) — which makes the model return data matching your Sentiment shape and hands you a real Python object (r.parsed_output / r.output_parsed).
  3. Note the small, cheap model (claude-haiku-4-5) — classification is easy work, so you don't pay for a big model. The try/except returns None on failure so the caller can decide what to do.
  4. The loop runs three sample feedbacks through classify and prints each label with its confidence, using the f-string alignment you learned in P1.

What the output means: Three aligned lines, e.g. Love it! -> positive (0.98) — structured, typed results you could store in a database or branch on directly.

Try this: Change one input to something ambiguous like "it's okay I guess" and watch the confidence drop. Low confidence is exactly the signal the course uses to route tricky cases to a human.

Notice the model choiceSentiment is easy — we used a small fast model, not the frontier one. Matching model tier to task difficulty is a core cost skill (Chapter 6).

Common pitfalls expert

PitfallFix
Parsing free text with regexUse a schema (output_format) — never regex model output
Aggressive "YOU MUST" prompts over-triggeringCalm, literal instructions; state the reason behind rules
Prompts as scattered string literalsCentralize + version them; log the version per request
Assuming schema output is always completeStill check max_tokens/refusal — truncation won't match schema
Using the frontier model for trivial workRoute easy tasks to a small model

Exercises expert

Exercise 2.1 — Extraction schema

Context: Extracting structured fields from a messy sales email is a canonical structured-output task — and real emails omit fields, so the schema has to tolerate absence.

Your task: Write a Pydantic model plus call that extracts name, email, company, and interested_products (a list) from a sales email, handling missing fields gracefully by making them Optional.

Requirements:

  • Declare the four fields on a Pydantic BaseModel
  • Optional fields use Optional[...] with a default of None
  • interested_products is a list type
  • Pass the model via output_format= and read parsed_output
  • Missing fields populate as None rather than raising

💡 Hint: from typing import Optional; a default of None lets the model null out what it can't find and fill the rest.

Show hint

Use from typing import Optional; give optional fields a default of None. The model will populate what it can find and null the rest.

Exercise 2.2 — Prompt A/B

Context: “It feels better” is not evaluation. Running two prompt versions over the same fixed inputs is the manual precursor to Chapter 5's automated evals.

Your task: Take your Lab 2.1 summarizer, write two prompt versions, and run both over the same five changelogs; decide which is more consistent, keep the winner, and note why.

Requirements:

  • Hold the five changelog inputs fixed across both prompt versions
  • Vary only the prompt so the comparison is fair
  • Judge on output consistency (length, tone, structure)
  • Pick a winner and write a one-line rationale
  • Frame it as the manual version of an eval

💡 Hint: Same inputs, different prompt, eyeball the diff — that controlled comparison is exactly what an eval automates.

Exercise 2.3 — Confidence routing

Context: The confidence score a classifier returns is only useful if you act on it. Routing low-confidence items to human review is the simplest possible guardrail.

Your task: Extend the Lab 2.4 classifier: if confidence < 0.6, route the item to a “needs human review” list instead of trusting the label.

Requirements:

  • Call the classifier and capture its typed result
  • Treat a None result (parse failure) as needing review too
  • Route items with confidence < 0.6 to the review queue
  • Route confident results to the trusted list
  • Keep the threshold explicit so it's easy to tune

💡 Hint: One branch does it: if s is None or s.confidence < 0.6 → review queue, else trust the label.

Show solution
Setup to run this snippet
class _Any:
    '''stands in for any undefined demo value; supports call/attr/index/
    iteration and basic arithmetic (as 0.7) so demo snippets run.'''
    def __call__(self, *a, **k): return _Any()
    def __getattr__(self, k): return _Any()
    def __getitem__(self, k): return _Any()
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __contains__(self, o): return True
    def __enter__(self, *a): return _Any()
    def __exit__(self, *a): return False
    def __float__(self): return 0.7
    def __int__(self): return 1
    def __lt__(self, o): return True
    def __gt__(self, o): return False
    def __le__(self, o): return True
    def __ge__(self, o): return False
    def __add__(self, o): return o
    def __radd__(self, o): return o
    def __bool__(self): return True
    def __repr__(self): return 'demo'
    def __str__(self): return 'demo'
def classify(*a, **k):  # demo stub
    return _Any()
fb = _Any()
class _results_t:
    append = 'demo'
    def append(self, *a, **k): return 'demo'
    def __getattr__(self, k): return 'demo'
results = _results_t()
class _review_queue_t:
    append = 'demo'
    def append(self, *a, **k): return 'demo'
    def __getattr__(self, k): return 'demo'
review_queue = _review_queue_t()
s = classify(fb)
if s is None or s.confidence < 0.6:
    review_queue.append(fb)
else:
    results.append(s)

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Build the five-part system promptBeginner

Context: A production system prompt almost always stacks five parts in a fixed order. Assembling them stable-to-dynamic both prompts better and lets the fixed prefix be prompt-cached later.

Your task: Assemble the five parts from the lesson — role & objective, constraints, tools, few-shot, dynamic context — into one system string, in order, stable-to-dynamic.

Requirements:

  • Include all five parts in the lesson's order
  • Role & objective first; the dynamic per-request context (e.g. {user_ticket}) last
  • Constraints and tool rules sit between role and examples
  • Join the parts into a single string and print it
  • Assert the stable role text appears before the dynamic placeholder

💡 Hint: Keep the parts in a list and "\n".join them — the list order is the stable-to-dynamic ordering.

Show solution

Order matters: fixed parts first, the changing per-request context last (so it prompts better and can be prompt-cached). Runnable:

parts = [
    "You are a support-ticket triager. Classify and route incoming tickets.",  # 1 role
    "Respond in JSON only. Never invent a category outside the enum.",          # 2 constraints
    "Call escalate only when the user reports data loss.",                      # 3 tools
    "Example: 'server crash' -> bug | high | auth",                             # 4 few-shot
    "{user_ticket}",                                                            # 5 dynamic (LAST)
]
system = "\n".join(parts)
print(system)
assert system.index("You are") < system.index("{user_ticket}")  # stable before dynamic
Exercise 2 · Few-shot as prior messagesIntermediate

Context: Few-shot prompting teaches by example: the examples live as prior user/assistant turns, and the final lone user turn is the real input the model completes.

Your task: Build a messages list for a ticket classifier returning type | severity | area, with two complete example pairs and a trailing real user input, then assert the list is well-formed.

Requirements:

  • Alternate user/assistant turns for each example pair
  • Include at least two complete example pairs establishing the format
  • End with a lone user message (no assistant reply) as the item to classify
  • Assert the first and last roles are both user
  • Assert the roles strictly alternate across the whole list

💡 Hint: Check alternation with all(a != b for a, b in zip(roles, roles[1:])); the model continues the pattern to produce the missing answer.

Show solution

Two complete example pairs teach the format; the final lone user message is the item to classify. Runnable check of the alternation and the trailing user turn:

messages = [
    {"role":"user",      "content":"Server crashed on null input to /login"},
    {"role":"assistant", "content":"bug | high | auth"},
    {"role":"user",      "content":"Please add dark mode"},
    {"role":"assistant", "content":"feature | low | ui"},
    {"role":"user",      "content":"Billing charged me twice this month"},  # real input
]
roles = [m["role"] for m in messages]
assert roles[0] == "user" and roles[-1] == "user"
assert all(a != b for a, b in zip(roles, roles[1:])), "must alternate"
print("examples:", roles.count("assistant"), "| to classify:", messages[-1]["content"])

The model continues the pattern and returns bug | high | billing — format learned by example, never described in words.

Exercise 3 · Pick effort per task, with a rationaleAdvanced

Context: The effort dial trades cost and latency against quality: low for easy work, high for hard reasoning. Encoding that mapping as a router keeps model spend matched to task difficulty.

Your task: Write a router choose_effort(task) that returns the effort level and a one-line reason per task, defaulting sensibly for an unknown task, and note the deprecated thinking pattern to avoid.

Requirements:

  • Map classification/routing/extraction → low
  • Map general app work (e.g. summarize) → medium
  • Map hard reasoning / agents / code → high
  • Default to medium with a stated reason on an unknown task
  • Note the deprecated thinking={"type":"enabled","budget_tokens":N}; use adaptive thinking + output_config={"effort":...} instead

💡 Hint: A dict of task → (level, reason) with dict.get(task, default) is the whole router.

Show solution

Classification/routing -> low; most app work -> medium; hard reasoning/agents/code -> high. Default to medium when unsure. Runnable:

RULES = {
    "classify":  ("low",    "simple, fast, cheap"),
    "route":     ("low",    "simple, fast, cheap"),
    "extract":   ("low",    "shallow structure"),
    "summarize": ("medium", "balanced default for app work"),
    "plan":      ("high",   "hard multi-step reasoning"),
    "code":      ("high",   "correctness beats cost"),
}
def choose_effort(task):
    return RULES.get(task, ("medium", "unknown task -> safe balanced default"))

for t in ["classify", "plan", "translate"]:
    lvl, why = choose_effort(t)
    print(f"{t:10} -> effort={lvl:6} ({why})")

Avoid the deprecated thinking={"type":"enabled","budget_tokens":N} — on current models use adaptive thinking + output_config={"effort":...}; a fixed budget is rejected.

Exercise 4 · Model a schema with Optional fieldsExpert

Context: Pydantic Literal fields make invalid values impossible — they become an enum in the JSON schema the model must satisfy — while Optional fields let missing data null out instead of breaking parsing.

Your task: Write a SalesLead model that extracts name, email, company, and interested_products (a list), with the first three Optional (default None), and prove validation with a valid and an invalid payload.

Requirements:

  • name/email/company are Optional[str] = None
  • interested_products is a list[str] defaulting to empty
  • Add a Literal-typed field (e.g. stage) to enforce an enum
  • Construct a valid instance with fields omitted and show they default cleanly
  • Construct an invalid instance and catch the ValidationError
  • Note that only output_format=SalesLead on .parse() needs a key

💡 Hint: The schema/validation runs offline if pydantic is installed; a value outside the Literal set raises ValidationError.

Show solution

This is pure Pydantic (offline-runnable if pydantic is installed — the SDK call is separate). The Literal on stage enforces the enum; Optionals null gracefully:

from typing import Optional, Literal
from pydantic import BaseModel, ValidationError

class SalesLead(BaseModel):
    name: Optional[str] = None
    email: Optional[str] = None
    company: Optional[str] = None
    interested_products: list[str] = []
    stage: Literal["cold", "warm", "hot"] = "cold"

ok = SalesLead(name="Sam", interested_products=["Pro"], stage="warm")
print(ok.company, ok.stage)          # None warm  -- missing field is fine

try:
    SalesLead(stage="boiling")        # not in the Literal enum
except ValidationError as e:
    print("rejected bad stage:", e.error_count(), "error(s)")

In the real call you pass output_format=SalesLead to client.messages.parse(...) and read resp.parsed_output — that part needs an API key. The schema/validation above runs offline.

Exercise 5 · A classifier that fails safe and routes low-confidenceProfessional

Context: A real classifier must never crash on bad output and must escalate uncertainty. Returning None on failure and routing low-confidence results to a human queue is the first guardrail every pipeline needs.

Your task: Extend Lab 2.4: build classify_and_route(text) that returns a typed Sentiment(label, confidence), returns None on bad output rather than crashing, and sends any result with confidence < 0.6 (or a failure) to a human-review queue.

Requirements:

  • Classify into a typed Sentiment with a label and confidence
  • try/except around the parse returns None on failure
  • Route None or confidence < 0.6 to a review queue
  • Route confident results to the trusted-results list
  • Show the real .parse call as a key-needing skeleton and demo the routing with a stub classifier
  • Use a small/cheap model for this easy work

💡 Hint: Low confidence is the routing signal — treat a failed parse the same as an uncertain one and send both to the human.

Show solution

Real SDK skeleton (needs a key), then a runnable demonstration of the routing rule — the guardrail is the point:

# --- real classifier (needs ANTHROPIC_API_KEY) ---
# from pydantic import BaseModel
# from typing import Literal
# import anthropic; from anthropic import Anthropic
# client = Anthropic()
# class Sentiment(BaseModel):
#     label: Literal["positive","neutral","negative"]
#     confidence: float
# def classify(text):
#     try:
#         r = client.messages.parse(model="claude-haiku-4-5", max_tokens=128,
#             messages=[{"role":"user","content":text}], output_format=Sentiment)
#         return r.parsed_output
#     except (anthropic.APIError, ValueError):
#         return None

# --- runnable routing logic with a stub classifier ---
class Sentiment:
    def __init__(self, label, confidence):
        self.label = label; self.confidence = confidence

def classify(text):                      # stub standing in for the SDK call
    table = {"Love it!": Sentiment("positive", 0.98),
             "It's okay I guess": Sentiment("neutral", 0.42),
             "\x00": None}             # simulate a parse failure
    return table.get(text, Sentiment("neutral", 0.5))

results, review_queue = [], []
def classify_and_route(text):
    s = classify(text)
    if s is None or s.confidence < 0.6:
        review_queue.append(text)
    else:
        results.append((text, s.label, s.confidence))

for fb in ["Love it!", "It's okay I guess", "\x00"]:
    classify_and_route(fb)
print("trusted:", results)
print("to human:", review_queue)

Production notes: small/cheap model for easy work, try/except returns None so a bad response is data not a crash, and low confidence is the signal that routes tricky cases to a human — your first guardrail.

Exercise 6 · Version and A/B a prompt safely in productionIndustry scenario

Context: A triage prompt that regressed silently after an edit is a classic production failure. Treating prompts as versioned artifacts — id + version, logged per request, rolled out by canary — turns that failure into a one-query diagnosis.

Your task: Design a prompt-management approach: give each prompt an id + version, log the version on every request, and canary-A/B v2 against v1 on live traffic before full rollout. Provide a runnable registry + A/B split core and the rollout plan.

Requirements:

  • A registry maps (id, version) → text — no anonymous string literals
  • Stamp the chosen prompt_ver onto every log line for traceability
  • Split traffic deterministically (e.g. hash of id + request key) so a user stays on one variant
  • Send a small canary slice (5–25%) to v2, keeping v1 as instant rollback
  • Gate promotion on an eval metric, not vibes; slice metrics by prompt_ver
  • Accounting/split core runs offline with stub prompts

💡 Hint: A sha256(id + request_key) % 100 < canary_pct bucket keeps assignment stable and needs no shared state; promote only when v2 wins the metric.

Show solution

Design. Treat prompts as versioned artifacts, never anonymous string literals. A registry maps id -> {version: text}; the chosen version is stamped onto every log line so a later quality dip is traceable to the exact prompt. Roll out with a canary: send a small deterministic slice of traffic to v2, compare an eval metric, promote only if v2 wins.

import hashlib

class PromptRegistry:
    def __init__(self):
        self.store = {}   # (id, version) -> text
    def register(self, pid, version, text):
        self.store[(pid, version)] = text
    def get(self, pid, version):
        return self.store[(pid, version)]

def canary_version(pid, request_key, canary_pct):
    """Deterministic bucket so the same user is stable across calls."""
    h = int(hashlib.sha256(f"{pid}:{request_key}".encode()).hexdigest(), 16)
    return "v2" if (h % 100) < canary_pct else "v1"

reg = PromptRegistry()
reg.register("triage", "v1", "You are a triager. Respond JSON only.")
reg.register("triage", "v2", "You are a triager. Respond JSON only. State reasons briefly.")

log = []
for uid in ["u1", "u2", "u3", "u4", "u5", "u6", "u7", "u8"]:
    ver = canary_version("triage", uid, canary_pct=25)   # 25% see v2
    prompt = reg.get("triage", ver)
    log.append({"user": uid, "prompt_id": "triage", "prompt_ver": ver})

from collections import Counter
print("split:", Counter(r["prompt_ver"] for r in log))
print("every log line carries the version:", all("prompt_ver" in r for r in log))

Tradeoffs. Deterministic hashing keeps a user on one variant (no flip-flopping) and needs no shared state. Start the canary small (5-25%) and gate promotion on the Chapter-5 eval metric, not vibes; keep v1 as instant rollback. Because the version is logged per request, when quality shifts you can slice metrics by prompt_ver and know exactly which edit did it — the failure that started this story becomes a one-query diagnosis.

✓ Checkpoint — you can move on when you can…

  • Write a five-part system prompt and explain each part's job.
  • Add few-shot examples via the messages array.
  • Pick an effort level for a given task and justify it.
  • Return typed, schema-valid JSON with messages.parse() and never touch regex.
  • Build a classifier that fails safe and routes low-confidence cases to humans.
🏗️ Toward the capstoneThe DevOps agent's system prompt defines its persona ("a careful SRE who never touches prod unapproved"), and its structured output is exactly the Diagnosis schema and the Terraform-plan summary it produces. The Literal[...] trick you just learned becomes the agent's fix_risk: read_only|reversible|significant|irreversible field — the classification that drives its entire safety model. See the Diagnosis schema in the capstone →

Knowledge check check yourself

✓ Knowledge check

The five-part system prompt is ordered stable-to-dynamic, with retrieved docs and user input placed last. Give the two distinct reasons this ordering helps.

Show answer
First, it prompts better: fixed instructions (role, constraints, tools, examples) anchor the model before variable input arrives. Second, it maximizes prompt-cache hits — the stable prefix can be cached and reused, while volatile content at the end doesn't invalidate that cache.
✓ Knowledge check

Why does using a Pydantic Literal[...] field make schema-constrained output safer than parsing free text with a regex?

Show answer
The Literal becomes an enum in the JSON schema the model must satisfy, so it literally cannot return a value outside your allowed set — no validation code, regex, or surprise values. A regex over free text breaks on rephrasing, emoji, or extra words and is a downstream liability.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in