Framework runs a category/subcategory test catalog against multiple LLMs through an OpenAI-compatible endpoint. Deterministic evaluators score each response. Reports render as table, markdown, or JSON.
12 KiB
llm-testbench — LLM accuracy testing framework
Plan authored by fable (planner/orchestrator). Implementation: sonnet. Verification: opus.
Goal
Go framework that runs a catalog of realistic tests (category → subcategory) against multiple LLMs via an OpenAI-compatible endpoint, scores responses deterministically, and renders a comparison table.
Non-goals / constraints
- No Microsoft-related content anywhere (no Azure, PowerShell, Windows, TypeScript-the-MS-angle is fine as a language).
- No LLM-as-judge in v1 — deterministic evaluators only.
- Unit tests never hit the network. Live runs are explicit (
llmtest run).
Verified facts (grounded this session)
- Endpoint:
https://llm-gateway.example.com/v1— keyless, standard OpenAI chat/completions. - Models all available:
uni/deepseek-v4-flash-0731,uni/qwen3.6-27b,uni/btl-4. - Real response observed: content may carry leading whitespace (
"\n\nOK") — normalize before evaluating: trim, strip a leading<think>...</think>block if present. - Toolchain on this machine: go1.26.5 darwin/arm64.
python3andcc(clang) assumed present on macOS; exec-evaluators must detect and markskippedif a toolchain is missing (score excluded, not zero).
Architecture
Module: github.com/lukaszraczylo/llm-testbench. Go ≥1.23. Dependencies allowed:
gopkg.in/yaml.v3, golang.org/x/sync/errgroup. Nothing else without a reason.
cmd/llmtest/main.go CLI: run | list. Flags: --config, --category, --subcategory,
--models (csv override), --format table|markdown|json,
--concurrency, --timeout, --verbose
internal/config/ Config load (YAML) + env override LLMTB_API_KEY, validation
internal/llm/ Client interface + OpenAI-compatible impl (retry w/ backoff ×2
on transport/5xx, temperature 0, per-request timeout)
internal/eval/ Evaluator interface + implementations (below)
internal/testkit/ Test type, Registry, response normalization
internal/runner/ model×test fan-out, bounded concurrency (errgroup), Result
internal/report/ table (text/tabwriter), markdown, json renderers
internal/tests/ the catalog: one file per category
Core types (shape, adjust idiomatically)
// llm
type Message struct { Role, Content string }
type Request struct { Model string; Messages []Message; MaxTokens int; Temperature float64 }
type Response struct { Text string; PromptTokens, CompletionTokens int; Latency time.Duration }
type Client interface { Complete(ctx context.Context, req Request) (Response, error) }
// eval
type Score struct { Value float64; Detail string; Skipped bool } // Value 0..1
type Evaluator interface { Evaluate(ctx context.Context, response string) Score }
// testkit
type Test struct {
ID, Category, Subcategory, Description string
System, Prompt string
MaxTokens int
Eval eval.Evaluator
}
// runner
type Result struct { Model, TestID string; Score eval.Score; Latency time.Duration; Tokens int; Err error }
Evaluators (DRY, generics where natural)
ContainsAll(substrings...)/ContainsAny(...)— case-insensitive; partial credit for All (matched/total).NotContains(...)— composable guard (e.g. must NOT suggestkubectl edit).Regex(pattern)— full credit on match.Equals(want)— trimmed, case-insensitive.Numeric[T constraints.Float | constraints.Integer](extract func(string) (T, error), want, tol T)— extracts last number (or JSON field) from response.JSONField(path, want)— parses first JSON object/array in response (strip code fences), compares field. Use generics for want type.All(weighted ...WeightedEvaluator)/ composite — weighted mean.- Exec evaluators (compile/run extracted code, compare stdout):
GoRun(harness string)— extract first fenced code block, append/wrap with per-test harnessmain/driver,go runin temp dir (own go.mod), compare stdout to expected. Timeout 30s.PyRun(harness string)— same viapython3.CRun(harness string)— compile withcc, run, compare stdout.- Toolchain missing →
Score{Skipped: true, Detail: "toolchain missing: X"}.
- Shared helper:
ExtractCodeBlock(response, lang string)— first matching fenced block, fallback to whole response if no fences.
Response normalization (testkit, applied once before eval): trim space, strip leading <think>…</think> (and <reasoning> variants), collapse Windows line endings.
Scoring & report
- Per test: 0..1. Skipped tests excluded from aggregates (shown as
skip). - Table 1: rows = tests (grouped by category/subcategory), columns = models, cells =
score (Ntok)where Ntok = prompt+completion tokens that test burned on that model — orERR/skip. - Table 2: category rollup — rows = category, columns = models, cells = mean score.
- Table 3: model summary — overall mean, tests passed (≥0.99), partial, failed, errors, mean latency, total tokens, and avg tok/s = Σ completion_tokens / Σ latency-seconds across all non-skipped tests (aggregate ratio, not mean of per-test ratios — robust to short responses).
--format markdownrenders the same as GitHub tables;jsondumps raw results.
Config (config.yaml, example committed as config.example.yaml)
endpoint: https://llm-gateway.example.com/v1
api_key: "" # optional; env LLMTB_API_KEY wins
models:
- uni/deepseek-v4-flash-0731
- uni/qwen3.6-27b
- uni/btl-4
concurrency: 4
request_timeout: 120s
max_tokens_default: 4000
Test catalog (16 tests — implement ALL)
Grounded in the operator's real historical tasks (vector search engine work, Proxmox/LXC homelab, GitOps k8s home cluster, golangci strictness, semver tooling, macOS BSD-vs-GNU pain, Vue 3 frontends, agent orchestration). No hello-worlds. Exact prompt wording: sonnet authors it, but each prompt must be fully self-contained (all needed data inline) and pinned to one deterministic expected answer.
Ground-truth rule (non-negotiable): every constant expected value (numbers, sizes, orderings) must
carry a // ground truth: comment showing the derivation, and where cheaply possible the unit test
must recompute it (e.g. compute expected cosine similarity in the Go unit test rather than hardcode).
programming/golang
go-struct-align— give a Go struct with wasteful field order (mix of bool/int64/int32/string/byte); ask for reordered struct minimizing padding on 64-bit. Eval: Regex/ContainsAll on required field order. Ground truth: derive by alignment rules in comment; unit test may verify with unsafe.Sizeof on both variants.go-worker-pool— implementPool[T, R any](in []T, workers int, f func(T) R) []Rpreserving input order. Eval: GoRun harness feeding 100 items with a slow f, checks order + concurrency actually used (duration bound or counter).go-semver-classify— given 5 conventional-commit messages (incl. a body containing the word "breaking" in a non-breaking sense trap, afeat:, afix:, achore:), ask for the resulting semver bump as JSON{"bump":"major|minor|patch"}. Eval: JSONField.
programming/python
py-log-triage— inline ~15 lines of kubelet/pod log output (mixed OOMKilled, ImagePullBackOff, normal); ask for a python3 script printing counts per failure type astype=countsorted. Eval: PyRun compare stdout.py-cosine— two 8-dim float vectors inline; ask for cosine similarity to 4dp (plain number only). Eval: Numeric with tol 0.0005. Ground truth computed in unit test with math.
programming/typescript
ts-debounce-composable— Vue 3 composableuseDebouncedRef<T>(source: Ref<T>, ms: number): Ref<T>with proper cleanup. Eval: ContainsAll(export function useDebouncedRef,watch,clearTimeout/onScopeDisposeany, generic<T>) + NotContains(any) — weighted composite.
programming/c
c-struct-size— C struct with char/double/int/short members inline; asksizeofon LP64 (answer: number only). Eval: Numeric exact. Ground truth: derivation comment + optional CRun cross-check in unit test guarded by cc presence.
operations/macos
macos-timeout-portability— script fails withtimeout: command not foundon stock macOS; why + two portable fixes. Eval: composite ContainsAny(coreutils|gtimeout) + ContainsAny(perl -e 'alarm'|background & kill|builtin) + ContainsAll(GNU).macos-launchd-cron— persistent job on macOS that survives reboot and runs every 5 min: which mechanism + minimal plist keys. Eval: ContainsAll(launchd/launchctl, StartInterval or StartCalendarInterval, LaunchAgents/LaunchDaemons).
operations/linux
linux-pct-exec— CT 251 lives on Proxmox host 10.0.0.100, not directly reachable from laptop; one-line command to checksystemctl status myserviceinside it. Eval: Regex requiringssh+10.0.0.100+pct exec 251+ command.linux-systemd-oneshot— write a systemd unit that runs a script once at boot after network-online, no restart loops. Eval: ContainsAll(Type=oneshot,network-online.target,WantedBy=multi-user.target,RemainAfterExitoptional credit).
operations/kubernetes
k8s-crashloop-gitops— inlinekubectl describeexcerpt: CrashLoopBackOff, last state OOMKilled exit 137, limits 128Mi. Cluster is ArgoCD-managed. Ask: root cause + correct fix procedure. Eval: composite ContainsAll(OOM/memory, limit raise) + ContainsAny(git commit|ArgoCD|GitOps) + NotContains(kubectl edit,kubectl patch).
research/web
web-robots-ai-crawlers— inline a robots.txt with mixed rules (GPTBot disallowed, ClaudeBot allowed, PerplexityBot disallowed, Googlebot allowed); ask which AI crawlers can fetch/docs/. JSON array answer. Eval: JSONField/exact set compare.
research/whitepapers
paper-hnsw-params— inline a 150-word excerpt describing HNSW parameters (M, efConstruction, efSearch — sonnet writes a technically correct excerpt); ask three precise extraction questions answered as JSON. Eval: JSONField ×3 composite.
research/codebase
code-trace-go— inline a ~20-line Go function (bit manipulation + recursion, deterministic); ask for exact output for a given input, number only. Eval: Numeric exact. Ground truth: unit test RUNS the same function.
agents (subcategories: tool-routing, planning)
agent-tool-routing— inline roster of 6 tools with descriptions (search_web, read_file, run_shell, query_db, send_email, none) + 4 mini-tasks; ask JSON mapping task→tool. Eval: JSONField ×4.agent-plan-ordering— deployment scenario with dependencies (build→test→backup→deploy→verify→rollback-on-fail); ask for ordered JSON array of step ids. Eval: exact array compare (order matters).
(16–17: both under agents; subcategories tool-routing and planning. Total = 17 tests.)
Quality gates (must pass before you report done)
go build ./...go vet ./...go test ./...— offline only, mock Client; evaluator tests cover good/bad/edge; exec-evaluator tests guarded by toolchain detection.golangci-lint runif the binary exists — must be clean incl. fieldalignment (fix orders, do not disable linters).gofmt -l .empty.
Deliverables
- Full source per layout above,
go.mod,config.example.yaml,.gitignore(binaries, config.yaml),README.md(usage, adding a test, adding an evaluator). - Do NOT
git commit— orchestrator commits after opus verification. - Do NOT call the live endpoint anywhere in code paths exercised by
go test.
Report back
Files created; output of each quality gate (pass/fail + failure detail); any deviation from this plan with one-line reason; open concerns.