Skip to content
Ayhan Sipahi Ayhan Sipahi

FinOps for AI Workloads: Managing LLM Costs in Production

Prompt caching, model routing, token budgets, and semantic caching: how to keep production LLM spend predictable without giving up answer quality.

Running large language models in production breaks the cost model most cloud teams already trust. Compute-hour billing is predictable, and token billing is not. The same feature can cost a fraction of a cent or several dollars per call, depending on how much context the prompt carries, which model answers, and how many tool calls the agent makes before it stops.

The default worth starting from is prompt caching plus a hard output limit. Both are provider features, both take an afternoon, and neither changes what the model produces. Model routing and semantic caching come after that, because they trade answer quality for savings and need an evaluation set before they can be trusted in production. Token budgets and cost metrics are the safety net under all four.

The Token-Based Billing Challenge

A single poorly designed prompt can consume more tokens than thousands of optimized requests. The examples below price everything with gpt-4-turbo rates ($10 per 1M input tokens, $30 per 1M output tokens) so the comparisons stay on one scale.

Cost Variability Example

# Simple query: "What's the weather?"
# Input: 50 tokens (user message + system prompt)
# Output: 30 tokens
# Cost: (50 * $10/1M) + (30 * $30/1M) = $0.0014

# Complex RAG query: "Analyze Q4 sales trends and recommend strategies"
# Input: 8,050 tokens (50 user + 6,000 system + 2,000 RAG context)
# Output: 500 tokens
# Cost: (8,050 * $10/1M) + (500 * $30/1M) = $0.0955 (68x more expensive)

# Tool-call storm: agent invokes 20 tools in one turn
# Input across tool calls: 8,050 * 20 = 161,000 tokens
# Output across tool calls: 500 * 20 = 10,000 tokens
# Cost: (161,000 * $10/1M) + (10,000 * $30/1M) = $1.91 (1,364x more expensive)

Nothing in the architecture has to change for the bill to grow by an order of magnitude. The same feature, sent more often and carrying a longer context, is enough. That is why the gap widens exactly when an application moves from proof of concept to production.

Provider Pricing Models

Different providers offer distinct pricing structures that significantly impact total cost of ownership.

LLM Pricing Models

On-Demand

Provisioned Throughput

Batch Inference

Pay per token

Variable latency

No commitment

Reserved capacity

Fixed hourly cost

Predictable performance

50% discount

Async processing

Non-urgent workloads

AWS Bedrock Pricing Tiers

Standard (On-Demand): Token-based billing with no commitments

  • Claude Sonnet 4.6: $3 input, $15 output per 1M tokens
  • Most flexible option, highest per-token cost

Batch Inference: 50% discount for asynchronous workloads

  • Ideal for overnight reports, bulk document analysis
  • Non-real-time processing acceptable

Provisioned Throughput: Time-based pricing for high-volume scenarios

  • Reserved capacity with predictable costs
  • Example: Claude Haiku 4.5 with Provisioned Throughput (6-month commitment)

OpenAI Pricing Structure

PRICING = {
    'gpt-4-turbo': {
        'input': 10.00 / 1_000_000,
        'output': 30.00 / 1_000_000
    },
    'gpt-4o': {
        'input': 2.50 / 1_000_000,
        'output': 10.00 / 1_000_000
    },
    'gpt-4o-mini': {
        'input': 0.15 / 1_000_000,
        'output': 0.60 / 1_000_000
    }
}

# Key insight: Output tokens cost 2-5x more than input tokens
# GPT-4: Output is 3x more expensive ($30 vs $10 per 1M)
# GPT-4o: Output is 4x more expensive ($10 vs $2.50 per 1M)

Anthropic Direct Pricing

  • Claude Opus 4.1: $15 input, $75 output per 1M tokens
  • Claude Opus 4.5: $5 input, $25 output per 1M tokens (newer, more cost-effective)
  • Claude Sonnet 4.6: $3 input, $15 output per 1M tokens
  • Claude Haiku 3: $0.25 input, $1.25 output per 1M tokens
  • Claude Haiku 4.5: $1 input, $5 output per 1M tokens (newer generation)
  • Prompt Caching: 90% discount on cached tokens with 85% latency reduction
  • Cache Write Premium: 25% premium on cache writes (one-time cost for caching content)

Optimization Strategy 1: Prompt Caching

Prompt caching provides the highest cost reduction with minimal implementation effort. By marking static prompt components as cacheable, subsequent requests within the cache TTL period receive a 90% discount on those tokens.

