DEV Community

Ali Suleyman TOPUZ
Ali Suleyman TOPUZ

Posted on Originally published at topuzas.Medium on

Building a Production-Grade Eval Pipeline for Your Agent, Not Just a Demo

Six stages, real C# code, and the one number that convinced me this wasn’t busywork

Here’s the failure mode I’ve now watched happen three separate times, on three different agents, and it’s always the same shape. You build the thing, poke at it manually for an afternoon, it handles every question you throw at it, and you ship it. Two weeks later someone hits an edge case nobody thought to test, the agent confidently gives a wrong answer, and when you go looking for where it went wrong, there’s nothing to look at. No record of the failure, no test that would have caught it. You fix the one case someone reported, ship again, and the next edge case is still sitting out there waiting for the next person to trip over it.

The reason this keeps happening isn’t that manual testing is lazy. It’s that manual testing has no memory. Every session starts from zero, you poke at the same three or four scenarios you always remember, and the things that actually broke in production six weeks ago have evaporated from anyone’s head. A pipeline fixes this not because it’s smarter than an engineer’s judgment, but because it has a hard drive and a human doesn’t.

I went looking for how other people structure this, rather than invent an architecture from scratch and get the shape wrong. Subrat Pati’s piece on architecting an agent improvement loop is the clearest writeup I found, a six-phase structure built around a LangGraph agent with LangSmith tracing, and it names something I hadn’t put words to yet: the difference between an eval pipeline that produces a report and one that produces a gate. A report tells you a score went down. A gate refuses to let you ship until the score comes back up. Most of what teams call “evals” is the first thing dressed up to look like the second.

What that article doesn’t do is hand you working code for a different stack, it’s a LangGraph and LangSmith story. I wanted the same six stages against Microsoft Agent Framework and plain C#, because that’s what I actually run in production, and I wanted to know how much of it a solo developer could build without waiting on a platform team. So that’s what this is: the same six-stage shape rebuilt in C#, against the OrderAgent I've used as a running example across this series, with two full working pieces (a trace-capture wrapper and a deterministic scorer) and an honest accounting of what the rest costs.

The six stages, and which ones actually need code today

Here’s the shape, stated plainly:

+---+---------------------------+----------------------------------------------+
| # | Stage | What it does |
+---+---------------------------+----------------------------------------------+
| 1 | Run + trace | Agent executes the task, every input, tool |
| | | call, and output gets logged in structured |
| | | form, not just the final response |
| 2 | Deterministic scoring | Cheap, code-only checks: does the output |
| | | parse, does it match a schema, does the math |
| | | actually add up |
| 3 | LLM-as-judge scoring | A second model rates things code can't check: |
| | | tone, relevance, whether the right tool was |
| | | even the right call |
| 4 | Human spot-check | A sparse human sample, overriding the other |
| | | two layers where they disagree with a person |
| 5 | Failure clustering | Group failures by root cause, not just count |
| | | them, so one bad docstring doesn't look like |
| | | nine unrelated bugs |
| 6 | Regression dataset + gate | Every distinct failure becomes a permanent |
| | | test case; nothing ships until the full |
| | | dataset passes, not just the new tests |
+---+---------------------------+----------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

Stages 1 and 2 are an afternoon of plain C#, no external service required. Stage 3 leans on a package that already exists, Microsoft.Extensions.AI.Evaluation.Quality, and runs fine against a local Ollama model instead of a paid API. Stage 4 is a process decision more than a code problem. Stages 5 and 6 are where the real engineering discipline lives, and they're mostly plumbing once 1 and 2 exist, since clustering and gating both just read the trace log you're already writing.

Stage 1: trace everything, not just the final answer

The instinct most people have is to log the final response and call it done. That throws away the one thing that actually explains a failure later: which tools got called, with what arguments, and what came back. If OrderAgent gives a wrong refund total, the final response tells you it's wrong. The trace tells you whether it's wrong because GetOrderStatus returned stale data, the agent picked the wrong order, or the arithmetic is broken, three different bugs with three different fixes, invisible from the final text alone.

I built this as a DelegatingChatClient, the same pattern I've used for cost tracking and rate limiting elsewhere in this stack, because it sits in the right spot: it sees every message in and every response out, tool calls included, no matter how many round trips the model needs internally.

