07-production-observability

Cost Tracking, Prompt Caching & Guardrails

The token-budget alert from the last guide just fired. Here's what actually brings the number back down — per-feature cost tracking, prompt caching, rate limits, and a cost-aware model router.

August 13, 2026
spring-aicostprompt-cachingguardrailsrate-limitingmodel-router

The Alert That Actually Fired

The token-budget alert from the last guide isn't hypothetical anymore — it fired last Tuesday. Daily spend on the support agent is up 40% week over week, and nobody added a new feature. This guide is the response: where the tokens are actually going, what cuts the number without cutting quality, and what stops it from happening again unnoticed.


1. Cost Tracking Per User, Per Feature

The dashboard from the last guide shows aggregate spend. Finding what's driving an increase needs it broken down — the same custom-counter pattern from this phase's Micrometer guide, tagged for cost attribution specifically:

java
@Service
public class SupportChatService {
 
    private final ChatClient chatClient;
    private final MeterRegistry registry;
 
    public String reply(String customerId, String feature, String message) {
        ChatResponse response = chatClient.prompt()
            .user(message)
            .call()
            .chatResponse();
 
        Usage usage = response.getMetadata().getUsage();
        registry.counter("ai.tokens.input", "feature", feature, "customer_tier", tierOf(customerId))
            .increment(usage.getPromptTokens());
        registry.counter("ai.tokens.output", "feature", feature, "customer_tier", tierOf(customerId))
            .increment(usage.getCompletionTokens());
 
        return response.getResult().getOutput().getText();
    }
}

ChatResponse.getMetadata().getUsage() — the same object Spring AI already populates on every call — gives you exact per-call token counts. Tag by feature (triage vs conversational reply vs RAG-grounded policy answer) and customer_tier, not just model, and "spend is up 40%" becomes "the new RAG-grounded policy-answer feature is 60% of spend" in one query, instead of a guess.

⚠️

Tag cardinality matters here more than in the last guide's latency metrics. Tagging by feature (a handful of values) is fine; tagging by raw customer_id on a Counter creates one time series per customer, which doesn't scale past a few hundred customers before Prometheus storage becomes a real problem. Aggregate spend into your application database keyed by customer ID instead, and reserve Micrometer tags for low-cardinality dimensions.


2. Prompt Caching: The Biggest Single Lever

The support agent's system prompt — the persona, the tool descriptions, the "never guess" RAG instruction — is identical on every single call. Sending it fresh every time is pure waste. Anthropic (and Bedrock) support prompt caching: the provider caches a prefix of the prompt server-side, and a cache hit costs a fraction of a full input token.

java
var cacheOptions = AnthropicCacheOptions.builder()
    .strategy(AnthropicCacheStrategy.SYSTEM_AND_TOOLS)
    .build();
 
var options = AnthropicChatOptions.builder()
    .model("claude-sonnet-4-5")
    .cacheOptions(cacheOptions)
    .build();
 
ChatResponse response = chatModel.call(new Prompt(messages, options));
StrategyCachesBest for
NONENothing (default)No caching
SYSTEM_ONLYThe system messageA stable persona/instructions, no tools
TOOLS_ONLYTool definitionsMany tool descriptions, simple system prompt
SYSTEM_AND_TOOLSBothThe support agent's exact situation — a fixed persona plus OrderTools/CalculatorTools/EscalationTools from this roadmap's fourth phase
CONVERSATION_HISTORYGrowing message historyLong-running conversations where ChatMemory keeps resending prior turns