Implementation with AWS Bedrock

import boto3
import json

bedrock_runtime = boto3.client('bedrock-runtime')

# Large system prompt with company policies (10,000 tokens)
SYSTEM_PROMPT = """You are a customer support agent for Acme Corp.

Company Policies:
[... 8,000 tokens of policies, procedures, FAQs ...]

Communication Style:
- Professional but friendly
- Concise responses (max 200 words)
- Always include relevant policy references

Tool Usage Guidelines:
[... 2,000 tokens of tool documentation ...]
"""

def invoke_with_caching(user_message: str):
    response = bedrock_runtime.converse(
        modelId="anthropic.claude-sonnet-4-5-20250929-v1:0",
        messages=[
            {
                "role": "user",
                "content": [{"text": user_message}]
            }
        ],
        system=[
            {
                "text": SYSTEM_PROMPT,
                # Default TTL is 5 minutes. Sonnet 4.5 needs at least 4,096
                # tokens before a checkpoint caches anything at all.
                "cachePoint": {"type": "default"}
            }
        ]
    )

    # Cost analysis
    usage = response['usage']

    # First call: 50 uncached input tokens, 10,000 written to cache
    # (writes are billed at 1.25x the base input rate)
    # Later calls inside the TTL: 50 uncached, 10,000 read at the
    # discounted rate

    # inputTokens counts only uncached tokens, so total input is
    # inputTokens + cacheReadInputTokens + cacheWriteInputTokens
    print(f"Uncached input tokens: {usage.get('inputTokens', 0)}")
    print(f"Cache read tokens: {usage.get('cacheReadInputTokens', 0)}")
    print(f"Cache write tokens: {usage.get('cacheWriteInputTokens', 0)}")
    print(f"Output tokens: {usage.get('outputTokens', 0)}")

    return response

Cost Impact Analysis

# 100 requests against the same 10,000-token system prompt, 200 tokens
# out each, priced at $3/1M input and $15/1M output

# Without caching:
# (10,050 * 100 * $3/1M) + (200 * 100 * $15/1M) = $3.32

# With caching, 1 write and 99 reads inside the TTL window:
# Write request: (50 * $3/1M) + (10,000 * $3.75/1M) + (200 * $15/1M) = $0.0407
# Read requests: (50 * $3/1M) + (10,000 * $0.30/1M) + (200 * $15/1M) = $0.0062
# Total: $0.0407 + (99 * $0.0062) = $0.65
# Reduction: 80%

# The write premium is why sparse traffic can cost more with caching on:
# a cache that expires before it is read is a pure 1.25x surcharge.

Implementation Best Practices

Structure Prompts for Caching:

  • Place static content (policies, instructions) first
  • Dynamic context (user data, timestamps) goes in user messages
  • Avoid changing cached sections unnecessarily

Common Pitfalls:

  • Dynamic Timestamps: Adding current_time to system prompt invalidates cache every request
  • Intermittent Traffic: The TTL resets on every hit, so steady traffic keeps the cache warm and an idle gap lets it expire
  • Prompt Versioning: Deploy prompt changes during low-traffic periods
# BAD: Dynamic content invalidates cache
system_prompt = f"""
You are a support agent.
Current time: {datetime.now().isoformat()}  # Changes every request!
[... rest of prompt ...]
"""

# GOOD: Static prompt, dynamic context in user message
system_prompt = """
You are a support agent.
[... static policies and instructions ...]
"""

user_message = f"""
Current time: {datetime.now().isoformat()}
User question: {question}
"""

Optimization Strategy 2: Intelligent Model Routing

Not all queries require the most powerful (and expensive) model. Routing by complexity moves the simple majority of traffic to a cheaper model, and the saving equals the price gap between the two models times the share of traffic you can safely move. That share is a measurement, not a constant, so keep an evaluation set behind the router.

Custom Routing Implementation

import OpenAI from 'openai';

interface ModelRoutingConfig {
  simpleThreshold: number;  // < 0.3 = simple query
  complexThreshold: number;  // > 0.7 = complex query
  models: {
    simple: string;
    medium: string;
    complex: string;
  };
}

interface QueryComplexity {
  score: number;
  factors: {
    wordCount: number;
    questionType: string;
    contextRequired: boolean;
    multiStepReasoning: boolean;
  };
}

class IntelligentRouter {
  private openai: OpenAI;
  private config: ModelRoutingConfig;

