Skip to main content
An evaluation measures how well a model does your task, whether it’s a model you could use as is or a checkpoint you trained. Build it before you train: the same definition of a good answer sets the bar a trained model must beat, selects training data, and decides whether a checkpoint ships. A payments API company’s support tool reads each incoming ticket and writes a JSON summary for the on-call engineer: a short summary and a severity from sev1 to sev4. The tool runs on GLM 5.3 today. The team wants to know whether GLM 5.3 Flash, which costs less per token, or a smaller model they train can do the job as well.

Prerequisites

Create the project and add the OpenAI and Pydantic packages:
Export the key as BASETEN_API_KEY:
Every script calls Model APIs, which bill per token: each run sends every ticket to the model under test, and each summary that parses to the judge once per axis. Download the example data, 134 synthetic tickets from 40 fictional merchant accounts, and write it as one ticket per line:
Each line of tickets.jsonl is one ticket, with the account that sent it and the severity it should get under the tool’s severity policy:
tickets.jsonl

Write down what a good answer is

Turn quality into rules a reviewer can apply to one output. Write each rule as a trigger and a required behavior, not an adjective: Collect rules from material that already encodes them, most trusted first:
  • System prompts and docs: every must or must-not instruction is a rule someone wrote down.
  • Templates and schemas: required fields, formats, and allowed values are stated there.
  • Production outputs: the choices the outputs make consistently describe what production does, which isn’t necessarily correct.
  • Escalations and corrections: a complaint or a “don’t do this again” note can outrank a pattern seen in hundreds of rows.
Write down what’s acceptable as well as what isn’t, so the evaluation doesn’t punish it. For the support tool, paraphrasing the ticket is fine, and so is leaving out background the engineer doesn’t need. The support tool’s system prompt holds most of its rules. Save it as prompt.py. The evaluation sends it with every ticket:
prompt.py
Two escalations add rules the prompt doesn’t cover. An engineer spent an hour looking for an order ID that a summary invented, and another summary reported a merchant’s guess about the cause as the diagnosis. Sort the rules by what can score them: Then decide with the task’s owners which failures are worst. An invented ID or fact sends an engineer the wrong way during an incident, so for this tool ids_from_ticket and grounded outrank length. Where production outputs disagree on the same trigger, keep both versions of the rule and have the owners choose one. Record these calls, because they set how you weigh the scores later. Find out where your reference outputs came from. In production, a ticket’s severity label comes from triage; in this synthetic data, it’s the severity each ticket was written to have. Either way, the severity check compares against that label. The tool has no verified reference summaries, only production outputs nobody checked, and scoring against those rewards matching production rather than being correct. So the judge scores each summary against the ticket and the rules, without a reference. If you need reference outputs, have a person verify a sample of them first.

Split the data

Hold back tickets that never enter training data or prompt examples, and score every model on the same ones. Split the data three ways:
  • Training pool: the only rows that training data comes from.
  • Dev set: rows you read while you write rules, tune judge prompts, and choose checkpoints.
  • Holdout: rows you score only to compare finished candidates. A score on rows you tuned against overstates how well the winner does.
Split by group, not by row. A group is whatever makes rows near-duplicates. For the support tool it’s the merchant account, because an account’s tickets follow up on each other and repeat the same IDs. If one account lands on both sides, the evaluation measures recall of training data instead of the task. Save the following as split.py. It assigns each account by a hash of the account name and a fixed salt, so the split doesn’t change between runs, and adding tickets later never moves an account to another split:
split.py
To split the tickets, run the script from the directory containing tickets.jsonl:
Output
The fractions apply to accounts, and accounts send different numbers of tickets, so each split’s share of tickets varies. Record the salt and the holdout’s hash with every score. Two scores are comparable only when both came from the same holdout file. Size the holdout to the smallest change you need to see. A pass rate measured on n rows is uncertain by about ±1/√n at 95% confidence: That estimate assumes every row is independent. The holdout’s 37 tickets come from 11 accounts, and one account’s tickets follow up on each other, so the real uncertainty is larger than ±1/√n. To measure it, resample whole accounts rather than single tickets. Comparing two models on the same rows is tighter than this, because only the rows where they disagree carry signal. Read a slice, such as sev1 tickets, on its own only when it has at least 30 rows. The 37-ticket holdout here is enough to run the scripts and see a large difference, not to measure a few points. Use 300 or more rows for a release decision.

