Cost & Performance Engineering

Making an AI feature affordable and fast enough to keep in production.

Introduction

AI features have a habit of working beautifully in a demo and becoming indefensible at scale. The prototype costs nothing because it runs a hundred times; production runs it a million times and someone starts asking questions. Every measurement in this lesson was taken by running the code against the real API, because the interesting result is almost never the one you would guess: the first one below is a 26x cost difference between two models that returned identical answers.

1. Where the Money Actually Goes

Four things are billed, and they do not cost the same. Output tokens are roughly five times input tokens. Thinking tokens are billed as output. Cached reads are about a tenth of input. Getting the mental model right is most of the work:

ModelInput $/1MOutput $/1MContextReach for it when
claude-haiku-4-5$1$5200KClassification, extraction, routing, anything high volume
claude-sonnet-5$2$101MThe everyday default for production work
claude-opus-5$5$251MAgentic coding, deep reasoning, long-horizon tasks
claude-fable-5$10$501MThe genuinely hardest work you have
Prices move. Treat this table as of August 2026 and read the live numbers from the pricing page before you build a budget on it.

2. Model Routing: The Biggest Lever

The most expensive habit in AI engineering is using one capable model for everything. Here is the same three-review sentiment classification run on three models:

# routing.py - one classification task, three models, real prices
import anthropic

client = anthropic.Anthropic()

PRICES = {   # USD per 1M tokens, list price, August 2026
    "claude-haiku-4-5": (1.00, 5.00),
    "claude-sonnet-5":  (2.00, 10.00),
    "claude-opus-5":    (5.00, 25.00),
}

REVIEWS = [
    "Shipping took three weeks and the box was crushed. Never again.",
    "Exactly what I needed, arrived early, works perfectly.",
    "It's fine. Does the job, nothing special.",
]

REPEATS = 3        # how much Opus thinks varies per call, so do not trust one sample

print(f"{'model':<20}{'in':>6}{'out':>6}{'cost/1k reqs':>14}   answers")
for model in PRICES:
    total_in = total_out = calls = 0
    answers = []
    for _ in range(REPEATS):
        for review in REVIEWS:
            r = client.messages.create(
                model=model, max_tokens=2000,
                messages=[{"role": "user", "content":
                    "Classify sentiment as positive, negative, or neutral. "
                    f"Reply with one word only.\n\n{review}"}],
            )
            total_in += r.usage.input_tokens
            total_out += r.usage.output_tokens
            calls += 1
            # Opus 5 thinks by default, so content[0] may be a ThinkingBlock.
            answers.append(next(b.text for b in r.content if b.type == "text").strip().lower())

    inp, out = PRICES[model]
    per_req = (total_in * inp + total_out * out) / 1_000_000 / calls
    print(f"{model:<20}{total_in:>6}{total_out:>6}{per_req * 1000:>14.4f}$   "
          f"{sorted(set(answers))}")
Expected Output:
model                   in   out  cost/1k reqs   answers
claude-haiku-4-5       339    45        0.0627$   ['negative', 'neutral', 'positive']
claude-sonnet-5        474    48        0.1587$   ['negative', 'neutral', 'positive']
claude-opus-5          474   458        1.5356$   ['negative', 'neutral', 'positive']

All three got every answer right on all nine calls. Haiku costs $0.06 per thousand requests, Opus costs $1.54: a 24x difference for identical output. At a million classifications a month that is $63 against $1,536.

Look at why, because it is not the headline price. Opus's input was the same 474 tokens as Sonnet's. Its output was 458 tokens against Sonnet's 48. Opus 5 thinks by default, so a task needing one word produced roughly ten times the billable output, and usage.output_tokens_details.thinking_tokens confirms where it went: a single one-word classification on Opus 5 returns 53 output tokens, 48 of them thinking. That is the trap: routing a trivial task to a reasoning model does not cost you the price ratio, it costs you the price ratio multiplied by the thinking it does on your behalf.

Note the REPEATS = 3. How much Opus decides to think varies from call to call, and a single sample moved this ratio between 17x and 26x across runs. Any cost number you quote from one API call is a number you cannot defend.

Route by Task, Not by Habit

Incoming requestClassify itHaiku, cheapRoutine-> HaikuStandard-> SonnetHard-> Opus

Figure 1: The router itself runs on the cheapest model. A Haiku call to decide where to send the work costs far less than it saves.

