cost-optimization

Optimize LLM token costs and latency for AI agents and LLM applications. Use this skill when the user wants to reduce API costs, control token usage, implement model routing (cheap vs expensive models), add token budget guardrails to LangGraph graphs, leverage Anthropic prompt caching, compress context windows, or implement batch processing strategies. Trigger whenever you see concerns about cost, "too expensive", "too slow", token limits, or scaling LLM workloads.

LLM Cost Optimization

Role: LLM Cost Engineer

You are an expert in reducing the cost and latency of LLM-powered agents without sacrificing quality. You know that the #1 mistake teams make is defaulting to the most capable model for every task. You use tiered model routing, prompt caching, context compression, and smart batching to cut costs by 60-90% on typical workloads.

When to Use This Skill

  • API bills are growing faster than the product
  • Agent is slow due to unnecessarily large context
  • Single model used for all tasks regardless of complexity
  • No caching for repeated prompts or large documents
  • No token budget guardrails in production graphs
  • Need to scale to 10x more users without 10x cost increase

Cost Mental Model

Total Cost = Σ (input_tokens × input_price + output_tokens × output_price)
           + Σ cache_write_tokens × write_price     ← one-time investment
           - Σ cache_read_tokens × (input_price - read_price)  ← savings

Key levers:
1. Model choice       — up to 95% savings (Haiku vs Opus)
2. Prompt caching     — up to 90% savings on reads
3. Context reduction  — fewer input tokens = less cost
4. Output control     — structured output = no rambling
5. Batching           — 50% discount on Anthropic Batch API

Anthropic Model Pricing Tiers (2026)

ModelInputOutputCache WriteCache ReadBest For
claude-haiku-4-5~$0.80/M~$4/M~$1/M~$0.08/MClassification, extraction, routing
claude-sonnet-4-6~$3/M~$15/M~$3.75/M~$0.30/MMost agent tasks, reasoning
claude-opus-4-6~$15/M~$75/M~$18.75/M~$1.50/MComplex reasoning, planning only

Rule of thumb: Route 70% of tasks to Haiku, 25% to Sonnet, 5% to Opus.


Strategy 1: Model Routing (Biggest Lever)

Never use one model for everything. Classify task complexity first, then route.

Simple Router in LangGraph

from langgraph.graph import StateGraph, START, END
from langchain_anthropic import ChatAnthropic
from typing import TypedDict, Annotated, Literal
from langgraph.graph.message import add_messages

# Three-tier model pool
haiku  = ChatAnthropic(model="claude-haiku-4-5-20251001")   # ~20x cheaper than Opus
sonnet = ChatAnthropic(model="claude-sonnet-4-6")            # balanced
opus   = ChatAnthropic(model="claude-opus-4-6")              # max capability

class AgentState(TypedDict):
    messages: Annotated[list, add_messages]
    complexity: Literal["simple", "medium", "complex"]
    task_type: str

# Classifier uses cheapest model to decide routing
async def classify_complexity(state: AgentState) -> dict:
    """Use Haiku to classify — never use Opus to decide which model to use!"""
    prompt = f"""Classify this task complexity. Reply with ONLY one word: simple, medium, or complex.

simple = factual lookup, yes/no, extraction, formatting, classification
medium = multi-step reasoning, code generation, summarization, analysis  
complex = novel research, strategic planning, advanced math, multi-constraint optimization

Task: {state['messages'][-1].content}"""

    response = await haiku.ainvoke(prompt)
    complexity = response.content.strip().lower()
    if complexity not in ("simple", "medium", "complex"):
        complexity = "medium"  # safe default
    return {"complexity": complexity}

async def route_to_model(state: AgentState) -> dict:
    """Execute with the appropriate model tier."""
    model_map = {"simple": haiku, "medium": sonnet, "complex": opus}
    model = model_map[state["complexity"]]
    response = await model.ainvoke(state["messages"])
    return {"messages": [response]}

def complexity_router(state: AgentState) -> str:
    """All tasks go through route_to_model — routing happens inside the node."""
    return "execute"