  constructor() {
    this.openai = new OpenAI();
    this.config = {
      simpleThreshold: 0.3,
      complexThreshold: 0.7,
      models: {
        simple: 'gpt-4o-mini',  // $0.15 input, $0.60 output per 1M
        medium: 'gpt-4o',  // $2.50 input, $10.00 output per 1M
        complex: 'gpt-4-turbo'  // $10.00 input, $30.00 output per 1M
      }
    };
  }

  /**
   * Analyze query complexity using heuristics
   * Production systems might use a lightweight classifier model
   */
  analyzeComplexity(query: string): QueryComplexity {
    const words = query.split(/\s+/);
    const wordCount = words.length;

    // Detect question type
    const questionType = this.detectQuestionType(query);

    // Check for multi-step reasoning indicators
    const multiStepKeywords = ['compare', 'analyze', 'design', 'implement',
                                'evaluate', 'recommend', 'strategize'];
    const multiStepReasoning = multiStepKeywords.some(kw =>
      query.toLowerCase().includes(kw)
    );

    // Context required (references to previous conversation, documents)
    const contextRequired = query.toLowerCase().includes('previous') ||
                           query.toLowerCase().includes('earlier') ||
                           query.toLowerCase().includes('mentioned');

    // Calculate complexity score (0.0 - 1.0)
    let score = 0.0;

    // Word count factor (longer = potentially more complex)
    if (wordCount < 10) score += 0.1;
    else if (wordCount < 30) score += 0.3;
    else score += 0.5;

    // Question type factor
    if (questionType === 'factual') score += 0.1;
    else if (questionType === 'analytical') score += 0.5;
    else score += 0.3;

    // Multi-step reasoning adds significant complexity
    if (multiStepReasoning) score += 0.3;

    // Context requirement adds complexity
    if (contextRequired) score += 0.2;

    // Normalize to 0.0 - 1.0 range
    score = Math.min(1.0, score);

    return {
      score,
      factors: {
        wordCount,
        questionType,
        contextRequired,
        multiStepReasoning
      }
    };
  }

  private detectQuestionType(query: string): string {
    const lower = query.toLowerCase();

    // Factual questions
    if (lower.match(/^(what|when|where|who) is/)) return 'factual';

    // Analytical questions
    if (lower.match(/(how|why|explain|compare|analyze)/)) return 'analytical';

    // Procedural questions
    if (lower.match(/(how to|steps|process|implement)/)) return 'procedural';

    return 'general';
  }

  selectModel(complexity: QueryComplexity): string {
    if (complexity.score < this.config.simpleThreshold) {
      return this.config.models.simple;
    } else if (complexity.score < this.config.complexThreshold) {
      return this.config.models.medium;
    } else {
      return this.config.models.complex;
    }
  }

  async invoke(query: string, systemPrompt: string) {
    const complexity = this.analyzeComplexity(query);
    const model = this.selectModel(complexity);

    console.log(`Query complexity: ${complexity.score.toFixed(2)} -> ${model}`);

    const response = await this.openai.chat.completions.create({
      model,
      messages: [
        { role: 'system', content: systemPrompt },
        { role: 'user', content: query }
      ],
      temperature: 0.7
    });

    return {
      response: response.choices[0].message.content,
      model,
      complexity: complexity.score,
      usage: response.usage
    };
  }
}

// Usage example
const router = new IntelligentRouter();

// Simple query -> gpt-4o-mini
await router.invoke(
  "What's your return policy?",
  "You are a customer support agent"
);

// Complex query -> gpt-4-turbo
await router.invoke(
  "Compare our enterprise and business plans, analyze which would be better for a mid-sized company with 500 employees, considering scalability and cost over 3 years",
  "You are a customer support agent"
);

AWS Bedrock Intelligent Prompt Routing

AWS Bedrock offers the same idea as a managed endpoint. A prompt router predicts response quality per request and picks between exactly two models in one family:

import boto3

bedrock = boto3.client('bedrock')
runtime = boto3.client('bedrock-runtime')

# Router ARNs come from ListPromptRouters or the Prompt Routers page
# in the console. A router chooses between two models in one family.
router_arn = bedrock.list_prompt_routers()['promptRouterSummaries'][0]['promptRouterArn']

response = runtime.converse(
    modelId=router_arn,
    messages=[
        {
            "role": "user",
            "content": [{"text": "What's the weather in Seattle?"}]
        }
    ]
)

# The response reports which model actually served the request.
# Two constraints before relying on this in production:
# - Routing is only optimized for English prompts.
# - Savings are the price gap between the two models times the share of
#   traffic that lands on the cheaper one, so measure the split rather
#   than assuming one.

