<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Litmus</title>
    <description>The latest articles on DEV Community by Litmus (@litmustools).</description>
    <link>https://dev.to/litmustools</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4238757%2F52233233-bb89-4fcb-bf33-ba8bf58efbe3.png</url>
      <title>DEV Community: Litmus</title>
      <link>https://dev.to/litmustools</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/litmustools"/>
    <language>en</language>
    <item>
      <title>Catch LLM regressions in CI before your users do</title>
      <dc:creator>Litmus</dc:creator>
      <pubDate>Wed, 11 Nov 2026 11:46:14 +0000</pubDate>
      <link>https://dev.to/litmustools/catch-llm-regressions-in-ci-before-your-users-do-25bk</link>
      <guid>https://dev.to/litmustools/catch-llm-regressions-in-ci-before-your-users-do-25bk</guid>
      <description>&lt;p&gt;You change one line of a prompt. The demo still works. You merge. Two days later a user screenshots a nonsense answer and asks what happened.&lt;/p&gt;

&lt;p&gt;What happened: nothing caught the regression, because nothing ran the prompt against real cases before merge.&lt;/p&gt;

&lt;p&gt;Unit tests do not cover this. A prompt is not a function with one right answer - it has a distribution of acceptable answers, and the only cheap way to pin that distribution down is a small golden dataset plus a runner that scores every change against it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 20-minute setup
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Write 10-30 cases from real traffic. Each case: the input, plus what a good answer must contain (or must not contain). JSONL is fine:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"refund request, order late by 3 weeks"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"must_include"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"refund"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"apolog"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"must_not_include"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"as an AI"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"ask for discount on enterprise plan"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"must_include"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"annual"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"max_words"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Run your app against the cases on every PR. Score with simple checks first (must_include, must_not_include, length, JSON validity). Add model-graded checks later if you need them - most teams never do.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Fail the build when the pass rate drops below the last known-good run.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is the whole pattern. The tooling should stay out of the way: a YAML file for config, a JSONL file for cases, one command in CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looks like with Litmus
&lt;/h2&gt;

&lt;p&gt;I maintain Litmus, a zero-dependency harness for exactly this (single Python file, Python 3.8+, standard library only, MIT license):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"support-bot-suite"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"openai"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"cases"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"refund-request"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;"prompt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Reply to this customer: {{message}}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;"vars"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"my order is 3 weeks late, I want a refund"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;"checks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nl"&gt;"contains"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"refund"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;"not_contains"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"as an AI"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;"max_latency_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8000&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ python3 litmus.py run tests.json --baseline .litmus-baseline.json
Litmus 1.0.0 - suite &lt;span class="s1"&gt;'support-bot-suite'&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;provider: openai&lt;span class="o"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt;FAIL&lt;span class="o"&gt;]&lt;/span&gt; refund-request
        failed check: contains &lt;span class="s1"&gt;'refund'&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt;PASS&lt;span class="o"&gt;]&lt;/span&gt; discount-question
1/2 cases passed

