Prompt Playoff — Prompt Optimization and Benchmarking for Local LLMs
Deterministic prompt-technique selection, compilation and benchmarking for local LLMs: it picks the technique your task needs, compiles the prompt that technique implies, measures it on your own Ollama or OpenAI-compatible model, and searches for a better one — chain-of-thought, self-consistency, ReAct, schema-first extraction and 25 more, ranked on your data rather than on a blog post's opinion.
# macOS
git clone https://github.com/KazKozDev/prompt-playoff.git && cd prompt-playoff && ./start.command
# Linux (Python 3.11+, after cloning)
python3 -m venv .venv && .venv/bin/pip install -e '.[all]'
.venv/bin/python -m prompt_playoff serve
Double-click start.command on macOS. Linux runs the same server from the checkout.
Quick start
-
Run the commands above. On macOS,
start.commandfinds a Python 3.11+ interpreter, builds.venv, installs every optional extra, offers to install Ollama and pullllama3.2:3b, picks a free port in 8000–8020, and opens the browser. It rebuilds the environment when the checkout moved, because an editable install pinned to an old path imports fine and runs the wrong code. -
Keep Ollama running with at least one local model. Selection and compilation work without one; only measurement needs it.
-
Describe the task, read the ranking, then measure the winner instead of trusting it:
describe → ranked techniques with reasons → compiled prompt → measured result → optimized prompt
The same four steps are
recommend,compile,benchmarkandoptimizeon the command line.
Which prompting technique to use for your task
Selection runs in two passes. Hard constraints come first — declared capabilities, tool access, call budget, model class, and whether the evidence is supplied or still has to be fetched. Whatever survives is ranked on what the request looks like: dependent steps, a checkable answer, a fixed output shape, a long input, real cost of error. Not on its task type alone.
Every recommendation and every rejection carries its reasons, and says whether the number behind it was declared or measured:
Schema-first output (structured.schema-first)
• Strong declared fit for structured_extraction.
• Built for this request being exact format, verifiable.
• Unmeasured prior 0.91 from: task:structured_extraction, provider:ollama, default.
• Executes as single (1 call minimum).
The registry ships 29 techniques across 7 execution strategies — 13 single, 11 multi_stage, and one each of self_consistency, map_reduce, tool_loop, program_of_thought and tree_search. 22 of them carry the paper they come from, and the catalog links it.
Compilation then turns the task into the prompt that technique implies — its own blocks, stages and call count. Schema-first and map-reduce do not produce the same prompt with a different label on it:
prompt-playoff compile --task structured_extraction \
--input-file examples/book_excerpt.txt --schema-file examples/entity_schema.json \
--technique structured.schema-first --capabilities structured_output,system_messages
Benchmark and compare prompt techniques on your own model
benchmark runs the compiled prompt on a dataset and prints what the model actually did next to what the registry claimed:
prompt-playoff benchmark --model llama3.2:3b --model-class small --dataset entity-extraction --repeats 3
Measured: Schema-first output on llama3.2:3b
Metric Measured Declared
quality 0.867 0.840
reliability 1.000 0.960
contract pass rate 1.000 —
stability across repeats 1.000 —
mean latency (s) 0.936 —
mean tokens 204.3 —
quality is the headline grader for that data — field_f1 here, so 3 of 4 entities scores 0.86 rather than 0. reliability is contract pass rate × stability, because a technique that emits valid JSON every time but a different answer each time is not reliable. stability needs --repeats > 1 and reports the share that produced the modal answer. Latency, tokens and calls are summed across every call the technique makes, so a three-sample technique reports three calls' worth of cost.
compare ranks several techniques on one dataset, weighted by your priorities:
prompt-playoff compare --model llama3.2:3b --model-class small --dataset entity-extraction \
--techniques structured.schema-first,structured.few-shot-repair,reasoning.self-consistency,direct.explicit-constraints
Technique Weighted Quality Reliability Latency s Tokens Calls
direct.explicit-constraints 0.962 0.891 1.000 0.64 127 1.0
structured.schema-first 0.833 0.867 1.000 1.11 204 1.0
structured.few-shot-repair 0.711 0.700 1.000 1.51 366 2.0
reasoning.self-consistency 0.706 0.775 1.000 2.08 499 3.0
That contradicts the registry, which priors structured.schema-first at 0.95 for this task: on a 3B model the plainer technique wins. Results are recorded to benchmark-results/measurements.json and reused for later ranking, labelled measured instead of prior only.
Eleven datasets ship with the package, from a 6-example smoke set to entity-extraction-hard, multiconer-en and few-nerd at 200 each. prompt-playoff list-datasets prints their sizes and how many carry gold answers.
Automatic prompt optimization, natively or with DSPy
prompt-playoff optimize --model llama3.2:3b --model-class small \
--dataset entity-extraction --technique structured.schema-first \
--rounds 3 --token-cost 0.3 --export my-technique.yaml
The loop: seed candidates (baseline, plus few-shot demos bootstrapped from the train examples the baseline already gets right) → benchmark each on the train split → score with your priorities over measured quality, reliability, latency and tokens → feed the worst failures back to the model and ask for better instructions → repeat → verify the winner on data it never optimized against.
Metric (held-out) Baseline Optimized Delta
quality 1.000 1.000 +0.000
reliability 1.000 1.000 +0.000
mean tokens 211.500 201.000 -10.500
mean latency s 0.826 0.741 -0.086
Only instruction blocks are mutable — a candidate cannot win by dropping the output contract. The Pareto front over quality, reliability, tokens and latency is reported next to the scalarized winner, so a cheaper-but-slightly-worse prompt stays visible instead of being averaged away.
--backend swaps the search algorithm and nothing else: the prompt is still built by this project's compiler, executed by the technique's own strategy and graded by its graders. native reflects on measured failures, dspy:mipro searches instructions and demonstrations jointly, dspy:gepa is reflective and Pareto-selected, dspy:bootstrap does demonstrations only and needs no proposer model.
prompt-playoff optimize --model llama3.2:3b --model-class small \
--dataset entity-extraction --backend dspy:gepa --max-metric-calls 60
On entity-extraction-hard (40 examples, 26 train / 14 held out, llama3.2:3b), MIPROv2 beat the native loop by +0.064 F1 over three repeats, using about half the model calls. Neither recovered the dataset's annotation rules, because the proposer was the same 3B model — full write-up with the failure modes.
That last point is why the proposer is a separate profile. PROMPT_PLAYOFF_ENGINE_MODEL puts a stronger model on the job of writing candidate prompts while the numbers still describe your target model, and a run where the two are the same says so in its notes:
Candidate prompts were written by llama3.2:3b, the same model the numbers describe. Part of the gain may be that model's own phrasing rather than a better prompt.
Selection, compilation and grading stay LLM-free either way: the engine never chooses the technique and never scores anything.
Prompt regression testing in CI
Commit prompt-playoff.yaml with the model and the thresholds your build promises:
version: 1
model:
provider: ollama
model_id: llama3.2:3b
model_class: small
capabilities: [structured_output, system_messages]
checks:
- name: entities-schema-first
technique: structured.schema-first
task: structured_extraction
dataset: entity-extraction
repeats: 3
require:
quality_min: 0.85
reliability_min: 0.95
mean_total_tokens_max: 300
p95_latency_seconds_max: 2.0
prompt-playoff check
Exit code 0 means every bound passed, 1 means at least one regression, 2 means invalid configuration such as an unknown dataset or an unreachable provider. Requirement names are explicit fields ending in _min or _max, and an empty require block is an error — a check can never pass while enforcing nothing. --json prints machine-readable output; --update rewrites the committed bounds to the current measurements, preserving YAML comments and key order.
For wider matrices, hand the work to promptfoo:
prompt-playoff export-promptfoo --techniques structured.schema-first,direct.explicit-constraints \
--models llama3.2:3b,qwen3.5:4b --model-class small --dataset entity-extraction --output promptfoo
cd promptfoo && promptfoo eval && promptfoo view
The export writes the compiled prompts with {{input}} templated, a config covering techniques × providers, and a Python assertion bridge that calls this project's graders — so promptfoo reports the same field_f1, not a different metric wearing the same name. Multi-call techniques export their first stage only, and the command says so instead of silently truncating.
Build datasets from traces and public corpora
prompt-playoff import-traces --output datasets/from-prod.jsonl --limit 200
prompt-playoff import-hf multiconer-en --output datasets/multiconer.jsonl --limit 200
With PROMPT_PLAYOFF_TRACING set to langfuse or phoenix, every call of every technique becomes its own span, and import-traces turns observed traffic into a dataset. Rows arrive with expected: null and tagged unreviewed — a trace has no gold answer, and pretending otherwise would benchmark a model against its own past mistakes.
Four presets convert Hugging Face corpora instead: MultiCoNER v2 and Few-NERD for extraction, GSM8K for reasoning graded on the number, and MBPP for code graded by running its own tests. Licence and citation are printed on every import.
How it differs from DSPy, promptfoo and PromptWizard
DSPy optimizes the prompt inside a module you have already chosen: you write dspy.ChainOfThought or dspy.ReAct yourself, and the optimizer tunes that module's instructions and demonstrations. Prompt Playoff makes the choice you would otherwise make by hand, deterministically and with its reasons printed, and then hands the winner to DSPy if you want its search — --backend dspy:mipro and dspy:gepa run against this project's compiler and this project's graders.
promptfoo measures prompts you have already written. It is a test harness, not a designer, and it does not tell you which technique the task needs. Prompt Playoff produces the prompt to be measured; export-promptfoo then writes a promptfoo project whose assertions call this project's graders, so both tools report the same field_f1 rather than two metrics sharing a name.
PromptWizard and other agent-driven optimizers ask an LLM to critique and rewrite instructions. Prompt Playoff does that too, but only during optimization: selection is scored constraints, grading is deterministic code, and no model is ever asked how good its own answer was.
When not to use it
Do not use it for one prompt on one task that already works — selection needs something to rank against, and every number here comes from examples with expected answers. It pays for itself when several techniques are plausible, when you have a dataset with gold answers, when picking wrong is expensive, or when the choice has to be defended to somebody else.
It is also the wrong tool for open-ended prose. There is no LLM judge in this project, so quality is measured as field overlap, grounding overlap, contract compliance and constraint coverage — not as whether the writing is good.
How it works
One Python package with a Typer CLI, a FastAPI service and a YAML registry.
The normalizer turns a description into a TaskProfile.
The selector filters on hard constraints, then ranks on the shape of the request, and prints a reason for every accept and reject.
The compiler builds the blocks and stages that technique implies, and a strategy executor issues its 1..n provider calls.
Deterministic graders score the result, and the measurement is stored where ranking, the optimizer and the CI gate all read it.
Task → TaskProfile → Selector → Compiled prompt → Model calls → Graders → Measurement
Adding a technique is one YAML file and no Python — see docs/extending.md.
prompt-playoff new-technique structured.my-technique
prompt-playoff validate-registry # placeholders, strategies, graders, render probe
Technical architecture
Important files
start.command— macOS launcher: interpreter discovery, environment rebuild, extras, Ollama, free port, browser.src/prompt_playoff/normalizer.py— free-text description →TaskProfile, with the keyword fallback.src/prompt_playoff/selector.py(466 lines) — hard constraints, ranking, and the reason for every accept and reject.src/prompt_playoff/compiler.py(192 lines) — technique spec → prompt blocks and stages.src/prompt_playoff/strategies.py(764 lines) — the seven execution strategies and their call sequencing.src/prompt_playoff/graders.py(580 lines) — 21 deterministic graders:field_f1,exact_match,json_schema,grounding_overlap,label_accuracy,unit_tests,tool_successand the rest.src/prompt_playoff/optimizer.py(968 lines) — native search loop, Pareto front, held-out verification, technique export.src/prompt_playoff/engine.py(1116 lines) — the optional engine model, its cache, and its fail-closed authoring path.src/prompt_playoff/api.py— the HTTP surface and the job queue behind it.
HTTP API
GET /v1/capabilities /v1/techniques /v1/datasets /v1/lint /v1/integrations
POST /v1/recommend /v1/select /v1/compile /v1/author /v1/run
POST /v1/benchmark /v1/compare /v1/optimize /v1/export/promptfoo
GET /v1/jobs /v1/jobs/{id} /v1/measurements
The reads mirror the CLI. Benchmark, compare and optimize return a job id immediately, because they issue real model calls; status, results, errors and the complete event stream are persisted atomically to benchmark-results/jobs.json, so the Logs view survives a restart.
Configuration
| Variable | Default | What it changes |
|---|---|---|
PROMPT_PLAYOFF_ENGINE_MODEL |
Unset — keyword parsing only | Model that reads descriptions, authors prompts, and proposes rewrites |
PROMPT_PLAYOFF_ENGINE_PROVIDER |
Provider of the target model | Provider for the engine model |
PROMPT_PLAYOFF_ENGINE_BASE_URL |
Provider default | Base URL for the engine model |
PROMPT_PLAYOFF_ENGINE_CACHE |
benchmark-results/engine-cache.json |
Engine answer cache, keyed by description, technique, scaffold, mode and engine model |
PROMPT_PLAYOFF_TRACING |
none |
langfuse, phoenix, or none |
PROMPT_PLAYOFF_MEASUREMENTS |
benchmark-results/measurements.json |
Evidence store read back into ranking |
PROMPT_PLAYOFF_JOBS_PATH |
benchmark-results/jobs.json |
Persisted job records and event logs |
PROMPT_PLAYOFF_REGISTRY |
Packaged data/ |
Alternative technique, model and dataset root |
PROMPT_PLAYOFF_API_KEY |
Unset | Fallback API key for providers without their own variable |
--engine-model, --engine-provider and --engine-base-url override the environment per run, and the web UI has the same fields. The engine is a full profile of its own — a remote proposer against a local target is the point, so nothing is inherited from the model under test.
Providers
Ollama plus eight OpenAI-compatible endpoints out of the box: openai, anthropic, together, openrouter, groq, fireworks and deepseek, each with its default base URL and its usual key variable (OPENAI_API_KEY, ANTHROPIC_API_KEY, and so on). Unknown ids work too when given a base_url. Keys resolve from the Settings request key, then model.api_key_env, then the provider default, then PROMPT_PLAYOFF_API_KEY; a missing key fails before the request and names the variable to set.
Native JSON Schema is used when the model declares structured_output. Otherwise the schema is embedded in the prompt and validated after the call, and the compiler says so in its notes.
Requirements
- Python 3.11 or newer, as declared by
pyproject.toml. - Ollama, or an OpenAI-compatible endpoint, for anything that measures. Selection and compilation run without a model.
- A local model such as
llama3.2:3b. The macOS launcher offers to pull it when Ollama has none. - Optional extras, installed only when needed:
[cli]for the terminal commands,[serve]for the HTTP API and web UI,[dspy]for the MIPROv2/GEPA/Bootstrap backends,[tracing]for Langfuse or Phoenix,[huggingface]for the corpus presets,[all]for everything. The base install is the registry, selector, normalizer and compiler alone.
start.command is a macOS launcher and depends on open, lsof and optionally Homebrew. Linux and Windows run the same server from the checkout; this repository has not been verified through a clean-machine end-to-end run on those systems.
Limitations
- Ranking still uses declared priors for any (technique, task, model) triple you have not benchmarked. The UI and the CLI mark those
prior only. - Of the 29 techniques, 6 carry
benchmarkedevidence, 16documentedand 7heuristic. The label is on every row; do not read a prior as a measurement. entity-extraction-hard(200 examples) andmulticoner-en(200, imported) are the datasets with real headroom. The others, especially the 6-exampleentity-extraction, are demonstrations.- The optimizer is only as good as the model writing its proposals. With the target model doubling as the proposer, expect rephrasings rather than genuine rule discovery — use
--engine-modelto put a stronger model on that job. tool_loopexecutes only tools present inprompt_playoff.tools, which ships with a calculator. Register your own to benchmark real agent work.- Graders are deterministic by design. There is no LLM judge, so open-ended generation is measured on grounding overlap and constraint coverage rather than on prose quality.
- The promptfoo export covers a technique's first stage only. Multi-call techniques must be measured here.
- Trace import reads from Langfuse only. Phoenix is write-only in this direction — spans go out, datasets do not come back.
Docker and development setup
docker build -t prompt-playoff .
docker run --rm -p 8000:8000 prompt-playoff
Open http://127.0.0.1:8000; the non-root image health-checks /health.
python3 -m venv .venv && source .venv/bin/activate
pip install -e '.[dev]'
make test # pytest
make lint # ruff
make validate # registry lint
The suite replaces every provider call with controlled test doubles, so it needs no network and no running model server, and the optional extras are skipped when absent. make validate checks every technique file for unknown placeholders, strategies and graders, then render-probes the prompt it compiles to. The current checkout:
308 passed in 12.81s
License
Prompt Playoff is free and open-source software licensed under the MIT License.
Issues · Contributing · Extending · Integrations · Papers · LICENSE · LinkedIn
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file prompt_playoff-0.3.0.tar.gz.
File metadata
- Download URL: prompt_playoff-0.3.0.tar.gz
- Upload date:
- Size: 319.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
98300cd3c656cfe071e08250587310dfe267eec3cabe6b85e85fbe24e7315e85
|
|
| MD5 |
c9b72215c50649fc46635073ab9b9a24
|
|
| BLAKE2b-256 |
2b0b0b9570443d22d28599f815970e11f33aed220811b78e7ec0fcc0771d82d9
|
Provenance
The following attestation bundles were made for prompt_playoff-0.3.0.tar.gz:
Publisher:
release.yml on KazKozDev/prompt-playoff
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
prompt_playoff-0.3.0.tar.gz -
Subject digest:
98300cd3c656cfe071e08250587310dfe267eec3cabe6b85e85fbe24e7315e85 - Sigstore transparency entry: 2452972370
- Sigstore integration time:
-
Permalink:
KazKozDev/prompt-playoff@35b13efcc81aa748a670b64e1c9e75ac17f7d013 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/KazKozDev
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@35b13efcc81aa748a670b64e1c9e75ac17f7d013 -
Trigger Event:
push
-
Statement type:
File details
Details for the file prompt_playoff-0.3.0-py3-none-any.whl.
File metadata
- Download URL: prompt_playoff-0.3.0-py3-none-any.whl
- Upload date:
- Size: 320.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3a572443faa5d8e40e20bb5ee5b4d5bdadc1401bb306015f5953f4fb8e2f85c6
|
|
| MD5 |
0b4a7a34354e45a4a647aa8230645589
|
|
| BLAKE2b-256 |
f9811bb437d563d746e9c6f6638997034fbb64049a394c4e37fc6676036c13b9
|
Provenance
The following attestation bundles were made for prompt_playoff-0.3.0-py3-none-any.whl:
Publisher:
release.yml on KazKozDev/prompt-playoff
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
prompt_playoff-0.3.0-py3-none-any.whl -
Subject digest:
3a572443faa5d8e40e20bb5ee5b4d5bdadc1401bb306015f5953f4fb8e2f85c6 - Sigstore transparency entry: 2452972406
- Sigstore integration time:
-
Permalink:
KazKozDev/prompt-playoff@35b13efcc81aa748a670b64e1c9e75ac17f7d013 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/KazKozDev
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@35b13efcc81aa748a670b64e1c9e75ac17f7d013 -
Trigger Event:
push
-
Statement type: