Amazon Bedrock Prompt Caching and Batch Inference: Cut Coding-Agent Latency and Cost Without Stale Rules

Amazon Bedrock Prompt Caching and Batch Inference: Cut Coding-Agent Latency and Cost Without Stale Rules
23 views
A coding agent sends the same large block of text on every turn: tool definitions, a system prompt with coding rules, maybe a style guide. The model processes all of it every time, and you pay for it every time. Meanwhile your nightly eval suite calls the model thousands of times in a loop, at interactive prices, for results nobody reads until morning. Amazon Bedrock has a feature for each problem. Prompt caching lets repeated prompt prefixes be read from a cache, reducing input token cost and latency for interactive calls. Batch inference runs large sets of prompts asynchronously from files in S3. This guide explains both, shows the code, and covers the caching pitfall teams worry about most: agents following stale coding rules after a big repository change. It merges our earlier posts on caching economics and caching pitfalls into one guide.
TL;DR
  • Put stable content first (tools, then system rules) and add a cachePoint after it. Dynamic content (user turns, retrieved chunks) goes after the checkpoint.
  • Any change to the prefix is a cache miss, and changing tools invalidates the system and messages caches after it. Bedrock can’t serve an old prompt to a new request.
  • The default TTL is 5 minutes and resets on each hit; some models support "ttl": "1h". Claude models allow up to 4 checkpoints and need a minimum prefix size.
  • Read cacheReadInputTokens and cacheWriteInputTokens in the response. inputTokens counts only uncached tokens.
  • Batch inference takes JSONL from S3 (InvokeModel or Converse format) and writes results back to S3. It doesn’t support prompt caching or tool calling.

How prompt caching works

Bedrock offers two kinds of prompt caching, and support varies by model and API:
  • Implicit caching: Bedrock and the model automatically try to reuse eligible prefixes. No request changes are needed, but it’s best effort.
  • Explicit caching: you mark where the reusable prefix ends with cache checkpoints (cachePoint in Converse, cache_control in Anthropic’s InvokeModel format).
The rules from the Bedrock documentation that matter for agents:
  • Exact prefix match. Prefixes must stay identical between requests; any change causes a miss.
  • Order is tools, then system, then messages. The sections are chained, so changing tools invalidates everything after them.
  • Minimum size. Each checkpoint needs a minimum number of tokens in the prefix before it (for Claude models in the current table, between 512 and 4,096 depending on the model). Below the minimum, the request still succeeds but nothing is cached.
  • TTL. Many models use a 5-minute TTL that resets with every hit; some support a 1-hour TTL. Check the model card.
  • No guarantee. Even with checkpoints, a hit isn’t guaranteed. Check the usage fields to see what happened.
  • Billing. Cache reads are billed at the model’s cache-read rate. Cache writes can cost more than normal input, depending on the model. Output tokens are never discounted.
Figure 1. Prompt caching speeds up repeated interactive turns; batch inference handles deferred volume. They are separate paths. Diagram by CheatCoders.
Figure 1. Prompt caching speeds up repeated interactive turns; batch inference handles deferred volume. They are separate paths. Diagram by CheatCoders.

Step 1: Add cache checkpoints to agent calls

For a coding agent, the stable prefix is usually the tool definitions and the system prompt with your coding rules. Put a checkpoint after each, so a rules edit doesn’t invalidate the tools cache, and keep the conversation after them.
python
import json
import os

import boto3

brt = boto3.client("bedrock-runtime")
MODEL_ID = os.environ["AGENT_MODEL_ID"]  # a model or inference profile that supports caching

TOOLS = [
    {
        "toolSpec": {
            "name": "read_file",
            "description": "Read a file from the checked-out repository.",
            "inputSchema": {"json": {
                "type": "object",
                "properties": {"path": {"type": "string"}},
                "required": ["path"],
            }},
        }
    },
    # ... more tool specs ...
    {"cachePoint": {"type": "default"}},  # checkpoint 1: after the tools
]


def build_system(rules_text: str) -> list[dict]:
    return [
        {"text": rules_text},                 # long, stable coding rules
        {"cachePoint": {"type": "default"}},  # checkpoint 2: after the rules
    ]


def agent_turn(rules_text: str, history: list[dict], user_text: str) -> dict:
    resp = brt.converse(
        modelId=MODEL_ID,
        system=build_system(rules_text),
        toolConfig={"tools": TOOLS},
        messages=history + [{"role": "user", "content": [{"text": user_text}]}],
        inferenceConfig={"maxTokens": 2048},
    )
    u = resp["usage"]
    read = u.get("cacheReadInputTokens", 0)
    write = u.get("cacheWriteInputTokens", 0)
    total_input = u["inputTokens"] + read + write  # inputTokens excludes cached tokens
    print(json.dumps({
        "metric": "bedrock_usage",
        "input_total": total_input,
        "cache_read": read,
        "cache_write": write,
        "cache_hit_ratio": round(read / total_input, 3) if total_input else 0,
        "output": u["outputTokens"],
    }))
    return resp
