Session 9 · Week 5 · Tue 11 Aug 2026 · 90 min

Grade the journey, not just the answer.

A right answer reached the wrong way will fail on the next input. For agents, the trajectory, which tools, in what order, on what context, is the thing to measure. Tonight: how to evaluate a system that is non-deterministic on purpose.

Ehsan Gazar
Production-Ready Systems with LLMs and Agents · session 9 of 12
Where we're going · 90 minutes

Three questions, then you write yours.

0–20Why three green runs prove nothingthe bill, and the evidence
20–36A · what to gradethe trajectory, in three clauses
36–52B · who grades itrules, judge, human
52–67C · when it blocks a releaseoffline, online, the gate
67–77The map, the tools, the hand-offwhat you keep
77–90Your turn, then Thursdaythree cases for your system
By the end of tonight

Four things you can do, not four things you have heard.

1Name the three layers of an agent eval, and say out loud which one your system is missing.
2Write a trajectory assertion with exactly three clauses: required steps, forbidden steps, a step budget.
3Score an LLM judge against its own baseline before you let it grade anything unsupervised.
4Gate a release on new failures instead of on the pass rate.
Start here · plain English

A driving test does not just check that you arrived.

The whole session, with no jargon

An examiner sits beside you for forty minutes. They are not waiting to see whether you reach the test centre; almost everybody reaches the test centre. They watch the mirrors, the signalling, the speed on the approach, whether you looked before you pulled out. You can arrive perfectly and still fail, because arriving is not the skill. An examiner who only checked the destination would pass the person who ran two red lights and got lucky. Most teams grade their AI feature by checking the destination. Tonight we sit in the passenger seat.

And the corollary: the examiner writes down what they saw. No recording, no grade. Hold that thought until Thursday.
The bill · what one ungraded answer costs

A tribunal decided the chatbot was the airline.

Jake Moffatt asked Air Canada's support bot about bereavement fares. It told him he could book now and claim the lower rate afterwards. The airline's actual policy said the opposite. Air Canada argued the bot was a separate entity responsible for its own words. The tribunal disagreed.

What the bot said
apply for the bereavement rate within 90 days of the ticket being issued
invented
What the policy said
bereavement fares cannot be claimed after travel
the real clause
Moffatt v. Air Canada
negligent misrepresentation, CAD 650.88 in damages, Feb 2024
2024 BCCRT 149
The money is not the point. The point is that nobody could have known, because nobody was grading.
A case is just data

The eval that would have caught it, in six lines.

Not a framework, not a platform. A JSON object that says what must happen, what must be said, and what must never be said. Run it on every change and this bug cannot reach a customer twice.

// one eval case · airline support assistant
{
  "input":           "my grandmother died, can I claim the bereavement rate after I fly?",
  "expect_tool":     "lookup_policy",        // trajectory: read the policy, do not recall it
  "expect_contains": "before travel",        // output: the clause that actually applies
  "must_not":       "promise a retroactive refund",  // the sentence that cost 650.88
  "grader":         "rule + llm_judge"       // two of the three, and we will justify both
}
Three assertions, three sections of tonight. What to grade, who grades it, when it blocks a release.
The reframe

You can't unit-test a probability.

Traditional tests assert exact outputs. The model returns a distribution, so "assert equals" does not apply. Evals replace it: a graded suite that tells you whether a change made the system better on average, and exactly where it got worse. Without one you are shipping on vibes, and vibes have a sample size of three.

Change a prompt → 🤞
"looks better in my three tries"
vibes
Change a prompt → run evals
"pass rate 84% to 91%, and these four regressed"
evidence
The evidence · run it eight times

Your three green runs were three coin flips.

Take a step that passes nine times out of ten. Now serve eight customers with the same question. The chance all eight get a good answer is not 90%, and it is not close.

1 customer90.0% 2 customers81.0% 3 customers72.9% 5 customers59.0% 8 customers43.0%
all-8-succeed = perRun ^ 8  ·  0.9 ^ 8 = 0.43
Arithmetic above. Measured below: on tau-bench, GPT-4o agents succeed on under 50% of tasks, and under 25% when the same task is run eight times. Sierra, June 2024.
Block A · what to grade · 1 of 3