Score with code first

Use code for every rule a person wouldn’t need to reason about: valid JSON, a required field, a length limit, an exact label, an ID copied from the input. Code runs locally and gives the same answer every time. Save the following as checks.py. Its check() function scores one reply against the four code rules:
checks.py
A reply that doesn’t parse fails every check, because the tool can’t show it to the engineer. Score format and task correctness separately: a reply can be valid JSON and still name the wrong severity. Structured outputs can constrain the JSON at generation time; keep the check anyway, because it also catches a reply cut off at the token limit. Report each check separately. A single combined score hides which direction a change moved: a model that rates every ticket sev3 gains on the most common severity and loses on every other one.

Judge what code can’t decide

Use a large language model (LLM) judge only for rules that need reading and reasoning, such as whether every fact in a summary is stated in the ticket. Design each judged rule as its own axis:
  • One prompt per axis. A prompt that scores several rules at once lets one obvious failure bleed into unrelated scores.
  • Pass or fail by default. A binary verdict varies less between repeated judgments than a 1 to 10 scale. If you use a scale, set the passing threshold yourself rather than asking the judge whether an output passes.
  • A boundary for each axis. State in words what passes and what fails at the edge. Judges and people disagree most on boundary cases.
  • Reasoning before the verdict. Ask for the judge’s reasoning and quoted evidence first, then the verdict, so the verdict follows from the evidence.
  • As few axes as the rules need. Two axes that never disagree on the same rows measure one thing. Each extra axis is another judge call per row.
Choose the judge model deliberately: Save the following as judge.py. It judges each axis with its own call to Kimi K3 on Model APIs, a different family from the GLM models under test. Kimi K3’s model card reports its results at a temperature of 1.0. The structured output schema puts reasoning and evidence before verdict:
judge.py
judge() retries a reply that doesn’t parse instead of dropping the row or scoring it as a fail: dropped rows skew the score toward easy cases, and a parse failure says nothing about the summary. Each axis is one judge call per summary, so two axes on the 37 holdout tickets cost 74 judge calls per model. To lower the cost of each call, set a lower reasoning_effort.

Run the evaluation

Save the following as evaluate.py. It sends each ticket to one candidate with the system prompt, runs the checks, judges each summary that parses, and writes one result per ticket to runs/:
evaluate.py
CANDIDATES gives each candidate an OpenAI-compatible base URL, a model name, and the request settings to send with it. For a model on Model APIs, the base URL is https://inference.baseten.co/v1 and the model name is its slug. Send every setting explicitly rather than relying on defaults, so every run uses the same ones. While you iterate on the rules and the judge, run on the dev set:
Output
The rates differ between runs: the models and the judge sample at temperature 1.0. Read the failing rows before you change anything. One reply isn’t valid JSON: its summary quotes an event name without escaping the quotation marks. Eleven tickets fail grounded: that reply, plus ten judged summaries, most of which share two patterns. Four state an impact the ticket never mentions, such as “No workaround available”, and four give a likely cause the merchant didn’t, such as “Likely an issue with the webhook dispatcher”. The severity policy in the prompt asks the model to weigh impact and workarounds, and the summaries report that reasoning as fact. The other two invent details, including one that attributes the merchant’s logs to another payment company. The rules are right to catch these, so they stay. A prompt that keeps the severity reasoning out of the summary is a fix to try before training. A reply that doesn’t parse fails every metric, not just valid_json, and a format failure is the request’s problem rather than the model’s. JSON mode constrains the reply to a JSON object. Add a candidate for Flash that requests it to CANDIDATES, and freeze the set before you score the holdout:
evaluate.py

Check the judge before you trust it

A judge is a measuring instrument. Measure it on dev rows before it decides anything:
  1. Three known cases. On real rows, confirm a known-good output passes, a known-bad output fails, and an empty or errored output neither crashes your parser nor scores as a pass.
  2. Stability. Judge the same outputs several times and compute agreement per axis. Report a chance-corrected statistic such as Cohen’s kappa. On an axis where 95% of outputs pass, a judge that always says pass reaches 95% raw agreement and a kappa near 0.
  3. Planted violations. Take outputs the judge passed and add one violation the rules forbid. A judge that misses planted violations is too lenient to gate a release or filter training data.
  4. Agreement with people. Have a person label a sample of dev outputs on each axis, then compare the judge’s verdicts with theirs using kappa. Where they disagree, read the row: either the rule or the label needs fixing.