graph = StateGraph(AgentState)
graph.add_node("classify", classify_complexity)
graph.add_node("execute", route_to_model)
graph.add_edge(START, "classify")
graph.add_edge("classify", "execute")
graph.add_edge("execute", END)

app = graph.compile()

Fine-Grained Task-Type Router

from typing import Literal

TaskType = Literal[
    "extract",      # Pull structured info from text → Haiku
    "classify",     # Label or categorize → Haiku
    "summarize",    # Condense document → Haiku/Sonnet by length
    "analyze",      # Identify patterns, insights → Sonnet
    "generate",     # Write content, code → Sonnet
    "plan",         # Multi-step strategy → Opus
    "reason",       # Complex logic, math → Opus
]

MODEL_FOR_TASK: dict[TaskType, ChatAnthropic] = {
    "extract":   haiku,
    "classify":  haiku,
    "summarize": haiku,    # override to sonnet for docs > 50k tokens
    "analyze":   sonnet,
    "generate":  sonnet,
    "plan":      opus,
    "reason":    opus,
}

def get_model_for_task(task_type: TaskType, context_tokens: int = 0) -> ChatAnthropic:
    model = MODEL_FOR_TASK[task_type]
    # Upgrade summarization for large docs (Haiku may struggle)
    if task_type == "summarize" and context_tokens > 50_000:
        model = sonnet
    return model

Strategy 2: Anthropic Prompt Caching (90% Read Savings)

Cache any content that repeats across requests: system prompts, tool definitions, large documents.

Cache a Large System Prompt

from langchain_anthropic import ChatAnthropic
from langchain_core.messages import SystemMessage, HumanMessage

llm = ChatAnthropic(model="claude-sonnet-4-6")

# Large, stable system prompt — cache it!
SYSTEM_PROMPT = """You are an expert legal document analyst with 20 years of experience...
[... 2000 tokens of instructions ...]
""" * 50  # simulate large prompt

def make_cached_messages(user_query: str) -> list:
    return [
        SystemMessage(content=[{
            "type": "text",
            "text": SYSTEM_PROMPT,
            "cache_control": {"type": "ephemeral"}  # ← cache this block
        }]),
        HumanMessage(content=user_query)
    ]

# First call: cache WRITE (1.25× cost) — pays for itself after 1 reuse
response1 = llm.invoke(make_cached_messages("What does clause 3.2 say?"))

# Subsequent calls: cache READ (0.1× cost = 90% savings!)
response2 = llm.invoke(make_cached_messages("What are the termination conditions?"))
response3 = llm.invoke(make_cached_messages("List all party obligations."))
# response2 and response3 charged at 10% of normal input price for the system prompt

Cache RAG Context (Multi-Breakpoint Strategy)

from langchain_core.messages import SystemMessage, HumanMessage

def build_rag_messages_with_caching(
    system_prompt: str,
    documents: str,         # Large retrieved context
    conversation_history: list,  # Previous turns
    user_query: str
) -> list:
    """
    Three-level cache strategy:
    1. System prompt (stable, always cached)
    2. Documents (changes per-query batch, cached for the batch)
    3. Conversation history is NOT cached (changes every turn)
    """
    return [
        SystemMessage(content=[{
            "type": "text",
            "text": system_prompt,
            "cache_control": {"type": "ephemeral"}  # Cache block 1
        }]),
        # Inject documents as a human turn with cache breakpoint
        HumanMessage(content=[{
            "type": "text",
            "text": f"Reference documents:\n\n{documents}",
            "cache_control": {"type": "ephemeral"}  # Cache block 2
        }]),
        # Conversation history (dynamic, no cache)
        *conversation_history,
        HumanMessage(content=user_query)
    ]

Track Cache Performance

import anthropic

client = anthropic.Anthropic()

response = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=1024,
    system=[{"type": "text", "text": SYSTEM_PROMPT, "cache_control": {"type": "ephemeral"}}],
    messages=[{"role": "user", "content": "Analyze this contract."}]
)

usage = response.usage
print(f"Input tokens:          {usage.input_tokens}")
print(f"Cache write tokens:    {usage.cache_creation_input_tokens}")  # first call
print(f"Cache read tokens:     {usage.cache_read_input_tokens}")      # subsequent calls
print(f"Output tokens:         {usage.output_tokens}")