Expected Outcomes

The diagram below assumes a 60/30/10 traffic mix. Change that mix and the whole case for routing changes with it.

60% Simple

30% Medium

10% Complex

100 Queries/Day

Query Complexity

GPT-4o Mini

GPT-4o

GPT-4 Turbo

Cost: $0.15/1M

Cost: $2.50/1M

Cost: $10.00/1M

Weighted Average: $1.84/1M

vs GPT-4 Only: $10/1M

Savings: 82%

Optimization Strategy 3: Token Budget Enforcement

Unbounded token consumption leads to cost storms. Implementing hard limits prevents runaway expenses while maintaining system functionality.

Budget Tracking Implementation

from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Dict, Optional
import redis

@dataclass
class TokenBudget:
    max_input_tokens_per_request: int
    max_output_tokens_per_request: int
    max_tokens_per_user_daily: int
    max_tokens_per_team_monthly: int

@dataclass
class BudgetUsage:
    user_id: str
    team_id: str
    tokens_used_today: int
    tokens_used_this_month: int
    last_reset: datetime

class TokenBudgetEnforcer:
    def __init__(self, budget: TokenBudget):
        self.budget = budget
        self.redis_client = redis.Redis(host='localhost', decode_responses=True)

    def check_and_reserve(
        self,
        user_id: str,
        team_id: str,
        estimated_input_tokens: int,
        estimated_output_tokens: int
    ) -> tuple[bool, Optional[str]]:
        """
        Check if request is within budget and reserve tokens.
        Returns (allowed, error_message)
        """

        # Check per-request limits
        if estimated_input_tokens > self.budget.max_input_tokens_per_request:
            return False, f"Input tokens ({estimated_input_tokens}) exceed per-request limit ({self.budget.max_input_tokens_per_request})"

        if estimated_output_tokens > self.budget.max_output_tokens_per_request:
            return False, f"Output tokens ({estimated_output_tokens}) exceed per-request limit ({self.budget.max_output_tokens_per_request})"

        # Check daily user limit
        user_daily_key = f"budget:user:{user_id}:daily"
        user_tokens_today = int(self.redis_client.get(user_daily_key) or 0)

        total_estimated = estimated_input_tokens + estimated_output_tokens

        if user_tokens_today + total_estimated > self.budget.max_tokens_per_user_daily:
            return False, f"User daily limit exceeded ({user_tokens_today}/{self.budget.max_tokens_per_user_daily})"

        # Check monthly team limit
        team_monthly_key = f"budget:team:{team_id}:monthly"
        team_tokens_this_month = int(self.redis_client.get(team_monthly_key) or 0)

        if team_tokens_this_month + total_estimated > self.budget.max_tokens_per_team_monthly:
            return False, f"Team monthly limit exceeded ({team_tokens_this_month}/{self.budget.max_tokens_per_team_monthly})"

        # Reserve tokens (optimistic locking)
        pipe = self.redis_client.pipeline()

        # Increment user daily counter (expires at midnight)
        tomorrow = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
        seconds_until_midnight = int((tomorrow - datetime.now()).total_seconds())
        pipe.incrby(user_daily_key, total_estimated)
        pipe.expire(user_daily_key, seconds_until_midnight)

        # Increment team monthly counter (expires at month end)
        next_month = (datetime.now().replace(day=1) + timedelta(days=32)).replace(day=1)
        seconds_until_month_end = int((next_month - datetime.now()).total_seconds())
        pipe.incrby(team_monthly_key, total_estimated)
        pipe.expire(team_monthly_key, seconds_until_month_end)

        pipe.execute()

        return True, None

    def record_actual_usage(
        self,
        user_id: str,
        team_id: str,
        actual_input_tokens: int,
        actual_output_tokens: int,
        estimated_input_tokens: int,
        estimated_output_tokens: int
    ):
        """
        Adjust budget based on actual vs estimated usage.
        """
        actual_total = actual_input_tokens + actual_output_tokens
        estimated_total = estimated_input_tokens + estimated_output_tokens
        difference = actual_total - estimated_total

        if difference != 0:
            pipe = self.redis_client.pipeline()
            pipe.incrby(f"budget:user:{user_id}:daily", difference)
            pipe.incrby(f"budget:team:{team_id}:monthly", difference)
            pipe.execute()

