Audience: Platform engineers and AI leads managing Bedrock or OpenAI spend at scale.
Problem: Token costs are not linear — they are architectural. The same workload can cost 3–10x more depending on how prompts are structured, how context accumulates, and which model handles which task. This note covers each lever with formulas and working code.
How It Works
Anthropic’s prompt caching creates a server-side snapshot of a prefix up to a breakpoint marker. Subsequent requests that share that prefix pay the read price instead of the write price.
| Tier | Cache Write | Cache Read | Base Input |
|---|---|---|---|
| Claude 3.5 Sonnet | $3.75/MTok (1.25×) | $0.30/MTok (0.1×) | $3.00/MTok |
| Claude 3 Haiku | $0.30/MTok (1.25×) | $0.03/MTok (0.1×) | $0.25/MTok |
| Claude 3 Opus | $18.75/MTok (1.25×) | $1.50/MTok (0.1×) | $15.00/MTok |
On Bedrock, cache read is slightly higher than the direct Anthropic API (approximately $0.0003 per 1,000 tokens, roughly 90% discount vs on-demand), but the write multiplier stays at 1.25×.
Minimum cacheable prefix: 1,024 tokens for Claude models.
Cache TTL: 5 minutes (default). Bedrock now supports up to 1-hour TTL via the ttl parameter — but see the LiteLLM bug below before relying on this.
Break-Even Formula
A cache write costs 1.25× the base input price for N_prefix tokens. Each cache read saves (1 - 0.1) × P_input × N_prefix = 0.9 × P_input × N_prefix. The write cost extra is 0.25 × P_input × N_prefix.
Break-even reads = 0.25 / 0.9 ≈ 0.278 → round up to 1 read per write to clear overhead
But you also need to account for the number of requests per TTL window:
def cache_break_even(
prefix_tokens: int,
input_price_per_mtok: float, # e.g. 3.0 for Sonnet
ttl_minutes: int = 5
) -> dict:
"""How many requests must hit the cache before it pays off."""
write_overhead = 0.25 * input_price_per_mtok * prefix_tokens / 1_000_000
read_savings_per_req = 0.90 * input_price_per_mtok * prefix_tokens / 1_000_000
break_even_requests = write_overhead / read_savings_per_req
return {
"break_even_requests": break_even_requests, # always ~0.278 → rounds to 1
"write_overhead_usd": write_overhead,
"read_savings_per_req_usd": read_savings_per_req,
"note": "Economical if >1 request hits cache per write cycle"
}
# Example: 4,000-token system prompt on Sonnet
result = cache_break_even(4000, 3.0)
# write_overhead: $0.000003, read_savings_per_req: $0.0000108 → breaks even at 0.28 requests
# At 10 requests/window: saves $0.000105/window → $0.0126/hour on a 100 rps workload
The Bedrock breakeven hit ratio is ~30%. If fewer than 30% of requests share the cached prefix within the TTL window, you are spending more on writes than you recover from reads. High-traffic system prompts break even within the first request. One-shot document analysis rarely does.
System Prompt Caching — Highest-Leverage Pattern
The system prompt is the most stable prefix in any deployment. It changes rarely (deploys, not per-user). Cache it unconditionally.
# Anthropic SDK direct
import anthropic
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
system=[
{
"type": "text",
"text": LARGE_SYSTEM_PROMPT, # your 2000-4000 token system context
"cache_control": {"type": "ephemeral"}
}
],
messages=[{"role": "user", "content": user_message}]
)
For multi-turn conversations, also cache the growing message history by placing a cache_control breakpoint after the most recent assistant turn.
CUR Instrumentation on Bedrock
Enable Cost and Usage Report with resource-level granularity. The four columns you need:
lineItem/UsageType → contains model identifier + token type
product/usagetype → e.g. "USE1-Claude3.5Sonnet-cache-read-input-token-count"
"USE1-Claude3.5Sonnet-cache-write-input-token-count"
"USE1-Claude3.5Sonnet-input-token-count"
"USE1-Claude3.5Sonnet-output-token-count"
Critical: If you only sum input-token-count and output-token-count, your totals will not match your bill. Cache write tokens are billed separately at 1.25× and appear in their own usage type. Teams consistently hit 15–25% reconciliation gaps from this oversight. Always aggregate all four token columns per model.
Athena query to break down cache efficiency:
SELECT
regexp_extract(line_item_usage_type, '(Claude[^-]+)', 1) AS model,
SUM(CASE WHEN line_item_usage_type LIKE '%cache-read%' THEN line_item_usage_amount ELSE 0 END) AS cache_reads,
SUM(CASE WHEN line_item_usage_type LIKE '%cache-write%' THEN line_item_usage_amount ELSE 0 END) AS cache_writes,
SUM(CASE WHEN line_item_usage_type LIKE '%input-token%'
AND line_item_usage_type NOT LIKE '%cache%' THEN line_item_usage_amount ELSE 0 END) AS uncached_inputs,
ROUND(SUM(line_item_unblended_cost), 4) AS total_cost_usd
FROM cur_table
WHERE line_item_product_code = 'AmazonBedrock'
AND line_item_usage_start_date >= DATE_TRUNC('day', CURRENT_DATE - INTERVAL '7' DAY)
GROUP BY 1
ORDER BY total_cost_usd DESC;
LiteLLM / Bedrock Converse API Bug — Known Active Issue
LiteLLM translates OpenAI-format cache_control markers to Bedrock’s native cachePoint format. Several bugs remain active as of mid-2026:
-
TTL stripping (issue #17250, #15880): LiteLLM silently strips the
ttlparameter from Bedrock requests. The 1-hour TTL option is supported by AWS but unreachable via LiteLLM. Workaround: use the Bedrock SDK directly for long-TTL use cases. -
Assistant and tool message cache points dropped (issue #12695): Cache breakpoints in assistant turns and tool result messages are dropped before the request reaches Bedrock. This hurts agentic loops that rely on caching tool outputs.
-
Content array format regression (issue #23247): When messages use the content-block array format (required for vision/multi-modal), text parts are reconstructed with only
typeandtext, silently droppingcache_control. Affects any multi-modal pipeline. -
Tool configuration caching unsupported (issue #21969): Bedrock Converse supports caching
toolConfig.toolsfor Claude models, but LiteLLM has nocache_control_injection_pointssupport for tool arrays.
Mitigation: For production Bedrock + caching, pin LiteLLM versions and test cache hit rates via CUR. Alternatively, use the Anthropic Bedrock SDK directly for cache-critical paths.
2. Context Window Bloat — The Hidden Cost Driver
Why Context Grows Quadratically in Cost Terms
In a multi-turn conversation, each API call sends the full history. After N turns of T tokens each:
Total tokens billed = T + 2T + 3T + ... + NT = T × N(N+1)/2
A 10-turn conversation with 500 tokens per turn bills 500 × 55 = 27,500 input tokens, not 5,000. At Sonnet pricing, that is $0.083 vs $0.015. A 50-turn support conversation multiplies cost by 25× relative to a naive single-call estimate.
Agentic loops compound this: every tool call result appended to the context inflates subsequent calls. An agent with 10 tool calls per task, each returning 500 tokens, adds 5,000 tokens to every subsequent step.
Cost Impact at Scale
| Effective Context | Tokens/Request | Daily Cost (10M tokens/day, Sonnet) | vs Baseline |
|---|---|---|---|
| 4K | 4,000 | $12.00 | 1× |
| 8K | 8,000 | $24.00 | 2× |
| 32K | 32,000 | $96.00 | 8× |
| 128K | 128,000 | $384.00 | 32× |
“10M tokens/day” refers to output tokens for context — real input tokens grow proportionally. These figures assume the context is the dominant cost driver (typical in multi-turn or RAG pipelines).
Memory Strategy Comparison
Full history (default): No implementation cost, O(N²) token cost. Acceptable only for short sessions (<5 turns).
Sliding window: Keep the last K turns. Simple to implement. Loses older context entirely — problematic for reference-heavy tasks.
def sliding_window_messages(messages: list, max_turns: int = 6) -> list:
"""Keep system message + last max_turns conversation turns."""
system = [m for m in messages if m["role"] == "system"]
conversation = [m for m in messages if m["role"] != "system"]
return system + conversation[-max_turns * 2:] # pairs of user/assistant
LangChain ConversationSummaryBufferMemory: Maintains a running LLM-generated summary of older turns, plus verbatim recent turns. Costs one extra summarization call when the buffer limit is hit, but keeps context roughly constant.
from langchain.memory import ConversationSummaryBufferMemory
from langchain_anthropic import ChatAnthropic
llm = ChatAnthropic(model="claude-3-haiku-20240307") # Use Haiku for summaries — not Sonnet
memory = ConversationSummaryBufferMemory(
llm=llm,
max_token_limit=2000, # summarize when rolling buffer exceeds this
return_messages=True
)
Cost note: use Haiku (not Sonnet) for the summarization step. The summary model does not need reasoning capability — it needs compression. Haiku costs 12× less than Sonnet.
LLMLingua / LongLLMLingua — Semantic Compression
LLMLingua (Microsoft) uses a small proxy LLM to score token importance and drop low-scoring tokens from the context before sending to the main model. This is distinct from summarization — it preserves original tokens but at lower density.
Measured performance:
- LLMLingua-2: 3–6× faster than LLMLingua-1, trained via GPT-4 distillation
- Practical compression ratios: 4× to 10× with <5% task performance degradation on QA benchmarks
- LongLLMLingua: 21.4% performance improvement on NaturalQuestions at 4× token reduction (GPT-3.5-Turbo)
- LooGLE benchmark: 94% cost reduction reported at the extreme end (10–20× compression)
from llmlingua import PromptCompressor
compressor = PromptCompressor(
model_name="microsoft/llmlingua-2-xlm-roberta-large-meetingbank",
use_llmlingua2=True,
device_map="cpu" # runs locally, no API cost
)
compressed = compressor.compress_prompt(
context_tokens,
rate=0.5, # target 50% of original tokens
force_tokens=["\n", ".", "?"], # never compress sentence boundaries
drop_consecutive=True
)
# compressed["compressed_prompt"] is the reduced context
# compressed["ratio"] reports actual compression achieved
Quality tradeoff: At 2× compression, quality degradation is negligible for extraction and classification tasks. At 5× compression, expect 3–8% drop on reasoning tasks. At 10×, only use for coarse retrieval or pre-filtering, not final generation.
When to use what:
| Scenario | Strategy |
|---|---|
| Chat sessions, <5 turns | Full history |
| Chat sessions, >5 turns | SummaryBufferMemory (Haiku summarizer) |
| RAG retrieval context | LLMLingua-2 at 2–4× |
| Agentic tool output accumulation | Sliding window + tool output truncation |
| Long document analysis | LongLLMLingua before send |
3. Model Routing by Complexity
The Cost Multiplier Reality
Claude 3.5 Sonnet costs 12× more than Claude 3 Haiku per token. GPT-4o costs roughly 6× more than GPT-4o-mini. For classification, extraction, and slot-filling tasks, the cheaper model performs comparably or identically. The cost reduction from right-sizing model selection on classification tasks is typically 70–80%.
| Task Type | Appropriate Tier | Example Models |
|---|---|---|
| Classification, intent detection | Tier 1 (nano) | Haiku, GPT-4o-mini, Llama 3 8B |
| Extraction, structured parsing | Tier 1–2 | Haiku, GPT-4o-mini |
| Single-step reasoning, summarization | Tier 2 (mid) | Sonnet, GPT-4o-mini |
| Multi-step reasoning, code generation | Tier 2–3 | Sonnet, GPT-4o |
| Complex synthesis, adversarial review | Tier 3 (large) | Opus, GPT-4o |
LiteLLM Complexity Router Configuration
LiteLLM’s built-in complexity router scores requests with rule-based heuristics (prompt length, structural cues, keyword signals) and routes to configured tiers. It operates in sub-millisecond latency with zero external API calls.
# litellm_config.yaml
router_settings:
routing_strategy: "complexity-based-routing"
model_list:
- model_name: tier-1-fast
litellm_params:
model: bedrock/anthropic.claude-3-haiku-20240307-v1:0
aws_region_name: us-east-1
- model_name: tier-2-reasoning
litellm_params:
model: bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0
aws_region_name: us-east-1
- model_name: tier-3-complex
litellm_params:
model: bedrock/anthropic.claude-3-opus-20240229-v1:0
aws_region_name: us-east-1
complexity_based_routing:
simple:
threshold: 30
model: tier-1-fast
medium:
threshold: 60
model: tier-2-reasoning
complex:
threshold: 100
model: tier-3-complex
Known issue (March 2026): The complexity router fails when messages use the content-block array format (multi-modal). Plain string content works correctly. File issue #23247 for tracking.
Building a Custom Routing Classifier
The built-in complexity router uses surface-level signals. For higher precision, build a task-type classifier:
Option A: Rule-based (fastest, no model cost)
import re
EXTRACTION_PATTERNS = re.compile(
r'\b(extract|parse|identify|list all|find all|what is the|return the)\b',
re.IGNORECASE
)
REASONING_PATTERNS = re.compile(
r'\b(analyze|evaluate|compare|recommend|explain why|design|architect)\b',
re.IGNORECASE
)
def route_request(prompt: str, context_tokens: int) -> str:
if context_tokens < 500 and EXTRACTION_PATTERNS.search(prompt):
return "tier-1-fast"
elif REASONING_PATTERNS.search(prompt) or context_tokens > 8000:
return "tier-3-complex"
else:
return "tier-2-reasoning"
Option B: Embedding similarity (medium cost, high precision)
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np
# Pre-embed labeled examples offline
TIER_CENTROIDS = {
"tier-1": np.load("centroids/tier1.npy"),
"tier-2": np.load("centroids/tier2.npy"),
"tier-3": np.load("centroids/tier3.npy"),
}
def route_by_embedding(prompt_embedding: np.ndarray) -> str:
scores = {
tier: cosine_similarity([prompt_embedding], [centroid])[0][0]
for tier, centroid in TIER_CENTROIDS.items()
}
return max(scores, key=scores.get)
Use text-embedding-3-small at $0.02/MTok for routing — the embedding cost is negligible against the model cost savings.
Option C: Fine-tuned small classifier
Fine-tune a distilbert or sentence-transformer model on labeled routing examples (500–2000 labeled prompts). Achieves >95% accuracy at sub-1ms inference cost. Use ONNX runtime for CPU deployment alongside the LiteLLM proxy.
4. Few-Shot vs Zero-Shot Economics
Token Cost of Few-Shot Examples
Each few-shot example adds tokens to every request. Three examples at 300 tokens each add 900 input tokens per call. At Sonnet pricing and 10M calls/day, that is $27/day for the examples alone — before any actual task content.
| Approach | Extra Input Tokens | Benefit | When to Use |
|---|---|---|---|
| Zero-shot | 0 | Cheapest | Simple tasks with structured output |
| 1-shot | ~300 | Formatting anchor | When output format is critical |
| 3-shot | ~900 | Consistency | When model misunderstands task |
| 5-shot | ~1,500 | Edge case coverage | Complex classification with rare classes |
Structured Output as Few-Shot Replacement
JSON mode / structured output (response_format: {type: "json_object"}) often eliminates the need for format-anchoring few-shot examples. The model produces valid JSON without examples demonstrating the format.
# Instead of 3 few-shot examples showing JSON format:
response = client.messages.create(
model="claude-3-haiku-20240307",
max_tokens=256,
system="""Extract entities from the text. Return ONLY valid JSON matching this schema:
{"entities": [{"name": string, "type": "person"|"org"|"location", "confidence": 0.0-1.0}]}
No other text.""",
messages=[{"role": "user", "content": document_text}]
)
This approach removes 600–1,500 input tokens per request while maintaining format consistency, at the cost of occasionally requiring output validation and retry logic.
Chain-of-Thought Cost Analysis
CoT reasoning tokens are output tokens, which cost 3–5× more per token than input on most providers (Sonnet: $3/MTok input vs $15/MTok output). A 500-token reasoning chain costs $0.0075 per request. At 1M requests/day, that is $7,500/day — for reasoning tokens that are discarded and never returned to the user.
When to skip CoT:
- Classification and extraction tasks (accuracy gain is marginal)
- Tasks with deterministic correct answers (the model either knows or doesn’t)
- High-volume, low-stakes inference (summarization, tagging, routing)
When CoT is worth the cost:
- Multi-step math or logic
- Code generation with edge cases
- Tasks where a wrong confident answer causes downstream failures
Extended thinking (Claude 3.7+): Anthropic’s extended thinking produces separate reasoning tokens billed at output token rates. These are valuable for complex reasoning but should be explicitly disabled for simpler subtasks. Pass thinking: {type: "disabled"} explicitly in task decomposition architectures.
Output Token Minimization
Output tokens cost 5× input tokens on Sonnet. Techniques to reduce output length:
-
Constrained schemas: Ask for a JSON object with specific fields rather than prose. Prose explanations add 200–500 tokens. A structured response with the same information is 50–150 tokens.
-
Explicit length directives:
"Respond in 3 sentences or fewer."Models follow this reliably on extraction tasks. -
Binary/categorical outputs for routing:
"Answer YES or NO only."For gate-keeping steps, this is a 99% output token reduction. -
Enumerate, don’t explain:
"List only. No explanations."Cuts output by 60–80% on list generation tasks.
5. Batch and Async Patterns
Bedrock Batch Inference: 50% Discount
Bedrock batch inference processes JSONL input asynchronously from S3 at 50% of on-demand token pricing. Supported across Anthropic Claude, Meta Llama, Mistral, and Amazon Nova models. Maximum latency: 24 hours.
Economics at scale:
| Daily Volume | On-Demand Cost (Sonnet) | Batch Cost | Annual Savings |
|---|---|---|---|
| 10M tokens | $30/day | $15/day | $5,475/year |
| 100M tokens | $300/day | $150/day | $54,750/year |
| 1B tokens | $3,000/day | $1,500/day | $547,500/year |
Use cases for batch:
- Nightly document processing pipelines (contracts, reports, transcripts)
- Offline evaluation/scoring runs against labeled datasets
- Bulk classification or tagging of existing document corpora
- Embedding backfills when adding new content to a vector store
S3 → Bedrock Batch Pattern
import boto3
import json
import uuid
from datetime import datetime
bedrock = boto3.client("bedrock", region_name="us-east-1")
def submit_batch_job(
prompts: list[str],
model_id: str = "anthropic.claude-3-5-sonnet-20241022-v2:0",
s3_bucket: str = "my-batch-bucket"
) -> str:
job_id = str(uuid.uuid4())[:8]
input_key = f"batch-inputs/{job_id}.jsonl"
output_prefix = f"batch-outputs/{job_id}/"
# Write JSONL to S3
s3 = boto3.client("s3")
records = "\n".join(
json.dumps({
"recordId": str(i),
"modelInput": {
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 1024,
"messages": [{"role": "user", "content": prompt}]
}
})
for i, prompt in enumerate(prompts)
)
s3.put_object(Bucket=s3_bucket, Key=input_key, Body=records.encode())
# Submit batch job
response = bedrock.create_model_invocation_job(
jobName=f"batch-{job_id}-{datetime.now():%Y%m%d}",
modelId=model_id,
inputDataConfig={
"s3InputDataConfig": {
"s3Uri": f"s3://{s3_bucket}/{input_key}"
}
},
outputDataConfig={
"s3OutputDataConfig": {
"s3Uri": f"s3://{s3_bucket}/{output_prefix}"
}
}
)
return response["jobArn"]
SQS → Lambda → Bedrock Batch — Production Pattern
For production pipelines where documents arrive continuously but latency tolerance is 1–24 hours:
Documents → SQS FIFO queue
→ EventBridge Scheduler (triggers every 4 hours)
→ Lambda aggregator (reads SQS, builds JSONL, writes to S3)
→ Bedrock CreateModelInvocationJob
→ S3 output prefix
→ Lambda consumer (reads JSONL results, writes to downstream store)
→ SQS dead-letter queue (failed records)
Key design decisions:
- 4-hour aggregation window balances batch efficiency against freshness requirements. Reduce to 1 hour if SLA requires. Never go below 500 records per batch job — setup overhead makes smaller batches uneconomical.
- FIFO queue + message deduplication prevents duplicate submissions.
- Lambda consumer must be idempotent — Bedrock may write partial output files before completing.
LiteLLM Batch Submission
import litellm
batch_requests = [
{"model": "bedrock/claude-3-5-sonnet", "messages": [{"role": "user", "content": p}]}
for p in prompts
]
# LiteLLM's async gather pattern (not true Bedrock batch — uses concurrent on-demand calls)
import asyncio
async def batch_complete(requests):
tasks = [litellm.acompletion(**req) for req in requests]
return await asyncio.gather(*tasks, return_exceptions=True)
results = asyncio.run(batch_complete(batch_requests))
Note: LiteLLM’s gather pattern uses concurrent on-demand API calls, not Bedrock’s native batch inference. You pay on-demand pricing. For true 50% discount, use the Bedrock CreateModelInvocationJob API directly.
6. Embedding Cost Optimization
Model Cost vs Quality (2026)
| Model | Price (standard) | Price (batch) | MTEB Retrieval | Notes |
|---|---|---|---|---|
| OpenAI text-embedding-3-small | $0.02/MTok | $0.01/MTok | 62.3% | Best cost/quality |
| OpenAI text-embedding-3-large | $0.13/MTok | $0.065/MTok | 64.6% | 6.5× more for +2.3 pts |
| Amazon Titan Embeddings v2 | $0.02/MTok | N/A | ~60% | No batch discount |
| Cohere Embed 4 | $0.12/MTok | N/A | 63.1% | Multilingual strength |
| Voyage-4-lite | $0.02/MTok | N/A | ~63% | Lowest latency |
For most production RAG pipelines: text-embedding-3-small via the Batch API at $0.01/MTok is the correct default. The quality gap vs large models is 2–3 NDCG points, which is imperceptible in end-user retrieval quality for most document types.
Cache Embeddings for Stable Documents
Embeddings for documents that do not change should be computed once and stored. The cost per re-embedding is pure waste.
import hashlib
import numpy as np
from functools import lru_cache
class EmbeddingCache:
def __init__(self, store): # store: Redis, DynamoDB, local file
self.store = store
def embed(self, text: str, model: str = "text-embedding-3-small") -> np.ndarray:
cache_key = hashlib.sha256(f"{model}:{text}".encode()).hexdigest()
cached = self.store.get(cache_key)
if cached is not None:
return np.frombuffer(cached, dtype=np.float32)
# Only call API on cache miss
embedding = self._call_api(text, model)
self.store.set(cache_key, embedding.tobytes(), ex=86400 * 30) # 30-day TTL
return embedding
Re-embedding triggers: Only re-embed on content change (hash mismatch), not on schedule. Static knowledge bases (product catalogs, legal docs, policy manuals) can go months without re-embedding.
Matryoshka Embeddings — Dimensionality Reduction
Matryoshka Representation Learning (MRL) trains embeddings to front-load semantic information into the earliest dimensions, enabling truncation without retraining.
from openai import OpenAI
client = OpenAI()
# Full 1536 dimensions — maximum quality
full = client.embeddings.create(
model="text-embedding-3-large",
input=documents,
dimensions=1536
)
# Truncate to 768 — 94.6% of full performance, 50% storage reduction
reduced = client.embeddings.create(
model="text-embedding-3-large",
input=documents,
dimensions=768 # native API support for MRL truncation
)
Storage and cost impact at scale:
| Dimensions | Storage per 1M docs | vs Full | Quality Retention |
|---|---|---|---|
| 1536 | ~6 GB (float32) | 1× | 100% |
| 768 | ~3 GB | 0.5× | 94.6% |
| 256 | ~1 GB | 0.17× | ~90% |
For 10M document corpora: reducing from 1536 to 768 dimensions cuts vector store storage cost in half with no meaningful retrieval quality loss. At Pinecone/Weaviate pricing (~$0.08/GB/month), this saves approximately $240/month per 10M documents.
Re-Embedding Frequency Strategy
Static documents (policies, manuals): Embed once. Re-embed on content version bump.
Semi-static (product catalog): Weekly batch re-embedding. Use Batch API.
Dynamic (news, tickets, chat): Real-time embedding at ingest. Monitor drift quarterly.
Experimental (new model evaluation): Parallel embed a 1% sample before full migration.
7. Measurement Framework
Token Efficiency Ratio
The core metric for prompt engineering quality:
TER = useful_output_tokens / total_input_tokens
Where useful_output_tokens is the parsed, structured output that your application actually consumes (not chain-of-thought, not preamble). A well-optimized pipeline has TER > 0.3. A bloated one often sits at 0.05–0.10.
def compute_ter(response) -> float:
"""Compute token efficiency ratio from an Anthropic response."""
total_input = response.usage.input_tokens
# Approximate useful output: strip CoT blocks if present
output_text = response.content[0].text
useful_tokens = response.usage.output_tokens
# If CoT is embedded in prose, estimate: structured JSON ÷ total output
import json
try:
parsed = json.loads(output_text)
useful_tokens = len(json.dumps(parsed).split()) * 1.3 # rough token estimate
except json.JSONDecodeError:
pass
return useful_tokens / max(total_input, 1)
Retry Rate as Quality Proxy
Failed structured output parses that trigger retries are a quality signal — and a cost multiplier. Each retry doubles the cost of that request.
import litellm
from litellm import completion
import json
def tracked_completion(prompt: str, model: str, max_retries: int = 2) -> dict:
metrics = {"attempts": 0, "total_input_tokens": 0, "total_output_tokens": 0}
for attempt in range(max_retries + 1):
metrics["attempts"] += 1
response = completion(model=model, messages=[{"role": "user", "content": prompt}])
metrics["total_input_tokens"] += response.usage.prompt_tokens
metrics["total_output_tokens"] += response.usage.completion_tokens
try:
result = json.loads(response.choices[0].message.content)
metrics["success"] = True
return {"result": result, "metrics": metrics}
except json.JSONDecodeError:
if attempt == max_retries:
metrics["success"] = False
return {"result": None, "metrics": metrics}
Track retry_rate = (total_attempts - total_requests) / total_requests. A rate above 5% indicates the prompt needs few-shot examples or a tighter output schema. A rate above 15% indicates the wrong model tier — upgrade.
A/B Testing Prompt Changes for Cost Impact
Never deploy a prompt change to 100% of traffic without measuring cost delta. Use LiteLLM’s callback hooks to log per-variant metrics.
import litellm
from litellm.integrations.custom_logger import CustomLogger
class PromptABLogger(CustomLogger):
def __init__(self, experiment_id: str):
self.experiment_id = experiment_id
self.variants = {}
def log_success_event(self, kwargs, response_obj, start_time, end_time):
variant = kwargs.get("metadata", {}).get("variant", "control")
model = kwargs.get("model", "unknown")
key = f"{self.experiment_id}:{variant}:{model}"
if key not in self.variants:
self.variants[key] = {"requests": 0, "input_tokens": 0, "output_tokens": 0, "cost": 0}
usage = response_obj.usage
self.variants[key]["requests"] += 1
self.variants[key]["input_tokens"] += usage.prompt_tokens
self.variants[key]["output_tokens"] += usage.completion_tokens
self.variants[key]["cost"] += litellm.completion_cost(completion_response=response_obj)
def report(self) -> dict:
"""Summarize cost per request per variant."""
return {
k: {
**v,
"cost_per_request": v["cost"] / max(v["requests"], 1),
"avg_input_tokens": v["input_tokens"] / max(v["requests"], 1)
}
for k, v in self.variants.items()
}
logger = PromptABLogger("system-prompt-v2-test")
litellm.callbacks = [logger]
# Tag each request with variant
def call_with_variant(prompt: str, variant: str) -> str:
response = litellm.completion(
model="bedrock/claude-3-5-sonnet",
messages=[{"role": "user", "content": prompt}],
metadata={"variant": variant}
)
return response.choices[0].message.content
# Route 10% to treatment, 90% to control
import random
variant = "treatment" if random.random() < 0.10 else "control"
result = call_with_variant(user_prompt, variant)
After 1,000 requests per variant, compare cost_per_request and downstream quality metrics (task success rate, retry rate, human eval scores). Require both to improve before full rollout.
Quick Reference — Cost Reduction Levers by Impact
| Lever | Complexity | Typical Cost Reduction | Risk |
|---|---|---|---|
| Model routing (right-sizing) | Medium | 70–80% on classification tasks | Accuracy regression on edge cases |
| Batch inference (async workloads) | Low | 50% | Latency increase to 24h |
| Prompt caching (stable system prompts) | Low | 30–60% on repeated-prefix workloads | Hit rate must stay >30% |
| Context window management | Medium | 40–80% on multi-turn | Loss of distant context |
| Output minimization + structured output | Low | 20–40% | Parse failures if schema is too strict |
| LLMLingua compression (RAG context) | High | 60–90% on retrieval context | 3–8% task accuracy loss |
| Embedding dimensionality reduction | Low | 50% storage (matryoshka) | 5% retrieval quality loss |
| Batch embedding (OpenAI Batch API) | Low | 50% embedding cost | Async only |
Starting point recommendation: Deploy model routing and batch inference first — both have high leverage and low implementation risk. Prompt caching comes second for any deployment with a large, stable system prompt. Context window management is essential for any multi-turn or agentic use case that runs at volume. LLMLingua compression is a later-stage optimization once the others are baseline.
Sources
- Anthropic Prompt Caching Docs
- Anthropic API Pricing 2026 — Finout
- AWS Bedrock Prompt Caching Hidden Cost — DEV Community
- Amazon Bedrock Pricing 2026 — go-cloud.io
- Simplified Cache Management for Claude on Bedrock — AWS
- Bedrock CUR Data — AWS Docs
- LiteLLM Bug #12695 — assistant messages dropping cache points
- LiteLLM Bug #17250 — Bedrock rejects cache_control with ttl field
- LiteLLM Bug #23247 — Complexity router fails with content array format
- LiteLLM Auto Routing Docs
- LLMLingua GitHub — Microsoft
- LongLLMLingua — arXiv 2310.06839
- Compressing Prompts with LLMLingua — PromptHub
- AWS Bedrock Batch Inference 50% Pricing — AWS announcement
- Bedrock Batch Inference Guide — Wring
- Level Up with Bedrock Batch Inference — Towards AI
- Embedding Models Comparison 2026 — Reintech
- Embedding Model Specs 2026 — PECollective
- Scaling Vector Search with Matryoshka Embeddings — Towards Data Science
- Best LLM Router 2026 — Clawrouters
- How to Route LLM Traffic by Cost and Complexity — AgentBus