Turn prompts and expected behavior into repeatable tests.
Install promptfoo and author the `promptfooconfig.yaml` that declares your prompts, providers, and test targets for a customer-support bot harness. You leave the `tests` key stubbed so the next module can fill it in.
Install promptfoo and author the promptfooconfig.yaml that declares prompts, providers, and a stubbed tests block for a customer-support bot eval harness.
Why this matters: This config file is the foundation of your entire evaluation harness — every test, assertion, and CI gate in later modules depends on getting this structure right.
Decision this forces: Which providers and prompt files to include in the initial matrix.
You've tuned a system prompt for your customer-support bot. But how do you know the next edit won't break the refund-flow response? Without a repeatable harness, every prompt change is a manual spot-check.
is a declarative, matrix-oriented eval framework. You describe what to test — prompts, providers, and cases — in a single YAML file. It runs every combination automatically.
The result is an : every prompt variant crossed with every provider, scored in one pass. This module gets that file standing up. The next module fills in real test cases.
promptfoo eval with no tests key in your config, what happens?Imagine you're setting up the harness for Acme's support bot. It must handle refund requests, shipping queries, and escalation paths.
Your promptfooconfig.yaml makes three declarations: (1) which prompt files to load, (2) which LLM providers to call, and (3) which test cases to run against every prompt–provider pair.
prompts — paths to your files (Jinja2 or plain text).providers — model IDs like openai:gpt-4o or anthropic:claude-3-5-sonnet-20241022.tests — the cases that drive the matrix. You'll stub this now and fill it in Module 2.Every row in the resulting is one (prompt, provider, test case) triple. With two prompts and two providers you get 2 × 2 = 4 rows before you've written a single assertion.
# Install promptfoo globally npm install -g promptfoo # Scaffold a blank project in the current directory promptfoo init # Try to eval before editing anything promptfoo eval
npm install -g promptfoopromptfoo initpromptfoo evalRunning promptfoo eval on the raw scaffold reveals what the config is missing before you write a single line of YAML.
promptfoo throws: 'No providers configured' or 'Provider "openai:gpt-4o-mini" requires OPENAI_API_KEY'. The scaffold lists a provider but your environment has no API key set, so the eval aborts before running a single row. Fix: export OPENAI_API_KEY=sk-... (or the equivalent) and replace the placeholder provider string with a real one.
# promptfooconfig.yaml description: "Acme support-bot eval harness" prompts: - prompts/system_v1.txt - prompts/system_v2.txt providers: - openai:gpt-4o - anthropic:claude-3-5-sonnet-20241022 tests: [] # Module 2 fills this in
description:prompts:openai:gpt-4oanthropic:claude-3-5-sonnet-20241022tests: []This is the minimal valid config: two crossed with two providers, producing a 2 × 2 eval matrix with an empty test list that won't error.
Zero scored rows — promptfoo reports '0 tests passed, 0 failed' and exits cleanly. The matrix exists but has nothing to iterate over yet. That's the correct baseline: the harness runs without errors, ready for test cases.
# promptfooconfig.yaml — extend the matrix description: "Acme support-bot eval harness" prompts: - prompts/system_v1.txt - prompts/system_v2.txt providers: - openai:gpt-4o - anthropic:claude-3-5-sonnet-20241022 - # TODO: add a second OpenAI model for cost comparison tests: []
- openai:gpt-4o-miniStop — attempt the TODO before revealing. The goal is to add a cheaper OpenAI model so the matrix compares cost vs. quality across three providers.
Replace the TODO with: - openai:gpt-4o-mini
Changed lines: one new provider entry under providers (the only delta from Stage 2).
Why it works: promptfoo treats each list item as an independent provider; no other config changes needed.
Row count: still 0 scored rows (tests: [] is empty), but the matrix is now 2 prompts × 3 providers = 6 potential rows waiting for test cases.
Error: AuthenticationError: No API key provided for openai. The eval stops before the first row. Fix: export the key in your shell or add it to a .env file. promptfoo auto-loads it.
Typing openai:gpt4o (missing the dash) doesn't error. OpenAI's API returns a model-not-found message inside the response. promptfoo marks the row as a provider error rather than a config error. You only notice when you read the report.
If prompts/system_v1.txt doesn't exist relative to the config file, promptfoo throws an error and exits. Paths are resolved from the directory containing promptfooconfig.yaml. Not from where you run the CLI.
If you asked an AI assistant to draft your promptfooconfig.yaml, check these four things before trusting it:
promptfoo eval --dry-run and confirm each provider resolves without an auth or model-not-found error.ls prompts/ should list every file named under prompts:.tests: is present (even as []) — a missing key causes a schema validation error on some promptfoo versions.provider: (singular) won't error but your providers list won't load.| Option | Output quality | Cost per 1K tokens | Latency | When to choose | Cost | Complexity |
|---|---|---|---|---|---|---|
| openai:gpt-4o only | Strong across most support tasks | Mid-range; adds up at scale | ~1–2 s median for short prompts | When you need a fast baseline and your team already has an OpenAI key; simplest starting matrix. | ~$5 / 1M input tokens | Low — one key, one provider string |
| openai:gpt-4o + openai:gpt-4o-mini | mini degrades on nuanced escalation paths | mini is ~33× cheaper than gpt-4o | mini is noticeably faster | When you want a cost–quality tradeoff comparison within one provider; no extra API key needed. | mini ~$0.15 / 1M input tokens | Low — same key, two model strings |
| openai:gpt-4o + anthropic:claude-3-5-sonnet-20241022 | Both strong; Claude often better on long context | Similar price tier; budget doubles | Claude slightly higher median latency | When you need a true cross-vendor comparison to avoid provider lock-in from day one. | Claude ~$3 / 1M input tokens | Medium — two API keys, two billing accounts |
Author `vars`-driven test cases for the customer-support bot — mapping user intents to expected behaviors — and learn how to use external CSV or JSONL datasets to scale beyond hand-written cases.
How to write vars-driven test cases and load external JSONL datasets so your promptfoo harness covers multiple user intents at scale.
Why this matters: Without well-structured test cases, your eval harness produces misleading green results — this module gives you the building blocks to test real customer-support behaviors reliably.
Decision this forces: Inline test cases vs. external dataset file — which fits your team's workflow.
promptfooconfig.yaml you built in module 1, which top-level key did you intentionally leave stubbed — and why?Answer: you left the tests key empty. The config declares prompts, providers, and targets. Test cases are this module's job.
This module fills that stub. You'll author that map user intents to expected behaviors. Then you'll load them from an external so the harness scales without touching the config.
A in has two parts. A block injects inputs into your prompt template. An block declares what a passing response must satisfy.
Vars slot into {{mustache}} placeholders in your prompt. One template covers many intents without duplication.
Assertions come in three flavors: exact output (response equals a string), output shape (response is valid JSON or contains a phrase), and behavioral properties (tone is polite, policy is followed).
Choosing the right flavor matters. Exact checks are fast and deterministic. Shape checks are flexible. Behavioral checks need an LLM judge and cost more.
Your customer-support bot must handle three intents: refund request, password reset, and out-of-scope question.
For refund requests, you care about an exact output property. The response must mention "refund". A contains assertion is enough.
For password resets, you care about output shape — valid JSON with a steps key. Use is-json plus javascript assertions to check the shape.
For out-of-scope questions, you care about a behavioral property — polite decline without hallucination. Use an assertion.
# promptfooconfig.yaml — tests section only tests: - description: Refund request vars: user_message: "I want a refund for order #4821" assert: - type: contains value: refund - description: Password reset vars: user_message: "How do I reset my password?" assert: - type: is-json - type: javascript value: "output.steps !== undefined"
vars:assert:type: containstype: is-jsontype: javascriptEach entry under tests is one : feeds the prompt template and declares what must be true about the response.
The contains assertion is a fast, deterministic check; is-json validates shape; javascript lets you inspect a specific field.
The is-json assertion fails immediately because the response is not valid JSON. promptfoo reports: FAIL [is-json] — output is not valid JSON. The javascript assertion never runs.
# promptfooconfig.yaml — swap inline tests for a file tests: file://tests/support_cases.jsonl # tests/support_cases.jsonl (one JSON object per line) # {"vars":{"user_message":"I want a refund for order #4821"},"assert":[{"type":"contains","value":"refund"}]} # {"vars":{"user_message":"How do I reset my password?"},"assert":[{"type":"is-json"}]} # {"vars":{"user_message":"What's the weather in Paris?"},"assert":[{"type":"llm-rubric","value":"Politely declines and does not invent an answer"}]}
tests: file://...JSONL formatllm-rubricReplacing the inline list with file://tests/support_cases.jsonl tells to stream test cases from a file — the config stays unchanged as the file grows to hundreds of cases.
Each JSONL line is a self-contained test case with its own and — the same schema as inline, just one object per line.
promptfoo runs them but marks each as PASS automatically, because a case with no assertions has nothing to fail. This is a silent gap: the eval matrix shows green for those rows even though you're testing nothing. Always include at least one assertion per case.
# Complete the third test case — stop and attempt before revealing. # Hint 1: the intent is out-of-scope (weather question). # Hint 2: a contains check won't catch hallucination; pick the right type. tests: - description: Refund request vars: user_message: "I want a refund for order #4821" assert: - type: contains value: refund - description: Out-of-scope question vars: user_message: "What's the weather in Paris?" assert: - type: ??? # ← what assertion type goes here? value: ??? # ← what criterion do you write?
type: llm-rubricvalue: "..."The first case is complete; your job is to fill in the assertion for the out-of-scope intent.
Think about which assertion type can verify that the bot politely declines without inventing an answer — a property no string-match can confirm.
Changed lines:
type: llm-rubric
value: "Politely declines and does not invent an answer about the weather"
Why: llm-rubric sends the response to an LLM judge with your natural-language criterion. A contains check would only verify a word appears — it can't detect hallucination or measure politeness. This is the crux of the module: matching assertion type to the property you actually care about.
| Option | Ease of editing | Scales to 100+ cases | Version-control friendly | When to choose | Cost | Complexity |
|---|---|---|---|---|---|---|
| Inline (tests: list in YAML) | Edit YAML directly; no extra file | Config file becomes unreadable | Config + data in one diff — harder to review | Small suites (< ~20 cases) owned by one engineer; fast iteration during prompt authoring. | None | Low — everything in one file |
| External JSONL/CSV (file://...) | Edit in any text editor or spreadsheet; append programmatically | Stream thousands of lines without touching config | Data file diffs cleanly separate from config changes | Team-owned suites, QA-contributed cases, or any suite expected to grow beyond ~20 cases. | None | Low — one extra file, same schema |
These are the three failure patterns that produce misleading green results — the worst outcome in an eval harness.
assert block always passes. The shows green; you're testing nothing. Fix: lint your JSONL for missing assert keys before committing.{{user_message}} but your vars key is userMessage, the placeholder renders as an empty string. The bot replies to nothing, and a loose contains check may still pass. Observable symptom: response is suspiciously short or generic across all cases.Map each customer-support bot behavior to the right assertion: `contains` for required phrases, `llm-rubric` for tone and policy compliance, and a custom JavaScript evaluator for business-logic checks that built-in types can't express.
How to pick the right assertion type — deterministic, LLM-judge, or custom JavaScript — for each behavioral property of a customer-support bot.
Why this matters: Choosing the wrong scorer makes your CI gate either too brittle (false failures) or too loose (real regressions slip through), so this decision directly affects the reliability of your evaluation harness.
Decision this forces: Deterministic assertion vs. LLM-judge vs. custom evaluator — based on stability requirements and behavior type.
Answer: each carries a type (which scorer to run) and a value (what to check against). When the scorer returns false, that is marked failing in the .
Module 3 picks up exactly there: now you need to choose the right scorer type for each behavioral property your customer-support bot must satisfy.
Every behavioral property your bot must satisfy falls into one of three categories: something you can check with a string rule, something that requires semantic judgment, or something that encodes business logic no built-in type knows about.
contains, regex, similar): fast, stable, zero cost. They are ideal for required phrases, format checks, and fuzzy-match thresholds.javascript): arbitrary logic in code. Use them when the check involves structured data, external lookups, or multi-condition rules.The key question is: can a string rule or embedding distance express this property exactly? If yes, pick a . If the property is inherently subjective, reach for . If it's objective but structurally complex, write a .
Your customer-support bot must satisfy three distinct behavioral properties for a refund-request scenario.
contains. It's a literal string check: fast, zero cost, never flaky.Notice the pattern: deterministic for facts, LLM-judge for quality, custom for logic that crosses data and output together.
# promptfooconfig.yaml — assert block for the refund test case tests: - description: refund request over $500 vars: amount: 600 user_message: "I need a refund for my $600 order." assert: - type: contains value: "refund processed" - type: llm-rubric value: "Reply is empathetic and does not dismiss the customer's concern."
type: containstype: llm-rubricvalue:Two assertions on one test case: a for the required phrase and an for tone.
Both must pass for this to be green in the . The contains check runs locally in milliseconds; the llm-rubric fires a second LLM call to score the rubric.
The test case FAILS. Both assertions must pass; a single failing assertion marks the whole test case as failing in the eval matrix. There is no partial-pass state per test case — only per-assertion scores.
// evaluators/refund_approval.js module.exports = (output, context) => { const amount = Number(context.vars.amount); const mentionsApproval = output.toLowerCase().includes("manager approval"); if (amount > 500 && !mentionsApproval) { return { pass: false, score: 0, reason: `Amount $${amount} > $500 but reply omits manager approval.` }; } return { pass: true, score: 1, reason: "Approval policy satisfied." }; };
module.exports = (output, context) =>context.vars.amount{ pass, score, reason }A is a plain JS function that receives the bot's output and a context object (which carries ) and returns { pass, score, reason }.
The reason string surfaces in the promptfoo UI next to the failing row — make it specific enough to diagnose the problem without opening the code.
PASS. The condition is amount > 500, so a $400 refund never triggers the approval check. The evaluator returns { pass: true, score: 1, reason: 'Approval policy satisfied.' }. This is intentional: the policy only applies above the threshold.
# promptfooconfig.yaml — add the custom evaluator to the assert block tests: - description: refund request over $500 vars: amount: 600 user_message: "I need a refund for my $600 order." assert: - type: contains value: "refund processed" - type: llm-rubric value: "Reply is empathetic and does not dismiss the customer's concern." - type: javascript # TODO: set 'value' to the path of your custom evaluator file value: ???
type: javascriptvalue: file://...Stop — attempt this before revealing. The three assertions are in place; only the value for the javascript type is missing.
Hint 1: the type: javascript assertion expects value to be a file path (relative to the config) or an inline JS expression. Hint 2: you saved the evaluator as evaluators/refund_approval.js.
value: file://evaluators/refund_approval.js
Changed line: the value key uses the file:// prefix so promptfoo loads and runs the JS module. Without the prefix, promptfoo treats the string as an inline JS expression and tries to evaluate it directly — which fails for a multi-line function.
| Option | Stability in CI | Handles subjective quality | Encodes business logic | When to choose | Cost | Complexity |
|---|---|---|---|---|---|---|
| Deterministic (contains / regex / similar) | Fully stable — same input always gives same result | Cannot judge tone, empathy, or policy nuance | Only simple string/numeric rules | Required phrases, format checks, embedding-similarity thresholds — any property a string or number can express exactly. | Zero token cost | Low — one-liner in YAML |
| LLM-judge (llm-rubric) | Non-deterministic; pin model + prompt and average samples to reduce flakiness | Designed for exactly this — flexible rubric in natural language | Can approximate, but LLM may misinterpret edge cases | Tone, empathy, policy compliance, or any quality a human would judge — where a rubric prompt can capture the standard. | Per-call LLM token cost | Medium — write a rubric prompt; pin model version for stability |
| Custom JavaScript evaluator | Fully deterministic — pure function, no model calls | Cannot judge subjective properties without calling an LLM itself | Full code expressiveness — any logic you can write | Multi-condition rules, structured-data parsing, external lookups, or any check that requires real code to express correctly. | Zero token cost | Higher — write and maintain a JS function |
Three failure patterns show up repeatedly in real harnesses:
regex with word boundaries, or add an alongside it.Number(undefined) returns NaN, and NaN > 500 is false — means the approval check never fires. Every test case shows pass even when the bot omits the required phrase.Before trusting AI-generated evaluators, verify: (1) the return shape is exactly { pass, score, reason }; (2) the condition covers the boundary value. Test with amount = 500 and 501; (3) the rubric prompt is specific enough that a wrong-tone reply actually fails. Run it against a deliberately bad output and confirm it returns false.
Execute `promptfoo eval` against the customer-support bot harness, read the pass/fail matrix and score distributions in the web UI, and diagnose a failing test case by tracing the raw model output back to the assertion that rejected it.
Run `promptfoo eval`, read the pass/fail matrix in the web UI, and trace a failing test case back to the assertion and raw output that caused it.
Why this matters: Knowing how to interpret results turns your evaluation harness from a black box into a diagnostic tool — you can pinpoint which prompt, provider, and assertion is responsible for a failure before shipping.
Answer: contains checked for required phrases. used an LLM as the judge for tone and policy compliance. A handled business-logic checks that built-in types couldn't express.
Now those assertions are wired. This module covers running the eval and reading results — which cases passed, which failed, and what the raw output reveals about why.
Your customer-support bot is failing some tests. Which prompt, provider, and assertion is the culprit? The answers at a glance: rows are test cases, columns are prompt/provider combinations. Each cell shows pass ✓, fail ✗, or a numeric score.
A cell turns red when any assertion fails its — the minimum score required to pass. Without a threshold, a scored assertion (like ) only records a number. Adding threshold: 0.8 converts that number into a hard .
The panel shows how scores cluster. A tight cluster near 0.5 means your rubric is ambiguous, not that the bot is borderline.
Your harness has a test case: a customer asks for a refund outside the 30-day window. The expected behavior is a polite refusal citing the policy. After running promptfoo eval, the matrix shows a red cell for prompt-v1 / gpt-4o.
You click the cell. The raw output reads: "I'm sorry, I can't help with that." — polite, but it never mentions the 30-day policy. The contains assertion for "30-day" scored 0 (hard fail). The for tone scored 0.74 — below your threshold: 0.8.
You now have two signals: the prompt doesn't instruct the model to cite policy (a prompt fix), and the rubric threshold may be too strict for "polite but incomplete" responses (a threshold question). These require different actions.
tests: - description: refund outside 30-day window vars: user_message: "I bought this 45 days ago, can I get a refund?" assert: - type: contains value: "30-day" - type: llm-rubric value: "Response is polite and does not promise a refund"
descriptionvars:type: containstype: llm-rubricvalue:This is the starting config from module 3 — no threshold on the assertion. Without a threshold, the rubric records a score (0–1) but never causes a hard fail — a score of 0.3 still shows as a pass in the matrix.
Pass. Without a threshold, any non-zero score is treated as passing. The score appears in the cell, but the cell stays green. You only see the number if you click into the detail pane.
tests: - description: refund outside 30-day window vars: user_message: "I bought this 45 days ago, can I get a refund?" assert: - type: contains value: "30-day" - type: llm-rubric value: "Response is polite and does not promise a refund" threshold: 0.8 # ← added: score must reach 0.8 to pass
threshold: 0.8# ← added:Adding threshold: 0.8 converts the rubric score into a : any score below 0.8 now turns the cell red. The only changed line is flagged with the comment — everything else carries forward from Stage 1.
The cell turns red (fail). The detail pane shows: score = 0.74, threshold = 0.8, result = FAIL. The contains assertion is evaluated independently — if '30-day' is present, that assertion still passes regardless.
tests:
- description: angry customer escalation
vars:
user_message: "This is unacceptable! I want to speak to a manager NOW."
assert:
- type: contains
value: "escalate"
- type: llm-rubric
value: "Response de-escalates without dismissing the customer's frustration"
threshold: ??? # TODO: set the threshold
- type: javascript
value: "output.length < 300" # keep responses concisethreshold: ???type: javascriptoutput.length < 300This is a variation of the refund case — same structure, new scenario. Your job: replace ??? with a threshold value that gates the de-escalation rubric. Stop and decide before revealing.
A value between 0.7 and 0.8 is appropriate — 0.75 is a common starting point for tone rubrics.
Changed line: threshold: 0.75 (replacing ???).
Why: De-escalation scoring has higher judge variance than factual checks, so a strict 0.9 gate will fire on legitimate responses. Start at 0.75, run the eval, inspect the score distribution, and tighten only if the histogram shows most passing scores cluster well above 0.75.
promptfoo eval --verbose and confirm the raw output matches what you expect the model to say, not just what the assertion reports.Once you can read the matrix and diagnose a failing case, the next step is comparing two prompt variants side by side — which module 5 unlocks with the diff view.
Add a second prompt variant to the customer-support bot config and run a head-to-head comparison across both variants and two providers, using the diff view to identify which rewrite wins on policy compliance without regressing on tone.
How to declare multiple prompt variants in a single promptfoo config and use the diff view to run a head-to-head comparison across providers.
Why this matters: Lets you make data-driven decisions about which prompt rewrite to ship — catching regressions before they reach users.
The matrix shows pass/fail and scores for every test case against every provider. But it only covers prompts you've already run. It can't tell you whether a rewrite is better until you run both side-by-side.
This module closes that gap. You'll add a second to your config. Then promptfoo runs a full matrix across both variants and two providers at once.
In , every entry in the prompts list is a separate . The framework crosses every variant with every provider and every test case automatically, producing a full .
Two variants × two providers × N test cases means 4N runs. All share the same assert blocks, so scoring is identical and comparison is fair.
The surfaces where variants diverge. Green cells mean the rewrite gained. Red cells mean it .
The key question: did the rewrite improve policy compliance without degrading tone? Not "which variant passed more tests?"
Your customer-support bot's baseline prompt is polite but vague on refund policy. You write a stricter variant with an explicit policy clause. Before shipping, you need to know: does stricter wording help compliance without making the bot sound cold?
You add prompt_v2.txt to the prompts list in promptfooconfig.yaml alongside the original. Running promptfoo eval produces 40 runs (2 variants × 2 providers × 10 cases).
In the , prompt_v2 gains +12 points on the policy-compliance scorer. But it drops −8 points on tone for the "angry customer" test cases.
That's a . The tradeoff is real: better policy, worse tone on edge cases. You now have data to decide whether to promote, iterate, or split variants by use-case.
prompts: - file://prompts/support_v1.txt # baseline - file://prompts/support_v2.txt # stricter policy clause providers: - openai:gpt-4o-mini - openai:gpt-3.5-turbo tests: - file://tests/support_cases.csv # shared across all variants
file://prompts/support_v2.txtproviders: [openai:gpt-4o-mini, openai:gpt-3.5-turbo]file://tests/support_cases.csvAdding a second path under prompts is all it takes to trigger the full matrix — promptfoo crosses every prompt with every provider and every test case automatically.
The tests key is shared, so both variants face identical inputs and identical assert blocks — the comparison is apples-to-apples.
40 calls — 2 prompts × 2 providers × 10 test cases. Each cell in the matrix is one call.
promptfoo eval --output results.json promptfoo view # Diff view (web UI) — variant comparison summary: # Metric support_v1 support_v2 Delta # policy-compliance 0.61 0.73 +0.12 ✅ # tone-friendly 0.84 0.76 -0.08 ⚠️ # contains-apology pass pass — # Overall pass rate 72% 74% +2%
promptfoo eval --output results.jsonpromptfoo viewDelta columnThe renders each metric as a column so you can spot gains and regressions at a glance — a positive delta on one scorer alongside a negative delta on another is the classic tradeoff signal.
Overall pass rate alone hides the regression: v2 is +2% overall but −0.08 on tone, which may matter more for your use case than the headline number suggests.
Not yet. v2 improves policy compliance (+0.12) but regresses on tone (−0.08). Whether the tradeoff is acceptable depends on which metric your product prioritises — but a −0.08 tone drop on 'angry customer' cases is a meaningful regression worth iterating on before promoting.
# support_cases.csv row (vars): intent=refund_request tests: - vars: intent: refund_request customer_tier: premium assert: - type: contains value: "within 5 business days" - type: llm-rubric value: "Response is empathetic and does not sound robotic" - type: # TODO: add a deterministic scorer that checks # the response is under 120 words value: 120
type: containstype: llm-rubrictype: # TODOThis test case already checks policy phrasing and tone — your job is to add the third assertion that enforces a length cap, using the right for a deterministic word-count check.
The missing type is javascript with an inline expression, OR the built-in max-words type:
value: 120
Changed lines: only the type field — max-words is a deterministic scorer (no LLM call) that counts whitespace-delimited tokens and fails if the count exceeds value. This exercises the module's core idea: pairing a deterministic length guard alongside an llm-rubric tone check so both metrics appear in the diff view when you compare variants.
Slide to see how adding prompt variants multiplies total eval runs (2 providers × 10 test cases fixed).
{{system_note}} var and you change it between runs, the diff reflects the variable change, not the prompt rewrite. Symptom: both variants shift the same way. Fix: freeze all shared vars before comparing.tests key points to the same dataset for both variants. Mismatched files mean you're not comparing on equal footing.promptfoo eval --dry-run first to print the resolved matrix and verify the count matches your expectation before spending on real LLM calls.results.json and spot-check two or three cells where variants diverged. Confirm the model output actually changed, not just the score.Once your variant comparison is stable and reproducible, the next step is making it automatic. Module 6 shows you how to wire promptfoo eval --ci into a GitHub Actions workflow so a score regression blocks a merge before it reaches production.
Wire `promptfoo eval --ci` into a GitHub Actions workflow for the customer-support bot, set score thresholds that block a merge on regression, and handle the two most common failure modes: flaky LLM-judge gates and slow eval runs in CI.
Wire promptfoo eval into a GitHub Actions CI gate that blocks merges on score regressions, and fix the two failure modes that make eval gates unreliable.
Why this matters: Your evaluation harness only protects production if it runs automatically on every change — this module turns the harness you've built into a live quality gate.
Decision this forces: Which assertions are stable enough to be hard CI gates vs. which should be advisory-only scores.
Answer: the in the showed per-case score drops on tone assertions for the winning variant. That per-case diff is exactly what you'll surface automatically in CI — so a reviewer sees it on every pull request, not just when someone remembers to run the comparison.
This module wires that same eval into a GitHub Actions workflow. It sets score that block a merge on regression. It fixes the two failure modes that make CI eval gates unreliable in practice.
A runs your eval suite on every pull request. It fails the workflow if any score drops below a declared — blocking the merge automatically.
The key design choice is which assertions become hard gates versus advisory scores. (exact match, regex, JSON schema) are stable enough to gate on directly. scores are non-deterministic. Gating on a single sample is risky — a flaky judge can block a perfectly good PR.
CI evals catch regressions on known, repeatable behaviors. They don't catch novel failure modes that aren't yet in your test suite. That's the job of production monitoring and human review.
# .github/workflows/eval.yml name: Prompt Eval on: [pull_request] jobs: eval: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm install -g promptfoo - run: promptfoo eval --ci --config promptfooconfig.yaml
on: [pull_request]promptfoo eval --ci--config promptfooconfig.yamlThis is the minimal workflow: install and run promptfoo eval --ci on every pull request. The --ci flag exits with a non-zero code when any fails, which GitHub Actions treats as a workflow failure — blocking the merge.
promptfoo eval exits with code 0 regardless of failures. GitHub Actions sees success, the check goes green, and the regression merges silently — the gate does nothing.
# promptfooconfig.yaml (additions) thresholds: pass_rate: 0.90 # hard gate: ≥90 % of cases must pass score: 0.75 # hard gate: mean score across all assertions # .github/workflows/eval.yml (updated job) - uses: actions/cache@v4 with: path: ~/.promptfoo/cache key: promptfoo-${{ hashFiles('promptfooconfig.yaml') }} - run: | promptfoo eval --ci \ --filter-pattern "critical" \ --config promptfooconfig.yaml
pass_rate: 0.90score: 0.75actions/cache@v4--filter-pattern "critical"Two additions keep CI fast and reliable. The block in your promptfooconfig.yaml sets the numeric bar that triggers a gate failure. The cache step reuses prior LLM responses for unchanged test cases, cutting runtime significantly. --filter-pattern "critical" runs only test cases tagged critical — a practical way to stay under a time budget on large suites.
No — 9/10 is exactly 0.90, which meets the threshold. The gate blocks only when the rate drops below 0.90 (i.e., 8 or fewer pass). Thresholds are inclusive lower bounds.
| Option | Stability across runs | Catches regressions reliably | When to choose | Cost | Complexity |
|---|---|---|---|---|---|
| Hard CI gate | High for deterministic; acceptable for averaged LLM judges | Yes — fails the workflow automatically on score drop | Use for deterministic assertions (contains, regex, JSON schema) and averaged LLM-rubric scores with ≥3 samples on critical behaviors. | Blocks merge; must be reliable or teams disable it | Low — threshold is a single number in config |
| Advisory score | Doesn't matter — it never blocks the merge | Only if a reviewer reads the artifact; easy to miss | Use for single-sample LLM-rubric scores, subjective tone checks, or any assertion that flaps more than ~5% of the time on identical inputs. | No merge block; requires a human to read the report | Low — same config, just no threshold block |
An scorer is non-deterministic: the same input can score 0.8 one run and 0.6 the next, making the gate flap without any real regression. Two fixes work together.
provider: openai:gpt-4o-2024-05-13). A model update mid-run changes scoring behavior and invalidates your baseline.numSamples: 3 on the rubric assertion so the gate uses the mean score. Three samples cut variance enough for most tone and policy checks.If a rubric still flaps after pinning and averaging, demote it to an advisory score rather than a hard gate. A gate that teams learn to ignore is worse than no gate.
# .github/workflows/eval.yml — complete this step name: Prompt Eval on: [pull_request] jobs: eval: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/cache@v4 with: path: ~/.promptfoo/cache key: promptfoo-${{ hashFiles('promptfooconfig.yaml') }} - run: npm install -g promptfoo - run: promptfoo eval __TODO__ --config promptfooconfig.yaml # promptfooconfig.yaml — also add a thresholds block: # thresholds: # pass_rate: __TODO__
hashFiles('promptfooconfig.yaml')pass_rate: 0.90__TODO__Stop — attempt this before revealing. Fill in both TODOs: the correct CLI flag that makes the workflow fail on a score drop, and a pass_rate threshold appropriate for a customer-support bot where 1 in 10 failures is acceptable.
Also verify the AI-generated version of this workflow before trusting it: check that --ci is present (not just eval), that the cache key includes the config file hash, that the threshold is a decimal not a percentage, and that the workflow triggers on pull_request not only push.
CHANGED LINES:
(--ci is the flag; without it the exit code is always 0 and the gate is silent)
thresholds:
pass_rate: 0.90
(9 of 10 passing = 0.90; if you wrote 90 instead of 0.90 the threshold is ignored or errors — it must be a decimal fraction)
Three failure modes account for most broken CI eval setups:
--ci to exit 0 or removed the threshold block after a flaky block. Fix: pin the judge and average samples before removing the gate.--filter-pattern to scope to critical cases on PRs; run the full suite nightly.Before looking at the summary: reconstruct the build order from memory — what does the config declare, what do test cases encode, how do assertions score outputs, what does the matrix report show, how do variants get compared, and what makes a CI gate enforceable? Write it out, then check.
Apply what you learned to A production-ready evaluation harness using promptfoo that tests prompts against expected behaviors.
You need to verify that your model never outputs a phone number in any format. Which assertion type is the best fit?
assert:
value: "\\b\\d{3}[-.\\s]?\\d{3}[-.\\s]?\\d{4}\\b"
regex is correct because you have a precise, deterministic pattern to match against — it is fast, stable, and perfect for a CI hard gate. contains only checks for a literal substring, so it cannot handle format variations. llm-rubric is flexible but non-deterministic and overkill for a rule that can be expressed as a pattern. similar measures semantic closeness to a reference string, which is irrelevant to format enforcement.
Your team has hundreds of test cases stored in a JSONL file and you want to load them without editing the config each time. Which config field points promptfoo at that file?
The tests field accepts a path to a JSONL file, so the harness reads every row as a test case automatically — no config edits needed as the dataset grows. providers lists the LLM backends to call, not the test data. assert is a child block inside an individual test case, not a top-level loader. prompts lists the prompt template files or strings to evaluate.
Read this GitHub Actions step:
env:
PROMPTFOO_CACHE: "true"
A colleague says this step will always pass even when scores drop. What is the most likely reason they are wrong?
The --ci flag is specifically designed to make promptfoo exit with a non-zero code when any hard-gate assertion fails, which causes the GitHub Actions step to fail — so the colleague is wrong. The flag is valid and well-documented. Caching stores LLM responses to speed up reruns; it does not bypass assertions. GitHub Actions does respect non-zero exit codes from run steps and marks them as failures.
You are comparing two prompt variants. Variant B scores higher on tone (llm-rubric) but lower on factual-accuracy (contains checks). How should you classify this result?
When a variant improves one metric while degrading another, that is a regression by definition — the team must consciously decide whether the tradeoff is acceptable rather than treating it as a net win. Calling it a clean win ignores the factual-accuracy drop, which could be a hard CI gate failure. Non-determinism in llm-rubric is a real concern but does not make the tone signal worthless; it means you should average multiple samples, not discard the result. More providers would add breadth but do not resolve the existing metric conflict.
Explain in 2–3 sentences when you would make an assertion a hard CI gate versus keeping it as an advisory-only score. Name one assertion type that typically belongs in each category.
Deterministic assertions (regex, contains, javascript with fixed logic) produce the same result on every run, making them reliable enough to block a merge. LLM-judge assertions (llm-rubric, similar) can vary run-to-run due to model non-determinism, so treating them as hard gates risks flaky pipelines; they are better used as trend signals. The key decision axis is stability: if the scorer can flip without the prompt changing, it should not be a hard gate.