# Usage in LLM application
budget_enforcer = TokenBudgetEnforcer(
    budget=TokenBudget(
        max_input_tokens_per_request=8000,  # Prevent huge contexts
        max_output_tokens_per_request=2000,  # Limit response length
        max_tokens_per_user_daily=100_000,  # ~$1/day per user at $10/1M input
        max_tokens_per_team_monthly=10_000_000  # ~$100/month per team, same rate
    )
)

def invoke_llm_with_budget(user_id: str, team_id: str, prompt: str):
    # Estimate tokens (rough approximation)
    estimated_input = len(prompt.split()) * 1.3  # Account for tokenization
    estimated_output = 500  # Conservative estimate

    # Check budget
    allowed, error = budget_enforcer.check_and_reserve(
        user_id, team_id, int(estimated_input), estimated_output
    )

    if not allowed:
        raise BudgetExceededError(error)

    # Invoke LLM
    response = openai.chat.completions.create(
        model="gpt-4",
        messages=[{"role": "user", "content": prompt}],
        max_tokens=budget_enforcer.budget.max_output_tokens_per_request
    )

    # Record actual usage
    budget_enforcer.record_actual_usage(
        user_id,
        team_id,
        response.usage.prompt_tokens,
        response.usage.completion_tokens,
        int(estimated_input),
        estimated_output
    )

    return response.choices[0].message.content

Alert Configuration

def check_budget_alerts(user_id: str, team_id: str, redis_client, budget):
    """
    Trigger alerts at 70%, 90%, 100% budget thresholds
    """
    user_daily_key = f"budget:user:{user_id}:daily"
    user_tokens_today = int(redis_client.get(user_daily_key) or 0)

    daily_limit = budget.max_tokens_per_user_daily
    usage_percentage = (user_tokens_today / daily_limit) * 100

    if usage_percentage >= 100:
        send_alert(
            level="CRITICAL",
            message=f"User {user_id} exceeded daily budget",
            action="BLOCK"
        )
    elif usage_percentage >= 90:
        send_alert(
            level="WARNING",
            message=f"User {user_id} at 90% of daily budget",
            action="NOTIFY"
        )
    elif usage_percentage >= 70:
        send_alert(
            level="INFO",
            message=f"User {user_id} at 70% of daily budget",
            action="MONITOR"
        )

Common Budget Pitfalls

Tool-Call Storms: Agents invoke 50+ tools without limits, consuming millions of tokens

# Solution: Set max_tool_calls_per_turn
agent = Agent(
    tools=[get_product_details, get_reviews, get_pricing],
    max_tool_calls_per_turn=5,  # Hard limit
    instructions="Use batch queries when possible."
)

RAG Over-Retrieval: Retrieving 50 chunks when 5 would suffice

# BAD: Too many chunks
retriever = VectorStoreRetriever(
    vector_store=vector_db,
    search_kwargs={"k": 50}  # 25,000 tokens of context
)

# GOOD: Focused retrieval
retriever = VectorStoreRetriever(
    vector_store=vector_db,
    search_kwargs={"k": 5}  # 2,500 tokens (90% reduction)
)

Optimization Strategy 4: Semantic Caching

Traditional caching only matches exact queries. Semantic caching uses vector similarity so that questions with the same meaning share one answer, which raises the hit rate on repetitive traffic. It also introduces the risk that two questions look alike and are not, so the similarity threshold is as much a product decision as a tuning knob.

Implementation with Vector Similarity

import hashlib
import json
from typing import Optional
import redis
from sentence_transformers import SentenceTransformer
import numpy as np

