Skip to content
Software Atlas

LLMs in Production

A model call is a slow, flaky, metered dependency. Where the time goes (time to first token vs tokens per second), streaming from the model API through your server to the browser, three kinds of caching, retries that don't cause storms, what each request really costs in tokens, rate limits, logging and model upgrades.

⏱ ~50 min read14 sections7 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceIn production, a model call is a dependency that is slow (seconds, not milliseconds), flaky (busy, rate-limited, sometimes cut off halfway) and metered (billed per token, in and out). Almost everything on this page is a way to hide the slowness, survive the flakiness, and keep the meter in check.

Those are the three facts, and each one maps to a set of tools:

FactWhat it causesWhat you do about it
SlowA long reply takes 5–30 s to finishStream it, so words appear in about a second (§02–§04); ask for shorter output
Flaky429 rate limits, overloaded errors, broken streamsRetry the right errors with backoff and jitter, time out, fall back (§07–§08)
MeteredCost grows with every token, every turn, every retryCount tokens, cache what repeats, cap output, batch offline work (§05–§06, §09–§10)

One request, from your server's point of view, looks like this. The long part is the model writing:

One model call (not to scale)
your server
send
waiting for the first token
tokens arrive one after another…
done
model API
read prompt
write the reply, token by token
The wait before the first token depends mostly on the prompt's length. After that, the model writes one token after another, so the length of the reply sets how long the rest takes.

What this page covers

1

Speed

Where the seconds go (§02), streaming from the model API to your server (§03) and on to the browser (§04).
2

Caching and failure

Caching whole answers (§05) and the provider's prompt cache (§06). Retries (§07) and what to do when retries aren't enough (§08).
3

Money and operations

What each request costs (§09), rate limits and capacity (§10), what to log (§11), and staff-level concerns: upgrades, providers, privacy, self-hosting (§12).
Foundations§02

Where the seconds go

In one sentenceTotal time ≈ time to first token + output tokens ÷ tokens per second. The first part grows with the prompt; the second grows with the reply, and it's usually the bigger one.

Three numbers, not one

A normal web request has one latency number. A model call has three you should know apart:

  1. Time to first token (TTFT). From sending the request until the first piece of the reply arrives. The model spends it reading your prompt. A longer prompt means a longer TTFT.
  2. Tokens per second. How fast the reply comes out once it has started. The model writes one token at a time, each one depending on the token before it, so this rate is roughly steady for a given model and load.
  3. Total time. When the last token arrives. That's TTFT plus the time to write every output token.

Why are the two phases so different? Reading the prompt can be done for all tokens at once, in parallel. Writing can't, because token 7 needs token 6 to exist first. How LLMs Work → inference shows this in detail; it calls the two phases prefill and decode.

A worked example

Assume (these are illustrative numbers, not measurements; real speeds vary by model, provider and load):

  • a 2,000-token prompt with a TTFT of 0.8 s,
  • an output speed of 50 tokens per second,
  • a 400-token reply.

Total ≈ 0.8 s + 400 ÷ 50 = 0.8 + 8 = 8.8 s. Now try the two obvious fixes:

ChangeNew totalSaved
Halve the prompt (TTFT maybe 0.8 → 0.6 s)0.6 + 8 = 8.6 s~0.2 s
Halve the reply (400 → 200 tokens)0.8 + 4 = 4.8 s4 s

Output length dominates. If a feature is slow, first ask whether the model needs to write that much. Ask for a short answer, a list of IDs instead of prose, or JSON with only the fields you use.

Streaming: same total, much shorter wait

You can't make the model write faster, but you can show each token as soon as it exists. That's Streaming response: the API sends the reply in small pieces while it is still being written, instead of one response at the end. The total time doesn't change. What changes is how long the user stares at a spinner.

Streamed vs all at once
The same reply, the same model speed. Press Send and watch when the user first sees something.
Not streamed
(press Send)
Streamed
(press Send)
0s1s2snot streamedstreamedTTFT
one token arrivingtime to first tokenwhole reply shown at once

65 output tokens: total ≈ 1.2 s + 65 ÷ 40 tokens/s ≈ 2.8 s, streamed or not. Streaming doesn't make the model faster; it shows the first words 1.6 s sooner.

How this data was made: The token split is real: the reply was cut into 65 tokens by OpenAI's o200k_base tokenizer (via gpt-tokenizer); other models split differently. The reply text is Illustrative (written by hand, no model ran), and the timing comes from the two sliders, which are assumptions, not measurements. Real streams also arrive in uneven bursts.

Drag the sliders. With a long reply, the non-streamed version keeps the user waiting for many seconds; the streamed version starts after the TTFT whatever the length. People also read slower than many models write, so a streamed answer often feels finished as soon as it starts.

Practitioner§03

Streaming from the model API

In one sentenceAdd "stream": true and the API answers with one long HTTP response that stays open until the reply is finished, made of small text events. Your code reads it piece by piece, glues the text pieces together, and must cope with events split across network chunks and with errors that arrive mid-stream.

The format: server-sent events

Model APIs stream with server-sent events (SSE): one long HTTP response whose body is plain text, a few field: value lines per event, each event ended by a blank line. WebSockets & SSE → SSE explains the format and the browser's EventSource; here we only need to read such a stream from server code.

Here is the sequence Anthropic's Messages API sends (event names and fields as documented in October 2026; OpenAI and Google also stream over SSE, with different event names):

what arrives (shortened)
event: message_start
data: {"type":"message_start","message":{"id":"msg_…","content":[],"usage":{"input_tokens":25,"output_tokens":1}}}

event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}

event: ping
data: {"type":"ping"}

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"!"}}

event: content_block_stop
data: {"type":"content_block_stop","index":0}

event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":15}}

