Lab 6 · Domain 5 · 16.8%

Model Selection & Optimization

This is the second-largest domain on the exam, and it is where good engineering separates from expensive engineering. Two teams can build the same feature on Claude; one pays 10× more because it defaults to the biggest model everywhere, never caches, and estimates cost with the wrong tokenizer. This lab teaches the judgment behind the numbers: how tokens and context actually work, how to match a model tier and an effort level to a task, why a model upgrade is a code change that needs an eval run, and how to build a real cost model from response.usage, then cut that cost with prompt caching, the single biggest lever and the one people silently get wrong.

Answer key Lab6_Complete.ipynb
Exam skills covered
  • LLM Fundamentals (5.2%) — Basic understanding of LLMs (tokens, context windows, sampling, non-determinism, next-token generation), model options (fast mode, extended thinking, adaptive thinking, effort levels), and fundamental prompting techniques (zero-shot, single-shot, multi-shot).
  • Technical Fundamentals (6.1%) — Foundational technical concepts supporting AI application development, including basic engineering practices (integrating with SDKs that wrap REST APIs, websockets).
  • Model Selection and Tradeoffs (2.7%) — Claude model capabilities (Opus vs. Sonnet vs. Haiku use cases, adaptive thinking support), tradeoffs across quality/latency/cost parameters, and breaking behavior changes across model releases when selecting models for tasks.
  • Cost and Token Management (2.8%) — Token budgeting and cost management techniques for Claude applications, including token usage tracking, cost modeling, and caching techniques (prompt caching, cache check-pointing) for cost optimization.

1. LLM Fundamentals: Tokens, Context, and Non-Determinism

Everything in this lab is downstream of one fact: Claude reads and writes tokens, not words or characters. A token is a chunk of text: often a word fragment, sometimes punctuation or whitespace. English prose runs roughly 3–4 characters per token; code, JSON, and non-English text tokenize far less efficiently. You are billed per token, your context window is measured in tokens, and your rate limits are counted in tokens. If your mental model is "words," every cost estimate you make is wrong.

The context window is a hard budget shared by input and output. It is not "how much you can send"; it is the ceiling on input tokens plus generated output tokens combined. On a 1M-token model, a 950K-token prompt leaves room for only ~50K of output no matter what you set max_tokens to. Overrun the window and the request fails; you do not silently get a truncated prompt.

Claude generates text by next-token prediction: given everything so far, it produces a probability distribution over the next token, emits one, appends it, and repeats. This is why streaming works (tokens are produced one at a time) and why longer outputs cost proportionally more time and money.

Outputs are not guaranteed identical, even without sampling parameters. On current models temperature, top_p, and top_k are removed (they return 400; see Lab 1), so there is no knob to "make it deterministic." But even the old temperature=0 never guaranteed byte-identical outputs: floating-point non-associativity across hardware, batching, and routing all introduce variation. Design tests and pipelines to tolerate semantic equivalence, never exact-string equality on model output.

Prompting techniques you will be asked to name

The exam tests the vocabulary of few-shot prompting. These describe how many worked examples you place in the prompt before the real task:

  • Zero-shot — instruction only, no examples. "Classify this review as positive or negative."
  • Single-shot (one-shot) — one example demonstrating the input→output format, then the real input.
  • Multi-shot (few-shot) — several examples. Best when the task has a specific format, edge cases, or a labeling convention Claude must match exactly.

Multi-shot examples are stable, shared prefix content, which makes them a prime caching target (Section 6). Put them before your varying question, and put the cache breakpoint at the end of the examples.

2. Model Options: Adaptive Thinking, Effort, and Fast Mode

Adaptive thinking is the current mode. Rather than you allocating a fixed thinking budget, Claude decides when and how much to reason. You control the depth (and the total token spend) with the effort parameter, which lives inside output_config, not at the top level. Valid values are low, medium, high, xhigh, and max; the default is high.

python
response = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=16000,
    thinking={"type": "adaptive", "display": "summarized"},
    output_config={"effort": "high"},   # low | medium | high | xhigh | max  (default: high)
    messages=[{"role": "user", "content": "Refactor this module for testability..."}],
)

Two subtleties the exam rewards. First, on Opus-tier models omitting thinking means no thinking at all; set {"type": "adaptive"} explicitly if you want it. Second, display defaults to "omitted", so reasoning renders as empty thinking blocks unless you ask for "summarized". Effort is not just a quality dial: on agentic work, higher effort often reduces total cost because a better plan up front cuts the number of tool-calling turns.