class SemanticCache:
    def __init__(
        self,
        redis_client: redis.Redis,
        similarity_threshold: float = 0.95,
        ttl_seconds: int = 3600
    ):
        self.redis = redis_client
        self.similarity_threshold = similarity_threshold
        self.ttl_seconds = ttl_seconds

        # Lightweight embedding model for semantic matching
        self.embedding_model = SentenceTransformer('all-MiniLM-L6-v2')

    def _get_embedding(self, text: str) -> np.ndarray:
        """Generate embedding vector for query"""
        return self.embedding_model.encode(text, normalize_embeddings=True)

    def _cosine_similarity(self, vec1: np.ndarray, vec2: np.ndarray) -> float:
        """Calculate cosine similarity between two vectors"""
        return np.dot(vec1, vec2)  # Vectors are already normalized

    def get(self, query: str, system_prompt: str = "") -> Optional[dict]:
        """
        Retrieve cached response if semantically similar query exists
        """
        cache_key_prefix = f"semantic_cache:{hashlib.md5(system_prompt.encode()).hexdigest()}"

        # Get all cached queries for this system prompt
        cached_keys = self.redis.keys(f"{cache_key_prefix}:*")

        if not cached_keys:
            return None

        query_embedding = self._get_embedding(query)

        best_match = None
        best_similarity = 0.0

        # Find most similar cached query
        for key in cached_keys:
            cached_data = self.redis.get(key)
            if not cached_data:
                continue

            cached = json.loads(cached_data)
            cached_embedding = np.array(cached['embedding'])

            similarity = self._cosine_similarity(query_embedding, cached_embedding)

            if similarity > best_similarity:
                best_similarity = similarity
                best_match = cached

        # Return cached response if similarity exceeds threshold
        if best_similarity >= self.similarity_threshold:
            return {
                'response': best_match['response'],
                'similarity': best_similarity,
                'cached': True,
                'original_query': best_match['query']
            }

        return None

    def set(self, query: str, response: str, system_prompt: str = ""):
        """
        Cache query-response pair with semantic embedding
        """
        cache_key_prefix = f"semantic_cache:{hashlib.md5(system_prompt.encode()).hexdigest()}"
        query_hash = hashlib.md5(query.encode()).hexdigest()
        cache_key = f"{cache_key_prefix}:{query_hash}"

        embedding = self._get_embedding(query)

        cache_data = {
            'query': query,
            'response': response,
            'embedding': embedding.tolist(),
            'timestamp': datetime.utcnow().isoformat()
        }

        self.redis.setex(
            cache_key,
            self.ttl_seconds,
            json.dumps(cache_data)
        )

# Usage in production
semantic_cache = SemanticCache(
    redis_client=redis.Redis(host='localhost', decode_responses=False),
    similarity_threshold=0.95,  # 95% similarity required
    ttl_seconds=3600  # Cache for 1 hour
)

def invoke_with_semantic_cache(query: str, system_prompt: str):
    # Check semantic cache first
    cached = semantic_cache.get(query, system_prompt)

    if cached:
        print(f"Cache hit! Similarity: {cached['similarity']:.2%}")
        print(f"Original query: {cached['original_query']}")
        return cached['response']

    # Cache miss - invoke LLM
    response = openai.chat.completions.create(
        model="gpt-4",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": query}
        ]
    )

    result = response.choices[0].message.content

    # Cache for future semantically similar queries
    semantic_cache.set(query, result, system_prompt)

    return result

# Example: Semantically similar queries
# Query 1: "What's your refund policy?"
# Query 2: "How do I get my money back?"
# Query 3: "Can I return items for a refund?"
# All three would match with > 95% similarity and return cached response

Performance Impact

# Hit rate belongs to the traffic. Support queues and FAQ
# endpoints cluster tightly around a few intents;
# open-ended assistants barely cluster at all. Replay a day of real
# queries through the matcher before budgeting for savings.

# Trade-offs:
# - Embedding computation: one extra forward pass on a small local
#   model, negligible next to the LLM call it may avoid
# - Redis memory: 384 float dimensions per entry for MiniLM-L6,
#   roughly 1.5 KB at 4-byte floats
# - Similarity tuning: too low = confidently wrong answers,
#   too high = the cache never hits

Cost Monitoring and Observability

Without real-time visibility into token consumption, cost problems remain hidden until the bill arrives.

CloudWatch Metrics Implementation

import boto3
from datetime import datetime
from dataclasses import dataclass

@dataclass
class CostMetrics:
    timestamp: datetime
    model: str
    input_tokens: int
    output_tokens: int
    cached_tokens: int
    total_cost: float
    user_id: str
    team_id: str
    request_type: str  # 'simple', 'medium', 'complex'