Save the following as check_judge.py. It judges each dev summary three times per axis, then changes a number in each summary that passed grounded and judges it again:
check_judge.py
To check the judge against the dev run, run the following command:
Output
The first version of the grounded boundary didn’t say whether a claim like “no workaround” or a hedged cause like “likely an issue with the webhook dispatcher” fails. In two checks on the dev set, its verdicts were identical across three replays on only 72% and 81% of summaries, with a kappa between the first two replays of 0.14 and 0.45. Most of the summaries whose verdicts changed between replays were those cases. Naming both cases in the fail text produced this output. A change to a rule makes a new version of the evaluation, so score every candidate again after one. coverage passed every summary, so its kappa is undefined and it can’t show a difference between models on these tickets. An axis that nothing fails costs judge calls without measuring anything: harden it with harder rows, or drop it. The planted violations show that grounded catches a changed number. Plant violations of each rule you rely on, and have a person label a sample of dev summaries before the judge gates a release or filters training data.

Score a baseline

Before you train, score the models you can already use: the model that serves the task today, cheaper candidates, and the strongest model you can call. Score them on the holdout with the same prompt each time:
Save the following as compare.py. It prints each metric for two runs on the same split, with the tickets the candidate won and lost against the baseline:
compare.py
To compare GLM 5.3 Flash with GLM 5.3, run the following command:
Output
Start with the rows, not the ranking. All 13 tickets Flash lost on valid_json are valid JSON wrapped in a Markdown code fence, which the tool can’t parse, so those tickets fail every other metric too. The JSON-mode candidate you added on dev targets exactly that failure. To score it on the holdout and compare it with GLM 5.3, run the following commands:
Output
JSON mode closes the format gap. To tell a real difference from noise, look at wins and losses, because only the tickets where the two models disagree carry information. As a rule of thumb, a difference stands out from noise when wins minus losses is more than twice the square root of wins plus losses. The threshold assumes independent rows, so with account-grouped tickets it screens for a difference rather than certifying one. Flash’s 2 severity wins with no losses are within noise. On grounded, it wins 3 tickets and loses 10, a gap of 7 against a threshold of about 7.2. Its losses repeat the dev set’s patterns, such as “Likely a dashboard display bug”, so Flash may state more unsupported causes, but 37 tickets can’t confirm it. Score a larger holdout and try the prompt fix before you decide. If the gap holds, it’s the gap a trained model has to close. Read the spread as well as the ranking. If clearly different models all land within the judge’s run-to-run variation, the evaluation is saturated: it can’t show a training gain, so make the task harder or the rules sharper first. If a smaller model beats a larger one, read the rows where it won before you decide whether the evaluation or your expectation is wrong. If an existing model already meets your bar, skip post-training. For that decision, see Decide whether to post-train.

Compare a checkpoint with its baseline

Deploy the checkpoint, then add it to CANDIDATES with its deployment URL and the settings you’ll serve it with. For a deployed Loops checkpoint, the model name is the checkpoint name, such as step-124:
evaluate.py
To score the checkpoint and compare it with the model it would replace, run the following commands:
The comparison uses the same columns as the baseline comparison. The deployment bills for its GPU while it’s live; when you’re done, delete it. Scoring through the deployment measures what you’d serve, including its chat template. To see what training added, also score the untrained base model with the same prompt and settings. The gain over the base model is what training added; the comparison with GLM 5.3 tells you whether to ship. Apply the same wins-and-losses test to the checkpoint, and read its losses row by row: a checkpoint can gain overall and still regress on the rows that matter most. Break each metric down by the slices that matter before you decide, such as sev1 tickets for the support tool; each run file holds one row per ticket, so a slice is a filter over runs/*.jsonl. The supervised fine-tuning (SFT) example shows why: its adapter’s overall tool-call decision score rose from 88.0% to 99.0%, and on the 30 requests no offered tool could serve it rose from 73.3% to 100%, because the base model called an unrelated tool and the adapter answered directly. Release thresholds are a product decision. Set them from what the task needs, such as “grounded and ids_from_ticket don’t drop, and severity is right on at least 95% of sev1 tickets”, not from the baseline’s numbers. Keep the holdout, the checks, the judge prompts, and the judge model fixed across candidates; changing any of them makes earlier scores incomparable.

Next steps