# Effective savings calculation
saved_tokens = usage.cache_read_input_tokens
normal_price_per_token = 3 / 1_000_000   # Sonnet input price
cache_read_price       = 0.3 / 1_000_000  # 10× cheaper
savings = saved_tokens * (normal_price_per_token - cache_read_price)
print(f"Saved on this call:    ${savings:.4f}")

Strategy 3: Token Budget Node in LangGraph

Add a token guardian node that monitors cumulative spending and can halt or downgrade the agent.

from typing import TypedDict, Annotated, Optional
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langchain_core.messages import AIMessage
import tiktoken

class BudgetedAgentState(TypedDict):
    messages: Annotated[list, add_messages]
    total_tokens_used: int
    token_budget: int          # max tokens for this session
    budget_exceeded: bool
    model_tier: str            # current model tier: "economy" | "standard" | "premium"

def count_tokens(text: str, model: str = "claude-sonnet-4-6") -> int:
    """Approximate token count (use tiktoken as proxy for Claude)."""
    enc = tiktoken.get_encoding("cl100k_base")
    return len(enc.encode(text))

def token_budget_guardian(state: BudgetedAgentState) -> dict:
    """
    Check token usage after each step. Can:
    1. Downgrade model tier if spending too fast
    2. Halt gracefully if budget exceeded
    3. Emit warning if approaching limit
    """
    used = state.get("total_tokens_used", 0)
    budget = state.get("token_budget", 50_000)
    pct_used = used / budget

    if pct_used >= 1.0:
        # Hard stop — return early exit signal
        return {
            "budget_exceeded": True,
            "messages": [AIMessage(
                content="I've reached the token budget for this session. "
                        "Please start a new conversation or increase the budget."
            )]
        }
    elif pct_used >= 0.8:
        # Downgrade to economy mode
        return {"model_tier": "economy", "budget_exceeded": False}
    elif pct_used >= 0.5:
        # Switch to standard
        return {"model_tier": "standard", "budget_exceeded": False}
    else:
        return {"model_tier": "premium", "budget_exceeded": False}

def track_usage(state: BudgetedAgentState) -> dict:
    """Count tokens in the latest message and accumulate."""
    if not state["messages"]:
        return {}
    last_msg = state["messages"][-1]
    content = last_msg.content if isinstance(last_msg.content, str) else str(last_msg.content)
    new_tokens = count_tokens(content)
    return {"total_tokens_used": state.get("total_tokens_used", 0) + new_tokens}

def budget_exceeded_router(state: BudgetedAgentState) -> str:
    return END if state.get("budget_exceeded") else "agent"

def tiered_agent(state: BudgetedAgentState) -> dict:
    """Use different models based on current budget tier."""
    tier_models = {
        "economy":  ChatAnthropic(model="claude-haiku-4-5-20251001"),
        "standard": ChatAnthropic(model="claude-sonnet-4-6"),
        "premium":  ChatAnthropic(model="claude-opus-4-6"),
    }
    tier = state.get("model_tier", "standard")
    model = tier_models[tier]
    response = model.invoke(state["messages"])
    return {"messages": [response]}

# Wire it all together
builder = StateGraph(BudgetedAgentState)
builder.add_node("track",    track_usage)
builder.add_node("guardian", token_budget_guardian)
builder.add_node("agent",    tiered_agent)
builder.add_edge(START, "track")
builder.add_edge("track", "guardian")
builder.add_conditional_edges("guardian", budget_exceeded_router, {"agent": "agent", END: END})
builder.add_edge("agent", "track")  # loop back through tracker

app = builder.compile()

# Example invocation with a 20k token budget
result = app.invoke({
    "messages": [("user", "Help me analyze this dataset")],
    "total_tokens_used": 0,
    "token_budget": 20_000,
    "budget_exceeded": False,
    "model_tier": "premium"
})

Strategy 4: Context Compression

Don't send the whole conversation — trim, summarize, or compress it.

Rolling Summary Pattern

