Purpose-built load generator for LLM gateways — any OpenAI-/Anthropic-compatible endpoint. Decoupled from the system under test (zero DB dependency). Closed- and open-loop load models, target tens-of-thousands concurrency.
LLM traffic has semantics a generic tool ignores: multi-turn conversations (growing context), streaming with time-to-first-token, token throughput, cost, and response/prompt caching that silently distorts results. This tool models those first-class.
Engine (staged ramp: ramp → steady hold, warmup excluded)
└ VU pool per stage (total concurrency split across scenarios by weight)
└ each VU loops: pick scenario by weight → run one Conversation
└ Conversation: inject conv-UUID (front of first user msg + header)
└ 1..turns Turns: build normalized msgs (prior assistant fed back)
→ Protocol.BuildBody → HTTP/SSE → measure total / TTFT / tokens
├ Sink: JSONL, large buffer, batched, crash-safe (decoupled from metrics)
└ Reporter: per-scenario + aggregate → report.txt + summary.json
The metrics aggregator is in-memory and always complete (feeds the report); the JSONL sink is best-effort under extreme rate (overflow counted, never blocks a VU — blocking would distort latency). At LLM rates the sink never drops.
The engine is 100% protocol-agnostic; it works on a normalized conversation. A protocol is ONE self-registering file. Adding/changing a protocol touches only that file — never the engine, sink, or reporter.
type Msg struct { Role, Content string }
type Conversation struct { Model, System string; Msgs []Msg; MaxTokens int; Stream bool }
type Turn struct { Content string; PromptTokens, CompletionTokens int }
type Protocol interface {
Name() string
Path() string // default endpoint path
BuildBody(Conversation) ([]byte, error)
ParseNonStream([]byte) (Turn, error)
ParseStream(io.Reader) (Turn, error) // owns its SSE format
}
// adapters self-register: func init(){ Register("anthropic", func() Protocol {...}) }The only stream/non-stream branch in the engine is protocol-agnostic:
if c.Stream { p.ParseStream(body) } else { p.ParseNonStream(bytes) }. TTFT is
measured by the transport (httptrace), independent of protocol and streaming.
Ship now: openai-chat (/v1/chat/completions), anthropic (/v1/messages).
Future (one file each, one register line): openai-responses, gemini, …
Protocol-specific headers (e.g. anthropic-version) go in the scenario's
headers config — not baked into code.
{
"defaults": { "protocol":"openai-chat", "target":"https://.../v1/chat/completions",
"headers": {"Authorization":"Bearer nvk_..."}, "model":"claude-haiku-4-5",
"max_tokens":64, "stream":false },
"stages": [ {"concurrency":100,"duration":"60s"}, // closed-loop (VU ramp)
{"rate":4000,"duration":"60s"}, // open-loop constant arrival rate
{"rate_from":1000,"rate_to":8000,"duration":"60s"} ], // open-loop ramp (rate sweep)
"warmup": "10s",
"arrival": "uniform", // open-loop inter-arrival: uniform (default) | poisson
"live_interval": "10s", // rolling RPS/p50/p99/err progress line ("0" disables)
"reuse_body": true, // marshal the sized body once (cache-OFF runs only)
"openloop_max_inflight": 50000, // open-loop in-flight bound (drives CO backpressure)
"cache_mode": "bust", // bust (default) | natural
"correlation": { "uuid_in_prompt": true, "header": "x-request-id" },
"thresholds": { "ttft_p95_ms":0, "p95_ms":0, "error_rate":0, "abort_on_fail":false },
"scenarios": [
{ "name":"quick-qa", "weight":70, "turns":1, "stream":false, "max_tokens":64,
"content": {"mode":"pool", "prompts":["...","..."]},
"checks": {"contains":["chatcmpl-llm-mock"], "min_completion_tokens":1} },
{ "name":"chat", "weight":20, "turns":{"min":3,"max":6}, "stream":true,
"content": {"mode":"scripted", "script":["Hi","tell me more","and then?"]} },
{ "name":"long-ctx", "weight":10, "turns":2, "stream":true, "max_tokens":256,
"content": {"mode":"sized", "approx_input_tokens":2000} }
]
}No scenarios block → a single implicit scenario from defaults (simple use).
A scenario may override protocol/target → one run can mix OpenAI-ingress
and Anthropic-ingress load against the same gateway.
- turns: fixed int or
{min,max}(distribution). Multi-turn feeds the assistant reply back so context grows like a real session. - content:
pool(random prompt),scripted(fixed dialogue),sized(generate ~N input tokens — controls input size for throughput tests). - cache_mode:
bustdefault — a per-conversation UUID at the FRONT of the first user message forces a cache miss, threads all turns of the conversation, and is the join key to server-side records. Also sent as a header. - metrics: TTFT (first token), TPOT/inter-token = (total−TTFT)/completion_tokens, output tokens/s, prompt/completion tokens, total latency, error taxonomy.
- Closed-loop (
concurrency): N VUs loop back-to-back → the max-sustained- throughput / ceiling view.weight= share of the VU pool (1000 VU → 700/200/100). - Open-loop (
rate): fire on a fixed schedule regardless of in-flight — the honest latency-vs-offered-load view. Mirrors wrk2/vegeta. The in-flight bound (openloop_max_inflight) makes a saturated target backpressure the scheduler. - Open-loop ramp (
rate_from+rate_to): the rate sweeps linearly across the stage (cumulative-arrivals inverse), walking straight through the latency knee in one stage instead of a hand-listed grid. k6 ramping-arrival-rate. - Arrival (
arrival):uniform(evenly paced) orpoisson(exponential gaps, a non-homogeneous Poisson process tracking any ramp) — Poisson reproduces real- traffic burstiness, so its tails are harsher and more honest. - Coordinated-omission correction (open-loop): latency is recorded as
done − scheduled_send, NOTdone − actual_send, so a backed-up target is charged the full queueing delay and p99/p99.9 stay honest (wrk2/vegeta semantics). warmupwindow excluded from steady-state stats; startup raisesRLIMIT_NOFILE.- Percentiles: bounded HDR-style log-linear histogram (
metrics/, ~0.4% rel resolution) — accurate p50…p99.9 on a 300s multi-kRPS run with no unbounded sample slice. Count/min/max/mean are exact. - Generator health: client-side saturation errors (ephemeral-port/FD exhaustion) listed separately so a generator bottleneck is never misread as a server fault. Single-host ceiling (distributed generation intentionally omitted).
- checks (per scenario): a 200 whose body fails an assertion (
contains/not_containssubstrings,min/max_completion_tokens) is counted as a failure (check_failed), catching wrong/truncated content behind a 200. k6 checks. - abort_on_fail (thresholds): stop the run after the first stage that breaks a threshold — an arrival-rate sweep halts as soon as a rate breaks the SLO.
results-<ts>.jsonl— one line per turn (conv-UUID, scenario, turn, stream, start, CO latency + raw svc latency, ttft, dns/conn/tls/transfer breakdown, status, prompt/completion tokens, content_len, err).report-<ts>.txt— per-scenario + aggregate (latency p50…p99.9 + TTFT percentiles, RPS, tokens/s, error breakdown, threshold pass/fail, generator health).summary-<ts>.json— machine-readable (per-stagestageStat).report-<ts>.csv— one row per stage for spreadsheets/notebooks.metrics-<ts>.prom— Prometheus text exposition (textfile collector / Pushgateway).- regression compare:
loadtest -compare old.json,new.json [-regress-pct 10]diffs two runs by stage, flags RPS drops / tail blow-ups, exits non-zero on a regression (CI gate). No load is generated. join.sh— optional post-step template: if your gateway logs each request to a database, join the client JSONL against the server-side rows by conv-UUID into a combined client+server report. Edit it to match your schema; the load tester itself has no DB dependency.
Layered Go packages — the design's layers are compiler-enforced, and the engine is importable as a library:
cmd/loadtest/ CLI: flags, wiring, the crash-safe JSONL sink, signals (thin)
protocol/ Protocol interface + registry + openai-chat / anthropic adapters
(the ONLY place a wire format lives) + SSE inter-token timing
metrics/ lock-free HDR histogram, Sampler, Stat/StageStat (the aggregator)
config/ declarative JSON profile: schema, defaults, validation, content gen
engine/ closed-loop (RunStage) + open-loop/ramp/poisson + CO (RunStageOpenLoop),
the request transport, response checks, live progress
report/ text report, summary.json, CSV, Prometheus textfile, regression compare
Dependency graph (acyclic): protocol and metrics are leaves; config →
{protocol, metrics}; engine → {config, protocol, metrics}; report → {config,
metrics}; cmd/loadtest → {config, engine, report}. Single static binary; runs
on any generator host.