Embeddable Go Evaluation Runner

AI Evaluation & Safety intermediate Building with AI ~20 min read

Overview

The Embeddable Go Evaluation Runner is a reusable component for orchestrating LLM evaluation suites. It drives a set of evaluator functions across a corpus of test examples, managing concurrency with rate limiting, collecting per-evaluator statistics, and recording per-example failures—all without aborting the suite on individual failures. The runner is designed to be embedded into larger testing and CI pipelines as a library, not as a standalone binary.

The runner supports two modes of operation: live mode, where it calls a TargetFunc callback to generate fresh outputs for each example, and replay mode, where it reads pre-recorded outputs from disk. Replay mode enables deterministic re-evaluation of historical outputs when evaluator logic changes, without needing to re-invoke the (potentially expensive) LLM.

A critical design goal is resilience: a single failing example or evaluator must not abort the entire suite. The runner captures panics, logs failures with full context, and continues to the next example. At the end, it produces an aggregate report showing pass rates, score distributions, and failure reasons per evaluator, making it straightforward to identify regressions across evaluation runs.

Core Concepts

TargetFunc Callback

The TargetFunc is a user-provided function with the signature func(ctx context.Context, input Example) (Output, error). It represents the system under evaluation—typically an LLM inference call. The runner invokes this function for each example in the dataset, passing the result to all registered evaluators. In replay mode, the TargetFunc is replaced with a loader that reads outputs from a recorded dataset.

Rate-Limited Parallel Execution

The runner uses a combination of a worker pool (bounded goroutine count) and a token-bucket rate limiter to control the pace of TargetFunc invocations. This prevents overwhelming external LLM APIs with burst traffic while still achieving parallelism. The rate limit is configurable per-runner and is typically set to match the API’s requests-per-minute quota divided by a safety margin.

Per-Evaluator Statistics Aggregation

Each evaluator produces a result (pass/fail, numeric score, or structured judgment) for each example. The runner aggregates these into per-evaluator statistics: pass rate, mean score, score standard deviation, and a histogram of failure reasons. These aggregates are the primary output of an evaluation run and are used to detect regressions when comparing against baseline runs.

Per-Example Failure Recording

When an evaluator fails on a specific example, the runner records the full context: the input, the output, the evaluator’s judgment, and any error. These records are written to a structured log (JSON Lines) that can be inspected post-hoc. Crucially, the runner does not abort on failure—it marks the example as failed and moves to the next one.

Replay Mode for Deterministic Re-Evaluation

In replay mode, the runner skips TargetFunc invocations and instead loads pre-recorded (input, output) pairs from disk. This enables re-evaluating the same outputs with updated evaluator logic, which is essential when tuning evaluation prompts or adding new evaluators. Replay runs are fast (no LLM calls) and deterministic (same inputs, same outputs).

How It Works

The runner’s execution flow is: load examples, optionally invoke TargetFunc for each, fan out to evaluators, aggregate results, and write the report. Rate limiting and error handling are woven throughout.

Runner Core Structure

package eval

import (
    "context"
    "fmt"
    "sync"
    "time"

    "golang.org/x/time/rate"
)

type Example struct {
    ID    string
    Input map[string]interface{}
}

type Output struct {
    Response string
    Metadata map[string]interface{}
}

type EvalResult struct {
    Pass     bool
    Score    float64
    Reason   string
    Keywords []string
}

type TargetFunc func(ctx context.Context, ex Example) (Output, error)

type Evaluator interface {
    Name() string
    Evaluate(ctx context.Context, ex Example, out Output) (EvalResult, error)
}

type RunnerConfig struct {
    MaxConcurrency int
    RateLimit      float64 // requests per second
    Timeout        time.Duration
}

type Runner struct {
    cfg        RunnerConfig
    target     TargetFunc
    evaluators []Evaluator
    limiter    *rate.Limiter
}

func NewRunner(cfg RunnerConfig, target TargetFunc, evaluators ...Evaluator) *Runner {
    return &Runner{
        cfg:        cfg,
        target:     target,
        evaluators: evaluators,
        limiter:    rate.NewLimiter(rate.Limit(cfg.RateLimit), 1),
    }
}

Parallel Execution with Rate Limiting

type ExampleResult struct {
    ExampleID string
    Output    Output
    Evals     map[string]EvalResult
    Error     error
}

