AI EngineeringZero to ProductionHome·About·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 Anthropic API key (ANTHROPIC_API_KEY) + pip install anthropic
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

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

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

When you need a specific format or edge-case handling, show don't tell. Examples live as prior turns in the messages array.

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

Two dials trade cost/latency against quality:

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

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

Lab 2.3

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

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.

Lab 2.4
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 client.messages.parse(..., output_format=Sentiment) — the .parse method makes the model return data matching your Sentiment shape and hands you a real Python object in r.parsed_output.
  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_outputthat 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