ADR-009: Add a Message Batches API path for non-latency-sensitive bulk classification¶
Status: Accepted Date: 2026-07-11 Deciders: San Lee
Context¶
Three call sites classify a whole collection of articles by looping classify() once per row,
synchronously, with a small time.sleep() between calls to stay inside rate limits:
eval.py'srun_predictions()— every row ofdata/synthetic_articles.csv.gold_eval.py'srun_predictions()— every gold row, on two models (the workhorse baseline and the Opus judge), so it makes 2N synchronous calls.optimize.py's per-iteration scoring loop — out of scope here; see Alternatives Considered.
None of these are interactive: they're run manually or from CI, and nothing is waiting on a single
row's result in real time. Anthropic's Message Batches API is built for exactly this shape — submit
many Messages requests as one batch, poll until it finishes, then retrieve all results — at 50%
of the synchronous per-token price. Most batches finish within an hour (max 24h), and results stay
retrievable for 29 days.
Decision¶
Add a Message Batches API path alongside the existing synchronous loop, opt-in via a --batch CLI
flag, in both corpus-wide runners:
eval.py:run_predictions_batch(), wired tomain() --batch.gold_eval.py:run_predictions_batch(), wired tomain() --batch— submits both the workhorse and judge requests for every not-yet-done row as one batch (custom IDs"{id}::workhorse"/"{id}::judge"), rather than two separate batches, so the discount applies to the whole judge-validation pass in a single submission.
The synchronous path (run_predictions()) stays the default in both scripts and is unchanged —
this is additive, not a replacement. Shared plumbing lives in classify.py:
build_batch_request(custom_id, text, model=, system_prompt=, temperature=)— builds oneRequestwith the exact same system block (including itscache_controlmarker — see ADR history inclassify.py), tool, andtool_choiceas the synchronousclassify()call, so the two paths only differ in how the request is dispatched, not what's being asked.parse_batch_result(result)— extracts and validates the classified labels from one batch result item the same wayclassify()does (reusing_validate()), plus a newBatchItemErrorfor the batch-only outcomes (errored/canceled/expired) that a synchronous call can't produce.
Per-item failure handling: a batch item that isn't "succeeded", or a "succeeded" item whose
labels fail validation, is skipped rather than aborting the whole run — its id is simply not written
to the predictions CSV, so it stays in the resume set (done_ids's complement) and is picked up
automatically the next time either the sync or batch path runs. In gold_eval.py, if either the
workhorse or judge request for a given gold id fails, the whole row is dropped (not a
half-filled record with one side missing), for the same reason.
Consequences¶
- ~50% cheaper for unattended bulk runs. The natural place to reach for
--batch: a fulleval.pyrun over the synthetic corpus, or agold_eval.py --batchrun to (re)score the gold set — both cases where nobody is watching a progress bar in real time. - No progress output while in flight, and coarser resume granularity. The synchronous path
writes one row to the predictions CSV per completed call, so a crash mid-run loses at most one
row. The batch path has no such checkpoint: if the process is interrupted after submitting a
batch but before it ends, there is no batch-id checkpoint to resume against, so a rerun
resubmits a fresh batch for whatever's still
todo— re-paying for any batch that was in flight. Documented inrun_predictions_batch()'s docstring in both files. Acceptable for a background/cron use case; use the synchronous path when resume-per-request granularity matters more than the discount. - Prompt caching carries over automatically.
build_batch_request()reusesclassify()'s exact system-block construction, so if/when the system prompt grows pastclaude-sonnet-4-6's ~2048-token minimum cacheable prefix (e.g. via theoptimize.pyloop lengthening it — seeclassify.py's existing caching comment), batch requests over that prompt benefit the same way synchronous ones do. No separate caching logic needed for the batch path. - New shared test fixtures.
tests/conftest.pygainedFakeBatch/FakeBatches/FakeBatchResultItem/FakeBatchClient(and thebatch_clientfixture) — fakes forclient.messages.batches.{create,retrieve,results}that resolve instantly (no polling) and produce results keyed by whatevercustom_ids the code under test actually submits, sorun_predictions_batch()in both files is tested the same way the synchronous path already was: offline, with a fake client. optimize.py's scoring loop is unaffected. Its ~354-call-per-iteration pattern is latency-sensitive within the loop (each iteration's revised prompt depends on the prior iteration's scored feedback), so batching doesn't fit there — see Alternatives Considered.
Alternatives Considered¶
| Option | Reason Not Chosen |
|---|---|
| Replace the synchronous path entirely | Loses per-row resume granularity and real-time progress output that some runs (e.g. interactive dataset-building sessions, or a gold_eval.py run somebody is watching) still want; the task is additive, not a migration |
Add batch mode to optimize.py's scoring loop |
Each iteration's revised prompt is generated from that iteration's scored feedback — the loop is inherently sequential across iterations, and batch results aren't available until the whole batch ends (up to an hour), which would turn every iteration into an hour-long wait. The within-iteration scoring calls (~354 per iteration) are the one place batching could apply without changing the loop's sequencing, but that's a larger, separate change against a hot, already-tested file — left for a future iteration if optimize.py's live-run cost becomes the bottleneck. |
Separate batches for workhorse vs. judge in gold_eval.py |
Works, but pays two poll cycles and two "batch ends" waits instead of one; combining them into a single batch (differentiated by model per request and a ::workhorse/::judge suffix on custom_id) gets the same discount in one submission |
| Silently retry a failed batch item synchronously | Adds a second code path (and a second cost model) for the failure case; simply leaving the id out of the predictions CSV already gets a free retry via the existing resume logic on the next run, sync or batch, with no extra code |