func (r *Runner) Run(ctx context.Context, examples []Example) []ExampleResult {
    results := make([]ExampleResult, len(examples))
    sem := make(chan struct{}, r.cfg.MaxConcurrency)
    var wg sync.WaitGroup

    for i, ex := range examples {
        wg.Add(1)
        sem <- struct{}{} // acquire slot

        go func(idx int, example Example) {
            defer wg.Done()
            defer func() { <-sem }() // release slot

            result := ExampleResult{
                ExampleID: example.ID,
                Evals:     make(map[string]EvalResult),
            }

            // Rate limit before calling target
            if err := r.limiter.Wait(ctx); err != nil {
                result.Error = fmt.Errorf("rate limiter: %w", err)
                results[idx] = result
                return
            }

            // Call target function with timeout
            tctx, cancel := context.WithTimeout(ctx, r.cfg.Timeout)
            defer cancel()

            out, err := r.safeCallTarget(tctx, example)
            if err != nil {
                result.Error = err
                results[idx] = result
                return
            }
            result.Output = out

            // Run all evaluators
            for _, ev := range r.evaluators {
                evalResult, evalErr := r.safeCallEvaluator(tctx, ev, example, out)
                if evalErr != nil {
                    result.Evals[ev.Name()] = EvalResult{
                        Pass:   false,
                        Reason: evalErr.Error(),
                    }
                } else {
                    result.Evals[ev.Name()] = evalResult
                }
            }

            results[idx] = result
        }(i, ex)
    }

    wg.Wait()
    return results
}

func (r *Runner) safeCallTarget(ctx context.Context, ex Example) (out Output, err error) {
    defer func() {
        if p := recover(); p != nil {
            err = fmt.Errorf("panic in TargetFunc: %v", p)
        }
    }()
    return r.target(ctx, ex)
}

func (r *Runner) safeCallEvaluator(
    ctx context.Context, ev Evaluator, ex Example, out Output,
) (result EvalResult, err error) {
    defer func() {
        if p := recover(); p != nil {
            err = fmt.Errorf("panic in evaluator %s: %v", ev.Name(), p)
        }
    }()
    return ev.Evaluate(ctx, ex, out)
}

Statistics Aggregation

import "math"

type EvalStats struct {
    Name      string
    Total     int
    Passed    int
    PassRate  float64
    MeanScore float64
    StdDev    float64
    Failures  []FailureRecord
}

type FailureRecord struct {
    ExampleID string
    Reason    string
}

func Aggregate(results []ExampleResult) map[string]*EvalStats {
    stats := make(map[string]*EvalStats)

    for _, r := range results {
        if r.Error != nil {
            continue
        }
        for name, evalResult := range r.Evals {
            s, ok := stats[name]
            if !ok {
                s = &EvalStats{Name: name}
                stats[name] = s
            }
            s.Total++
            if evalResult.Pass {
                s.Passed++
            } else {
                s.Failures = append(s.Failures, FailureRecord{
                    ExampleID: r.ExampleID,
                    Reason:    evalResult.Reason,
                })
            }
            s.MeanScore += evalResult.Score
        }
    }

    // Finalize aggregates
    for _, s := range stats {
        if s.Total > 0 {
            s.PassRate = float64(s.Passed) / float64(s.Total)
            s.MeanScore /= float64(s.Total)
        }
        // Compute standard deviation (second pass)
        var variance float64
        for _, r := range results {
            if r.Error != nil {
                continue
            }
            if ev, ok := r.Evals[s.Name]; ok {
                diff := ev.Score - s.MeanScore
                variance += diff * diff
            }
        }
        if s.Total > 1 {
            s.StdDev = math.Sqrt(variance / float64(s.Total-1))
        }
    }

    return stats
}

Practical Applications

In a typical Go-based AI platform, the evaluation runner is embedded into the CI pipeline to run regression tests against LLM-generated outputs. Each pull request triggers an evaluation suite that compares the PR branch’s output quality against a baseline. If any evaluator’s pass rate drops below a configured threshold, the CI check fails and the PR cannot be merged.

Replay mode is used when the team tunes evaluation prompts. Rather than re-running expensive LLM inference, they replay the last production run’s recorded outputs through updated evaluators. This lets them iterate on evaluation criteria quickly—a replay run of 1,000 examples takes seconds instead of the minutes or hours required for live inference.

The per-example failure records feed into a dashboard that highlights the most common failure modes. Product teams use this to prioritize prompt engineering work: if 30% of failures share the same reason string (“response exceeded token limit”), that becomes the next optimization target.

Common Pitfalls

Further Reading