AI EngineeringZero to ProductionHome·About·Contact
Anthropic API in Practice · Part 9

Prompting Claude & model migration

The prompting techniques Anthropic documents as first-class — XML tags, prefilling the assistant turn, long-context ordering, the Console prompt improver — plus the workflow for migrating safely when a model is deprecated or upgraded: pin the id, keep an eval set, diff old vs new, adjust prompts, and roll out behind a flag. Real SDK code, with one offline migration gate you can run.

⏱️ ~1.5 hours🧪 5 recipes🎯 Beginner→Tech-lead

Learning objectives

  • Structure a prompt with XML tags (<document>, <example>, <instructions>) so Claude can tell the parts apart.
  • Use the system prompt for the role and the user turn for the task and data.
  • Understand prefilling the assistant turn — what it does, and that it is removed on current models (use output_config.format instead).
  • Order a long-context prompt correctly: long document first, question last, and quote-then-answer.
  • Know what the Anthropic Console prompt improver / generator is and when to reach for it.
  • Run the safe model-migration loop: pin the id, keep an eval set, diff old vs new, adjust prompts, canary, roll out.
  • Write an offline migration gate that turns eval pass-rates into a go / adjust / block decision.

1 · Anthropic-specific prompting, in one picture essential

You already know general prompting from ch02 and pe1 — be clear, give examples, ask for structure. This lesson is narrower and deeper: the handful of techniques that are specific to Claude and that Anthropic documents as first-class. There are four, and they stack:

XML tags structure System prompt role Prefill / format shape reply Long-context order doc first

Read it left to right as a build order. XML tags label the pieces of your prompt so Claude never confuses your data for your instructions. The system prompt sets who Claude is. Prefill (on older models) or structured output (on current ones) pins the shape of the reply. And when the prompt is huge, ordering — long document first, question last — is what keeps recall high. Everything else in this half is detail on those four.

Deeper, not repeatedGeneral prompting (few-shot, chain-of-thought, decomposition) lives in the earlier lessons and still applies. Here we only cover what is distinctively Anthropic: XML tags, prefilling, long-context ordering, and the Console tools.

2 · Recipe 1 — structure the prompt with XML tags essential

Claude is trained to pay attention to XML-style tags. When your prompt mixes a document, a few examples, and your instructions, wrapping each part in a tag (<document>, <example>, <instructions>) removes ambiguity: Claude can tell "the thing to act on" from "how to act on it". The tag names are yours to choose — there is no fixed vocabulary — but pick descriptive names and close every tag.

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.
Python · anthropic SDK — an XML-structured prompt ▶ needs API key + network
xml_prompt.py# needs: pip install anthropic + ANTHROPIC_API_KEY; makes a real API call.
# XML tags are just text in the prompt string — no special parameter, no beta header.
import anthropic

client = anthropic.Anthropic()   # reads ANTHROPIC_API_KEY from the environment

CONTRACT = "... the full text of a 40-page contract ..."

prompt = f"""<document>
{CONTRACT}
</document>

<instructions>
Answer the question using ONLY the contract above. Quote the exact clause you
relied on inside <quote></quote> tags, then give your answer in <answer></answer> tags.
If the contract does not address the question, say so — do not guess.
</instructions>

<question>What is the termination notice period?</question>"""

resp = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=1024,
    messages=[{"role": "user", "content": prompt}],
)
print(next(b.text for b in resp.content if b.type == "text"))
▶ How this works

This is the core Anthropic move: wrap each part of your prompt in an XML-style tag so Claude can tell your data from your rules from your question. The tags are just text in the prompt string — there's no special parameter.

  1. <document>...</document> wraps the material to act on. <instructions> holds the rules, and <question> holds the ask. Claude is trained to notice these boundaries.
  2. The instructions tell Claude to answer only from the document and to put its answer inside <answer></answer> tags — so on your side you just split on the tag instead of guessing where prose ends.
  3. The f-string drops the contract text into the tag; the rest of the prompt is a plain triple-quoted string. client.messages.create(...) sends it like any other message.

What the output means: Claude replies grounded in the contract, with the clause it used quoted in <quote> tags and the answer in <answer> tags.

Try this: Rename <document> to <contract> in BOTH the wrapper and the instructions and watch it still work — the names are yours, as long as they match and every tag is closed.

Two things are doing work here. First, the tags give Claude a frame: the contract is data, the instructions are rules, the question is the ask. Second, asking for the answer inside <answer> tags makes the reply easy to parse on your side — you split on the tag instead of guessing where the prose ends.

