Wicked Smart Data
LearnArticlesAbout
Sign InSign Up
LearnArticlesAboutContact
Sign InSign Up
Wicked Smart Data

The go-to platform for professionals who want to master data, automation, and AI — from Excel fundamentals to cutting-edge machine learning.

Platform

  • Learning Paths
  • Articles
  • About
  • Contact

Connect

  • Contact Us
  • RSS Feed

© 2026 Wicked Smart Data. All rights reserved.

Privacy PolicyTerms of Service
All Articles
Cost Optimization for AI API Usage: Managing Tokens, Model Tiers, and Caching Strategies to Control LLM Spend in Production

Cost Optimization for AI API Usage: Managing Tokens, Model Tiers, and Caching Strategies to Control LLM Spend in Production

AI & Machine Learning⚡ Practitioner23 min readAug 11, 2026Updated Aug 11, 2026
Table of Contents
  • Introduction
  • Prerequisites
  • Understanding What You're Actually Paying For
  • Token Reduction: Trimming the Fat Without Losing the Signal
  • Auditing and Compressing System Prompts
  • Managing Context: Retrieval-Augmented Generation (RAG) Costs
  • Controlling Output Length
  • Model Routing: Using the Right Tool for the Job
  • Building a Routing Layer
  • The Waterfall Pattern
  • Caching Strategies: Don't Pay for the Same Answer Twice
  • Exact-Match Caching
  • Semantic Caching
  • Provider-Level Prompt Caching
  • Real-World Project: Building a Cost-Aware LLM Middleware Layer
  • Monitoring: What Gets Measured Gets Managed
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Summary & Next Steps
  • Cost Optimization for AI API Usage: Managing Tokens, Model Tiers, and Caching Strategies to Control LLM Spend in Production

    Introduction

    You deployed a proof-of-concept LLM feature last quarter. It worked beautifully in testing. Then it hit production, and your finance team started asking questions about a line item in your cloud bill that looks like a typo. You're not alone. Teams that build with LLM APIs routinely see costs balloon 5–20x between prototype and production, not because they made bad technical decisions, but because nobody sat down to engineer the cost architecture the same way they engineered the feature itself.

    The core insight here is deceptively simple: LLM APIs charge you for tokens, and tokens are everywhere — in your system prompts, your retrieved context chunks, your function-calling scaffolding, and your model's verbose completions. When you multiply that token count by the number of requests per day, per user, and per workflow, small inefficiencies compound into serious budget problems. But this isn't purely a cost-cutting exercise. Done correctly, cost optimization also improves latency, reduces hallucination surface area, and forces you to be clearer about what you actually need from the model.

    By the end of this lesson, you'll be able to look at any LLM-powered production system and identify where money is leaking, implement concrete fixes at the token, model, and caching levels, and build monitoring instrumentation so you catch regressions before the next billing cycle.

    What you'll learn:

    • How to audit and reduce token usage in prompts, context, and completions
    • When to route requests to smaller, cheaper model tiers versus flagship models
    • How to implement exact-match and semantic caching to eliminate redundant API calls
    • How to structure a cost monitoring dashboard using real usage data
    • How to balance cost reduction against quality — and know when frugality is the wrong move

    Prerequisites

    You should be comfortable calling LLM APIs programmatically (OpenAI, Anthropic, or equivalent) and have at least one production or near-production system you're thinking about. Familiarity with Python is assumed for the code examples. You don't need deep ML knowledge — this is about systems engineering, not model internals.


    Understanding What You're Actually Paying For

    Before you can optimize, you need a mental model of what the pricing structure actually means in practice.

    Every major LLM provider (OpenAI, Anthropic, Google, Mistral) charges by the token, typically split into input tokens and output tokens. Output tokens usually cost 3–5x more than input tokens because generation is computationally more expensive than processing. As of mid-2025, a rough but useful benchmark: flagship models like GPT-4o or Claude 3.5 Sonnet run somewhere in the $2–15 per million token range for inputs and $6–60 per million for outputs. Smaller models in the same families cost 10–20x less.

    A "token" is roughly 0.75 words in English, or about 4 characters. That sounds small, but consider a customer support system that processes 10,000 tickets per day. If each request involves a 1,000-token system prompt, 500 tokens of retrieved FAQ context, 200 tokens of user message, and 300 tokens of response, you're looking at 20 million tokens daily — just for a mid-sized support tool. At $5 per million input tokens, that's $75,000 per month before you've added a single fancy feature.

    Here's a quick way to estimate your current spend accurately:

    import tiktoken
    from dataclasses import dataclass
    from typing import Optional
    
    @dataclass
    class CostEstimate:
        input_tokens: int
        output_tokens: int
        input_cost_usd: float
        output_cost_usd: float
        total_cost_usd: float
    
    def estimate_cost(
        messages: list[dict],
        completion_text: str,
        model: str = "gpt-4o",
        input_price_per_million: float = 2.50,
        output_price_per_million: float = 10.00,
    ) -> CostEstimate:
        """
        Estimate the cost of a single API call.
        Prices default to gpt-4o as of mid-2025 — update as needed.
        """
        enc = tiktoken.encoding_for_model(model)
        
        # Count input tokens across all messages
        input_tokens = 0
        for msg in messages:
            # Each message has a small overhead for role/formatting
            input_tokens += 4
            input_tokens += len(enc.encode(msg.get("content", "")))
        input_tokens += 2  # reply priming
        
        output_tokens = len(enc.encode(completion_text))
        
        input_cost = (input_tokens / 1_000_000) * input_price_per_million
        output_cost = (output_tokens / 1_000_000) * output_price_per_million
        
        return CostEstimate(
            input_tokens=input_tokens,
            output_tokens=output_tokens,
            input_cost_usd=input_cost,
            output_cost_usd=output_cost,
            total_cost_usd=input_cost + output_cost,
        )
    
    
    # Example: a customer support interaction
    messages = [
        {"role": "system", "content": "You are a helpful support agent for AcmeCorp..."},
        {"role": "user", "content": "Why was I charged twice for my subscription last month?"},
    ]
    completion = "I understand your concern about the duplicate charge. Let me look into this for you..."
    
    estimate = estimate_cost(messages, completion)
    print(f"Input tokens:  {estimate.input_tokens}")
    print(f"Output tokens: {estimate.output_tokens}")
    print(f"Total cost:    ${estimate.total_cost_usd:.6f}")
    print(f"Daily cost at 10k requests: ${estimate.total_cost_usd * 10_000:.2f}")
    

    Run this against your actual prompts before you do anything else. The numbers often surprise people.

    Tip: Always use tiktoken (for OpenAI models) or the equivalent tokenizer for your provider to count tokens programmatically before requests, not just to estimate retroactively. Building this into a middleware layer gives you precise data instead of guesses.


    Token Reduction: Trimming the Fat Without Losing the Signal

    Token optimization has three distinct targets: your system prompt, your context window, and your completion. Each requires a different approach.

    Auditing and Compressing System Prompts

    System prompts are the easiest place to find waste because they're static and you send them with every single request. Even a 200-token reduction in a system prompt translates to 200 fewer input tokens times your entire daily request volume.

    The most common problem is verbose, conversational system prompts written when the feature was new and nobody was thinking about scale. Here's a real before/after from a document summarization tool:

    Before (~380 tokens):

    You are an expert document summarization assistant. Your job is to help users 
    understand complex documents by creating clear, accurate summaries. When you 
    receive a document, please read it carefully and thoroughly. Then, create a 
    summary that captures all of the key points. Make sure to include the main 
    arguments, any important data or statistics, key conclusions, and 
    recommendations if there are any. Your summary should be well-organized and 
    easy to read. Please write in a professional tone. Format the summary with 
    a brief overview paragraph first, then bullet points for the key details. 
    Do not include your own opinions or analysis — just summarize what is in 
    the document. If the document is technical, make sure to explain any jargon 
    in plain language. Always double-check that your summary is accurate and 
    does not misrepresent the information in the original document.
    

    After (~85 tokens):

    Summarize the document below. Format: one overview paragraph, then bullet 
    points for key details. Tone: professional, neutral. Explain jargon in 
    plain language. Do not add opinions or analysis.
    

    The second version performs comparably on most documents because LLMs are not humans — they don't benefit from motivational framing or reminders that they should "read carefully." You're spending tokens on instructions the model implicitly follows anyway.

    The compression process:

    1. Remove meta-commentary ("Your job is to...", "Please remember to...")
    2. Convert prose instructions to structured directives
    3. Delete redundant constraints (if it says "be accurate" and "don't misrepresent," pick one)
    4. Move rarely-needed edge case handling out of the static prompt and into conditional logic

    Managing Context: Retrieval-Augmented Generation (RAG) Costs

    If you're running a RAG system, your retrieved chunks are often your largest cost driver. A naive implementation fetches 5–10 chunks of 500 tokens each and stuffs them all in the context whether they're relevant or not.

    The right approach is a two-stage retrieval strategy: retrieve more than you need, then rerank and truncate before sending to the expensive model.

    from typing import List, Tuple
    import numpy as np
    
    def rerank_and_truncate_chunks(
        query: str,
        chunks: List[dict],  # Each: {"text": str, "score": float, "source": str}
        max_context_tokens: int = 1500,
        min_relevance_score: float = 0.72,
    ) -> List[dict]:
        """
        Filter retrieved chunks by relevance score and hard token budget.
        Call this before building your prompt, not after.
        """
        enc = tiktoken.get_encoding("cl100k_base")
        
        # Filter by minimum relevance
        relevant = [c for c in chunks if c["score"] >= min_relevance_score]
        
        # Sort by relevance descending
        relevant.sort(key=lambda x: x["score"], reverse=True)
        
        selected = []
        token_count = 0
        
        for chunk in relevant:
            chunk_tokens = len(enc.encode(chunk["text"]))
            if token_count + chunk_tokens <= max_context_tokens:
                selected.append(chunk)
                token_count += chunk_tokens
            else:
                # If this chunk would exceed budget, try to fit a truncated version
                remaining = max_context_tokens - token_count
                if remaining > 100:  # Only worth including if we have meaningful space
                    tokens = enc.encode(chunk["text"])[:remaining]
                    truncated_text = enc.decode(tokens)
                    selected.append({**chunk, "text": truncated_text, "truncated": True})
                break
        
        return selected
    
    
    def format_context_block(chunks: List[dict]) -> str:
        """Build a compact context string from selected chunks."""
        parts = []
        for i, chunk in enumerate(chunks, 1):
            source = chunk.get("source", f"Document {i}")
            truncation_note = " [truncated]" if chunk.get("truncated") else ""
            parts.append(f"[{source}{truncation_note}]\n{chunk['text']}")
        return "\n\n".join(parts)
    

    Another often-overlooked technique: chunk compression before storage. If you're storing raw document text, consider running a one-time summarization pass on each chunk during ingestion to reduce average chunk size by 40–60%. You pay the compression cost once; you save on every query thereafter.

    Controlling Output Length

    Output tokens cost more and are harder to predict. Two concrete controls:

    1. Use max_tokens to set a hard ceiling. This is obvious but often skipped in production because it feels risky. The right approach is to set it based on your actual output distribution, not a conservative overestimate.

    import json
    from collections import Counter
    
    def analyze_output_distribution(response_log_path: str) -> dict:
        """
        Read a JSONL log of past completions and find the 95th percentile
        output length so you can set max_tokens intelligently.
        """
        lengths = []
        
        with open(response_log_path, "r") as f:
            for line in f:
                record = json.loads(line)
                lengths.append(record["output_tokens"])
        
        lengths.sort()
        n = len(lengths)
        
        return {
            "p50": lengths[int(n * 0.50)],
            "p90": lengths[int(n * 0.90)],
            "p95": lengths[int(n * 0.95)],
            "p99": lengths[int(n * 0.99)],
            "max_observed": lengths[-1],
            "recommended_max_tokens": int(lengths[int(n * 0.95)] * 1.15),  # 15% buffer
        }
    

    2. Instruct the model to be concise explicitly. "Respond in 2–3 sentences" or "answer in under 100 words" consistently reduces output length without the abrupt cutoffs that max_tokens can cause if set too aggressively.

    Warning: Never set max_tokens without analyzing your actual output distribution first. Setting it too low will silently truncate responses, leading to degraded quality that's hard to debug. Set it to the 95th percentile of observed output length plus a safety margin.


    Model Routing: Using the Right Tool for the Job

    One of the highest-leverage cost strategies is simply not using your most expensive model for every request. This isn't about cutting corners — it's about recognizing that most of your request volume consists of tasks that don't require frontier model capability.

    Building a Routing Layer

    A model router evaluates each incoming request and assigns it to a model tier based on estimated complexity. Here's a practical implementation:

    from enum import Enum
    from dataclasses import dataclass
    import re
    
    class ModelTier(Enum):
        NANO = "gpt-4o-mini"          # ~$0.15/M input — classification, simple Q&A
        STANDARD = "gpt-4o"            # ~$2.50/M input — reasoning, summarization
        FRONTIER = "o3"                # ~$10+/M input — complex reasoning, code gen
    
    @dataclass
    class RoutingDecision:
        tier: ModelTier
        reasoning: str
        estimated_input_tokens: int
    
    def route_request(
        user_message: str,
        system_context: str,
        has_code: bool = False,
        requires_json_output: bool = False,
    ) -> RoutingDecision:
        """
        Rule-based router. In production, you might replace or augment
        the rules with a small classifier model for higher accuracy.
        """
        enc = tiktoken.get_encoding("cl100k_base")
        input_tokens = len(enc.encode(user_message + system_context))
        
        message_lower = user_message.lower()
        
        # Signals that require frontier model
        frontier_signals = [
            has_code and len(user_message) > 500,
            "debug" in message_lower and has_code,
            "architect" in message_lower,
            "multi-step" in message_lower,
            input_tokens > 8000,  # Large context needs best comprehension
        ]
        
        # Signals that work fine with nano/mini
        nano_signals = [
            re.match(r'^(yes|no|what is|define|how do i|when did)', message_lower),
            len(user_message) < 100 and not has_code,
            "classify" in message_lower,
            "categorize" in message_lower,
            "translate" in message_lower and len(user_message) < 300,
        ]
        
        if any(frontier_signals):
            return RoutingDecision(
                tier=ModelTier.FRONTIER,
                reasoning="Complex reasoning or large context detected",
                estimated_input_tokens=input_tokens,
            )
        elif any(nano_signals):
            return RoutingDecision(
                tier=ModelTier.NANO,
                reasoning="Simple, short-form request suitable for mini model",
                estimated_input_tokens=input_tokens,
            )
        else:
            return RoutingDecision(
                tier=ModelTier.STANDARD,
                reasoning="Moderate complexity, standard model appropriate",
                estimated_input_tokens=input_tokens,
            )
    

    A rule-based router like this is a starting point. In production, you'll want to validate it against your actual task distribution. A common pattern is to run requests through both your routed model and the flagship model on a 5% sample, compare outputs using an LLM-as-judge approach, and use that data to tune your routing thresholds.

    The Waterfall Pattern

    For tasks where quality is non-negotiable but most requests are simple, use a waterfall: try a cheap model first, evaluate the output, and escalate to a more capable model only if the output fails validation.

    import openai
    from typing import Optional
    
    def waterfall_completion(
        messages: list[dict],
        validator_fn,  # callable: (str) -> bool
        max_escalations: int = 1,
    ) -> tuple[str, str]:
        """
        Try gpt-4o-mini first. If the output fails validation,
        escalate to gpt-4o. Returns (completion_text, model_used).
        """
        client = openai.OpenAI()
        
        model_sequence = [
            ("gpt-4o-mini", {"max_tokens": 500}),
            ("gpt-4o",      {"max_tokens": 800}),
        ]
        
        for i, (model, kwargs) in enumerate(model_sequence[:max_escalations + 1]):
            response = client.chat.completions.create(
                model=model,
                messages=messages,
                **kwargs,
            )
            completion = response.choices[0].message.content
            
            if validator_fn(completion):
                return completion, model
            
            if i < len(model_sequence) - 1:
                print(f"Output from {model} failed validation, escalating...")
        
        # Return last attempt even if it failed validation
        return completion, model
    
    
    # Example validator for a structured data extraction task
    def is_valid_json_with_required_fields(text: str) -> bool:
        try:
            data = json.loads(text)
            return all(k in data for k in ["customer_id", "issue_type", "priority"])
        except json.JSONDecodeError:
            return False
    

    The waterfall pattern works especially well for structured output tasks, classification, and extraction where correctness is binary and verifiable. It's less useful for open-ended generation where "valid" is subjective.

    Tip: Track your escalation rate. If more than 15–20% of requests escalate to the expensive model, your validator is too strict or your mini-model genuinely can't handle the task. If less than 2% escalate, you may not need the waterfall at all — just use the cheap model.


    Caching Strategies: Don't Pay for the Same Answer Twice

    Caching is the highest-ROI optimization available to most production systems because it eliminates API calls entirely. There are two distinct caching strategies and they solve different problems.

    Exact-Match Caching

    For deterministic use cases — report generation, data extraction from fixed templates, FAQ responses — many requests are genuinely identical or near-identical. An exact-match cache keyed on a hash of the prompt will serve these responses instantly for zero API cost.

    import hashlib
    import json
    import time
    import redis
    from typing import Optional
    
    class LLMCache:
        def __init__(self, redis_url: str = "redis://localhost:6379", ttl_seconds: int = 86400):
            self.redis = redis.from_url(redis_url)
            self.ttl = ttl_seconds
        
        def _cache_key(self, messages: list[dict], model: str) -> str:
            """
            Generate a deterministic cache key from the full prompt.
            Include model because the same prompt on different models
            produces different outputs.
            """
            payload = json.dumps({"messages": messages, "model": model}, sort_keys=True)
            return f"llm:exact:{hashlib.sha256(payload.encode()).hexdigest()}"
        
        def get(self, messages: list[dict], model: str) -> Optional[str]:
            key = self._cache_key(messages, model)
            cached = self.redis.get(key)
            if cached:
                self.redis.incr(f"llm:cache:hits")
                return cached.decode()
            self.redis.incr(f"llm:cache:misses")
            return None
        
        def set(self, messages: list[dict], model: str, response: str) -> None:
            key = self._cache_key(messages, model)
            self.redis.setex(key, self.ttl, response)
        
        def cache_stats(self) -> dict:
            hits = int(self.redis.get("llm:cache:hits") or 0)
            misses = int(self.redis.get("llm:cache:misses") or 0)
            total = hits + misses
            return {
                "hits": hits,
                "misses": misses,
                "hit_rate": hits / total if total > 0 else 0,
                "estimated_savings_pct": hits / total * 100 if total > 0 else 0,
            }
    

    The catch with exact-match caching: even minor variations in user input create cache misses. A user typing "What's your return policy?" versus "what is your return policy?" are different strings. This is where semantic caching comes in.

    Semantic Caching

    Semantic caching uses embedding similarity to match new queries against previously answered questions. If the semantic distance is below a threshold, serve the cached response.

    import numpy as np
    from openai import OpenAI
    from dataclasses import dataclass
    
    @dataclass
    class CachedEntry:
        query: str
        embedding: np.ndarray
        response: str
        timestamp: float
        hit_count: int = 0
    
    class SemanticCache:
        def __init__(
            self,
            similarity_threshold: float = 0.95,
            max_entries: int = 10_000,
            embedding_model: str = "text-embedding-3-small",
        ):
            self.threshold = similarity_threshold
            self.max_entries = max_entries
            self.embedding_model = embedding_model
            self.entries: list[CachedEntry] = []
            self.client = OpenAI()
        
        def _get_embedding(self, text: str) -> np.ndarray:
            response = self.client.embeddings.create(
                input=text,
                model=self.embedding_model,
            )
            return np.array(response.data[0].embedding)
        
        def _cosine_similarity(self, a: np.ndarray, b: np.ndarray) -> float:
            return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
        
        def lookup(self, query: str) -> Optional[tuple[str, float]]:
            """
            Returns (cached_response, similarity_score) if found, else None.
            """
            if not self.entries:
                return None
            
            query_embedding = self._get_embedding(query)
            
            best_score = -1
            best_entry = None
            
            for entry in self.entries:
                score = self._cosine_similarity(query_embedding, entry.embedding)
                if score > best_score:
                    best_score = score
                    best_entry = entry
            
            if best_score >= self.threshold:
                best_entry.hit_count += 1
                return best_entry.response, best_score
            
            return None
        
        def store(self, query: str, response: str) -> None:
            embedding = self._get_embedding(query)
            entry = CachedEntry(
                query=query,
                embedding=embedding,
                response=response,
                timestamp=time.time(),
            )
            self.entries.append(entry)
            
            # Evict least-used entries if over capacity
            if len(self.entries) > self.max_entries:
                self.entries.sort(key=lambda e: (e.hit_count, e.timestamp))
                self.entries = self.entries[len(self.entries) // 4:]  # Remove bottom 25%
    

    Warning: The similarity threshold is the most critical parameter in semantic caching. Set it too low (e.g., 0.85) and you'll serve wrong answers to semantically adjacent but meaningfully different questions. Start at 0.95 and lower it only after reviewing cache hit samples manually. In high-stakes domains like healthcare, legal, or financial queries, consider not using semantic caching at all, or adding a human-in-the-loop review step for cache population.

    Provider-Level Prompt Caching

    Major providers now offer native prompt caching that happens at the infrastructure level. OpenAI automatically caches prompts over 1,024 tokens at a 50% discount on cached input tokens. Anthropic's prompt caching is explicit — you mark cache breakpoints in your prompt and pay a small upfront cost per cache write, then get 90% discounts on subsequent cache hits.

    To maximize provider-level caching:

    def build_cacheable_prompt(
        static_system_prompt: str,
        static_context: str,  # e.g., a company's full FAQ document
        dynamic_user_message: str,
    ) -> list[dict]:
        """
        Structure your messages so static content comes first and is
        consistent across requests. This maximizes provider-side cache hits.
        
        For Anthropic with explicit caching, you'd add cache_control markers.
        For OpenAI, just keeping the prefix consistent triggers automatic caching.
        """
        return [
            {
                "role": "system",
                # Static content first — this prefix will be cached
                "content": f"{static_system_prompt}\n\n<knowledge_base>\n{static_context}\n</knowledge_base>",
            },
            {
                "role": "user",
                # Dynamic content last — only this changes between requests
                "content": dynamic_user_message,
            },
        ]
    

    The key principle: stable content first, variable content last. If your system prompt changes every request because you're injecting dynamic values into it, you're defeating provider-level caching. Restructure so the static portion is a clean prefix.


    Real-World Project: Building a Cost-Aware LLM Middleware Layer

    Let's put the pieces together into a middleware class you can actually deploy. This wraps any LLM call with caching, model routing, token counting, and cost logging.

    import logging
    import time
    from dataclasses import dataclass, asdict
    from openai import OpenAI
    
    logger = logging.getLogger(__name__)
    
    @dataclass
    class LLMCallRecord:
        request_id: str
        timestamp: float
        model_requested: str
        model_used: str
        input_tokens: int
        output_tokens: int
        cost_usd: float
        latency_ms: float
        cache_hit: bool
        cache_type: Optional[str]  # "exact", "semantic", "provider", or None
    
    class CostAwareLLMClient:
        
        MODEL_PRICING = {
            "gpt-4o-mini":  {"input": 0.15,  "output": 0.60},   # per million tokens
            "gpt-4o":       {"input": 2.50,  "output": 10.00},
            "o3":           {"input": 10.00, "output": 40.00},
        }
        
        def __init__(
            self,
            exact_cache: LLMCache,
            semantic_cache: SemanticCache,
            enable_routing: bool = True,
            cost_alert_threshold_usd: float = 0.10,  # Alert if single call exceeds this
        ):
            self.client = OpenAI()
            self.exact_cache = exact_cache
            self.semantic_cache = semantic_cache
            self.enable_routing = enable_routing
            self.alert_threshold = cost_alert_threshold_usd
            self.call_records: list[LLMCallRecord] = []
            self.enc = tiktoken.get_encoding("cl100k_base")
        
        def _count_tokens(self, messages: list[dict]) -> int:
            count = 0
            for msg in messages:
                count += 4 + len(self.enc.encode(msg.get("content", "")))
            return count + 2
        
        def _calculate_cost(self, model: str, input_tokens: int, output_tokens: int) -> float:
            pricing = self.MODEL_PRICING.get(model, self.MODEL_PRICING["gpt-4o"])
            return (
                (input_tokens / 1_000_000) * pricing["input"] +
                (output_tokens / 1_000_000) * pricing["output"]
            )
        
        def complete(
            self,
            messages: list[dict],
            preferred_model: str = "gpt-4o",
            force_model: Optional[str] = None,
            user_query_for_semantic_cache: Optional[str] = None,
            request_id: Optional[str] = None,
            **openai_kwargs,
        ) -> tuple[str, LLMCallRecord]:
            
            import uuid
            request_id = request_id or str(uuid.uuid4())[:8]
            start_time = time.time()
            
            # 1. Check exact cache
            cached = self.exact_cache.get(messages, preferred_model)
            if cached:
                record = LLMCallRecord(
                    request_id=request_id,
                    timestamp=start_time,
                    model_requested=preferred_model,
                    model_used="cache",
                    input_tokens=self._count_tokens(messages),
                    output_tokens=len(self.enc.encode(cached)),
                    cost_usd=0.0,
                    latency_ms=(time.time() - start_time) * 1000,
                    cache_hit=True,
                    cache_type="exact",
                )
                self.call_records.append(record)
                return cached, record
            
            # 2. Check semantic cache
            if user_query_for_semantic_cache:
                result = self.semantic_cache.lookup(user_query_for_semantic_cache)
                if result:
                    cached_response, similarity = result
                    record = LLMCallRecord(
                        request_id=request_id,
                        timestamp=start_time,
                        model_requested=preferred_model,
                        model_used="cache",
                        input_tokens=self._count_tokens(messages),
                        output_tokens=len(self.enc.encode(cached_response)),
                        cost_usd=0.0,
                        latency_ms=(time.time() - start_time) * 1000,
                        cache_hit=True,
                        cache_type="semantic",
                    )
                    logger.info(f"Semantic cache hit (similarity={similarity:.3f}) for request {request_id}")
                    self.call_records.append(record)
                    return cached_response, record
            
            # 3. Route to appropriate model
            if force_model:
                model = force_model
            elif self.enable_routing and user_query_for_semantic_cache:
                decision = route_request(
                    user_message=user_query_for_semantic_cache,
                    system_context=messages[0].get("content", "") if messages else "",
                )
                model = decision.tier.value
            else:
                model = preferred_model
            
            # 4. Make the actual API call
            input_tokens = self._count_tokens(messages)
            
            response = self.client.chat.completions.create(
                model=model,
                messages=messages,
                **openai_kwargs,
            )
            
            completion = response.choices[0].message.content
            output_tokens = response.usage.completion_tokens
            cost = self._calculate_cost(model, input_tokens, output_tokens)
            latency_ms = (time.time() - start_time) * 1000
            
            if cost > self.alert_threshold:
                logger.warning(
                    f"High-cost call: ${cost:.4f} for request {request_id} "
                    f"({input_tokens} in / {output_tokens} out on {model})"
                )
            
            record = LLMCallRecord(
                request_id=request_id,
                timestamp=start_time,
                model_requested=preferred_model,
                model_used=model,
                input_tokens=input_tokens,
                output_tokens=output_tokens,
                cost_usd=cost,
                latency_ms=latency_ms,
                cache_hit=False,
                cache_type=None,
            )
            
            # 5. Populate caches for future requests
            self.exact_cache.set(messages, model, completion)
            if user_query_for_semantic_cache:
                self.semantic_cache.store(user_query_for_semantic_cache, completion)
            
            self.call_records.append(record)
            return completion, record
        
        def cost_summary(self) -> dict:
            if not self.call_records:
                return {}
            
            api_calls = [r for r in self.call_records if not r.cache_hit]
            cache_hits = [r for r in self.call_records if r.cache_hit]
            
            total_cost = sum(r.cost_usd for r in api_calls)
            
            model_breakdown = {}
            for record in api_calls:
                model_breakdown.setdefault(record.model_used, {"calls": 0, "cost": 0.0})
                model_breakdown[record.model_used]["calls"] += 1
                model_breakdown[record.model_used]["cost"] += record.cost_usd
            
            return {
                "total_calls": len(self.call_records),
                "api_calls": len(api_calls),
                "cache_hits": len(cache_hits),
                "cache_hit_rate": len(cache_hits) / len(self.call_records),
                "total_cost_usd": total_cost,
                "avg_cost_per_api_call": total_cost / len(api_calls) if api_calls else 0,
                "model_breakdown": model_breakdown,
                "estimated_savings_from_cache_usd": sum(
                    self._calculate_cost("gpt-4o", r.input_tokens, r.output_tokens)
                    for r in cache_hits
                ),
            }
    

    Monitoring: What Gets Measured Gets Managed

    A cost optimization effort without monitoring is a one-time fix. You need ongoing visibility into four metrics:

    1. Cost per request (by feature, by user segment, by model tier)
    2. Cache hit rate (exact and semantic separately)
    3. Model distribution (what fraction of traffic hits each tier)
    4. Token efficiency ratio (output tokens / input tokens — unusually low ratios suggest prompt bloat)

    Emit your LLMCallRecord to your observability stack (Datadog, Grafana, CloudWatch, whatever you use) as structured logs. A simple approach:

    def emit_cost_metric(record: LLMCallRecord, feature_name: str) -> None:
        """
        Emit structured log for ingestion by any observability system.
        """
        logger.info(json.dumps({
            "event": "llm_api_call",
            "feature": feature_name,
            "request_id": record.request_id,
            "model": record.model_used,
            "input_tokens": record.input_tokens,
            "output_tokens": record.output_tokens,
            "cost_usd": record.cost_usd,
            "latency_ms": record.latency_ms,
            "cache_hit": record.cache_hit,
            "cache_type": record.cache_type,
        }))
    

    Set a weekly alert if your rolling 7-day cost grows more than 20% week-over-week without a corresponding growth in user activity. Cost growing faster than usage is always a signal that something has changed — a new code path, a prompt regression, or a routing misconfiguration.


    Hands-On Exercise

    Build a cost-optimized pipeline for the following scenario:

    Scenario: A SaaS company has a feature that lets users ask questions about their invoice history. The system has a 2,000-token static context block (company billing policy documentation) and answers questions like "When is my next billing date?" or "Can I get a refund for the charge on March 15?"

    Your tasks:

    1. Write a system prompt for this feature that is under 80 tokens while still being effective.
    2. Estimate the cost for 1,000 requests per day using GPT-4o, then calculate the savings if you route 60% of simple factual questions to GPT-4o-mini instead.
    3. Implement an exact-match cache for this system. What TTL would you set, and why? Consider that invoice data changes when a new billing cycle runs.
    4. Identify two specific query patterns that would be good semantic cache candidates and two that would be bad candidates. Explain your reasoning.
    5. Write a validator_fn for a waterfall that checks whether the response from the mini model actually contains a date when the user asks a date-related question.

    There are no trick questions here. The goal is to practice the tradeoffs — you'll find yourself arguing both for and against aggressive caching depending on the query type, which is exactly the right instinct.


    Common Mistakes & Troubleshooting

    Mistake 1: Caching responses that include dynamic data If your system prompt includes Today is {date} or injects user-specific account details, exact-match caching will serve stale or wrong data. The fix: separate static from dynamic prompt elements. Cache only responses built on purely static inputs.

    Mistake 2: Setting similarity thresholds too low A 0.85 cosine similarity threshold sounds high, but in practice it can match semantically related but factually distinct questions. "How do I cancel my subscription?" and "How do I pause my subscription?" might score 0.87. Serving the cancellation answer to a pause question is worse than a cache miss. Audit cache hits weekly.

    Mistake 3: Over-routing to cheap models Mini models hallucinate more on multi-step reasoning, struggle with complex instruction following, and produce lower-quality structured outputs. If you route everything to the cheapest tier and then spend engineering time debugging quality regressions, the cost savings evaporate. Measure quality, not just cost.

    Mistake 4: Ignoring streaming costs If you use streaming responses, your token counts are identical, but you can't cache mid-stream. Make sure your cache layer wraps the full response, not the stream. A common implementation mistake is writing to cache before the stream completes.

    Mistake 5: Forgetting embedding costs in semantic caching Every cache lookup requires an embedding call (text-embedding-3-small at $0.02/M tokens). For high-volume systems, this adds up. At 100,000 lookups per day with average 50-token queries, that's $0.10/day in embedding costs — probably worth it, but account for it in your ROI calculation.

    Troubleshooting: Cache hit rate below 5% Usually means either your prompts are highly variable (dynamic content mixed into static sections) or your semantic threshold is too high. Add logging to inspect cache misses and look for near-duplicate queries that should have hit.

    Troubleshooting: Unexpected cost spikes Check your model routing logs first — a configuration change that accidentally routes everything to a frontier model will cause a 10x cost spike instantly. Second, look at input token counts — an upstream change that passes a full document as context instead of a summary is a common culprit.


    Summary & Next Steps

    Cost optimization for LLM APIs isn't a one-time project — it's an ongoing discipline, like database query optimization or API rate limiting. The key levers are:

    • Token reduction: Compress system prompts, bound context windows, control output length with both instructions and max_tokens
    • Model routing: Match request complexity to model tier using rule-based or ML-based routing, with waterfalls for quality assurance
    • Caching: Exact-match for deterministic workflows, semantic caching for conversational interfaces, and provider-level caching by structuring prompts with stable prefixes
    • Monitoring: Track cost per request, cache hit rates, model distribution, and week-over-week cost trends

    The target is not the cheapest possible implementation — it's the cheapest implementation that meets your quality bar. Define that quality bar explicitly (through evals, user satisfaction metrics, or correctness tests), then optimize toward it. Cost and quality are in tension, but they're not opposites. A well-designed system often achieves both simultaneously by eliminating waste without sacrificing signal.

    What to build next:

    • Implement prompt evals before and after compression to quantify the quality impact of your optimizations. promptfoo is a good open-source starting point.
    • Explore fine-tuning for high-volume, narrow-domain tasks. A fine-tuned mini model can outperform a frontier model on your specific task at 20x lower cost.
    • Learn about batching APIs. OpenAI's Batch API offers 50% discounts for asynchronous workloads — perfect for nightly data processing or bulk document analysis.
    • Study LLM evaluation frameworks so you can measure quality rigorously rather than relying on subjective impressions when making model-tier tradeoffs.

    Learning Path: Intro to AI & Prompt Engineering

    Previous

    From Vague to Precise: How to Diagnose and Fix Prompts That Return Unhelpful AI Responses

    Related Articles

    AI & Machine Learning🌱 Foundation

    Document Ingestion Pipelines: Loading, Cleaning, and Preprocessing Text for RAG

    15 min
    AI & Machine Learning🌱 Foundation

    Conversation Design for LLMs: Structuring System Prompts, Roles, and Message Threads with the Chat Completions API

    15 min
    AI & Machine Learning🌱 Foundation

    From Vague to Precise: How to Diagnose and Fix Prompts That Return Unhelpful AI Responses

    16 min

    On this page

    • Introduction
    • Prerequisites
    • Understanding What You're Actually Paying For
    • Token Reduction: Trimming the Fat Without Losing the Signal
    • Auditing and Compressing System Prompts
    • Managing Context: Retrieval-Augmented Generation (RAG) Costs
    • Controlling Output Length
    • Model Routing: Using the Right Tool for the Job
    • Building a Routing Layer
    • The Waterfall Pattern
    • Caching Strategies: Don't Pay for the Same Answer Twice
    • Exact-Match Caching
    • Semantic Caching
    • Provider-Level Prompt Caching
    • Real-World Project: Building a Cost-Aware LLM Middleware Layer
    • Monitoring: What Gets Measured Gets Managed
    • Hands-On Exercise
    • Common Mistakes & Troubleshooting
    • Summary & Next Steps