A practical playbook for finding where your Workers AI tokens are going, and how to cut them without cutting quality.
How to use this document. Six root causes are covered below in the order we usually see them cause real dollar problems. For each cause: how to spot it in AI Gateway, how to fix it, and code you can drop in. Nothing here is speculative — sources are labelled CF Docs or Estimate.
Why AI Gateway matters here. You can't fix what you can't measure. AI Gateway is the observability layer that turns "our AI bill is high" into "request #47 spent 12,000 tokens because the system prompt was 8,000 tokens and we sent it 47 times today."
| # | Root cause | How much token waste it typically drives | Fix effort |
|---|---|---|---|
| 1 | No response cache — identical prompts keep hitting the model | 20–60% of tokens | 10 minutes |
| 2 | System prompt is huge and re-sent on every request | 30–70% of input tokens on agent workloads | 1 hour |
| 3 | No max_tokens ceiling on the response | 10–40% of output tokens | 15 minutes |
| 4 | Wrong model for the job (using GLM-5.2 for tasks Llama-3.1-8b would do) | Cost multiplier of 10–50× | 1 day (eval work) |
| 5 | Chatty conversation history — sending the full transcript every turn | Grows linearly with turns; unbounded | 2 hours |
| 6 | Retries and duplicate requests silently doubling spend | 5–20% of all tokens | 10 minutes |
The single fastest cost win. If any part of your app asks the model the same question more than once — health checks, common FAQ questions, standard summaries, prompt templates that produce the same output — every duplicate is 100% wasted spend.
In AI Gateway → Analytics, look at the Cached Requests percentage. If Cache Responses is off, this is 0%. Turn caching on for 24 hours and see what percentage of your traffic is exactly-duplicate — that's your immediate savings ceiling.
AI Gateway caching is exact-match only. Two prompts that differ by a single character are two cache entries. Semantic cache ("close enough" matching) is on the roadmap but not shipped as of this document. So this fix has a real ceiling.
// Workers AI binding — cache this response for 1 hour const resp = await env.AI.run( "@cf/meta/llama-3.1-8b-instruct-fast", { messages: [{ role: "user", content: prompt }] }, { gateway: { id: "your-gateway-id", skipCache: false, cacheTtl: 3600, // seconds }, } );
Live proof. On cf-demo-app we measured a repeat identical RAG query: cold path 1,507ms → cached hit 144ms (10× faster), and — critically — zero tokens billed on the cached hit. The response bytes are byte-identical because they're served from cache, not re-generated. Speed and cost win together.
This is the silent killer for agentic workloads (Kimi-k2.7-code, GLM-5.2, anything with long tool definitions). If your system prompt is 8,000 tokens of instructions + tool schemas, and you send it 500 times a day, that's 4M input tokens/day before the user says a word.
AI Gateway → Logs → click any request → look at the input body. If your system message is more than 500 tokens and it looks identical across requests, this is you. Also check Analytics tokens-per-request average — anything >3,000 avg input tokens suggests a big shared prefix.
Workers AI has prefix caching (also called prompt caching) built in for supported models — including Kimi-k2.5+ and other agentic models. Cached prefix tokens are billed at a discounted rate, and you skip the "prefill" compute stage entirely. Faster and cheaper on the same request.
Send the x-session-affinity header with a stable identifier so consecutive requests route to the same model instance and can hit the cached prefix.
// Workers AI binding const response = await env.AI.run( "@cf/moonshotai/kimi-k2.5", { messages: [ { role: "system", content: LONG_SYSTEM_PROMPT }, // static, front-loaded { role: "user", content: userQuery }, // dynamic, at the end ], }, { extraHeaders: { "x-session-affinity": "ses_" + sessionId, }, } );
How to verify it's working. Workers AI returns cached token counts in the response usage object. First request is always cold (no cached tokens). Second and later requests with the same prefix should show non-zero cached_tokens. If they don't, your prefix isn't stable (probably a timestamp or a per-user variable is sneaking into the prefix).
max_tokens ceiling on the responseIf you don't set max_tokens, the model generates until it decides to stop — sometimes that's 50 tokens, sometimes it's 4,000. On expensive models (GLM-5.2 at $4.40/M output), a runaway response can cost 40× what you needed for the actual answer.
AI Gateway → Analytics → Output tokens per request. High variance (P50 is 200 tokens, P95 is 3,500) is the tell. Also review Logs for any responses ending mid-sentence — that's the model hitting a hard limit you didn't set.
Ask yourself: "What's the longest useful answer for this task?" Set max_tokens to 1.5× that. Chat responses: 500–1000. Summaries: 300–500. Code generation: 1500–2500. Anything unbounded is a bug.
const resp = await env.AI.run( "@cf/meta/llama-3.3-70b-instruct-fp8-fast", { messages: [...], max_tokens: 500, // hard ceiling temperature: 0.3, // lower = more focused, often shorter } );
Bonus tactic. Add a length instruction to the system prompt: "Keep responses under 4 sentences unless the user explicitly asks for more." Models generally obey these. Costs zero tokens after the first cached request.
Most workloads don't need a 200B-parameter model. Classification, extraction, simple Q&A, and routing all work well on Llama-3.1-8b-fast. The prices differ by 10–50×.
| Model | Input $/M | Output $/M | Context | Good for |
|---|---|---|---|---|
@cf/meta/llama-3.1-8b-instruct-fast |
$0.05 | $0.10 | 128k | Classification, extraction, routing, short Q&A |
@cf/meta/llama-3.3-70b-instruct-fp8-fast |
$0.29 | $2.25 | 128k | General reasoning, RAG answers, mid-complexity tasks |
@cf/zai-org/glm-4.7-flash |
$0.06 | $0.40 | 131k | Chinese-language, quick agentic tasks |
@cf/zai-org/glm-5.2 |
$1.40 | $4.40 ($0.26 cached) | 262k | Long-context reasoning, complex agents |
@cf/moonshotai/kimi-k2.7-code |
varies | varies | — | Code generation, tool-use agents |
Use a cheap classifier to decide which model handles the actual request:
// 1. Cheap classifier call — llama-3.1-8b-fast, ~200 tokens const route = await env.AI.run( "@cf/meta/llama-3.1-8b-instruct-fast", { messages: [{ role: "system", content: "Classify this request as one of: SIMPLE, COMPLEX, CODE. Respond with only the word." }, { role: "user", content: userQuery }], max_tokens: 5, } ); // 2. Route to the right-sized model const model = route.response.includes("CODE") ? "@cf/moonshotai/kimi-k2.7-code" : route.response.includes("COMPLEX") ? "@cf/meta/llama-3.3-70b-instruct-fp8-fast" : "@cf/meta/llama-3.1-8b-instruct-fast"; const answer = await env.AI.run(model, { messages: [...] });
Don't do this without evals. You need a labelled test set of 50–100 real production requests with known-good answers, and you have to score both models against it. Otherwise you'll ship a downgrade and find out from angry users. Cloudflare's AI Gateway logs give you the production data to build that eval set — export requests, replay them against the cheaper model, compare.
Naive chat implementations send the entire conversation history on every turn. Turn 10 sends 9 prior exchanges. Turn 50 sends 49. Input tokens per request grow linearly, and the cost per conversation grows quadratically. Users don't feel it until the bill arrives.
AI Gateway → Logs — sort by input tokens descending. If your top requests are 20k+ input tokens and the top of the message list is the same "hello" from 40 turns ago, this is you.
Blindly appending every user + assistant message to a list forever, then sending the whole list on every call.
// Keep last N turns verbatim; summarize older content once const RECENT_TURNS = 6; const older = history.slice(0, -RECENT_TURNS); const recent = history.slice(-RECENT_TURNS); let summary = ""; if (older.length > 0) { // Summarize in one cheap call, cache the result on user session summary = await summarizeOnce(older); // llama-3.1-8b-fast, 300 tokens out } const messages = [ { role: "system", content: SYSTEM_PROMPT }, ...(summary ? [{ role: "system", content: "Prior context: " + summary }] : []), ...recent, { role: "user", content: userQuery }, ];
Why this compounds well with cause #2. If you keep the system prompt stable and the recent turns at the end, prefix caching kicks in for the shared history. You save both on prompt tokens (fewer of them) and on the ones that remain (cached-tier pricing).
Client retries on timeout. Frontend double-clicks. Background jobs that run twice because a queue redelivered. All of these bill twice. In our experience 5–20% of AI spend on undisciplined codebases is duplicates.
AI Gateway → Logs → filter by any user/session ID → look for identical prompts within seconds of each other. Or in Analytics, compare request count to unique-user count — mismatch is a smell.
Layer 1: response cache (Cause #1) turns the duplicate into a cache hit — free. Layer 2: idempotency at the app level — dedupe by request hash before you even call the gateway.
AI Gateway Settings → Rate Limit Requests → set a per-token or per-IP cap. This is your seatbelt. It won't fix bad code, but it stops a runaway loop from becoming a runaway bill.
AI Gateway Settings → Retry Requests → configure exponential backoff at the gateway. Then remove the retry loop in your client code. One place to reason about retries beats a dozen scattered implementations.
| Setting (Dashboard → AI Gateway → Settings) | Fixes cause | Recommended default |
|---|---|---|
| Collect Logs | All 6 — diagnosis foundation | ON, 100k limit |
| Cache Responses | #1, #6 | ON, TTL 3600s |
| Rate Limit Requests | #6 — blast-radius cap | Start at 100 req/min per token, tune |
| Spend Limits Beta | All — hard budget stop | Set to 120% of expected monthly |
| Retry Requests | #6 — centralize retries | ON, 2 retries, exponential |
| Authenticated Gateway | Stops unauthorized traffic from spending your tokens | ON |
Spent time hands-on with the Fallback and Dynamic Routing features on AI Gateway. Built a working demo you can point customers at: cf-demo-app.dustinburke23nc.workers.dev/ai-gateway. What works, what does not, and what to say if a customer asks.
What these features are. Fallback Routing lets you define a chain of models — if the primary provider fails or rate-limits, the gateway automatically tries the next one. Dynamic Routing is the visual pipeline builder that lets you compose Rate Limit, Budget Limit, Model, and Fallback nodes into a single route. Both are useful cost-and-reliability tools that map directly onto Cause #6 (runaway loops) and the "seatbelt" pattern this playbook already recommends.
Basic Fallback (orange section of the demo). Solid. If the primary model drops offline, the gateway routes to the fallback provider with no client-side change. Good simple illustration of what happens when a primary LLM is unhealthy. The demo simulates the failure because I don't have the superpowers to break a real LLM for the world, but the routing behavior is real.
Per-user rate limiting on metadata.userId. Works as advertised once caching is off (see caveat below). In the demo, Bob (heavy user) gets blocked after his rate ceiling is hit, while Alice (separate userId) is unaffected. This is exactly the seatbelt pattern for Cause #6 — the gateway blocks blast-radius requests before they reach any provider, so blocked calls incur zero token cost.
Budget Limit node only accepts whole dollars. The UI currently only accepts integer USD values ($1, $2, $3…), not fractional ($0.0001). At Workers AI prices, $1 is a massive number of requests, which makes the node useless for a live click-demo. Stick to rate limiting for demos and for production seatbelt behavior until this is fixed.
Fallback branch on Rate Limit node keeps reverting. If you configure a fallback model to fire when the rate limit is exceeded (e.g. "over limit, downgrade to 8B"), the UI keeps deleting the fallback model on save. The current working behavior is that hitting the rate limit outright blocks the user instead of rerouting them. Fine for a demo (and honestly the right story for a blast-radius seatbelt), but not what most customers actually want if they were sold a "graceful downgrade" narrative.
Caching bypasses the rate limit entirely. This one bit me. If Cache Responses is ON, identical prompts get served from cache (~25ms) and skip the Rate Limit node completely — the counter never increments. Once you turn caching OFF (or vary the prompt so no cache hit is possible), the rate limiter works as expected. If you're demoing rate limiting to a customer with caching enabled, either vary the prompt on each click or toggle caching off for the demo. Otherwise your rate limit will silently do nothing and you'll look confused live.
| Customer question | Honest answer |
|---|---|
| "Can we set a hard dollar cap in Dynamic Routing?" | Not usefully yet — Budget Limit only takes whole dollars in beta. Use Spend Limits in gateway Settings instead (that's the real hard cap on the platform), and use Dynamic Routing's Rate Limit node for request-count guardrails. |
| "Can we downgrade to a cheaper model when the rate limit is hit?" | Not reliably today — the fallback branch on the Rate Limit node keeps reverting in the UI. Position it as "we block the abuser at the gateway and save you the LLM cost" instead. That's actually the better cost story anyway. |
| "What about our normal caching, does it still work with routing?" | Yes, but be aware: cached responses bypass rate limits. That's fine (and desirable) for the intended use case — cache hits cost nothing and never overwhelm a provider. Just don't expect a cached prompt to trip a rate limit counter. |
| "Is this production ready?" | Fallback Routing: yes, use it. Dynamic Routing: it's Beta, the routing engine works, but the UI has rough edges (see above). Fine to configure and leave running; less fine to demo live without knowing the gotchas. |
How this ties back to the six root causes. Dynamic Routing's Rate Limit node keyed on metadata.userId is the cleanest implementation of the Cause #6 seatbelt this playbook has recommended all along. Add metadata: { userId: "..." } to every env.AI.run() call, configure a Rate Limit node in a Dynamic Route on the gateway, and you get per-user blast-radius protection without touching your Worker's retry logic. Zero cost on blocked requests because they never reach the provider.
If you do nothing else after this call, do these six things in order. Total effort ≈ 30 minutes. Expected cost reduction on a typical noisy workload: 30–50%.
gateway: { id: "..." } argument to every env.AI.run() call. Deploy.max_tokens on every LLM call — 10 minutes. Search codebase for env.AI.run, add a sensible ceiling to each.x-session-affinity header to agent workloads — 10 minutes. Every session with a stable long system prompt becomes 30–70% cheaper.After 24 hours of production traffic, come back and check Analytics. Cached % should be non-zero. Average input tokens should be lower. Compare to yesterday.