Tag names are a contract with yourselfUse the same tag names in your instructions that you use to wrap the content ("using the text in <document>…"). Consistency is what lets Claude follow the reference. Nesting is fine (<example><input>…</input></example>) as long as tags are balanced.

3 · The system prompt's job essential

Claude takes a system prompt separately from the conversation. Put the stable stuff there — who Claude is, the rules it must always follow, the output contract — and keep the variable stuff (the actual document, this turn's question) in the user message. The split matters for two reasons: it reads more reliably, and it caches better (the frozen system prefix is exactly what prompt caching reuses).

Python · anthropic SDK — role in system, task in user ▶ needs API key + network
system_role.py# needs: pip install anthropic + ANTHROPIC_API_KEY; makes a real API call.
import anthropic

client = anthropic.Anthropic()

resp = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=512,
    # STABLE: role + hard rules + output contract — same every request, caches well.
    system=(
        "You are a senior contracts analyst. Answer only from the document the user "
        "provides. Be explicit and literal. If something is not stated, say 'not stated' "
        "rather than inferring. Always cite the clause you used."
    ),
    # VARIABLE: this turn's data + question.
    messages=[{"role": "user", "content": "<document>...</document>\n\nWhat is the SLA credit?"}],
)
print(resp.stop_reason, "|", resp.usage.input_tokens, "input tokens")
▶ How this works

