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:
| Model | Input $/1M | Output $/1M | Context | Reach for it when |
|---|---|---|---|---|
claude-haiku-4-5 | $1 | $5 | 200K | Classification, extraction, routing, anything high volume |
claude-sonnet-5 | $2 | $10 | 1M | The everyday default for production work |
claude-opus-5 | $5 | $25 | 1M | Agentic coding, deep reasoning, long-horizon tasks |
claude-fable-5 | $10 | $50 | 1M | The genuinely hardest work you have |
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.
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
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.
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.
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.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.
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.
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:
| Step | Change | Effect |
|---|---|---|
| 0. Measure | Log usage per request | You discover the handbook is 96% of every prompt |
| 1. Route | Classification moves to Haiku | About 24x on the per-request price, biggest single win |
| 2. Cache | Handbook becomes a cached prefix | ~89% off the input side at this volume |
| 3. Batch | Overnight backlog moves to the Batch API | Another 50% on whatever is not interactive |
| 4. Stream | The interactive path streams | No 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
highahead oflowon 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_tokensexcludes cached tokens - add all three usage fields or you will under-report.- Optimise in order - measure, route, cache, batch, stream.