from langchain_anthropic import ChatAnthropic
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage

haiku = ChatAnthropic(model="claude-haiku-4-5-20251001")

async def compress_history(messages: list, keep_last_n: int = 4) -> list:
    """
    Summarize old messages, keep recent ones verbatim.
    Reduces token usage by 60-80% for long conversations.
    """
    if len(messages) <= keep_last_n + 1:
        return messages  # Nothing to compress yet

    # Separate old from recent
    old_messages = messages[:-keep_last_n]
    recent_messages = messages[-keep_last_n:]

    # Summarize old context with cheapest model
    history_text = "\n".join(
        f"{m.type.upper()}: {m.content}" for m in old_messages
        if hasattr(m, "content") and isinstance(m.content, str)
    )
    summary_response = await haiku.ainvoke(
        f"Summarize this conversation history in 3-5 bullet points. "
        f"Preserve key facts, decisions, and context:\n\n{history_text}"
    )
    summary = summary_response.content

    # Replace old messages with a single summary message
    summary_msg = SystemMessage(content=f"[Earlier conversation summary]\n{summary}")
    return [summary_msg] + recent_messages

# Use in your LangGraph node
async def agent_with_compression(state: dict) -> dict:
    compressed = await compress_history(state["messages"], keep_last_n=6)
    response = await sonnet.ainvoke(compressed)
    return {"messages": [response]}

LLMLingua Context Compression (Advanced)

# pip install llmlingua
from llmlingua import PromptCompressor

compressor = PromptCompressor(
    model_name="microsoft/llmlingua-2-bert-base-multilingual-cased-meetingbank",
    use_llmlingua2=True,
    device_map="cpu"
)

def compress_retrieved_docs(documents: str, target_ratio: float = 0.3) -> str:
    """
    Compress RAG retrieved documents to 30% of original length.
    Preserves semantic meaning while drastically cutting tokens.
    """
    result = compressor.compress_prompt(
        documents,
        rate=target_ratio,       # Keep 30% of tokens
        force_tokens=["\n", "."] # Always keep sentence boundaries
    )
    return result["compressed_prompt"]

# Before: 10,000 tokens of retrieved docs
# After:  ~3,000 tokens — 70% cost reduction on RAG inputs

Trim Tool Results

def trim_tool_result(result: str, max_chars: int = 2000) -> str:
    """
    Tool results are the #1 source of unexpected token bloat.
    Truncate and signal to the agent that more is available.
    """
    if len(result) <= max_chars:
        return result
    return result[:max_chars] + f"\n\n[... {len(result) - max_chars} chars truncated. Ask for more if needed.]"

# Apply to all ToolNode outputs via custom tool wrapper
from langchain_core.tools import tool

@tool
def search_database(query: str) -> str:
    """Search internal database. Returns top results."""
    raw_result = _do_actual_search(query)  # might return 10,000 chars
    return trim_tool_result(raw_result, max_chars=3000)

Strategy 5: Anthropic Batch API (50% Discount)

For offline workloads where you don't need real-time responses.

import anthropic
import json
from pathlib import Path

client = anthropic.Anthropic()

def batch_process_documents(documents: list[dict]) -> str:
    """
    Process documents in batch — 50% cheaper than real-time API.
    Results available within 24 hours.
    Best for: nightly report generation, bulk data extraction, offline analysis.
    """
    # Build batch requests
    requests = []
    for i, doc in enumerate(documents):
        requests.append({
            "custom_id": f"doc-{i}",
            "params": {
                "model": "claude-haiku-4-5-20251001",  # Use cheapest for bulk
                "max_tokens": 1024,
                "messages": [{
                    "role": "user",
                    "content": f"Extract key entities from:\n\n{doc['content']}"
                }]
            }
        })

    # Submit batch
    batch = client.messages.batches.create(requests=requests)
    print(f"Batch submitted: {batch.id}")
    print(f"Estimated cost: ~50% of real-time price")
    return batch.id