EffortUse it forWhat to expect
lowChat, classification, simple lookups, subagentsFewer, more-consolidated tool calls; terse; lowest token spend
mediumBalanced everyday application workOften the favorable cost / quality point
highIntelligence-sensitive work (the default)The sweet spot for quality vs. token efficiency
xhighHard coding and agentic tasksBest setting for those; more planning up front
maxCorrectness matters more than costCeiling; can overthink, so test before committing

Fast mode

Fast mode is a research preview on Opus 4.8 and 4.7 only. It runs the same model at up to ~2.5× output tokens per second, at premium pricing. Three things are required on every request: use the beta endpoint (client.beta.messages.create), pass the beta flag fast-mode-2026-02-01, and set speed="fast" as a top-level parameter (not a header, not in extra_body). It is not available with the Batch API.

python
response = client.beta.messages.create(
    model="claude-opus-4-8", max_tokens=4096,
    speed="fast",
    betas=["fast-mode-2026-02-01"],
    messages=[{"role": "user", "content": "Draft a reply to this ticket."}],
)
# response.usage.speed reports which speed actually ran.

3. Model Selection: Match the Tier to the Task

There are four tiers to choose from. Prices are per million tokens (MTok), input / output.

ModelContextPrice (in / out per MTok)Best fit
claude-fable-51M$10 / $50Most capable overall: hardest reasoning and long-horizon agentic work
claude-opus-4-81M$5 / $25Most capable Opus tier; the sensible default for demanding work
claude-sonnet-51M$3 / $15Best speed / intelligence balance; high-volume production
claude-haiku-4-5200K$1 / $5Fastest and cheapest; simple, speed-critical, high-volume tasks

Learn the reasoning, not the table. The exam does not want you to memorize prices; it wants you to defend a choice. Two failure modes cost real money:

  • Defaulting to the top model everywhere wastes both cost and latency. A high-volume stream of short customer replies where speed and price dominate does not need Fable 5; a cheaper, faster tier gives you the same user-visible result for a fraction of the bill.
  • Using the cheapest tier for hard reasoning is a false economy. The tokens you "save" come back as rework: wrong answers, extra turns, human correction. Match the tier to the task's actual difficulty.

Effort is a second, orthogonal dial. Model tier sets the capability ceiling; effort sets how hard the model works within it. They combine. A common winning pattern on agentic workloads is a mid tier at high/xhigh effort: higher effort reduces turn count, and fewer turns can cost less overall than a top-tier model run at low effort that flails through many rounds.

4. Breaking Behavior Changes Across Releases

This is its own exam objective, and it is the reason to pin the model ID. A model upgrade is a code change (behavior, defaults, and even the request shape can differ between releases), so it needs an eval run, not a silent swap.

Pin the model ID. Always send an exact string like claude-opus-4-8. Never use a floating alias that resolves to "latest," and never guess a date suffix; a date-suffixed or invented ID returns a 404. Pinning means a new model release changes your behavior only when you choose to migrate, run evals, and ship. An unpinned upgrade is an untested deploy.

Real, current breakages you must handle when selecting or migrating models:

Old shapeWhat happens nowCurrent approach
thinking={"budget_tokens": N}HTTP 400, removedthinking={"type": "adaptive"} + output_config.effort
temperature / top_p / top_kHTTP 400, removedSteer with prompting, not sampling params
Assistant-turn prefillHTTP 400, rejectedoutput_config.format or a system instruction
Reading thinking text by defaultEmpty: display defaults to "omitted"Ask for display: "summarized"
Omitting thinking (Opus-tier)Runs with no thinkingSet {"type": "adaptive"} explicitly

5. Token Counting and Cost Modeling

Before you can optimize cost, you have to measure tokens accurately. Use the API's own counter, which is model-specific; pass the same model ID you will run inference on.

python
resp = client.messages.count_tokens(
    model="claude-opus-4-8",
    system=system_prompt,
    messages=[{"role": "user", "content": open("CLAUDE.md").read()}],
)
print(resp.input_tokens)   # accurate, model-specific token count
Never use tiktoken to estimate Claude cost. It is OpenAI's tokenizer. It undercounts Claude by roughly 15–20% on typical prose, and far more on code and non-English text. Any budget, rate-limit threshold, or cost estimate built on tiktoken (or gpt-tokenizer) is wrong for Claude. Use client.messages.count_tokens(...), which counts against the exact model you will call.

After a request, response.usage is the source of truth for what you were billed. But reading one field is the classic mistake:

python
u = response.usage

# The four fields that matter:
u.input_tokens                  # UNCACHED remainder only (full price)
u.cache_creation_input_tokens   # written to cache this request (~1.25x write)
u.cache_read_input_tokens       # served from cache this request (~0.1x)
u.output_tokens                 # generated (full output price)

