Benchmarking AI agents: node pools, job queues, and cost per step

Five workload classes with five different scaling shapes, a bounded queue that fans tests into Kubernetes Jobs, and the reason your autoscaler makes terrible decisions about 30 GB virtual machines.

The whole platform exists to answer one question:

Did this change make the agent better?

Answering it means running N scenarios × M repeats, recording pass rate, cost and duration for each, and comparing runs. That is a benchmark, and building one that runs on Kubernetes surfaces a problem that does not exist at small scale: your workloads have wildly different scaling shapes, and a single node pool serves none of them well.

Five workload classes

The Terraform ended up with five pools plus the system one, and the interesting part is that no two share a scaling policy:

resource "azurerm_kubernetes_cluster_node_pool" "adminnp" {
  name       = "ooadminnp"
  node_count = var.admin_node_count      # fixed — the control plane
}

resource "azurerm_kubernetes_cluster_node_pool" "storagenp" {
  name                = "oostoragenp"
  min_count           = var.storage_min_node_count
  max_count           = var.storage_max_node_count
  enable_auto_scaling = true
  os_disk_size_gb     = 300
  node_labels = { "longhorn.io/storage" = "true" }
  node_taints = ["longhorn=true:NoSchedule"]
}

resource "azurerm_kubernetes_cluster_node_pool" "usernp" {
  name                = "oousernp"
  min_count           = var.user_min_node_count
  max_count           = var.user_max_node_count   # 20
  enable_auto_scaling = true
  max_pods            = 30
}
Pool Shape Why
ooadminnp fixed, small API, UI, Mongo, broker — always on, never bursty
oostoragenp autoscaling, 300 GB disks, tainted Longhorn replicas; sized for disk, not CPU
oobenchnp fixed benchmark orchestrator Jobs — one per run, tiny
ooagentnp fixed agent Jobs — CPU-light, they wait on LLM calls
oousernp autoscaling 1→20, huge VMs the virtual computers

The variables carry their own cost annotations, which I recommend as a habit:

variable "user_vm_size" {
  # For testing the deployment (allows nested virtualization)
  # default = "Standard_D2_v5"  # 2 cores, 8 GB   -> $70/month,  $2.3/day
  # default = "Standard_D4_v5"  # 4 cores, 16 GB  -> $140/month, $4.6/day
  # For real environment
  default   = "Standard_D16_v5" # 16 cores, 64 GB -> $560/month, $18.5/day
}

Twenty of those at full scale is roughly $370 a day. Having that number in the variable file, where you change the value, is worth more than any dashboard. It is also the number that makes min_count = 1 non-negotiable.

Why one pool does not work

The temptation is a single autoscaling pool. Three reasons it fails here.

Nested virtualisation is a size property. Only some VM families expose /dev/kvm to guests. The computers must land on those; nothing else needs to. Mixing them means either overpaying for every pod or discovering the forty-minute install on a node that cannot do KVM.

Autoscaler heuristics are tuned for stateless pods. It assumes a pod is cheap to move: evict, reschedule, done. A computer pod is 30 GB of RAM, a 100 GB disk, and possibly a Windows installation in progress. Consolidation, which is a good default, is destructive here — which is why every computer carries cluster-autoscaler.kubernetes.io/safe-to-evict: "false".

Blast radius. Computers run privileged with hostPath /dev/kvm. Keeping them on their own pool means they never share a node with the API, the database, or anything holding a credential.

There is also max_pods = 30 on the user pool, which is deliberate. The default is higher, and it does not matter how many pod slots a node advertises when each pod wants 30 GB of RAM — a 64 GB node fits two. Capping pod density stops the scheduler from making optimistic packing decisions it cannot honour.

The benchmark, as a queue

A benchmark is a Kubernetes Job that spawns test Jobs. The fan-out is a bounded worker pool over an asyncio.Queue:

class TestRunnerQueue:
    def __init__(self, jobs: list[TestJob], concurrency_limit: int, wsc: WSClient, ws_path: str):
        self.queue = asyncio.Queue()
        for job in jobs:
            self.queue.put_nowait(job)
        self.concurrency_limit = concurrency_limit

    async def run_all(self):
        workers = [asyncio.create_task(self.worker(i)) for i in range(self.concurrency_limit)]
        await asyncio.gather(*workers)

    async def worker(self, worker_id: int):
        while not self.queue.empty():
            job = await self.queue.get()
            try:
                await self.handle_test_job(...)
            except Exception as e:
                logger.exception(f"[Worker {worker_id}] Job {job.test_config_name} #{job.run_number} failed: {e}")
            finally:
                self.queue.task_done()

