DEV Community

Mohd Yusuf Khan
Mohd Yusuf Khan

Posted on Originally published at learn.homingo.co.in Fully Autonomous

How to test an LLM agent: traces, budgets and an eval gate in CI (Python)

Originally published on Homingo Learn.

An agent you cannot measure is an agent you cannot change safely. Three pieces make it measurable: traces, budgets and an eval gate.

Unit tests cover the deterministic parts of an agent: parsers, validators, tool wrappers. They say nothing about whether the agent still does its job after you change a prompt. For that you need three things working together: traces that record what each step did, budgets that stop a run before it burns tokens in a loop, and an eval gate that scores the agent on fixed tasks and fails the build when the score drops. This guide builds all three in plain Python.

1. Trace every step as one JSON line

Write one line per model call or tool call: which agent, which task, what it did, how many tokens, how long, and how it ended. JSON Lines files are easy to grep, diff and load into a notebook.

import json
import time
from contextlib import contextmanager

class Tracer:
    def __init__(self, path: str, run_id: str):
        self.path, self.run_id = path, run_id

    @contextmanager
    def step(self, agent: str, task: str, kind: str):
        rec = {"run": self.run_id, "ts": time.time(), "agent": agent, "task": task, "kind": kind, "tokens": 0}
        start = time.perf_counter()
        try:
            yield rec  # the caller fills rec["tokens"], rec["attempt"], rec["input_chars"], ...
            rec.setdefault("status", "ok")
        except Exception as e:
            rec["status"], rec["error"] = "error", str(e)
            raise
        finally:
            rec["ms"] = round((time.perf_counter() - start) * 1000)
            with open(self.path, "a", encoding="utf-8") as f:
                f.write(json.dumps(rec, default=str) + "\n")
Enter fullscreen mode Exit fullscreen mode

When a report comes out wrong, the trace tells you which agent produced the bad step, how many tokens and how long it took, and (if you fill those fields) its input size and attempt number, instead of a guess.

2. Budgets: check before every call

Agents loop. A tool returns an error, the agent tries again, and again. A budget object checked before each model call turns that into a clean stop with a clear reason:

class BudgetExceeded(RuntimeError):
    pass

class Budget:
    def __init__(self, total_tokens: int, per_agent_tokens: int, max_calls: int):
        self.total, self.per_agent, self.max_calls = total_tokens, per_agent_tokens, max_calls
        self.used, self.by_agent, self.calls = 0, {}, 0

    def check(self, agent: str, estimate: int) -> None:
        """Call before every model call; counts the attempt even if the call then fails."""
        if self.calls + 1 > self.max_calls:
            raise BudgetExceeded(f"call cap {self.max_calls} reached")
        if self.used + estimate > self.total:
            raise BudgetExceeded(f"run budget {self.total} tokens would be exceeded")
        if self.by_agent.get(agent, 0) + estimate > self.per_agent:
            raise BudgetExceeded(f"{agent} would exceed {self.per_agent} tokens")
        self.calls += 1

    def charge(self, agent: str, tokens: int) -> None:
        """Call after a model call with the real token count from the API response."""
        self.used += tokens
        self.by_agent[agent] = self.by_agent.get(agent, 0) + tokens
Enter fullscreen mode Exit fullscreen mode

Charge the real token count from the API response after each call, not the estimate. The estimate (for example, prompt characters divided by four plus the maximum output tokens) only decides whether to make the call at all.

def call_model(agent, task, prompt, budget, tracer, client, max_out=800):
    budget.check(agent, estimate=len(prompt) // 4 + max_out)
    with tracer.step(agent, task, "model") as rec:
        rec["input_chars"] = len(prompt)
        reply = client.complete(prompt, max_tokens=max_out)  # your model client
        rec["tokens"] = reply.input_tokens + reply.output_tokens
    budget.charge(agent, rec["tokens"])
    return reply.text
Enter fullscreen mode Exit fullscreen mode

One model call with both pieces wired in.

3. An eval set: fixed tasks, a score, a threshold

Pick a small, fixed set of tasks that represent the job: ten to twenty is enough to start. For each run, compute a score you can defend. For a research agent that writes cited reports, a good one is the share of report sentences that a separate checker marks as supported by their cited sources; add the run’s token count and failure count next to it.

import json
import statistics
import sys
import traceback

from agents.team import run_agent  # returns an object with .sentences, .supported, .tokens

def load_tasks(path: str) -> list[dict]:
    with open(path, encoding="utf-8") as f:
        return json.load(f)  # e.g. [{"topic": "..."}, ...]

def run_evals(tasks, min_score: float = 0.85) -> int:
    if not tasks:
        print("eval: no tasks")
        return 1
    scores, failures, tokens = [], 0, 0
    for task in tasks:
        try:
            result = run_agent(task)
            scores.append(result.supported / max(1, result.sentences))
            tokens += result.tokens
        except Exception:
            failures += 1
            scores.append(0.0)
            traceback.print_exc()
    mean = statistics.mean(scores)
    print(f"eval: mean {mean:.3f} over {len(tasks)} tasks, {failures} failed, {tokens} tokens, threshold {min_score}")
    return 0 if mean >= min_score and failures == 0 else 1

if __name__ == "__main__":
    sys.exit(run_evals(load_tasks("evals/tasks.json")))
Enter fullscreen mode Exit fullscreen mode

evals/run.py (run with python -m evals.run). run_agent is your agent entry point.

4. Run the gate in CI

Run the eval script on every pull request that touches prompts, agent code or dependencies. The exit code is the gate. Keep the API key in the repository’s secrets (note that secrets are not passed to pull requests from forks, so the gate only runs on branches in your own repository):

name: agent evals
on:
  pull_request:
    paths: ["agents/**", "prompts/**", "evals/**", "requirements.txt"]
jobs:
  evals:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements.txt
      - run: python -m evals.run
        env:
          MODEL_API_KEY: ${{ secrets.MODEL_API_KEY }}
Enter fullscreen mode Exit fullscreen mode

.github/workflows/evals.yml

What to watch over time

  • Score and cost together. A prompt change that raises the score but doubles the tokens is a trade-off, not a win.
  • Failure rate. A run that crashes on one task in twenty will likely crash on a similar share of real tasks.
  • Variance. Model output varies between runs, so a single run near the threshold can flap. Run the set two or three times and gate on the mean, or keep a small margin above the threshold.

These pieces slot into a full agent team: see our multi-agent research system guide for the planner, researchers, shared memory and source checker they measure.

Multi-agent systems: build an AI research team: Six modules, each a short video lesson, a quiz check and a hands-on coding round that adds one tested piece of the team to your own repo: the planner contract, handoffs, the tool layer, shared memory, the source checker, budgets, traces and an eval gate. The capstone is your team running live on a topic of your choice, with its reports, eval scores and code in a public GitHub repo. For builders who know Python and have called a model API. Certificate on completion. See the course


Disclosure: this guide is by Homingo, the team behind the course linked above. Everything in it works without it.

Top comments (0)