def retrieve_batch_results(batch_id: str) -> list[dict]:
    """Poll for results (typically available within 1 hour)."""
    import time

    while True:
        batch = client.messages.batches.retrieve(batch_id)
        print(f"Status: {batch.processing_status} — "
              f"{batch.request_counts.succeeded} done, "
              f"{batch.request_counts.processing} processing")

        if batch.processing_status == "ended":
            break
        time.sleep(60)  # Poll every minute

    # Download results
    results = []
    for result in client.messages.batches.results(batch_id):
        if result.result.type == "succeeded":
            results.append({
                "id": result.custom_id,
                "content": result.result.message.content[0].text
            })
    return results

Strategy 6: Output Token Control

Unnecessary output tokens are wasted money. Force concise responses.

from langchain_anthropic import ChatAnthropic
from pydantic import BaseModel, Field

# Strategy A: Explicit max_tokens limits
llm = ChatAnthropic(
    model="claude-sonnet-4-6",
    max_tokens=512   # Never let it ramble more than ~400 words
)

# Strategy B: Structured output eliminates verbose prose
class ExtractionResult(BaseModel):
    entities: list[str] = Field(description="Named entities found (max 10)")
    sentiment: str = Field(description="positive, negative, or neutral")
    key_topic: str = Field(description="Main topic in 5 words or less")

structured_llm = ChatAnthropic(model="claude-haiku-4-5-20251001").with_structured_output(ExtractionResult)
# Structured output uses ~3x fewer output tokens than prose answers

# Strategy C: System prompt output constraints
CONCISE_SYSTEM = """Be extremely concise. 
- Facts only, no preamble
- No "Certainly!" or "Great question!"  
- No repeating what was asked
- Maximum 3 sentences unless code is required
- Use bullet points over paragraphs"""

Strategy 7: LangSmith Cost Monitoring

Track actual costs per run to identify expensive nodes.

import os
os.environ["LANGSMITH_TRACING"] = "true"
os.environ["LANGSMITH_API_KEY"] = "ls__..."
os.environ["LANGSMITH_PROJECT"] = "cost-tracking"

# LangSmith automatically captures:
# - Token usage per LLM call
# - Latency per node
# - Total cost per trace
# Use the LangSmith dashboard → "Cost" column to find expensive nodes

# Add custom cost metadata to traces
config = {
    "configurable": {"thread_id": "user-123"},
    "metadata": {
        "budget_usd": 0.10,      # Track budget per session
        "user_tier": "free",     # Route by user tier
        "expected_tokens": 5000
    }
}
result = await app.ainvoke(user_input, config=config)

Cost Reduction Playbook

Use this checklist when optimizing an existing agent:

Phase 1 — Quick Wins (1 day, typical 3-5× savings)
──────────────────────────────────────────────────
[ ] Add complexity classifier → route simple tasks to Haiku
[ ] Add cache_control to system prompt if > 1024 tokens
[ ] Set max_tokens on all LLM calls
[ ] Trim tool results to < 3000 chars

Phase 2 — Medium Effort (1 week, 5-10× total savings)
──────────────────────────────────────────────────────
[ ] Add token budget guardian node to the graph
[ ] Implement rolling summary for conversations > 10 turns
[ ] Cache RAG documents for same-session repeated queries
[ ] Switch bulk/offline tasks to Batch API

Phase 3 — Advanced (ongoing, 10-20× total savings)
──────────────────────────────────────────────────
[ ] Structured output for all extraction/classification tasks
[ ] LLMLingua for RAG context compression
[ ] Fine-tune Haiku on domain tasks → replace Sonnet
[ ] Custom embedding model to reduce retrieval pass size

Common Cost Anti-Patterns

Anti-PatternCost ImpactFix
Using Opus for all tasks20-95× overspendAdd complexity router
No system prompt caching6× overspend on repeated callsAdd cache_control
Returning raw tool results5-10× extra input tokensTrim to 3k chars
No max_tokens set3× extra output tokensSet limit explicitly
Real-time call for batch work2× overspendUse Batch API
Full history in every promptGrows linearlyRolling summary
Prose output when structured works3× extra outputwith_structured_output()

Related Skills

Works best with: langchain-langgraph, rag-implementation, prompt-engineering, instructor

cost-optimization — skill by ferzat0918 | Shared Context