event: message_stop
data: {"type":"message_stop"}
  • The reply text arrives in deltas: each content_block_delta carries the next small piece in delta.text. Glue them together in order.
  • message_delta near the end carries the Stop reason (end_turn, max_tokens, refusal, …) and the output token count so far. The docs note the counts in it are cumulative: the total, not an increment.
  • ping events can appear anywhere. They keep the connection busy so proxies don't close it; ignore them.
  • An error event can arrive after the 200 status, for example overloaded_error when the provider runs out of capacity mid-reply. The docs also say new event types may be added, so ignore types you don't know instead of failing.
  • On models that think first, a thinking block streams before the text block, with thinking_delta and signature_delta events (the thinking text is often empty, when the reasoning isn't shown). Anthropic's newest models always think (as of October 2026), so these will arrive. Act only on text_delta; reading delta.text from a thinking delta would append the string undefined.
  • message_stop is the only sign that the reply is complete. If the connection closes without it, the answer was cut off, even though no error event arrived.

Parsing it: why you need a buffer

The network doesn't deliver events one by one. It delivers chunks of bytes, and a chunk can hold three events, or half of one. So the parser appends every chunk to a buffer, cuts off complete events (everything up to a blank line), and leaves any incomplete tail for the next chunk.

Parsing a stream, chunk by chunk
Step through the bytes as they arrive from the model API. The JSON is shortened with … where the details don't matter.
Code
1let buffer = "", text = "", stopReason = null;
2for await (const chunk of body) { // text, as it arrives
3 buffer += chunk;
4 let end;
5 while ((end = buffer.indexOf("\n\n")) !== -1) {
6 const raw = buffer.slice(0, end); // one whole event
7 buffer = buffer.slice(end + 2); // keep the rest
8 const ev = JSON.parse(dataLineOf(raw)); // the JSON after "data: "
9 if (ev.type === "content_block_delta" && ev.delta.type === "text_delta") {
10 text += ev.delta.text; // skip thinking_delta etc.
11 show(ev.delta.text);
12 } else if (ev.type === "message_delta") {
13 stopReason = ev.delta.stop_reason;
14 } else if (ev.type === "error") {
15 throw new Error(ev.error.type);
16 } else if (ev.type === "message_stop") {
17 return { text, stopReason }; // the only successful end
18 } // ping, message_start, …: ignore
19 }
20}
21throw new Error("stream ended early");
Chunk just received
nothing yet
buffer after the loop
empty
Events handled
none
On screen
(blank)
The request has been sent with "stream": true. The status line (200) and headers came back at once, but no events yet: the model is still reading the prompt (prefill). for await waits for the first chunk of the body without blocking anything else.
1 / 6

The code

To run this for real without an API key, we wrote a small fake API (shown further down) that streams Anthropic-shaped events: a 300 ms pause, then 11 text deltas 40 ms apart. With ?fail=1 it sends an overloaded_error event halfway; with ?cut=1 it simply hangs up halfway, with no error at all. The client does four runs: a normal one, the two broken ones, and one the "user" cancels after 400 ms.

stream.go
type event struct {
	Type  string `json:"type"`
	Delta struct {
		Type       string `json:"type"`
		Text       string `json:"text"`
		StopReason string `json:"stop_reason"`
	} `json:"delta"`
	Usage struct {
		OutputTokens int `json:"output_tokens"`
	} `json:"usage"`
	Error struct {
		Type string `json:"type"`
	} `json:"error"`
}

type result struct {
	Text         string
	StopReason   string
	OutputTokens int
}

var errEndedEarly = errors.New("stream ended early") // closed before message_stop

// streamReply sends the request with "stream": true and calls onText for every text delta.
func streamReply(ctx context.Context, url string, body map[string]any, onText func(string)) (result, error) {
	var r result
	body["stream"] = true // note: this adds a key to the caller's map
	payload, err := json.Marshal(body)
	if err != nil {
		return r, err
	}
	req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload)) // ← ctx cancels it
	if err != nil {
		return r, err
	}
	req.Header.Set("content-type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return r, err
	}
	defer res.Body.Close()
	if res.StatusCode != http.StatusOK {
		msg, _ := io.ReadAll(res.Body)
		return r, fmt.Errorf("HTTP %d: %s", res.StatusCode, msg)
	}

	sc := bufio.NewScanner(res.Body) // reads the body line by line, as it arrives
	sc.Buffer(make([]byte, 64*1024), 1024*1024)
	for sc.Scan() {
		line := sc.Text()
		if !strings.HasPrefix(line, "data: ") {
			continue // "event: …" lines and the blank line between events
		}
		var ev event
		if err := json.Unmarshal([]byte(line[len("data: "):]), &ev); err != nil {
			return r, err
		}
		switch ev.Type {
		case "content_block_delta":
			if ev.Delta.Type == "text_delta" { // skip thinking_delta, signature_delta, …
				r.Text += ev.Delta.Text
				onText(ev.Delta.Text)
			}
		case "message_delta":
			r.StopReason = ev.Delta.StopReason
			r.OutputTokens = ev.Usage.OutputTokens // cumulative
		case "message_stop":
			return r, nil // ← the only way a stream ends successfully
		case "error": // ← errors can arrive AFTER a 200 status
			return r, fmt.Errorf("stream error: %s", ev.Error.Type)
		}
	}
	if err := sc.Err(); err != nil {
		return r, err // a cancelled ctx or a broken connection
	}
	return r, errEndedEarly // the server hung up cleanly, but before message_stop
}

func main() {
	url := "http://localhost:8787/v1/messages"
	newBody := func() map[string]any { // a fresh map per call (streamReply adds "stream")
		return map[string]any{
			"model":      "claude-opus-5-5",
			"max_tokens": 1024,
			"messages":   []map[string]string{{"role": "user", "content": "Checkout got slow. What first?"}},
		}
	}
	show := func(s string) { fmt.Print(s) } // print each piece as it arrives

	start := time.Now()
	r, err := streamReply(context.Background(), url, newBody(), show)
	if err != nil {
		fmt.Println("\nerror:", err)
		return
	}
	fmt.Printf("\nstop_reason=%s output_tokens=%d total=%dms\n", r.StopReason, r.OutputTokens, time.Since(start).Milliseconds())

	r, err = streamReply(context.Background(), url+"?fail=1", newBody(), show)
	fmt.Printf("\n%v; kept %q\n", err, r.Text)

	r, err = streamReply(context.Background(), url+"?cut=1", newBody(), show)
	fmt.Printf("\n%v; kept %q\n", err, r.Text)

	ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond) // user clicks "Stop"
	defer cancel()
	_, err = streamReply(ctx, url, newBody(), show)
	fmt.Println("\nstopped:", errors.Is(err, context.DeadlineExceeded))
}

Real output against the fake API (Go 1.24, Node 22; the Go file also has the usual package main and imports, left out above). The total time varies a little between runs:

Start with what changed on Tuesday. Compare p95 latency.
stop_reason=end_turn output_tokens=11 total=760ms
Start with what changed
stream error: overloaded_error; kept "Start with what changed"
Start with what changed
stream ended early; kept "Start with what changed"
Start with what
stopped: true

Look at the second and third runs: the failure came after four pieces of text had already been shown. Both versions keep that partial text and return it with the error; §04 covers what to do with it. The third run is the sneaky one. A proxy restart or a server crash can close the connection cleanly, so the read loop simply ends with no error. Without the message_stop check, the code would report a truncated answer as a success.

The fake API, for completeness. It's the server side of the same format: write event: and data: lines, end each event with a blank line.

fake-llm.mjs (the part that streams)
import http from "node:http";

const REPLY = ["Start", " with", " what", " changed", " on", " Tuesday", ".", " Compare", " p95", " latency", "."];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