# Total prompt size is the SUM of the three input fields:
total_prompt = (
    u.input_tokens
    + u.cache_creation_input_tokens
    + u.cache_read_input_tokens
)

# A real per-request cost model (Opus 4.8: $5 in / $25 out per MTok):
IN, OUT = 5.0 / 1e6, 25.0 / 1e6
cost = (
    u.input_tokens                * IN
    + u.cache_creation_input_tokens * IN * 1.25   # 5-min write premium
    + u.cache_read_input_tokens     * IN * 0.10   # cache read
    + u.output_tokens               * OUT
)
print(f"request cost: ${cost:.5f}")
input_tokens is the uncached remainder; sum all three input fields. If your agent ran for hours and input_tokens shows 4K, do not conclude the prompt was tiny. The rest was served from cache. Total prompt = input_tokens + cache_creation_input_tokens + cache_read_input_tokens. Modeling cost off input_tokens alone will make caching look like it did nothing, because you subtracted out exactly the tokens it saved.

6. Prompt Caching: The Biggest Cost Lever

Caching a large stable prefix can cut cost by up to ~90% on the cached portion. It is also the feature people most often silently misconfigure: there is no error when it fails to cache, just a bill that never goes down. Master this and you master this domain.

How it works

Caching is a prefix match. The cache key is the exact bytes of the rendered prompt up to each cache_control breakpoint. Any byte change anywhere in the prefix invalidates everything after it. Render order is fixed: toolssystemmessages. A breakpoint on the last system block therefore caches tools and system together.

python
response = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=16000,
    system=[
        {"type": "text", "text": SHORT_ROLE},
        {
            "type": "text",
            "text": LARGE_STABLE_CONTEXT,          # e.g. 50KB of frozen docs
            "cache_control": {"type": "ephemeral"},  # optional: "ttl": "1h"
        },
    ],
    messages=[{"role": "user", "content": user_question}],  # volatile: goes AFTER the breakpoint
)

You get a maximum of 4 breakpoints per request. A top-level cache_control={"type": "ephemeral"} on the request auto-places one on the last cacheable block, the simplest option when you do not need fine placement.

OperationPrice vs. base inputBreak-even
Cache read~0.1×
Cache write (5-min TTL)1.25×2 requests (1.25 + 0.1 < 2)
Cache write (1-hour TTL)≥3 requests (2 + 0.2 < 3)
Uncached input

The 1-hour TTL keeps entries alive across gaps in bursty traffic, but the doubled write cost means it needs more reads to pay off. If a prefix is used only once, do not cache it; you would pay the write premium for zero reads.

The minimum cacheable prefix is model-dependent. On Opus 4.8 it is 4096 tokens (Sonnet 4.6 and Fable 5 are 2048; Sonnet 4.5 is 1024). A prefix shorter than the minimum silently does not cache: no error, and cache_creation_input_tokens is 0. A 3,000-token system prompt on Opus 4.8 will never cache no matter how many breakpoints you add.

Silent invalidators: the weak / strong pair

The most common cause of a cache that never hits is a byte that changes every request, buried in the prefix. Here is the canonical bug (a timestamp interpolated into the system prompt) and its fix.

Weak — cache NEVER hits
python
from datetime import datetime

# datetime.now() changes the prefix bytes on EVERY request.
# Every request writes a new cache entry; none is ever read.
system = [{
    "type": "text",
    "text": f"You are a support agent. Current time: {datetime.now()}.\n\n{LARGE_STABLE_CONTEXT}",
    "cache_control": {"type": "ephemeral"},
}]
# usage.cache_read_input_tokens will be 0 forever.
Strong — stable prefix, volatile data moved out
python
# Freeze the system prompt. Put the timestamp in the user turn,
# AFTER the cached prefix, where it invalidates nothing before it.
system = [{
    "type": "text",
    "text": f"You are a support agent.\n\n{LARGE_STABLE_CONTEXT}",
    "cache_control": {"type": "ephemeral"},
}]
messages = [{
    "role": "user",
    "content": f"[time: {datetime.now()}] {user_question}",
}]
# Now cache_read_input_tokens climbs on every repeat request.
Silent invalidator (in the prefix)Why it breaks cachingFix
datetime.now() / uuid4()Prefix bytes differ every requestMove after the last breakpoint, or drop it
json.dumps(d) without sort_keys=TrueNon-deterministic key orderjson.dumps(d, sort_keys=True)
Iterating a set into the promptIteration order not stableSort into a list first
Tool list that varies per userTools render at position 0, invalidating everythingFreeze & sort tools; use tool search for dynamic sets
Conditional system sections (if flag: system += ...)Every flag combo is a distinct prefixMove variable sections after the breakpoint
Changing the model or tool definitionsInvalidates tools + system + messagesKeep model & tools stable within a session

