← Back to DevBytes

Building an Agent Evaluation Harness with llama.cpp: Complete Guide

Introduction to Agent Evaluation Harnesses

An agent evaluation harness is a systematic framework for measuring how well an autonomous LLM agent performs across a suite of tasks. Unlike simple benchmark scripts that check whether a model produces a specific string, an evaluation harness exercises the full agent loop: tool calls, multi-step reasoning, error recovery, and final answer synthesis. With llama.cpp becoming a popular choice for running quantized models locally, building an evaluation harness around it gives developers a reproducible, offline, and cost-effective way to compare agent architectures, prompts, and model sizes.

This guide walks through designing and implementing a complete evaluation harness from scratch. We will cover the architecture, the integration with llama.cpp's server, scoring strategies, and best practices for getting trustworthy results.

Why Build an Evaluation Harness?

When you ship an agent into production, you need confidence that it will behave correctly on unseen inputs. A harness gives you several concrete advantages:

Understanding the Components

A robust harness has four logical layers. Each layer should be isolated so you can swap implementations without rewriting the whole system.

1. The Model Server

llama.cpp ships an OpenAI-compatible HTTP server (llama-server). This is the easiest integration point because your harness can use the same client code you would use against a hosted API. Start it with a quantized model:

./llama-server \
  -m models/Meta-Llama-3-8B-Instruct.Q4_K_M.gguf \
  --host 0.0.0.0 --port 8080 \
  -c 8192 -ngl 33 \
  --jinja

The --jinja flag enables the model's chat template, which is essential for tool-calling agents. The -ngl flag offloads layers to the GPU for faster inference, which matters because evaluation runs can involve thousands of completions.

2. The Agent Runtime

The agent runtime wraps the model server and implements the reasoning loop. It sends the prompt, parses tool calls, executes them, and feeds results back. Keeping this layer generic lets you evaluate different agent strategies (ReAct, function-calling, plan-and-execute) under the same harness.

3. The Task Suite

Tasks are declarative specifications of what the agent must accomplish. Each task defines the initial prompt, available tools, the maximum number of steps, and a scoring function. Storing tasks as YAML or JSON keeps them versionable and reviewable.

4. The Scorer

The scorer converts an agent's trajectory into a numeric score. Scoring can be exact-match, programmatic (running assertions against tool call arguments), or LLM-as-judge (using a stronger model to grade the trajectory).

Designing the Task Format

Consistency in task definition is what makes a harness trustworthy. Below is a minimal schema that captures everything a scorer needs.

# tasks/math_calc.yaml
id: math_calc_001
description: "Compute the area of a circle given radius via a calculator tool."
prompt: |
  What is the area of a circle with radius 7? Use the calculator tool.
  Round the result to two decimal places.
max_steps: 5
tools:
  - name: calculator
    description: "Evaluate a math expression."
    parameters:
      type: object
      properties:
        expression:
          type: string
      required: [expression]
scorer:
  type: tool_call_match
  expected_tool: calculator
  expected_expression_regex: "3\\.14.*7.*7|pi.*7.*7|7\\*\\*2.*pi"
  expected_final_answer_regex: "153\\.93|153\\.94"

Notice that the scorer combines two checks: that the agent called the right tool with a sensible expression, and that the final answer matches the expected value. This dual check prevents false positives where an agent produces the correct number through hallucination rather than computation.

Implementing the Agent Runtime

Below is a Python implementation of a minimal ReAct-style agent that talks to the llama.cpp server. It uses the OpenAI Python client for compatibility.

import json
from openai import OpenAI

client = OpenAI(base_url="http://localhost:8080/v1", api_key="not-needed")

def run_agent(prompt, tools, max_steps=5):
    messages = [{"role": "user", "content": prompt}]
    trajectory = {"steps": [], "final_answer": None}

    for step in range(max_steps):
        response = client.chat.completions.create(
            model="local",
            messages=messages,
            tools=tools,
            tool_choice="auto",
            temperature=0.0,
        )
        msg = response.choices[0].message

        if msg.tool_calls:
            messages.append(msg)
            for call in msg.tool_calls:
                args = json.loads(call.function.arguments)
                result = execute_tool(call.function.name, args)
                trajectory["steps"].append({
                    "tool": call.function.name,
                    "args": args,
                    "result": result,
                })
                messages.append({
                    "role": "tool",
                    "tool_call_id": call.id,
                    "content": json.dumps(result),
                })
        else:
            trajectory["final_answer"] = msg.content
            break

    return trajectory

def execute_tool(name, args):
    if name == "calculator":
        # Safe-ish eval for demo only; use a real parser in production.
        expr = args["expression"].replace("^", "**")
        try:
            return {"value": eval(expr, {"__builtins__": {}}, {})}
        except Exception as e:
            return {"error": str(e)}
    return {"error": f"unknown tool {name}"}

Setting temperature=0.0 is deliberate for evaluation. While it does not guarantee identical outputs across runs due to floating-point non-determinism in GPU inference, it minimizes variance and makes scores more comparable.

Building the Scorer

The scorer inspects the trajectory and returns a score between 0 and 1, plus a reason string for debugging. Here is a scorer that handles the schema defined earlier.

import re

def score_task(trajectory, scorer_spec):
    if scorer_spec["type"] == "tool_call_match":
        return score_tool_call_match(trajectory, scorer_spec)
    if scorer_spec["type"] == "exact_match":
        return score_exact_match(trajectory, scorer_spec)
    return {"score": 0.0, "reason": f"unknown scorer {scorer_spec['type']}"}