This shows the split that makes prompts both reliable and cheap: the stable stuff (who Claude is, the hard rules, the output contract) goes in system=; the variable stuff (this turn's document and question) goes in the user message.

  1. system= takes the role and rules once. Because it's identical every request, it's exactly the frozen prefix that prompt caching reuses — you pay for it once.
  2. The messages list carries only what changes this turn. Keeping the document out of system is what lets the cache hit.
  3. The rules are stated plainly and literally ("say 'not stated' rather than inferring"). Current Claude models follow instructions closely, so precise beats aggressive.

What the output means: resp.stop_reason and the input-token count print — the point here is the structure of the call, not the answer text.

Try this: Move the role sentence out of system and into the user message. It still works, but you lose the clean cache boundary — the whole prompt is now 'variable'.

Be explicit — Claude follows instructions literallyCurrent Claude models (Opus 4.6 and later) follow the system prompt closely. That is a feature: state exactly what you want. It also means old-style "CRITICAL: YOU MUST…" shouting now over-triggers — write plain, precise instructions instead of aggressive ones.

4 · Recipe 2 — prefilling the assistant turn (and its modern replacement) intermediate

Prefilling means you put the first words of Claude's reply into the conversation yourself, as an assistant message with no answer after it. Claude then continues from your words. The classic use is forcing a format: start the assistant turn with { and Claude keeps writing JSON; start it with a label and Claude fills it in. It skips preambles like "Sure, here is…".

Prefill is removed on current models — this is a migration factAssistant-turn prefills are no longer supported on Claude Opus 4.6+, Sonnet 4.6, and Fable 5 — a trailing assistant message returns a 400. The technique is real and still works on older models; the recipe below shows both the classic form (on an older model) and the replacement you should use going forward.
Python · anthropic SDK — classic prefill on an OLDER model ▶ needs API key + network
prefill_old.py# needs: pip install anthropic + ANTHROPIC_API_KEY; makes a real API call.
# PREFILL PATTERN — works on older models (e.g. claude-sonnet-4-5). On Opus 4.6+ /
# Sonnet 4.6 / Fable 5 a trailing assistant turn returns a 400. See the replacement below.
import anthropic

client = anthropic.Anthropic()

resp = client.messages.create(
    model="claude-sonnet-4-5",                 # older model: prefill still allowed
    max_tokens=256,
    messages=[
        {"role": "user", "content": "Give the capital and population of France as JSON."},
        {"role": "assistant", "content": "{"},  # PREFILL: Claude continues the JSON
    ],
)
# The reply starts after your "{" — prepend it back to reconstruct the full object.
text = next(b.text for b in resp.content if b.type == "text")
print("{" + text)
▶ How this works

This is prefilling: you write the first characters of Claude's reply yourself, as a trailing assistant message, and Claude continues from them. Here the prefill is {, so Claude keeps writing JSON.

  1. The messages list ends with an assistant turn ("{") that has no answer after it. Claude picks up exactly where you left off — it never re-writes your {.
  2. Because the reply starts after your {, you prepend the { back to reconstruct the full object — that's what print("{" + text) does.
  3. This uses claude-sonnet-4-5 on purpose: prefill is removed on current models (Opus 4.6+, Sonnet 4.6, Fable 5), where a trailing assistant turn returns a 400. The next recipe is the modern replacement.

What the output means: A JSON object with France's capital and population, reconstructed by gluing your { back onto the front of the reply.

Try this: Change the model to claude-opus-4-8 and you'll get a 400 — that error is the migration lesson: a prompt tuned for one model can break on the next.

On current models you get the same guarantee — and a stronger one — with structured output. Instead of nudging the first token, you hand Claude a JSON schema and the reply is constrained to match it. This is the recommended replacement for every "prefill to force a format" case.

Python · anthropic SDK — structured output (the modern replacement) ▶ needs API key + network
structured_output.py# needs: pip install anthropic + ANTHROPIC_API_KEY; makes a real API call.
# Replaces format-forcing prefills on current models. output_config.format constrains
# the reply to your JSON schema — no prefill, guaranteed shape.
import anthropic

client = anthropic.Anthropic()

resp = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=256,
    messages=[{"role": "user", "content": "Give the capital and population of France."}],
    output_config={
        "format": {
            "type": "json_schema",
            "schema": {
                "type": "object",
                "properties": {
                    "capital": {"type": "string"},
                    "population": {"type": "integer"},
                },
                "required": ["capital", "population"],
                "additionalProperties": False,
            },
        }
    },
)
import json
text = next(b.text for b in resp.content if b.type == "text")
print(json.loads(text))    # {'capital': 'Paris', 'population': ...}
▶ How this works

This is how you force a format on current models — no prefill. You hand Claude a JSON schema in output_config.format and the reply is constrained to match it.

  1. output_config={"format": {"type": "json_schema", "schema": {...}}} declares the exact shape: a capital string and a population integer, both required, no extra keys.
  2. The reply's first text block is guaranteed valid JSON for that schema, so json.loads(text) parses it straight into a dict — no reconstruction, no prefix to glue back on.
  3. This is stronger than a prefill: the prefill only nudged the first token, while the schema constrains the whole reply. It's the recommended replacement for every 'prefill to force JSON' case.

What the output means: A parsed Python dict like {'capital': 'Paris', 'population': ...} — typed and ready to use.

Try this: Add a "region": {"type": "string"} property to the schema (and to required) and re-run — the reply gains the field, still valid JSON.

You want to…Older modelCurrent model (Opus 4.6+/Sonnet 4.6/Fable 5)
Force JSON / a schemaPrefill with {output_config.format (structured output)
Force a classification labelPrefill the label startStrict tool with an enum, or structured output
Skip "Sure, here is…" preamblesPrefill the first real wordSystem instruction: respond directly, no preamble
Continue an interrupted replyPrefill the tailPut the continuation ask in the user turn

5 · Recipe 3 — long-context ordering intermediate

Claude's models have very large context windows (up to 1M tokens on Opus 4.8), so you can drop a whole book in a prompt. But where you put things changes the answer quality. Anthropic's documented long-context guidance is three rules:

Long-context rules

  1. Put the long document first, the question last. Claude attends best to a query that comes after the material it's about. A 50-page doc followed by "now answer X" beats "answer X about the following: [50 pages]".
  2. Wrap each document in tags (<document>, or <doc index="1"> when there are several) so Claude can reference them and you can ask it to cite by index.
  3. Ask it to quote first, then answer. Instruct Claude to pull the relevant sentences into <quotes> before writing the answer. Grounding the answer in extracted quotes raises accuracy and gives you something to audit.
Python · anthropic SDK — long document first, question last, quote-then-answer ▶ needs API key + network
long_context.py# needs: pip install anthropic + ANTHROPIC_API_KEY; makes a real API call.
import anthropic

client = anthropic.Anthropic()

DOCS = {1: "... report one ...", 2: "... report two ..."}

# Long material FIRST ...
doc_blocks = "\n".join(f'<doc index="{i}">\n{txt}\n</doc>' for i, txt in DOCS.items())

# ... question and instructions LAST.
prompt = f"""{doc_blocks}

Using only the documents above:
1. Pull the sentences relevant to the question into <quotes></quotes>, tagging each with
   the doc index it came from.
2. Then write your answer in <answer></answer>, citing the doc index.

<question>Which report projects higher Q4 revenue, and by how much?</question>"""

resp = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=1024,
    messages=[{"role": "user", "content": prompt}],
)
print(next(b.text for b in resp.content if b.type == "text"))
▶ How this works

This shows Anthropic's long-context ordering rules in code: put the long material first, the question last, and make Claude quote before it answers.

  1. doc_blocks builds the documents up front, each wrapped in <doc index="1"> tags so Claude can cite them by index — and they come before anything else in the prompt.
  2. The instructions and the <question> come after all the documents. Claude attends best to a question that follows the material it's about.
  3. Step 1 forces Claude to pull relevant sentences into <quotes> before writing the answer. Grounding the answer in extracted quotes raises accuracy and gives you something to audit.

What the output means: A <quotes> block (sentences tagged by doc index) followed by an <answer> that cites which report projects higher revenue.

Try this: Flip the order — put the question first and the documents last — on a long input and compare. The document-first version is the one Anthropic recommends for a reason.

Quote-then-answer is a cheap accuracy winForcing an extraction step before the answer keeps Claude anchored to the source instead of recalling from memory, and the <quotes> block is exactly what you show a reviewer when they ask "where did this come from?"

6 · The Console prompt improver & generator advanced

You don't have to write every prompt from a blank page. The Anthropic Console (console.anthropic.com) ships two tools that most people underuse:

ToolWhat it isWhen to reach for it
Prompt generatorDescribe the task in plain language; it drafts a structured prompt (role, XML tags, examples) for you.Starting a new prompt from scratch, or when you're staring at a blank page.
Prompt improverPaste an existing prompt; it rewrites it applying Anthropic best practices — adds tags, tightens instructions, adds a reasoning step.You have a prompt that mostly works but is inconsistent, or you're about to migrate models and want a clean baseline.

Treat the output as a strong first draft, not a finished artifact. The Console applies the exact techniques in this lesson — XML tags, explicit instructions, an answer contract — so its drafts are a good scaffold. But you still own the evals: a generated prompt that reads beautifully can still score worse on your task than the one you had. Generate, then measure.

This is a workflow tool, not an APIThe improver/generator lives in the Console UI. Use it while designing a prompt; the prompt you ship is still plain text you send through the SDK exactly as shown above.

7 · Why migration is a workflow, not a find-and-replace advanced

Models get deprecated and upgraded. A retired model id starts returning 404; a deprecated one keeps working until a cutoff date, then stops. So you will migrate — the only question is whether it's controlled or a fire drill. Two facts make it more than swapping a string:

What actually changes across a model version

  1. The request surface can change. Moving to Opus 4.6+ / Sonnet 4.6 / Fable 5, assistant-turn prefills 400, budget_tokens is replaced by thinking={"type":"adaptive"}, and (Opus 4.7+) sampling params temperature/top_p are rejected. A blind id swap can start erroring.
  2. Behavior shifts even when the code is valid. Newer models follow instructions more literally, reach for tools differently, and calibrate verbosity to the task. A prompt tuned for the old model may over- or under-trigger on the new one — so the prompt often needs adjusting, not just the id.

The upshot: a prompt is tuned for a model. When the model changes, you re-validate the prompt against evidence — which is exactly what the loop below is for. This is where your eval set (ch05) earns its keep: it's the evidence.

8 · The safe migration loop professional

The whole workflow is a loop you can draw. Nothing exotic — pin, test, diff, adjust, canary, watch — but doing it in this order is the difference between a boring migration and a Friday incident:

New model announced pin ids Run evals: old vs new same evals Pass? no → adjust Canary small % Full rollout promote
🗺️ How to read this diagram

This is the whole model-migration workflow as a loop. It's the safe path from 'a new model exists' to 'the new model is serving all my traffic' — and the order of the steps is what keeps it boring instead of risky.

  • New model announcedRun evals: old vs new: you pin both ids and run your fixed eval set (ch05) against each. Same prompts, same inputs, two models.
  • Pass? is the gate. If it doesn't pass, you adjust the prompts and re-run — a regression is usually prompt fit, not a bad model. This is the loop-back arrow.
  • Once it passes, Canary sends a small slice of real traffic to the new model behind a flag (instant rollback, no deploy), and only then Full rollout promotes it to 100%.

In short: Never skip the canary. The eval set tells you the new model is good on your known cases; the canary tells you it's good on real traffic — and the flag lets you undo it in seconds if it isn't.

The loop, step by step

  1. Pin the model id. Put the id in one config constant (MODEL = "claude-opus-4-8"), never scattered as string literals. You can't migrate safely what you can't find in one place.
  2. Keep an eval set. A fixed set of representative inputs with known-good expectations (from ch05). This is the ground truth you'll diff against.
  3. Run BOTH old and new against the evals. Same prompts, same inputs, two model ids. Compare pass-rates and eyeball the diffs — where the new model changed matters as much as the aggregate number.
  4. Adjust prompts if needed. If the new model regresses, it's usually prompt fit, not a bad model: dial back aggressive tool instructions, add a length instruction, remove prefills. Re-run the evals after each change.
  5. Roll out behind a flag (canary). Send a small slice of real traffic to the new model, keep the rest on the old one. A flag means instant rollback with no deploy.
  6. Watch, then promote. Monitor the canary — latency, cost, error rate, refusals, and any quality signal you have — then widen the flag to 100% once it holds.
Pin, don't floatIt's tempting to use a floating alias so you "always get the latest." Don't, for anything you can't instantly re-test — a silent model upgrade can shift behavior under you with no canary. Pin the exact id, and make bumping it a deliberate, eval-gated change.

9 · Recipe 4 — the migration gate (this one runs offline) professional

The "Pass?" diamond in the diagram deserves an actual rule, not a vibe. The helper below is pure stdlib and runs with a plain python file.py — no API key, no network. It takes per-case results for the old and new model on the same eval set and returns a decision: go (new is as good or better), adjust (small regression — tune prompts and re-run), or block (real regression — do not ship).

Python · migration gate: diff old vs new pass-rates → go/adjust/block (runs offline)
migration_gate.pydef migration_gate(old_results, new_results, adjust_band=0.03):
    """Decide whether a new model is safe to roll out, from eval results.

    old_results / new_results: dict[case_id] -> bool (True = passed the eval).
    Both must cover the SAME case ids. Returns a decision dict.

    Rule (per-case regressions matter, not just the aggregate):
      no case regressed                        -> "go"      (as good or better)
      some regressed, rate held within band    -> "adjust"  (small/hidden regression)
      rate dropped past the band               -> "block"   (real regression)
    A "regression" is a case the old model passed but the new one fails. Note a
    migration can hold its aggregate pass-rate while still regressing cases (it fixed
    as many as it broke) — that is exactly why go requires ZERO regressions.
    """
    ids = sorted(old_results)
    assert ids == sorted(new_results), "eval sets must cover the same case ids"

    n = len(ids)
    old_rate = sum(old_results[i] for i in ids) / n
    new_rate = sum(new_results[i] for i in ids) / n
    regressions = [i for i in ids if old_results[i] and not new_results[i]]

    if not regressions and new_rate >= old_rate:
        decision = "go"
    elif old_rate - new_rate <= adjust_band:
        decision = "adjust"
    else:
        decision = "block"

    return {
        "old_rate": round(old_rate, 3),
        "new_rate": round(new_rate, 3),
        "delta": round(new_rate - old_rate, 3),
        "regressions": regressions,
        "decision": decision,
    }


# Fake eval set: 10 cases, old model (e.g. sonnet-4-5) vs new (opus-4-8).
old = {f"case-{i:02d}": True for i in range(10)}
old["case-07"] = False                       # old already failed this one
new = dict(old)
new["case-03"] = False                       # new regressed one case the old passed
new["case-07"] = True                        # new fixed the one old failed

result = migration_gate(old, new)
for k, v in result.items():
    print(f"{k}: {v}")
old_rate: 0.9
new_rate: 0.9
delta: 0.0
regressions: ['case-03']
decision: adjust
▶ How this works

This is the offline decision rule behind the 'Pass?' diamond in the migration diagram. It takes per-case pass/fail for the old and new model on the SAME eval set and returns go, adjust, or block. It's pure stdlib — run it with python migration_gate.py.

  1. It computes each model's pass-rate and, crucially, the list of regressions — cases the old model passed but the new one fails. go requires zero regressions, not just an equal-or-better rate.
  2. A small regression that keeps the rate within adjust_band returns adjust (tune prompts, re-run); a drop past the band returns block (don't ship).
  3. The fake eval set is rigged so the new model fixes case-07 and breaks case-03 — the pass-rate stays 0.9 → 0.9, but a case regressed.

What the output means: old_rate: 0.9, new_rate: 0.9, delta: 0.0, regressions: ['case-03'], decision: adjust. The aggregate held, but a case regressed — so it's not an automatic go.

Try this: Set new["case-03"] = True so nothing regresses and re-run — the decision flips to go. Then break two more cases and watch it become block.

Read the output: overall pass-rate is unchanged (0.9 → 0.9), but the models disagree on which cases — the new one fixed case-07 and broke case-03. The aggregate hid a real regression, which is why the gate reports regressions explicitly and returns adjust: go look at case-03, tune the prompt, re-run. A pass-rate that only went up would return go; a drop past the band would return block.

Aggregate pass-rate lies by omissionTwo models can post the identical pass-rate while disagreeing on a third of the cases. Always diff per case, not just the headline number — the set of newly-failing cases is the thing that actually tells you whether to ship.

10 · Tech-lead — owning migrations across a fleet tech-lead

A lead's job is to make model changes routine and reversible across many services, not heroic one-offs. The levers:

Fleet-level migration discipline

  1. One pinned id per service, discoverable in one grep. A single MODEL constant (or a shared config key) per service. When a deprecation lands, you want rg 'claude-' to find every call site in seconds — not a scavenger hunt.
  2. Evals are a shipping gate, tied to the prompt. The eval set lives with the code and runs in CI. A model or prompt change that drops the gate (the migration_gate returning block) fails the build.
  3. The model id is a flag, not a constant edit. Route it through a feature flag / config so you can canary and roll back without a deploy. Deploy-to-rollback is too slow when a migration goes sideways in production.
  4. Migration is a checklist, not a memory. Codify the breaking changes (prefill → structured output, budget_tokens → adaptive thinking, drop sampling params) so every service applies the same edits. Anthropic publishes a per-version migration guide — start from it.
  5. Watch refusals and stop reasons after cutover. New models can refuse more appropriately (handle stop_reason == "refusal") and hit the context window differently. Add these to the canary dashboard, not just latency and cost.
  6. Keep the old id available during the canary. Until the new model is at 100% and holding, the old (non-retired) id is your instant fallback. Don't delete the old path until the new one has soaked.

The mental model: a model version is a dependency, and you manage it like any other — pinned, tested in CI, rolled out behind a flag, monitored, and rolled back cleanly. Prompting well (Half A) makes each service work; the migration loop (Half B) keeps it working as the model underneath it moves.

A retired id is a hard 404Deprecated models keep serving until a published cutoff; retired ones return 404 immediately. If you learn a model is being retired, treat the migration as time-boxed work with a deadline — not a someday task. The safe loop above is exactly what lets you hit that deadline without gambling on quality.

Exercise AP9.1 — Convert a prefill to structured output

Context: The clearest way to feel the prefill-to-schema migration is to run both: watch the old prefill get rejected on a current model, then watch the schema version parse cleanly — and notice what the schema gives you that a brace never did.

Your task: Take a prompt that uses an assistant-turn prefill to force JSON (or the France-JSON example) and rewrite it for a current model using output_config.format with a real JSON schema. Confirm the prefill would 400 on claude-opus-4-8 and that the structured-output version parses with json.loads(), then state one advantage the schema gives you.

Requirements:

  • Show the legacy prefill form and note it returns a 400 on claude-opus-4-8
  • Rewrite it with a typed JSON schema passed through output_config.format
  • Remove the assistant-turn prefill entirely from the messages
  • Confirm the structured output parses cleanly with json.loads()
  • State one concrete advantage of the schema (e.g. guaranteed field types / validation) that the prefill lacked

💡 Hint: The schema does more than force JSON — it constrains which fields and types come back, which is exactly the guarantee a lone { could never give.

Exercise AP9.2 — Gate a pretend migration

Context: The whole reason a migration gate inspects cases is that the headline number lies. Build a set where the average holds steady while real cases regress, and the gate's value becomes obvious.

Your task: Invent a 12-case eval set as two dicts (old vs new pass/fail) where the aggregate pass-rate is identical but three cases regressed. Run migration_gate on it, then explain why it returns 'adjust' or 'block' despite the unchanged headline, and your next step per regressed case.

Requirements:

  • Construct 12 aligned old/new cases whose total pass counts are equal
  • Ensure three cases flip from pass to fail (offset by three that flip the other way)
  • Run migration_gate and report its verdict and regressed indexes
  • Explain in your own words why the per-case view overrides the flat aggregate
  • State a concrete next step for each regressed case (re-prompt, hold, investigate)

💡 Hint: Balance every pass-to-fail flip with a fail-to-pass flip so the sum is unchanged — that is precisely the situation the aggregate can't see but the gate can.

🪜 Practice ladder beginner → industry

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

Exercise 1 · Structure a prompt with XML tagsBeginner

Context: When instructions and data blur together, the model guesses which is which. XML tags draw a hard line so Claude knows the <document> is the material and the <question> is the ask.

Your task: Rewrite a messy instruction-plus-data prompt to use <document> and <question> tags so the model can tell content from task.

Requirements:

  • Wrap the source material in <document>...</document>
  • Wrap the ask in <question>...</question>
  • Send the tagged text as a single user-turn message
  • The tags carry the structure — no restating the task in prose outside them
  • Print the model's answer

💡 Hint: Build the prompt by concatenating the tagged sections; the tags themselves are the boundary the model reads, so keep them clean and closed.

Show solution

XML tags give the model unambiguous boundaries between the data and the ask.

import anthropic
client = anthropic.Anthropic()

doc = "Our refund window is 30 days from delivery."
prompt = (
    "<document>\n" + doc + "\n</document>\n"
    "<question>How long do customers have to request a refund?</question>"
)
resp = client.messages.create(
    model="claude-sonnet-4-6", max_tokens=128,
    messages=[{"role": "user", "content": prompt}],
)
print(resp.content[0].text)
Exercise 2 · Put role in system, data in the user turnIntermediate

Context: Splitting the stable role from the changing data is both cleaner prompting and a caching win: a fixed system prompt can be cached across calls while only the user turn varies.

Your task: Split a prompt so the stable role/rules go in system and only the variable data goes in messages, and state why this helps caching.

Requirements:

  • The system parameter holds the durable persona and output contract (e.g. a fixed label set)
  • The user turn carries only the changing input for this call
  • A comment explains the stable system prompt is what becomes cacheable
  • Keep max_tokens tight for a terse classification reply
  • Print the model's label

💡 Hint: Anything that would be identical on the next request belongs in system; anything that changes per call belongs in messages.

Show solution

The system prompt is the stable contract; the user turn carries the changing task. Keeping the stable part separate makes it cacheable.

import anthropic
client = anthropic.Anthropic()

SYSTEM = ("You are a support classifier. Reply with exactly one label: "
          "billing, technical, or other.")   # stable -> cacheable
resp = client.messages.create(
    model="claude-sonnet-4-6", max_tokens=8,
    system=SYSTEM,
    messages=[{"role": "user", "content": "My card was charged twice."}],  # variable
)
print(resp.content[0].text)
Exercise 3 · Structured output instead of a prefillAdvanced

Context: The old trick of prefilling the assistant turn with { to force JSON now returns a 400 on current models. The modern replacement constrains the whole reply with a JSON schema via output_config.

Your task: Take legacy code that prefilled the assistant turn with { to force JSON and replace it with output_config and a JSON schema.

Requirements:

  • Remove the assistant-turn prefill entirely — there is no trailing assistant message
  • Define a JSON schema with typed properties and a required list
  • Pass it via output_config={"format": {"type": "json_schema", "schema": ...}}
  • The user turn just states the task in plain language
  • The reply is valid JSON conforming to the schema — note it now parses with json.loads()

💡 Hint: The schema, not a prefilled brace, is what pins the output shape — let output_config.format do the job the prefill used to.

Show solution

Prefilling is rejected on Opus 4.6+/Sonnet 4.6/Fable 5. The modern replacement constrains the whole reply via a JSON schema.

import anthropic
client = anthropic.Anthropic()

schema = {"type": "object",
          "properties": {"label": {"type": "string"},
                         "confidence": {"type": "number"}},
          "required": ["label", "confidence"]}
resp = client.messages.create(
    model="claude-sonnet-4-6", max_tokens=128,
    output_config={"format": {"type": "json_schema", "schema": schema}},
    messages=[{"role": "user", "content": "Classify: the app keeps crashing."}],
)
print(resp.content[0].text)   # valid JSON per the schema
Exercise 4 · Long-context orderingExpert

Context: On long inputs, position matters: models attend better when the big document comes first and the question comes last, and a quote-then-answer instruction forces the model to ground itself before it speaks.

Your task: Assemble a long-context prompt correctly — long document first, question last, quote-then-answer — and explain why the ordering matters.

Requirements:

  • The <document> block leads the prompt
  • The <question> comes last, after the instructions
  • Instructions require quoting the exact supporting sentence before answering
  • A comment explains document-first / question-last improves grounding on long inputs
  • Print the model's quote-then-answer response

💡 Hint: Order the sections document → instructions → question, and make the quote requirement explicit so the answer is anchored to a real sentence.

Show solution

Placing the document first and the question last, plus a quote-first instruction, improves grounding on long inputs.

import anthropic
client = anthropic.Anthropic()

long_doc = "...(many pages of policy text)..."
prompt = (
    "<document>\n" + long_doc + "\n</document>\n"
    "<instructions>First quote the exact sentence that answers the "
    "question, then answer in one line.</instructions>\n"
    "<question>What is the SLA for critical incidents?</question>"
)
resp = client.messages.create(
    model="claude-sonnet-4-6", max_tokens=256,
    messages=[{"role": "user", "content": prompt}],
)
print(resp.content[0].text)
Exercise 5 · The offline migration gateProfessional

Context: A headline pass-rate can stay flat while individual cases quietly regress. A migration gate looks case-by-case so a swap that breaks specific inputs can't sneak through on an unchanged average.

Your task: Implement migration_gate(old, new, band=0.03) over aligned per-case pass/fail lists that returns 'go' (no regressions), 'adjust' (net drop within band), or 'block' (drop beyond band), and lists the regressed case indexes.

Requirements:

  • Detect a regression as a case that passed in old but fails in new
  • Return 'go' when there are no regressions at all
  • Return 'adjust' when the net pass-rate drop is within band, else 'block'
  • Report the list of regressed case indexes and the rounded net drop
  • Assert the two lists are the same length; runs fully offline

💡 Hint: Compute the regressed indexes first, then branch on whether that set is empty and, if not, on whether the net drop clears the band.

Show solution

Aggregate pass-rate hides per-case disagreements, so the gate inspects each case.

def migration_gate(old, new, band=0.03):
    assert len(old) == len(new)
    regressed = [i for i, (o, n) in enumerate(zip(old, new)) if o and not n]
    drop = (sum(old) - sum(new)) / len(old)
    if not regressed:
        decision = "go"
    elif drop <= band:
        decision = "adjust"
    else:
        decision = "block"
    return {"decision": decision, "regressed": regressed, "net_drop": round(drop, 4)}

old = [1,1,1,1,1,1,1,1,1,1]
new = [1,1,0,1,1,1,1,1,1,1]     # one regression, 10% drop
print(migration_gate(old, new))  # block (drop 0.1 > band)
Exercise 6 · Safe fleet-wide model migrationIndustry scenario

Context: Migrating a fleet of prompts to a new model is a workflow, not a flag flip: some prompts use now-broken prefills, and the rest must be proven safe per-case before any canary.

Your task: Write a migration driver that moves prompts from claude-sonnet-4-5 to claude-sonnet-4-6: detect prompts that use a prefill (a breaking change), route the safe ones through the gate, and produce a rollout plan (canary vs block).

Requirements:

  • Detect a prefill by a trailing assistant-role message and flag it for refactor to output_config.format
  • Route only the non-prefill prompts through the per-case migration gate
  • Map a 'go' verdict to a canary-behind-a-flag rollout
  • Map any non-go verdict to a hold, surfacing which verdict caused it
  • Produce a plan keyed by prompt name; runs fully offline

💡 Hint: Screen for the breaking change first so prefill prompts never reach the gate — then the gate only ever sees prompts that could actually ship.

Show solution

Migration is a workflow: find breaking changes, evaluate old vs new per case, canary the safe prompts behind a flag.

def uses_prefill(messages):
    return bool(messages) and messages[-1].get("role") == "assistant"

def gate(old, new, band=0.03):
    reg = [i for i,(o,n) in enumerate(zip(old,new)) if o and not n]
    drop = (sum(old)-sum(new))/len(old)
    return "go" if not reg else ("adjust" if drop<=band else "block")

prompts = {
    "p1": {"messages": [{"role":"user","content":"x"}],
           "old": [1,1,1,1], "new": [1,1,1,1]},
    "p2": {"messages": [{"role":"user","content":"x"},
                        {"role":"assistant","content":"{"}],   # prefill!
           "old": [1,1,1,1], "new": [1,1,1,1]},
}
plan = {}
for name, p in prompts.items():
    if uses_prefill(p["messages"]):
        plan[name] = "refactor: prefill -> output_config.format"
    else:
        d = gate(p["old"], p["new"])
        plan[name] = "canary behind flag" if d == "go" else f"hold ({d})"
print(plan)

✓ Checkpoint — you can move on when you can…

  • Structure a prompt with XML tags and ask for the answer inside a named tag.
  • Say what goes in the system prompt vs the user turn, and why.
  • Explain prefilling, why it 400s on current models, and the output_config.format replacement.
  • Order a long-context prompt: document first, question last, quote-then-answer.
  • Say what the Console prompt improver/generator is and when to use it.
  • Run the migration loop: pin id → eval old vs new → adjust → canary → full.
  • Read a migration_gate result and act on per-case regressions, not just the pass-rate.

Knowledge check check yourself

✓ Knowledge check

Why did assistant-turn prefilling stop working on Opus 4.6+/Sonnet 4.6, and what replaces it for forcing JSON output?

Show answer
Prefilling (starting the assistant turn with { to force JSON) now returns a 400 on those models. The replacement is output_config.format with a JSON schema, which is stronger because it constrains the whole reply to match the schema rather than just biasing the first token.
✓ Knowledge check

Why can aggregate pass-rate hide a regression during a model migration, and what does the safe migration loop do about it?

Show answer
Two models can post identical overall pass-rates while disagreeing on a large fraction of individual cases, so the aggregate lies by omission. The safe loop pins the model ID, runs evals old vs new and diffs per case, adjusts prompts if regressed, then canaries behind a feature flag with the old ID kept as instant fallback.
© 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