Do not route mid-conversation. Prompt caches are scoped per model, so switching models partway through a conversation discards the cache and re-processes the whole prefix at full price. Route at the start, or spawn a separate sub-task for the cheaper model and keep the main loop where it is.

3. Effort: The Dial Inside a Model

Once you have picked a model, effort controls how much thinking and acting it does. It accepts low, medium, high (the default), xhigh, and max:

# effort.py - four levels, four samples each, on one Opus 5 question
import statistics
import time

import anthropic

client = anthropic.Anthropic()

PROMPT = ("A team of 5 engineers maintains a Python monolith with 40 minute CI. "
          "Should they split it into services? Answer in under 120 words.")

IN_PRICE, OUT_PRICE = 5.00, 25.00        # Opus 5, USD per 1M tokens
SAMPLES = 4                              # one sample per level is noise, not a measurement

print(f"{'effort':<9}{'thinking':>9}{'output':>8}{'cost/1k reqs':>14}{'latency':>9}")
for effort in ("low", "medium", "high", "max"):
    thinking, output, sent, latency = [], [], [], []
    for _ in range(SAMPLES):
        t0 = time.monotonic()
        r = client.messages.create(
            model="claude-opus-5", max_tokens=8000,
            output_config={"effort": effort},
            messages=[{"role": "user", "content": PROMPT}],
        )
        latency.append(time.monotonic() - t0)
        thinking.append(r.usage.output_tokens_details.thinking_tokens)
        output.append(r.usage.output_tokens)
        sent.append(r.usage.input_tokens)

    med_out = statistics.median(output)
    per_1k = (statistics.median(sent) * IN_PRICE + med_out * OUT_PRICE) / 1_000
    print(f"{effort:<9}{statistics.median(thinking):>9.0f}{med_out:>8.0f}"
          f"{per_1k:>13.4f}${statistics.median(latency):>8.1f}s")
Expected Output:
effort    thinking  output  cost/1k reqs  latency
low             22     314       8.1050$     6.6s
medium          45     331       8.5300$     7.1s
high            57     334       8.5925$     6.1s
max            249     510      12.9925$     8.4s

Read the thinking column, not the output column. Thinking tokens go 22, 45, 57, 249 across the four levels: that is the dial itself, and it moves more than tenfold. Total output moves only from 314 to 510, because the visible answer stays about the same length no matter how hard the model thought about it.

Two practical readings. The step from low to high is small here, about 6 percent more spend, because this question does not demand much reasoning; max is where the cost turns, at 60 percent above low. And latency barely separates the first three levels (6.6s, 7.1s, 6.1s), so effort is a spend dial, not a speed dial, until you reach max.

Sample it, do not measure it once. The SAMPLES = 4 in that block is load bearing. On single runs, high came back faster than low as often as not, and thinking tokens at one level ranged from 43 to 239. A one-shot timing of an LLM call measures the network as much as the model.
Effort is not a verbosity control. It changes how much the model thinks, which is only loosely related to how much it writes. If you want shorter user-facing answers, ask for shorter answers in the prompt. Lowering effort to get brevity gets you a less considered answer of roughly the same length.

4. Caching Pays From the Second Request

Lesson 17 covered how caching works. Here is what it is worth. Writes cost 1.25x at the default 5 minute TTL, reads cost 0.1x, so the arithmetic is simple enough to just run:

# cache_math.py - what a cached prefix is worth at each request count
PREFIX = 4447     # tokens in the cached prefix (measured in Lesson 17)
RATE   = 1.00     # Haiku input, $ per 1M tokens


def uncached(n):
    return n * PREFIX * RATE / 1_000_000


def cached(n, write_mult=1.25):     # 1.25x for the 5 minute TTL
    return (PREFIX * RATE * write_mult
            + (n - 1) * PREFIX * RATE * 0.1) / 1_000_000


print(f"{'requests':>9}{'uncached':>11} {'cached 5m':>11} {'saving':>9}")
for n in (1, 2, 3, 10, 100):
    u, c = uncached(n), cached(n)
    print(f"{n:>9}{u:>11.5f}${c:>11.5f}${(1 - c / u) * 100:>9.0f}%")
Expected Output:
 requests   uncached   cached 5m    saving
        1    0.00445$    0.00556$      -25%
        2    0.00889$    0.00600$       33%
        3    0.01334$    0.00645$       52%
       10    0.04447$    0.00956$       78%
      100    0.44470$    0.04958$       89%