Verify hits, then hunt the invalidator

Never assume caching works. Confirm it against usage. If cache_read_input_tokens is 0 across repeated identical-prefix requests, something in the prefix is changing.

python
def report(resp, label):
    u = resp.usage
    print(f"{label}: write={u.cache_creation_input_tokens} "
          f"read={u.cache_read_input_tokens} uncached={u.input_tokens}")

r1 = client.messages.create(**params); report(r1, "req 1")   # write > 0, read == 0  (cold)
r2 = client.messages.create(**params); report(r2, "req 2")   # read > 0             (warm)

# If req 2 still shows read == 0 with an identical prefix, an invalidator is at work.
# Diff the two rendered prompts byte-for-byte to find the culprit.

Two placement traps

  • Shared prefix, varying suffix. When many requests share a large preamble but differ in the final question, put the breakpoint at the end of the shared portion, not the end of the whole prompt. If you cache to the end of a prompt whose tail differs every time, every request writes a distinct entry and nothing is ever read.
  • Concurrency. A cache entry becomes readable only after the first response begins streaming. Fire N parallel identical-prefix requests at once and all N pay full price; none can read what the others are still writing. Fan-out pattern: send one request, await the first token, then fire the remaining N−1 so they read the cache the first one just wrote.

7. Batching for Cost

The last cost lever is the Message Batches API (covered in Lab 1): it processes high-volume, latency-tolerant workloads within a 24-hour window at 50% of standard token cost. Caching and batching are complementary: a batch with a shared cached system prompt gets both discounts. Reach for batching whenever the user is not waiting and the work is large. Note that fast mode is not available with the Batch API; the two optimizations point in opposite directions (latency vs. throughput).

8. Lab Exercise: Measure, Break, Model, Sweep

Self-driven lab Lab6_Self_Driven_Lab.ipynb

Objective: prove to yourself that caching works, that you can break it, and that you can read the true cost off usage.

  1. Measure a cache hit. Build a request with a system prompt of at least 4096 tokens on claude-opus-4-8 and a cache_control breakpoint on the last system block. Send it twice with an identical prefix. Confirm request 1 shows cache_creation_input_tokens > 0, cache_read_input_tokens == 0 and request 2 shows cache_read_input_tokens > 0.
  2. Break it deliberately. Interpolate datetime.now() into the system prompt and re-run the pair. Watch cache_read_input_tokens stay at 0. Now move the timestamp into the user turn and confirm the hit returns. You have just reproduced the single most common caching bug.
  3. Prove the minimum. Shrink the system prompt to ~3,000 tokens on Opus 4.8. Observe that cache_creation_input_tokens is 0 with no error: it is below the 4096-token minimum and silently does not cache.
  4. Build a cost model. Write a function that takes response.usage and returns dollars, using all four fields and the write/read multipliers. Run it on your cached and uncached requests and confirm the cached run is cheaper, and that summing all three input fields recovers the full prompt size.
  5. Sweep effort levels. Run the same agentic task at low, high, and xhigh. Record output tokens, turn count, and total cost at each. Confirm for yourself that higher effort can lower total cost by cutting turns.

Step 2 is the one that shows up in production. Do it until the failure is obvious to you at a glance.

Check Yourself

Exam-style items: this is the second-biggest domain, so there are five. Commit to an answer before expanding.

A developer estimates the cost of a Claude feature by running the prompts through tiktoken and multiplying by the per-token price. The real bill comes in about 18% higher than estimated. What is the most likely cause?
  • A. Claude's per-token prices changed between estimate and billing.
  • B. tiktoken is OpenAI's tokenizer and undercounts Claude tokens; the estimate was low from the start.
  • C. The developer forgot to include output tokens.
  • D. Prompt caching inflated the token count.

Correct: B. tiktoken tokenizes for OpenAI models and undercounts Claude by roughly 15–20% on typical text (more on code). The fix is client.messages.count_tokens(model=..., ...), which counts against the exact Claude model you will call.

A is possible in theory but doesn't explain a consistent one-directional gap of exactly the size tiktoken's undercount predicts. C would produce a much larger and more erratic error. D is backwards: caching lowers cost and does not change how many tokens the prompt contains.

An application sends the same large system prompt on every request with a cache_control breakpoint on it, but usage.cache_read_input_tokens is 0 across many repeated requests. Which of the following could be the cause? (Choose two.)
  • A. The system prompt interpolates datetime.now() before the breakpoint.
  • B. The prompt is 3,000 tokens and the model is claude-opus-4-8.
  • C. max_tokens is set too low.
  • D. The response uses output_config={"effort": "high"}.
  • E. The request streams instead of using a blocking call.

