Token Cost Engineering in Agent Loops — Prompt Caching and State Pruning
Output tokens cost 5× more than input. In agent loops, both compound. Two targeted fixes — prompt caching and state pruning — cut per-incident cost by ~25%.
The agent pipeline runs a ReAct loop: Thought → Action → Observation, repeated until the agent has enough information to answer. Every iteration sends the system prompt again — and every iteration appends new tool results to the conversation history. Two things grow: the stable prefix sent identically on every call, and the accumulating context that is different every call. This post covers one fix for each.
The 5× asymmetry: why output tokens cost more
Every LLM pricing table has two numbers. On Sonnet 4.6: $3.00 and $15.00 per million tokens. Output is 5× more expensive than input.
This reflects how inference works at the hardware level. Input tokens are processed in a single forward pass — the attention mechanism reads all input tokens in parallel. Output tokens are generated one at a time, each requiring a full pass through the model and an update to the KV cache. Output is memory-bandwidth bound. Input is compute-bound. Memory bandwidth is the scarcer resource at scale.
| Model | Input (per 1M tokens) | Output (per 1M tokens) | Multiplier |
|---|---|---|---|
| claude-sonnet-4-6 | $3.00 | $15.00 | 5× |
| claude-haiku-4-5 | $0.80 | $4.00 | 5× |
| gpt-4.1 | $2.00 | $8.00 | 4× |
Token splits across system types
The 5× gap matters differently depending on what your system produces.
Classification (99.8% input): 500-token document → 1-token label (“P0”, “CRITICAL”). Output cost is negligible. Longer prompts or more examples add to the dominant input cost.
RAG query (~84% input): system prompt + 4 retrieved chunks + user query = ~1,100 tokens. Response: ~200 tokens. Even at 84% input by volume, that 200-token output costs as much as 1,000 input tokens.
Summarization (~50% input): 2,000-token document → 2,000-token summary. Equal split by token count, but the output half carries 83% of the cost. Reducing output length has 5× the leverage of reducing input.
Chatbot with growing history (~85% input by turn 20): each response gets appended to the conversation and re-sent on the next turn. The output from turn 19 is billed at $15/M on generation, then at $3/M again when it’s included in turn 20’s input. History accumulation, not per-turn generation, becomes the cost driver.
Agent loop with compounding (~74% apparent input): each ReAct iteration sends the accumulated conversation history. Output tokens from iteration N-1 are re-billed as input at iteration N. A 400-token observation at iteration 2 gets included in the input for iterations 3, 4, 5, 6, 7, 8, 9, 10 — nine more times at input rate.
For a 10-iteration FixGen run with I=2,400 average input tokens, O=400 output tokens:
10 × 2,400 × $3/M = $72,000/M (base input per iteration)
10 × 400 × $15/M = $60,000/M (output per iteration)
400 × 9 later iters × $3/M = $10,800/M (compounding: prior outputs re-billed as input)
──────────
$142,800/M ≈ $0.143/run (before optimization)
The two optimizations in this post target different parts of that formula. Prompt caching cuts the stable input terms — harness docs billed at 10% instead of 100%. State pruning cuts the compounding term — stale tool results truncated before they accumulate across iterations.
When to prioritize each lever
Input-heavy (>90% of cost from input): caching stable prefixes is the highest-leverage move. Shaving output length has minimal impact.
Output-heavy (>30% of cost from output): reduce output verbosity — shorter tool responses, tighter answer formats. Caching has no effect on output cost.
Growing history in multi-turn or agent contexts: state pruning. The longer the agent runs, the more stale context accumulates. Pruning truncates old tool results before they compound across iterations.
The problem: stable tokens billed at full price every iteration
Every agent in the pipeline inherits from BaseAgent, which loads a shared harness at startup:
# app/agents/base.py
def _load_harness_docs() -> str:
"""Load AGENTS.md and CONSTRAINTS.md from the configured harness path."""
docs: list[str] = []
for filename in ("AGENTS.md", "CONSTRAINTS.md"):
fp = p / filename
if fp.exists():
docs.append(f"=== {filename} ===\n{fp.read_text()}")
return "\n\n".join(docs)
These docs are ~1,267 tokens. Every LLM call uses _with_harness(agent_system_prompt) which prepends them:
def _with_harness(self, system: str) -> str:
return f"{docs}\n\n---\n\n{system}"
DiagnosisAgent runs 5–8 ReAct iterations per incident. FixGenerationAgent runs up to 14. The harness docs are sent — and billed — on every single one. For a typical fix generation run with 10 iterations, that’s 12,700 tokens of identical content billed at full price 10 times.
The fix: one content block, one cache_control flag
Anthropic’s prompt caching marks a content block with cache_control: {"type": "ephemeral"}. On the first call, the marked prefix is written to the cache (5-minute TTL). Every subsequent call that sends the same prefix reads from cache at 10% of the regular input token price.
The change to _with_harness:
def _with_harness(self, system: str) -> str | list:
docs = getattr(self, "_harness_docs", "")
if not docs:
return system
return [
{"type": "text", "text": docs, "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": f"\n\n---\n\n{system}"},
]
Instead of a plain string, this returns a list of content blocks. The harness docs are the first block with cache_control. The agent-specific system prompt is the second block, never cached (it changes between agent types).
Both the Anthropic Python SDK and LiteLLM accept list[dict] for the system parameter. No other call sites needed to change — the if system: checks in LiteLLMProvider.complete() handle both types.
Tracking the savings
LLMResponse now carries cache metadata:
@dataclass
class LLMResponse:
content: str
input_tokens: int
output_tokens: int
provider: str
model: str
cost_usd: float
cache_read_input_tokens: int = 0 # tokens served from cache (billed at 10%)
cache_creation_input_tokens: int = 0 # tokens written to cache (billed at 125%)
LiteLLMProvider extracts them from LiteLLM’s Anthropic response:
details = getattr(usage, "prompt_tokens_details", None)
if details:
cache_read = getattr(details, "cached_tokens", 0) or 0
The gateway applies correct billing:
# Cached tokens are billed at 10% — compute actual cost accordingly
billed_input = raw.input_tokens - raw.cache_read_input_tokens
cost = self._compute_cost(model, billed_input, raw.output_tokens)
cost += self._compute_cost(model, raw.cache_read_input_tokens, 0) * 0.1
And logs cache activity on every call:
[gateway] task=diagnosis model=claude-sonnet-4-6 in=2043 out=312 cache_read=1858 cache_write=0 cost=0.006234 latency=2341ms
The measured result
Baseline vs cached — 6 iterations of a diagnosis loop, same system prompt, same user message:
Metric Without cache With cache Delta
────────────────────────────────────────────────────────────────────────────
Total input tokens 12,123 12,081
Cache read tokens – 9,290 (77% of input)
Cache write tokens – 2,471
Total cost (USD) $0.150534 $0.130755 -$0.0198 (13%)
Avg latency (ms) 26,774 27,651
First-call latency (ms) 28,848 29,693 (cache write)
77% of input tokens are served from cache after the first iteration. The cache write on the first call is slightly slower — the token prefix has to be computed and stored. From iteration 2 onward, the cached tokens are faster to process than uncached.
The average latency looks slightly higher because the slower first call is included in a 6-iteration average. In production with longer fix generation loops (10–14 iterations), the first-call penalty is amortized over more cache hits and the average latency drops.
Why 77% and not 100%
The cached prefix is the harness docs block — 2,471 tokens. Each iteration adds new conversation turns (the growing ReAct context: thought, action, observation), which are not cached. So the cache hit fraction decreases slightly as the conversation grows.
Iteration 1: cache write (2,471 tokens). No cache read. Iterations 2–6: cache read (2,471 tokens) + uncached new context (growing each turn).
As conversation length grows, uncached tokens accumulate. By iteration 10, the conversation might be 2,000 tokens — 2,471 cached tokens as a fraction of 4,471 total input = 55% hit rate. The absolute savings per iteration stay constant; the percentage naturally decreases as conversation grows.
This is expected and fine. The cache saves the same number of tokens per iteration regardless of conversation length — it’s the ratio that changes.
Why the 5-minute TTL matters for agents
Anthropic’s cache TTL is 5 minutes. A single ReAct loop usually completes in under 2 minutes — all iterations share the same cached prefix with no expiry risk.
Where TTL matters: between incidents, not within them. If the next incident starts more than 5 minutes after the previous one, the cache is cold and the first iteration pays the write cost again. For the pipeline polling CloudWatch every 5 minutes, the cache will frequently be cold at the start of each incident. The savings come from within-incident reuse, not cross-incident.
For higher-throughput pipelines running many concurrent incidents, cross-incident cache sharing is possible if incidents start within the TTL window. In this pipeline, incident volume is low enough that within-incident savings are the primary benefit.
State pruning: cutting the compounding history
Prompt caching handles the stable prefix — harness docs, system prompt, tool definitions. It cannot help with the growing conversation history, because history changes on every iteration.
In FixGenerationAgent, the largest per-iteration growth comes from read_file tool results. Each call returns the full source file — typically 1,500–2,000 tokens. By iteration 5, the agent has read several files, and every subsequent iteration re-sends all of those file contents as part of the conversation history.
The agent needed those file contents to reason about what to change. By iteration 8, either the edit has been applied or the file is no longer relevant. The full text is dead weight billed on every remaining iteration.
The fix: _prune_tool_results()
def _prune_tool_results(
self,
messages: list[dict],
keep_last: int = 2,
threshold: int = 500,
) -> list[dict]:
tool_result_indices = [
i for i, m in enumerate(messages)
if m.get("role") == "tool"
and len(str(m.get("content", ""))) > threshold
]
to_stub = tool_result_indices[:-keep_last] if len(tool_result_indices) > keep_last else []
pruned = list(messages)
for i in to_stub:
pruned[i] = {
**pruned[i],
"content": "[tool result pruned — see most recent call for current state]",
}
return pruned
Called every 3 iterations in the FixGen loop:
if iteration % 3 == 0:
messages = self._prune_tool_results(messages, keep_last=2, threshold=500)
threshold=500 characters catches read_file results (1,500–2,000 tokens each) while preserving short tool confirmations like {"status": "ok", "path": "..."} (~50 chars — kept intact). keep_last=2 retains the two most recent oversized results — the agent needs recent file state to reason about the next edit. Everything older is replaced with a 12-token stub.
Cost impact
A 10-iteration FixGen run with 3 file reads spread across the first 5 iterations:
Without pruning:
3 reads × ~2,000 tokens each, re-sent on avg 3 subsequent iterations each
→ 3 × 2,000 × 3 = 18,000 extra input tokens per run
→ 18,000 × $3/M = $0.054 per run in avoidable input cost
With pruning:
Older reads stubbed to 12 tokens each
→ 18,000 tokens removed → $0.054 saved → 29% reduction on the FixGen step
Prompt caching and state pruning attack different parts of the cost with no overlap:
Prompt caching: stable harness prefix (2,471 tokens) — billed at 10% instead of 100%
State pruning: stale tool results (18,000 tokens) — removed from later iterations entirely
Impact on pipeline cost
The $0.1861 average cost per incident comes from the full pipeline: triage (Haiku), diagnosis (Sonnet), fix generation (Sonnet, up to 14 iterations), self-critique (Haiku), code review (GPT-4.1).
Prompt caching (Anthropic calls only):
- Diagnosis (6 iterations measured): 13% reduction on that step
- FixGeneration (10–14 iterations): 15–18% reduction — cache hit count scales with iterations while write cost is fixed
- Triage and self-critique (Haiku): benefit from caching, but shorter runs mean smaller absolute savings
- CodeReview (GPT-4.1): not affected — OpenAI’s caching mechanism is different and not implemented here
State pruning (FixGeneration only):
- 29% reduction on the FixGen step — $0.054 saved per run on a 10-iteration loop
- No overlap with prompt caching: caching targets stable harness tokens, pruning targets stale tool results that have grown into dead weight
Combined: prompt caching saves ~10–13% on the full pipeline; state pruning saves an additional ~15% on the FixGen component. For a typical incident where FixGen is the dominant cost step, combined savings are in the ~20–25% range — bringing the average from $0.1861 toward $0.14–0.15.
Summary
Prompt caching — three logical changes:
_with_harness()— return content blocks withcache_controlinstead of a plain stringLLMResponse— addcache_read_input_tokensandcache_creation_input_tokensfieldsLLMGateway.complete()— bill cached tokens at 10%, log cache activity
No agent code changed. No prompt changed. No behavior changed. The ReAct loop sees the same system prompt content; only the billing changes.
State pruning — one function, one call site:
_prune_tool_results()— replace oversized tool results older thankeep_last=2with a stub- Called every 3 iterations in the FixGen agentic loop
No model changed. No prompts changed. The agent still reasons over the same events — it just stops carrying full file contents past the point where they’re useful.
Together, these target the two independent cost drivers in agent loops: the stable prefix billed redundantly on every iteration, and the growing history carrying data that has already served its purpose.
What doesn’t get cached
The agent-specific system prompt (the second content block in the list) is never cached — it’s small (~80 tokens for FixGen, ~50 for Diagnosis) and different for each agent type. Not worth the complexity of a separate cache entry.
The growing conversation history (ReAct turns) is not cached — it changes on every iteration. Anthropic supports caching messages in addition to the system prompt, but caching a growing conversation is complex: you’d need to checkpoint at specific message boundaries and re-cache when the checkpoint changes. The harness docs cache already captures the highest-value stable prefix; the incremental gain from conversation caching doesn’t justify the complexity.
Tool definitions are also not cached here. They’re static for a given agent, and caching them alongside the system prompt would require restructuring how complete_with_tools passes tools. That’s a natural follow-on if token costs become a bottleneck.