Fifty jobs, concurrency_limit workers, each pulling the next job when it finishes. Standard, and correct here for a specific reason: the concurrency limit is a money dial. Ten workers means up to ten Windows VMs at once, which is a known hourly rate. Unbounded fan-out means the autoscaler discovers twenty nodes and you discover the invoice.

The try/except/finally matters more than it looks. One test failing must not kill the worker — with fifty jobs in flight, an unhandled exception in worker 3 silently reduces your throughput and leaves jobs unprocessed with no error at the benchmark level. Catch, log, continue, and let the result record the failure.

Each test Job then does the full sequence:

from core.k8s import (deploy_computer, deploy_agent, is_computer_running,
                      wait_until_agent_job_completes, register_computer_with_guacamole,
                      delete_test)
from core.oo_servers import check_oo_servers
from core.functions import FunctionExecutor, evaluate
from core.storage import AzureBlobStorageClient

Provision a computer (from a snapshot), wait for it, verify the control servers answer, register it with the remote-desktop gateway, run the scenario’s setup hooks, deploy the agent, wait, run teardown and evaluation, upload artifacts, tear everything down.

check_oo_servers deserves a mention. Before the agent starts, the test Job health-checks all seven control servers inside the guest. Without that gate, a guest that booted but whose scheduled tasks did not fire produces a test failure rather than an infrastructure failure — and those get counted as agent regressions, which corrupts exactly the number you built the platform to measure.

Recording results

Every run produces four kinds of output:

  • Structured results in MongoDB — the test instance, its steps, per-step validation, cost, duration.
  • Video in Azure Blob Storage — the recording of the desktop.
  • LLM traces in Langfuse — every prompt, completion and token count.
  • Logs in Elasticsearch, with Grafana for cluster metrics.

sum_costs aggregates the per-step spend into a run total. As the evaluation post argues, cost is a result, not telemetry: a scenario that passes reliably at $4 per run and eleven minutes is a different finding from one that passes at $0.40 in ninety seconds, and only one of them is deployable.

Langfuse is the piece I would add earliest next time. When a run goes wrong, the question is always “what did the model actually see?” — and a trace showing the exact prompt, including the annotated screenshot, answers it in seconds. Video tells you the desktop was in a strange state; the trace tells you the model was shown something misleading.

Longhorn, and why not just Azure Disk

Storage got its own pool, its own labels and its own taint:

defaultSettings:
  systemManagedComponentsNodeSelector: longhorn.io/storage:true
  taintToleration: longhorn=true:NoSchedule

global:
  nodeSelector:
    longhorn.io/storage: "true"
  tolerations:
    - key: "longhorn"
      operator: "Equal"
      value: "true"
      effect: "NoSchedule"

The taint is doing real work: longhorn=true:NoSchedule means only pods that explicitly tolerate it land on storage nodes. Storage nodes have 300 GB disks and exist to hold replicas — filling them with agent pods would be an expensive mistake.

The honest assessment of Longhorn: it gave us node-local volume performance and a storage layer we controlled, which matters when the volume is a VM disk being written to constantly. It also added a distributed storage system to operate, and VolumeSnapshots went through the Azure Disk CSI driver anyway. If I were starting over I would begin with the managed CSI driver and only reach for Longhorn when a specific measured problem demanded it. We reached for it slightly ahead of the evidence.

What a benchmark actually tells you

After all of this, a benchmark run produces a table:

Scenario Runs Passed Avg cost Avg duration
teams / chat_switch 5 4 $1.82 6m 40s
teams / summarize 5 2 $4.15 11m 20s
notepad / write_file 5 5 $0.31 1m 50s

Which is worth every hour spent on hypervisors in pods and byte-level protocol decoders, because it is the first artifact in the whole project that supports a decision. “Summarise is our weak scenario, it is also our most expensive, and the two are probably the same problem” is actionable. “The agent worked when I tried it” is not.

Next, and last: what five months of this actually taught me.