TL;DR
An LLM agent without CI/CD evals is a production deploy without unit tests: it eventually breaks and nobody knows which commit broke it. In this article we build a complete pipeline with Promptfoo for deterministic prompt evals, DeepEval for RAG metrics (faithfulness, context relevance, answer relevance), Langfuse for traces and a versioned golden dataset, and GitHub Actions to run everything on every PR. If the score drops more than 5% versus main, the merge is blocked. We include the full pipeline YAML, sample datasets and the Go script that calculates the semantic diff between runs.
The problem
Classical tests validate exact input/output. LLMs are non-deterministic — two runs with the same input may differ syntactically without being incorrect. That's why the three CI/CD metrics that matter are:
- Correctness: does the response contain the right information? (LLM-as-judge against a rubric).
- Faithfulness: is the response faithful to the retrieved context? (hallucination detection).
- No-regression: does the PR's mean score drop relative to baseline?
Validating only one isn't enough. A prompt can improve correctness while degrading faithfulness. So we run all three in parallel with independent thresholds.
Architecture
┌─────────────────────────────────────────────────────────┐
│ GitHub Actions (CI) │
│ │
│ PR opened │
│ │ │
│ ├─► job: promptfoo-eval │
│ │ └─► runs promptfoo run --config promptfoo.yml │
│ │ │
│ ├─► job: deepeval-rag │
│ │ └─► python -m deepeval run │
│ │ │
│ └─► job: regression-guard │
│ └─► downloads baseline from Langfuse │
│ compares scores │
│ ✗ blocks merge if delta < -5% │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Langfuse (observability + golden dataset) │
│ │
│ datasets/ │
│ ├── golden-support-v3 (120 curated cases) │
│ ├── golden-rag-v2 (80 cases) │
│ └── regression-baseline (per-release snapshots) │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Qdrant (golden dataset versioning) │
│ └─► every item vectorized lets us detect drift │
└─────────────────────────────────────────────────────────┘
1. Versioned golden dataset
The dataset lives in two places: plain text in the repo (evals/datasets/*.yml) for legible PR diffs, and vectorized in Qdrant for drift detection (if someone "improves" a case by altering it, we want to know).
# evals/datasets/golden-support-v3.yml
- id: SUP-001
tags: [refund, mexico, regulated]
input: |
Hi, I bought a medical device 3 days ago and it doesn't work.
Can I return it? RFC XAXX010101000, invoice F-20456.
expected_facts:
- "return window is 7 calendar days"
- "requires the original invoice"
- "refund within 10 business days to the original payment method"
forbidden_phrases:
- "I cannot help you"
- "contact a human"
rubric:
- criterion: "Does it mention the correct period (7 days)?"
weight: 0.3
- criterion: "Does it validate RFC and invoice before confirming?"
weight: 0.3
- criterion: "Professional and empathetic tone?"
weight: 0.2
- criterion: "Does it avoid fabricating policies not in the context?"
weight: 0.2
2. Promptfoo for deterministic evals
promptfoo.yml:
description: "Support agent — regression suite"
prompts:
- file://prompts/support_system.txt
providers:
- id: litellm
config:
apiBaseUrl: https://api.numoru.com/v1
apiKey: ${LITELLM_MASTER_KEY}
model: claude-sonnet
temperature: 0.0
tests:
- vars:
input: "{{case.input}}"
assert:
- type: llm-rubric
value: "{{case.rubric}}"
threshold: 0.75
- type: contains-all
value: "{{case.expected_facts}}"
- type: not-contains-any
value: "{{case.forbidden_phrases}}"
- type: latency
threshold: 6000
datasets:
- file://evals/datasets/golden-support-v3.yml
Promptfoo runs the 120 cases, computes per-assertion scores, aggregates at the suite level and produces an HTML report.
3. DeepEval for RAG metrics
When the agent uses RAG (our mcp-qdrant-rag), errors are not in the prompt but in retrieval. DeepEval has specific metrics:
# evals/rag/test_rag_quality.py
from deepeval import evaluate
from deepeval.metrics import (
FaithfulnessMetric,
AnswerRelevancyMetric,
ContextualRelevancyMetric,
ContextualRecallMetric,
)
from deepeval.test_case import LLMTestCase
import yaml, httpx
def load_golden(path):
with open(path) as f:
return yaml.safe_load(f)
def call_agent(input_text):
r = httpx.post(
"https://api.numoru.com/v1/agents/support",
json={"input": input_text},
timeout=30,
)
data = r.json()
return data["output"], data["retrieved_contexts"]
def test_rag_suite():
golden = load_golden("evals/datasets/golden-rag-v2.yml")
cases = []
for case in golden:
output, contexts = call_agent(case["input"])
cases.append(LLMTestCase(
input=case["input"],
actual_output=output,
expected_output=case["expected"],
retrieval_context=contexts,
))
metrics = [
FaithfulnessMetric(threshold=0.85),
AnswerRelevancyMetric(threshold=0.80),
ContextualRelevancyMetric(threshold=0.75),
ContextualRecallMetric(threshold=0.70),
]
evaluate(cases, metrics)
Each metric has its own threshold. A PR that breaks one — typically ContextualRecallMetric when someone "optimizes" chunking — fails CI.
4. Regression guard: comparing against baseline
The most important step. We run the suite on main and store the aggregate score in Langfuse. Each PR runs its own suite and compares.
// cmd/regression-guard/main.go
package main
import (
"context"
"fmt"
"os"
"github.com/numoru-ia/langfuse-go"
)
func main() {
lf := langfuse.New(os.Getenv("LANGFUSE_URL"), os.Getenv("LANGFUSE_KEY"))
ctx := context.Background()
baseline, err := lf.GetDatasetRun(ctx, "golden-support-v3", "baseline")
must(err)
pr, err := lf.GetDatasetRun(ctx, "golden-support-v3", os.Getenv("GITHUB_SHA"))
must(err)
delta := pr.MeanScore - baseline.MeanScore
fmt.Printf("baseline=%.3f pr=%.3f delta=%+.3f\n", baseline.MeanScore, pr.MeanScore, delta)
if delta < -0.05 {
fmt.Fprintln(os.Stderr, "❌ regression detected: blocking merge")
os.Exit(1)
}
// List individually-regressed cases
for _, c := range pr.Cases {
base := baseline.CaseByID(c.ID)
if base != nil && c.Score < base.Score-0.10 {
fmt.Printf("⚠️ case %s dropped from %.2f to %.2f\n", c.ID, base.Score, c.Score)
}
}
}
5. The GitHub Actions workflow
# .github/workflows/agent-evals.yml
name: agent-evals
on:
pull_request:
paths:
- "prompts/**"
- "evals/**"
- "internal/agent/**"
jobs:
promptfoo:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20" }
- run: npm install -g [email protected]
- name: Run promptfoo
env:
LITELLM_MASTER_KEY: ${{ secrets.LITELLM_MASTER_KEY }}
run: promptfoo eval -c promptfoo.yml --output results.json
- uses: actions/upload-artifact@v4
with:
name: promptfoo-results
path: results.json
deepeval:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install deepeval==2.3.0 pyyaml httpx
- name: Run DeepEval suite
env:
LITELLM_MASTER_KEY: ${{ secrets.LITELLM_MASTER_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: pytest evals/rag/ -v --junitxml=deepeval-results.xml
regression-guard:
needs: [promptfoo, deepeval]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "1.23" }
- name: Build guard
run: go build -o guard ./cmd/regression-guard
- name: Compare against baseline
env:
LANGFUSE_URL: ${{ secrets.LANGFUSE_URL }}
LANGFUSE_KEY: ${{ secrets.LANGFUSE_KEY }}
run: ./guard
publish-results:
needs: [regression-guard]
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
- name: Post PR comment
uses: marocchino/sticky-pull-request-comment@v2
with:
recreate: true
path: promptfoo-results.md
6. Dataset drift: how to detect it
When someone edits evals/datasets/*.yml, we want to know if they changed an existing case or added a new one. Pre-commit hook:
#!/bin/bash
# .githooks/pre-commit
python scripts/vectorize_dataset.py \
--input evals/datasets/golden-support-v3.yml \
--qdrant https://qdrant.numoru.com \
--collection golden-support-diff
python scripts/dataset_drift_check.py --threshold 0.05
The script vectorizes each case and compares against the version in Qdrant. If a case changed semantically by more than 5% without changing its id, it requests explicit confirmation (and automatically opens an issue).
7. Metrics in Langfuse
We configure automated evals in Langfuse over production, not just CI. Every 1,000 real interactions, a worker samples them and runs the same rubrics. The resulting dashboard lets us:
- Detect model drift (when the provider updates Claude and behavior changes).
- Detect data drift (new cases the golden dataset doesn't cover).
- Prioritize which real examples to add to the golden dataset.
8. Real failure example detected
Two weeks ago a PR changed the system prompt to "make responses shorter." In CI:
promptfoo: score 0.812 → 0.807 ✓ ok
deepeval rag: faithfulness 0.88 → 0.76 ❌ dropped
regression-guard: delta -0.12 in case SUP-047 ❌ blocked
The root cause: shorter responses skipped the disclaimer "based on available documentation…", which dropped faithfulness because the judge interpreted that the agent was asserting facts without marking the source. We added a rule to the prompt and the score recovered without losing concision.
9. Real costs
| Suite | Cases | LLM calls | Cost/run |
|---|---|---|---|
| promptfoo support | 120 | 120 × 1 = 120 | ~$0.40 USD |
| deepeval rag | 80 | 80 × 5 = 400 | ~$1.30 USD |
| judge (claude-sonnet) | 200 | 200 | ~$0.70 USD |
| Total per PR | ~$2.40 USD |
With ~30 PRs per month, $72/month of safety-net cost. A production incident from a regression typically costs an order of magnitude more.
Order-of-magnitude comparison of the expected cost of a regression depending on where it's caught. Based on incident post-mortems from LangChain State of AI Agents 2024 and Numoru client engagements.
LangChain State of AI Agents report 2024 + Numoru client incident logs.
Business & commercial impact
Selling evals-as-a-service
Every company shipping agents in 2026 needs this pipeline. Most don't have the muscle to assemble Promptfoo + DeepEval + Langfuse + GitHub Actions themselves. The engagement pattern is simple: a 2-4 week setup that leaves the suite wired in their CI, plus an optional retainer that curates the golden dataset as their product evolves.
Who buys this
Eval pipeline engagement ticket by buyer (Numoru, USD)
Public benchmarks
LangChain — State of AI Agents 2024
Promptfoo — enterprise case studies
Illustrative case — Series B AI-native SaaS
Series B AI-native support SaaS with 12 engineers shipping to 180 paying customers
ROI calculator — bringing evals in-house
AI-native SaaS: no evals vs Numoru-installed evals pipeline (12 months)
| Setup (one-time) | −$16,500 |
| Retainer (12 mo × $600) | −$7,200 |
| CI eval infra (12 mo × $72) | −$864 |
| Eng time avoided (3.2 × 12 × 12 × $92) | +$42,393 |
| Customer impact avoided (3.2 × 12 × $2,400) | +$92,160 |
| Churn reduction contribution | +$180,000 |
| Net year-1 contribution | +$289,989 |
Pricing tiers Numoru sells
- Promptfoo + DeepEval wiring
- Golden dataset (40 cases)
- GitHub Actions pipeline
- Regression-guard at 5% threshold
- 30-day warranty
- Everything in Starter
- 3 agents / 120 golden cases
- Langfuse datasets + drift alerts
- Weekly dataset curation
- Slack alerts on regression
- Quarterly eval review
- Multi-team shared eval infra
- 300+ case curated dataset
- Bias + safety addendum
- Custom judge-model config
- Compliance-ready artifacts
- Dedicated CSM
10. Anti-patterns
- Using the same model as judge and as responder. Circular bias. Judge must always be a different model (ideally another vendor).
- Dataset that grows without curation. More ≠ better. 100 curated cases beat 1,000 auto-generated ones.
- Global thresholds. Some cases are critical (compliance, charges); they need a higher individual threshold, not to be diluted in the average.
- No trace-back to real cases. Every golden-dataset case must be linkable to the incident or feature request that originated it.
FAQ
Can I run this in a monorepo with multiple agents?Yes. Each agent has its own promptfoo.yml and dataset. The regression-guard accepts an --agent-id flag and compares against the corresponding baseline.
What if the judge LLM has a bad day?That's why the suite runs 3 samples per case and uses the median, not the mean. If the judge variance for a case exceeds 0.10, it's marked "flaky" and excluded from the delta calculation.
How do I migrate from manual testing?Export 50 recent cases from your production Langfuse. Manually classify "good/bad response" and use those labels as a seed for the golden dataset. 2-3 days of work, months of peace of mind.
Does it work with multi-step agents?Promptfoo only evaluates final output. To evaluate intermediate tool calls, DeepEval has ToolCorrectnessMetric. To evaluate full trajectories, we use deepeval.metrics.ConversationalMetric with the full Langfuse trace as input.
Does it detect prompt injection?Indirectly: if the adversarial input changes behavior, the golden cases fail. For direct detection, add a specific suite with garak or rebuff.
Next steps
The repo github.com/numoru-ia/agent-evals-template has the complete pipeline ready to fork. It includes seed golden datasets for: customer support, B2B sales agent, technical-doc RAG and AI receptionist. Each with 50-100 curated cases from real Numoru clients (anonymized).