SYSTEM_AND_TOOLS is the highest-value default for most of this roadmap's SupportChatService — the system prompt and tool descriptions are large, static, and repeated on literally every call. CONVERSATION_HISTORY matters specifically once MessageWindowChatMemory's 20-message window (this roadmap's second phase) means later turns in a long conversation are resending most of the earlier ones — caching that growing prefix means each new turn only pays full price for what's actually new.

Anthropic reports up to 90% cost reduction on cache hits, but a cache hit requires the cached prefix to be byte-identical to a recent previous call — a system prompt that includes a live timestamp or a per-request customer ID breaks the cache every time. Keep anything genuinely per-request (the actual customer message, per-request ToolContext data) out of the cached prefix, and keep the cached portion — persona, tool descriptions, static instructions — exactly static.


3. Budgeting for Reasoning Models

This roadmap's first guide introduced reasoning ("thinking") models as spending extra hidden tokens on a reasoning chain before answering. That's real, uncached cost that doesn't show up as visible response text — a reasoning model can cost several times a standard model's price for what looks like the same short answer. Reserve reasoning models for genuinely complex judgment calls (Embabel's .withLlm(...) per-action model selection from this roadmap's fifth phase is exactly the mechanism for scoping this), not as the default for every call.


4. Guardrails

Three layers, from simplest to most involved:

java
// 1. Cap output length per request
ChatOptions.builder().maxTokens(500).build();
java
// 2. Rate limit per customer with Bucket4j
Bucket bucket = Bucket.builder()
    .addLimit(Bandwidth.classic(20, Refill.intervally(20, Duration.ofMinutes(1))))
    .build();
 
if (!bucket.tryConsume(1)) {
    throw new RateLimitExceededException("Too many requests — please wait a moment");
}
java
// 3. Daily token budget per customer, Redis sliding window
public boolean withinDailyBudget(String customerId, int tokensRequested) {
    String key = "token-budget:" + customerId + ":" + LocalDate.now();
    Long used = redisTemplate.opsForValue().increment(key, tokensRequested);
    redisTemplate.expire(key, Duration.ofHours(25));
    return used <= DAILY_TOKEN_BUDGET;
}

maxTokens bounds a single response; the rate limiter stops one customer from hammering the endpoint; the daily budget stops a single very long conversation (or a bug causing a retry storm) from running up an unbounded bill for one account. Layer all three — they guard against different failure modes, not the same one.


5. A Cost-Aware Model Router

The cheapest fix for "cost is too high" is often not sending every request to the same model in the first place:

java
@Service
public class ModelRouter {
 
    private final ChatClient cheapClient;   // e.g. gpt-5-mini, or Ollama locally
    private final ChatClient capableClient; // e.g. claude-sonnet-4-5
 
    public ChatClient routeFor(TicketCategory category, boolean isFollowUp) {
        boolean simple = category == TicketCategory.GENERAL && !isFollowUp;
        return simple ? cheapClient : capableClient;
    }
}

This is the same idea this roadmap has now applied at three different layers: ChatOptions.builder().temperature(0.0) for a classification call versus a creative one (second guide), Embabel's .withLlm(...) picking a model per @Action (fifth phase), and now an explicit router choosing between two whole ChatClient beans by task. Ticket triage — one word out, low stakes — never needed the same model as a nuanced refund-denial explanation a customer will actually read.


What's Next

This is the last guide before the roadmap's true finale. You now have every piece: a working agent across three JVM frameworks, RAG, tools, MCP, evaluation, streaming, observability, and cost control. The final guide closes the loop this whole roadmap opened — how to keep all of it current as Spring AI, Embabel, and LangChain4j keep shipping.

Frequently asked questions

Does prompt caching work with Ollama or other local models?

No — prompt caching as covered here is a provider-side feature specific to Anthropic and Bedrock, relying on the provider's own infrastructure to cache a prefix server-side. Ollama has no equivalent concept, though running locally sidesteps the cost problem caching solves in the first place — there's no per-token bill to reduce.

Should the daily token budget block the request or just alert?

That's a product decision, not a technical one — this guide's Redis example blocks, which is appropriate for a hard cost ceiling on a free tier. For a paying customer, consider alerting and allowing an override, or a tiered budget matched to their plan, rather than a hard cutoff that turns a cost-control measure into a support ticket of its own.

Is a cheap-vs-capable model router worth the complexity for a small application?

Not necessarily on day one — this is a genuine "add it when the cost data justifies it" case, not a default to build before you have a token-usage-by-feature breakdown (section 1 of this guide) showing which calls are simple enough to downgrade. Building the router before you know where the spend actually is risks optimizing the wrong thing.

How do I know if prompt caching is actually reducing cost, not just adding complexity?

The gen_ai.client.token.usage metrics from this phase's Micrometer guide, combined with your provider's own billing dashboard showing cache read/write token pricing separately from regular tokens, is the real answer — most providers report cache hit rates directly. Don't just assume it's working; verify it against actual billing data after rollout.