// dotnet add package Microsoft.Extensions.AI

using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.AI;
public sealed record ToolCallRecord(
    string CallId,
    string Name,
    IDictionary<string, object?>? Arguments,
    object? Result);
public sealed record TraceRecord(
    string TraceId,
    string AgentName,
    DateTimeOffset StartedAtUtc,
    double DurationMs,
    string InputSummary,
    List<ToolCallRecord> ToolCalls,
    string? OutputText,
    string? ErrorMessage);
public sealed class TraceCapturingChatClient : DelegatingChatClient
{
    private readonly string _agentName;
    private readonly string _traceLogPath;
    private readonly SemaphoreSlim _writeLock = new(1, 1);
    public TraceCapturingChatClient(IChatClient inner, string agentName, string traceLogPath = "./traces.jsonl")
        : base(inner)
    {
        _agentName = agentName;
        _traceLogPath = traceLogPath;
    }
    public override async Task<ChatResponse> GetResponseAsync(
        IEnumerable<ChatMessage> messages,
        ChatOptions? options = null,
        CancellationToken cancellationToken = default)
    {
        var traceId = Guid.NewGuid().ToString("N");
        var startedAt = DateTimeOffset.UtcNow;
        var stopwatch = Stopwatch.StartNew();
        var messageList = messages as IReadOnlyList<ChatMessage> ?? messages.ToList();
        ChatResponse? response = null;
        Exception? failure = null;
        try
        {
            response = await base.GetResponseAsync(messageList, options, cancellationToken);
            return response;
        }
        catch (Exception ex)
        {
            failure = ex;
            throw;
        }
        finally
        {
            stopwatch.Stop();
            var record = new TraceRecord(
                TraceId: traceId,
                AgentName: _agentName,
                StartedAtUtc: startedAt,
                DurationMs: stopwatch.Elapsed.TotalMilliseconds,
                InputSummary: SummarizeInput(messageList),
                ToolCalls: response is null ? [] : ExtractToolCalls(response),
                OutputText: response?.Text,
                ErrorMessage: failure?.Message);
            await AppendTraceAsync(record, cancellationToken);
        }
    }
    private static string SummarizeInput(IReadOnlyList<ChatMessage> messages)
    {
        var lastUser = messages.LastOrDefault(m => m.Role == ChatRole.User);
        return lastUser?.Text ?? "(no user message)";
    }
    private static List<ToolCallRecord> ExtractToolCalls(ChatResponse response)
    {
        var byCallId = new Dictionary<string, ToolCallRecord>();
        foreach (var message in response.Messages)
        {
            foreach (var content in message.Contents)
            {
                switch (content)
                {
                    case FunctionCallContent call:
                        byCallId[call.CallId] = new ToolCallRecord(call.CallId, call.Name, call.Arguments, null);
                        break;
                    case FunctionResultContent result when byCallId.TryGetValue(result.CallId, out var existing):
                        byCallId[result.CallId] = existing with { Result = result.Result };
                        break;
                }
            }
        }
        return byCallId.Values.ToList();
    }
    private async Task AppendTraceAsync(TraceRecord record, CancellationToken cancellationToken)
    {
        var line = JsonSerializer.Serialize(record) + Environment.NewLine;
        await _writeLock.WaitAsync(cancellationToken);
        try
        {
            await File.AppendAllTextAsync(_traceLogPath, line, cancellationToken);
        }
        finally
        {
            _writeLock.Release();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Wiring it in is one line, same as every other delegating client in this stack:

IChatClient tracedClient = baseChatClient
    .AsBuilder()
    .Use(inner => new TraceCapturingChatClient(inner, agentName: "OrderAgent"))
    .Build();
Enter fullscreen mode Exit fullscreen mode

Two things I got wrong on the first pass. First, I originally logged only inside the try block, so a thrown exception left no trace record at all, exactly the run you most want visibility into. Moving the logging into finally fixed it, at the cost of a null-checked response. Second, I logged the entire message history on every call at first, and the file hit four hundred megabytes in a day of local testing. Logging just the last user message plus tool calls and final output keeps the file readable and still tells you why a run failed. If you need the full conversation for replay, log it to a separate keyed store referenced by TraceId, not inline in the hot-path log.

JSONL, one JSON object per line, is the format that matters here, not because it’s clever but because it’s replayable. Every downstream stage, clustering, dataset-building, the gate, is just a program that reads this file line by line. No database, no schema migration, nothing to stand up before you can start.

Stage 2: deterministic scoring, the layer that costs nothing to run

This is the layer people skip because it feels like it isn’t “real” evaluation, and that’s backwards. Deterministic checks are the cheapest, most reliable signal you have, and they should run on every trace, every time, because they cost a function call, not a model call.

I picked OrderAgent producing a refund calculation as structured JSON, a schema to validate and arithmetic to check, exactly the kind of task where an LLM judge is overkill and code is the right tool.

using System.Text.Json;

public sealed record RefundLineItem
{
    public string Sku { get; init; } = "";
    public int Quantity { get; init; }
    public decimal UnitPrice { get; init; }
}
public sealed record RefundCalculation
{
    public string OrderId { get; init; } = "";
    public List<RefundLineItem> Items { get; init; } = [];
    public decimal Subtotal { get; init; }
    public decimal Tax { get; init; }
    public decimal Total { get; init; }
}
public sealed record ScoreResult(bool Passed, string? Reason)
{
    public static ScoreResult Pass() => new(true, null);
    public static ScoreResult Fail(string reason) => new(false, reason);
}
public static class RefundCalculationScorer
{
    private const decimal Epsilon = 0.01m;
    public static ScoreResult Score(string agentOutputJson)
    {
        RefundCalculation? parsed;
        try
        {
            parsed = JsonSerializer.Deserialize<RefundCalculation>(
                agentOutputJson,
                new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
        }
        catch (JsonException ex)
        {
            return ScoreResult.Fail($"output is not valid JSON: {ex.Message}");
        }
        if (parsed is null)
            return ScoreResult.Fail("output deserialized to null");
        var failures = new List<string>();
        if (string.IsNullOrWhiteSpace(parsed.OrderId))
            failures.Add("orderId is missing or empty");
        if (parsed.Items.Count == 0)
            failures.Add("items array is empty");
        for (int i = 0; i < parsed.Items.Count; i++)
        {
            var item = parsed.Items[i];
            if (string.IsNullOrWhiteSpace(item.Sku))
                failures.Add($"items[{i}].sku is missing");
            if (item.Quantity <= 0)
                failures.Add($"items[{i}].quantity must be positive, got {item.Quantity}");
            if (item.UnitPrice < 0)
                failures.Add($"items[{i}].unitPrice cannot be negative, got {item.UnitPrice}");
        }
        var computedSubtotal = parsed.Items.Sum(i => i.Quantity * i.UnitPrice);
        if (Math.Abs(computedSubtotal - parsed.Subtotal) > Epsilon)
            failures.Add($"subtotal mismatch: items sum to {computedSubtotal}, agent reported {parsed.Subtotal}");
        var computedTotal = parsed.Subtotal + parsed.Tax;
        if (Math.Abs(computedTotal - parsed.Total) > Epsilon)
            failures.Add($"total mismatch: subtotal + tax = {computedTotal}, agent reported {parsed.Total}");
        return failures.Count == 0
            ? ScoreResult.Pass()
            : ScoreResult.Fail(string.Join("; ", failures));
    }
}
Enter fullscreen mode Exit fullscreen mode

Point it at the trace log from stage one, one line per record, RefundCalculationScorer.Score(trace.OutputText), and you have a scoring pass over every trace, in seconds, for free.

The LLM-judge layer sits on top of this, catching what code can’t: tone, whether the agent picked a reasonable order when the request was ambiguous, whether the explanation would actually make sense to a human. I already have this wired up from an earlier piece, using Microsoft.Extensions.AI.Evaluation.Quality's IntentResolutionEvaluator and friends, and the same trick applies here: run a cheap local judge through Ollama on every trace, and save a stronger hosted judge for anything actually gating a release. Putting deterministic scoring first isn't about the judge layer not mattering, it's that most failures don't need one, and burning a model call to discover your JSON didn't parse is money you didn't need to spend.

The human layer is the smallest in volume and the most authoritative in weight. I sample five to ten traces a week, read them cold, and where my read disagrees with what the automated layers scored, that disagreement itself becomes a signal, usually that the judge’s rubric needs adjusting, not that the human is wrong.

Stages 5 and 6: cluster failures, then make them permanent

Counting failures tells you something broke. Clustering tells you why. Nine failing traces that all trace back to one confusing tool description look, counted, like nine separate bugs to chase one at a time. Grouped by root cause, they’re one fix.

public sealed record FailureCase(string TraceId, string Category, string Reason, string InputSummary);

// failures populated from the scoring pass, tagged with a category
// like "schema_violation" or "wrong_order_selected", not raw free text
var failures = new List<FailureCase>();
var clustered = failures
    .GroupBy(f => f.Category)
    .OrderByDescending(g => g.Count())
    .Select(g => new { Category = g.Key, Count = g.Count(), Examples = g.Take(3) });
foreach (var group in clustered)
    Console.WriteLine($"{group.Category}: {group.Count} traces");
Enter fullscreen mode Exit fullscreen mode

Every cluster, once you understand its root cause, becomes a dataset entry, not a footnote in a report. I append these to regression-dataset.jsonl, with the original input, the expected shape of a correct answer, and which scorer applies. That file only grows, nothing is ever removed, because the whole value is that a case fixed six months ago stays tested forever.

The gate is the last piece, and the one people build last and need first: load every case in regression-dataset.jsonl, replay each against the current build, score it with the matching scorer, and fail the build if anything that used to pass now fails. Not "did the new tests pass." Did everything pass, new cases included.

var dataset = File.ReadLines("./regression-dataset.jsonl")
    .Select(line => JsonSerializer.Deserialize<RegressionCase>(line)!)
    .ToList();

var failed = new List<string>();
foreach (var testCase in dataset)
{
    var output = await RunAgentAsync(testCase.Input);
    var result = RefundCalculationScorer.Score(output);
    if (!result.Passed)
        failed.Add($"{testCase.CaseId}: {result.Reason}");
}
if (failed.Count > 0)
{
    Console.WriteLine($"REGRESSION GATE FAILED: {failed.Count} of {dataset.Count} cases regressed");
    failed.ForEach(Console.WriteLine);
    Environment.Exit(1);
}
Console.WriteLine($"Regression gate passed: {dataset.Count} of {dataset.Count} cases");
Enter fullscreen mode Exit fullscreen mode

That’s the whole mechanism that turns “we have evals” into “nothing ships without them.” A pull request touching the system prompt runs this before it can merge. Break a case fixed three months ago and the build fails, same as a broken unit test, for the same reason: merging anyway means shipping a known regression on purpose.

The number that made this real for me

I could have built all of this and still not been sure it was worth the extra CI minutes, if I hadn’t seen a documented before-and-after that made the payoff concrete instead of theoretical. Subrat Pati’s writeup includes exactly that: an agent scoring 9/9 runs, avg tool_match: 0.33, picking the correct tool roughly a third of the time, failing on a mix of wrong-tool calls and unhelpful answers. The fix wasn't a new model or a bigger prompt. It was expanding one tool's docstring to include the word "subscription," language the model needed to map a subscription question to that specific tool.

After that one change, the same nine runs scored avg tool_match: 0.89. The breakdown went from five wrong-tool calls, three unhelpful answers, and one human-flagged case, down to one wrong-tool call and nothing else. A single docstring expansion resolved eight of nine failures.

What makes that number matter is how it was found. Nobody guessed the docstring was the problem. The pipeline clustered the failures, the cluster pointed at a shared root cause across tool-selection misses, and the fix fell out of looking at what those traces had in common. “The agent is wrong sometimes” isn’t specific enough to act on. “Five of nine failures happen when the user says subscription and the agent reaches for the wrong tool” is a one-line fix you ship the same day.

The same shape shows up in QA test generation too

I wanted to know if this was one team’s opinion about agent evals, or a pattern that shows up wherever someone builds an agentic pipeline seriously. Rajesh Yemul’s writeup on an agentic quality engineering system, going from a JIRA ticket to a pull request, is a different problem domain entirely, generating test coverage instead of evaluating an agent’s answers, and it lands on the same shape anyway.

+---+----------------------+---------------------------------------------+
| # | Agent | Role |
+---+----------------------+---------------------------------------------+
| 1 | JIRA Extractor | Pulls structured requirements off the ticket |
| 2 | Test Case Generator | Designs scenarios, maps each one to an |
| | | acceptance criterion |
| 3 | E2E Validator | Checks the existing test framework for what |
| | | actually exists before assuming it does |
| 4 | Code Generation | Writes the test code, reusing existing |
| | | framework conventions |
| 5 | Quality Checker | Compiles and lints the generated code |
| 6 | Test Executor | Runs the tests against the live app |
| 7 | Report Generator | Synthesizes every upstream result into one |
| | | narrative |
| 8 | PR Submitter (Gate) | PASSED proceeds; anything else, including |
| | | PASSED WITH WARNINGS, stops |
| 9 | Orchestrator | Coordinates state across all eight |
+---+----------------------+---------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

Nine specialized stages instead of my six, a different problem entirely, and still the same two structural decisions doing the real work: specialized stages instead of one agent trying to do everything, and a hard gate at the end that nothing skips. Their gate is stricter than mine in one respect: a test that compiles, passes linting, and genuinely executes successfully can still get stopped if an earlier stage flagged something like a weak assertion selector. Passing the mechanical checks isn’t enough if a semantic concern was raised upstream, the same principle as my regression dataset never shrinking, once a concern is on the record it doesn’t get waved through just because today’s check came back clean.

The part I found most honest in that writeup is what it doesn’t claim. The author is upfront that they haven’t yet watched a fully unattended run go from a fresh ticket to a merged pull request with zero human involvement, and the system is deliberately built so a human still approves the actual merge. That’s the design working as intended: the gate keeps a false stamp of approval from reaching a human, it isn’t meant to remove the human.

What this actually costs

Here’s my honest accounting, what I built for this piece versus what I know from experience gets harder past a certain scale.

+---------------------------------+------------------+---------------------------------+
| Piece | Weekend-buildable | Needs real investment |
+---------------------------------+------------------+---------------------------------+
| Trace capture (JSONL, one | Yes | |
| DelegatingChatClient) | | |
+---------------------------------+------------------+---------------------------------+
| Deterministic scorers for 2-3 | Yes | |
| task types you already have | | |
+---------------------------------+------------------+---------------------------------+
| LLM-judge layer, local model | Yes, with Ollama | |
+---------------------------------+------------------+---------------------------------+
| Failure clustering by category | Yes, LINQ groupby | |
+---------------------------------+------------------+---------------------------------+
| Regression dataset + CI gate | Yes | |
+---------------------------------+------------------+---------------------------------+
| Judge-quality calibration at | | Yes, needs a real rubric and |
| scale (hundreds of task types) | | ongoing human-labeling process |
+---------------------------------+------------------+---------------------------------+
| A UI for browsing/replaying | | Yes, this is the part that turns |
| traces across a team | | into an actual platform team job |
+---------------------------------+------------------+---------------------------------+
| Cross-team dataset governance | | Yes, once multiple agents share |
| (who can edit shared cases) | | one regression suite |
+---------------------------------+------------------+---------------------------------+
Enter fullscreen mode Exit fullscreen mode

The left column is what I actually built and ran, over a weekend, for one agent with two task types, no hosted service I didn’t already control. The right column is real, I’m not pretending a solo developer builds LangSmith or cross-team dataset governance in an afternoon. But the part that stops the regression, trace, score, cluster, gate, is the cheap part. The part that costs real money is making it pleasant at a scale most solo developers haven’t hit yet. Don’t let the second column talk you out of building the first one this weekend.

The failure mode I opened with, an agent that looks fine until someone hits the edge case nobody re-tested, was never a smarter-model problem. The fix was a place for the last bug to live, so the next person doesn’t have to rediscover it by hand. That’s a JSONL file and a gate in CI. It’s genuinely that unglamorous, and it’s genuinely the part that was missing.

Tags: dotnet, ai-agents, llm-evaluation, csharp, microsoft-agent-framework, regression-testing, quality-engineering

Top comments (0)