A single request is 25 percent worse off, because you paid the write premium and never read it back. Two requests break even. By a hundred you are saving 89 percent. This is why caching belongs on anything with a shared prefix and repeat traffic, and why it is actively counterproductive on genuine one-shot calls.

5. Batch Anything That Can Wait

The Batch API processes requests asynchronously at 50 percent of standard price. Most batches finish within an hour, the ceiling is 24. If a workload is not latency-sensitive, this is a straight halving of its bill for a few lines of code:

# batch.py - submit, poll, and collect results by custom_id
import time

import anthropic
from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
from anthropic.types.messages.batch_create_params import Request

client = anthropic.Anthropic()

reviews = [
    "Shipping took three weeks and the box was crushed. Never again.",
    "Exactly what I needed, arrived early, works perfectly.",
    "It's fine. Does the job, nothing special.",
]

batch = client.messages.batches.create(requests=[
    Request(
        custom_id=f"r-{i}",
        params=MessageCreateParamsNonStreaming(
            model="claude-haiku-4-5", max_tokens=10,
            messages=[{"role": "user", "content":
                "Classify sentiment as positive, negative, or neutral. "
                f"Reply with one word only.\n\n{t}"}],
        ),
    )
    for i, t in enumerate(reviews)
])
print("batch id     :", batch.id)
print("status       :", batch.processing_status)

while True:                      # poll until done
    b = client.messages.batches.retrieve(batch.id)
    if b.processing_status == "ended":
        break
    time.sleep(10)

print("final status :", b.processing_status)
print("counts       :", b.request_counts)

# Results arrive in ANY order. Key by custom_id, never by position.
for res in client.messages.batches.results(batch.id):
    answer = res.result.message.content[0].text.strip()
    print(" ", res.custom_id, "->", res.result.type, "|", answer)
Expected Output:
batch id     : msgbatch_01AbCdEfGhIjKlMnOpQrStUv
status       : in_progress
final status : ended
counts       : MessageBatchRequestCounts(canceled=0, errored=0, expired=0, processing=0, succeeded=3)
  r-1 -> succeeded | Positive
  r-0 -> succeeded | Negative
  r-2 -> succeeded | Neutral

Nightly summarisation, backfills, bulk classification, evaluation runs, and report generation are all natural fits. Anything a user is actively waiting on is not.

Results come back in any order. The run above submitted r-0, r-1, r-2 and got back r-1, r-0, r-2. Key them by custom_id, never by position in the list. This is the bug that makes a batch job silently attribute every result to the wrong record, and it does not reproduce reliably enough to catch in testing.

6. Streaming Buys Perceived Latency

Streaming does not make generation faster. It makes the wait visible, which users experience as a different thing entirely:

# streaming.py - time to first token against time to full answer
import statistics
import time

import anthropic

client = anthropic.Anthropic()

Q = "Explain what a database index is, in about 150 words."
SAMPLES = 3        # network latency is noisy, so take a median

blocking, first_token, streamed = [], [], []
for _ in range(SAMPLES):
    # Non-streaming: the user sees nothing until the whole answer is ready.
    t0 = time.monotonic()
    client.messages.create(model="claude-haiku-4-5", max_tokens=400,
                           messages=[{"role": "user", "content": Q}])
    blocking.append(time.monotonic() - t0)

    # Streaming: measure time to the FIRST visible token.
    t0 = time.monotonic()
    first = None
    with client.messages.stream(model="claude-haiku-4-5", max_tokens=400,
                                messages=[{"role": "user", "content": Q}]) as stream:
        for _chunk in stream.text_stream:
            if first is None:
                first = time.monotonic() - t0
        streamed.append(time.monotonic() - t0)
    first_token.append(first)

b = statistics.median(blocking)
f = statistics.median(first_token)
s = statistics.median(streamed)

print(f"non-streaming: nothing visible for {b:.2f}s, then the whole answer")
print(f"streaming    : first token at {f:.2f}s, complete at {s:.2f}s")
print(f"perceived improvement: {b / f:.1f}x faster to first visible output")
Expected Output:
non-streaming: nothing visible for 2.59s, then the whole answer
streaming    : first token at 0.51s, complete at 2.53s
perceived improvement: 5.0x faster to first visible output

Total time is unchanged, 2.53s streamed against 2.59s blocking, which is the point: nothing got faster. Time to something on screen went from 2.59s to 0.51s, a 5.0x improvement in the only number the user actually feels.

