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.
The 30-second version
Those are the three facts, and each one maps to a set of tools:
| Fact | What it causes | What you do about it |
|---|---|---|
| Slow | A long reply takes 5–30 s to finish | Stream it, so words appear in about a second (§02–§04); ask for shorter output |
| Flaky | 429 rate limits, overloaded errors, broken streams | Retry the right errors with backoff and jitter, time out, fall back (§07–§08) |
| Metered | Cost grows with every token, every turn, every retry | Count 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:
What this page covers
Speed
Caching and failure
Money and operations
Where the seconds go
Three numbers, not one
A normal web request has one latency number. A model call has three you should know apart:
- 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.
- 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.
- 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:
| Change | New total | Saved |
|---|---|---|
| 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 s | 4 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.
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.
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.
Streaming from the model API
"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):
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_deltacarries the next small piece indelta.text. Glue them together in order. message_deltanear 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.pingevents can appear anywhere. They keep the connection busy so proxies don't close it; ignore them.- An
errorevent can arrive after the 200 status, for exampleoverloaded_errorwhen 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
thinkingblock streams before the text block, withthinking_deltaandsignature_deltaevents (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 ontext_delta; readingdelta.textfrom a thinking delta would append the stringundefined. message_stopis 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.
1let buffer = "", text = "", stopReason = null;2for await (const chunk of body) { // text, as it arrives3 buffer += chunk;4 let end;5 while ((end = buffer.indexOf("\n\n")) !== -1) {6 const raw = buffer.slice(0, end); // one whole event7 buffer = buffer.slice(end + 2); // keep the rest8 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 end18 } // ping, message_start, …: ignore19 }20}21throw new Error("stream ended early");
"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.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.
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: trueLook 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.
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.
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 streamingOn to the browser: relay, cancel, recover
← SSE: text, done, error
abort() closes it
← SSE: provider events
cancelled with it
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.
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=1stream, both relays loggedstream ended earlyand sent anerrorevent, neverdone: a truncated answer is not reported as finished. - A malformed body (
curl -d nope) gets a 400 from both. The Node version needs thetry/catcharoundJSON.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):
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 stopThree 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:
- 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.
- 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.
Caching whole answers (and why it's risky)
There are three different "LLM caches", and people mix them up:
| Cache | What it stores | Who runs it | Saves | Section |
|---|---|---|---|---|
| Exact-match response cache | The finished answer | You | The whole call | this one |
| Semantic cache | The finished answer, found by meaning | You | The whole call | this one |
| Prompt cache | The provider's processed form of your prompt's beginning | The provider | Part 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:
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:
- 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.750.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.
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.
Provider prompt caching
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-levelcache_controlthat 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:
cache_read_input_tokens: 0
input_tokens: 21
cache_read_input_tokens: 0
input_tokens: 16
cache_read_input_tokens: 0
input_tokens: 16
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.
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.
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:
| Breaker | Fix |
|---|---|
| A timestamp, request ID or user name in the system prompt | Move it into the latest user message, after the breakpoint |
| Tools built from a map or set, so their order changes | Sort tools by name; build them once at startup |
| JSON serialized with unstable key order | Use 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 turns | Only append; if you must shorten history, do it rarely and in one big step |
| A different model for some requests | Each model has its own cache; route a conversation consistently |
| A prefix under the model's minimum | Not an error, just no caching. Check the usage fields |
Retries that don't make things worse
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:
| Response | Meaning | Retry? |
|---|---|---|
429 rate_limit_error with retry-after | You exceeded requests or tokens per minute | Yes, after the Retry-After delay |
429 rate_limit_error with error.details.error_code: "enforced_spend_limit_reached" and no retry-after | Your organization hit its tier's monthly spend cap | No: 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_error | The provider has no spare capacity right now | Yes, with backoff |
500 api_error, 502, 503, 504 | Something broke on their side | Yes, with backoff |
| Network error, timeout | No answer at all | Yes, if you can tolerate a duplicate (see below) |
400 invalid_request_error, 413 | Your request is wrong or too big | No: fix the request |
| 401, 403, 404 | Bad key, no permission, unknown model | No: 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:
- Exponential backoff: the wait limit doubles each attempt (200 ms, 400 ms, 800 ms…, up to a cap).
- Full jitter: wait a random time between 0 and that limit, so thousands of clients don't come back at the same instant.
retry-afterwins: 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:
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.
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.
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.
When retries aren't enough
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:
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:
| Feature | Graceful fallback |
|---|---|
| Support chat | "Our assistant is unavailable. Here are help articles / contact a person." |
| Auto-tagging tickets | Queue them and tag later; humans see untagged tickets meanwhile |
| Summaries on a dashboard | Show the last good summary with its timestamp |
| Code review bot | Skip 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.
What a request really costs
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:
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
- Ask for less output. Tell the model the length you want, ask for IDs or JSON fields instead of prose, and set
max_tokensas 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. - Cache what repeats (§06), and don't re-send what the model doesn't need: trim retrieved documents to the relevant parts (RAG).
- 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.
- 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.
- 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
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.
Rate limits, queues and capacity
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_tokensdoesn'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:
| Feature | Arrivals | Time per call | In flight |
|---|---|---|---|
| Autocomplete | 20 / s | 0.5 s | 10 |
| Chat answer | 5 / s | 12 s | 60 |
| Document summary | 2 / s | 40 s | 80 |
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.
Logging and metrics for model calls
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:
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-keyheader 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
| Metric | Why |
|---|---|
| TTFT and total time, p50 / p95 / p99 per feature | Averages hide the slow tail (Observability → percentiles); TTFT and total regress for different reasons (§02) |
| Output tokens per second | Drops when the provider is busy, before errors appear |
| Tokens and cost per request, per feature, per user | Cost regressions and abusive users show up here first |
| Cache hit share = cache reads ÷ total input tokens | A drop to zero means something broke the prefix (§06) |
| Error rate by status (429, 529, 5xx, timeout) and retries per request | Tells "we're over our limits" apart from "the provider is struggling" |
Stop reasons: max_tokens, refusal | max_tokens = truncated answers; a rise in refusal after a prompt or model change is a regression |
| Fallback and circuit-breaker state | How 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: upgrades, providers, privacy, self-hosting
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.
- Pin the exact model ID in config, never a moving alias, so a change is always a reviewed config change.
- 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.
- Re-check the operational numbers: tokens per request, TTFT, output speed, cache behavior, rate limits for the new model.
- 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).
- 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 gain | You take on |
|---|---|
| Data never leaves your infrastructure | Buying or renting GPUs, and keeping them busy enough to pay off |
| The model version changes only when you decide | Serving software, batching, scaling, and on-call for all of it |
| No provider rate limits or outages | Your own capacity limits and outages; peaks need spare GPUs |
| Predictable cost at steady high volume | Evaluating 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.
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.
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?
Which of these responses from a model API should your code retry automatically?
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" },
}];A chat feature costs 4× more per message at turn 20 than at turn 1, even though users type short messages. Why?
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?
Your chat UI has shown 200 tokens of an answer when the stream fails with an overloaded error. What's the best behavior?
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?
Cheat sheet
| Symptom | Likely cause | Fix |
|---|---|---|
| Long spinner, then the whole answer | Not streaming, or a proxy buffers the stream | Stream (§03); flush; check proxy buffering (§04) |
| Long pause even for short prompts | Hidden thinking tokens, or a busy provider | Lower effort, smaller model, measure TTFT (§02) |
| Answer appears garbled after a network blip | Retry appended to partial output | Mark interrupted; retry replaces (§04) |
cache_read_input_tokens always 0 | Something changing inside the prefix | Stable content first; sort tools (§06) |
| Wrong answer served instantly | Semantic cache false hit | Narrow scope, labelled threshold, or remove (§05) |
| Outage gets worse when the provider recovers | Synchronized retries, stacked retry layers | Full jitter, retry-after, one retry layer, budget (§07) |
| Same email sent twice | Tool action inside a retried call | Idempotency key per action (§08) |
| Bill grows with conversation length | History re-sent every turn | Cache the history, trim or summarize (§09) |
| 429s with low request counts | Tokens-per-minute limit | Cache (cached reads don't count), queue, smaller prompts (§10) |
| Truncated answers | Output hit max_tokens | Check stop reason; raise the cap or ask for less (§09, §11) |