Things to get right:
  • Nothing that changes per request (timestamps, user names, request IDs, retrieved chunks) may appear before a checkpoint. One changing character in the rules section turns every call into a cache write.
  • If your total prefix before a checkpoint is below the model’s minimum, it isn’t cached. Small system prompts may not benefit at all.
  • For long sessions with pauses longer than 5 minutes, a model that supports "ttl": "1h" in the cachePoint keeps the cache alive. Don’t send the ttl field to models that only support 5 minutes; that can cause a ValidationException.
  • Cross-Region inference works with caching, but at times of high demand it may lead to more cache writes.

Step 2: Measure whether caching pays off

Whether caching saves money depends on how often the same prefix is reused within the TTL. The arithmetic per call is simple:
  • Without caching, a prefix of P tokens is billed P times the input rate on every call.
  • With caching, the first call in a TTL window pays P times the cache-write rate, and each later hit pays P times the cache-read rate.
So savings grow with the number of hits per write. An agent that sends many turns per session within a few minutes gets a high ratio; a prefix used twice an hour on a model with a 5-minute TTL mostly pays write prices. Use the cache_hit_ratio metric from Step 1 and the current rates on the Amazon Bedrock pricing page for your model and Region instead of assuming a discount. Latency also improves on hits because the prefix isn’t reprocessed; measure time to first token in your own traces.

Step 3: Avoid stale coding rules

A common worry: after a big repository change (a renamed package, a banned library, a new lint rule), will agents keep following the old rules because of the cache? Not because of Bedrock’s cache. Prefix caching requires an exact match, so new rules text is simply a cache miss followed by a new write. When agents do follow old rules, the cause is almost always in your own system:
  • The agent runtime loads rules from an old checkout or an old prompt version pin.
  • An application-level cache (for example, a cache of answers or of assembled prompts) is keyed only by the question, not by the rules version.
  • Old few-shot examples that use the removed API are still in the prompt.
Make the rules version explicit, log it on every call, and include it in any cache key you own:
python
import hashlib
import pathlib
import re


def rules_version(rules_dir: str) -> str:
    """Content hash of every rules file, so any edit produces a new version."""
    h = hashlib.sha256()
    for path in sorted(pathlib.Path(rules_dir).rglob("*.md")):
        h.update(str(path).encode())
        h.update(path.read_bytes())
    return h.hexdigest()[:16]


def app_cache_key(tenant: str, question: str, rules_ver: str, tools_ver: str) -> str:
    # Anything that changes behavior belongs in the key, and so does the tenant.
    return hashlib.sha256(f"{tenant}|{rules_ver}|{tools_ver}|{question}".encode()).hexdigest()


CANARIES = [
    {"prompt": "Add a function that loads a user record.",
     "must_not_match": r"LegacyUserRepo", "reason": "LegacyUserRepo removed in ADR-214"},
]


def run_canaries(ask) -> None:
    """Run after every rules change; fail the deploy if a removed pattern comes back."""
    for c in CANARIES:
        answer = ask(c["prompt"])
        if re.search(c["must_not_match"], answer):
            raise AssertionError(f"stale rule detected: {c['reason']}")
If you manage the system prompt with Bedrock Prompt Management, remember it versions prompts but has no aliases; your application pins a version number, so a rules change means publishing a new version and updating that pin. Untrusted text such as issue bodies and PR descriptions changes per request anyway, so it belongs after the checkpoints, clearly delimited as data.

Step 4: Move offline work to batch inference

Evals, backfills and bulk classification don’t need answers in seconds. With batch inference you upload JSONL files to S3, submit one job, and collect results from S3 later. Each line has a recordId and a modelInput. With the Converse invocation type, modelInput uses the Converse request format, so the same request shape works across models.
python
import json
import os

import boto3

s3 = boto3.client("s3")
bedrock = boto3.client("bedrock")
BUCKET = "agent-batch-jobs"
RULES = open("rules/system.md").read()


def submit_eval_batch(run_id: str, cases: list[dict]) -> str:
    lines = []
    for case in cases:
        lines.append(json.dumps({
            "recordId": case["id"],
            "modelInput": {
                "system": [{"text": RULES}],
                "messages": [{"role": "user", "content": [{"text": case["prompt"]}]}],
                "inferenceConfig": {"maxTokens": 1024},
            },
        }))
    key = f"evals/{run_id}/input.jsonl"
    s3.put_object(Bucket=BUCKET, Key=key, Body=("\n".join(lines) + "\n").encode())

    job = bedrock.create_model_invocation_job(
        jobName=f"eval-{run_id}",
        roleArn=os.environ["BATCH_ROLE_ARN"],
        modelId=os.environ["EVAL_MODEL_ID"],
        modelInvocationType="Converse",
        inputDataConfig={"s3InputDataConfig": {"s3Uri": f"s3://{BUCKET}/{key}"}},
        outputDataConfig={"s3OutputDataConfig": {"s3Uri": f"s3://{BUCKET}/evals/{run_id}/out/"}},
        timeoutDurationInHours=24,
    )
    return job["jobArn"]
