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
- A workspace API key.
- uv and Python 3.12 or later.
- Access to GLM 5.3 and Kimi K3 on Model APIs.
BASETEN_API_KEY:
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.
prompt.py. The evaluation sends it with every ticket:
prompt.py
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.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
tickets.jsonl:
Output
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 aschecks.py. Its check() function scores one reply against the four code rules:
checks.py
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.
- A different model family from the one under test: judges tend to favor their own outputs, and to favor models trained on data from their own model or family.
- A reasoning model for correctness: an axis like
groundedneeds the judge to check each claim against the ticket. - Sampling settings from its model card: send them explicitly, and measure how much the verdicts vary at those settings.
- Cost at your data size: forecast the judge calls so you know what each evaluation costs to run.
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 asevaluate.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
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:- 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.
- 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.
- 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.
- 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.
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
Output
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: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
Output
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
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 toCANDIDATES 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
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
- Decide whether to post-train: Compare what training would save with what it costs.
- Prepare SFT data: Use the same rules to choose training examples.
- Check whether an RL run is learning: Read reward and rollout metrics during training.
- Deploy a checkpoint: Serve the checkpoint you evaluate.