The problem
What the customer needs
- Know before shipping whether a prompt or model change made things better or worse.
- Grade what the agent actually did (tool calls, sequence), not just how the final text reads.
- Run evaluations cheaply at scale, not one request at a time against a live endpoint.
Why naive approaches fail
- Grading only the final text lets an agent that skipped identity verification still score well because the refund text 'looked right'.
- Using a weaker or cheaper model as the judge than the system under test produces unreliable, inconsistent scores.
- Re-running evals ad hoc with no versioned golden set gives you no regression signal, every run is a fresh, incomparable snapshot.
- Golden set versioned alongside the prompt/model it was built against
- Tool-call correctness scored separately from output-text quality
- Judge forced into structured, parseable output via output_config.format
- New versions gated on matching or beating the baseline pass rate before deploy
The system
What each part does
4 components, each owns a concept. Click any card to drill into the underlying primitive.
Golden Set
versioned inputs + expected behavior
A repo-tracked set of representative test cases, each with an input and the expected stop_reason/tool sequence/output shape, versioned alongside the prompt it was built against.
Configuration
golden_set_v3.jsonl - one JSON object per line: {id, input, expected_tools, expected_shape}.
Batch Runner
Message Batches API
Submits the full golden set as a single batch job rather than sequential live requests, at half the per-token cost and without holding a connection open per case.
Configuration
client.messages.batches.create(requests=[{custom_id, params} for each golden-set case]).
LLM Judge
structured, deterministic scoring
A model at least as capable as the system under test, forced into a fixed JSON schema via output_config.format so scores are parseable and comparable across runs.
Configuration
output_config={"format": {"type": "json_schema", "schema": JUDGE_SCHEMA}} - never a prefill (400s on current models).
Regression Gate
baseline comparison
Compares the new run's pass rate against the versioned baseline and blocks deployment if it regresses, turning eval results into a CI-style check rather than a one-off report.
Configuration
if new_pass_rate < baseline_pass_rate - TOLERANCE: fail the deploy step.
Data flow
6 steps to production
Build a versioned golden set
Each case pairs an input with the expected tool sequence and output shape. Version it in the repo alongside the prompt it was authored against.
golden_set = [
{
"id": "refund-happy-path",
"input": "I want a refund for order 42, it arrived damaged.",
"expected_tools": ["verify_customer", "lookup_order", "process_refund"],
"expected_stop_reason": "end_turn",
},
{
"id": "refund-over-cap",
"input": "I need a $900 refund on order 55.",
"expected_tools": ["verify_customer", "lookup_order", "escalate_to_human"],
"expected_stop_reason": "end_turn",
},
]Run the golden set through Message Batches
Submit every case as one batch job instead of sequential live requests - half the cost, and it doesn't hold connections open per case.
def submit_eval_batch(golden_set, model="claude-sonnet-5"):
return client.messages.batches.create(requests=[
{"custom_id": case["id"], "params": {
"model": model, "max_tokens": 4096, "tools": tools,
"messages": [{"role": "user", "content": case["input"]}],
}} for case in golden_set
])Score tool-call correctness separately from text quality
Extract the actual tool call sequence from the response and compare it to the expected sequence directly - exact-match grading, not judge opinion, for structured behavior.
def score_tool_sequence(response, expected_tools: list[str]) -> bool:
actual = [b.name for b in response.content if b.type == "tool_use"]
return actual == expected_toolsGrade open-ended output with a structured LLM judge
Force the judge into a JSON schema so scores are parseable and comparable. Use a model at least as capable as the system under test, and never a prefill.
JUDGE_SCHEMA = {
"type": "object",
"properties": {
"correct": {"type": "boolean"},
"reasoning": {"type": "string"},
},
"required": ["correct", "reasoning"],
"additionalProperties": False,
}
def judge_output(case_input: str, agent_output: str, expected_behavior: str):
return client.messages.parse(
model="claude-opus-4-8",
max_tokens=1024,
output_config={"format": {"type": "json_schema", "schema": JUDGE_SCHEMA}},
messages=[{"role": "user", "content":
f"Input: {case_input}\nExpected: {expected_behavior}\nActual: {agent_output}\n"
"Does the actual output satisfy the expected behavior?"}],
).parsed_outputGate deploys on the versioned baseline
Compare the new run's pass rate to the checked-in baseline. A regression beyond tolerance blocks the deploy, the same way a failing unit test would.
def check_regression_gate(new_pass_rate: float, baseline_pass_rate: float, tolerance=0.02):
if new_pass_rate < baseline_pass_rate - tolerance:
raise SystemExit(
f"Regression: {new_pass_rate:.2%} vs baseline {baseline_pass_rate:.2%}"
)
print(f"OK: {new_pass_rate:.2%} (baseline {baseline_pass_rate:.2%})")Replay failures from full content blocks, not just final text
When a case fails, inspect the entire content array (tool_use, tool_result, stop_reason per turn) to see exactly where the agent diverged, not just the final answer.
def debug_failure(case_id: str, response):
print(f"--- {case_id} ---")
print(f"stop_reason: {response.stop_reason}")
for block in response.content:
if block.type == "tool_use":
print(f" tool_use: {block.name}({block.input})")
elif block.type == "text":
print(f" text: {block.text[:200]}")The four decisions
| Decision | Right answer | Wrong answer | Why |
|---|---|---|---|
| Grading tool-call sequences | exact-match against the expected sequence | LLM judge opinion on whether it 'seems right' | Structured behavior (which tools, what order) has a ground truth; exact-match is cheaper and more reliable than judge opinion for it. |
| Grading open-ended text quality | LLM judge, forced into structured output | exact string match against a reference answer | Natural-language quality has no single correct string, but scores must still be parseable and comparable across runs. |
| Judge model tier | at least as capable as the system under test | the cheapest available model, to save eval cost | A weaker judge produces unreliable grading, defeating the purpose of the harness. |
| Running large eval sets | Message Batches API | sequential live requests in a loop | Batches cut cost 50% and don't hold connections open per case, and eval runs are inherently latency-insensitive. |
Where it breaks
4 failure pairs. Each maps to an exam-style question - the naive move on the left, the disciplined fix on the right.
The judge scores only the final response text, missing that the agent skipped verify_customer.
CCDF-15Score tool-call sequence separately (exact-match) from output-text quality (LLM judge).
Haiku 4.5 is used to judge output from a Sonnet-5-powered agent, producing noisy scores.
CCDF-16Use a judge model at least as capable as the system under test.
The eval script's test cases live only in someone's scratch notebook, changing between runs.
CCDF-17Version the golden set in the repo alongside the prompt/model it was built against.
Judge prompt ends with an assistant-turn prefill like '{"correct": ' to force JSON shape.
CCDF-18Assistant-turn prefills return a 400 on current models. Use output_config.format (structured outputs) instead.
Cost & latency
50% Batches discount on Sonnet 5 traffic for 100 short-to-medium cases with tool use.
A separate Opus 4.8 (or Sonnet 5) call per case for open-ended text grading; skip it for pure tool-sequence cases.
Ship checklist
Two passes. Build-time gates verify the code; run-time gates verify the system in production.
Build-time
- Golden set versioned in the repo, paired with a prompt/model version↗ evaluation
- Eval runs executed via the Message Batches API↗ batch-api
- Tool-call correctness scored separately from output text quality
- Judge forced into structured output, never a prefill↗ structured-outputs
- Regression gate compares new pass rate against the checked-in baseline
- Failures debuggable from full content blocks, not just final text↗ debugging-llm-system-failures
- Cost and latency tracked alongside accuracy in the eval report
Run-time
- Golden set covers known production edge cases, not just happy paths
- Judge schema and prompt versioned alongside the golden set
- Regression gate wired into the deploy pipeline, not run manually
- Tool-call sequence checks cover every state-changing tool
- Failure replay tooling surfaces full content blocks, not truncated text
Five exam-pattern questions
Your eval harness scores an agent 95% pass rate, but production shows it frequently skips identity verification before processing refunds. What's the gap?
verify_customer before process_refund) - structured behavior needs structured grading, not judge opinion on the text.You use Haiku 4.5 to judge outputs from a Sonnet-5-powered agent to save on eval cost. Scores are inconsistent between runs. Why?
Your judge prompt ends with `{"role": "assistant", "content": '{"correct": '}` to force JSON output. It fails on the current model. Why, and what's the fix?
output_config.format with a json_schema (structured outputs) instead - it constrains the response shape without relying on a prefill.