Files
llm-testbench/internal/runner/runner.go
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

170 lines
5.3 KiB
Go

// Package runner fans a test catalog out across models with bounded
// concurrency and collects per-(model,test) results.
package runner
import (
"context"
"sync/atomic"
"time"
"golang.org/x/sync/errgroup"
"github.com/lukaszraczylo/llm-testbench/internal/eval"
"github.com/lukaszraczylo/llm-testbench/internal/llm"
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
)
// truncatedDetailPrefix is prepended to Score.Detail when the model's
// response was cut off by the token budget (llm.FinishReasonLength), so
// the reason a test scored the way it did is visible without cross-
// referencing FinishReason separately.
const truncatedDetailPrefix = "TRUNCATED: "
// Result is the outcome of running one test against one model.
type Result struct {
Err error
Model string
TestID string
ResponseText string
FinishReason string
Score eval.Score
Latency time.Duration
PromptTokens int
CompletionTokens int
}
// Truncated reports whether the model's response was cut off by the token
// budget before it finished (finish_reason=length), rather than completing
// normally. A truncated response's Score is still whatever the evaluator
// computed on the partial text; Truncated is the signal that the score may
// not reflect what the model would have said with a larger budget.
func (r Result) Truncated() bool {
return r.FinishReason == llm.FinishReasonLength
}
// TotalTokens returns PromptTokens + CompletionTokens, the total token
// usage billed for this (model, test) call.
func (r Result) TotalTokens() int {
return r.PromptTokens + r.CompletionTokens
}
// Config controls how the Runner fans work out.
type Config struct {
Reporter ProgressReporter
Concurrency int
Temperature float64
MaxTokensDefault int
}
// Runner executes a set of tests against a set of models.
type Runner struct {
client llm.Client
cfg Config
}
// New builds a Runner that issues requests through client.
func New(client llm.Client, cfg Config) *Runner {
if cfg.Reporter == nil {
cfg.Reporter = NoopProgressReporter{}
}
return &Runner{client: client, cfg: cfg}
}
// Run executes every combination of models x tests, bounded by
// cfg.Concurrency in-flight calls, and returns one Result per combination.
// A per-call failure is captured in Result.Err rather than aborting the run.
func (r *Runner) Run(ctx context.Context, models []string, tests []testkit.Test) []Result {
type job struct {
model string
test testkit.Test
}
jobs := make([]job, 0, len(models)*len(tests))
for _, m := range models {
for _, t := range tests {
jobs = append(jobs, job{model: m, test: t})
}
}
results := make([]Result, len(jobs))
g, gctx := errgroup.WithContext(ctx)
concurrency := r.cfg.Concurrency
if concurrency <= 0 {
concurrency = 1
}
g.SetLimit(concurrency)
r.cfg.Reporter.ReportStart(len(tests), len(models), concurrency)
var completed int32
// go.mod's go directive is 1.22+, so loop variables are already
// per-iteration scoped; no manual i, j := i, j shadowing needed (N4).
for i, j := range jobs {
g.Go(func() error {
res := r.runOne(gctx, j.model, j.test)
results[i] = res
// atomic.AddInt32 makes the completion counter safe under
// Config.Concurrency-many concurrent goroutines; ReportDone
// itself must also be concurrency-safe per the ProgressReporter
// contract.
done := atomic.AddInt32(&completed, 1)
r.cfg.Reporter.ReportDone(int(done), len(jobs), res)
return nil
})
}
// Errors are captured per-result, not surfaced from Run; g.Wait() only
// ever returns non-nil if a goroutine itself returned an error, which
// runOne never does.
_ = g.Wait()
return results
}
// runOne executes a single (model, test) combination.
func (r *Runner) runOne(ctx context.Context, model string, test testkit.Test) Result {
// cfg.MaxTokensDefault is a floor, not a fallback used only when the
// test leaves MaxTokens unset: a per-test value only ever raises the
// budget, it never lowers it below the default. A reasoning model can
// burn thousands of tokens on reasoning_content before ever writing an
// answer to content, so a small per-test MaxTokens (sized for the
// answer alone) previously starved the model into finish_reason=length
// with an empty answer.
maxTokens := max(test.MaxTokens, r.cfg.MaxTokensDefault)
messages := make([]llm.Message, 0, 2)
if test.System != "" {
messages = append(messages, llm.Message{Role: "system", Content: test.System})
}
messages = append(messages, llm.Message{Role: "user", Content: test.Prompt})
req := llm.Request{
Model: model,
Messages: messages,
MaxTokens: maxTokens,
Temperature: r.cfg.Temperature,
}
resp, err := r.client.Complete(ctx, req)
if err != nil {
return Result{Model: model, TestID: test.ID, Err: err}
}
normalized := testkit.Normalize(resp.Text)
score := test.Eval.Evaluate(ctx, normalized)
if resp.FinishReason == llm.FinishReasonLength {
score.Detail = truncatedDetailPrefix + score.Detail
}
return Result{
Model: model,
TestID: test.ID,
Score: score,
ResponseText: normalized,
FinishReason: resp.FinishReason,
Latency: resp.Latency,
PromptTokens: resp.PromptTokens,
CompletionTokens: resp.CompletionTokens,
}
}