← All posts

How do you prove that each release of an AI agent is actually better?

2026-10-27

Originally published on LinkedIn.

Shipping the first version of an LLM agent is the easy part. The hard part starts with v2. Models are retrained on new data, retrieval is tuned, prompts change and the graph gains new steps. Each change raises the same two questions: is the new version actually better, and is it still safe? "It looked better in the demo" is not an answer, especially when the people using the system are fixing aircraft.

I led the architecture of an AI assistant for aircraft maintenance engineers at a European airline group. In this article I call it HangarMind. This is the evaluation plan I would put around a system like it: a sealed, time-split golden set of real cases, metrics for every node of the agent graph, paired significance tests, zero tolerance for safety-critical errors, and confirmation in shadow mode before anything reaches users.

Disclaimer: this article does not disclose any confidential information. HangarMind is a fictitious name, the architecture is simplified for educational purposes, and all scores, counts and examples are illustrative, not real results.


The system: what HangarMind does

When a fault grounds an aircraft (an AOG, Aircraft on Ground), every hour costs the airline money. An engineer describes the fault in plain text, the way it is typed on shift. HangarMind searches past AOG cases and the fixes engineers recorded, falls back to the Airbus maintenance manuals when the history is thin, and drafts resolution steps with references. When it has nothing reliable to say, it abstains and escalates to an engineer.

A few terms used throughout:

Tech stack

Everything runs in AWS. The agent is Python with LangGraph, the chat interface is Next.js, and both run as containers on ECS. An ETL built on AWS Step Functions and Lambda loads the AOG history and the manuals into Aurora PostgreSQL with pgvector, calling an LLM in Amazon Bedrock to extract the problem and the solution from each AOG record. Langfuse is self-hosted on the same ECS cluster for tracing and evaluation, and the test sets are versioned in S3.

Architecture

The full stack, marking what the core system uses and what the evaluation approach adds:

Tech stack, part 1

Tech stack, part 2

The ETL is part of what is tested

ETL

Part 1 · The system under test

Agent graph

Each step in the graph gets its own metric (the amber tags), and the Abstain exit is scored separately with correct abstention and over-refusal. An end-to-end score tells you that something changed. Per-node scores tell you where.

HangarMind abstains in three cases: the question is out of scope, neither past AOGs nor the manuals hold anything relevant, or the guardrail check fails. "Nothing relevant" needs a relevance threshold, because vector search always returns its nearest results, even irrelevant ones. In Langfuse each run is a trace with one span per node, which is where the per-node metrics are read.

Part 2 · The procedure, in order

Procedure

Part 3 · Three kinds of data, three jobs

For every version, v1 included, take a snapshot of all the AOG history available and split it by date at a training cutoff. Older AOGs build the system. The newest AOGs are labelled by engineers and split again: a validation set the team practises on, and a sealed set used only to decide whether the version ships.

Data split

Three sets

Terms and rotation

How ground truth grows from v1 to v2

Each release builds on everything up to its cutoff and holds back the newest slice to test on. While v1 is in service, new ground truth keeps arriving from four sources: new AOG records (every resolved AOG still produces a recorded fix), human in the loop (an engineer confirms or corrects a low-confidence answer live), offline review of logged queries, and feedback and failures (thumbs-down, rephrased questions, escalations).

Ground truth growth

If the sealed set changes, how are v1 and v2 compared fairly?

v1 and v2 are always scored on exactly the same cases. v1 is frozen and can be re-run at any time, so at the v2 release both versions answer v2's new sealed set side by side. Frozen means the whole configuration, including a snapshot of its RAG index: a live index that keeps ingesting AOGs may already contain v2's sealed cases, which would leak the answers.

Rotating vs anchor

Next to the rotating sealed set, keep a small fixed anchor set of critical safety cases and known past failures: it tracks the trend across every release and catches old bugs that come back. Keeping one sealed set forever looks simpler but fails over time: the team slowly tunes to it, the fleet and fault types change, and its AOGs are lost as training data.

Part 4 · How a test case is built

Annotators are qualified mechanical engineers who write down the correct answer for each case. A case writer first turns each AOG record into the question an engineer would have typed. Two annotators then label it blind, and a senior engineer adjudicates every disagreement.

Annotation workflow

Why two annotators? Experts disagree more than expected. The agreement rate is measured with Cohen's kappa (κ), which removes the agreement expected by chance. Example: two annotators judge 100 "is this past AOG relevant?" pairs and agree on 85. Chance agreement is about 0.50, so κ = (0.85 − 0.50) / (1 − 0.50) = 0.70, "substantial". Low κ means the guidelines are unclear, which you want to know before blaming the model.

Case anatomy

Write queries the way engineers really type, with abbreviations: clean, tidy questions make scores look better than real use. The route (the path through the graph) and the reference steps (the procedure) are scored separately, because a case can take the right route and still give the wrong procedure.