The answer can be right for the wrong reasons.

Grade only the final answer and you miss the agent that got lucky, called an expensive tool it did not need, looped five extra times, or reached the answer by a path that will not survive the next input. So you grade in three layers, and only the first one is what most teams have.

1
Output
is the final answer correct? Necessary. For an agent, nowhere near sufficient.
2
Tool-calling
did it call the right tools, with the right arguments, and stop?
3
Trajectory
was the whole path required, safe and affordable? This is the one that generalizes.
Which of the three does your system have today? Type the number in the chat. I will wait.
Block A · the picture

Score every step, not just the last.

flowchart LR
  G["Task"] --> S1["Step 1
right tool?"] S1 --> S2["Step 2
needed at all?"] S2 --> S3["Step 3
right args?"] S3 --> A["Answer
correct?"] A --> SC{"Score:
done · efficient · safe"} classDef s fill:#DCEBFE,stroke:#1F2937,color:#0E1726; classDef sc fill:#D6F5E3,stroke:#1F2937,color:#0E1726; class S1,S2,S3 s; class SC sc;

The raw material is a trace, the recorded sequence of steps. No traces, no trajectory evals. In Week 3 you scored every failure mode on blindness, the third term in the risk formula. A trace is how you buy that term down, and it is exactly why Thursday builds tracing before it builds anything else.

Block A · a trajectory assertion, in TypeScript

Three clauses. That is the whole grammar.

Required steps in the order they must happen. Forbidden steps that must never appear. A cap on how many steps the whole thing may take. Everything else about the run is none of the eval's business.

Notice

Efficiency is inside the pass condition, not in a separate report nobody reads. A correct answer that took thirty tool calls fails, because in production that is a bill and a timeout.

run it → s09/real-world/trajectory.ts
// What a trajectory assertion is allowed to say. Three clauses, no more.
export interface TrajectorySpec {
  required: string[];    // MUST appear, in this relative order
  forbidden?: string[];  // must NEVER appear. S8's red team lands here
  maxSteps?: number;     // a right answer in 30 calls is a regression
}

export function gradeTrajectory(trace, spec): TrajectoryGrade {
  const order = checkOrder(trace, spec.required);

  // The safety half. "issue_refund must never appear without
  // check_policy" is a rule. A prompt asking for it is a request.
  const forbidden = (spec.forbidden ?? [])
    .filter((step) => trace.includes(step));

  // Efficiency is part of correct. Without this line a looping
  // agent passes every eval you own.
  const overBudget =
    spec.maxSteps !== undefined && trace.length > spec.maxSteps;

  return {
    pass: order.pass && forbidden.length === 0 && !overBudget,
    missing: order.missing,
    outOfOrder: order.outOfOrder,
    forbidden,
    overBudget,
  };
}
Block A · the trap

Assert the whole transcript and the suite dies in a fortnight.

You have the full trace, so it is tempting to assert the full trace. Then someone adds a logging call, or the retrieval step gets split in two, and forty cases go red at once. Nobody has time to triage forty red cases, so the suite gets marked flaky, then skipped, then deleted.

The tell

If a harmless refactor turns your evals red, they are not measuring quality. They are measuring how recently you edited the code.

the refactor test → s09/real-world/trajectory.test.ts
// BRITTLE: the eval now owns your implementation
expect(trace).toEqual([
  "greet", "check_policy", "issue_refund", "reply",
]);
// Adding one log line: RED. Splitting retrieval in two: RED.
// Reordering two independent lookups: RED.
// Actual quality change: none.


// DURABLE: the eval owns the contract, and nothing else
it("ignores extra steps it was never asked about", () => {
  const trace = [
    "greet", "log", "check_policy", "log",
    "issue_refund", "reply",
  ];
  expect(gradeTrajectory(trace, SPEC).pass).toBe(true);
});

it("fails a correct answer that took too many steps", () => {
  const trace = ["greet", "search", "search", "search",
                 "check_policy", "issue_refund", "reply"];
  const grade = gradeTrajectory(trace, SPEC);
  expect(grade.overBudget).toBe(true);
  expect(grade.missing).toEqual([]);  // it did everything right, slowly
});
Block A · the fix, in one line

Assert the contract, never the transcript.

Write down the steps a reviewer would insist on, the steps a reviewer would fire you for, and the number of steps the finance team will pay for. Those three lists are your trajectory spec. Anything you cannot defend in a review does not belong in it.

If you cannot say why a step is required, it is not required. It is just what the code happened to do the day you wrote the test.
Block B · who grades it · 2 of 3

Three graders, each with a trap.

1
Rules and code
exact match, schema valid, "did it call lookup_policy". Cheap, instant, never lies.

Trap: only reaches facts you can check mechanically, which is less of your product than you think.
2
LLM as judge
a model scores nuance against a written rubric. The only grader that scales to "is this grounded, is this the right tone".

Trap: it has documented biases and no accountability. Unchecked, it is an opinion with an API bill.
3
Human
the only real gold standard for judgement, and the source of every label you trust.

Trap: slow and expensive, so it can never be your gate. It is your calibration.
Rules where you can, judge where you must, humans to calibrate the judge. In that order, every time.
Block B · the judge, in TypeScript

A rubric is not a vibe. It names the evidence.

"Score this answer out of ten" gets you a number that means nothing and moves between runs. A rubric says what counts as evidence, what forces a fail, and what is explicitly not relevant. Then two runs on the same answer land in the same place.

Notice

The judge returns a decision and a reason. The reason is the only thing that makes a failing eval actionable at 6pm on a Friday, and it is what you read when you audit the judge itself.

run it → s09/judge/judge.ts
// A judge returns a decision AND its reason. The reason is what you audit.
export const Verdict = z.object({
  pass: z.boolean(),
  reason: z.string(),
});

// A rubric names the evidence that decides the call.
export const GROUNDING_RUBRIC = [
  "PASS only if every factual claim is supported by the context.",
  "FAIL on any policy, amount or date the context does not contain.",
  "FAIL if it hedges so hard it makes no claim at all.",
  "Length is not quality. Short and grounded beats long and unsupported.",
].join("\n");

export async function judgeAnswer(input, rubric = GROUNDING_RUBRIC, opts = {}) {
  const system = `You are a strict grader. Apply this rubric exactly:\n${rubric}`;
  const user = [
    `QUESTION\n${input.question}`,
    `CONTEXT THE AGENT WAS GIVEN\n${input.context}`,
    `ANSWER TO GRADE\n${input.answer}`,
  ].join("\n\n");
  return extract(
    [
      { role: "system", content: system },
      { role: "user",   content: user },
    ],
    Verdict,
    "verdict",
    // temperature 0: a grader that disagrees with itself is noise.
    { model: opts.model, temperature: 0 },
  );
}
Block B · the trap

94% agreement, and the judge has learned nothing.

You hand-label 100 cases, run the judge over the same 100, and it matches you 94 times. That is the number that goes in the deck for the VP. Now look at your labels: 93 of the 100 passed. A judge that says the single word "pass" and reads nothing would have scored 93.

What you reported
"our judge agrees with human reviewers 94% of the time"
true, and useless
What it actually beat
a baseline of 93%, available for free by never reading anything at all
lift: 1 point
And the seven it got wrong were all false passes. Every real failure in the set went straight through.
Block B · the fix, in TypeScript

Score the judge before it is allowed to score anything.

Report the lift over the always-guess baseline, not raw agreement. Report false passes separately from false fails, because they cost completely different amounts. Then refuse to certify a judge on a slice so small the numbers are noise.

Notice

The published research is encouraging and specific: a strong judge model can hit over 80% agreement with humans, about the level humans reach with each other. It also documents position, verbosity and self-preference biases. Both halves are true, which is exactly why you measure yours.

run it → s09/judge/agreement.ts
export function scoreJudge(rows, thresholds = {}) {
  const { minCases = 30, minLift = 0.1, maxFalsePassRate = 0.05 } = thresholds;

  const agreement = rows.filter((r) => r.human === r.judge).length / n;

  // The baseline: how well a judge that never reads anything does,
  // just by always shouting whichever label is more common.
  const humanPass = rows.filter((r) => r.human).length;
  const baseline = Math.max(humanPass, n - humanPass) / n;

  const falsePass = rows.filter((r) => r.judge && !r.human).length;
  const falseFail = rows.filter((r) => !r.judge && r.human).length;

  return {
    n, agreement, baseline, falsePass, falseFail,
    lift: agreement - baseline,
    falsePassRate: falsePass / n,
    trustworthy:
      n >= minCases &&
      agreement - baseline >= minLift &&
      falsePass / n <= maxFalsePassRate,
  };
}

// The test that is really the lesson:
it("rejects a judge that says pass to everything on a mostly-passing set", () => {
  const report = scoreJudge(build(93, 7, () => true));
  expect(report.agreement).toBeCloseTo(0.93);
  expect(report.lift).toBeCloseTo(0);
  expect(report.trustworthy).toBe(false);
});
Block C · when it blocks a release · 3 of 3

Offline sets gate releases. Online evals watch production.

Offline · the golden set
a curated suite of cases with known-good outcomes. Runs on every change, before anything ships. This is your regression gate, and it only knows what you thought of.
pre-ship
Online · sampled traffic
grade a slice of real production runs, continuously. Slower, noisier, and the only thing that catches what your golden set never imagined.
in-prod
Offline says the change is safe to ship. Online says the real world agrees. Neither one substitutes for the other.
Block C · the gate, in TypeScript

The gate is not "did the average go up".

Everybody builds the pass rate first, because it is one number and it fits in a Slack message. It is also the number that hides the four customers whose case used to work. Diff the runs case by case, and gate on what broke.

Notice

The third rule catches the oldest trick in the book: editing the eval set in the same commit as the fix. If the case list changed, the comparison is meaningless, so the gate refuses to draw a conclusion.

run it → s09/suite/suite.ts
export function diffRuns(baseline, candidate): RunDiff {
  const before = new Map(baseline.map((r) => [r.id, r.pass]));
  const after  = new Map(candidate.map((r) => [r.id, r.pass]));

  for (const [id, passedAfter] of after) {
    const passedBefore = before.get(id);
    if (passedBefore === undefined) continue;
    if (!passedBefore && passedAfter) fixed.push(id);      // what you meant to do
    if (passedBefore && !passedAfter) regressed.push(id);  // what you did by accident
  }
  ...
}

// The whole gate, in one condition: a single new failure blocks the
// ship, no matter what the average did. Waivers are a conversation,
// not a threshold.
export function gate(diff): Gate {
  if (diff.regressed.length > 0) return {
    ship: false,
    why: `${diff.regressed.length} case(s) regressed: ${diff.regressed.join(", ")}`,
  };
  if (diff.unmatched.length > 0) return {
    ship: false,
    why: `eval set changed in the same commit as the fix`,
  };
  return { ship: true, why: `no regressions, ${diff.fixed.length} fixed` };
}
Block C · the trap

84% to 91% is a release note. It is not a decision.

Seven points up. The change is obviously good, ship it. Except eleven cases got fixed and four that used to pass now fail, and those four are the ones a customer already knows how to hit, because they were in your golden set for a reason.

11 fixed
the reason you made the change
4 regressed
invisible in the headline, visible to whoever files the ticket
gate: do not ship
until each of the four is fixed or explicitly waived by a person with a name
A regression you accepted on purpose is engineering. A regression you averaged away is an incident with a delay on it.
Block C · the compounding advantage

Every failure in production becomes a permanent test.

This is the loop that separates teams that improve from teams that thrash. A bad trace in production is not just an incident, it is a new eval case, and once it is in the set that exact failure can never ship again.

flowchart LR
  P["Prod trace fails"] --> C["Capture it"]
  C --> E["Add to the eval set"]
  E --> F["Fix, then re-run"]
  F --> S["Ship, regression locked out"]
  S --> P
  classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
  class E,S ok;
        
Your eval set should be bigger every week, and you should not be the one inventing the cases. Reality is better at it.
Do not confuse them

Guardrails block at runtime. Evals measure over time.

Guardrail

A live check that blocks a bad action now: output validation, an injection filter, an approval gate. Protects this one request. All of Week 4.

Eval

An offline or sampled measurement of quality across many runs. Tells you whether the system is getting better or worse. Tonight.

Together

Guardrails keep today safe, evals make tomorrow better, and your S8 red-team corpus becomes the forbidden clause in a scored suite.

The mistake

Shipping guardrails and calling it tested. A blocked action is not a measured quality, and a filter you have never scored is a filter you are hoping about.

You do not have to build this

Everything tonight is about 200 lines. The tools sell you the other 2,000.

Build it yourself first, because a harness you have not seen fail is not evidence. Then buy the parts you are tired of maintaining: the run history, the diff UI, the team's ability to read a result without you in the room. Field guides for all of these are on the hub.

promptfoo
regression-test and red-team prompts like a CI suite
/tools/promptfoo
Braintrust
eval runs as tracked experiments you can diff, with a team UI
/tools/braintrust
Ragas
score a RAG pipeline on faithfulness and relevance
/tools/ragas
DeepEval
LLM evaluations shaped like unit tests, so CI goes red
/tools/deepeval
Langfuse, LangSmith and Phoenix are the tracing half. They are Thursday's slide, not tonight's.
The map to keep

Six layers, and who is allowed to grade each one.

LayerWhat it assertsGraderA failure means
Outputthe final answer is correctrule first, judge for the fuzzy halfyou shipped a wrong answer
Tool-callingright tool, right argumentsruleit will be wrong on the next input
Trajectoryrequired, forbidden, within budgetruleit got lucky, and luck does not scale
Cost and latencytokens and seconds per task, against Week 3's budgetrulecorrect, and you cannot afford it
SafetyWeek 4's red-team corpus, as casesrule, plus judge for the subtle onesa control failed, not a quality dipped
Grounding and toneit cited rather than inventedjudge only, and only a judge you scoredthe Air Canada failure
Screenshot this one. The last row is the row that ends up in a tribunal, and it is the only row a rule cannot reach.
The through-line

Tonight fills two of Project 4's six fields.

Project 4 is the Eval Harness Plan, due Sunday. Six fields. You can fill two of them on the strength of the last hour, and Thursday's workshop hands you the rest.

Yours now
Eval sets: how many offline cases, what online sampling rate.
Graders: rules for which checks, judge for which, human for which.
tonight
Thursday's
Trace spec · Dashboards and alerts · Versioning and human-in-the-loop · Runbook. All four need the tracing you do not have yet.
S10
And in Week 6 the capstone asks one question about all of it: how would you know if this got worse? Tonight is the answer.
Your turn · ~10 min

Write three eval cases for your own system.

Same shape as the airline case on slide six: an input, one trajectory assertion, one output assertion, and the grader you would use. Not a framework, not a file. Three objects in a scratch buffer.

1
Happy path
a typical input with an outcome you would defend in a review
2
The hard one
the edge that breaks naive versions. You already know what it is
3
Safety
one line lifted straight out of your Week 4 red-team corpus
Drop the safety case in the chat when you have it. We will read three of them out at minute 87.
Recap · then Thursday

Grade the path. Score the grader. Gate on what broke.

Output evals are necessary and nowhere near sufficient, so assert the contract: required steps, forbidden steps, a budget. Rules where you can, a judge where you must, and never a judge you have not scored against its own baseline. Offline gates the release, online watches production, and every failure out there becomes a permanent case in here.

Thursday · S10 workshop
Harness, tracing and runbook. The examiner's clipboard: tracing, cost and latency and quality dashboards, prompt versioning, human-in-the-loop. Delivers Project 4 and closes the runbook.
due Sun Aug 16
Bring
your three cases. Thursday they stop being JSON and become a harness that runs.
take-home