http.createServer(async (req, res) => {
  const url = new URL(req.url, "http://x");
  // ?fail=1 sends an error event halfway; ?cut=1 just hangs up halfway.
  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
  const send = (type, data) => res.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`);
  await sleep(300); // "prefill": the wait before the first token
  send("message_start", { message: { id: "msg_fake", role: "assistant", content: [], usage: { input_tokens: 16, output_tokens: 1 } } });
  send("content_block_start", { index: 0, content_block: { type: "text", text: "" } });
  send("ping", {});
  for (let i = 0; i < REPLY.length; i++) {
    if (i === 4 && url.searchParams.get("fail")) {
      send("error", { error: { type: "overloaded_error", message: "Overloaded" } });
      return res.end();
    }
    if (i === 4 && url.searchParams.get("cut")) return res.end(); // a clean close, no error event
    send("content_block_delta", { index: 0, delta: { type: "text_delta", text: REPLY[i] } });
    await sleep(40); // "decode": one token at a time
  }
  send("content_block_stop", { index: 0 });
  send("message_delta", { delta: { stop_reason: "end_turn" }, usage: { output_tokens: REPLY.length } });
  send("message_stop", {});
  res.end();
}).listen(8787);

Pointing it at the real API

Only the URL and three headers change. Not run here: it needs an API key, which must stay on your server.

real.go (not run)
req.Header.Set("content-type", "application/json")
req.Header.Set("x-api-key", os.Getenv("ANTHROPIC_API_KEY")) // never hard-code the key
req.Header.Set("anthropic-version", "2026-10-21")
// url = "https://api.anthropic.com/v1/messages"
// body: "max_tokens": 64000 is a reasonable cap when streaming
Practitioner§04

On to the browser: relay, cancel, recover

In one sentenceThe browser never talks to the model API directly (it would need your key). Your server opens the upstream stream, forwards each piece the moment it arrives, and treats a disconnect on either side as "stop everything", because tokens nobody reads still cost money.
Browser
fetch + reader
→ POST /chat
← SSE: text, done, error
abort() closes it
Your server
holds the API key, logs, limits
→ POST /v1/messages
← SSE: provider events
cancelled with it
Model API
generates tokens
Two streams, one after the other. Your server translates provider events into a small format of your own, and a disconnect at either end should stop both.

Why a second stream, and why your own format

Your server sits in the middle for good reasons: it holds the API key, checks who the user is, applies limits, and logs usage. It also shouldn't pass the provider's events straight through. Translate them into a tiny format of your own, say text, done and error. The browser code then doesn't change when you switch providers, and internal details (token counts, request IDs) don't leak to the client.

The relay handler

Each handler reads the user's message, opens the upstream stream with streamReply from §03, and writes one SSE event per text piece. The important lines are the flush and the cancellation.

relay.go
func chat(w http.ResponseWriter, r *http.Request) {
	var in struct {
		Message string `json:"message"`
	}
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}
	w.Header().Set("content-type", "text/event-stream")
	w.Header().Set("cache-control", "no-cache")
	rc := http.NewResponseController(w)

	send := func(event string, v any) {
		data, _ := json.Marshal(v)
		fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, data)
		rc.Flush() // ← push it to the browser now, don't wait for more
	}

	body := map[string]any{
		"model":      "claude-opus-5-5",
		"max_tokens": 64000,
		"messages":   []map[string]string{{"role": "user", "content": in.Message}},
	}
	// r.Context() is cancelled when the browser disconnects or aborts,
	// which cancels the upstream request too: we stop paying for tokens nobody reads.
	res, err := streamReply(r.Context(), upstream, body, func(text string) {
		send("text", map[string]string{"text": text})
	})
	if err != nil { // an error event, a cut connection, or "stream ended early": never "done"
		log.Printf("stream failed after %d chars: %v", len(res.Text), err)
		send("error", map[string]string{"message": "The answer was interrupted. Please try again."})
		return
	}
	send("done", map[string]string{"stop_reason": res.StopReason})
}

// registered with: http.HandleFunc("POST /chat", chat)

We ran both against the fake API from §03 and read them with curl -N:

  • Closing curl after a few events made the Go server log stream failed after 23 chars: context canceled. The browser-side disconnect really did cancel the upstream call.
  • Against the ?cut=1 stream, both relays logged stream ended early and sent an error event, never done: a truncated answer is not reported as finished.
  • A malformed body (curl -d nope) gets a 400 from both. The Node version needs the try/catch around JSON.parse. Without it, the exception escapes the async handler as an unhandled rejection, which by default crashes the whole Node process: one bad request would take the server down for everyone.

The browser side

EventSource, the browser's built-in SSE client, can only send GET requests with no body. A chat message needs a POST, so read the stream with fetch, using the same buffer-and-split loop as §03. This is browser code (not run here):

chat.js (browser)
let controller = null;

async function ask(message, outputEl) {
  controller = new AbortController();
  let state = "interrupted"; // stays this way unless a "done" event arrives
  try {
    const res = await fetch("/chat", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ message }),
      signal: controller.signal, // ← the Stop button calls controller.abort()
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`); // e.g. 400 for a bad body, 429 from your own limits
    let buffer = "";
    for await (const chunk of res.body.pipeThrough(new TextDecoderStream())) {
      buffer += chunk;
      let end;
      while ((end = buffer.indexOf("\n\n")) !== -1) {
        const raw = buffer.slice(0, end);
        buffer = buffer.slice(end + 2);
        const event = raw.match(/^event: (.*)$/m)?.[1];
        const data = JSON.parse(raw.match(/^data: (.*)$/m)?.[1] ?? "{}");
        if (event === "text") outputEl.textContent += data.text; // textContent: never innerHTML for model output
        if (event === "done") {
          state = { end_turn: "done", max_tokens: "cut-off", refusal: "declined" }[data.stop_reason] ?? "done";
        }
      }
    }
  } catch (err) {
    state = err.name === "AbortError" ? "stopped" : "interrupted"; // Stop button, or a network failure
  }
  outputEl.dataset.state = state; // CSS shows "stopped", "cut off", "declined" or "interrupted" labels
}

stopButton.onclick = () => controller?.abort(); // browser → server → model API: all three stop

Three details matter. Every ending gets a label: the answer is only marked done when a done event arrives, so an error event, a dropped connection or a stream that just stops all leave it marked "interrupted", and a refusal or max_tokens stop reason is shown as such. Appending with textContent treats the model's output as plain text: if it ever contains <script> or an <img onerror=…>, it is shown, not run (see Injection). And a Stop button is a cost feature, not just a nicety: with the cancellation chain above, stopping a long answer stops the billing for tokens that were never generated.

When the stream breaks halfway

Errors mid-stream are normal: an overloaded provider, a deploy that restarts your server, a phone switching networks. By then the user has already read part of the answer. Your options:

✕ Tempting
  • Silently retry and append the new reply. A retry is a fresh generation that starts from the beginning and won't continue the old sentence, so the result is garbled.
  • Throw the partial text away and show a generic error. The user loses what they were reading.
✓ Better
  • Keep the partial text and mark it clearly: "This answer was interrupted."
  • Offer Retry, which replaces the partial text with a fresh answer.
  • Log how far it got (characters or tokens), so you can see how often this happens.

If you really need to continue an interrupted answer, Anthropic's streaming docs (October 2026) describe sending a new request with the partial text and asking the model to continue from where it stopped. That costs a second request's input tokens and is only worth it for long outputs.

Timeouts for streams

A single overall timeout doesn't fit a stream. A healthy long answer can take two minutes, while a dead connection should fail in seconds. Use two limits:

  • an idle timeout: if no event (not even a ping) arrives for, say, 30 s, give up. Reset the timer on every event;
  • an overall deadline generous enough for your longest legitimate answer.
Practitioner§05

