Evaluation Framework — How We Know If an Agent Is Actually Good
ares Architecture Deep Dive (XXI): Evaluation Framework — How We Know If an Agent Is Actually Good
"How do you know your agent improved?" This question haunted v0.2.5. The evolution engine was generating new strategies, the arena was running battles — but we had no objective way to say "strategy A is 12% better than strategy B."
The evaluation framework (internal/ares_eval/, 3,390 lines) is the answer. It's the layer that turns "looks better to me" into reproducible scores.
The Problem: Vibes-Based Evaluation
Early ares had three evaluation paths, all broken:
| Path | Method | Problem |
|---|---|---|
| Manual | "Read the output, does it look right?" | Not scalable, heavily biased |
| Unit tests | Hardcoded expected outputs | Fragile, LLM output varies |
| Token counting | "More tokens = more thorough" | GPT-4o-mini at 500 tokens beats GPT-4 at 2000 |
We tried building a custom scoring rubric. It worked for a week, then someone asked "how do you compare a 7/10 on task X with a 8/10 on task Y?" The answer: you can't, unless you have a framework.
Honest reflection: We considered using an existing eval framework (promptfoo, langchain eval). They're great for single-model evaluation. But ares needed comparative evaluation — is strategy A better than strategy B on the same task? That's a different problem.
The Design: Three Layers
graph TD
L[Loader] --> R[Runner]
R --> EV[Evaluator]
EV --> LJ[LLMJudge]
EV --> DJ[DimensionJudge]
EV --> CO[Comparison]
R --> RE[Report]Layer 1: Test Cases (loader.go, types.go)
// internal/ares_eval/types.go
type TestCase struct {
ID string
Input string
Expected string // optional reference answer
Category string // "reasoning", "coding", "chat", etc.
Difficulty string // "easy", "medium", "hard"
Metadata map[string]any
}
type TestResult struct {
TestCaseID string
Output string
Scores []EvalScore
Duration time.Duration
Error error
}
type EvalScore struct {
EvaluatorName string
Score float64
MaxScore float64
Reasoning string
}
Test cases are loaded from YAML or JSON:
- id: reasoning_01
input: "If A > B and B > C, what's the relationship between A and C?"
expected: "A > C"
category: reasoning
difficulty: easyLayer 2: Evaluators (evaluator.go, llm_judge.go, dimension_judge.go)
The core interface:
// internal/ares_eval/evaluator.go
type Evaluator interface {
Name() string
Evaluate(ctx context.Context, tc TestCase, result TestResult) ([]EvalScore, error)
}
Three built-in evaluators:
LLMJudgeEvaluator
Uses an LLM-as-judge to score outputs. Supports three scales:
// internal/ares_eval/llm_judge.go
const (
ScaleOneToTen ScaleType = iota + 1 // 1-10 scoring
ScaleOneToFive // 1-5 scoring
ScalePassFail // binary pass/fail
)
The judge prompt (simplified):
You are evaluating an AI assistant's response.
Task: {input}
Expected: {expected}
Actual: {output}
Score the response on a scale of 1-10:
- 10: Perfect, matches or exceeds expected
- 7-9: Good, minor issues
- 4-6: Partial, missing key elements
- 1-3: Poor, wrong or irrelevant
Respond with JSON: {"score": N, "reasoning": "..."}
Honest reflection: LLM-as-judge has a known bias — it prefers longer, more verbose responses. We added a length penalty to the judge prompt, but it's a band-aid. The "real" fix is calibrating the judge against human evaluations, which we haven't done yet.
DimensionJudgeEvaluator
Scores across multiple dimensions:
// internal/ares_eval/dimension_judge.go
type Dimension struct {
Name string // "accuracy", "completeness", "clarity"
Weight float64
MaxScore float64
}
Each dimension gets its own score, then a weighted aggregate. This is what the evolution engine uses for fitness evaluation.
Layer 3: Runner and Comparison (runner.go, comparison.go, concurrent_runner.go)
// internal/ares_eval/runner.go
type Runner struct {
evaluators []Evaluator
loader *Loader
}
func (r *Runner) RunAll(ctx context.Context) (*Report, error)
func (r *Runner) RunScenario(ctx context.Context, scenario string) (*Report, error)
The comparison layer is where the magic happens:
// internal/ares_eval/comparison.go
type Comparison struct {
Baseline *Report
Candidate *Report
Improvements []ScoreDelta
Regressions []ScoreDelta
}
This lets us say:
Strategy A (baseline): average score 7.2/10
Strategy B (candidate): average score 8.1/10
Improvement: +12.5%
The concurrent_runner.go runs test cases in parallel, cutting evaluation time from 30 minutes to 3 minutes on a 100-case suite.
Integration with Evolution
The evaluation framework is the fitness function for the GA engine (Article XI):
// internal/ares_bootstrap/bootstrap.go (simplified)
func SetupEvaluators(llmClient *llm.Client, registry *eval.EvaluatorRegistry) error {
judge, err := eval.NewLLMJudgeEvaluator(llmClient,
eval.WithScale(eval.ScaleOneToTen),
eval.WithMaxRetries(3),
)
if err != nil {
return err
}
registry.Register(judge)
dimJudge, err := eval.NewDimensionJudgeEvaluator(llmClient,
eval.WithDimensions(defaultDimensions),
)
if err != nil {
return err
}
registry.Register(dimJudge)
return nil
}
When evolution runs, it:
- Generates a new strategy (via mutation)
- Runs it through the agent
- Evaluates the output with
LLMJudgeEvaluator - Uses the score as fitness for selection
Honest reflection: The LLM judge is expensive — each evaluation is an LLM call. For a 100-case suite with 10 strategies per generation, that's 1000 LLM calls per generation. We added caching (same input → same score) and a "fast mode" that only evaluates 10 random cases. But the fundamental cost remains.
The Service Layer
internal/ares_eval/service/ exposes the evaluation framework via HTTP:
service/
├── handler.go # HTTP handlers
├── router.go # Route registration
├── service.go # Business logic
├── repository.go # Result persistence
└── types.go # API types
Endpoints:
POST /eval/run— Run an evaluation suiteGET /eval/results/{id}— Get resultsPOST /eval/compare— Compare two runs
Lessons
The evaluation framework is the most undervalued module in ares. Nobody asks "how do you evaluate?" in a demo. But without it, evolution is just random mutation — no fitness function, no selection pressure, no improvement.
The best evaluation framework is the one that makes "is it better?" a question with a numerical answer. Vibes don't scale. Reproducible scores do.