Batch input must meet the service quotas for minimum records per job, records per file and file size; check the current values in Service Quotas before you build the pipeline. The job’s service role needs read access to the input, write access to the output, and the caller needs iam:PassRole for that role. A job moves through Submitted, Validating, Scheduled, InProgress and ends as Completed, PartiallyCompleted, Failed, Stopped or Expired (scheduled but not started before the timeout). Use EventBridge job state change notifications rather than polling from a request thread. When it finishes, read the results:
python
import json

import boto3

s3 = boto3.client("s3")


def read_batch_results(bucket: str, prefix: str):
    """Yield (record_id, output, error) from every .out file except the manifest."""
    paginator = s3.get_paginator("list_objects_v2")
    for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
        for obj in page.get("Contents", []):
            key = obj["Key"]
            if key.endswith("manifest.json.out"):
                manifest = json.loads(s3.get_object(Bucket=bucket, Key=key)["Body"].read())
                print("records ok/err:", manifest["successRecordCount"], manifest["errorRecordCount"])
                continue
            if not key.endswith(".out"):
                continue
            body = s3.get_object(Bucket=bucket, Key=key)["Body"].read().decode()
            for line in body.splitlines():
                rec = json.loads(line)
                yield rec.get("recordId"), rec.get("modelOutput"), rec.get("error")
Output order isn’t guaranteed to match input order, so always join on recordId. Records that failed carry an error object instead of modelOutput, and the manifest.json.out file summarizes record and token counts.

Caching or batch: choose per workload

WorkloadUser waiting?Repeated long prefix?Use
Agent turns in an IDE sessionYesYes (tools, rules)Prompt caching
One-off questions with unique large contextYesNoOn-demand; shrink the prompt
Nightly eval suiteNoMaybeBatch inference (no caching there)
Bulk labelling or summarizing historyNoNoBatch inference
Anything needing tool callsEitherEitherOn-demand (batch doesn’t support tool calling)

Worked example: one agent, two paths

  1. Your review agent sends about 9,000 tokens of tool specs and rules on every turn (illustrative). You add checkpoints after the tools and after the rules.
  2. The usage metric shows the first turn of each session writing to the cache and later turns reading from it. Sessions with long pauses show repeated writes, so you test the 1-hour TTL on a model that supports it.
  3. A platform team renames a core package. The rules hash changes, the next calls show cache writes instead of reads (as expected), and the canary confirms the agent no longer suggests the old package.
  4. The nightly eval of 5,000 cases moves from an on-demand loop to one batch job in the Converse format. The morning report joins outputs by recordId and lists the records that came back with errors.

Troubleshooting

SymptomLikely causeFix
cacheReadInputTokens always 0Prefix below the model minimum, model doesn’t support explicit caching, or something dynamic sits before the checkpointCheck the model table; move dynamic content after the checkpoint
Every call writes to the cachePrefix changes each time (timestamps, reordered tools) or gaps longer than the TTLMake the prefix byte-stable; consider the 1-hour TTL
ValidationException after adding ttlModel supports only the 5-minute TTLRemove the ttl field for that model
Input token counts look too lowinputTokens excludes cached tokensTotal = inputTokens + cacheRead + cacheWrite
Batch job Failed during validationRole can’t read S3, file isn’t .jsonl, or quotas not metFix permissions and file format; check record quotas
Batch job ExpiredIt was scheduled but didn’t start before the timeoutResubmit; use a longer timeoutDurationInHours
Agent follows removed rulesOld rules source or an app cache without the rules versionVersion rules by content hash; run canaries after changes

Production checklist

  • ☐ Checkpoints after tools and after system rules; nothing dynamic before them
  • ☐ Prefix meets the model’s minimum tokens; TTL chosen per session pattern
  • ☐ Cache read/write tokens and hit ratio logged per route
  • ☐ Rules version (content hash) logged with each call and included in your own cache keys
  • ☐ Canary prompts run after every rules or prompt-version change
  • ☐ Untrusted, per-request text placed after the checkpoints
  • ☐ Offline workloads on batch inference, joined by recordId, with EventBridge notifications
  • ☐ Savings forecasts based on measured hit ratios and current pricing

FAQ

Can I use prompt caching inside a batch job?

No. Prompt caching is supported only for on-demand inference, not the batch inference API.

Can Prompt Management prompts use caching?

Yes. When you create or edit a prompt in Bedrock Prompt Management you can enable prompt caching, and depending on the model cache the system prompt, system instructions and messages.

Does caching change the model’s answers?

It’s meant to skip reprocessing an identical prefix, not to change behavior. If outputs differ after you enable it, check whether your prompt content or order changed.

Why are my batch outputs out of order?

Bedrock doesn’t guarantee output order. Join on recordId.

Official documentation

Related on CheatCoders: Bedrock Prompt Management · Streaming Bedrock tokens from Lambda Last updated on October 6, 2026

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

1 Comment

Leave a comment

No account needed. Name and email are optional.