Caching whole answers (and why it's risky)

In one sentenceThe cheapest model call is the one you don't make. An exact-match cache is safe but rarely hits; a semantic cache hits often, and sometimes hands one user the answer to a different question.

There are three different "LLM caches", and people mix them up:

CacheWhat it storesWho runs itSavesSection
Exact-match response cacheThe finished answerYouThe whole callthis one
Semantic cacheThe finished answer, found by meaningYouThe whole callthis one
Prompt cacheThe provider's processed form of your prompt's beginningThe providerPart of the input cost and TTFT; the answer is still generated fresh§06

Exact match: a normal cache with a careful key

This is ordinary cache-aside: before calling the model, look up a key; on a miss, call the model and store the answer. The only hard part is the Cache key. The answer depends on everything in the request, so everything goes into the key: model name, system prompt, the full message list, tools, output settings, and your prompt version. Serialize them in a fixed order and hash the result with SHA-256:

cachekey.go
type Message struct {
	Role    string `json:"role"`
	Content string `json:"content"`
}

// Everything that can change the answer goes into the key.
type keyParts struct {
	Model         string            `json:"model"`
	PromptVersion string            `json:"prompt_version"`
	System        string            `json:"system"`
	Tools         []json.RawMessage `json:"tools"` // each tool definition as JSON, in a fixed order
	Messages      []Message         `json:"messages"`
	MaxTokens     int               `json:"max_tokens"`
}

func cacheKey(p keyParts) string {
	b, _ := json.Marshal(p) // struct fields always marshal in the same order
	sum := sha256.Sum256(b)
	return "llm:" + hex.EncodeToString(sum[:])
}

Both versions produce the same key for the same request (we ran them: llm:7e3aa642… for a small test request), so a Go service and a Node service can share one cache.

Why it rarely hits in chat: the key includes the whole conversation, and no two conversations are identical. One extra space and it's a miss. Where it does pay off:

  • Fixed inputs: generating a description for each of 10,000 products, then regenerating only the changed ones.
  • Repeated identical requests: the same document summarized for many users, a classification run over a dataset with duplicates.
  • Development: re-running an eval suite without paying again for unchanged cases (but don't let it hide real variation: Evaluating LLM Apps runs cases several times on purpose).

Note that a cached answer is frozen: the same request always gets the same text. That's usually what you want, but it also means a bad answer stays bad until it expires. Give entries a TTL and include the prompt version in the key, so a prompt change invalidates old answers automatically (Caching → invalidation).

Semantic caching: matching by meaning

Users rarely type exactly the same thing twice. A Semantic cache tries to catch paraphrases: turn the new question into an Embedding, find the most similar stored question by Cosine similarity, and if the score is above a threshold, return that question's stored answer. (Embeddings and Vector Databases cover the machinery.)

The failure mode is the whole story. Embeddings measure "about the same topic, with similar words", not "has the same answer". Drag the threshold and watch what gets served:

Semantic cache: where do you draw the line?
Four answers are cached. Fifteen new questions arrive. Each is matched to its most similar cached question; above the threshold, the cached answer is returned without calling the model.
3
cached answer, right
3
cached answer, WRONG
5
model called (could have reused)
4
model called (needed)
  • 0.902“I forgot my password, how do I change it?”→ returns the answer to “How do I reset my password?” ✓ fineSame task, different words.
  • 0.884“Cancel my subscription please”→ returns the answer to “How do I cancel my subscription?” ✓ fineSame task.
  • 0.824“What is your refund policy for annual plans?”→ returns the answer to “What is your refund policy?” ✗ wrong answerAnnual plans may have different terms; the cached answer may be wrong.
  • 0.794“How do I cancel my teammate's subscription?”→ returns the answer to “How do I cancel my subscription?” ✗ wrong answerDifferent task: an admin has to remove a seat.
  • 0.778“How do I renew my subscription?”→ returns the answer to “How do I cancel my subscription?” ✗ wrong answerThe opposite task: renew, not cancel.
  • 0.775“How do I reset my password on the mobile app?”→ returns the answer to “How do I reset my password?” ✓ fineSame steps on mobile (in this example).
  • threshold 0.75
    0.748“Do you give refunds?”→ nearest: “What is your refund policy?” (miss: same answer would have worked)Same question, shorter.
  • 0.716“How do I import my files?”→ nearest: “How do I export my files?” (miss: correct)The opposite direction: import, not export.
  • 0.696“How do I cancel my order?”→ nearest: “How do I cancel my subscription?” (miss: correct)Different product: orders, not subscriptions.
  • 0.666“How do I download all my files?”→ nearest: “How do I export my files?” (miss: same answer would have worked)Download all = export.
  • 0.636“How can I cancel my plan?”→ nearest: “How do I cancel my subscription?” (miss: same answer would have worked)Same task: plan = subscription.
  • 0.488“Can I get my money back?”→ nearest: “What is your refund policy?” (miss: same answer would have worked)Same question, no shared keywords.
  • 0.483“How do I reset my router?”→ nearest: “How do I reset my password?” (miss: correct)Unrelated: a router, not an account.
  • 0.421“What is your privacy policy?”→ nearest: “What is your refund policy?” (miss: correct)Unrelated policy.
  • 0.356“How do I stop being charged?”→ nearest: “How do I cancel my subscription?” (miss: same answer would have worked)Same task: stop paying = cancel.

Try to find a threshold with no wrong answers and no missed chances. There isn't one: “renew my subscription” (0.778) scores higher than “cancel my plan” (0.636), even though only the second wants the cancel answer. Similar words aren't the same question.

How this data was made: Similarities are real: cosine similarity between all-MiniLM-L6-v2 sentence embeddings (384 dimensions, run offline with transformers.js) of exactly these strings. A bigger embedding model would score them differently but makes the same kind of mistake. The “same answer?” labels are human judgement for an imaginary file-hosting product.

No threshold is both safe and useful here, and that's typical. The pairs that break it are exactly the ones that matter: cancel vs renew, export vs import, my subscription vs my teammate's. The cache doesn't just fail; it answers instantly and confidently, so nobody notices until a user follows the wrong instructions.

Practitioner§06

Provider prompt caching

In one sentenceWith prompt caching, the provider keeps the processed form of your prompt's beginning for a few minutes. A later request that starts with exactly the same tokens skips that work: it's billed at a lower rate and gets its first token sooner. One changed token early on, and nothing after it matches.

What is actually cached

Not the answer. The model still writes a fresh reply every time. What's reused is the result of reading the prompt (the "prefill" work from §02, stored as the KV cache, see How LLMs Work → inference). That's why it helps most when a long, identical beginning repeats: a big system prompt, tool definitions, a long document you ask several questions about, or the earlier turns of a conversation.

Because reading token 500 depends on tokens 1 to 499, the reused part must be an exact prefix. Change token 3 and every later token has to be processed again.

How Anthropic's API does it (as of October 2026)

Other providers have their own versions, some fully automatic; the prefix rule is the same everywhere. On Anthropic's Messages API:

  • Order. The prompt is assembled as tools → system → messages. A change in the tool list invalidates everything after it, including the system prompt.
  • Breakpoints. You mark where a cacheable prefix ends by adding cache_control: {"type": "ephemeral"} to a content block, up to 4 per request. There is also a top-level cache_control that places the breakpoint on the last block automatically.
  • Minimum size. Prefixes shorter than a model-dependent minimum (512 to 4,096 tokens) are simply not cached. There is no error; you just see zero cache tokens.
  • Lifetime. An entry lives 5 minutes by default, and every read restarts the timer. A 1-hour lifetime is available ("ttl": "1h"), at a higher write price.
  • Scope. Caches are per model and per workspace (or organization): switching models means starting cold.
  • Billing. Writing a prefix into the cache costs somewhat more than normal input; reading it costs much less. So a prefix that's written and never read again is a net loss.

Watch it work (or not)

The three requests below share 747 tokens of tool definitions and system rules. Each also carries a timestamp, a classic way to break caching by accident:

Three requests, one cache breakpoint
An order-support bot. Tools and system rules never change; the question and a timestamp change every request. The breakpoint (▼) sits at the end of the system prompt.
Request 1 · first request785 tokens in
tools
system rules
Current time: 2026-10-30T14:02:11Z · “Hi, my order A-10442 says delivered but I never got it. What can I do?”
cache_creation_input_tokens: 764
cache_read_input_tokens: 0
input_tokens: 21
Request 2 · 45 s later780 tokens in
tools
system rules
Current time: 2026-10-30T14:02:56Z · “Can I return hiking boots I wore once on a trail? They're too small.”
cache_creation_input_tokens: 764
cache_read_input_tokens: 0
input_tokens: 16
Request 3 · 2 min 34 s later780 tokens in
tools
system rules
Current time: 2026-10-30T14:05:30Z · “The tent I ordered arrived with a broken pole. I'd like a replacement please.”
cache_creation_input_tokens: 764
cache_read_input_tokens: 0
input_tokens: 16
written to the cache (processed, and billed a bit above the normal rate)read from the cache (cheaper and faster)after the breakpoint: normal input

Over three requests: 2,292 tokens written, 0 read from the cache. The timestamp sits inside the cached prefix, so every request has a new prefix: it pays for a cache write and never gets a read. That's worse than no caching at all.

How this data was made: Token counts are real, from OpenAI's o200k_base tokenizer (gpt-tokenizer) on the actual tool definitions, system prompt, questions and timestamps (see data-gen.mjs.txt). Claude counts differently, so the exact numbers on a real bill would differ. The 747-token prefix is above the 512-token minimum Anthropic documents for its newest models (as of October 2026). The usage numbers are computed with the documented prefix rule and 5-minute lifetime, not measured from a real API.

The request

A system prompt sent as a list of blocks, with the breakpoint on the last stable block. The per-request details go into the user message, after the breakpoint. Not run here (needs an API key); the request shape is as documented in October 2026.

cached.go (not run)
body := map[string]any{
	"model":      "claude-opus-5-5",
	"max_tokens": 16000,
	"tools":      tools, // ← same list, same order, every request (sort by name)
	"system": []map[string]any{{
		"type":          "text",
		"text":          systemRules, // ← frozen: no dates, names or IDs in here
		"cache_control": map[string]string{"type": "ephemeral"}, // ← cache everything up to here
	}},
	"messages": []map[string]any{{
		"role":    "user",
		"content": fmt.Sprintf("Current time: %s\n\n%s", now.Format(time.RFC3339), question), // ← changing parts last
	}},
}
// After the call: log usage.cache_read_input_tokens and usage.cache_creation_input_tokens.

For a chat, put a second breakpoint on the latest message (or use the automatic top-level one). Each new turn then reads the whole earlier conversation from the cache and only processes the new message, which directly attacks the cost growth you'll see in §09.

Silent cache breakers

Each of these changes the prefix on every request, and none of them causes an error:

BreakerFix
A timestamp, request ID or user name in the system promptMove it into the latest user message, after the breakpoint
Tools built from a map or set, so their order changesSort tools by name; build them once at startup
JSON serialized with unstable key orderUse a fixed field order (Go structs; build JS objects in a fixed order)
Swapping tools in and out per request ("modes")Keep one tool list; express the mode in the message
Editing or trimming early conversation turnsOnly append; if you must shorten history, do it rarely and in one big step
A different model for some requestsEach model has its own cache; route a conversation consistently
A prefix under the model's minimumNot an error, just no caching. Check the usage fields
Practitioner§07

Retries that don't make things worse

In one sentenceRetry what can go away by itself (rate limits, overload, server errors, timeouts), never what will fail the same way again (a bad request, a bad key). Wait longer each time, randomly, honor retry-after, and stop at a deadline.

Model APIs fail more often than most dependencies: providers run near full capacity, and your account has rate limits. The general retry rules are taught in Idempotency → retries and Rate Limiting → HTTP; this section applies them to model calls.

What to retry

Error codes from Anthropic's API (as of October 2026); other providers use the same HTTP meanings, though not always the same numbers:

ResponseMeaningRetry?
429 rate_limit_error with retry-afterYou exceeded requests or tokens per minuteYes, after the Retry-After delay
429 rate_limit_error with error.details.error_code: "enforced_spend_limit_reached" and no retry-afterYour organization hit its tier's monthly spend capNo: it clears at 00:00 UTC on the 1st of next month, or when your tier is raised
400 invalid_request_error, "You have reached your specified API usage limits"A spend limit you set yourself (for the organization or a workspace)No: raise or remove the limit. Not a malformed request, despite the 400
529 overloaded_errorThe provider has no spare capacity right nowYes, with backoff
500 api_error, 502, 503, 504Something broke on their sideYes, with backoff
Network error, timeoutNo answer at allYes, if you can tolerate a duplicate (see below)
400 invalid_request_error, 413Your request is wrong or too bigNo: fix the request
401, 403, 404Bad key, no permission, unknown modelNo: fix the configuration

A retried model call is usually safe: generating text twice has no side effect except paying twice. It stops being safe when your code acts on the reply (sends the email the model drafted, runs the tool it asked for). §08 covers that.

How long to wait

Three rules, all from Idempotency → retries:

  1. Exponential backoff: the wait limit doubles each attempt (200 ms, 400 ms, 800 ms…, up to a cap).
  2. Full jitter: wait a random time between 0 and that limit, so thousands of clients don't come back at the same instant.
  3. retry-after wins: if the server says when to come back, don't come back sooner.

The simulator shows why. Sixty clients hit a provider that is down for a second and then can only serve 4 requests per 100 ms:

Retry storm simulator
60 clients call a fake provider at once. It is down for the first second (529 overloaded), then serves at most 4 requests per 100 ms and rejects the rest with 429 + retry-after: 1. Each client tries up to 8 times.
provider down03060capacity: 4 per 100 ms0s1s2s3s4s5s6s7s8s9s10s
servedrejected (529 or 429)
480
requests sent
0 / 60
clients served
60 / 0
gave up / still retrying at 10 s
never
last one served at

Every failure comes straight back on the next tick. The provider spends the outage drowning in retries, every client burns all 8 attempts within a second, and they all give up just before it recovers: nobody is served.

How this data was made: A simulation, not a real provider: the outage, capacity, retry-after value and client count are made up to show the shape of the problem. Plain backoff waits 200 ms and doubles up to 3.2 s; the jitter strategies wait a random time up to twice that (the same average). Random numbers come from a seeded generator; “New random run” picks a new seed. Hover over a bar for its numbers.

Without jitter, the clients stay synchronized and arrive in waves far above capacity, the shape of a Retry storm. With jitter, the same capacity serves every client within a few seconds, with far fewer wasted requests.

A retry wrapper, run against a flaky fake

The fake server from §03 has a /flaky route that answers 429 (with retry-after: 1), then 529, then 500, then success. It answers 400 to a request with max_tokens: -1, and imitates the spend-cap 429 (no retry-after, enforced_spend_limit_reached in the body) for a message containing spend-cap-test. A 429 alone doesn't tell you which kind it is, so both wrappers read the body of every 429.

retry.go
var retryable = map[int]bool{408: true, 429: true, 500: true, 502: true, 503: true, 504: true, 529: true} // 529 = "overloaded"

// isSpendCap reports whether a 429 body is Anthropic's monthly spend cap, which retrying can't fix.
func isSpendCap(body []byte) bool {
	var e struct {
		Error struct {
			Details struct {
				ErrorCode string `json:"error_code"`
			} `json:"details"`
		} `json:"error"`
	}
	return json.Unmarshal(body, &e) == nil && e.Error.Details.ErrorCode == "enforced_spend_limit_reached"
}

// callWithRetry POSTs body and retries failures that might go away. ctx carries the overall deadline.
func callWithRetry(ctx context.Context, url string, body []byte) ([]byte, error) {
	const maxAttempts = 5
	base, maxWait := 200*time.Millisecond, 8*time.Second
	client := &http.Client{Timeout: 30 * time.Second} // ← limit per attempt

	for attempt := 1; ; attempt++ {
		req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
		if err != nil {
			return nil, err
		}
		req.Header.Set("content-type", "application/json")
		res, err := client.Do(req)

		what, retryAfter := "network error", 0
		if err != nil {
			if ctx.Err() != nil || attempt == maxAttempts {
				return nil, err // out of time or attempts
			}
			// network error or per-attempt timeout: retry
		} else {
			data, readErr := io.ReadAll(res.Body)
			res.Body.Close()
			if res.StatusCode == http.StatusOK {
				return data, readErr
			}
			if !retryable[res.StatusCode] || isSpendCap(data) { // ← 400, 401, 404…, and the spend cap
				return nil, fmt.Errorf("HTTP %d (not retrying): %s", res.StatusCode, data)
			}
			what = strconv.Itoa(res.StatusCode)
			retryAfter, _ = strconv.Atoi(res.Header.Get("retry-after")) // seconds; 0 if absent
		}
		if attempt == maxAttempts {
			return nil, fmt.Errorf("gave up after %d attempts (last: %s)", attempt, what)
		}

		limit := min(maxWait, base<<(attempt-1))         // 200 ms, 400 ms, 800 ms… up to 8 s
		wait := time.Duration(rand.Int64N(int64(limit))) // "full jitter": random, from 0 up to the limit
		note := ""
		if retryAfter > 0 {
			wait, note = time.Duration(retryAfter)*time.Second, " (retry-after)"
		}
		fmt.Printf("attempt %d: %s → wait %d ms%s\n", attempt, what, wait.Milliseconds(), note)

		select {
		case <-time.After(wait):
		case <-ctx.Done(): // the overall deadline passed while we were waiting
			return nil, ctx.Err()
		}
	}
}

func main() {
	url := "http://localhost:8787/flaky"
	ctx, cancel := context.WithTimeout(context.Background(), time.Minute) // ← one budget for ALL attempts
	defer cancel()

	data, err := callWithRetry(ctx, url, []byte(`{"model":"claude-opus-5-5","max_tokens":1024,"messages":[{"role":"user","content":"Hi"}]}`))
	if err != nil {
		fmt.Println("failed:", err)
		return
	}
	fmt.Println("success:", string(data))

	_, err = callWithRetry(ctx, url, []byte(`{"model":"claude-opus-5-5","max_tokens":-1,"messages":[]}`))
	fmt.Println(err)

	_, err = callWithRetry(ctx, url, []byte(`{"model":"claude-opus-5-5","max_tokens":1024,"messages":[{"role":"user","content":"spend-cap-test"}]}`))
	fmt.Println(err)
}

Real output (the fake server was restarted before each run; the jittered waits are random, so yours will differ):

attempt 1: 429 → wait 1000 ms (retry-after)
attempt 2: 529 → wait 56 ms
attempt 3: 500 → wait 36 ms
success: {"content":[{"type":"text","text":"Hello!"}],"stop_reason":"end_turn","usage":{"input_tokens":12,"output_tokens":3}}
HTTP 400 (not retrying): {"type":"error","error":{"type":"invalid_request_error","message":"max_tokens: must be positive"}}
HTTP 429 (not retrying): {"type":"error","error":{"type":"rate_limit_error","message":"You have reached your API usage limits.","details":{"error_code":"enforced_spend_limit_reached"}}}

Timeouts for long generations

A model call can legitimately take minutes. Pick limits per feature from the longest answer you allow, not from your usual 5-second API default:

  • Non-streaming: the per-attempt Timeout must cover TTFT plus max_tokens ÷ tokens per second. A 4,000-token cap at 40 tokens/s is 100 s before you even count the prompt. If that's too long for your users, stream (§04) or ask for less.
  • Streaming: an idle timeout between events, plus an overall deadline (§04).
  • The overall deadline covers all attempts and the waits between them. A user waiting on a page won't wait for five 100-second attempts; a nightly job might.
Staff-level§08

When retries aren't enough

In one sentenceRetries handle a bad second. For a bad hour you need a plan: fail fast instead of piling up waiting requests, fall back to something that still works, and make sure anything the model's answer triggers happens exactly once.

Fallback models

When the main model keeps failing (overloaded, or the provider is down), a Fallback model can keep the feature alive: a different model from the same provider, or a different provider. It's simple to write and easy to get subtly wrong:

  • Prompts don't port perfectly. A prompt tuned for one model can produce different formats, lengths or refusals on another. Run your eval set against the fallback too (Evaluating LLM Apps), and keep its prompt variant in source control.
  • The fallback is cache-cold. Prompt caches belong to one model (§06), so the first minutes on the fallback cost more and start slower.
  • Capacity and limits are separate. The fallback has its own rate limits. If all your traffic moves there at once, you can hit them immediately.
  • Some failures shouldn't fall back. A 400 means your request is wrong and will be wrong everywhere. A refusal is a policy decision, not an outage (some providers offer their own refusal-fallback features; treat those as a product decision).
  • Record which model answered, in logs and ideally in your data, so a quality complaint can be traced to the fallback.

Circuit breakers

If the provider is down, each request still waits through timeouts and retries before failing. With long timeouts, that ties up connections and memory until your own service falls over: a Cascading failure. A Circuit breaker watches the recent failure rate and, past a threshold, stops calling for a while:

CLOSEDcalls go throughOPENfail fast / fallbackHALF-OPENlet one test call intoo many failuresafter 30 stest failstest succeeds → close

While the breaker is open, requests fail immediately (or go straight to the fallback) instead of waiting. After a pause it lets a test request through; success closes it again. Notification system → providers builds one for SMS and email providers; the same pattern applies per model.

Pair it with a Retry budget: retries may add at most, say, 10% on top of normal traffic. When the budget is used up, failures are returned instead of retried, so retries can't multiply an outage.

Side effects must happen once

A model call itself is safe to repeat. The danger is what your code does with the answer. If the model asks to call a tool like create_return or send_email, and the request is retried after a timeout, the tool can run twice. (Tool calling and agent loops get their own topic: Agents.)

  • Give each action an Idempotency key derived from the conversation and step (for example conv_812:step_3:create_return), and have the tool refuse a key it has already completed. Idempotency → keys shows how.
  • Separate "the model suggested it" from "we did it": record the action, then execute it once, outside the retry loop.
  • Never retry a streamed answer that a user has already read by appending (§04).

Degrade, don't disappear

Decide per feature what "the model is down" looks like before it happens:

FeatureGraceful fallback
Support chat"Our assistant is unavailable. Here are help articles / contact a person."
Auto-tagging ticketsQueue them and tag later; humans see untagged tickets meanwhile
Summaries on a dashboardShow the last good summary with its timestamp
Code review botSkip the review and say so in the pull request, rather than blocking merges

Background work should go through a job queue so an outage delays it instead of losing it. Interactive features need a message the user understands and, ideally, a way to get help without the model.

Practitioner§09

What a request really costs

In one sentenceYou pay per token: input tokens × input price + output tokens × output price (plus cache reads and writes, at their own rates). The surprises come from tokens you didn't think about: re-sent history, retries, hidden thinking, and replies longer than they need to be.

The formula

For one request:

cost = uncached_input × input_price
     + cache_writes   × cache_write_price
     + cache_reads    × cache_read_price
     + output         × output_price          (prices are per token; lists show them per million)

Every term comes straight from the usage fields in the response, so you can compute it exactly for every request and add it up per user and per feature (§11). Two facts make this formula behave differently from what people expect:

  • Output tokens usually cost several times more than input tokens, and they're also what makes requests slow (§02). A wordy reply is expensive twice.
  • Thinking tokens are output. On models that reason before answering, those tokens are usually billed as output even when you never see them.

Chat history is the silent multiplier

Model APIs are stateless (Prompting → messages): to continue a chat, you send the whole conversation again. So call 8 pays for the system prompt and calls 1–7 all over again:

Every turn re-sends the whole chat
A real-length support conversation. Click a bar to see what call N sends.
0220439Call 1: 72 input tokens#1Call 2: 137 input tokens#2Call 3: 195 input tokens#3Call 4: 249 input tokens#4Call 5: 309 input tokens#5Call 6: 353 input tokens#6Call 7: 395 input tokens#7Call 8: 439 input tokens#8439
system promptearlier messages (re-sent)new user message
Call 8 sends
system prompt 57
7 earlier turns 379
new message 3 “Yes please.”
input 439 · output 26
Whole conversation
The user typed 106 tokens in total, but the app sent 2,149 input tokens (20×), plus 302 output tokens. Call 8 alone costs 6.1× call 1 in input. Prompt caching (§06) makes the re-sent part cheaper; trimming or summarizing old turns makes it shorter.
How this data was made: Token counts are real (OpenAI's o200k_base via gpt-tokenizer) for the text of each message; real requests add a few tokens of formatting per message, and Claude's tokenizer counts differently. The assistant replies are Illustrative: written by hand at a realistic length, no model ran.

The total input grows roughly with the square of the number of turns: each call is a bit longer than the last, and you pay for every call. Your options:

  • Prompt caching (§06): the repeated part is read from the cache at a fraction of the price. The biggest lever for chat.
  • Trim: drop or shorten old turns once the conversation is long, or replace them with a short summary. Do it in rare, big steps rather than every turn, because every edit of early history breaks the cache.
  • Keep tool results small: a tool that returns a 5,000-token JSON blob adds 5,000 tokens to every later call in that conversation.

Levers, roughly in order

  1. Ask for less output. Tell the model the length you want, ask for IDs or JSON fields instead of prose, and set max_tokens as a safety cap, not a target. If a reply hits the cap, it's cut off (stop_reason: "max_tokens") and you've paid for a useless answer, so don't set it tight. On models with built-in thinking, the thinking counts toward the cap too.
  2. Cache what repeats (§06), and don't re-send what the model doesn't need: trim retrieved documents to the relevant parts (RAG).
  3. Batch offline work. For work nobody is waiting on (nightly classification, backfills, evals), providers offer a Batch API. As of October 2026, Anthropic's Message Batches API processes requests asynchronously, usually within an hour and at most 24 hours, at a discounted per-token rate. Results come back in any order, so match them by your own ID.
  4. Choose the model per task. Smaller models are cheaper and faster per token; larger ones handle harder tasks and may need fewer retries or follow-ups. It's a trade-off you measure with your eval set, not a ranking: compare cost per successful task, not cost per request. On models with an effort setting, lowering it is another way to trade depth for tokens.
  5. Count before you send. Providers offer token-counting endpoints (Anthropic: POST /v1/messages/count_tokens) so you can reject or trim an oversized request before paying for it.

Put numbers on it

Cost & latency calculator
Enter your own numbers. Nothing here is a real price: costs are in “your currency” at the per-token prices you type.
Per request
7.5 s
≈ 0.8 s TTFT + 400 tokens ÷ 60 tokens/s. Streaming shows the first words after 0.8 s.
in flight on average ≈ 0.12 req/s × 7.5 s = 0.9 requests
(Little's law, assuming traffic is spread evenly over the day; real peaks are several times higher.)
Per day
34.00 / day · 1,020 / 30 days
(30,000,000 × 1 + 0 × 1 × 0.1 + 4,000,000 × 1) ÷ 1,000,000
uncached input 88%cached input 0%output 12%

Try it: set the output price to 5× the input price, then raise the cache percentage. Caching shrinks the blue part; only shorter replies shrink the pink one.

How this data was made: Pure arithmetic on the numbers you enter; nothing is looked up. The defaults (1.00 per million tokens, a cache-read ratio of 0.1, 0.8 s and 60 tokens/s) are placeholders to make the formula visible, not any provider's prices or speeds. Real bills have more lines (cache writes, thinking tokens billed as output, batch discounts).
Practitioner§10

Rate limits, queues and capacity

In one sentenceA provider limits you on requests per minute and tokens per minute, and long generations mean many calls are open at once. Know your numbers, limit your own concurrency below the provider's limits, and queue work that can wait.

What's limited

Every provider caps how much one account can use; exceed it and you get a Rate limit error (429). As of October 2026, Anthropic measures three things for each model (a few related models share one limit), each with a Token bucket that refills continuously rather than resetting at the top of the minute. Careful with the word "token" here: the bucket's tokens are just units of allowance. For RPM, each request takes one unit; for ITPM and OTPM, each LLM token takes one unit.

  • RPM: requests per minute.
  • ITPM: input tokens per minute. For most current models, tokens read from the prompt cache don't count toward it, only uncached input and cache writes. Caching raises your effective limit as well as cutting cost.
  • OTPM: output tokens per minute, counted as tokens are actually generated. max_tokens doesn't count against it, so a generous cap costs you nothing here.

Those last two are tokens-per-minute limits, and they surprise people: 20 requests with 50,000-token prompts each can use a million input tokens in a minute while barely touching the request limit. A limit of 60 RPM may also be enforced as roughly 1 per second, so short bursts can be rejected even when the minute's total is fine.

Responses carry headers showing where you stand, for example anthropic-ratelimit-input-tokens-remaining and anthropic-ratelimit-requests-reset (an RFC 3339 time), plus retry-after on a 429. Log them, and graph "remaining" during load tests (Rate Limiting → HTTP explains this family of headers in general).

How many calls are open at once?

Little's law: calls in flight = arrival rate × time each call takes. Model calls take seconds, so this number gets big fast:

FeatureArrivalsTime per callIn flight
Autocomplete20 / s0.5 s10
Chat answer5 / s12 s60
Document summary2 / s40 s80
Calls overlap when each one is long
call 1
streaming…
call 2
streaming…
call 3
streaming…
call 4
streaming…
call 5
streaming…
One new chat request every 0.2 s, each taking about 12 s (shortened here to fit). Draw a vertical line anywhere in the middle: it crosses all five rows. The number of rows a line crosses is the number of calls in flight at that moment.

Every one of those is an open connection on your server and one in-progress request at the provider. Size HTTP connection pools, memory, and your server's stream limits for the peak, not the average (the calculator in §09 does this sum).

Limit yourself before the provider does

Rather than sending everything and handling a flood of 429s, cap your own concurrency slightly below what your limits allow:

  • A Semaphore per model, e.g. "at most 50 calls in flight" (Worker Pools → why limit shows how in Go and JS).
  • A client-side token bucket that spends estimated tokens, not just requests, so a burst of huge prompts waits its turn.
  • Priorities: interactive requests first; background jobs only use what's left.

Queue what can wait

Work that no user is staring at (summarizing yesterday's tickets, embedding new documents, running evals) belongs in a job queue, drained by a fixed number of workers. When the provider is slow or rate-limited, the queue gets longer instead of requests failing, and workers retry with backoff. That's Backpressure: the slowdown is absorbed in one visible place you can monitor. For very large offline jobs, the batch API (§09) is usually cheaper still.

Practitioner§11

Logging and metrics for model calls

In one sentenceLog one structured line per model call with the numbers you'll need on a bad day (feature, model, prompt version, tokens, TTFT, total time, stop reason, status), and keep the prompt and reply text out of ordinary logs: they're full of users' personal data.

One line per call

A structured log line can be grouped and summed later, which is what turns "the bill doubled" into "the triage feature's output tokens doubled after Tuesday's prompt change". Log what you'll want to group by and what you'll want to add up:

llmlog.go
type usage struct{ Input, CacheRead, CacheWrite, Output int }

func logLLMCall(log *slog.Logger, feature, userID, prompt string, u usage, ttft, total time.Duration, stop string) {
	sum := sha256.Sum256([]byte(prompt))
	log.Info("llm_call",
		"feature", feature, // ← cost per feature = sum of tokens grouped by this
		"model", "claude-opus-5-5",
		"prompt_version", "triage-v7",
		"user_id", userID, // an internal ID, never an email address
		"prompt_sha", hex.EncodeToString(sum[:])[:12], // ← groups identical prompts without storing the text
		"input_tokens", u.Input,
		"cache_read_tokens", u.CacheRead,
		"cache_write_tokens", u.CacheWrite,
		"output_tokens", u.Output,
		"ttft_ms", ttft.Milliseconds(),
		"total_ms", total.Milliseconds(),
		"stop_reason", stop, // "end_turn", "max_tokens", "refusal", …
	)
}

func main() {
	log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
			if a.Key == slog.TimeKey {
				return slog.Attr{} // drop the timestamp so this demo's output is stable
			}
			return a
		},
	}))
	logLLMCall(log, "ticket-triage", "u_4821", "Ticket: my invoice is wrong…",
		usage{Input: 212, CacheRead: 747, CacheWrite: 0, Output: 96},
		840*time.Millisecond, 2910*time.Millisecond, "end_turn")
}

Both print the same line (real output; the Go file's imports are left out above):

{"level":"INFO","msg":"llm_call","feature":"ticket-triage","model":"claude-opus-5-5","prompt_version":"triage-v7","user_id":"u_4821","prompt_sha":"8a871a49595e","input_tokens":212,"cache_read_tokens":747,"cache_write_tokens":0,"output_tokens":96,"ttft_ms":840,"total_ms":2910,"stop_reason":"end_turn"}

Keep the text out of ordinary logs

Prompts and replies contain whatever users typed: names, addresses, health details, passwords pasted by mistake. That's Personal data, and logs are copied to more places with more readers than any database (Secrets → leak paths).

  • Default: log numbers, not text. The line above is enough for cost, latency and error dashboards.
  • If you need text for debugging or evals, store it separately: a restricted store with a short retention period and access logs, written for a sample of requests, with Redaction of obvious identifiers (emails, card numbers, phone numbers) first. Redaction by pattern misses things; treat the store as sensitive anyway.
  • Never log the API key or full request headers. An x-api-key header in a debug log is a leaked key.
  • Tell users what you keep, and make sure "delete my data" also reaches these stores.

The dashboard

MetricWhy
TTFT and total time, p50 / p95 / p99 per featureAverages hide the slow tail (Observability → percentiles); TTFT and total regress for different reasons (§02)
Output tokens per secondDrops when the provider is busy, before errors appear
Tokens and cost per request, per feature, per userCost regressions and abusive users show up here first
Cache hit share = cache reads ÷ total input tokensA drop to zero means something broke the prefix (§06)
Error rate by status (429, 529, 5xx, timeout) and retries per requestTells "we're over our limits" apart from "the provider is struggling"
Stop reasons: max_tokens, refusalmax_tokens = truncated answers; a rise in refusal after a prompt or model change is a regression
Fallback and circuit-breaker stateHow often users got the degraded path (§08)

Quality needs its own signals (thumbs up or down, user edits, escalations), which Evaluating LLM Apps → online evaluation covers.

Staff-level§12

Staff level: upgrades, providers, privacy, self-hosting

In one sentenceTreat the model like a dependency you don't control: pin it, test every change with evals, roll changes out gradually, know exactly what the provider keeps of your data, and choose abstraction and hosting by what you can afford to operate, not by fashion.

Model upgrades are deploys

Switching to a newer model changes behavior everywhere at once without a line of your code changing: output length, format details, refusals, tool-calling habits, even token counts (a new tokenizer can change your bill). Providers also retire old models on a schedule, so upgrades aren't optional; they're just best done on your schedule.

  1. Pin the exact model ID in config, never a moving alias, so a change is always a reviewed config change.
  2. Run the eval set on the candidate: several runs per case, compared against the current model (Evaluating LLM Apps → CI). Read a sample of outputs side by side; aggregate scores hide the weird ones.
  3. Re-check the operational numbers: tokens per request, TTFT, output speed, cache behavior, rate limits for the new model.
  4. Roll out gradually. Send a copy of real traffic to the new model without showing users its answers (Shadow traffic), then move a small share of users to it (a Canary release) behind a Feature flag, watching the §11 dashboard and quality signals (Deployment strategies → canary).
  5. Keep the old one switchable until the new one has survived real traffic for a while.

The same process applies to big prompt changes, since a prompt change is also a behavior change (Prompting).

Multi-provider abstraction: what it costs

An LLM gateway or a thin interface over several providers promises freedom from Vendor lock-in and a fallback for outages (§08). It's often worth it, but be clear about the price:

  • Lowest common denominator. Each provider's best features (prompt caching controls, structured outputs, tool details, batch APIs, thinking settings) differ in shape. A uniform interface either hides them or grows provider-specific escape hatches.
  • Prompts are not portable. The same prompt behaves differently on another model; each provider needs its own tuned and evaluated variant.
  • Semantics differ. Error codes, streaming event types, token counting, rate-limit headers and cache rules all vary. Your retry and logging code must handle each one correctly.

A reasonable middle ground: one internal interface for the operations you need (generate, stream, count tokens), shared concerns in one place (keys, retries, logging, cost), and per-provider adapters that don't pretend the providers are the same.

Data retention and privacy

Before sending user data to any model API, find out (from the provider's current terms and your contract, not from blog posts) what happens to it. As of October 2026, the questions to ask are broadly the same everywhere:

  • Retention: how long are prompts and outputs stored, and why (abuse monitoring, debugging, features that keep results like batches or file uploads)? For example, Anthropic's Message Batches results stay downloadable for 29 days.
  • Training: is API data used to train models by default? Many providers say no for API traffic, but check.
  • Zero data retention and region options: available? On which contract? Which features stop working under them? (Some require a minimum retention period. For example, as of October 2026, Anthropic documents that Claude Fable 5.1 requires 30-day data retention and isn't available under zero data retention unless Anthropic authorizes it, and that Message Batches aren't eligible for zero data retention.)
  • Your side: what you log (§11), where it goes, and how deletion requests reach it. Usually the larger risk is in your own logs, not at the provider.

Send the minimum: strip fields the model doesn't need, replace identifiers with placeholders where the task allows, and keep secrets out of prompts entirely. AI Security covers prompt injection and data leaks through the model itself.

Self-hosting vs an API

Self-hosting an open-weight model gives you full control over data, version and availability, and no per-token bill. In return, you take on what the provider did for you:

You gainYou take on
Data never leaves your infrastructureBuying or renting GPUs, and keeping them busy enough to pay off
The model version changes only when you decideServing software, batching, scaling, and on-call for all of it
No provider rate limits or outagesYour own capacity limits and outages; peaks need spare GPUs
Predictable cost at steady high volumeEvaluating and upgrading models yourself; the best models may not be available to download

Rules of thumb that last: APIs win for spiky or modest traffic, for access to the most capable models, and for small teams. Self-hosting starts to make sense with strict data-residency needs, steady very high volume of a task a smaller model handles well, or a need for a frozen model. Either way, the techniques on this page (streaming, caching, retries, budgets, logging, evals) still apply: they're properties of the workload, not of who runs the GPU.

Practitioner§13

Check yourself

Predict the answer before you click. Q1 is covered in §02, Q2 in §07, Q3 in §06, Q4 in §09, Q5 in §05, Q6 in §04 and Q7 in §10.

QUESTION 1 / 7
What does streaming speed up?

A reply takes 1 s to its first token and then 300 tokens at 50 tokens per second. You switch from a normal request to streaming. What changes?

QUESTION 2 / 7
Retry or not?

Which of these responses from a model API should your code retry automatically?

QUESTION 3 / 7
Predict the usage

This code builds the system prompt for every request, a few seconds apart. What will usage.cache_read_input_tokens be on the second and later requests?

// Called for every request, a few seconds apart.
const system = [{
  type: "text",
  text: `Today is ${new Date().toISOString()}.\n` + RULES, // RULES: 3,000 tokens
  cache_control: { type: "ephemeral" },
}];
QUESTION 4 / 7
Why is turn 20 expensive?

A chat feature costs 4× more per message at turn 20 than at turn 1, even though users type short messages. Why?

QUESTION 5 / 7
Semantic cache surprise

Your semantic cache has a threshold of 0.75. A user asks “How do I renew my subscription?” (similarity 0.78 to the cached “How do I cancel my subscription?”). What happens?

QUESTION 6 / 7
The stream broke

Your chat UI has shown 200 tokens of an answer when the stream fails with an overloaded error. What's the best behavior?

QUESTION 7 / 7
How many at once?

Your summarizer gets 4 requests per second at peak and each takes about 15 s to finish. Roughly how many model calls are in progress at the same moment?

Answered 0 / 7
Foundations§14

Cheat sheet

⏱️
Three latency numbersTotal ≈ TTFT + output tokens ÷ tokens per second. Prompt length drives TTFT; reply length drives the rest, and usually dominates.
🌊
Stream itSame total, words in about a second. Buffer partial events, ignore unknown types, check the stop reason at the end.
🔌
Relay and cancelServer holds the key, flushes each piece, and cancels upstream when the browser leaves. Show partial output as interrupted; retry replaces it.
🗃️
Three cachesExact-match answers: safe, rare. Semantic: hits often, sometimes wrong. Provider prompt cache: prefix match, stable content first.
🔁
Retry right429 (with retry-after), 529, 5xx, timeouts: retry with jittered backoff in one layer. 400/401/404 and spend caps: don't.
🧯
Plan the bad hourCircuit breaker, retry budget, evaluated fallback model, idempotent side effects, a degraded mode users understand.
💰
Cost is tokensInput × price + output × price + cache terms. Chat re-sends history every turn; cap output, cache, trim, batch offline work.
🚦
CapacityRPM, input and output tokens per minute. In flight = rate × duration. Limit yourself first; queue what can wait.
📈
ObserveOne log line per call: feature, model, prompt version, tokens, TTFT, total, stop reason. Numbers in logs, not personal data.
🧪
Change carefullyPin model IDs, run evals on upgrades, shadow then canary. Know the provider's retention terms.
SymptomLikely causeFix
Long spinner, then the whole answerNot streaming, or a proxy buffers the streamStream (§03); flush; check proxy buffering (§04)
Long pause even for short promptsHidden thinking tokens, or a busy providerLower effort, smaller model, measure TTFT (§02)
Answer appears garbled after a network blipRetry appended to partial outputMark interrupted; retry replaces (§04)
cache_read_input_tokens always 0Something changing inside the prefixStable content first; sort tools (§06)
Wrong answer served instantlySemantic cache false hitNarrow scope, labelled threshold, or remove (§05)
Outage gets worse when the provider recoversSynchronized retries, stacked retry layersFull jitter, retry-after, one retry layer, budget (§07)
Same email sent twiceTool action inside a retried callIdempotency key per action (§08)
Bill grows with conversation lengthHistory re-sent every turnCache the history, trim or summarize (§09)
429s with low request countsTokens-per-minute limitCache (cached reads don't count), queue, smaller prompts (§10)
Truncated answersOutput hit max_tokensCheck stop reason; raise the cap or ask for less (§09, §11)
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in AI EngineeringAI Security
Read →