BXUP Track progress & test yourself — free
Data Science & ML/AI · Playbook · Core · ~5 min read
DATA SCIENCE & ML/AI · PLAYBOOK · CORE

Batch Enrichment at Scale: Cost and Latency Engineering for Millions of Records

Classifying, normalizing, or deduplicating millions of records with an LLM is an engineering problem, not a prompting problem: batch endpoints (50% discount), prompt caching (~90% off repeated prefixes), model-tier routing, and token accounting decide whether the job costs $40 or $4,000.

Tier: Core · Workflow area: LLM Data Pipelines & Cost Engineering

This playbook builds a batch enrichment pipeline with cost controls and sampling QA baked in.

When to use

  • Enriching a table of 10k–10M rows (categorize products, normalize addresses/companies, tag tickets) where minutes-to-hours latency is acceptable.
  • An existing per-row synchronous loop over an LLM API is slow, rate-limited, or expensive.
  • You need a defensible cost estimate before running the full job.

For sub-1k rows, a plain loop with pb-14's extraction function is fine — skip the batch machinery.

Prerequisites

  • The typed extraction function and schema from pb-14.
  • Provider batch API access; polars or Spark for the surrounding dataframe work.
  • A 500–1,000-row stratified pilot sample and ~200 hand-labeled rows for QA.

The workflow

  1. Estimate cost before anything runs. Count tokens on 100 representative rows and extrapolate:

    n = client.messages.count_tokens(model="claude-haiku-4-5",
        system=SYSTEM_PROMPT, messages=[{"role": "user", "content": row_text}])
    # total_cost ≈ rows * (avg_in * price_in + avg_out * price_out) * 0.5  (batch discount)
    

    Present the estimate per tier. If the number is unacceptable, fix it here — shorter prompt, cheaper tier, truncated inputs — not after the invoice.

  2. Structure prompts for caching. Put the static instruction block + few-shot examples first with a cache breakpoint; only the row payload varies:

    system=[{"type": "text", "text": INSTRUCTIONS_AND_EXAMPLES,
             "cache_control": {"type": "ephemeral"}}]
    

    Verify with usage.cache_read_input_tokens > 0 on the second call; a zero means a silent invalidator (timestamp, unsorted JSON, varying prefix). Note minimum cacheable prefix sizes are model-dependent (~1–4k tokens) — pad the shared block with examples rather than shrinking below the threshold.

  3. Route by tier with an escalation rule. Default every row to the cheap tier; escalate to the frontier tier only rows where the cheap model abstains or reports low confidence (add confidence: Literal["high","low"] to the schema). Typical result: 90–95% of rows at ~1/5 the price.

  4. Submit in batch, keyed by stable IDs. Chunk into batches (≤100k requests / 256 MB each on Anthropic), custom_id = your primary key:

    from anthropic.types.messages.batch_create_params import Request
    from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
    
    batch = client.messages.batches.create(requests=[
        Request(custom_id=str(row.id), params=MessageCreateParamsNonStreaming(
            model="claude-haiku-4-5", max_tokens=200,
            system=cached_system, messages=[{"role": "user", "content": row.text}]))
        for row in chunk])
    

    Poll processing_status == "ended", then stream results and join on custom_id — results arrive in any order, never join by position. Handle all four result types (succeeded/errored/canceled/expired); resubmit expired, dead-letter errored.

  5. Sampling QA, then full run. Run the pilot batch, score it against your 200 labeled rows, and only then submit the full job. During the full run, continuously sample 0.5–1% of outputs into a review queue; alert if the "unknown"/abstain rate or per-field distributions drift from the pilot.

  6. Make the pipeline idempotent. Persist raw responses keyed by (custom_id, prompt-version, model); reruns skip already-enriched keys. A prompt or schema change bumps the version and triggers selective re-enrichment, not a full re-spend.

Key Takeaway:

The 50% batch discount, cache reads, and tier routing multiply: cheap-tier + cached prefix + batch is routinely 20–30x cheaper per row than naive frontier-tier synchronous calls — measure your own stack's factor and put it in the report.

Tool variants

  • Anthropic Message Batches:client.messages.batches.create(...) as above; caching works inside batches, so the shared system block gets cache pricing across the batch.
  • OpenAI Batch API: write requests to JSONL, client.files.create(file=..., purpose="batch"), then client.batches.create(input_file_id=..., endpoint="/v1/chat/completions", completion_window="24h"); results come back as a JSONL output_file_id keyed by custom_id. Same 50% discount.
  • Spark/Polars orchestration: partition the table, one batch submission per partition, join results back by key — keeps the LLM layer stateless.
  • Open-weights at extreme volume: past roughly tens of millions of rows, a vLLM deployment on spot GPUs can undercut even batch pricing; re-run the pb-15 frontier math before committing.

Verification

  • Pilot accuracy vs the 200 labeled rows meets your bar on the cheap tier (or the escalation rule demonstrably closes the gap).
  • Cache hit rate confirmed in usage fields; effective cost per 1k rows recomputed from actual token counts and within 15% of the pre-run estimate.
  • Row count in = row count out (succeeded + dead-lettered + expired-resubmitted reconciles exactly).
  • Re-running the pipeline on the same input costs ~$0 (idempotency check).

Pitfalls

  • Joining batch results by position. Results are unordered; a positional join silently mislabels the entire table. Always join on custom_id.
  • Uncapped output tokens. A verbose failure mode (model explaining itself) at 1M rows is real money; set tight max_tokens and a schema that leaves no room for prose.
  • One giant prompt per row. Stuffing 20 rows per call to "save requests" breaks caching economics, complicates error attribution, and hurts accuracy; prefer one row per request inside a batch.
  • Skipping the pilot. The most expensive sentence in this workflow is "let's just run all 5 million and see."
  • Silent input truncation. If some rows exceed the context you budgeted, log and route them to a long-input handler; don't let the SDK or your code clip them quietly.

Restricted-environment variant

  • Client data that cannot leave your VPC: run vLLM (7–8B AWQ model, one T4/A10 spot instance in ap-south-1, ~$0.10–0.40/hr) with guided JSON decoding; keep the same schema, tier-escalation (small local model → larger local model), and QA sampling.
  • Budget-constrained learning setup: the whole playbook is rehearsable for under ₹250 using a free-tier/cheap-tier model on a 1k-row public dataset — the design constraint for this track's labs.
  • Redaction path: if a hosted batch API is permitted post-anonymization, hash join keys and strip PII columns before JSONL export; verify the redaction with a grep pass over the outbound file, not by trust.

Success criteria

  • Full-table enrichment completed with reconciled counts, a measured cost within 15% of estimate, and a per-1k-row cost you can quote.
  • Tier-routing stats reported (fraction escalated, accuracy by tier) proving the cheap tier carried the bulk of the load.
  • QA sample results and drift alerts wired into the run, not performed once and forgotten.
  • The pipeline reruns idempotently and a schema/prompt version bump re-enriches only what changed.
This is the free reading view. Open BXUP to track your progress, run the hands-on labs, and prove it with original, exam-pattern assessments.
© Anvi Innovate · BXUP — Be Exponentially Upskilled · BXUP is an independent learning platform, not affiliated with or endorsed by AWS, Microsoft, Google, NVIDIA, IBM, Salesforce, Databricks, Oracle or Anthropic. All trademarks belong to their respective owners. · bxup.in