Correct: A and B. A changes the prefix bytes every request, so each request writes a fresh entry and never reads one. B is below Opus 4.8's 4096-token minimum cacheable prefix, so it silently does not cache (cache_creation_input_tokens is also 0).

C caps output length and has nothing to do with prefix caching. D controls thinking depth, not caching; toggling thinking/effort invalidates only the messages tier, not system. E does not affect whether a prefix caches; streaming vs. blocking is orthogonal.

A support product handles a high volume of short customer replies. Latency and cost per reply dominate; the answers are simple and formulaic. Which choice best fits the requirement?
  • A. Use claude-fable-5 at effort: "max" for the best possible quality.
  • B. Use a cheaper, faster tier (e.g. claude-haiku-4-5 or claude-sonnet-5) at a modest effort level.
  • C. Use claude-opus-4-8 and raise max_tokens to guarantee complete replies.
  • D. Use the Batch API so every reply is 50% cheaper.

Correct: B. When speed and cost dominate and the task is simple, defaulting to the top model wastes both. Match the tier to the task's actual difficulty; a cheaper, faster tier delivers the same user-visible result for a fraction of the bill.

A pays the most for capability the task does not need, and max effort adds latency, the opposite of the requirement. C raises the output ceiling without engaging the cost/latency tradeoff. D is wrong because these replies are latency-sensitive (the customer is waiting) and batches have no latency SLA.

After a long agentic run on claude-opus-4-8, response.usage.input_tokens reads 4,100 even though the conversation clearly involved hundreds of thousands of tokens of context. What does this indicate?
  • A. The context was silently truncated to 4,100 tokens.
  • B. input_tokens is the uncached remainder; the rest was served from cache. Sum it with the two cache fields for the true prompt size.
  • C. A bug in the SDK is under-reporting usage.
  • D. The model switched to a smaller context window mid-run.

Correct: B. input_tokens reports only the tokens processed at full price. Total prompt size = input_tokens + cache_creation_input_tokens + cache_read_input_tokens. A tiny input_tokens after a long run is the signature of caching working well, not of lost context.

A and D describe failures that would surface as errors or degraded answers, not a small usage number. C blames the SDK for reporting exactly what the API returned: the value is correct; the interpretation was wrong.

Which of the following will cause a request to claude-opus-4-8 to fail with HTTP 400? (Choose two.)
  • A. thinking={"type": "adaptive"} with output_config={"effort": "xhigh"}
  • B. thinking={"type": "enabled", "budget_tokens": 8000}
  • C. temperature=0.5
  • D. cache_control={"type": "ephemeral", "ttl": "1h"}
  • E. A pinned model ID of claude-opus-4-8

Correct: B and C. A fixed thinking budget via budget_tokens is removed on current models and returns 400; adaptive thinking plus output_config.effort replaces it. Sampling parameters (temperature, top_p, top_k) are likewise removed and return 400; steer with prompting instead.

A is the current, correct way to configure thinking and depth. D is a valid 1-hour-TTL cache control. E is exactly what you should do: a pinned, exact model ID is required, and it is not date-suffixed or a floating alias.

Key Takeaways

  • Claude bills in tokens, not words. The context window is a hard budget shared by input + output. Outputs are never guaranteed identical, even without sampling params.
  • Adaptive thinking is current; effort (lowmax, default high) lives in output_config. Higher effort on agentic work often lowers total cost by cutting turns. Fast mode is Opus 4.8/4.7 only, beta, and not on the Batch API.
  • Match the tier to the task: top model everywhere wastes money and latency; cheapest tier on hard reasoning is paid back in rework. Tier and effort are orthogonal dials.
  • A model upgrade is a code change: pin the model ID and run evals before migrating. budget_tokens, sampling params, and prefill all return 400; display defaults to "omitted"; omitting thinking on Opus-tier means no thinking.
  • Count tokens with count_tokens, never tiktoken. input_tokens is the uncached remainder: sum all three input fields for the real prompt size, and build your cost model from usage.
  • Prompt caching is the biggest lever: a prefix match, render order toolssystemmessages, max 4 breakpoints. Reads ~0.1×, writes 1.25× (5m) / 2× (1h). Below the model's minimum prefix (4096 on Opus 4.8) it silently does not cache. Verify with cache_read_input_tokens; hunt silent invalidators when it stays 0.
  • Batching gives 50% off for latency-tolerant volume, and it stacks with caching.