Rails LLM Evals: Prompts Testen in CI Voordat Ze Productie Breken
Rails LLM evals vangen prompt-regressies voordat ze productie raken. Bouw golden datasets, scoor CI runs en monitor token-kosten voor Claude en OpenAI.
Een Rails-team dat ik adviseer rolde op een woensdagmiddag een wijziging van één regel uit naar een classificatie-prompt voor klantenservice. Die prompt verwerkte al negen maanden stilletjes 18.000 binnenkomende tickets per dag. Donderdagochtend werden urgente escalaties naar de facturatie-inbox gerouteerd, factuurgeschillen kwamen bij de engineering on-call rotatie terecht, en de support-lead zat aan haar bureau te twijfelen of zij gek aan het worden was. De wijziging bestond uit drie woorden. Ze dachten dat het de prompt beknopter zou maken. In plaats daarvan draaide het stilletjes de betekenis om van één van de categorieën die het model moest voorspellen. Er was geen test. Er was geen eval. Er was alleen de diff, de deploy en de ravage. We hebben een vrijdag besteed aan het schrijven van Rails LLM evals zoals het op dag één had gemoeten, en sindsdien hebben ze geen stille regressie meer gehad.
Na negentien jaar Rails ben ik niet meer verrast door wat productiecode doet. Na drie jaar LLMs in productie verbaast nog maar één ding me: hoe achteloos teams omgaan met prompt-wijzigingen. Een prompt is een functie. Hij heeft inputs, hij heeft outputs, hij heeft een gedragscontract, en dat contract kan door een komma breken. We testen onze Ruby. We testen onze SQL. We moeten onze prompts testen.
Waarom Rails LLM Evals Niet Meer Optioneel Zijn
Een klassieke test asserteert dat een functie een deterministische waarde teruggeeft. Een LLM-call doet dat niet. Roep dezelfde prompt twee keer aan en je krijgt twee verschillende strings. Dat is de eigenschap waardoor mensen hun handen ten hemel heffen en testen helemaal overslaan. Het is ook de eigenschap die evals onmisbaar maakt. Het hele punt van een Rails LLM eval is asserteren op de distributie van outputs over een zorgvuldig samengestelde dataset, niet op één gouden string.
Als je een van de volgende dingen in productie hebt, ben je jezelf evals verschuldigd: een classificatie-prompt die naar mensen routeert, een samenvattings-prompt die aan een klant getoond wordt, een gestructureerde extractie-prompt die naar een database schrijft, een tool-gebruikende agent die zij-effecten produceert, of welke prompt dan ook waar een stille kwaliteitsdaling een gebruiker schade berokkent voordat iemand het merkt. Dat is de meeste productie-Rails-apps met AI erin.
De kosten van evals zijn klein. De kosten van een stille regressie die twee weken loopt voordat iemand het opmerkt zijn enorm. Vorig kwartaal had een fintech waar ik mee werk een door Claude aangedreven transactie-categorizer die na een model-upgrade stilletjes de nauwkeurigheid liet zakken van 94 procent naar 71 procent. Ze ontdekten het op dag elf. De opruimactie duurde een maand.
De Anatomie van een Rails LLM Eval
Een bruikbare eval bestaat uit vier onderdelen: een dataset van inputs met bekende goede outputs, een runner die je prompt tegen elke input draait, een scorer die een oordeel per voorbeeld produceert, en een reporter die een stapel oordelen omzet in een pass/fail-signaal voor CI. De Ruby-community heeft hier nog geen dominant framework voor, wat goed nieuws is, want zelf bouwen kost ongeveer 200 regels code en geeft je precies de juiste ergonomie voor jouw codebase.
Ik bewaar evals in test/evals/ of spec/evals/ zodat ze zichtbaar zijn voor engineers maar geen deel uitmaken van de unit-test run. Ze zijn een aparte Rake-taak in CI, gated op wijzigingen aan prompt-bestanden of model-configuratiebestanden, en posten hun samenvatting terug naar de pull request.
Golden Datasets: Waar Eval-Kwaliteit Staat of Valt
Het meest onderschatte onderdeel van evals is de dataset. Een slechte dataset laat een geregresseerde prompt slagen. Een goede dataset vangt een regressie die een menselijke reviewer zou missen. Ik mik op 50 tot 200 voorbeelden per prompt, doelgericht samengesteld.
# test/evals/datasets/ticket_classifier.yml
- id: billing_dispute_polite
input: "Hi, I noticed I was charged twice for my March subscription. Can you help?"
expected_category: billing
expected_urgency: medium
notes: "Polite billing dispute, common path"
- id: angry_outage_caps
input: "EVERYTHING IS DOWN AND I HAVE A DEMO IN 20 MINUTES, FIX THIS NOW"
expected_category: outage
expected_urgency: critical
notes: "Adversarial caps, critical urgency cue"
- id: feature_request_disguised
input: "Is there a way to export my data as CSV? I really need this for my board meeting."
expected_category: feature_request
expected_urgency: low
notes: "Sounds urgent but is a feature request"
Waar komen de voorbeelden vandaan? Uit drie bronnen. Ten eerste de bugmeldingen — elke keer dat een klant of interne reviewer een misclassificatie vlagt, gaat dat voorbeeld in de dataset voordat de fix uitgaat. Ten tweede handmatig opgestelde adversariële voorbeelden voor elke categoriegrens waar je om geeft. Ten derde een gesamplede slice van echt productieverkeer met handmatig gelabelde verwachte outputs. Behandel de dataset als een asset met een lange levensduur. Versioneer hem. Review hem in pull requests. Laat hem niet rotten.
Een Minimale Eval Runner in Pure Ruby
Dit is de runner die ik op elk Rails-project als startpunt gebruik. Pure Ruby, parallel via threads (LLM-calls zijn I/O-bound), en levert een gestructureerd resultaat dat je naar elke pipeline kunt sluizen.
# lib/llm_evals/runner.rb
require "yaml"
require "concurrent"
module LlmEvals
class Runner
def initialize(dataset_path:, prompt:, scorer:, concurrency: 8)
@dataset = YAML.load_file(dataset_path, permitted_classes: [Symbol])
@prompt = prompt
@scorer = scorer
@pool = Concurrent::FixedThreadPool.new(concurrency)
end
def run
futures = @dataset.map do |example|
Concurrent::Promises.future_on(@pool) do
response = @prompt.call(example.fetch("input"))
score = @scorer.call(example, response)
{
id: example.fetch("id"),
input: example.fetch("input"),
expected: example.except("id", "input", "notes"),
actual: response.body,
score: score,
tokens: response.usage,
latency_ms: response.latency_ms
}
end
end
futures.map(&:value!)
ensure
@pool.shutdown
@pool.wait_for_termination
end
end
end
De prompt en scorer zijn gewone objecten met een call-methode, wat betekent dat je ze per test kunt vervangen en je geen DSL nodig hebt. Acht parallelle calls is een verstandige default — hoog genoeg om de wall-clock laag te houden, laag genoeg dat je geen rate limit raakt op een kleine Anthropic-tier.
Twee Scorers die 80 Procent van de Praktijk Dekken
De meeste productie-prompts vallen in één van drie categorieën: classificatie, gestructureerde extractie en vrije generatie. De eerste twee worden gescoord met simpele deterministische vergelijkingen. De derde heeft een LLM-judge nodig — wat duur en circulair klinkt maar in de praktijk goed werkt.
# lib/llm_evals/scorers/exact_match.rb
module LlmEvals
module Scorers
class ExactMatch
def initialize(fields:)
@fields = fields
end
def call(example, response)
parsed = JSON.parse(response.body)
mismatches = @fields.each_with_object({}) do |field, acc|
expected = example.fetch("expected_#{field}")
actual = parsed[field.to_s]
acc[field] = { expected: expected, actual: actual } if expected != actual
end
{ pass: mismatches.empty?, mismatches: mismatches }
end
end
end
end
Voor vrije generatie is een LLM-judge met een strakke rubric de juiste keuze. Gebruik een goedkoper model dan het model dat je test, geef het een vijfpuntsrubric en laat het een gestructureerde score teruggeven. Ik gebruik Claude Haiku om prompts te beoordelen die op Claude Sonnet draaien — dezelfde familie, andere kostenklasse, voorspelbare overeenstemming met menselijke reviewers.
# lib/llm_evals/scorers/llm_judge.rb
module LlmEvals
module Scorers
class LlmJudge
RUBRIC = <<~PROMPT
You are grading a customer-support reply. Return JSON:
{ "factual": 0..2, "tone": 0..2, "actionable": 0..2, "notes": "..." }
- factual: 2 if every claim is supported by the input, 0 if any hallucination.
- tone: 2 if professional and empathetic, 0 if dismissive or robotic.
- actionable: 2 if the customer can act on this immediately, 0 if not.
Customer message:
---
%{input}
---
Proposed reply:
---
%{reply}
---
PROMPT
def initialize(judge_client:)
@judge = judge_client
end
def call(example, response)
rendered = RUBRIC % { input: example.fetch("input"), reply: response.body }
judgment = JSON.parse(@judge.complete(rendered).body)
total = judgment.values_at("factual", "tone", "actionable").sum
{ pass: total >= 5, judgment: judgment, total: total }
end
end
end
end
De judge is niet onfeilbaar. Controleer steekproefsgewijs 10 procent van zijn beoordelingen tegen je eigen oordeel als je hem voor het eerst aansluit, en opnieuw elke keer dat je de rubric wijzigt. Als de judge het 90 procent van de tijd met je eens is, is hij goed genoeg om regressies te vangen.
Evals in CI Bedraden
De eval-suite draait op elke pull request die een prompt-bestand, een modelconfiguratie of de eval-code zelf aanraakt. De job post drie cijfers terug naar de PR: pass rate tegen de dataset, verandering in pass rate ten opzichte van main, en totale token-kosten van de run.
# .github/workflows/llm_evals.yml
name: llm-evals
on:
pull_request:
paths:
- "app/prompts/**"
- "config/llm.yml"
- "lib/llm_evals/**"
- "test/evals/**"
jobs:
evals:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with: { bundler-cache: true }
- run: bundle exec rake llm_evals:all > eval_report.json
env:
ANTHROPIC_API_KEY: $
- uses: actions/github-script@v7
with:
script: |
const report = require('./eval_report.json');
const body = `## LLM Eval Results\n` +
`- Pass rate: **${report.pass_rate}%** (was ${report.baseline}%)\n` +
`- Failures: ${report.failures.length}\n` +
`- Cost this run: $${report.cost.toFixed(2)}\n`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body
});
Faal de build als de pass rate met meer dan twee procentpunten daalt ten opzichte van main, of als de totale kosten met meer dan 50 procent stijgen. De kosten-guard is degene die je vangt wanneer je per ongeluk een retry-lus of een ontspoorde tool-use-lus toevoegt, voordat die productie haalt. Ben je niet bekend met hoe zulke lussen je tokenrekening kunnen laten exploderen, dan dekt de Rails AI agents post de faalmodi in meer detail.
Volg Kosten en Latency, Niet Alleen Nauwkeurigheid
Een prompt die 1 procent nauwkeuriger is en 4x duurder is zelden de moeite waard om uit te rollen. Elke eval-run registreert tokengebruik en latency per voorbeeld, en het rapport toont de p50 en p95 van beide. Ik heb veel “betere” prompts tijdens review afgeschoten omdat hun p95-latency onze SLO brak.
def summarize(results)
costs = results.map { |r| token_cost(r[:tokens]) }
latencies = results.map { |r| r[:latency_ms] }.sort
{
pass_rate: (results.count { |r| r[:score][:pass] }.fdiv(results.size) * 100).round(1),
failures: results.reject { |r| r[:score][:pass] },
cost: costs.sum.round(2),
p50_latency_ms: latencies[latencies.size / 2],
p95_latency_ms: latencies[(latencies.size * 0.95).floor]
}
end
Combineer dit met Anthropic prompt caching op de testruns zelf — een stabiele system-prompt over 200 eval-voorbeelden is exact de workload waarvoor caching ontworpen is, en het verlaagt de eval-kosten met 60 tot 80 procent.
Omgaan met Non-Determinisme Zonder Gek te Worden
Twee strategieën. Ten eerste: zet temperature: 0 op het model tijdens evals, zodat dezelfde input dezelfde output produceert voor zover de provider dat kan garanderen. Dit maakt regressiedetectie scherp. Ten tweede: voor prompts die in productie hogere temperature nodig hebben, draai je elk voorbeeld drie keer en eis je dat minstens twee van de drie slagen. Dat verdrievoudigt de eval-kosten maar vertelt je of de prompt robuust is of gewoon mazzel had.
def call_with_majority(example)
responses = 3.times.map { @prompt.call(example.fetch("input")) }
scores = responses.map { |r| @scorer.call(example, r) }
pass_count = scores.count { |s| s[:pass] }
{ pass: pass_count >= 2, individual: scores }
end
Ik draai goedkope classificatie-evals op elke PR. De duurdere judge-gebaseerde evals draai ik op een nachtelijk schema en post het trendresultaat naar een Slack-channel. Die cadans vangt de regressies die ertoe doen zonder het API-budget op te branden.
Wat Evals Niet Zullen Vangen
Evals vangen gedragsregressies op jouw dataset. Ze vangen niet dat een prompt wordt misbruikt op inputs die je niet voorzag. Ze vangen niet dat een modelprovider het onderliggende model stilletjes hertraint en de defaults verandert. Ze vangen geen gebruikerswaarneming-drift waar dezelfde nauwkeurigheid in productie anders landt. Daarvoor heb je productie-monitoring nodig — steekproefsgewijze menselijke review van live outputs, klantgerichte duim-omhoog/duim-omlaag feedback die terugvloeit in de dataset, en wekelijks een gestructureerde blik op een slice van echte outputs door iemand die het product kent.
Evals zijn noodzakelijk. Ze zijn niet voldoende. Ze zijn de vloer die je toestaat om prompt-wijzigingen sneller dan eens per kwartaal uit te rollen.
FAQ
Hoeveel voorbeelden heeft een Rails LLM eval-dataset nodig?
Voor classificatie- en gestructureerde-extractie-prompts is 50 voorbeelden een werkbare ondergrens en is 150 tot 200 comfortabel. Voor vrije generatie beoordeeld door een LLM-judge begin je bij 30 voorbeelden omdat elke run duur is. De metric die je in de gaten houdt is of de pass rate noemenswaardig beweegt wanneer je de prompt tijdens testen bewust regresseert. Als een bekend-slechte prompt nog steeds slaagt, is de dataset te klein of te makkelijk.
Moeten Rails LLM evals draaien op elke commit of alleen op prompt-wijzigingen?
Gate ze op wijzigingen aan prompt-bestanden, modelconfiguratie en de eval-code zelf, plus een volledige nachtelijke run. Op elke commit draaien verbrandt het API-budget voor wijzigingen die het resultaat niet kunnen beïnvloeden. De nachtelijke run vangt drift vanaf de provider-kant — Anthropic en OpenAI rollen beide stilletjes verbeteringen uit aan hun modellen, en je evals vertellen je wanneer een van die verbeteringen voor jouw use case eigenlijk een regressie is.
Hoe ga je om met Rails LLM evals als de model-output non-deterministisch is?
Twee benaderingen die goed samen werken. Zet temperature: 0 voor evals zodat het model zo deterministisch is als de provider toestaat. Voor prompts die in productie een hogere temperature nodig hebben, draai je elk voorbeeld drie keer en vereis je een meerderheidspass. Die laatste verdrievoudigt de eval-kosten maar vertelt je of de prompt echt robuust is of incidenteel een goede output produceert door geluk.
Zijn Rails LLM evals de moeite waard voor interne prompts met lage volumes?
Ja, met een kleinere dataset. Een eval van 20 voorbeelden voor een interne prompt die 200 keer per dag wordt aangeroepen, vangt nog steeds de stille regressie die het vertrouwen in de AI-feature onder de mensen die hem gebruiken stilletjes uitholt. Interne gebruikers zijn stiller over kwaliteitsdalingen dan betalende klanten, wat de regressie moeilijker te detecteren maakt via normale feedbackloops.
Bouw of onderhoud je LLM-features in een Rails-app en wil je een gestructureerde eval-setup die regressies vangt voordat gebruikers dat doen? TTB Software bouwt Rails LLM eval-infrastructuur voor productie-AI-systemen. Negentien jaar Rails, drie jaar LLMs in productie, levering tegen vaste prijs.
Related Articles
Rails ActionMailer Productiegids: E-mailbezorging, Moderne API's en Waterdicht Testen
Rails ActionMailer in productie: Resend, Postmark of SendGrid, betrouwbare inboxbezorging, bounceverwerking, deliver_...
Rails Technical Due Diligence: De Fractional CTO-Checklist voor Kopers en Investeerders
Rails technical due diligence checklist van een fractional CTO. Wat te auditen voor je een Rails-app koopt of erin in...
Rails Phlex: Ruby-first view components die sneller zijn dan ERB en ViewComponent
Rails Phlex schrijft views in pure Ruby — geen templates, geen DSL-verrassingen. Sneller dan ERB, kleiner dan ViewCom...