def score_tool_call_match(traj, spec):
    reasons = []
    score = 0.0

    expected_tool = spec.get("expected_tool")
    tool_calls = [s for s in traj["steps"] if s.get("tool") == expected_tool]
    if not tool_calls:
        return {"score": 0.0, "reason": f"never called {expected_tool}"}

    score += 0.4
    reasons.append("called expected tool")

    expr_re = spec.get("expected_expression_regex")
    if expr_re:
        matched = any(
            re.search(expr_re, json.dumps(s["args"]))
            for s in tool_calls
        )
        if matched:
            score += 0.3
            reasons.append("expression matched expected pattern")
        else:
            reasons.append("expression did not match")

    ans_re = spec.get("expected_final_answer_regex")
    if ans_re and traj["final_answer"]:
        if re.search(ans_re, traj["final_answer"]):
            score += 0.3
            reasons.append("final answer matched")
        else:
            reasons.append("final answer did not match")

    return {"score": round(score, 2), "reason": "; ".join(reasons)}

def score_exact_match(traj, spec):
    target = spec["expected"]
    got = (traj["final_answer"] or "").strip()
    ok = target.strip().lower() in got.lower()
    return {"score": 1.0 if ok else 0.0, "reason": "exact match" if ok else "no match"}

Splitting the score into weighted components is more informative than a binary pass/fail. You can see whether an agent fails because it never calls the tool, calls it incorrectly, or produces the wrong final synthesis. This granularity is what makes a harness useful for debugging.

Assembling the Harness Runner

The runner ties everything together: it loads tasks, runs the agent, scores results, and writes a report.

import yaml
import json
import time
from pathlib import Path

def load_tasks(task_dir):
    tasks = []
    for path in sorted(Path(task_dir).glob("*.yaml")):
        with open(path) as f:
            tasks.append(yaml.safe_load(f))
    return tasks

def run_harness(task_dir, output_path="results.json"):
    tasks = load_tasks(task_dir)
    results = []

    for task in tasks:
        start = time.time()
        try:
            traj = run_agent(
                task["prompt"],
                task["tools"],
                max_steps=task.get("max_steps", 5),
            )
            verdict = score_task(traj, task["scorer"])
        except Exception as e:
            traj = {"steps": [], "final_answer": None}
            verdict = {"score": 0.0, "reason": f"exception: {e}"}

        results.append({
            "id": task["id"],
            "score": verdict["score"],
            "reason": verdict["reason"],
            "elapsed_s": round(time.time() - start, 2),
            "steps_taken": len(traj["steps"]),
        })
        print(f"{task['id']}: {verdict['score']} - {verdict['reason']}")

    summary = {
        "total": len(results),
        "mean_score": round(sum(r["score"] for r in results) / max(len(results), 1), 3),
        "results": results,
    }
    with open(output_path, "w") as f:
        json.dump(summary, f, indent=2)
    return summary

if __name__ == "__main__":
    run_harness("tasks/")

Running this produces a JSON report with per-task scores and an aggregate mean. You can diff reports between runs to see whether a prompt change improved or hurt performance.

Adding LLM-as-Judge Scoring

Some tasks are too open-ended for regex matching. For those, use a stronger model as a judge. The trick is to use a different model instance (or a different endpoint) so the judge is not grading its own work.

JUDGE_PROMPT = """You are grading an AI agent's performance on a task.

Task prompt:
{prompt}

Agent trajectory (tool calls and results):
{trajectory}

Final answer:
{answer}

Grading criteria:
{criteria}

Respond with a JSON object: {{"score": float 0-1, "reason": "short explanation"}}
"""

def llm_judge(task, traj, judge_client):
    criteria = task["scorer"].get("criteria", "Did the agent complete the task correctly?")
    prompt = JUDGE_PROMPT.format(
        prompt=task["prompt"],
        trajectory=json.dumps(traj["steps"], indent=2),
        answer=traj["final_answer"],
        criteria=criteria,
    )
    resp = judge_client.chat.completions.create(
        model="judge",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.0,
        response_format={"type": "json_object"},
    )
    try:
        return json.loads(resp.choices[0].message.content)
    except json.JSONDecodeError:
        return {"score": 0.0, "reason": "judge returned invalid JSON"}

When using an LLM judge, always log the judge's reasoning. If the judge gives a suspicious score, the reasoning lets you audit whether the judge itself misunderstood the task.

Best Practices

Extending the Harness

Once the core is stable, you can extend it in several directions. Add parallel execution with a thread pool to speed up large suites, since the llama.cpp server can queue requests. Add a web dashboard that reads the results JSON and plots score trends over commits. Integrate the harness into CI so every pull request that touches agent code or prompts triggers a full evaluation run.

For multi-agent systems, generalize the task schema to declare multiple agent roles and a shared scratchpad. The scorer then evaluates the collective outcome rather than a single trajectory. This requires careful prompt design to prevent agents from talking past each other, but the harness structure remains the same.

Conclusion

Building an agent evaluation harness around llama.cpp gives you a private, reproducible, and inexpensive way to measure agent quality. By separating the model server, agent runtime, task definitions, and scorer into distinct layers, you create a system that can evolve as your agents grow more complex. Start with a small suite of well-specified tasks, score them with a mix of programmatic and LLM-judge methods, and treat the results as a first-class artifact alongside your code. The discipline of writing tasks and scorers forces you to articulate what correct agent behavior actually means, and that clarity pays off every time you ship a change.

— Ad —

Google AdSense will appear here after approval

← Back to all articles