Streaming is also a correctness requirement at scale. Non-streaming requests with large max_tokens hit HTTP timeouts, and the SDK refuses a request it estimates cannot finish in ten minutes. The threshold is exact: max_tokens=21333 is sent, 21334 raises ValueError: Streaming is required for operations that may take longer than 10 minutes. Above that, stream and use .get_final_message() if you only want the finished result.

7. You Cannot Optimise What You Do Not Track

Every response carries a usage object. Recording it per request, tagged by model and feature, is the smallest useful cost dashboard and takes a wrapper function:

# cost_dashboard.py - a wrapper that prices every call as it happens
import collections

import anthropic

client = anthropic.Anthropic()

PRICES = {"claude-haiku-4-5": (1.00, 5.00)}   # USD per 1M tokens

spend = collections.defaultdict(float)


def tracked(model, **kwargs):
    r = client.messages.create(model=model, **kwargs)
    u = r.usage
    inp, out = PRICES[model]

    # Cached reads are ~0.1x, cache writes 1.25x at the default TTL.
    dollars = (
        u.input_tokens * inp
        + (u.cache_read_input_tokens or 0) * inp * 0.1
        + (u.cache_creation_input_tokens or 0) * inp * 1.25
        + u.output_tokens * out
    ) / 1_000_000

    spend[model] += dollars
    return r


for question in ["Name one benefit of an index.", "Name one cost of an index."]:
    r = tracked("claude-haiku-4-5", max_tokens=60,
                messages=[{"role": "user", "content": question}])
    u = r.usage
    print(f"prompt tokens: uncached={u.input_tokens:4} "
          f"cache_write={u.cache_creation_input_tokens or 0:4} "
          f"cache_read={u.cache_read_input_tokens or 0:4} "
          f"out={u.output_tokens:4}")

for model, dollars in spend.items():
    print(f"{model}: ${dollars:.6f} over {len(spend)} tracked model(s)")
Expected Output:
prompt tokens: uncached=  14 cache_write=   0 cache_read=   0 out=  46
prompt tokens: uncached=  14 cache_write=   0 cache_read=   0 out=  34
claude-haiku-4-5: $0.000428 over 1 tracked model(s)

Note that input_tokens is only the uncached remainder. Total prompt size is input_tokens + cache_creation_input_tokens + cache_read_input_tokens. The run above has zeros in the two cache fields because nothing was cached; add a cached prefix and input_tokens collapses to the handful of tokens in the user turn, exactly as it does in Lesson 17 (13 uncached tokens against a 4,447 token prefix). Teams routinely under-report their token usage by reading the first field alone and wondering why the invoice disagrees with the dashboard.

8. Putting It Together

A support-triage feature classifies a ticket against a 4,447 token policy handbook, one million times a month, on Opus with no caching. Applying this lesson in order, using the measured figures above:

StepChangeEffect
0. MeasureLog usage per requestYou discover the handbook is 96% of every prompt
1. RouteClassification moves to HaikuAbout 24x on the per-request price, biggest single win
2. CacheHandbook becomes a cached prefix~89% off the input side at this volume
3. BatchOvernight backlog moves to the Batch APIAnother 50% on whatever is not interactive
4. StreamThe interactive path streamsNo cost change, roughly 4x better perceived latency

The ordering matters. Routing first, because it is multiplicative and dwarfs everything else. Caching second, because it changes prompt structure and you want to do that once. Batching and streaming last, because they are per-path decisions rather than global ones.

Key Takeaways

  • Routing is the biggest lever - measured 24x between Haiku and Opus on a task where all 27 answers were identical.
  • Reasoning models bill their thinking as output - Opus produced 458 output tokens where Sonnet produced 48, for the same one-word answers.
  • Effort moves thinking, not visible length - thinking tokens went 22 to 249 across the four levels while the answer stayed the same size.
  • Measure over several samples - one API call tells you about the network; single runs put high ahead of low on latency as often as not.
  • Caching is a loss on one request and 89% off at a hundred - it needs repeat traffic over a shared prefix to pay.
  • Batch halves the price of anything nobody is waiting for - and results come back unordered, so key by custom_id.
  • Streaming changes perceived latency, not real latency - 5.0x to first token here, with total time unchanged.
  • input_tokens excludes cached tokens - add all three usage fields or you will under-report.
  • Optimise in order - measure, route, cache, batch, stream.