Who fills what

Pooling: finding the expected AOGs

No engineer can read thousands of past AOGs for every case. Instead, several retrievers each return their top results, the lists are merged and de-duplicated, and the annotator judges only that pool. Every version being compared contributes equally, and plain keyword search is included so that an AOG no vector retriever finds can still be marked relevant.

Pooling

Part 5 · Metrics by layer

Metrics by layer

Part 6 · The metrics in plain words

All example numbers are illustrative.

Classification: did the router pick the right AOG category?

Confusion matrix

Four of the seven categories: LG landing gear, HYD hydraulics, ENG engines, AVI avionics. Landing gear and hydraulics get confused 16 times. That makes sense, because the gear is hydraulically actuated, and it tells you where to add examples or sharpen the category definitions.

Retrieval: did pgvector bring back the right AOGs and manual tasks?

Generation: is the answer correct, grounded and usable?

Safety and behaviour

Speed and cost

p50 is the median wait; p95 is the slow tail, the one query in 20 that engineers remember. The average is pulled up by a few slow queries and describes almost nobody's experience, so release gates use p95. Cost per query is the model tokens plus database and reranker calls for one answer: a reranker or a retry loop can improve quality while raising it.

Latency

Statistics: is the gain real, or luck?

Part 7 · Reading a v1 vs v2 comparison

v1 vs v2

Slices

Overall v2 is better, but landing gear drops. With 64 cases the drop may not survive the Holm correction, yet in a safety-relevant category someone must explain it before release. Never report a gain without its interval: "+5 points" alone cannot tell you whether it is real; "+5 points (+2 to +8)" can.

Part 8 · Which change earned the gain

v2 shipped newer data and several improvements at once. Adding them one at a time on the validation set (an ablation) attributes the gain and exposes a change that does nothing. The first step runs v1's pipeline on v2's newer data, to separate the gain from more history from the gain from better techniques.

Ablation

The fine-tuned LLM does not move retrieval, which is expected: its effect shows up in the generation metrics.

Part 9 · Release gates in CI

Release gates

A hard block cannot be overridden; a soft gate can be accepted by a person with a justified trade-off. Version everything that changes a result: prompts, model IDs, the embedding model per index, and the sets themselves.

Part 10 · Running the evaluation on Langfuse

Langfuse plugs into LangGraph through a callback handler, so every run becomes a trace with one span per node. On top of those traces it holds the datasets, runs the experiments and stores the scores.

Langfuse flow

Annotators and evaluators are different jobs. Annotators write the answer key before any testing; evaluators mark the answers against that key every time a test runs. Machines mark everything; engineers check the judge on a sample, plus every critical-error flag and every case where v1 and v2 disagree.

Who marks what

Langfuse mapping, part 1

Langfuse mapping, part 2

A sketch of scoring v1 and v2 on the sealed set with the Langfuse Python SDK (check names against the current docs; build_graph and untraceable_values stand for the system's own code):

from langfuse import get_client, Evaluation
from langfuse.langchain import CallbackHandler
from hangarmind import build_graph, untraceable_values

langfuse = get_client()   # self-hosted URL and keys from env
sealed = langfuse.get_dataset("hm-sealed-2026-10")

def make_task(version):
    graph = build_graph(version)   # frozen v1 or v2
    def task(*, item, **kwargs):
        state = graph.invoke(item.input, config={"callbacks": [CallbackHandler()]})
        return {"answer": state["answer"], "sources": state["sources"],
                "top5": state["retrieved_aogs"][:5], "route": state["route"]}
    return task

def aog_recall_at_5(*, output, expected_output, **kwargs):
    expected = set(expected_output["expected_aogs"])
    if not expected:   # manual or abstain case
        return []   # no score for this item
    return Evaluation(name="aog_recall@5", value=len(expected & set(output["top5"])) / len(expected))

def critical_errors(*, output, **kwargs):
    bad = untraceable_values(output["answer"], output["sources"])
    return Evaluation(name="critical_errors", value=len(bad), comment=", ".join(bad))

for version in ["v1", "v2"]:
    for run in range(5):
        sealed.run_experiment(name=f"{version}-sealed-run{run}", task=make_task(version),
                              evaluators=[aog_recall_at_5, critical_errors])

Keeping the sealed set sealed:

Part 11 · Confirming in production

Rollout

Part 12 · Improvement techniques and how each is proven

Each technique targets one layer, so you know in advance which metric should move and what could get worse.

Retrieval

Generation

Graph and loop


The one-line version

Score every release node by node against a sealed, time-split set of real cases labelled by domain experts. Ship only with a significant gain, zero safety-critical errors and confirmation in shadow mode.

How do you decide that a new version of your LLM system is ready to ship? I would love to hear what has worked for you.

#LLMOps #AIAgents #RAG #LangGraph #Langfuse #MachineLearning #Evaluation #GenAI