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.
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.formatinstead). - 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
Here's the failure that this whole lesson is built to prevent. You paste a 40-page contract into a prompt, tack "what's the termination clause?" onto the end, and Claude confidently answers — from the wrong section, or worse, from a clause it half-invented. You didn't do anything obviously wrong. The trouble is that from the model's point of view your instructions and your data were one undifferentiated wall of text, and it guessed at the seams. A handful of small, deliberate moves would have removed that guesswork entirely — and those moves are what this lesson is.
Think of a good prompt like a well-labelled shipping crate rather than a pile of loose parts. The label on the outside says what's inside and how to handle it; the parts sit in clearly marked compartments; the "this way up" arrow points the reader's attention the right direction. Claude, like a careful worker, does its best job when the crate is labelled — and its most creative, most error-prone job when it has to reverse-engineer what you meant. Every technique here is a label or a compartment.
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:
Read the four boxes left to right — they're the Anthropic-specific techniques in the order you stack them onto a prompt.
- The first box, "XML tags / structure", is where you start: label the pieces of the prompt so Claude never mixes your data up with your instructions.
- The arrows mean "and then also" — each box adds another layer on top of the last, not a replacement. The second box, "System prompt / role", sets who Claude is; the third, "Prefill / format / shape reply", pins the shape of the answer.
- The last box, "Long-context order / doc first", is the payoff for big prompts: put the document first and the question last to keep recall high.
In short: four techniques that stack — structure, role, reply shape, then ordering — and the rest of this half just fills in the detail on each.
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.
2 · Recipe 1 — structure the prompt with XML tags essential
Imagine handing a colleague a single sheet of paper with a contract, a note to yourself, and a question all typed one after another with no headings. They'd have to infer where the contract ends and your question begins — and half the time they'd get it slightly wrong. That is exactly the position a plain, unstructured prompt puts Claude in. The fix is almost embarrassingly simple: draw lines around the pieces. In Claude's world, those lines are XML-style tags.
Here's why this specific technique works and isn't just tidiness. Claude was trained on a great deal of text where tags like <document>…</document> reliably mark a self-contained block, so the model has learned to treat a tag boundary as a real boundary of meaning. When you write "answer using the text in <document>", Claude can resolve that reference precisely, the same way you can point at a labelled box and say "the thing in that one." Without the tag, "the document" is just a phrase competing with every other phrase for the model's attention.
The common mistake to name up front: people either skip tags entirely on long mixed prompts (and get answers drawn from the wrong part), or they open a tag and forget to close it, or they use one name to wrap the content and a different name in the instructions. All three break the very reference you were trying to create. The rule of thumb is boring and reliable — every tag opens and closes, and the name you wrap with is the name you cite. Now the precise version:
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.
Read it left to right: the same prompt content before tags (left) and after tags (right).
- The left red box is the untagged prompt — the contract, the rules, and the question all run together as one blob, so Claude has to guess where each part ends. That guesswork is where wrong-section answers come from.
- The arrow is the fix — wrapping each part in an XML-style tag.
- The three stacked boxes on the right are the result:
<document>(the data),<instructions>(the rules), and<question>(the ask) are now separate, named compartments Claude can point at exactly.
In short: Tags draw hard lines between your data, your rules, and your question — so Claude references the right part instead of guessing at the seams.
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"))
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.
<document>...</document>wraps the material to act on.<instructions>holds the rules, and<question>holds the ask. Claude is trained to notice these boundaries.- 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. - 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.
<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
Most people cram everything into the user message and never touch the system prompt — and it quietly costs them both quality and money. The role, the rules, the format contract, the document, the question, all pasted into one turn. It works well enough that the leak goes unnoticed, which is exactly why it's worth stopping on: there's a cleaner place for half of that text, and using it makes your calls both more reliable and cheaper.
The mental model is a job description versus a work ticket. The system prompt is the job description — who you are, the standards you always hold to, how you're expected to format your output. It doesn't change from task to task. The user message is the work ticket — this specific document, this specific question, handed over fresh each time. You wouldn't re-read a new employee their entire job description every time you gave them a task; you set the role once and then just hand over the work. Claude is the same: put the durable identity and rules in system, and let the user turn carry only what's genuinely new.
Two concrete payoffs make this more than style. First, reliability: a stable, clearly-scoped role reads more consistently than the same instructions buried mid-paragraph next to the data. Second, and this is the one engineers underrate, cost: that frozen system prefix is precisely what prompt caching can reuse across requests, so you pay to process it once instead of on every call. The mistake that kills the caching win is sliding something variable — a timestamp, the document, "today's" question — into the system prompt; the moment the prefix changes, the cache misses. Keep the boundary clean and both wins hold. Now the precise version:
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).
system= (identical every call, so prompt caching reuses it); the document and question live in the user turn (new each time).
Read it as one API call split into two halves — left is what stays the same, right is what changes.
- The left purple box is
system=: the role, the hard rules, the output contract. Because it's byte-identical on every request, it becomes the frozen prefix that prompt caching reuses — you pay to process it once. - The right amber box is the user message: this turn's document and question. It changes every call, so it's always processed fresh.
- The arrow joins them into a single call. The caption at the bottom is the trap: slide anything variable (a timestamp, the document) into
system=and the prefix changes, so the cache misses.
In short: Stable role/rules in system= (cached once), variable document/question in the user turn (fresh each call) — keep that line clean and you get both reliability and the caching discount.
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")
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.
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.- The
messageslist carries only what changes this turn. Keeping the document out ofsystemis what lets the cache hit. - 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'.
4 · Recipe 2 — prefilling the assistant turn (and its modern replacement) intermediate
You ask for JSON and Claude gives you "Sure! Here's the data you asked for:" followed by the JSON in a code fence. Now your parser chokes on the friendly preamble. The old fix for this was a genuinely clever trick called prefilling — and it's worth learning even though it's been superseded, because understanding why it worked and why it was replaced is one of the cleanest windows into how these models actually generate text.
Here's the intuition. A language model doesn't decide on a whole answer and then type it; it writes one token at a time, each one strongly shaped by what came just before. Prefilling exploits that directly: you write the opening words of Claude's own reply for it — as an assistant turn left dangling with no response after it — and the model, seeing a reply already in progress, simply continues it. It's like starting someone's sentence for them: begin "The three main causes are…" and they'll naturally list three, not ramble. Begin the reply with { and the most natural continuation is more JSON, so the preamble never gets a chance to appear.
The catch — and it's the reason this section pulls double duty as your first real migration lesson — is that prefilling is a technique tied to a model's behaviour, and model behaviour changes. On current Claude models the trailing-assistant-turn trick has been removed and now returns an error outright. So this recipe teaches two things at once: the classic prefill (still live on older models, and a lovely illustration of token-by-token generation) and the modern replacement you should actually reach for. Watch the seam between them closely — a prompt that "just worked" turning into a hard error on the next model is the entire premise of the second half of this lesson. Now the precise version:
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…".
{ as the assistant turn and Claude just continues it — no preamble to strip.
Compare the two columns top-to-bottom: the same request, without a prefill (left) and with one (right).
- Left: the user asks for JSON, and Claude answers freely — starting with "Sure! Here's the data:" before the JSON. That red box is the preamble your parser chokes on.
- Right: you add a trailing
assistantturn containing just{(the amber box, "you write this"). Because a reply is already in progress, Claude continues it (the green box) straight into JSON. - The payoff is the green caption: the reply is valid JSON from the very first token, so there's no preamble to strip.
In short: A prefill is you writing the opening of Claude's reply; it continues from there, skipping the preamble. (On current models this 400s — the next recipe is the replacement.)
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.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)
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.
- The
messageslist ends with anassistantturn ("{") that has no answer after it. Claude picks up exactly where you left off — it never re-writes your{. - Because the reply starts after your
{, you prepend the{back to reconstruct the full object — that's whatprint("{" + text)does. - This uses
claude-sonnet-4-5on 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.
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': ...}
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.
output_config={"format": {"type": "json_schema", "schema": {...}}}declares the exact shape: acapitalstring and apopulationinteger, both required, no extra keys.- 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. - 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 model | Current model (Opus 4.6+/Sonnet 4.6/Fable 5) |
|---|---|---|
| Force JSON / a schema | Prefill with { | output_config.format (structured output) |
| Force a classification label | Prefill the label start | Strict tool with an enum, or structured output |
| Skip "Sure, here is…" preambles | Prefill the first real word | System instruction: respond directly, no preamble |
| Continue an interrupted reply | Prefill the tail | Put the continuation ask in the user turn |
5 · Recipe 3 — long-context ordering intermediate
The window is enormous, so surely order doesn't matter — just dump everything in and ask. That assumption is the trap this section exists to spring. Claude's models take huge inputs (up to 1M tokens on Opus 4.8), and it's tempting to treat the prompt as a bag where placement is irrelevant. It isn't. The same document and the same question can give you a sharp, grounded answer or a vague one depending purely on the order you arranged them in — and getting that order right costs you nothing.
An analogy that makes the fix stick: think of how you'd brief a human researcher. You'd hand them the report first, let them read it, and then ask your question — so the question lands while the material is fresh and they know what they're looking for. Ask the question first and shove fifty pages at them afterward, and they've read the whole thing without knowing what mattered, then have to scan back through their memory of it. Claude behaves in a strikingly similar way: it attends most sharply to a question that arrives after the material it's about. Document first, question last is not a superstition — it's putting the ask where the model can act on it.
The mistake to avoid is the natural, conversational order — "answer this about the following: [huge blob]" — which front-loads a question the model can't yet ground and back-loads the evidence. A second, subtler mistake is trusting a long-input answer that cites nothing; on big documents a model can drift toward what it half-remembers instead of what the text says. The antidote, quote-then-answer, is the third rule below and it's the cheapest accuracy upgrade in this whole lesson. Anthropic's documented long-context guidance is three rules:
Long-context rules
- 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]".
- 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. - 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.
Compare the two rows: the tempting order on top, the recommended order below the dividing line.
- The top row is the natural, conversational order — the red
questionfirst, then the huge document. Claude reads the whole thing without knowing what mattered, so recall suffers. - The bottom row is the fix: the document first, then the question (Q), then a quote→answer step. The question now arrives while the material is fresh, so the model attends to it sharply.
- The green final box is the accuracy upgrade — forcing Claude to pull supporting quotes before it answers anchors it to the text and leaves you something to audit.
In short: Put the long document first and the question last, then make Claude quote before it answers — same content, better grounded answer.
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"))
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.
doc_blocksbuilds 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.- The instructions and the
<question>come after all the documents. Claude attends best to a question that follows the material it's about. - 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.
<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've just learned four techniques — and now you're staring at a blank prompt, applying them by hand, one tag at a time. There's a faster on-ramp that most people never touch, and it applies these exact techniques for you. It won't replace your judgement, but it will get you to a decent first draft in seconds instead of an hour of fiddling.
The right way to think about these Console tools is as a scaffolding crew, not an architect. A scaffolding crew can put up a sound, standards-compliant structure fast — the equivalent of laying down XML tags, a clear role, worked examples, and an answer contract — so you're not starting from bare ground. But they don't know what building you actually need; that judgement stays with you. The generator raises scaffolding from a plain-language description; the improver re-scaffolds a prompt you already have. Both give you a well-formed starting point built from the very practices in this lesson.
The mistake here is subtle because the output looks so polished: people paste a generated prompt straight into production and assume "reads well" equals "performs well." It doesn't. A prompt that's beautifully tagged and articulate can still score worse on your specific task than the scrappy one you already had — because the Console optimizes for general best practice, and only your eval set knows your task. The discipline is simple: generate, then measure. Treat the draft as a hypothesis and let the numbers from ch05 confirm or kill it. Here's what each tool is:
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:
Follow the arrows left to right — two possible inputs feed one Console tool, which produces a draft you must then test.
- The two left boxes are the two entry points: a plain-language task description (feeds the generator) or an existing prompt (feeds the improver).
- The purple middle box is the Console tool — it applies this lesson's practices for you (XML tags, a clear role, examples, an answer contract).
- The amber box is what you get back: a well-structured draft — a strong start, not a finished artifact. The green box is the non-negotiable last step: run it against your eval set, because a prompt that reads well can still score worse on your task.
In short: The Console tools scaffold a good draft fast, but only your evals prove it's better — generate, then measure.
| Tool | What it is | When to reach for it |
|---|---|---|
| Prompt generator | Describe 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 improver | Paste 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.
7 · Why migration is a workflow, not a find-and-replace advanced
The email arrives on a Tuesday: the model your product runs on is being retired in six weeks. Your instinct is to shrug — it's one string, you'll change claude-sonnet-4-5 to the new id, deploy, done by lunch. That instinct is how migrations become Friday-night incidents. Swapping the string is the easy five percent; the ninety-five percent that bites you is everything the string is silently attached to.
The analogy that reframes it correctly: a model version is a dependency, exactly like a library you import. Nobody sensible bumps a major library version by find-and-replace and ships without running the tests — because a major version can change the function signatures and the behaviour behind them. A model is the same kind of dependency, just an unusually opaque one. Its "API surface" can change (parameters that used to be accepted now error), and its "behaviour" can change (it follows instructions more literally, reaches for tools differently, calibrates length differently) — all under an innocent-looking id swap.
That second kind of change is the one that fools careful engineers, so name it plainly: your code can be completely valid and the output still gets worse. Nothing throws. No error, no 400, no red in the logs — just a prompt that was tuned for the old model's temperament now over-triggering or under-triggering on the new one's. A prompt is tuned for a model, and when the model moves, the prompt has to be re-validated against evidence, not vibes. That evidence is your eval set, and the disciplined loop that uses it is the rest of this lesson. Two facts make migration more than swapping a string:
What actually changes across a model version
- The request surface can change. Moving to Opus 4.6+ / Sonnet 4.6 / Fable 5, assistant-turn prefills 400,
budget_tokensis replaced bythinking={"type":"adaptive"}, and (Opus 4.7+) sampling paramstemperature/top_pare rejected. A blind id swap can start erroring. - 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.
Read left to right: the innocent-looking change on the left, the two things it drags along in the middle, and the response on the right.
- The left box is the change everyone sees — swapping the model id, "the easy 5%".
- The two middle boxes are what the swap silently changes. The amber one — the request surface — can start erroring (prefills 400, some sampling params rejected). The red one — behaviour — can get worse with no error at all: valid code, quietly degraded output.
- The green box is the only reliable answer to both: re-validate the prompt against your eval set, because vibes won't catch the silent regression.
In short: A model is a dependency — the id is 5% of the change; the request surface and behaviour underneath it are the 95% that only evals can verify.
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
Every technique so far has been about a single call; this is about how you change the model under thousands of them without your users noticing. If §7 convinced you that a model is a dependency, this section is the deployment procedure for upgrading it — and like any good deployment procedure, its whole value is that it turns a scary event into a routine, reversible one.
The mental model is the way careful teams already ship risky changes anywhere else: you don't flip the new version on for everyone at midnight and hope. You test it against known-good cases first, you release it to a sliver of real traffic while keeping the escape hatch open, you watch, and only then do you widen it. The migration loop is that same discipline applied to a model swap — pin, test, diff, adjust, canary, watch — and the reason to draw it as a loop is the "adjust" arrow that sends you back: a regression almost never means "the new model is bad," it means "my prompt no longer fits it," so you tune and re-run.
The mistake that turns this into a fire drill is skipping steps because the pass-rate looked fine — most often skipping the canary, or trusting the aggregate number without diffing per case. The eval set proves the new model is good on the cases you thought of; the canary proves it's good on the messy real traffic you didn't. Both matter, and the order matters: doing them in sequence is precisely the difference between a boring Tuesday and a Friday incident. Here's the loop:
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 announced → Run 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
- 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. - 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.
- 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.
- 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.
- 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.
- 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.
9 · Recipe 4 — the migration gate (this one runs offline) professional
"The new model passed — 90% either way, ship it." That sentence has caused more quiet quality regressions than any bug, because it treats a single averaged number as if it were the whole story. The "Pass?" diamond in the migration diagram deserves a real rule, and this section builds one you can actually run — no API key, no network, pure stdlib, a plain python file.py.
The insight the gate is built around is worth internalizing beyond this lesson: an average can stay perfectly still while the thing underneath it churns. Picture a class where the average test score holds at 75% two years running — and yet half the students who used to pass now fail, exactly offset by half who used to fail now passing. The headline says "no change"; the reality is a completely different class. Two model versions do this constantly: identical aggregate pass-rate, but they disagree on which specific cases they get right. If you only watch the average, that reshuffle is invisible — and one of those newly-broken cases is your most important customer flow.
So the gate refuses to reason about averages alone. It diffs per case and treats a regression — a case the old model passed but the new one fails — as the signal that matters, regardless of what the aggregate did. That's why its verdicts are stricter than "did the number go up": go demands zero regressions, not merely an equal-or-better rate; adjust means something slipped but stayed within a tolerance band, so tune the prompt and re-run; block means a real drop, do not ship. The common mistake it's designed to stop is shipping on a flat headline while a case silently broke. Here's the rule made concrete:
Start on the left with the two rows of cells (each cell = one eval case: green passed, red failed), then follow the arrows right.
- The old and new rows both have one red cell, so both score 90% — identical aggregate. But look closer: the red cell moved. The new model fixed one case and broke a different one.
- The per-case diff box is what the gate does that a headline can't — it spots the newly-broken case (a regression) regardless of the flat average.
- The three right boxes are the verdicts: go (green) needs zero regressions; adjust (amber) means a slip stayed within the tolerance band, so tune and re-run; block (red) means a real drop — don't ship.
In short: Judge a migration on per-case regressions, not the aggregate — an unchanged 90% can still hide a broken case, and that case decides go/adjust/block.
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
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.
- It computes each model's pass-rate and, crucially, the list of regressions — cases the old model passed but the new one fails.
gorequires zero regressions, not just an equal-or-better rate. - A small regression that keeps the rate within
adjust_bandreturnsadjust(tune prompts, re-run); a drop past the band returnsblock(don't ship). - The fake eval set is rigged so the new model fixes
case-07and breakscase-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.
10 · Tech-lead — owning migrations across a fleet tech-lead
Everything so far assumed one service and one prompt. Now multiply it by forty. When you lead a team, "the model is being retired" doesn't land on one call site — it lands on every service your group owns, each with its own prompts, its own quirks, its own on-call engineer who's already busy. The loop from §8 is correct for each service; the tech-lead's problem is making it happen forty times without it being forty heroic, error-prone one-offs.
The reframe that makes this tractable is the same one from §7, promoted to an org principle: a model version is a dependency, so manage it with the boring machinery you already use for dependencies. You don't hand-track which service uses which version of a library from memory — you pin it in one discoverable place, you gate upgrades on CI, you roll them out behind config you can flip, and you keep the old version installable until the new one has proven itself. Do exactly that for the model id and a fleet migration stops being a scramble and becomes a checklist anyone on the team can execute.
The failure mode a lead is specifically paid to prevent is the "scavenger hunt": a deprecation lands, and the team spends two days just finding every place a model id is hardcoded, because it's scattered as string literals across repos with no single grep that surfaces them. One pinned constant per service, evals wired into the build, the id behind a flag, and a written per-version change checklist turn that scavenger hunt into a few minutes of routine work — and, just as importantly, make the rollback a config flip rather than an emergency deploy. The levers that get you there:
Fleet-level migration discipline
- One pinned id per service, discoverable in one grep. A single
MODELconstant (or a shared config key) per service. When a deprecation lands, you wantrg 'claude-'to find every call site in seconds — not a scavenger hunt. - 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_gatereturningblock) fails the build. - 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.
- 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. - 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. - 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.
rg), an eval gate in CI, and the id behind a flag let the same routine run 40 times instead of 40 heroic one-offs.
Read left to right: one event on the left fans out to the whole fleet, then converges on one repeatable procedure.
- The left red box is the trigger — a deprecation notice with a hard deadline — and it doesn't land on one call site, it lands on every service (the three fanned-out boxes, "×40").
- The purple box is the reframe that makes it tractable: because each service has one pinned id (so
rg 'claude-'finds it), an eval gate wired into CI, and the id behind a feature flag, every service runs the same checklist. - The green box is the payoff — the migration becomes routine, with a canary and an instant config-flag rollback instead of an emergency deploy.
In short: Manage the model id like any dependency — pinned in one place, gated in CI, behind a flag — and a fleet-wide deprecation becomes a checklist anyone can run.
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.
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_gateand 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.
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)
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
systemparameter 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_tokenstight 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)
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
requiredlist - 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
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)
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
oldbut fails innew - Return
'go'when there are no regressions at all - Return
'adjust'when the net pass-rate drop is withinband, 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)
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 tooutput_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.formatreplacement. - 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_gateresult and act on per-case regressions, not just the pass-rate.
Knowledge check check yourself
Why did assistant-turn prefilling stop working on Opus 4.6+/Sonnet 4.6, and what replaces it for forcing JSON output?
Show answer
{ 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.Why can aggregate pass-rate hide a regression during a model migration, and what does the safe migration loop do about it?