class LLMCostTracker:
    def __init__(self):
        self.cloudwatch = boto3.client('cloudwatch')

        # Provider pricing (updated 2025)
        self.pricing = {
            'gpt-4-turbo': {
                'input': 10.00 / 1_000_000,
                'output': 30.00 / 1_000_000
            },
            'gpt-4o': {
                'input': 2.50 / 1_000_000,
                'output': 10.00 / 1_000_000
            },
            'gpt-4o-mini': {
                'input': 0.15 / 1_000_000,
                'output': 0.60 / 1_000_000
            },
            'claude-sonnet-3.5': {
                'input': 3.00 / 1_000_000,
                'output': 15.00 / 1_000_000,
                'cached_input': 0.30 / 1_000_000  # 90% discount
            }
        }

    def calculate_cost(self, metrics: CostMetrics) -> float:
        """Calculate cost based on token usage and model pricing"""
        pricing = self.pricing.get(metrics.model)
        if not pricing:
            raise ValueError(f"Unknown model: {metrics.model}")

        input_cost = metrics.input_tokens * pricing['input']
        output_cost = metrics.output_tokens * pricing['output']

        # Apply caching discount if applicable
        if metrics.cached_tokens > 0 and 'cached_input' in pricing:
            cached_cost = metrics.cached_tokens * pricing['cached_input']
            # Cached tokens already counted in input_tokens, so adjust
            uncached_tokens = metrics.input_tokens - metrics.cached_tokens
            input_cost = (uncached_tokens * pricing['input']) + cached_cost

        return input_cost + output_cost

    def publish_metrics(self, metrics: CostMetrics):
        """Publish metrics to CloudWatch for dashboard visualization"""

        cost = self.calculate_cost(metrics)

        metric_data = [
            {
                'MetricName': 'TokenUsage',
                'Dimensions': [
                    {'Name': 'Model', 'Value': metrics.model},
                    {'Name': 'TokenType', 'Value': 'Input'}
                ],
                'Value': metrics.input_tokens,
                'Unit': 'Count',
                'Timestamp': metrics.timestamp
            },
            {
                'MetricName': 'TokenUsage',
                'Dimensions': [
                    {'Name': 'Model', 'Value': metrics.model},
                    {'Name': 'TokenType', 'Value': 'Output'}
                ],
                'Value': metrics.output_tokens,
                'Unit': 'Count',
                'Timestamp': metrics.timestamp
            },
            {
                'MetricName': 'LLMCost',
                'Dimensions': [
                    {'Name': 'Model', 'Value': metrics.model},
                    {'Name': 'Team', 'Value': metrics.team_id},
                    {'Name': 'RequestType', 'Value': metrics.request_type}
                ],
                'Value': cost,
                'Unit': 'None',  # Dollars
                'Timestamp': metrics.timestamp
            }
        ]

        # Add cache hit rate metric if caching is used
        if metrics.cached_tokens > 0:
            cache_hit_rate = (metrics.cached_tokens / metrics.input_tokens) * 100
            metric_data.append({
                'MetricName': 'CacheHitRate',
                'Dimensions': [{'Name': 'Model', 'Value': metrics.model}],
                'Value': cache_hit_rate,
                'Unit': 'Percent',
                'Timestamp': metrics.timestamp
            })

        self.cloudwatch.put_metric_data(
            Namespace='LLM/Costs',
            MetricData=metric_data
        )

    def create_cost_anomaly_alarm(self, threshold_dollars: float):
        """Create CloudWatch alarm for cost anomalies"""
        self.cloudwatch.put_metric_alarm(
            AlarmName='LLM-Daily-Cost-Anomaly',
            ComparisonOperator='GreaterThanThreshold',
            EvaluationPeriods=1,
            MetricName='LLMCost',
            Namespace='LLM/Costs',
            Period=86400,  # 24 hours
            Statistic='Sum',
            Threshold=threshold_dollars,
            ActionsEnabled=True,
            AlarmActions=[
                'arn:aws:sns:us-east-1:123456789012:llm-cost-alerts'
            ],
            AlarmDescription=f'Alert when daily LLM costs exceed ${threshold_dollars}'
        )

Key Metrics Dashboard

LLM Cost Observability

Cost Metrics

Efficiency Metrics

Performance Metrics

Cost per request

Cost per user

Budget burn rate

Cache hit rate

Token waste rate

Model routing accuracy

Latency p95/p99

Error rate

Timeout rate

CloudWatch Insights Queries:

-- Cost per model per day
fields @timestamp, model, sum(cost) as daily_cost
| filter namespace = "LLM/Costs"
| stats sum(daily_cost) by model, bin(@timestamp, 1d)

-- Top 10 most expensive users
fields user_id, sum(cost) as user_cost
| filter namespace = "LLM/Costs"
| stats sum(user_cost) by user_id
| sort user_cost desc
| limit 10

-- Cache effectiveness (cost savings)
fields @timestamp,
       sum(cached_tokens) / sum(input_tokens) * 100 as cache_hit_rate,
       sum(cached_tokens) * (standard_price - cached_price) as savings
| filter namespace = "LLM/Costs" and model = "claude-sonnet-3.5"
| stats avg(cache_hit_rate), sum(savings) by bin(@timestamp, 1h)

Common Pitfalls

Output Token Costs

Output tokens cost 2-5x more than input tokens, yet optimization often focuses only on input.

