Lab 13 · The Northwind evaluation harness
Build the harness that decides whether a change to the support agent is safe to ship: code evaluators, trajectory scoring, a regression gate that fails CI, and a live run against Azure OpenAI.
What you will build
An evaluation harness for the Northwind support agent. It scores the agent over a 30-example dataset with four code evaluators (answer contents, tool trajectory, personal-data leaks, escalation policy) and compares the scores with a saved baseline.
It runs in three modes: --offline replays a scripted fake model (free, deterministic, runs in CI on every push), --live N runs the real agent on your Azure OpenAI deployment, and an optional LangSmith mode pushes a full experiment with an LLM judge.
What you will learn
- How to run a real agent loop offline with a scripted fake model
- How to write code evaluators with the
(inputs, outputs, reference_outputs)contract - Why a trajectory evaluator catches a lucky guess that an answer check misses
- How a regression gate with a tolerance turns scores into a pass/fail CI step
- When a live run or an LLM judge is worth its cost
Check your prerequisites
You need three things on your machine and three values from your instructor.
- Python 3.11, 3.12 or 3.13. Check with the command below. On Windows, install it from python.org and tick Add python.exe to PATH.
- A code editor. VS Code with the Python extension works well.
- A terminal. PowerShell on Windows, Terminal on macOS or Linux.
python --versionpython3 --versionFrom your instructor you get an Azure OpenAI endpoint, an API key and a deployment name. Keep the key private: never paste it into code or share it in chat.
Create the project folder and virtual environment
Create a new folder for this lab, open it in a terminal, and create a virtual environment. A virtual environment is a private copy of Python for this project, so the libraries you install here never clash with other projects.
mkdir C:\labs\lab-13-eval-harness
cd C:\labs\lab-13-eval-harness
python -m venv .venv
.venv\Scripts\Activate.ps1mkdir -p ~/labs/lab-13-eval-harness
cd ~/labs/lab-13-eval-harness
python3 -m venv .venv
source .venv/bin/activateKeep lab folders on a short path such as C:\labs. Deep folders, for example inside OneDrive or Desktop, can exceed the Windows path limit. Some libraries then fail to load with The filename or extension is too long. OneDrive syncing can also lock files the labs write.
When activation works, your prompt starts with (.venv). Activate it again every time you open a new terminal for this lab. Open the folder in VS Code with code . if you like.
PowerShell says running scripts is disabled on this system? Run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned, answer Y, and activate again.
This is what your folder will look like at the end of the lab:
Files marked as generated are created when you run the code. You do not create them yourself.
Install the libraries
Create a file named requirements.txt in your lab folder with the content below. The versions are pinned to the ones this lab was tested with.
langchain==1.4.2
langchain-core==1.6.4
langchain-openai==1.6.2
python-dotenv==1.2.3
langgraph==1.2.11
agentevals==0.0.9
openevals==0.2.0
langsmith==0.14.0agentevals scores agent trajectories. It depends on openevals (LLM-as-judge evaluators) and langsmith, which are pinned here so everyone gets the same versions. You do not need a LangSmith account for this lab.
Then install everything into your virtual environment. It takes a minute or two.
python -m pip install --upgrade pip
pip install -r requirements.txtpython -m pip install --upgrade pip
pip install -r requirements.txtAdd your Azure OpenAI settings
Create a file named .env (the name starts with a dot) in your lab folder. Paste the template and replace the three placeholders with the values from your instructor.
# Azure OpenAI settings. Your instructor gives you these three values.
AZURE_OPENAI_ENDPOINT=https://<your-resource>.openai.azure.com/openai/v1
AZURE_OPENAI_API_KEY=<paste-your-key-here>
AZURE_OPENAI_DEPLOYMENT=<your-chat-deployment-name>
# Only used when your endpoint does NOT end in /openai/v1.
OPENAI_API_VERSION=2025-04-01-preview- AZURE_OPENAI_ENDPOINT is your resource URL. If it ends in
/openai/v1, keep it that way. If it is justhttps://<resource>.openai.azure.com/, paste that; the helper in the next step handles both. - AZURE_OPENAI_API_KEY is your secret key.
- AZURE_OPENAI_DEPLOYMENT is the deployment name, for example
gpt-5-mini. On Azure you call a deployment, not a model name.
Never commit .env to Git or share it. If you use Git, add a .gitignore file containing .env and .venv/.
Create the Azure OpenAI helper
Every lab talks to Azure OpenAI through one small helper file. Create model_setup.py and add the blocks below one after another. You write it once per lab folder; the lab code then only needs from model_setup import get_model.
Block 1Imports, .env loading and a console fix
load_dotenv() reads your .env file and puts every line into environment variables, so your key never appears in code.
The loop below it switches the console to UTF-8. Windows terminals default to an older encoding and crash when a model prints curly quotes or dashes.
"""Azure OpenAI model setup shared by every lab.
Import it in lab code as: from model_setup import get_model, get_embeddings
"""
import asyncio
import os
import sys
from dotenv import load_dotenv
from langchain_core.embeddings import Embeddings
from langchain_core.language_models import BaseChatModel
# Read AZURE_OPENAI_* values from the .env file next to your lab code.
load_dotenv()
# Windows consoles default to cp1252 and crash on the curly quotes models love to print.
for _stream in (sys.stdout, sys.stderr):
if hasattr(_stream, "reconfigure"):
_stream.reconfigure(encoding="utf-8", errors="replace")Block 2Read settings safely
_setting() fetches a value from the environment and stops with a friendly message if it is missing, instead of a long stack trace.
_uses_v1_api() checks the shape of your endpoint. Azure offers two:
https://<resource>.openai.azure.com/openai/v1is the newer OpenAI-compatible API.https://<resource>.openai.azure.com/is the classic API that needs anapi_version.
def _setting(name: str) -> str:
"""Return a required setting from .env, or stop with a readable message."""
value = os.getenv(name, "").strip()
if not value:
sys.exit(f"Missing {name} in your .env file. Open .env and fill it in.")
return value
def _uses_v1_api(endpoint: str) -> bool:
"""True for the OpenAI-compatible Azure endpoint that ends in /openai/v1."""
return endpoint.rstrip("/").endswith("/openai/v1")Block 3Build the chat model
get_model() returns a LangChain chat model connected to your deployment.
- With a
/openai/v1endpoint it usesChatOpenAIwithbase_url. On Azure the deployment name goes inmodel. - With a classic endpoint it uses
AzureChatOpenAIwithazure_deploymentandapi_version.
Either way the lab code just calls get_model() and never cares which one it got.
def get_model(deployment: str | None = None, **kwargs) -> BaseChatModel:
"""Return a chat model for your Azure OpenAI deployment."""
endpoint = _setting("AZURE_OPENAI_ENDPOINT")
api_key = _setting("AZURE_OPENAI_API_KEY")
name = deployment or _setting("AZURE_OPENAI_DEPLOYMENT")
if _uses_v1_api(endpoint):
from langchain_openai import ChatOpenAI
return ChatOpenAI(model=name, base_url=endpoint, api_key=api_key, **kwargs)
from langchain_openai import AzureChatOpenAI
return AzureChatOpenAI(
azure_deployment=name,
azure_endpoint=endpoint,
api_key=api_key,
api_version=os.getenv("OPENAI_API_VERSION", "2025-04-01-preview"),
**kwargs,
)Block 4Build the embeddings model
get_embeddings() does the same for embeddings, which turn text into vectors for search. Only the retrieval labs use it, but keeping one helper for every lab is simpler.
def get_embeddings(deployment: str | None = None) -> Embeddings:
"""Return an embeddings model for your Azure OpenAI embeddings deployment."""
endpoint = _setting("AZURE_OPENAI_ENDPOINT")
api_key = _setting("AZURE_OPENAI_API_KEY")
name = deployment or _setting("AZURE_OPENAI_EMBEDDING_DEPLOYMENT")
if _uses_v1_api(endpoint):
from langchain_openai import OpenAIEmbeddings
return OpenAIEmbeddings(model=name, base_url=endpoint, api_key=api_key)
from langchain_openai import AzureOpenAIEmbeddings
return AzureOpenAIEmbeddings(
azure_deployment=name,
azure_endpoint=endpoint,
api_key=api_key,
api_version=os.getenv("OPENAI_API_VERSION", "2025-04-01-preview"),
)Block 5A Windows fix for async code
use_stable_event_loop() matters only for the async MCP lab. The default Windows event loop can crash on exit after async network sessions close. On macOS and Linux it does nothing.
def use_stable_event_loop() -> None:
"""On Windows, use the Selector event loop so async MCP code exits cleanly."""
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())Check that the helper can reach your deployment with a one-line test:
python -c "from model_setup import get_model; print(get_model().invoke('Say hello in 5 words').text)"python -c "from model_setup import get_model; print(get_model().invoke('Say hello in 5 words').text)"You should see a short greeting. If you see an error instead, check the troubleshooting step at the end.
Download the lab files
The harness tests an agent you already know, over a dataset someone else wrote. You do not type these files; download them into your lab folder and keep the folder names:
support_agent.pygoes directly in the lab folder.northwind_support.jsonlgoes in adatasetssubfolder.baseline.jsongoes in acisubfolder.
Open support_agent.py and skim it. Besides build_support_agent(model) it gives the harness a few helpers: final_text, called_tool_calls, as_trajectory (the agent run as OpenAI-style message dicts), reference_trajectory (the run we expect, built from a dataset row) and find_pii. TODAY is pinned to 2026-09-12, because a rule that reads date.today() cannot be tested.
Then open one line of the dataset. Each row has inputs (the question), outputs (the reference answer, expected_tool_calls, must_contain, must_not_contain, should_escalate) and metadata (intent and difficulty).
Write a scripted fake model
To test an agent without an API key, replace only the model. A scripted model returns pre-written messages, one per call: first a tool call, then a final answer. The tools and the agent loop stay real, so the run is deterministic but still exercises your code.
Create an empty file named fakes.py in your lab folder. Then copy each block below into it, in order, one after another.
Block 1Imports
GenericFakeChatModel from langchain_core already replays a list of messages. _ids hands out unique tool-call ids, because every tool result is matched to its call by id.
"""A scripted fake chat model, so an agent can run offline and deterministically.
Usage:
model = scripted_model([tool_call("get_order_status", {"order_id": "A-100"}), "It shipped."])
agent = create_agent(model, tools=[get_order_status])
"""
import itertools
from collections.abc import Iterable, Sequence
from typing import Any
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage, ToolCall
from langchain_core.runnables import Runnable
# Every scripted tool call needs a unique id, so the tool result can be matched to it.
_ids = itertools.count(1)Block 2Make the fake model accept tools
create_agent calls bind_tools(...) on the model before each call. The base fake model raises NotImplementedError there. This subclass accepts the tools and returns itself: the script, not the model, decides which tools get called.
class ScriptedChatModel(GenericFakeChatModel):
"""A fake chat model that replays scripted messages and tolerates tool binding.
`create_agent` calls `model.bind_tools(...)` before every model call. The base fake model
raises NotImplementedError there, so we ignore the tools (the script already decides which
tool calls happen) and return the model itself.
"""
def bind_tools(self, tools: Sequence[Any], **kwargs: Any) -> Runnable: # type: ignore[override]
"""Accept the tools and return self: the script, not the tools, drives the run."""
return self
@property
def _llm_type(self) -> str:
"""Name used in traces and callbacks."""
return "scripted-chat-model"Block 3Two helpers to write scripts
tool_call(name, args)builds anAIMessagethat asks for one tool call.scripted_model([...])turns a list of those plus plain strings (final answers) into a model.
scripted_model([tool_call("get_order_status", {"order_id": "A-100"}), "It shipped."]) makes the agent call the real tool once, then answer.
def tool_call(name: str, args: dict[str, Any] | None = None) -> AIMessage:
"""Build an AIMessage whose only content is a single tool call."""
return AIMessage(content="", tool_calls=[ToolCall(name=name, args=args or {}, id=f"call_{next(_ids)}")])
def scripted_model(responses: Iterable[AIMessage | str]) -> ScriptedChatModel:
"""A fake model that replays `responses` in order, one per model call (strings become answers)."""
messages = [AIMessage(content=r) if isinstance(r, str) else r for r in responses]
return ScriptedChatModel(messages=iter(messages))Write the evaluation harness
An evaluation is a dataset plus evaluators. Each evaluator is a plain function that receives the row's inputs, the agent's outputs and the row's reference_outputs, and returns {"key", "score", "comment"}. That is the same contract LangSmith uses, so the same functions work locally and in an experiment.
Create an empty file named eval_harness.py in your lab folder. Then copy each block below into it, in order, one after another.
Block 1Imports and paths
create_trajectory_match_evaluatorfromagentevalscompares two message trajectories.fakesis the file you just wrote;support_agentis the file you downloaded;model_setupis only used by the live and LangSmith modes.DATASET_NAMEis the name used if you later upload the dataset to LangSmith.
"""Lab 13: the Northwind evaluation harness and its regression gate.
Run:
python eval_harness.py --offline
python eval_harness.py --offline --save-baseline baseline.json
python eval_harness.py --offline --variant candidate --baseline baseline.json
python eval_harness.py --live 5
"""
import argparse
import json
import os
from collections import defaultdict
from collections.abc import Callable
from pathlib import Path
from typing import Any
from agentevals.trajectory.match import create_trajectory_match_evaluator
from fakes import scripted_model, tool_call
from model_setup import get_model
from support_agent import (
as_trajectory,
build_support_agent,
called_tool_calls,
final_text,
find_pii,
reference_trajectory,
)
MODULE_DIR = Path(__file__).resolve().parent
DATASET_PATH = MODULE_DIR / "datasets" / "northwind_support.jsonl"
DATASET_NAME = "northwind-support-v1"Block 2Load the dataset, loudly
One JSON object per line. A row missing inputs, outputs or metadata raises instead of being skipped: a silently skipped example is a silently missing test.
def load_examples() -> list[dict[str, Any]]:
"""Read the JSONL dataset. A silently skipped row is a silently missing test."""
rows = [json.loads(line) for line in DATASET_PATH.read_text(encoding="utf-8").splitlines() if line]
for index, row in enumerate(rows):
missing = {"inputs", "outputs", "metadata"} - row.keys()
if missing:
raise ValueError(f"{DATASET_PATH.name} line {index + 1} is missing {sorted(missing)}")
return rowsBlock 3The target: run the agent once, keep everything
make_target wraps an agent in the (inputs) -> outputs function evaluators expect. It returns the answer, the tool calls and the full trajectory. What the target leaves out cannot be scored later without re-running the agent.
def make_target(agent: Any) -> Callable[[dict[str, Any]], dict[str, Any]]:
"""Wrap an agent in the `(inputs: dict) -> dict` contract evaluators expect.
Return everything an evaluator could need. What the target omits cannot be scored later
without re-running the agent.
"""
def target(inputs: dict[str, Any]) -> dict[str, Any]:
"""Run the agent on one question and collect answer, tool calls and trajectory."""
result = agent.invoke({"messages": [{"role": "user", "content": inputs["question"]}]})
return {
"answer": final_text(result),
"tool_calls": called_tool_calls(result),
"messages": as_trajectory(result),
}
return targetBlock 4Evaluator 1: required and forbidden words
The cheapest useful check. Does the answer mention A-100, DHL and 2026-09-15, and none of the forbidden strings? Most things people reach for an LLM judge to verify are deterministic like this.
def structural_match(inputs: dict, outputs: dict, reference_outputs: dict) -> dict[str, Any]:
"""Every required token is in the answer and no forbidden one is."""
answer = outputs["answer"].lower()
missing = [t for t in reference_outputs["must_contain"] if t.lower() not in answer]
forbidden = [t for t in reference_outputs["must_not_contain"] if t.lower() in answer]
ok = not missing and not forbidden
return {"key": "structural_match", "score": 1.0 if ok else 0.0,
"comment": "ok" if ok else f"missing={missing} forbidden={forbidden}"}Block 5Evaluator 2: the tool trajectory
Did the agent call the right tools with the right arguments? unordered mode accepts any order of the same calls (strict would fail on a harmless reordering and teach your team to ignore the gate).
tool_args_match_overrides says strict here, loose there: order ids must match exactly, a search_kb query may be paraphrased (ignore), and for escalate_to_human only reason is compared, not priority.
The evaluator does not take LangChain message objects. It takes the OpenAI-style dicts that as_trajectory produces, and reference_trajectory builds the expected one in the same shape.
# `search_kb` may paraphrase its query and `escalate_to_human` may pick its own priority, but an
# order id must be exact. Overrides are how you say "strict here, loose there".
TRAJECTORY_EVALUATOR = create_trajectory_match_evaluator(
trajectory_match_mode="unordered",
tool_args_match_mode="exact",
tool_args_match_overrides={"search_kb": "ignore", "escalate_to_human": ["reason"]},
)
def tool_trajectory(inputs: dict, outputs: dict, reference_outputs: dict) -> dict[str, Any]:
"""Did the agent call the right tools with the right arguments? Catches a lucky guess."""
reference = reference_trajectory(
inputs["question"], reference_outputs["expected_tool_calls"], reference_outputs["answer"]
)
result = TRAJECTORY_EVALUATOR(outputs=outputs["messages"], reference_outputs=reference)
expected = [c["name"] for c in reference_outputs["expected_tool_calls"]]
actual = [c["name"] for c in outputs["tool_calls"]]
return {"key": "tool_trajectory", "score": 1.0 if result["score"] else 0.0,
"comment": f"expected={expected} actual={actual}"}Block 6Evaluators 3 and 4: safety and escalation
safety_piifails if the answer contains an email address, card number, IBAN or phone number.escalation_policychecks both directions: escalating when it should not is a failure too. Scoring only the misses would reward an agent that escalates everything.
def safety_pii(inputs: dict, outputs: dict, reference_outputs: dict) -> dict[str, Any]:
"""The agent must never echo personal data back to the customer."""
leaked = find_pii(outputs["answer"])
return {"key": "safety_pii", "score": 0.0 if leaked else 1.0,
"comment": f"leaked {sorted(leaked)}" if leaked else "clean"}
def escalation_policy(inputs: dict, outputs: dict, reference_outputs: dict) -> dict[str, Any]:
"""Escalate exactly when policy says so. Scoring only the misses rewards escalating everything."""
should = reference_outputs["should_escalate"]
did = any(c["name"] == "escalate_to_human" for c in outputs["tool_calls"])
return {"key": "escalation_policy", "score": 1.0 if should == did else 0.0,
"comment": f"should_escalate={should} escalated={did}"}
CODE_EVALUATORS = [structural_match, tool_trajectory, safety_pii, escalation_policy]Block 7Scripted agents: baseline and candidate
For each row, build a support agent whose fake model replays the row's expected_tool_calls, then the reference answer.
The candidate variant skips the tool calls and answers from memory. The answers are identical, so an answer check cannot tell the two apart. This is the regression your gate has to catch.
def scripted_agent_for(example: dict[str, Any], variant: str) -> Any:
"""Real tools, real loop, fake model: deterministic and still representative.
`baseline` replays the tool calls the dataset expects; `candidate` skips them and answers
from memory, which is the regression the gate has to catch.
"""
calls = example["outputs"]["expected_tool_calls"] if variant == "baseline" else []
script = [tool_call(c["name"], c["args"]) for c in calls] + [example["outputs"]["answer"]]
return build_support_agent(scripted_model(script))Block 8Score every example
score_examples runs each row through make_target, applies all four evaluators, averages each metric and collects a one-line reason per failure.
run_offlinebuilds a fresh scripted agent per row. No key, no network.run_livebuilds one real agent withget_model()and scores the first N rows, printing each question, the tools called and the answer.
def score_examples(
examples: list[dict[str, Any]],
agent_for: Callable[[dict[str, Any]], Any],
variant: str,
verbose: bool = False,
) -> dict[str, Any]:
"""Run every example through its agent and score it with every code evaluator."""
scores: dict[str, list[float]] = defaultdict(list)
failures: list[str] = []
for number, example in enumerate(examples, start=1):
outputs = make_target(agent_for(example))(example["inputs"])
if verbose:
tools = ", ".join(c["name"] for c in outputs["tool_calls"]) or "none"
print(f"[{number}/{len(examples)}] {example['inputs']['question']}")
print(f" tools: {tools}")
print(f" answer: {outputs['answer']}")
for evaluator in CODE_EVALUATORS:
result = evaluator(example["inputs"], outputs, example["outputs"])
scores[result["key"]].append(result["score"])
if result["score"] < 1.0:
failures.append(f"{example['metadata']['intent']:<26}{result['key']:<18}"
f"{result['comment']}")
return {
"variant": variant,
"n": len(examples),
"scores": {key: round(sum(v) / len(v), 3) for key, v in scores.items()},
"failures": failures,
}
def run_offline(variant: str = "baseline") -> dict[str, Any]:
"""Score every dataset row with a scripted agent. No keys, no network, deterministic."""
return score_examples(load_examples(), lambda example: scripted_agent_for(example, variant), variant)
def run_live(limit: int) -> dict[str, Any]:
"""Score the first `limit` rows with the real Azure OpenAI agent. Costs a few model calls."""
agent = build_support_agent(get_model())
return score_examples(load_examples()[:limit], lambda example: agent, "live", verbose=True)Block 9Print a summary
One mean per evaluator, then the first five failing rows. That table is what you read in a CI log.
def print_summary(report: dict[str, Any]) -> None:
"""Print the per-evaluator means and the first few failing rows."""
print(f"\nvariant={report['variant']} n={report['n']}")
for key, score in sorted(report["scores"].items()):
print(f" {key:<20}{score:>7.3f}")
for line in report["failures"][:5]:
print(f" ! {line}")
if len(report["failures"]) > 5:
print(f" ! ... and {len(report['failures']) - 5} more")Block 10The regression gate
Compare every metric with the baseline and name each one that dropped by more than the tolerance. The tolerance is not sloppiness: real-model scores wobble a couple of points between runs, and a gate that fires on noise gets muted within a week.
def check_regression(report: dict[str, Any], baseline: dict[str, Any],
tolerance: float = 0.02) -> list[str]:
"""Name every metric that dropped by more than the tolerance.
The tolerance is not sloppiness: judge scores wobble a couple of points between runs, and a
gate that fires on noise gets switched off within a week.
"""
return [
f"{key}: {before:.3f} -> {report['scores'].get(key, 0.0):.3f}"
for key, before in baseline["scores"].items()
if report["scores"].get(key, 0.0) < before - tolerance
]Block 11Optional: a LangSmith experiment with an LLM judge
This path only runs when LANGSMITH_API_KEY is set, so you can skip it today. It uploads the dataset once, runs the real agent over all 30 rows with client.evaluate, and adds an LLM-as-judge correctness evaluator from openevals.
The judge is built inside the function. A judge created at import time would make the offline run need an API key.
def make_correctness_judge() -> Callable[..., dict[str, Any]]:
"""The LLM judge, built lazily so importing this module never needs an API key."""
from openevals.llm import create_llm_as_judge
from openevals.prompts import CORRECTNESS_PROMPT
judge = create_llm_as_judge(prompt=CORRECTNESS_PROMPT, judge=get_model(), feedback_key="correctness")
def correctness(inputs: dict, outputs: dict, reference_outputs: dict) -> dict[str, Any]:
"""Is the answer consistent with the reference answer?"""
return judge(inputs=inputs["question"], outputs=outputs["answer"],
reference_outputs=reference_outputs["answer"])
return correctness
def run_langsmith() -> None:
"""Optional: run a LangSmith experiment with the real agent, the code evaluators and a judge."""
if not os.environ.get("LANGSMITH_API_KEY"):
print("LANGSMITH_API_KEY is not set - skipping the LangSmith experiment.")
print("Use --offline (scripted, free) or --live N (real model, no LangSmith needed).")
return
from langsmith import Client
client = Client()
if not client.has_dataset(dataset_name=DATASET_NAME):
dataset = client.create_dataset(DATASET_NAME, description="Northwind support agent.")
client.create_examples(dataset_id=dataset.id, examples=load_examples())
print(f"Uploaded {DATASET_PATH.name} as LangSmith dataset '{DATASET_NAME}'.")
results = client.evaluate(
make_target(build_support_agent(get_model())),
data=DATASET_NAME,
evaluators=[*CODE_EVALUATORS, make_correctness_judge()],
experiment_prefix="northwind-support",
max_concurrency=4,
)
print(f"Experiment: {results.experiment_name}\n{results.url}")Block 12The command line
--offline(with--variant) or--live Nchoose the mode. No flag means the LangSmith experiment.--save-baseline FILEwrites the scores as JSON.--baseline FILEcompares against saved scores and exits with code 1 on any regression. The exit code is what makes a CI job fail.
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Northwind support evaluation harness")
parser.add_argument("--offline", action="store_true", help="score locally with scripted models")
parser.add_argument("--live", type=int, metavar="N", help="score the first N rows with the real model")
parser.add_argument("--variant", choices=["baseline", "candidate"], default="baseline")
parser.add_argument("--save-baseline", type=Path, help="write this run's scores to a file")
parser.add_argument("--baseline", type=Path, help="fail when a score drops below this file's")
args = parser.parse_args()
if not args.offline and not args.live:
run_langsmith()
raise SystemExit(0)
report = run_live(args.live) if args.live else run_offline(args.variant)
print_summary(report)
if args.save_baseline:
args.save_baseline.write_text(json.dumps(report, indent=2), encoding="utf-8")
print(f"\nbaseline written to {args.save_baseline}")
if args.baseline:
regressions = check_regression(report, json.loads(args.baseline.read_text(encoding="utf-8")))
for line in regressions:
print(f"REGRESSION {line}")
print("\ngate: FAILED" if regressions else "\ngate: passed")
raise SystemExit(1 if regressions else 0)Run the harness offline
Score all 30 rows with the scripted baseline agent. No model is called.
python eval_harness.py --offlinepython3 eval_harness.py --offlineYour output should look similar to this. Model answers vary a little from run to run, so the wording will not match exactly.
variant=baseline n=30
escalation_policy 1.000
safety_pii 1.000
structural_match 1.000
tool_trajectory 1.000Every metric is 1.000: the scripted agent calls exactly the expected tools and gives the reference answer. That is the point of an offline baseline. It proves the harness and the evaluators work, in about a second, for free.
Check against the committed baseline
In CI the harness compares each run with a baseline file kept in the repository. Try it with the ci/baseline.json you downloaded:
python eval_harness.py --offline --baseline ci/baseline.jsonpython3 eval_harness.py --offline --baseline ci/baseline.jsonYour output should look similar to this. Model answers vary a little from run to run, so the wording will not match exactly.
variant=baseline n=30
escalation_policy 1.000
safety_pii 1.000
structural_match 1.000
tool_trajectory 1.000
gate: passedNo metric dropped, so the gate passes and the command exits with code 0.
Save your own baseline
python eval_harness.py --offline --save-baseline baseline.jsonpython3 eval_harness.py --offline --save-baseline baseline.jsonYour output should look similar to this. Model answers vary a little from run to run, so the wording will not match exactly.
variant=baseline n=30
escalation_policy 1.000
safety_pii 1.000
structural_match 1.000
tool_trajectory 1.000
baseline written to baseline.jsonOpen baseline.json. It holds the variant, the row count, one mean per evaluator and the failures. After a change you are happy with, you save a new baseline and commit it.
Prove the gate fails
A gate nobody has ever seen fail is a gate you have no reason to trust. Run the candidate variant, which answers from memory without calling any tool:
python eval_harness.py --offline --variant candidate --baseline baseline.jsonpython3 eval_harness.py --offline --variant candidate --baseline baseline.jsonYour output should look similar to this. Model answers vary a little from run to run, so the wording will not match exactly.
variant=candidate n=30
escalation_policy 0.900
safety_pii 1.000
structural_match 1.000
tool_trajectory 0.167
! order_status tool_trajectory expected=['get_order_status'] actual=[]
! order_status tool_trajectory expected=['get_order_status'] actual=[]
! order_status tool_trajectory expected=['get_order_status'] actual=[]
! order_status tool_trajectory expected=['get_order_status'] actual=[]
! order_status tool_trajectory expected=['get_order_status', 'get_order_status'] actual=[]
! ... and 23 more
REGRESSION tool_trajectory: 1.000 -> 0.167
REGRESSION escalation_policy: 1.000 -> 0.900
gate: FAILEDThe command exits with code 1 and names tool_trajectory (it collapses to about 0.167: only the few rows that expect no tool call still match). escalation_policy drops too, because the rows that must escalate never call escalate_to_human.
Now look at structural_match: still 1.000. The answers look exactly right. Only the trajectory evaluator noticed that the agent guessed instead of looking things up. That is the lesson of this lab: score how the agent got the answer, not just the answer.
Run the real agent live
Offline runs test the harness; live runs test the agent. Score the first five rows with your Azure OpenAI deployment:
python eval_harness.py --live 5python3 eval_harness.py --live 5Your output should look similar to this. Model answers vary a little from run to run, so the wording will not match exactly.
[1/5] Where is my order A-100?
tools: get_order_status
answer: Your order A-100 has been shipped via DHL with an estimated delivery date of 2026-09-15. Would you like me to fetch the DHL tracking number or check the latest delivery updates?
[2/5] Has order A-101 shipped yet?
tools: get_order_status
answer: Order A-101 hasn't shipped yet; it's currently processing. The estimated arrival date is 2026-09-18 — I can notify you when it ships or check carrier details if you'd like.
[3/5] did order a 102 ever arrive??
tools: get_order_status
answer: Yes — order A-102 was delivered on 2026-08-20 via UPS. If you haven’t received it, please check with neighbors or your building/concierge and let me know. I can open an investigation with our claims team for you if you’d like.
[4/5] I never got anything for order #A-103, what happened?
tools: get_order_status
answer: I checked your order: A-103 was cancelled and was never charged. That means nothing was shipped and no refund is required since no payment was taken. Would you like me to investigate why it was cancelled or help place a replacement order?
[5/5] Can you check both A-100 and A-104 for me?
tools: get_order_status, get_order_status
answer: Order A-100 is shipped via DHL with an ETA of 2026-09-15. Order A-104 is shipped via UPS with an ETA of 2026-09-14. Would you like tracking links or help with anything else for these orders?
variant=live n=5
escalation_policy 1.000
safety_pii 1.000
structural_match 1.000
tool_trajectory 1.000Each row shows the question, the tools the real model chose and its answer, then the same summary table as offline.
Scores below 1.000 are normal here and informative. A structural_match miss usually means the model phrased something differently (for example not yet shipped instead of processing); a tool_trajectory miss means it called a different tool or skipped one. Run it twice and compare: that run-to-run spread is the noise your gate's tolerance has to sit above.
Check your work
You are done when
--offlineprints a table with all four evaluators at1.000for 30 rows.- The check against
ci/baseline.jsonprintsgate: passed. - The candidate run exits with code 1, prints a
REGRESSIONline fortool_trajectory, and still showsstructural_matchat1.000. --live 5shows the real model's tool calls and answers, followed by the summary table.
Troubleshooting
NotImplementedError from bind_tools
The fake model must override bind_tools and return self. Check the model block of fakes.py.
FileNotFoundError for northwind_support.jsonl or baseline.json
The dataset must be at datasets/northwind_support.jsonl and the committed baseline at ci/baseline.json, both inside the lab folder. Run the commands from the lab folder.
ModuleNotFoundError: No module named 'support_agent' or 'fakes'
Both files must sit next to eval_harness.py, and you must start Python from that folder.
The offline run shows tool_trajectory below 1.000
Check the overrides in the trajectory block and that make_target returns as_trajectory(result) as messages. The evaluator needs OpenAI-style dicts, not LangChain message objects.
401 Unauthorized or 'Access denied due to invalid subscription key'
The key in .env is wrong or has spaces around it. Copy it again from your instructor, with no quotes.
404 'DeploymentNotFound' or 'The API deployment for this resource does not exist'
AZURE_OPENAI_DEPLOYMENT must be the deployment name exactly as your instructor gave it, and the endpoint must be the same resource.
429 Too Many Requests
The shared deployment is busy. Wait 30 seconds and run again.
ModuleNotFoundError: No module named 'model_setup' (or another package)
Run the command from inside your lab folder, with the virtual environment activated. Reinstall with pip install -r requirements.txt if a library is missing.
'python' is not recognized, or the wrong Python runs
Activate the virtual environment first. On Windows you can also try py -3.11 instead of python when creating the venv.
Go further
- Run
--live 5three times and write down the spread per metric. Anything that swings more than the 0.02 tolerance cannot gate a real-model run yet. - Split the dataset by
metadata.difficultyand gate only oneasyandmedium, trackinghardas a trend. - Optional, needs a free LangSmith account: add
LANGSMITH_API_KEY=...to.envand runpython eval_harness.pywith no flags. It uploads the dataset, runs all 30 rows against the real agent with an LLM-as-judgecorrectnessscore, and prints a link to the experiment. - Compare the judge's
correctnessverdicts with your own on ten rows. The agreement rate is what your judge-based gate is actually worth. - Wire the offline gate into CI: run
python eval_harness.py --offline --baseline ci/baseline.jsonas a pipeline step so a non-zero exit blocks the merge.
Stuck?
Download the finished project and compare it with yours. It contains every file from this guide except .env and .venv, so you still add your own settings.