REGRESSIONS vs baseline:
  - refund-request: PASS -&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; FAIL &lt;span class="o"&gt;(&lt;/span&gt;contains &lt;span class="s1"&gt;'refund'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;# exit code 1 - the merge is blocked&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In CI the non-zero exit blocks the merge, and JUnit XML output feeds your CI dashboard. There is a deterministic offline mock provider, so you can test your wiring without spending tokens. The full setup guide is in the free starter pack (link on my profile).&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistakes everyone makes first
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Testing with vibes. "I tried it three times" is not a signal. Thirty saved cases beat three fresh tries because they are the same thirty every time.&lt;/li&gt;
&lt;li&gt;Golden datasets that never grow. Add a case every time a user reports a bad answer. The dataset is the memory of every failure you have already paid for.&lt;/li&gt;
&lt;li&gt;Model-graded everything on day one. String and length checks catch the majority of regressions and cost nothing. Start there.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Prompts change weekly now. The teams that treat them like code - versioned, reviewed, tested - ship faster precisely because they break less.&lt;/p&gt;




&lt;p&gt;If you want the runner already built: Litmus Starter is a free sample pack (config, sample dataset, full CLI) and the complete version is $12 with the CI templates included. Links on my profile.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>testing</category>
      <category>python</category>
    </item>
    <item>
      <title>I got tired of breaking my LLM app with "one small prompt change", so I built a 450-line eval harness</title>
      <dc:creator>Litmus</dc:creator>
      <pubDate>Wed, 11 Nov 2026 09:45:27 +0000</pubDate>
      <link>https://dev.to/litmustools/i-got-tired-of-breaking-my-llm-app-with-one-small-prompt-change-so-i-built-a-450-line-eval-4546</link>
      <guid>https://dev.to/litmustools/i-got-tired-of-breaking-my-llm-app-with-one-small-prompt-change-so-i-built-a-450-line-eval-4546</guid>
      <description>&lt;p&gt;Every LLM developer I know has the same workflow: tweak the prompt, eyeball three outputs, ship it. Then a user reports that the refund flow has been answering wrong for two days.&lt;/p&gt;

&lt;p&gt;The fix is evals, but the popular options all asked for more than I wanted to give: a SaaS account, a framework dependency, a YAML religion. I wanted &lt;code&gt;pytest&lt;/code&gt; energy: one file, run it, exit code.&lt;/p&gt;

&lt;p&gt;So I wrote Litmus: a single Python file, stdlib only, that evals an LLM app against a JSONL dataset and gates regressions in CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a case looks like
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"refund-q"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"How do I get a refund?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="nl"&gt;"expect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"contains"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"30 days"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"regex"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"refund"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Input, plus what a good answer must contain. Run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 litmus.py run --dataset cases.jsonl --endpoint https://api.openai.com/v1 --model gpt-4o-mini
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  LITMUS REPORT  ============================================
  pass rate   97.4%  (76/78)
  failed      1
  errored     1
  latency     mean 0.84s  p50 0.71s  p95 1.9s
  ============================================================
  [FAIL] refund-question (0.91s)
        contains: missing: 30 days
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The part that actually saves you: compare
&lt;/h2&gt;

&lt;p&gt;Run Litmus against your production prompt, save the JSON report as the baseline, and check every change against it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 litmus.py compare baseline.json current.json --threshold 0.02
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It lists the exact cases that used to pass and now fail, and exits 1 - so GitHub Actions blocks the merge. The workflow file is 15 lines, included in the guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design rules I stuck to
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One file, no dependencies.&lt;/strong&gt; &lt;code&gt;pip install&lt;/code&gt; is where side tools go to die. Litmus is Python 3.8+ stdlib only. You can read all of it before running it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No lock-in.&lt;/strong&gt; Your dataset is JSONL in your repo. If you stop using Litmus tomorrow, your evals still mean something.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any app.&lt;/strong&gt; Pipe mode tests anything that reads a case on stdin and prints an answer - Python, Node, a shell script. Endpoint mode talks to any OpenAI-compatible server, including local Ollama models, which makes the whole loop free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cheap scorers first, judge last.&lt;/strong&gt; &lt;code&gt;contains&lt;/code&gt; and &lt;code&gt;regex&lt;/code&gt; are deterministic and free. LLM-as-judge is there for tone and helpfulness, with your rubric - and judge failures are reported as errors, never silently passed.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I learned building it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mine real failures.&lt;/strong&gt; Every user-reported bad answer becomes a case the same day. Ten real failures beat a hundred synthetic cases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small cases.&lt;/strong&gt; One expectation per case, so the failure list reads like a bug report.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency is a regression too.&lt;/strong&gt; A prompt change that doubled p95 response time is a production incident you haven't had yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Litmus is $12, one time: &lt;a href="https://litmustools.gumroad.com/l/litmus" rel="noopener noreferrer"&gt;https://litmustools.gumroad.com/l/litmus&lt;/a&gt;. You get the tool, the field guide (eval-set patterns, LLM-judge rules, the CI workflow), and a runnable example. If it saves one bad deploy, it paid for itself.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
