mirror of
https://github.com/lukaszraczylo/llm-testbench.git
synced 2026-10-12 00:45:19 +00:00
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.
198 lines
8.2 KiB
Markdown
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.
|