# Example: RAG application
# Input: 8,000 tokens (2,000 user query + 6,000 retrieved context)
# Output: 2,000 tokens (detailed answer)

# GPT-4 cost:
# - Input: 8,000 × $10/1M = $0.08
# - Output: 2,000 × $30/1M = $0.06
# Output is 43% of total cost despite being 20% of tokens

# Solution: Aggressive max_tokens limits + conciseness prompts
system_prompt = """
Answer concisely in under 150 words.
Prioritize clarity over exhaustive detail.
"""

response = openai.chat.completions.create(
    model="gpt-4",
    messages=[...],
    max_tokens=200  # Hard limit
)

Cache Invalidation from Minor Changes

Small prompt variations invalidate entire cache, destroying effectiveness.

# BAD: Dynamic timestamp invalidates cache every request
system_prompt = f"""
You are a support agent.
Current time: {datetime.now().isoformat()}  # Different every time!
[... rest of prompt ...]
"""

# GOOD: Static content only, dynamic context in user message
system_prompt = """
You are a support agent.
[... static policies ...]
"""

user_message = f"""
Current time: {datetime.now().isoformat()}
User question: {question}
"""

Missing Early Instrumentation

Deploying to production without observability means discovering problems after the damage is done.

Token cost tracks request volume almost linearly, which makes the growth easy to project and easy to miss:

100 requests/day    = $50/month
1,000 requests/day  = $500/month
10,000 requests/day = $5,000/month
50,000 requests/day = $25,000/month

Instrument from day one, publish metrics to CloudWatch in the same change, and set the budget alerts before launch rather than after the first surprise.

Optimization Impact Matrix

Savings depend on your traffic mix, so the useful comparison is the lever each technique pulls and what it costs in quality and effort. The first three rows are safe to turn on for almost any workload; the last three need judgement.

OptimizationCost leverQuality impactImplementation effort
Prompt cachingRepeated prefix billed at the cache-read rateNoneLow (provider feature)
Batch inferenceProvider batch discount (50% on Bedrock)None, async onlyLow (provider feature)
Output limitsOutput is the expensive side of the billLow, answers get shorterLow (parameter setting)
Model routingCheap model absorbs the simple share of trafficNeeds an evaluation setMedium (routing logic)
Semantic cachingSkips the call entirely on near-duplicatesMedium, staleness and false matchesMedium (vector store)
Token budgetsCaps the blast radius of runaway loopsNone, prevents wasteMedium (budget system)

Where This Approach Holds

Prompt caching and a hard output limit are the right first move for almost any production LLM feature. The discount is a provider feature, so nothing about the architecture changes, neither technique touches the content of an answer, and both show up immediately in the usage fields the API already returns. Ship them alongside token metrics so the next optimization argument starts from data.

Three situations call for a different order. When traffic is too sparse to reach the cache before its TTL expires, the 1.25x write premium turns caching into a net loss, and batch inference or a smaller model is the better lever. When answers have to stay long and detailed, output limits fight the product, and the effort belongs in routing instead. When the workload is one narrow task rather than a general assistant, skip routing entirely: pick the cheapest model that clears your evaluation set and spend the saved complexity elsewhere, because a router only earns its keep on a genuinely mixed traffic profile.

References

  • FinOps Framework Overview - FinOps Foundation’s operating model covering the Inform, Optimize, and Operate lifecycle for cloud financial management
  • FinOps Principles - The six core principles of FinOps practice, updated in 2025 to include AI and non-cloud cost scopes
  • What is AWS Billing and Cost Management? - AWS documentation overview of Cost Explorer, Budgets, and Cost Optimization Hub
  • AWS Cost Explorer - Service documentation for analyzing and visualizing AWS spend by service, tag, and time period
  • AWS Budgets - Setting cost and usage budget thresholds with automated alerts for AI/ML workload cost control
  • AWS Cost Optimization Hub - Unified view of rightsizing and savings recommendations that accounts for Reserved Instances and Savings Plans
  • Prompt caching for faster model inference - Amazon Bedrock cache checkpoints, per-model token minimums, TTL behaviour, and the usage fields the Converse API returns
  • Intelligent prompt routing in Amazon Bedrock - How default and configured routers pick between two models in one family, including the English-only optimization limit
  • Amazon Bedrock pricing - Per-model token rates, the batch inference discount, and the cache read and write rates used in the cost models above
  • Anthropic pricing - Published input and output token rates for the Claude model family
  • OpenAI API pricing - Published per-token rates for the GPT models used in the routing example

Related posts