Files
llm-testbench/README.md
T
lukaszraczylo ace50757e4 fix: floor token budgets and surface truncation, add live progress
Reasoning models burn thousands of completion tokens before the
answer. Small per-test caps caused silent truncation and zero scores.
The runner now floors max_tokens at the config default (12000). The
client parses finish_reason. Reports mark truncated cells and the
JSON format carries response text for debugging. A stderr progress
reporter prints one line per completed request; --quiet disables it.
2026-08-17 01:55:14 +01:00

198 lines
8.2 KiB
Markdown

# llm-testbench
llm-testbench runs a catalog of realistic tests against multiple language
models through an OpenAI-compatible endpoint, scores each response with a
deterministic evaluator, and renders a comparison table. It does not use an
LLM as a judge.
## Requirements
- Go 1.25 or later.
- `python3` and `cc` on PATH, only if you want the Python and C
exec-evaluator tests to run instead of skip.
- An OpenAI-compatible chat completions endpoint to test against.
**Warning: the `go-worker-pool`, `py-log-triage`, and `c-struct-size`
exec-evaluator tests compile and run model-generated code** (`internal/eval`'s
`GoRun`/`PyRun`/`CRun`), **with the same privileges as the `llmtest`
process itself.** Each call runs in its own temp directory with a minimal
environment (`PATH`, an isolated `HOME`/`TMPDIR`, and `GOCACHE`/`GOMODCACHE`
for `GoRun`), not the operator's full environment - but this is not a
sandbox, and it does not stop a malicious payload from reading files the
operating-system user can read or making outbound network calls. Run
`llmtest run` in a throwaway VM or container if you test models you do not
already trust.
## Setup
Copy the example config and set your endpoint and models:
```sh
cp config.example.yaml config.yaml
```
Set an API key through the config file's `api_key` field, or through the
`LLMTB_API_KEY` environment variable. The environment variable always wins
over the file value.
## Usage
List the test catalog:
```sh
go run ./cmd/llmtest list
go run ./cmd/llmtest list --category programming --subcategory golang
```
Run the catalog against the configured models:
```sh
go run ./cmd/llmtest run
go run ./cmd/llmtest run --category operations --format markdown
go run ./cmd/llmtest run --models uni/deepseek-v4-flash-0731,uni/qwen3.6-27b
```
### Flags
Both `list` and `run` accept:
- `--config` - path to the YAML config file. Default: `config.yaml`.
- `--category` - filter to one category (for example `programming`).
- `--subcategory` - filter to one subcategory (for example `golang`).
`run` also accepts:
- `--models` - comma-separated model list, overriding the config file.
- `--format` - `table`, `markdown`, or `json`. Default: `table`.
- `--concurrency` - override the config file's `concurrency`.
- `--timeout` - override the config file's `request_timeout` (for example
`60s`).
- `--quiet` - suppress progress output on stderr. Progress is on by
default: a run can issue hundreds of requests at 30-50s each, and a
silent CLI reads as a hang.
By default, `run` prints one line to stderr at the start ("running N tests
x M models = K requests, concurrency C") and one line per completed
request ("[done/total] model=... test=... score=... tokens=... (latency)",
with a `TRUNCATED` marker when a response was cut off by the token
budget). stdout carries only the table/markdown/json report, so `llmtest
run --format json > results.json` stays pipeable regardless of `--quiet`.
If `config.yaml` omits `concurrency`, `request_timeout`, or
`max_tokens_default`, `internal/config` fills in `8`, `300s`, and `12000`.
The effective per-call token budget is `max(test.MaxTokens,
max_tokens_default)`: a test's own `MaxTokens` can only raise the budget
above the config default, never lower it below it. This matters for
reasoning models, which can spend thousands of completion tokens on
`reasoning_content` before writing an answer to `content`; a low budget
truncates the answer before the model reaches it.
### Output
`table` and `markdown` render three views: per-test scores grouped by
category and subcategory, a category rollup with mean score per model, and
a model summary with pass/partial/fail/error counts, mean latency, total
tokens, and average tokens per second. `json` dumps the raw per-test
results, joined with each test's category and subcategory.
A scored cell in the per-test table reads `score (Ntok)`, where `Ntok` is
the prompt plus completion tokens that test burned on that model (for
example `0.85 (312tok)`). The model summary's `TOK/S` column is an
aggregate ratio, sum of completion tokens over sum of elapsed seconds
across all non-skipped, non-error calls, not a mean of each test's own
ratio; this keeps a handful of very short responses from skewing the rate.
A cell reads `ERR` when the model call itself failed (a transport error or
a non-2xx response after retries), and `skip` when the test's evaluator
needs a local toolchain that is not installed (for example, a Python
exec-evaluator when `python3` is missing). Skipped tests are excluded from
every mean.
A cell carries a trailing `!` when the model's response was cut off by the
token budget (`finish_reason=length`) - for example `0.00 (6000tok)!`. A
truncated response's score is still whatever the evaluator computed on the
partial text, so a `!` cell needs a closer look: raise
`max_tokens_default` and re-run. `table` and `markdown` print a one-line
legend explaining `!` whenever at least one cell has it. `json` carries the
same signal per result as `"truncated": true`, plus `"finish_reason"` and
`"response_text"` (the exact normalized text that was scored) so a
truncated or unexpectedly low-scoring result can be debugged without
re-running the model.
## Test catalog
The catalog lives in `internal/tests`, one file per category. Every test
has a fully self-contained prompt (all data needed to answer is inline) and
a single deterministic evaluator. Every hardcoded expected value carries a
`// ground truth:` comment showing its derivation, and where the derivation
is cheap, the test file's `_test.go` recomputes it independently (for
example, `internal/tests/python_test.go` recomputes the cosine similarity
with `math.Sqrt` rather than trusting the constant).
| Category / subcategory | Tests |
| --- | --- |
| programming / golang | `go-struct-align`, `go-worker-pool`, `go-semver-classify` |
| programming / python | `py-log-triage`, `py-cosine` |
| programming / typescript | `ts-debounce-composable` |
| programming / c | `c-struct-size` |
| operations / macos | `macos-timeout-portability`, `macos-launchd-cron` |
| operations / linux | `linux-pct-exec`, `linux-systemd-oneshot` |
| operations / kubernetes | `k8s-crashloop-gitops` |
| research / web | `web-robots-ai-crawlers` |
| research / whitepapers | `paper-hnsw-params` |
| research / codebase | `code-trace-go` |
| agents / tool-routing | `agent-tool-routing` |
| agents / planning | `agent-plan-ordering` |
## Adding a test
1. Add a `testkit.Test` value to the relevant file in `internal/tests` (or a
new file, for a new category). Give it a unique, kebab-case `ID`, a
`Category` and `Subcategory`, a self-contained `Prompt`, and an
`eval.Evaluator`.
2. Register it in that file's `register<Category>Tests` function, called
from `internal/tests/catalog.go`'s `All`.
3. Add a `// ground truth:` comment above any hardcoded expected value,
showing how you derived it. Recompute it in the test file when that is
cheap (arithmetic, a struct's `unsafe.Sizeof`, running the traced
function).
4. Add table-driven `_test.go` cases covering a correct answer, a wrong
answer, and at least one edge case (a common trap, a partial match, or a
malformed response).
5. Add the test's ID to the `wantCatalog` map in
`internal/tests/catalog_test.go`.
## Adding an evaluator
Evaluators live in `internal/eval` and implement:
```go
type Evaluator interface {
Evaluate(ctx context.Context, response string) Score
}
```
`Score.Value` is in the range 0 to 1; set `Score.Skipped` only when a
required toolchain is missing, not for a wrong answer. Compose existing
evaluators with `eval.All` (weighted mean) or `eval.Mean` (equal-weighted
mean) rather than writing a one-off composite by hand. For a check that the
built-in evaluators cannot express, wrap a closure with
`eval.EvaluatorFunc`. Add table-driven tests in `internal/eval` covering a
match, a non-match, and an edge case (empty input, malformed JSON, and so
on).
## Quality gates
```sh
go build ./...
go vet ./...
go test ./...
gofmt -l . # must print nothing
golangci-lint run # if the binary is installed
```
Every unit test runs offline against a mock `llm.Client` or a local
`httptest.Server`; none of them call the live endpoint. The exec-evaluator
tests (`GoRun`, `PyRun`, `CRun`) detect a missing `go`, `python3`, or `cc`
toolchain and skip rather than fail.