<?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: Mayur Agnani</title>
    <description>The latest articles on DEV Community by Mayur Agnani (@theaicommit).</description>
    <link>https://dev.to/theaicommit</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%2F2280575%2F3c36a1b6-308f-429b-a971-c39346a9c238.jpg</url>
      <title>DEV Community: Mayur Agnani</title>
      <link>https://dev.to/theaicommit</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/theaicommit"/>
    <language>en</language>
    <item>
      <title>How to Fix Jargon in Speech-to-Text Without Adding Words Nobody Said</title>
      <dc:creator>Mayur Agnani</dc:creator>
      <pubDate>Mon, 02 Nov 2026 10:34:12 +0000</pubDate>
      <link>https://dev.to/theaicommit/how-to-fix-jargon-in-speech-to-text-without-adding-words-nobody-said-4acc</link>
      <guid>https://dev.to/theaicommit/how-to-fix-jargon-in-speech-to-text-without-adding-words-nobody-said-4acc</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Engineer's view.&lt;/strong&gt; This is a search spellchecker with a custom dictionary. Add your product names and their misspellings get fixed, but nearby ordinary words get "corrected" too. A transcription prompt works the same way: a listed spelling wins wherever the audio is close to it. You get your jargon right and pay in confident wrong words.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Give a speech-to-text model a short list of your terms and it spells most of them right. It also writes those terms where someone said a word that only sounds alike, so test for both.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Explain Like I'm 5
&lt;/h2&gt;

&lt;p&gt;Imagine a friend writing down everything you say at a noisy party. Before you start, you hand&lt;br&gt;
them a card with the tricky names on it. Now they spell those names right.&lt;/p&gt;

&lt;p&gt;But then someone says "Laura." Your friend glances at the card and writes the name from the card&lt;br&gt;
instead.&lt;/p&gt;

&lt;p&gt;The card helps your friend choose between words that sound alike. It cannot tell them which word&lt;br&gt;
was actually said. So the card fixes some mistakes and makes a few new ones.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;You have shipped this bug before, in a system with no model in it.&lt;/p&gt;

&lt;p&gt;Your search box had a spellchecker. Users typed your product names and got "no results," so you&lt;br&gt;
added the names to its dictionary. The failed searches stopped. A week later, a support ticket&lt;br&gt;
arrived. Someone searched for "laura" and got pages about your LoRA feature. The dictionary did&lt;br&gt;
what you asked. It pulled every close word toward the words you listed.&lt;/p&gt;

&lt;p&gt;A speech-to-text model is that spellchecker, running on sound.&lt;/p&gt;

&lt;p&gt;You send it meeting audio, a support call or a video voiceover. It hears "Q drant" and writes&lt;br&gt;
"Q-drant." It hears "K V cache" and writes "cavity." I read 15 AI terms to two OpenAI&lt;br&gt;
transcription models with no help. whisper-1 spelled 14% of them right, and gpt-transcribe spelled&lt;br&gt;
36% right. Nothing raised an error. The transcript just looked slightly wrong, and every search,&lt;br&gt;
summary and caption built on it inherited the mistake. My own video captions hit this last week.&lt;br&gt;
The voice said "halve" and the transcript said "have."&lt;/p&gt;

&lt;p&gt;Both models take a hint: a text prompt, or a list of keywords. Add one and most of the jargon&lt;br&gt;
comes out right. Some of it also comes out where nobody said it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix: give the model a short list of the terms this recording is likely to contain, put the&lt;br&gt;
important ones last, and test it on clips that say near-miss words, not only on clips that say the&lt;br&gt;
terms.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The Fix: Keep the Term List Short, Ordered and Tested on Near Misses
&lt;/h2&gt;
&lt;h3&gt;
  
  
  What does the prompt actually change?
&lt;/h3&gt;

&lt;p&gt;A transcription model has two halves. The encoder turns audio into features. The &lt;strong&gt;decoder&lt;/strong&gt;&lt;br&gt;
writes the text, one &lt;strong&gt;token&lt;/strong&gt; (a word or a piece of one) at a time, like any language model. It&lt;br&gt;
picks each token by how well it fits the sound and the text written before it.&lt;/p&gt;

&lt;p&gt;The prompt goes in front of that text, as if it had been said just before the recording. Whisper's&lt;br&gt;
open-source code marks it with a "previous text" token. It is not an instruction. OpenAI's guide&lt;br&gt;
says Whisper copies a prompt's style and ignores its commands. So a list of spellings does one&lt;br&gt;
thing: it makes those spellings more likely.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdreher6u22f0kkspalco.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdreher6u22f0kkspalco.png" alt="A term list adds probability, not evidence" width="800" height="370"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Why does it write words nobody said?
&lt;/h3&gt;

&lt;p&gt;Because the boost never checks the audio, it wins any close call. With a 15-term list, whisper-1&lt;br&gt;
spelled 90% of the terms right. On the same run, "Laura will call you back" became "LoRA will call&lt;br&gt;
you back" in 6 of 6 clips. "Cash the check" became "cache the check" in 6 of 6. gpt-transcribe,&lt;br&gt;
given the terms as keywords, wrote "Mixtral" for "mixed trial" in 5 of 6. Sentences that sounded&lt;br&gt;
like no term never changed.&lt;/p&gt;
&lt;h3&gt;
  
  
  Doesn't a longer list cover more?
&lt;/h3&gt;

&lt;p&gt;Not on Whisper. It keeps only the last 223 tokens of the prompt and drops the rest from the front.&lt;br&gt;
My 75-term list was 277 tokens. With the 15 real terms first, the cut took 14 of them, and accuracy&lt;br&gt;
fell to 19%. The same list with those terms last scored 92%.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frz430xrd48ughw2itqgj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frz430xrd48ughw2itqgj.png" alt="whisper-1: listed terms spelled right, by prompt (90 clips each)" width="800" height="310"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  What can a list never fix?
&lt;/h3&gt;

&lt;p&gt;A list chooses between spellings the audio already supports. It cannot add an underscore nobody&lt;br&gt;
spoke, so "max tokens" never came out as &lt;code&gt;max_tokens&lt;/code&gt;. And it cannot separate two words that sound&lt;br&gt;
the same. "Halve" stayed "have" on whisper-1 in every clip.&lt;/p&gt;
&lt;h2&gt;
  
  
  What This Means for You
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;When this matters.&lt;/strong&gt; You transcribe audio that contains names, product terms or acronyms. That&lt;br&gt;
covers meeting notes, call summaries, video captions, voice agents and search over recordings. It&lt;br&gt;
matters most when code acts on the words: a search index, a ticket field, or a voice agent that&lt;br&gt;
picks a tool from what it heard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it affects you.&lt;/strong&gt; A term list is the cheapest accuracy win you have. In my test it moved&lt;br&gt;
whisper-1 from 14% to 90% on jargon, for no extra cost per call. But it changes two numbers, and&lt;br&gt;
most teams only watch one. A voice agent that hears "cache" for "cash" may call the wrong tool. A&lt;br&gt;
long glossary on Whisper can quietly do nothing, because the terms you cared about were cut first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to do about it.&lt;/strong&gt; Start with no setup. Take ten real recordings and search their&lt;br&gt;
transcripts for your product names. Count the misspellings. That number tells you whether this is&lt;br&gt;
worth an afternoon.&lt;/p&gt;

&lt;p&gt;Next, send a short list with each request. Pick the terms this recording is likely to contain,&lt;br&gt;
from its meeting title, its customer or its agenda, rather than your whole glossary. Put the&lt;br&gt;
important terms last.&lt;/p&gt;

&lt;p&gt;Then build a small &lt;strong&gt;decoy set&lt;/strong&gt;: clips that say ordinary words close to your terms, such as&lt;br&gt;
"Laura" for LoRA. Run it with and without the list. Ship the list only if term accuracy rises and&lt;br&gt;
the decoys stay clean.&lt;/p&gt;

&lt;p&gt;Finally, fix formatting in code after the transcript arrives, never in the prompt.&lt;/p&gt;
&lt;h2&gt;
  
  
  Implementing It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The change.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Four jobs, and one person may do all of them.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Job 1, whoever calls the API.&lt;/em&gt; Send the terms in the field each model reads. whisper-1 takes one&lt;br&gt;
&lt;code&gt;prompt&lt;/code&gt; string. gpt-transcribe takes literal terms in &lt;code&gt;keywords&lt;/code&gt;, one form field each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.openai.com/v1/audio/transcriptions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$OPENAI_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="nv"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;whisper-1 &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="nv"&gt;language&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;en &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;@call.mp3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"prompt=Kubernetes, pgvector, SGLang, Qdrant, vLLM."&lt;/span&gt;

curl https://api.openai.com/v1/audio/transcriptions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$OPENAI_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="nv"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gpt-transcribe &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s1"&gt;'languages[]=en'&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;@call.mp3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s1"&gt;'keywords[]=Qdrant'&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s1"&gt;'keywords[]=pgvector'&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s1"&gt;'keywords[]=vLLM'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A keyword containing &lt;code&gt;&amp;lt;&lt;/code&gt;, &lt;code&gt;&amp;gt;&lt;/code&gt; or a line break makes the API reject the whole request. Strip those&lt;br&gt;
before you send.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Job 2, whoever owns the glossary.&lt;/em&gt; Build the list per recording, inside the budget, with the most&lt;br&gt;
important term last. Whisper drops from the front, so the order is the priority:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;budget&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;223&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;terms: most important first. Returns the prompt and the terms that did not fit.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;est_tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;  &lt;span class="c1"&gt;# +1 for the comma
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;cost&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="n"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;cost&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;reversed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;):]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;est_tokens&lt;/code&gt; deliberately overestimates. For an exact count, use the GPT-2 tokenizer, which is what&lt;br&gt;
Whisper uses.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Job 3, whoever consumes the transcript.&lt;/em&gt; When a wrong term is costly, transcribe twice and diff.&lt;br&gt;
A listed term that appears only in the prompted version is either a fix or an insertion. A person&lt;br&gt;
or a second check decides which:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;suspect_terms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[\w-]+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;pieces&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;terms&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[\w-]+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nf"&gt;words&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;words&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plain&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;pieces&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On my sample, it flagged all five fixes and both insertions. It compares exact case, so "LoRa"&lt;br&gt;
becoming "LoRA" counts. It compares words, so "cash" becoming "cache" is caught even though the&lt;br&gt;
listed term is "KV cache."&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Job 4, whoever owns the eval.&lt;/em&gt; Score two numbers on every list change: terms spelled right on&lt;br&gt;
clips that say them, and decoy clips with any listed term in them. &lt;code&gt;score()&lt;/code&gt; in&lt;br&gt;
&lt;a href="https://github.com/magna56/aI_daily_run/blob/main/2026-11-02/code_example.py" rel="noopener noreferrer"&gt;&lt;code&gt;code_example.py&lt;/code&gt;&lt;/a&gt; does both, and it runs on real transcripts from my test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How you know it worked.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Term accuracy on your term clips should rise sharply. On whisper-1 it went from 14% to 90%. Decoy&lt;br&gt;
insertions should stay at or near zero. Mine were 13 of 72 with the short list, all on decoys&lt;br&gt;
built to sound like a term. If accuracy does not move on Whisper, count your prompt's tokens&lt;br&gt;
first. A list over 223 tokens has lost its front.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a Term List Is the Wrong Tool
&lt;/h2&gt;

&lt;p&gt;When your vocabulary is huge. A catalog of 5,000 product codes will not fit in 223 tokens, and a&lt;br&gt;
long keyword list raises the insertion risk. Transcribe without the list, then correct the text&lt;br&gt;
against a lookup table, or with a text model given the catalog. OpenAI's guide suggests the second.&lt;/p&gt;

&lt;p&gt;When the words are ordinary. Listing common words gives the boost nothing to fix and plenty to&lt;br&gt;
break.&lt;/p&gt;

&lt;p&gt;When you need high accuracy on one domain. NVIDIA reports a medical benchmark where a better prompt&lt;br&gt;
cut the word error rate from 30.8% to 23.8%, and fine-tuning cut it to 11.8%. A prompt is the&lt;br&gt;
first step, not the last.&lt;/p&gt;

&lt;p&gt;When you chain chunks. Passing the previous chunk's transcript as the prompt carries its mistakes&lt;br&gt;
forward as well as its spellings.&lt;/p&gt;

&lt;p&gt;My test has limits. It used three synthetic macOS voices, 15 terms and one run per clip. Real&lt;br&gt;
speakers mumble, and accents move the close calls. Run your own decoy set before you trust my&lt;br&gt;
numbers.&lt;/p&gt;

&lt;p&gt;Three questions before you ship a list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which of your terms sound like an ordinary word?&lt;/li&gt;
&lt;li&gt;Does your prompt fit in the model's budget, with the important terms last?&lt;/li&gt;
&lt;li&gt;What happens downstream when a listed term appears that nobody said?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Glossary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;decoder&lt;/strong&gt; — the half of a transcription model that writes text, one token at a time, like a language model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;token&lt;/strong&gt; — a word or piece of a word; the unit a model reads and writes, and the unit prompt budgets count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;keywords&lt;/strong&gt; — gpt-transcribe's field for literal terms you expect to hear; hints, not required output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;decoy set&lt;/strong&gt; — test clips that say ordinary words close to your terms, used to catch insertions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;insertion&lt;/strong&gt; — a listed term written into a transcript where the speaker said a different word.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developers.openai.com/api/docs/guides/speech-to-text" rel="noopener noreferrer"&gt;Speech to text&lt;/a&gt; — OpenAI API documentation&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/openai/whisper/blob/main/whisper/decoding.py" rel="noopener noreferrer"&gt;decoding.py in openai/whisper&lt;/a&gt; — OpenAI Whisper repository&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developers.openai.com/cookbook/examples/whisper_prompting_guide" rel="noopener noreferrer"&gt;Whisper prompting guide&lt;/a&gt; — OpenAI Cookbook&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://huggingface.co/blog/nvidia/nemotron-transcribe-granary" rel="noopener noreferrer"&gt;NVIDIA Nemotron 3.5 Transcribe and Granary v2&lt;/a&gt; — NVIDIA on the Hugging Face blog&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://theaicommit.com/2026-11-02-how-to-fix-jargon-in-speech-to-text-without-adding-words/" rel="noopener noreferrer"&gt;theaicommit.com&lt;/a&gt;, a daily AI engineering lab for software engineers. The original has an &lt;a href="https://theaicommit.com/2026-11-02-how-to-fix-jargon-in-speech-to-text-without-adding-words/" rel="noopener noreferrer"&gt;interactive version&lt;/a&gt; you can play with, and the full runnable code is on &lt;a href="https://github.com/magna56/aI_daily_run/blob/main/2026-11-02/code_example.py" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>promptengineering</category>
      <category>testing</category>
    </item>
    <item>
      <title>How to Stop a Long LLM Answer From Freezing When the Server Runs Out of Memory</title>
      <dc:creator>Mayur Agnani</dc:creator>
      <pubDate>Sun, 01 Nov 2026 13:34:10 +0000</pubDate>
      <link>https://dev.to/theaicommit/how-to-stop-a-long-llm-answer-from-freezing-when-the-server-runs-out-of-memory-4kj3</link>
      <guid>https://dev.to/theaicommit/how-to-stop-a-long-llm-answer-from-freezing-when-the-server-runs-out-of-memory-4kj3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Engineer's view.&lt;/strong&gt; This is memory overcommit. Back every page up front and half your RAM sits idle. Overcommit, and an out-of-memory killer stops a process at peak. An LLM server faces the same choice, because each request's memory grows with its answer. You pay in idle GPU memory or in frozen streams.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; An LLM server must decide how much memory to hold for an answer before the model has written it. Hold too much and you waste capacity. Hold too little and a running answer gets paused to make room.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Explain Like I'm 5
&lt;/h2&gt;

&lt;p&gt;Picture a restaurant where nobody knows how long each family will stay. You could save a giant&lt;br&gt;
table for every family, just in case. Most families leave early, so half your seats sit empty while&lt;br&gt;
people wait outside.&lt;/p&gt;

&lt;p&gt;Or you could seat everyone at small tables and hope. When the room gets full, you ask the newest&lt;br&gt;
family to stand up and wait, even in the middle of dinner. They keep their food. They just can't&lt;br&gt;
eat for a while.&lt;/p&gt;

&lt;p&gt;An AI server runs that restaurant, and every answer is a family.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;You have shipped this bug already, in a system with no model in it.&lt;/p&gt;

&lt;p&gt;You ran a service on Kubernetes. You set its memory request to the worst case you had ever seen.&lt;br&gt;
Nothing crashed. But the cluster reported itself full while the real usage sat near 40%, and new&lt;br&gt;
pods stayed pending.&lt;/p&gt;

&lt;p&gt;So you lowered the request to the average. The cluster filled up nicely. Then a busy afternoon&lt;br&gt;
came, several pods grew at once, and the kernel killed one in the middle of its work.&lt;/p&gt;

&lt;p&gt;You were guessing a number before you knew the answer. An LLM server guesses that number for every&lt;br&gt;
request.&lt;/p&gt;

&lt;p&gt;The memory here is the &lt;strong&gt;KV cache&lt;/strong&gt;: the model's saved work for every token so far. Without it, the&lt;br&gt;
model would reread the whole conversation for each new token. It grows by one token per token&lt;br&gt;
generated. The model only decides the length by stopping, so at the moment a request starts, nobody&lt;br&gt;
knows its final size. The caller sends &lt;code&gt;max_tokens&lt;/code&gt;, but that is a ceiling, not a forecast.&lt;/p&gt;

&lt;p&gt;Under load, that guess shows up in two ways. Your GPU memory looks full while throughput sits far&lt;br&gt;
below what you paid for. Or a streaming answer stops mid-sentence for many seconds, then carries on&lt;br&gt;
as if nothing happened. Neither one logs an error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix: find out which rule your server uses, measure how long your answers really are, then set&lt;br&gt;
&lt;code&gt;max_tokens&lt;/code&gt; and your concurrency limit from those lengths.&lt;/strong&gt; New to the KV cache? Start at&lt;br&gt;
&lt;a href="https://theaicommit.com/#2026-10-11" rel="noopener noreferrer"&gt;How to Work Out How Much GPU Memory One Request Really Needs&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Fix: Match the Limits to Your Server's Admission Rule
&lt;/h2&gt;

&lt;p&gt;Admission is the moment a server lets a waiting request start. Three popular servers give three&lt;br&gt;
different answers to one question: what must be free first?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frn7rtg2qa7jvp1ruaivd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frn7rtg2qa7jvp1ruaivd.png" alt="One unknown length, three admission rules, two possible bills" width="800" height="370"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  What does each server hold back?
&lt;/h3&gt;

&lt;p&gt;TGI counts the prompt plus the full &lt;code&gt;max_new_tokens&lt;/code&gt; against its budget. It cannot run out mid-answer.&lt;/p&gt;

&lt;p&gt;SGLang counts &lt;code&gt;max_new_tokens&lt;/code&gt; times a ratio, clipped at 4,096 tokens. The ratio starts at 0.7 and&lt;br&gt;
falls to about 0.1 while things go well. When memory runs out, it resets from what requests really&lt;br&gt;
used.&lt;/p&gt;

&lt;p&gt;vLLM counts only what exists now. Its &lt;code&gt;--watermark&lt;/code&gt; setting can keep some memory free, but the default&lt;br&gt;
is zero.&lt;/p&gt;
&lt;h3&gt;
  
  
  What happens when memory runs out?
&lt;/h3&gt;

&lt;p&gt;The server &lt;strong&gt;evicts&lt;/strong&gt; a running request. It frees that request's cache and sends it to the front of&lt;br&gt;
the waiting line. vLLM picks the newest request. SGLang picks the one with the least output.&lt;/p&gt;

&lt;p&gt;The answer isn't lost. Tokens already generated are kept. The request must rebuild its cache by&lt;br&gt;
rereading the prompt and its own output, and only then does the stream move again. In vLLM, nothing&lt;br&gt;
new starts during a step that evicted.&lt;/p&gt;
&lt;h3&gt;
  
  
  Is the redone work the expensive part?
&lt;/h3&gt;

&lt;p&gt;I expected it to be. It wasn't. My first simulation was built to show a server thrashing, and&lt;br&gt;
throughput never collapsed. Rereading is much faster than generating.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Ftheaicommit.com%2Fassets%2Ffigures%2F2026-11-01%2Ffig-2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Ftheaicommit.com%2Fassets%2Ffigures%2F2026-11-01%2Ffig-2.png" alt="Long answers: tokens per second by admission rule (simulated, median of 9 runs)" width="799" height="272"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Eviction redid 80% of the prefill work and still delivered about five times the throughput. The&lt;br&gt;
price landed elsewhere: 26% of finished requests were evicted, and their median freeze was 58&lt;br&gt;
seconds. Throughput hid that completely.&lt;/p&gt;
&lt;h2&gt;
  
  
  What This Means for You
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;When this matters.&lt;/strong&gt; You run your own model server, or you are choosing one. It matters most when&lt;br&gt;
answers vary widely in length, as they do with reasoning models and agents. If you only call a hosted&lt;br&gt;
API, this is the provider's problem. You can still detect it from your side, and your &lt;code&gt;max_tokens&lt;/code&gt;&lt;br&gt;
may still matter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it affects you.&lt;/strong&gt; The same GPU can give very different results depending on which rule it runs.&lt;br&gt;
On a reserving server, one team's careless limit costs everyone. In my chat simulation, moving&lt;br&gt;
&lt;code&gt;max_tokens&lt;/code&gt; from 8,192 to 4,096 raised throughput 29%, from 351 to 454 tokens per second. Answers&lt;br&gt;
were short, so about 0.5% would have been cut. On an evicting server, average throughput looks&lt;br&gt;
healthy while a few users stare at a stopped stream. Your dashboard's mean hides them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to do about it.&lt;/strong&gt; Start with no setup. Log the largest gap between chunks on your streaming&lt;br&gt;
calls. A gap of many seconds in the middle of an answer is your first sign.&lt;/p&gt;

&lt;p&gt;Next, log how many tokens your answers really use. Compare that with the &lt;code&gt;max_tokens&lt;/code&gt; you send.&lt;br&gt;
Most teams send a default nobody chose.&lt;/p&gt;

&lt;p&gt;Then cap concurrency from those lengths. On an evicting server, that cap removed more freezing than&lt;br&gt;
any other setting I tried. Finally, check the eviction counter and alert on it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Implementing It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The change.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Four jobs, and one person may do all of them.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Job 1, whoever owns the client.&lt;/em&gt; Measure the freeze from the outside. This works against any server.&lt;br&gt;
In your streaming call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;msgs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;
&lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream_max_gap_seconds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start timing after the first chunk, as this does, so that queue time isn't counted as a freeze.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Job 2, whoever sets &lt;code&gt;max_tokens&lt;/code&gt;.&lt;/em&gt; Choose it from logged lengths, not from the model's limit. Take a&lt;br&gt;
value a little above your longest normal answer, then count what it would cut:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lengths&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.999&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lengths&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="n"&gt;cut&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lengths&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lengths&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A wrong limit shows up as &lt;code&gt;finish_reason: "length"&lt;/code&gt;. Alert on that rate.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Job 3, whoever runs the server.&lt;/em&gt; Work out two limits from the same logs. &lt;code&gt;safe_concurrency()&lt;/code&gt; in&lt;br&gt;
&lt;a href="https://github.com/magna56/aI_daily_run/blob/main/2026-11-01/code_example.py" rel="noopener noreferrer"&gt;&lt;code&gt;code_example.py&lt;/code&gt;&lt;/a&gt; does this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;never&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mean_prompt&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;p95_answer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# almost no evictions, low throughput
&lt;/span&gt;&lt;span class="n"&gt;average&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mean_prompt&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;mean_answer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# the memory is full on an average mix
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pool&lt;/code&gt; is your KV cache size in tokens. Those limits came out at 4 and 15 for the long-answer case. The upper&lt;br&gt;
limit is the useful one here. It gave up 17% of throughput, cut evicted requests from 26% to 7%, and&lt;br&gt;
cut the median freeze from 58 to 17 seconds.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fouc5s91vtfqjr6jm65vb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fouc5s91vtfqjr6jm65vb.png" alt="Median freeze of an evicted request, by concurrency cap (seconds)" width="800" height="310"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Job 4, the setting for your server.&lt;/em&gt; On vLLM, lower admission without shrinking the batch buffers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vllm serve &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MODEL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--max-num-active-seqs&lt;/span&gt; 15
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--max-num-seqs&lt;/code&gt; also works, and the docs recommend it. Be careful with &lt;code&gt;--watermark&lt;/code&gt;: at 0.2 it cut&lt;br&gt;
redone work from 80% to 53%, but the median freeze rose to 247 seconds, because an evicted request&lt;br&gt;
then waits for extra room.&lt;/p&gt;

&lt;p&gt;On SGLang, watch for the pool-full warning. Its tuning guide says to raise&lt;br&gt;
&lt;code&gt;--schedule-conservativeness&lt;/code&gt; to about 1.3 if the warning fires often. Lower it to about 0.3 if token&lt;br&gt;
usage stays low while requests queue. On TGI, set &lt;code&gt;--max-total-tokens&lt;/code&gt; as low as your longest real&lt;br&gt;
request. TGI is in maintenance mode, so weigh that for a new deployment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How you know it worked.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On vLLM, this should sit near zero:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rate(vllm:num_preemptions_total[5m])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;vllm:kv_cache_usage_perc&lt;/code&gt; should stay high, or your cap is too low. On any server, p99 of&lt;br&gt;
&lt;code&gt;stream_max_gap_seconds&lt;/code&gt; should fall while total tokens per second holds. If usage drops and waiting&lt;br&gt;
requests climb, you set the cap too low. Raise it until evictions just reappear.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Tuning Admission Is the Wrong Tool
&lt;/h2&gt;

&lt;p&gt;When you never fill the pool. If &lt;code&gt;vllm:kv_cache_usage_perc&lt;/code&gt; stays well under 1 at peak, no rule is&lt;br&gt;
making a difference. Fix something else.&lt;/p&gt;

&lt;p&gt;When batch work doesn't care. A nightly job nobody watches can freeze for a minute at no cost. Leave&lt;br&gt;
concurrency high, and take the throughput.&lt;/p&gt;

&lt;p&gt;When you are simply out of capacity. A cap moves waiting from the stream to the queue. It doesn't&lt;br&gt;
make more GPU. In my runs, the queue still grew, and your time to first token will show that.&lt;/p&gt;

&lt;p&gt;When a limit would cut real work. A low &lt;code&gt;max_tokens&lt;/code&gt; on a reasoning model can end the answer before&lt;br&gt;
it starts. Check &lt;code&gt;finish_reason&lt;/code&gt; first.&lt;/p&gt;

&lt;p&gt;Treat my numbers as a shape, not a measurement. They come from a simulation that follows each&lt;br&gt;
server's source code, with invented lengths and an assumed prefill speed. Single runs also varied widely: the same setup froze for 26 to 170 seconds across nine seeds. It&lt;br&gt;
ignores prefix caching, which can make a reread much cheaper. I did not run these servers on a GPU. If prefill is slow, or&lt;br&gt;
contexts are long, eviction costs more than it did here.&lt;/p&gt;

&lt;p&gt;Three questions before you change anything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How much longer is your p95 answer than your mean answer?&lt;/li&gt;
&lt;li&gt;Does your server hold memory for &lt;code&gt;max_tokens&lt;/code&gt;, or only for what exists?&lt;/li&gt;
&lt;li&gt;Would a user notice a ten-second pause, or only a slower batch?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Glossary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;KV cache&lt;/strong&gt; — the model's saved work for every token so far, which grows as the answer grows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;admission&lt;/strong&gt; — the moment a server lets a waiting request start using memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;evicts&lt;/strong&gt; — frees a running request's cache and sends it back to wait.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;prefill&lt;/strong&gt; — processing input tokens to build the cache, much faster than generating.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;max_tokens&lt;/strong&gt; — the most tokens a caller allows for one answer; a ceiling, not a forecast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;watermark&lt;/strong&gt; — a share of memory vLLM can keep free when admitting requests.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://docs.vllm.ai/en/latest/configuration/optimization.html" rel="noopener noreferrer"&gt;Optimization and Tuning&lt;/a&gt; — vLLM documentation&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/vllm-project/vllm/blob/main/vllm/v1/core/sched/scheduler.py" rel="noopener noreferrer"&gt;scheduler.py in vLLM&lt;/a&gt; — vLLM repository&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.sglang.io/advanced_features/hyperparameter_tuning.html" rel="noopener noreferrer"&gt;Hyperparameter Tuning&lt;/a&gt; — SGLang documentation&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://huggingface.co/docs/text-generation-inference/reference/launcher" rel="noopener noreferrer"&gt;Launcher reference&lt;/a&gt; — Hugging Face TGI documentation&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://theaicommit.com/2026-11-01-how-to-stop-a-long-llm-answer-from-freezing-when-the-server/" rel="noopener noreferrer"&gt;theaicommit.com&lt;/a&gt;, a daily AI engineering lab for software engineers. The original has an &lt;a href="https://theaicommit.com/2026-11-01-how-to-stop-a-long-llm-answer-from-freezing-when-the-server/" rel="noopener noreferrer"&gt;interactive version&lt;/a&gt; you can play with, and the full runnable code is on &lt;a href="https://github.com/magna56/aI_daily_run/blob/main/2026-11-01/code_example.py" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>performance</category>
    </item>
    <item>
      <title>How to Tell What Claude Code and Codex Forget When They Compact a Session</title>
      <dc:creator>Mayur Agnani</dc:creator>
      <pubDate>Sat, 31 Oct 2026 11:30:43 +0000</pubDate>
      <link>https://dev.to/theaicommit/how-to-tell-what-claude-code-and-codex-forget-when-they-compact-a-session-532a</link>
      <guid>https://dev.to/theaicommit/how-to-tell-what-claude-code-and-codex-forget-when-they-compact-a-session-532a</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Engineer's view.&lt;/strong&gt; This is a process restart with a handoff note. Whatever the process wrote down or saved to disk comes back. Whatever sat only in memory is gone, and nothing errors. A coding agent's memory is its conversation, so values it read but never wrote down vanish, and it must read them again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; A compacted agent keeps your instructions and its own edits, but loses most of what it only read. Whether a fact survives depends on how it entered the session, so you can predict the loss and tell the summary what to keep.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Explain Like I'm 5
&lt;/h2&gt;

&lt;p&gt;You are building a huge model castle, and your table is full. So you ask a friend to write a&lt;br&gt;
short note about the castle, then you clear the table.&lt;/p&gt;

&lt;p&gt;The note says what you were told to build and which towers you finished. It does not list every&lt;br&gt;
piece you looked at in the box. Those are still in the box, so you can look again.&lt;/p&gt;

&lt;p&gt;But some pieces you borrowed for a minute, and they went back to a friend's house. If the note&lt;br&gt;
does not mention them, you will not remember them, and you cannot go look.&lt;/p&gt;

&lt;p&gt;So before you clear the table, tell your friend what to put in the note.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;You have shipped this bug already, in a system with no model in it.&lt;/p&gt;

&lt;p&gt;Your service held a session cache in process memory. A deploy restarted it. The health check went&lt;br&gt;
green and the error rate stayed flat. Then users started logging in again, because the sessions&lt;br&gt;
never reached disk. Nothing crashed. The process simply came back without the state that had only&lt;br&gt;
lived in memory.&lt;/p&gt;

&lt;p&gt;A coding agent has the same split, and it restarts in the middle of your work.&lt;/p&gt;

&lt;p&gt;Its working memory is the conversation. When that fills, the agent runs &lt;strong&gt;compaction&lt;/strong&gt;: it&lt;br&gt;
replaces the conversation with a summary and carries on. In my test session, 78,642 tokens became&lt;br&gt;
2,632. That is 97% gone in about twenty seconds, and the session looks healthy afterward.&lt;/p&gt;

&lt;p&gt;The agent then keeps working from the note. If a detail is missing, you see no error. You only see&lt;br&gt;
it read the same file twice, or ask something you already answered. Worse, the detail may have&lt;br&gt;
come from a test run or a web page that no longer exists.&lt;/p&gt;

&lt;p&gt;You could treat this as bad luck. It is not. What survives follows a rule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix: know which lane each fact is in, tell the summary what to keep, and write anything you&lt;br&gt;
cannot regenerate to a file before compaction runs.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The Fix: Sort Facts by How They Entered the Session
&lt;/h2&gt;

&lt;p&gt;Compaction is a rebuild, not a shortening. The new context comes from three lanes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fha6qb8ph0sec9ottr57z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fha6qb8ph0sec9ottr57z.png" alt="What a compacted session is built from" width="799" height="434"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  What reloads from disk?
&lt;/h3&gt;

&lt;p&gt;Claude Code reloads the root &lt;code&gt;CLAUDE.md&lt;/code&gt;, auto memory and the plan file. It also re-reads up to&lt;br&gt;
five recent files, each under 5,000 tokens. Rules with &lt;code&gt;paths:&lt;/code&gt; and nested &lt;code&gt;CLAUDE.md&lt;/code&gt; files are&lt;br&gt;
different: they entered as messages, so they get summarized. They load again only when the agent&lt;br&gt;
touches a matching file.&lt;/p&gt;
&lt;h3&gt;
  
  
  What does a budget keep?
&lt;/h3&gt;

&lt;p&gt;Codex's open-source compaction path keeps your recent messages word for word, up to 20,000 tokens,&lt;br&gt;
walking newest to oldest. It adds a summary and re-injects its starting context. Older messages&lt;br&gt;
depend on the summary.&lt;/p&gt;
&lt;h3&gt;
  
  
  Which facts does the summary drop?
&lt;/h3&gt;

&lt;p&gt;I tested this rather than assume it. I planted facts in real Claude Code sessions, compacted them,&lt;br&gt;
then asked from memory with tools off. Each of three sessions read 25 small config files.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhhyy8u528ucxjlmbn69k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhhyy8u528ucxjlmbn69k.png" alt="Exact values the agent only read, recalled from memory (36 asked)" width="799" height="272"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every instruction survived, and so did all ten edited files, even after a second compaction. The&lt;br&gt;
agent said so plainly when a value was gone, even when told never to answer "unknown".&lt;/p&gt;

&lt;p&gt;The summary kept what mattered to the work so far. In an earlier run, the last task counted timeouts&lt;br&gt;
over 5000. Of six values I probed, only the two timeouts over 5000 survived.&lt;/p&gt;
&lt;h2&gt;
  
  
  What This Means for You
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;When this matters.&lt;/strong&gt; You run long sessions in Claude Code, Codex or any agent that summarizes its&lt;br&gt;
history. It matters most when the agent reads something it cannot read again: a failed test run, a&lt;br&gt;
production log, a web page, or the output of a command that changes state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it affects you.&lt;/strong&gt; A file on disk costs you one more read. The agent gets it back and you pay&lt;br&gt;
tokens. A one-time output costs more, because the evidence is gone. The agent will then rerun the&lt;br&gt;
command, or reason from a vague line in the note. Long sessions may lose more than my small test&lt;br&gt;
did. Factory ran probe questions after compression on 36,611 production messages. All three methods&lt;br&gt;
scored worst on tracking files, at 2.19 to 2.45 out of 5, even though each kept instructions well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to do about it.&lt;/strong&gt; Start with no setup. Before you compact, name what matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/compact Keep the exact failing test names, error strings and ids.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A line like that, naming the values I needed, took my test from 0 to 36 of 36.&lt;/p&gt;

&lt;p&gt;Next, watch for the moment that hurts. When the agent reads something you cannot regenerate, ask it&lt;br&gt;
to save the facts to a notes file. A file sits in the disk lane.&lt;/p&gt;

&lt;p&gt;Then fix the repeated cases. Move a rule that must always hold into your root rules file. Add a hook&lt;br&gt;
that restores your working state after every compaction. Finally, audit a real session to see your&lt;br&gt;
own numbers.&lt;/p&gt;
&lt;h2&gt;
  
  
  Implementing It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The change.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Four jobs, and one person may do all of them.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Job 1, whoever drives the session.&lt;/em&gt; Save anything you cannot regenerate, then compact at a task&lt;br&gt;
boundary with a focus. Codex takes a compaction prompt in its config, so the same habit can be set&lt;br&gt;
once there. In &lt;code&gt;~/.codex/config.toml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="n"&gt;compact_prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"Summarize this session. Keep exact error strings, ids, file paths and test names."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Job 2, whoever owns the repo's agent setup.&lt;/em&gt; Check which lane your rules are in. A rule with&lt;br&gt;
&lt;code&gt;paths:&lt;/code&gt; enters as a message, so its survival is up to the summary until the agent touches a&lt;br&gt;
matching file again. My short test kept one, but the docs warn it can go. If it must hold across&lt;br&gt;
compaction, move it to the root file. Then add a section the summarizer reads, in &lt;code&gt;CLAUDE.md&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Compact Instructions&lt;/span&gt;
Keep exact error strings, failing test names, ids and every file edited.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a hook for state that changes. A &lt;code&gt;SessionStart&lt;/code&gt; hook with the &lt;code&gt;compact&lt;/code&gt; matcher runs after&lt;br&gt;
every compaction, and its output joins the new context. In &lt;code&gt;.claude/settings.json&lt;/code&gt;:&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="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"SessionStart"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"compact"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"git status --short; cat notes/session.md 2&amp;gt;/dev/null"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Job 3, whoever builds their own agent.&lt;/em&gt; Do not trust a summary with the file list. Keep it as data&lt;br&gt;
in your loop, and put it back after you summarize.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rebuild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pinned&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;summarize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keep_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20_000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;recent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;newest_user_messages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keep_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;summarize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;focus&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exact errors, ids, decisions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;edited&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Files edited: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;edited&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;pinned&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;edited&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Job 4, whoever wants the numbers.&lt;/em&gt; &lt;a href="https://github.com/magna56/aI_daily_run/blob/main/2026-10-31/code_example.py" rel="noopener noreferrer"&gt;&lt;code&gt;code_example.py&lt;/code&gt;&lt;/a&gt; needs no model. It reads a session transcript,&lt;br&gt;
finds each compaction, and checks which identifiers reached the summary, grouped by lane. Run it on&lt;br&gt;
one of your own sessions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 code_example.py ~/.claude/projects/&amp;lt;project&amp;gt;/&amp;lt;session&amp;gt;.jsonl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An identifier here means an exact string such as a port, path or error code. On 40 compactions&lt;br&gt;
from my own sessions, 4% of the identifiers the agent only read reached the summary, against 19% of those the agent or I wrote. An exact-string match undercounts paraphrase, so&lt;br&gt;
trust the gap more than the percentages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How you know it worked.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Probe it. Fork a session right after a compaction, turn tools off, and ask for five facts you need.&lt;br&gt;
Offer "UNKNOWN" as an answer so a miss is visible. In Claude Code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nt"&gt;--resume&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--fork-session&lt;/span&gt; &lt;span class="nt"&gt;--tools&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"From memory, no tools. Which files did you edit? What was the exact error? Reply UNKNOWN if unsure."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before your change, count the misses. After it, the same probe should return your focus items&lt;br&gt;
exactly. Also watch re-reads: the same file read twice right after a compaction means the note&lt;br&gt;
missed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Managing Compaction Is the Wrong Tool
&lt;/h2&gt;

&lt;p&gt;When the task is short. A session that never fills its context never compacts. Spending effort on a&lt;br&gt;
hook then buys nothing.&lt;/p&gt;

&lt;p&gt;When the facts are on disk and cheap to read. Losing a value from a config file costs one more&lt;br&gt;
read. Asking the summary to keep every value makes the note longer, and a long note costs tokens on&lt;br&gt;
every later turn. Keep what is expensive to get back.&lt;/p&gt;

&lt;p&gt;When you should start over. If the next task is unrelated, &lt;code&gt;/clear&lt;/code&gt; is better than any summary. Old&lt;br&gt;
context crowds the new work, however well it was compressed.&lt;/p&gt;

&lt;p&gt;When the work belongs elsewhere. Send large research to a subagent, so the reads never enter your&lt;br&gt;
main conversation at all.&lt;/p&gt;

&lt;p&gt;My test has limits too. It used small synthetic sessions on one model, and I did not run the Codex&lt;br&gt;
side. Run the probe on your own work before you trust these numbers.&lt;/p&gt;

&lt;p&gt;Three questions before you add a hook:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which fact, if lost, could the agent not get back?&lt;/li&gt;
&lt;li&gt;Does that fact reach the model through a file, or only through a message?&lt;/li&gt;
&lt;li&gt;Would a probe after compaction return it exactly?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Glossary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;compaction&lt;/strong&gt; — replacing a conversation with a shorter summary so the agent can keep working.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;lane&lt;/strong&gt; — the route a fact takes through compaction: reloaded, kept by budget, or summarized.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;probe&lt;/strong&gt; — a question asked after compaction to test whether a fact survived.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;focus&lt;/strong&gt; — an instruction telling the summary what to keep, as in &lt;code&gt;/compact&lt;/code&gt; with text after it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;identifier&lt;/strong&gt; — an exact string such as a port, id, file path or error code.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://factory.com/news/evaluating-compression" rel="noopener noreferrer"&gt;Evaluating Context Compression for AI Agents&lt;/a&gt; — Factory Research&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://code.claude.com/docs/en/context-window" rel="noopener noreferrer"&gt;Explore the context window&lt;/a&gt; — Claude Code docs&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/openai/codex/blob/main/codex-rs/core/src/compact.rs" rel="noopener noreferrer"&gt;compact.rs in the Codex source&lt;/a&gt; — OpenAI Codex repository&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://code.claude.com/docs/en/hooks-guide#re-inject-context-after-compaction" rel="noopener noreferrer"&gt;Re-inject context after compaction&lt;/a&gt; — Claude Code docs&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://theaicommit.com/2026-10-31-how-to-tell-what-claude-code-and-codex-forget-when-they/" rel="noopener noreferrer"&gt;theaicommit.com&lt;/a&gt;, a daily AI engineering lab for software engineers. The original has an &lt;a href="https://theaicommit.com/2026-10-31-how-to-tell-what-claude-code-and-codex-forget-when-they/" rel="noopener noreferrer"&gt;interactive version&lt;/a&gt; you can play with, and the full runnable code is on &lt;a href="https://github.com/magna56/aI_daily_run/blob/main/2026-10-31/code_example.py" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>agents</category>
    </item>
    <item>
      <title>How to Tell Whether a Second Model Reviewing Your Agent's Work Pays for Itself</title>
      <dc:creator>Mayur Agnani</dc:creator>
      <pubDate>Fri, 30 Oct 2026 11:39:40 +0000</pubDate>
      <link>https://dev.to/theaicommit/how-to-tell-whether-a-second-model-reviewing-your-agents-work-pays-for-itself-pel</link>
      <guid>https://dev.to/theaicommit/how-to-tell-whether-a-second-model-reviewing-your-agents-work-pays-for-itself-pel</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Engineer's view.&lt;/strong&gt; This is running two replicas of the same software for safety. Both crash on the same leap second, because they share the bug. A reviewer model trained like the drafter misses the same failures. Pick a reviewer that fails differently, and measure what it catches on your drafter's failures, not on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; A reviewer model can only fix errors it would not have made itself, and models share many blind spots. Measure what it fixes and what it breaks on your own tasks, then review only where the drafter often fails.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Explain Like I'm 5
&lt;/h2&gt;

&lt;p&gt;You write a story for school. Before you hand it in, your twin reads it to look for mistakes.&lt;/p&gt;

&lt;p&gt;Your twin is smart, but your twin learned spelling from the same teacher. The words you get&lt;br&gt;
wrong are the words your twin gets wrong too. So those mistakes slip through.&lt;/p&gt;

&lt;p&gt;A friend from another school spots them, because they learned differently. But sometimes the&lt;br&gt;
friend says a right word is wrong. If you change it, your story gets worse.&lt;/p&gt;

&lt;p&gt;So the best helper is someone who makes different mistakes than you do. And they should only&lt;br&gt;
read the hard stories, where you make the most mistakes.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;You have shipped this bug already, in a system with no model in it.&lt;/p&gt;

&lt;p&gt;Your service ran two replicas for safety. If one crashed, the other would take the traffic. Then&lt;br&gt;
a leap second arrived. Both replicas crashed in the same minute, because both ran the same code&lt;br&gt;
with the same bug. The backup was real, and it protected you against nothing that mattered.&lt;br&gt;
Engineers call this a &lt;strong&gt;common-mode failure&lt;/strong&gt;: one cause takes out every copy at once.&lt;/p&gt;

&lt;p&gt;Redundancy only helps against failures the copies do not share.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;reviewer model&lt;/strong&gt; is the same idea applied to an agent. One model, the &lt;strong&gt;drafter&lt;/strong&gt;, writes a&lt;br&gt;
plan or a patch. A second model reads it and lists concerns. The drafter then revises once.&lt;br&gt;
Some coding tools now run this step for you. You can add it to your own agent in an afternoon.&lt;/p&gt;

&lt;p&gt;It costs at least two extra model calls per task. Whether it pays depends on two things the&lt;br&gt;
setup hides. The reviewer misses whatever blind spots it shares with the drafter. And when it&lt;br&gt;
raises a false alarm on a correct draft, the revision can break it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix: measure what the reviewer fixes and breaks on your drafter's own tasks, pick one&lt;br&gt;
that fails differently, and call it only where the drafter often fails.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The Fix: Measure Fixed and Broken, Then Review Only the Hard Tasks
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnuxjwitue6bt0veyujcg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnuxjwitue6bt0veyujcg.png" alt="Four things one review pass can do" width="800" height="326"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  What does one review pass add?
&lt;/h3&gt;

&lt;p&gt;Count the four outcomes above. The gain is the fixed tasks minus the broken ones. As rates:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;net = fail × catch × fix − (1 − fail) × false alarm × break&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here &lt;em&gt;fail&lt;/em&gt; is how often the drafter alone gets it wrong. &lt;em&gt;Catch&lt;/em&gt; is how often the reviewer flags&lt;br&gt;
those failures. &lt;em&gt;Fix&lt;/em&gt; is how often the revision then gets it right.&lt;/p&gt;
&lt;h3&gt;
  
  
  Why isn't the reviewer's own accuracy enough?
&lt;/h3&gt;

&lt;p&gt;Because the catch rate is measured on the drafter's failures, and those are the hard cases. A&lt;br&gt;
study of over 350 models found that when two models both get a question wrong, they pick the&lt;br&gt;
same wrong answer about 60% of the time. Same developer, same architecture and similar size all&lt;br&gt;
raised that overlap.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzgk8ddviih2elklhawy7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzgk8ddviih2elklhawy7.png" alt="Reviewer that is right 80% of the time: share of the drafter's failures it catches" width="800" height="310"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  When does review hurt?
&lt;/h3&gt;

&lt;p&gt;On easy tasks. If the drafter fails 4% of the time, there is little to fix. A 10% false-alarm&lt;br&gt;
rate with a 25% break rate then costs more than review gains.&lt;/p&gt;
&lt;h2&gt;
  
  
  What This Means for You
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;When this matters.&lt;/strong&gt; Your agent writes code, plans or answers, and you are adding a second model&lt;br&gt;
to check them. Or you turned on a tool's built-in critic and it now costs more. It matters most&lt;br&gt;
on long tasks, where one missed error ruins the run. GitHub found Rubber Duck helped most on&lt;br&gt;
problems spanning three or more files that take 70 or more steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it affects you.&lt;/strong&gt; A reviewer from the drafter's own family feels safe and catches less than&lt;br&gt;
its benchmark score suggests. A reviewer on every task pays two extra calls on work that was&lt;br&gt;
already right. Its false alarms then rewrite correct code. You will not see either in a demo,&lt;br&gt;
because demos show the fixed case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to do about it.&lt;/strong&gt; Start with no new code. Take 20 tasks your agent failed last month.&lt;br&gt;
Paste each failing draft into a reviewer from another model family and ask for its top three&lt;br&gt;
concerns. Count how many name the real bug. That is a first estimate of catch.&lt;/p&gt;

&lt;p&gt;Then run the full check. Run a few hundred tasks twice from the same start, alone and with&lt;br&gt;
review. Count fixed and broken. Split them by a signal you have before review runs, such as&lt;br&gt;
files touched. Turn review on only for the buckets where fixed clearly beats broken.&lt;/p&gt;
&lt;h2&gt;
  
  
  Implementing It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The change.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three people touch this. The second one decides whether it ships.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Role 1, whoever writes the agent loop.&lt;/em&gt; Add one review pass. Give the reviewer the task and the&lt;br&gt;
draft only, with no tools and no write access. HydraFusion does the same: its critic is&lt;br&gt;
read-only, runs in a tool-less context, and comes from a different model family. Then let the&lt;br&gt;
drafter revise once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;REVIEW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You are reviewing another engineer&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s change. List at most three concerns, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
          &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;most important first: bugs, wrong assumptions, missed edge cases. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
          &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;If you find nothing serious, reply exactly: LGTM.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;draft_with_review&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;drafter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reviewer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;draft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;drafter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                        &lt;span class="c1"&gt;# full agent loop, with tools
&lt;/span&gt;    &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reviewer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;REVIEW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;# one call, no tools
&lt;/span&gt;                              &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Task:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;Change:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LGTM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;flagged&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;revised&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;drafter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extra&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A reviewer raised:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                                      &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Fix what is real. Ignore what is wrong.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;revised&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;flagged&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;notes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "ignore what is wrong" line matters. It is your only defense against the broken case.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Role 2, whoever owns evals.&lt;/em&gt; Run paired trials. Every task runs twice from the same commit:&lt;br&gt;
drafter alone, then drafter with review. Grade both with the task's own tests. Only the tasks&lt;br&gt;
whose outcome changed carry evidence, so test those with McNemar's test.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;fixed&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;runs&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;alone_ok&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reviewed_ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;broken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;runs&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;alone_ok&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reviewed_ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;helps&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fixed&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;broken&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixed&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;broken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# about 95% confidence
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the demo, 400 tasks give 27 fixed and 8 broken overall. Split by files touched, one-file tasks&lt;br&gt;
give 2 fixed and 0 broken, too few to trust. Two-file tasks give 4 and 6. Tasks with three or more&lt;br&gt;
files give 21 and 2, the only bucket that clears the test.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Role 3, whoever owns the routing.&lt;/em&gt; Gate review on that signal. Files touched is the one GitHub&lt;br&gt;
reports. Plan length, step count or a past failure on the same module also work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;needs_review&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you drive Copilot CLI, Rubber Duck is behind &lt;code&gt;/experimental&lt;/code&gt; and pairs a Claude drafter with a&lt;br&gt;
GPT reviewer. The same paired check tells you whether it earns its cost on your repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How you know it worked.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Rerun the paired trials a week later on fresh tasks. Fixed minus broken should still clear the&lt;br&gt;
test in the buckets you review. Your success rate should rise while total model spend rises&lt;br&gt;
less than the review share would suggest. Log every false alarm. If broken climbs, tighten the&lt;br&gt;
prompt before you widen the gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a Reviewer Model Is the Wrong Tool
&lt;/h2&gt;

&lt;p&gt;When the task has a test that runs. A failing test is a reviewer with no blind spots and no&lt;br&gt;
false alarms. Run it, feed back the failure, and save the second model for what tests miss.&lt;/p&gt;

&lt;p&gt;When the drafter rarely fails. On easy work there is little to catch. A few false alarms then&lt;br&gt;
cost more than review gains, and every call costs the same.&lt;/p&gt;

&lt;p&gt;When you can only get a reviewer from the drafter's family. It still catches some errors, but&lt;br&gt;
fewer than its score suggests. A stronger single model may cost less than two weaker ones.&lt;/p&gt;

&lt;p&gt;Latency is real too. HydraFusion holds the draft until review and revision finish. A reader&lt;br&gt;
waiting on the answer feels every extra call.&lt;/p&gt;

&lt;p&gt;Three questions before you add one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What share of your agent's failed tasks does this reviewer actually flag?&lt;/li&gt;
&lt;li&gt;How often does it flag a draft that was already right?&lt;/li&gt;
&lt;li&gt;Which signal, known before review, separates the tasks where it helps?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Glossary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;common-mode failure&lt;/strong&gt; — one cause that takes out every redundant copy at once, because they share it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;reviewer model&lt;/strong&gt; — a second model that reads another model's work and lists concerns, without changing it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;drafter&lt;/strong&gt; — the model that does the task and revises after review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;catch&lt;/strong&gt; — the share of the drafter's failures that the reviewer flags.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;false alarm&lt;/strong&gt; — the reviewer flagging a draft that was already correct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;McNemar's test&lt;/strong&gt; — a check on paired runs that uses only the tasks whose outcome changed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.blog/ai-and-ml/github-copilot/github-copilot-cli-combines-model-families-for-a-second-opinion/" rel="noopener noreferrer"&gt;GitHub Copilot CLI combines model families for a second opinion&lt;/a&gt; — GitHub Blog (Nick McKenna, Bartek Perz)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.blog/ai-and-ml/github-copilot/project-hydrafusion-frontier-quality-via-multi-model-orchestration/" rel="noopener noreferrer"&gt;Project HydraFusion: Frontier quality via multi-model orchestration&lt;/a&gt; — GitHub Blog&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.blog/changelog/2026-10-28-route-and-orchestrate-models-with-github-copilot-fusion" rel="noopener noreferrer"&gt;Route and orchestrate models with GitHub Copilot Fusion&lt;/a&gt; — GitHub Changelog&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://icml.cc/virtual/2025/poster/44225" rel="noopener noreferrer"&gt;Correlated Errors in Large Language Models&lt;/a&gt; — Kim, Garg, Peng and Garg, ICML 2025&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://theaicommit.com/2026-10-30-how-to-tell-whether-a-second-model-reviewing-your-agents/" rel="noopener noreferrer"&gt;theaicommit.com&lt;/a&gt;, a daily AI engineering lab for software engineers. The original has an &lt;a href="https://theaicommit.com/2026-10-30-how-to-tell-whether-a-second-model-reviewing-your-agents/" rel="noopener noreferrer"&gt;interactive version&lt;/a&gt; you can play with, and the full runnable code is on &lt;a href="https://github.com/magna56/aI_daily_run/blob/main/2026-10-30/code_example.py" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>productivity</category>
      <category>testing</category>
    </item>
    <item>
      <title>How Many Pull Requests a Merge Queue Can Land in an Hour</title>
      <dc:creator>Mayur Agnani</dc:creator>
      <pubDate>Thu, 29 Oct 2026 09:22:28 +0000</pubDate>
      <link>https://dev.to/theaicommit/how-many-pull-requests-a-merge-queue-can-land-in-an-hour-4ae2</link>
      <guid>https://dev.to/theaicommit/how-many-pull-requests-a-merge-queue-can-land-in-an-hour-4ae2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Engineer's view.&lt;/strong&gt; This is optimistic locking under contention. Each transaction validates against everything committed before it, and one abort throws away the work queued behind it. More workers only waste more work. Throughput is set by the abort rate and the transaction time. Agents raise the load and leave that ceiling where it was.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; A merge queue's speed is capped by how often a queued change fails and how long CI takes, not by how many pipelines run at once. More pull requests from agents push it into long waits unless you cut one of those two.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Explain Like I'm 5
&lt;/h2&gt;

&lt;p&gt;A school lunch line has one rule. Each kid must show their tray to the cook, and the cook&lt;br&gt;
checks it against every tray that went before.&lt;/p&gt;

&lt;p&gt;The cook can look at twenty trays at once. If a tray is wrong, that kid steps out. Every kid&lt;br&gt;
behind them has to show their tray again, because the cook checked it next to the wrong one.&lt;/p&gt;

&lt;p&gt;On a normal day the line moves fine. Then a class of fast kids joins. They bring three times&lt;br&gt;
as many trays. The cook is not slower. The line still backs up, because each wrong tray sends&lt;br&gt;
everyone behind it back to the start.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;You have shipped this bug already, in a system with no model in it.&lt;/p&gt;

&lt;p&gt;Your service used optimistic locking. Each write read a row, changed it and committed only if&lt;br&gt;
nobody else had changed it first. At low traffic it was fast. Then traffic tripled. Writes&lt;br&gt;
started aborting and retrying, and each retry raced the next wave. Latency went from&lt;br&gt;
milliseconds to seconds. Adding app servers made it worse, because they only added contenders.&lt;/p&gt;

&lt;p&gt;The limit was never the servers. It was how often a commit failed, times how long it took.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;merge queue&lt;/strong&gt; has the same shape. It is the gate that lands pull requests on &lt;code&gt;main&lt;/code&gt; one&lt;br&gt;
tested batch at a time. GitHub calls it the merge queue. GitLab calls it a merge train. Each&lt;br&gt;
pull request is tested on top of &lt;code&gt;main&lt;/code&gt; plus every pull request ahead of it, so &lt;code&gt;main&lt;/code&gt; never&lt;br&gt;
breaks. When one fails, everything behind it was tested on a tree that included the failure.&lt;br&gt;
All of it reruns.&lt;/p&gt;

&lt;p&gt;Coding agents change the arrival rate. At Anthropic, merges grew from about 2,500 a day in May&lt;br&gt;
2026 to over 30,000 a day by September. They stopped using a merge queue. A team without that&lt;br&gt;
option has to know where its queue's ceiling is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix: measure your queue's failure rate and CI time, compute its ceiling, and lower the&lt;br&gt;
failure rate before adding pipelines.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The Fix: Cut the Failure Rate Before You Add Pipelines
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foged6a0k08nojal2le4l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foged6a0k08nojal2le4l.png" alt="Four pull requests in a queue, four pipelines running" width="799" height="342"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Why doesn't running more pipelines fix it?
&lt;/h3&gt;

&lt;p&gt;Call the share of queued pull requests that fail &lt;strong&gt;p&lt;/strong&gt;, and the CI time &lt;strong&gt;T&lt;/strong&gt;. Between two&lt;br&gt;
failures the queue lands about 1/p pull requests. Each failure throws away up to T of work&lt;br&gt;
behind it. So no number of pipelines lands more than about &lt;strong&gt;1/(p × T)&lt;/strong&gt; an hour.&lt;/p&gt;

&lt;p&gt;With a 30-minute CI and 5% failing, that is 40 an hour. With 20 pipelines it is about 24. Going&lt;br&gt;
to 100 pipelines buys 38, at five times the CI bill.&lt;/p&gt;
&lt;h3&gt;
  
  
  Where does the failure rate come from?
&lt;/h3&gt;

&lt;p&gt;Two places. Real breaks: a change that passed on its own branch and conflicts with one ahead of&lt;br&gt;
it. And &lt;strong&gt;flaky tests&lt;/strong&gt;, tests that fail now and then with no change at all. Flakes add up. Fifty&lt;br&gt;
tests that each flake one run in a thousand fail about one run in twenty. That is p = 5%.&lt;/p&gt;
&lt;h3&gt;
  
  
  What happens to the wait near the ceiling?
&lt;/h3&gt;

&lt;p&gt;It climbs fast. A queue at 8 pull requests an hour waits 34 minutes on average. Triple the&lt;br&gt;
arrivals to 24, inside the ceiling on paper, and the wait passes 100 minutes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxk6nncpuvgjquocxtk1n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxk6nncpuvgjquocxtk1n.png" alt="Mean wait in the queue at 24 pull requests an hour, 20 pipelines" width="800" height="310"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What This Means for You
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;When this matters.&lt;/strong&gt; Your repo uses a merge queue, and people or agents open more pull&lt;br&gt;
requests than they did last quarter. It matters most when CI runs long and has a few tests&lt;br&gt;
everyone retries. If you see "removed from the merge queue" often, you are paying for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it affects you.&lt;/strong&gt; The queue feels slow, and the obvious fix is more parallel pipelines.&lt;br&gt;
That buys little once failures are common, and it raises the CI bill. Waits grow faster than&lt;br&gt;
volume. Agents then rebase on a stale &lt;code&gt;main&lt;/code&gt; and conflict with each other, which adds real&lt;br&gt;
failures on top of the flaky ones. A flaky test used to be an annoyance. Under load it sets&lt;br&gt;
your throughput.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to do about it.&lt;/strong&gt; Start with two numbers from last week, with no new tooling. Count the&lt;br&gt;
pull requests the queue merged, and the ones it removed for failed checks. Divide removed by&lt;br&gt;
the total, and that is p. Read T off any recent queue run. Then compute 1/(p × T) and compare it&lt;br&gt;
with your busiest hour.&lt;/p&gt;

&lt;p&gt;If the busy hour is within half of the ceiling, waits are already long. Lower p first: retry a&lt;br&gt;
failing test once before ejecting, and quarantine the tests that flake. Then shorten T by moving&lt;br&gt;
slow checks out of the queue. Add pipelines last.&lt;/p&gt;
&lt;h2&gt;
  
  
  Implementing It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The change.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Four people touch this. Each change is small, and they compound.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Role 1, whoever owns CI.&lt;/em&gt; Run only fast required checks on the merge group, and make a flake&lt;br&gt;
fail twice before it ejects a pull request. In GitHub Actions the queue fires the &lt;code&gt;merge_group&lt;/code&gt;&lt;br&gt;
event. The retry here uses the &lt;code&gt;pytest-rerunfailures&lt;/code&gt; plugin.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;merge_group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pytest -q --reruns &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;   &lt;span class="c1"&gt;# a flaky test must fail twice to eject&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On GitLab, the same idea is &lt;code&gt;retry: { max: 1, when: script_failure }&lt;/code&gt; on the job. A retry turns&lt;br&gt;
a 5% flake rate into about 0.25%, since both runs have to fail. It also hides flakes, so pair it&lt;br&gt;
with Role 3.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Role 2, whoever administers the repo.&lt;/em&gt; Size the queue from the formula, not by feel. GitHub&lt;br&gt;
sets the pipeline count with &lt;strong&gt;build concurrency&lt;/strong&gt; (1 to 100). GitLab allows 20 parallel&lt;br&gt;
pipelines by default.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;landed_per_hour&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ci_minutes&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ci_minutes&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;# depth 1/p reaches half the ceiling; past about 3/p you mostly pay for reruns
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plug in your own numbers before you touch the setting. With p at 5% and a 30-minute CI, twenty&lt;br&gt;
pipelines land about 20 an hour by the formula and 24 in the simulation. Cut p to 1% and the same&lt;br&gt;
twenty land about 33. That is the cheaper change.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Role 3, whoever owns the test suite.&lt;/em&gt; Find the flakes. A test that fails and then passes on&lt;br&gt;
the same commit is flaky. Log those, rank tests by count, and quarantine the top of the list&lt;br&gt;
until they are fixed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;flaky_runs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.001&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;     &lt;span class="c1"&gt;# fifty tests at 0.1% each -&amp;gt; about 5% of runs
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Role 4, whoever runs the agents.&lt;/em&gt; Run the queue's checks before a pull request enters it.&lt;br&gt;
Rebase on fresh &lt;code&gt;main&lt;/code&gt; first. A conflict caught on the branch costs one pipeline, while one&lt;br&gt;
caught in the queue costs every pipeline behind it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How you know it worked.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Recompute p and T a week later. The removed-for-failure count should fall first. Mean time in&lt;br&gt;
the queue should drop with it, and landings per busy hour should come within reach of&lt;br&gt;
&lt;code&gt;landed_per_hour&lt;/code&gt;. If p falls and the wait does not, T is now the limit. Look at which check is&lt;br&gt;
slowest.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Tuning the Queue Is the Wrong Tool
&lt;/h2&gt;

&lt;p&gt;When volume is low. A team landing a few pull requests an hour is far below any ceiling. Waits&lt;br&gt;
come from CI time alone, so shorten CI and leave the queue alone.&lt;/p&gt;

&lt;p&gt;When failures are real, not flaky. If most ejected pull requests were broken, retries only delay&lt;br&gt;
the same answer. Catch them earlier, on the branch, which is Role 4's job.&lt;/p&gt;

&lt;p&gt;When volume outgrows any queue. Anthropic went from about 2,500 merges a day to over 30,000 and&lt;br&gt;
moved most testing to after the merge and before deploy. That trades a green &lt;code&gt;main&lt;/code&gt; for speed.&lt;br&gt;
It needs fast reverts and someone, or something, that fixes a broken &lt;code&gt;main&lt;/code&gt; within minutes. It&lt;br&gt;
is a bigger change than tuning a queue.&lt;/p&gt;

&lt;p&gt;Batching has a cost too. Larger merge groups test several pull requests in one pipeline. A&lt;br&gt;
failure then has to be split to find the culprit, which costs more runs.&lt;/p&gt;

&lt;p&gt;Three questions before you change anything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What share of last week's queued pull requests were removed for failed checks?&lt;/li&gt;
&lt;li&gt;How many minutes do the required checks take on a merge group?&lt;/li&gt;
&lt;li&gt;How close is your busiest hour to 1/(p × T)?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Glossary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;merge queue&lt;/strong&gt; — the gate that tests pull requests on top of each other and lands them on &lt;code&gt;main&lt;/code&gt; in order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;merge group&lt;/strong&gt; — the temporary branch a queue builds: &lt;code&gt;main&lt;/code&gt; plus a pull request and everything ahead of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;flaky tests&lt;/strong&gt; — tests that fail some runs and pass others on the same code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;build concurrency&lt;/strong&gt; — GitHub's setting for how many merge group pipelines run at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;p&lt;/strong&gt; — the share of queued pull requests that fail their checks, from real breaks or flakes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.anthropic.com/engineering/redesigning-source-control" rel="noopener noreferrer"&gt;Redesigning source control at Anthropic&lt;/a&gt; — Anthropic Engineering&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.uber.com/en-IL/blog/slashing-ci-costs-at-uber/" rel="noopener noreferrer"&gt;Slashing CI Costs at Uber&lt;/a&gt; — Uber Engineering&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue" rel="noopener noreferrer"&gt;Managing a merge queue&lt;/a&gt; — GitHub Docs&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.gitlab.com/ci/pipelines/merge_trains/" rel="noopener noreferrer"&gt;Merge trains&lt;/a&gt; — GitLab Docs&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://theaicommit.com/2026-10-29-how-many-pull-requests-a-merge-queue-can-land-in-an-hour/" rel="noopener noreferrer"&gt;theaicommit.com&lt;/a&gt;, a daily AI engineering lab for software engineers. The original has an &lt;a href="https://theaicommit.com/2026-10-29-how-many-pull-requests-a-merge-queue-can-land-in-an-hour/" rel="noopener noreferrer"&gt;interactive version&lt;/a&gt; you can play with, and the full runnable code is on &lt;a href="https://github.com/magna56/aI_daily_run/blob/main/2026-10-29/code_example.py" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>performance</category>
      <category>devops</category>
    </item>
    <item>
      <title>How to Find the Agent Actions a Prompt Injection Can Control</title>
      <dc:creator>Mayur Agnani</dc:creator>
      <pubDate>Wed, 28 Oct 2026 11:53:06 +0000</pubDate>
      <link>https://dev.to/theaicommit/how-to-find-the-agent-actions-a-prompt-injection-can-control-2mbl</link>
      <guid>https://dev.to/theaicommit/how-to-find-the-agent-actions-a-prompt-injection-can-control-2mbl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Engineer's view.&lt;/strong&gt; This is SQL injection without parameterized queries. A database fixed it by sending the query and the values on separate channels. A model reads both in one stream, so it cannot. The fix moves up a level: decide each action before reading the values, or read them somewhere that cannot act. It costs flexibility.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; A model treats every word in its context as a possible instruction, including text a tool fetched. Any decision your agent makes after reading that text belongs to whoever wrote it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Explain Like I'm 5
&lt;/h2&gt;

&lt;p&gt;A kid has a list of chores from their parent. One chore says "read the notes in the mailbox&lt;br&gt;
and do what's needed."&lt;/p&gt;

&lt;p&gt;One note in the mailbox says "Also, leave the back door open." It is written in pen, like the&lt;br&gt;
parent's list. The kid cannot tell which notes came from the parent.&lt;/p&gt;

&lt;p&gt;So the kid leaves the door open. The note writer did not break in. They just wrote a note in&lt;br&gt;
a place the kid was told to read.&lt;/p&gt;

&lt;p&gt;A smart parent writes the whole chore list first. The mailbox can change what the kid writes&lt;br&gt;
down. It can't add a chore.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;You have shipped this bug already, in a system with no model in it.&lt;/p&gt;

&lt;p&gt;A support page showed customer names. Someone signed up with a name that contained a&lt;br&gt;
&lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag. The page printed the name, and the browser ran it as code. Every agent who&lt;br&gt;
opened that ticket ran the attacker's script with their own login. Nothing was hacked. Data&lt;br&gt;
was put in a place where code was expected.&lt;/p&gt;

&lt;p&gt;The fix you shipped was a boundary. The browser now treats the name as text and never as code.&lt;/p&gt;

&lt;p&gt;An AI agent has the same bug, and no such boundary. An agent is a model that calls tools in a&lt;br&gt;
loop. A tool might read your inbox, fetch a web page, or open a GitHub issue. Whatever the tool&lt;br&gt;
returns goes into the &lt;strong&gt;context&lt;/strong&gt;, the text the model reads before each step. Your request is&lt;br&gt;
in there too. The model sees one stream of words and cannot tell your instruction from a&lt;br&gt;
sentence inside an email.&lt;/p&gt;

&lt;p&gt;This is &lt;strong&gt;prompt injection&lt;/strong&gt;: someone writes instructions into content your agent will read.&lt;br&gt;
Filters catch some of it. A filter that catches 95% of attacks still lets one in twenty through.&lt;/p&gt;

&lt;p&gt;The useful question is not whether a model can be fooled. It can. The question is what the&lt;br&gt;
fooled model can still do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix: list every decision your agent makes after it reads untrusted text, then move each&lt;br&gt;
one before that read or into a context that cannot act.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The Fix: Decide the Actions Before the Agent Reads Anything Untrusted
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb53brzchwepp3ecsh72y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb53brzchwepp3ecsh72y.png" alt="What one email can reach in a plan-then-execute agent" width="800" height="321"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Which decisions does an injection reach?
&lt;/h3&gt;

&lt;p&gt;Every decision made in a context that has seen the untrusted text. That covers three kinds:&lt;br&gt;
which tool to call, each argument, and the final reply. Decisions made before the read are&lt;br&gt;
safe. So are decisions made in a separate context that never saw it.&lt;/p&gt;

&lt;p&gt;That turns a fuzzy worry into bookkeeping. You can list the decisions, and you can count them.&lt;/p&gt;
&lt;h3&gt;
  
  
  Why not just tell the model to ignore instructions in tool results?
&lt;/h3&gt;

&lt;p&gt;That instruction lives in the same context as the attack. The attacker's text can argue with it,&lt;br&gt;
and the model decides who wins. A rule the model enforces on itself is advice.&lt;/p&gt;
&lt;h3&gt;
  
  
  When does a reached decision become a leak?
&lt;/h3&gt;

&lt;p&gt;When three things meet in one context: your private data, untrusted text, and a way out. Simon&lt;br&gt;
Willison calls this the &lt;strong&gt;lethal trifecta&lt;/strong&gt;. A way out can be an email, an HTTP call, or an&lt;br&gt;
image in a reply. When the chat window loads the image, the URL can carry your data to the&lt;br&gt;
attacker's server.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffr5grgsw9qf0ec9hgf80.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffr5grgsw9qf0ec9hgf80.png" alt="Decisions an email can control, out of 11, one email assistant" width="800" height="288"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What This Means for You
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;When this matters.&lt;/strong&gt; Your agent reads anything a stranger can write: email, web pages,&lt;br&gt;
tickets, issue comments, uploaded files, or the output of a third-party MCP server. It also has&lt;br&gt;
a tool that can send, write or fetch. A coding agent that reads an issue and can push a branch&lt;br&gt;
counts. So does a chat assistant that renders markdown images.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it affects you.&lt;/strong&gt; Prompt injection is not a bug you patch once. It comes from how the&lt;br&gt;
model reads. Each tool you add makes the trifecta easier to complete, because MCP lets anyone&lt;br&gt;
combine tools from different servers. The real risk is often a tool you would call harmless. A&lt;br&gt;
summary rendered in your chat window can carry an image URL. A calendar invite can carry a&lt;br&gt;
title. Neither looks like "sending data", and both can.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to do about it.&lt;/strong&gt; Start with paper and no code. Write down every tool your agent can&lt;br&gt;
call. Mark each one: does it read text a stranger wrote, does it read private data, and can it&lt;br&gt;
send anything out? Then walk one real task in order. After the first untrusted read, every&lt;br&gt;
later decision on that path is exposed. If private data and a way out sit downstream of it,&lt;br&gt;
you have a leak path.&lt;/p&gt;

&lt;p&gt;Then change the design, not the prompt. Fix the tool sequence before the read. Have a separate&lt;br&gt;
model read untrusted text and hand back only values. Make the chat window stop loading remote&lt;br&gt;
images. &lt;code&gt;## Implementing It&lt;/code&gt; shows each one.&lt;/p&gt;
&lt;h2&gt;
  
  
  Implementing It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The change.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Four people touch this. The design work belongs to the first.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Role 1, whoever writes the agent loop.&lt;/em&gt; Label every tool, then gate the loop. After the first&lt;br&gt;
untrusted result, anything consequential needs a person. This is the paper's rule in code: once&lt;br&gt;
untrusted input is in, it must be impossible for it to trigger a consequential action.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;TOOLS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;read_inbox&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nc"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reads&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;UNTRUSTED&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search_docs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reads&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;PRIVATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;send_email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nc"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dest&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;renders_remote&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;consequential&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;gate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;InjectionGate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TOOLS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;model_turns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;ask_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;refused&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;saw_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# flips once untrusted text arrives
&lt;/span&gt;    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gate is the floor. The bigger win is the design. Have a planner fix the tool list and the&lt;br&gt;
recipients from the user's request alone. That closes five of the ten exposed decisions in the demo.&lt;br&gt;
Better still, let a quarantined model read the email and return values, such as &lt;code&gt;$VAR1&lt;/code&gt; for a&lt;br&gt;
summary. The model that calls tools sees only the variable name. Your code swaps the real text&lt;br&gt;
in when it builds the final message, so no model that can call tools ever reads it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Role 2, whoever writes the MCP server.&lt;/em&gt; Say what each tool touches. MCP has&lt;br&gt;
&lt;code&gt;openWorldHint&lt;/code&gt; for tools that reach outside entities, and &lt;code&gt;readOnlyHint&lt;/code&gt; for tools that change&lt;br&gt;
nothing. A client can use them to label tools. The spec warns clients to ignore annotations&lt;br&gt;
from servers they do not trust, so set them honestly.&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="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fetch_page"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"annotations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"readOnlyHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"openWorldHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Role 3, whoever owns the chat window.&lt;/em&gt; Stop the reply from making requests. Strip remote images&lt;br&gt;
from model output, or block them with a content security policy: &lt;code&gt;img-src 'self'&lt;/code&gt;. Show full&lt;br&gt;
URLs on links, so a person sees where a click goes. This one change removes the reply from the&lt;br&gt;
list of ways out.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Role 4, whoever owns CI.&lt;/em&gt; Plant a canary. Put an instruction in a fixture email that asks for&lt;br&gt;
the canary to be sent somewhere. Then assert it never leaves.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_inbox_cannot_exfiltrate&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Summarize my inbox&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inbox&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;CANARY_EMAIL&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;outward&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;calls&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;TOOLS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;consequential&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CANARY-7f3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;outward&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;evil.example&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;strip_remote_images&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How you know it worked.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Run the audit in &lt;a href="https://github.com/magna56/aI_daily_run/blob/main/2026-10-28/code_example.py" rel="noopener noreferrer"&gt;&lt;code&gt;code_example.py&lt;/code&gt;&lt;/a&gt; on your agent's description. The leak count should read zero.&lt;br&gt;
The exposed count will not, and that is expected. A quarantined reader still writes the text&lt;br&gt;
you see. The canary test should pass on every model you ship. Re-run it when you add a tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Changing the Design Is the Wrong Tool
&lt;/h2&gt;

&lt;p&gt;When your agent never reads text a stranger wrote. A coding agent working only on your own repo&lt;br&gt;
has no untrusted input. The gate would only add prompts.&lt;/p&gt;

&lt;p&gt;When nothing downstream can act or leak. An agent that summarizes web pages into a chat with no&lt;br&gt;
images, links or tools can be fooled. A fooled summary is a wrong answer, not a breach. Keep&lt;br&gt;
the stronger designs for agents that hold private data or can send things.&lt;/p&gt;

&lt;p&gt;When the task needs to change course after reading. A plan fixed in advance cannot follow a&lt;br&gt;
link the email mentions. The paper says it plainly: general-purpose agents are unlikely to give&lt;br&gt;
reliable safety guarantees. Sometimes the honest answer is a narrower agent.&lt;/p&gt;

&lt;p&gt;The cost is real. A quarantined reader doubles the model calls. Every approval prompt costs a&lt;br&gt;
person's attention, and people learn to click yes.&lt;/p&gt;

&lt;p&gt;Three questions before you change anything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which tools can read text that someone outside your team wrote?&lt;/li&gt;
&lt;li&gt;After that read, which tools can still send, write or fetch?&lt;/li&gt;
&lt;li&gt;Does your chat window load images from URLs the model wrote?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Glossary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;context&lt;/strong&gt; — the text the model reads before each step: your request, its own replies, and every tool result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;prompt injection&lt;/strong&gt; — instructions written into content an agent will read, so the model follows them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;lethal trifecta&lt;/strong&gt; — private data, untrusted text and a way to send data out, all reachable from one context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;quarantined model&lt;/strong&gt; — a model that reads untrusted text but has no tools, and returns only values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;canary&lt;/strong&gt; — a unique marker string planted in a test, so you can detect if it ever leaves.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://simonwillison.net/2025/Jun/13/prompt-injection-design-patterns/" rel="noopener noreferrer"&gt;Design Patterns for Securing LLM Agents against Prompt Injections&lt;/a&gt; — Simon Willison's Weblog&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/" rel="noopener noreferrer"&gt;The lethal trifecta for AI agents&lt;/a&gt; — Simon Willison's Weblog&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/LLM_Prompt_Injection_Prevention_Cheat_Sheet.html" rel="noopener noreferrer"&gt;LLM Prompt Injection Prevention Cheat Sheet&lt;/a&gt; — OWASP Cheat Sheet Series&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://modelcontextprotocol.io/specification/2025-11-25/server/tools" rel="noopener noreferrer"&gt;Tools, Model Context Protocol specification&lt;/a&gt; — Model Context Protocol&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://theaicommit.com/2026-10-28-how-to-find-the-agent-actions-a-prompt-injection-can-control/" rel="noopener noreferrer"&gt;theaicommit.com&lt;/a&gt;, a daily AI engineering lab for software engineers. The original has an &lt;a href="https://theaicommit.com/2026-10-28-how-to-find-the-agent-actions-a-prompt-injection-can-control/" rel="noopener noreferrer"&gt;interactive version&lt;/a&gt; you can play with, and the full runnable code is on &lt;a href="https://github.com/magna56/aI_daily_run/blob/main/2026-10-28/code_example.py" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>agents</category>
      <category>mcp</category>
    </item>
    <item>
      <title>How Claude Code and Codex Decide Which Skill Descriptions the Model Reads</title>
      <dc:creator>Mayur Agnani</dc:creator>
      <pubDate>Tue, 27 Oct 2026 11:44:35 +0000</pubDate>
      <link>https://dev.to/theaicommit/how-claude-code-and-codex-decide-which-skill-descriptions-the-model-reads-k16</link>
      <guid>https://dev.to/theaicommit/how-claude-code-and-codex-decide-which-skill-descriptions-the-model-reads-k16</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Engineer's view.&lt;/strong&gt; This is a cache that evicts the least-used entry. A key you just added has the lowest hit count, so it goes first, and it never earns the hits that would keep it. Claude Code trims its skill list the same way. A skill you installed today is the first to lose its description.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; A coding agent shows the model only a short list of your skills, and that list has a size limit. Past the limit, descriptions get cut, and a skill the model cannot read about is a skill it rarely picks.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Explain Like I'm 5
&lt;/h2&gt;

&lt;p&gt;A school has one small board by the door. Every club pins a card with its name and what it&lt;br&gt;
does. Kids read the board to decide where to go after lunch.&lt;/p&gt;

&lt;p&gt;Then too many clubs sign up. The teacher keeps every name on the board but crosses out the&lt;br&gt;
descriptions of the clubs that got the fewest visitors lately. A brand-new club has had no&lt;br&gt;
visitors yet, so its description is crossed out first.&lt;/p&gt;

&lt;p&gt;A kid who loves chess walks past a card that just says "Knights". Nothing tells them it is the&lt;br&gt;
chess club. So the new club stays empty, and its card stays crossed out.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;You have shipped this bug already, in a system with no model in it.&lt;/p&gt;

&lt;p&gt;Your service wrote one log line per request. Someone added a field near the front, and the line&lt;br&gt;
crossed the log shipper's size limit. The shipper did not fail. It cut each line at the limit and&lt;br&gt;
sent the rest. The request ID sat at the end of the line, so searching by request ID stopped&lt;br&gt;
finding anything. No alert fired, because every line still arrived.&lt;/p&gt;

&lt;p&gt;The bug was not in any one field. It was a shared size limit, plus a rule for what gets cut when&lt;br&gt;
the limit is hit. Adding something new quietly took words away from something old.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;skill&lt;/strong&gt; in a coding agent has the same bug. A skill is a folder with a &lt;code&gt;SKILL.md&lt;/code&gt; file: a&lt;br&gt;
name, a description, and instructions for one kind of task. Coding agents that support the open&lt;br&gt;
Agent Skills format do not load your skills up front. They show the model&lt;br&gt;
a &lt;strong&gt;skill listing&lt;/strong&gt;: each skill's name and description, and nothing else. The model reads that&lt;br&gt;
listing and decides which skill fits your request.&lt;/p&gt;

&lt;p&gt;The listing has a size limit, a fixed share of the &lt;strong&gt;context window&lt;/strong&gt;, the model's working&lt;br&gt;
memory for the session. Install enough skills, and the listing overflows. Each tool then cuts&lt;br&gt;
it by its own rule. A skill whose description was cut can still be listed and still never be&lt;br&gt;
chosen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix: keep the listing under its budget, put each skill's use case in its first sentence&lt;br&gt;
and its name, and check what the model actually sees.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The Fix: Keep the Listing Under Budget and Put the Use Case First
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwrcz3378n3gfglywiz0d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwrcz3378n3gfglywiz0d.png" alt="What reaches the model when the skill listing overflows" width="800" height="321"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  What does the model actually see?
&lt;/h3&gt;

&lt;p&gt;Agent Skills loads in three steps, which the spec calls &lt;strong&gt;progressive disclosure&lt;/strong&gt;. At the start&lt;br&gt;
of a session the model sees only the listing. When a request matches a description, it reads&lt;br&gt;
that skill's full &lt;code&gt;SKILL.md&lt;/code&gt;. Scripts and reference files load only when the instructions point&lt;br&gt;
at them.&lt;/p&gt;

&lt;p&gt;So the description does all of the choosing. There is no keyword matcher behind it. The model&lt;br&gt;
reads the words and makes a judgment.&lt;/p&gt;
&lt;h3&gt;
  
  
  How big is the listing allowed to be?
&lt;/h3&gt;

&lt;p&gt;Claude Code gives it 1% of the context window. Codex gives it 2%, or 8,000 characters when the&lt;br&gt;
window size is unknown. On a 200K-token model, 1% is about 8,000 characters, at roughly four&lt;br&gt;
characters per token.&lt;/p&gt;

&lt;p&gt;That is not much. The 41 skills on the machine this article was written on have a median&lt;br&gt;
description of 408 characters. About eighteen such skills fill Claude Code's listing.&lt;/p&gt;
&lt;h3&gt;
  
  
  What gets cut when it overflows?
&lt;/h3&gt;

&lt;p&gt;The two tools use different rules. Claude Code keeps every name. It drops whole descriptions,&lt;br&gt;
starting with the skills you invoke least. Codex shortens descriptions first and omits whole&lt;br&gt;
skills only for very large sets.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6b6f8x8r2owp4osbv6hs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6b6f8x8r2owp4osbv6hs.png" alt="Characters in the listing, 31 skills on a 200K-token model" width="799" height="272"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Why does a brand-new skill go quiet first?
&lt;/h3&gt;

&lt;p&gt;Under Claude Code's rule, a skill you installed today has zero invocations. It is first in line&lt;br&gt;
to lose its description. The model then sees only its name, picks it less, and its count stays&lt;br&gt;
at zero. Unless you invoke it by hand, it never climbs out.&lt;/p&gt;
&lt;h2&gt;
  
  
  What This Means for You
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;When this matters.&lt;/strong&gt; You have more than about twenty skills, counting the ones that plugins&lt;br&gt;
bring along. Or you switch between a 1M-token model and a smaller one. The budget is a share of&lt;br&gt;
the window, so a smaller window shrinks it. A skill that worked yesterday can lose its&lt;br&gt;
description when you change models.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it affects you.&lt;/strong&gt; The failure is quiet. The skill still shows up in the &lt;code&gt;/&lt;/code&gt; menu, and it&lt;br&gt;
still works when you type its name. What stops is the model choosing it on its own. You notice&lt;br&gt;
weeks later, when a review skips the checklist you wrote for it. In Claude Code the newest skills&lt;br&gt;
are hit first. In Codex every description gets shorter, so a description that saves its trigger&lt;br&gt;
words for the last sentence loses exactly those words.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to do about it.&lt;/strong&gt; Start with one minute and no new tooling. In Claude Code, run&lt;br&gt;
&lt;code&gt;/context&lt;/code&gt; and read the Skills row. It shows the listing's size after the budget is applied. If&lt;br&gt;
it sits near 1% of your window, you are at the edge. Then open your three newest skills and read&lt;br&gt;
the first sentence of each description. If it does not say when to use the skill, rewrite it so&lt;br&gt;
it does.&lt;/p&gt;

&lt;p&gt;Then make it a habit. Turn off skills you never use, set manual-only skills so they leave the&lt;br&gt;
listing, and add a size check to the repo that holds your team's skills. &lt;code&gt;## Implementing It&lt;/code&gt;&lt;br&gt;
shows all three.&lt;/p&gt;
&lt;h2&gt;
  
  
  Implementing It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The change.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three people touch this, and each change is small.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Role 1, whoever writes the skill.&lt;/em&gt; Put the use case first, and make the name carry it too. A&lt;br&gt;
name survives every cut that a description does not. Keep the whole description short enough&lt;br&gt;
that a cut at about 250 characters still leaves the trigger.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# before: the trigger is in the last sentence, and the name says nothing&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;notes&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Collects merged pull requests, groups them by area, and drafts a&lt;/span&gt;
  &lt;span class="s"&gt;summary for customers in the house style. ... Use when the user asks for&lt;/span&gt;
  &lt;span class="s"&gt;release notes.&lt;/span&gt;

&lt;span class="c1"&gt;# after: the trigger leads, and the name alone still says what it is for&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;release-notes&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Use when the user asks for release notes or a changelog for&lt;/span&gt;
  &lt;span class="s"&gt;customers. Groups merged pull requests by area in the house style.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the demo, the Codex cut keeps 244 characters per description once a plugin doubles the&lt;br&gt;
skill count. The "before" description loses the words "release notes". The "after" keeps them.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Role 2, whoever owns the agent setup.&lt;/em&gt; Remove what the model never needs to choose. A skill you&lt;br&gt;
only run by hand should leave the listing entirely. In its frontmatter, Claude Code reads&lt;br&gt;
&lt;code&gt;disable-model-invocation: true&lt;/code&gt;. Codex reads &lt;code&gt;allow_implicit_invocation: false&lt;/code&gt; under &lt;code&gt;policy&lt;/code&gt;&lt;br&gt;
in &lt;code&gt;agents/openai.yaml&lt;/code&gt;. For skills you cannot edit, use &lt;code&gt;.claude/settings.json&lt;/code&gt; in Claude Code and&lt;br&gt;
&lt;code&gt;~/.codex/config.toml&lt;/code&gt; in Codex:&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="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"skillOverrides"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"legacy-context"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"name-only"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"old-deploy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"off"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&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 toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# ~/.codex/config.toml&lt;/span&gt;
&lt;span class="k"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;skills&lt;/span&gt;&lt;span class="k"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="k"&gt;]]&lt;/span&gt;
&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"/Users/you/.agents/skills/old-deploy"&lt;/span&gt;
&lt;span class="n"&gt;enabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also raise the budget. In Claude Code, set &lt;code&gt;skillListingBudgetFraction&lt;/code&gt; (for example&lt;br&gt;
&lt;code&gt;0.02&lt;/code&gt;). In Codex, set &lt;code&gt;skills.max_context_tokens&lt;/code&gt;, up to 10,000 tokens. Every character you add&lt;br&gt;
is paid on every turn, so prune first.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Role 3, whoever owns the repo that ships the team's skills.&lt;/em&gt; Fail the build when the listing&lt;br&gt;
would overflow on the smallest model your team uses. &lt;code&gt;read_skills&lt;/code&gt; and &lt;code&gt;claude_code_listing&lt;/code&gt;&lt;br&gt;
in &lt;a href="https://github.com/magna56/aI_daily_run/blob/main/2026-10-27/code_example.py" rel="noopener noreferrer"&gt;&lt;code&gt;code_example.py&lt;/code&gt;&lt;/a&gt; do the parsing and the arithmetic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;skills&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;read_skills&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.claude/skills&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.agents/skills&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;shown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;budget&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;claude_code_listing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;skills&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;200_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;dropped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;desc&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;shown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;dropped&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;over the &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;budget&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;-char listing budget: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;dropped&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How you know it worked.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In Claude Code, the Skills row in &lt;code&gt;/context&lt;/code&gt; should drop below the budget, and &lt;code&gt;--debug&lt;/code&gt; should&lt;br&gt;
stop logging the overflow warning. &lt;code&gt;/skill-doctor&lt;/code&gt; lists skills that were never invoked, with&lt;br&gt;
what each costs. In Codex, the warning about omitted skills should go away.&lt;/p&gt;

&lt;p&gt;Then check the behavior, not just the size. Pick five requests that should trigger the new skill.&lt;br&gt;
Run each in a fresh session with &lt;code&gt;claude -p "&amp;lt;request&amp;gt;" --output-format json&lt;/code&gt;, and look for a&lt;br&gt;
&lt;code&gt;Skill&lt;/code&gt; tool call naming it. Most of the five should now load the skill.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Trimming the Listing Is the Wrong Tool
&lt;/h2&gt;

&lt;p&gt;When you have a handful of skills. Ten skills with 400-character descriptions fit with room to&lt;br&gt;
spare. Rewriting them buys nothing.&lt;/p&gt;

&lt;p&gt;When the rule must hold every time. A skill is advice the model can skip, even when it was&lt;br&gt;
loaded. If every edit must pass a check, put the check in a hook. Claude Code's own docs say&lt;br&gt;
the same.&lt;/p&gt;

&lt;p&gt;When the listing is not the problem. A skill can be listed in full and still not be chosen. The&lt;br&gt;
model tends to skip skills for tasks it can do alone, like reading one file. That is a&lt;br&gt;
description problem, and no budget change will fix it.&lt;/p&gt;

&lt;p&gt;And raising the budget is not free. The listing is read on every turn. Doubling it on a big team&lt;br&gt;
costs context that every session pays for.&lt;/p&gt;

&lt;p&gt;Three questions before you change anything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does &lt;code&gt;/context&lt;/code&gt; show the Skills row near its budget on the smallest model you use?&lt;/li&gt;
&lt;li&gt;Do your newest skills say when to use them in the first sentence, and in the name?&lt;/li&gt;
&lt;li&gt;Which of your skills are only ever run by hand, and still listed for the model?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Glossary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;skill&lt;/strong&gt; — a folder with a &lt;code&gt;SKILL.md&lt;/code&gt; file: a name, a description, and instructions for one kind of task.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;skill listing&lt;/strong&gt; — the names and descriptions the model sees at the start of a session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;context window&lt;/strong&gt; — the model's working memory for a session, measured in tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;progressive disclosure&lt;/strong&gt; — loading the listing first, a skill's instructions on a match, and its files only when needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;invoke&lt;/strong&gt; — run a skill, either because the model chose it or because you typed its name.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://agentskills.io/client-implementation/adding-skills-support" rel="noopener noreferrer"&gt;How to add skills support to your agent&lt;/a&gt; — Agent Skills specification site (format originated by Anthropic)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://code.claude.com/docs/en/skills" rel="noopener noreferrer"&gt;Extend Claude with skills&lt;/a&gt; — Anthropic, Claude Code docs&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://learn.chatgpt.com/docs/build-skills" rel="noopener noreferrer"&gt;Build skills&lt;/a&gt; — OpenAI, Codex docs&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://agentskills.io/skill-creation/optimizing-descriptions" rel="noopener noreferrer"&gt;Optimizing skill descriptions&lt;/a&gt; — Agent Skills specification site&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://theaicommit.com/2026-10-27-how-claude-code-and-codex-decide-which-skill-descriptions/" rel="noopener noreferrer"&gt;theaicommit.com&lt;/a&gt;, a daily AI engineering lab for software engineers. The original has an &lt;a href="https://theaicommit.com/2026-10-27-how-claude-code-and-codex-decide-which-skill-descriptions/" rel="noopener noreferrer"&gt;interactive version&lt;/a&gt; you can play with, and the full runnable code is on &lt;a href="https://github.com/magna56/aI_daily_run/blob/main/2026-10-27/code_example.py" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>agents</category>
    </item>
    <item>
      <title>How to Correct an LLM Judge's Pass Rate With 100 Human Labels</title>
      <dc:creator>Mayur Agnani</dc:creator>
      <pubDate>Mon, 26 Oct 2026 12:05:05 +0000</pubDate>
      <link>https://dev.to/theaicommit/how-to-correct-an-llm-judges-pass-rate-with-100-human-labels-16oa</link>
      <guid>https://dev.to/theaicommit/how-to-correct-an-llm-judges-pass-rate-with-100-human-labels-16oa</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Engineer's view.&lt;/strong&gt; This is a health check that returns 200 while the database is down. Your uptime number then mixes real uptime with the check's blind spot. A model grading your outputs does the same thing. It passes some bad ones, so the score runs high and every real fix looks smaller than it was.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; The pass rate a grading model reports is part your product and part the grader's own mistakes. Measure those mistakes on a small set a person has checked, and you can subtract them back out.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Explain Like I'm 5
&lt;/h2&gt;

&lt;p&gt;You run a fruit stand, and a helper sorts apples into a good basket and a bad basket. She&lt;br&gt;
is quick, but she is kind. Most bruised apples land in the good basket too.&lt;/p&gt;

&lt;p&gt;So you count the good basket and feel proud. Nine apples in ten look fine. That number is&lt;br&gt;
partly your apples and partly her kindness.&lt;/p&gt;

&lt;p&gt;To find the real number, you test her. Hand her fifty apples you know are good and fifty&lt;br&gt;
you know are bruised. Count how many of each she gets right. Now you know how many bruised&lt;br&gt;
apples she lets through on a normal day. Take that many out of the good basket, and what is&lt;br&gt;
left is the true count.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;You have shipped this bug already, in a system with no model in it.&lt;/p&gt;

&lt;p&gt;Your uptime dashboard said 99.9% for a month. Users kept filing tickets about errors. After&lt;br&gt;
a day of digging you found the health check. It pinged an endpoint that returned 200 as long&lt;br&gt;
as the web process was alive. When the database was down, the check still passed.&lt;/p&gt;

&lt;p&gt;Nothing was wrong with the arithmetic. The dashboard faithfully counted what the check said.&lt;br&gt;
But the check had a blind spot, so the number mixed two things: real uptime and the check's&lt;br&gt;
habit of passing broken states. You could not fix the dashboard until you knew how often the&lt;br&gt;
check was wrong.&lt;/p&gt;

&lt;p&gt;An eval that uses an &lt;strong&gt;LLM judge&lt;/strong&gt; has that bug at full size. An LLM judge is a model you&lt;br&gt;
prompt to grade each output of your feature as Pass or Fail. Its pass rate goes on the&lt;br&gt;
dashboard as if it were your product's pass rate. It is not. Judges make mistakes in both&lt;br&gt;
directions, and they lean lenient. One 2025 study of judges grading feedback on student code&lt;br&gt;
found they passed 96% of good answers but failed fewer than a quarter of bad ones.&lt;/p&gt;

&lt;p&gt;That leniency does two kinds of damage. The score runs high. Worse, it hides progress. When&lt;br&gt;
the judge passes most bad outputs anyway, fixing some of them barely moves the number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix: label about a hundred outputs by hand, measure how often the judge agrees on the&lt;br&gt;
good ones and on the bad ones, and use those two rates to undo the blend.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The Fix: Measure the Judge's Two Error Rates, Then Undo Them
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fikm5t7d0uwxjqpot9izx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fikm5t7d0uwxjqpot9izx.png" alt="Where the judge's pass rate comes from" width="800" height="321"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Where does the wrong pass rate come from?
&lt;/h3&gt;

&lt;p&gt;From two numbers the dashboard never shows. The &lt;strong&gt;TPR&lt;/strong&gt; is the share of truly good outputs&lt;br&gt;
the judge passes. The &lt;strong&gt;TNR&lt;/strong&gt; is the share of truly bad outputs the judge fails. Here, Pass&lt;br&gt;
counts as the positive answer.&lt;/p&gt;

&lt;p&gt;The judge's pass rate is the good outputs it passes plus the bad outputs it lets through. A&lt;br&gt;
strict judge with a TPR of 0.92 and a TNR of 0.88 turns a true 70% into 68%. That is close,&lt;br&gt;
but only by luck: its two mistakes happen to cancel.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Rogan-Gladen correction&lt;/strong&gt; runs that arithmetic backwards. It comes from medical&lt;br&gt;
testing, where a lab test has the same two error rates. Add the TNR to the judge's pass&lt;br&gt;
rate, subtract one, and divide by the TPR plus the TNR minus one.&lt;/p&gt;
&lt;h3&gt;
  
  
  Why does a real fix look smaller?
&lt;/h3&gt;

&lt;p&gt;Because only part of any real change gets through. That part is the TPR plus the TNR minus&lt;br&gt;
one. For the strict judge it is 0.80, so a real eight-point gain shows as 6.4 points.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0ha1732ge2jyvoinl6jk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0ha1732ge2jyvoinl6jk.png" alt="A real 8-point gain, as each judge reports it" width="799" height="272"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A lenient judge with a TNR of 0.24 keeps only a fifth of the change. Your team ships a real&lt;br&gt;
improvement, and the chart moves by 1.6 points.&lt;/p&gt;
&lt;h3&gt;
  
  
  Can the correction rescue the lenient judge?
&lt;/h3&gt;

&lt;p&gt;No, and this is the part that bites. The correction divides by that same 0.20. Dividing by a&lt;br&gt;
small number turns small mistakes in your labels into huge swings. With a hundred labels, the&lt;br&gt;
lenient judge's corrected rate could be anything from 32% to 100%. Fix the judge first.&lt;/p&gt;
&lt;h2&gt;
  
  
  What This Means for You
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;When this matters.&lt;/strong&gt; Any time a model grades your outputs and you report the share it&lt;br&gt;
passed. That covers a nightly eval, a release check, and a quality chart on a dashboard. It&lt;br&gt;
matters most when you compare two numbers: this release against the last one, or one prompt&lt;br&gt;
against another.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it affects you.&lt;/strong&gt; The level is off, usually too high. The comparisons are shrunk, so&lt;br&gt;
real improvements look small and a prompt change that helped can look like noise. And it&lt;br&gt;
gets worse quietly. Swap in a stricter judge model, and your pass rate drops on a day you&lt;br&gt;
shipped nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to do about it.&lt;/strong&gt; Start with ten minutes and no new tooling. Find ten outputs you&lt;br&gt;
know are bad. A thumbs-down from a user, a bug report, or a few you break on purpose all&lt;br&gt;
work. Run your judge on them and count how many it passes. If it passes four, your judge&lt;br&gt;
lets roughly 40% of failures through. Every bad output it misses is counted on your dashboard&lt;br&gt;
as a success.&lt;/p&gt;

&lt;p&gt;Then do it properly. Label about a hundred outputs drawn from real traffic, half good and half&lt;br&gt;
bad. Measure the judge's TPR and TNR on them. Report the corrected rate with an interval&lt;br&gt;
next to the raw one.&lt;/p&gt;

&lt;p&gt;If the TPR plus the TNR comes out under about 0.3, stop. Do not correct. Rewrite the judge&lt;br&gt;
prompt or narrow it to one failure mode, and measure again. The next section shows the three&lt;br&gt;
pieces of code, and the check that tells you they work.&lt;/p&gt;
&lt;h2&gt;
  
  
  Implementing It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The change.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three people touch this. Each writes a little code, and none of it is hard. Getting the&lt;br&gt;
convention wrong is the usual failure, so name it once and use it everywhere.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Role 1, whoever owns the labels.&lt;/em&gt; Keep the human verdicts beside the judge verdicts, one&lt;br&gt;
line per output, sampled from production. Balance the classes on purpose. Real traffic&lt;br&gt;
might be 90% good, and ten bad examples cannot measure a TNR.&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"conv-8812"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"human"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"judge"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"split"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dev"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&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="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"conv-9034"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"human"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"judge"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"split"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&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="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"conv-9107"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"human"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"judge"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"split"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use the &lt;code&gt;dev&lt;/code&gt; rows while you rewrite the judge prompt. Measure the final rates on the &lt;code&gt;test&lt;/code&gt;&lt;br&gt;
rows only, once. Rates measured on the rows you tuned against will look better than they are.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Role 2, whoever owns the eval script.&lt;/em&gt; Compute the two rates, correct the pass rate, and&lt;br&gt;
refuse when the judge is too weak.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;judge_rates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;human&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;judge&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;          &lt;span class="c1"&gt;# 1 = Pass, for both lists
&lt;/span&gt;    &lt;span class="n"&gt;good&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;human&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;judge&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;bad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;human&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;judge&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;good&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;good&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;corrected_pass_rate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_obs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tpr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tnr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_youden&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.30&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;youden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tpr&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;tnr&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;youden&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;min_youden&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;                     &lt;span class="c1"&gt;# report "judge too weak", not a number
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_obs&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;tnr&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;youden&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interval comes from resampling. Redraw the labeled rows and the production verdicts with&lt;br&gt;
replacement, recompute both rates and the corrected rate, and repeat 2,000 times. The middle&lt;br&gt;
95% of the results is the interval. To compare two releases, draw one pair of rates per round&lt;br&gt;
and use it for both. The judge's error is shared, so part of it cancels. The full version is in&lt;br&gt;
&lt;a href="https://github.com/magna56/aI_daily_run/blob/main/2026-10-26/code_example.py" rel="noopener noreferrer"&gt;&lt;code&gt;code_example.py&lt;/code&gt;&lt;/a&gt;. If you would rather not write it, the MIT-licensed &lt;code&gt;judgy&lt;/code&gt; package does the&lt;br&gt;
same thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;judgy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;estimate_success_rate&lt;/span&gt;
&lt;span class="n"&gt;theta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;low&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;high&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;estimate_success_rate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;test_labels&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;human&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;test_preds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;judge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unlabeled_preds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;production_verdicts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Role 3, whoever owns the dashboard and the review.&lt;/em&gt; Show the raw rate, the corrected rate,&lt;br&gt;
and its interval. Store the judge's TPR, TNR and exact model version next to each run. A&lt;br&gt;
new judge prompt or model needs new rates before its numbers can be compared.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How you know it worked.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Run two checks. First, use the &lt;code&gt;dev&lt;/code&gt; rows to measure the rates, then correct the judge's pass&lt;br&gt;
rate on the &lt;code&gt;test&lt;/code&gt; rows. The corrected number should land near the human pass rate on those&lt;br&gt;
rows. The raw number usually will not.&lt;/p&gt;

&lt;p&gt;Second, swap the convention on purpose. In the Code tab, a corrected 70.0% becomes 75.0% when&lt;br&gt;
TPR and TNR trade places. Some guides call a caught failure the positive case, so their TPR&lt;br&gt;
is your TNR. If two people's numbers disagree by a few points, check this first.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the Correction Is the Wrong Tool
&lt;/h2&gt;

&lt;p&gt;When the judge is barely better than a coin. The correction divides by the judge's two rates&lt;br&gt;
added together, minus one. Near zero, the interval covers almost everything. Improve the judge&lt;br&gt;
until both rates clear 0.8, then correct.&lt;/p&gt;

&lt;p&gt;When today's failures are a new kind. The correction assumes the judge misses failures as often&lt;br&gt;
in production as it did on your labels. In the Code tab, the labels say the judge fails 88% of&lt;br&gt;
bad outputs. Today's failures are subtler, and it fails only 60% of them. The true rate is 60%,&lt;br&gt;
and the stale correction reports 74%. Re-label a fresh sample when the product changes.&lt;/p&gt;

&lt;p&gt;When you need a verdict on one output. The correction fixes a rate across many outputs. It says&lt;br&gt;
nothing about whether a given reply was good, so it cannot gate a single response.&lt;/p&gt;

&lt;p&gt;And when you can afford to label everything. At fifty outputs a week, a person is the eval.&lt;/p&gt;

&lt;p&gt;Three questions before you adopt it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Do both of the judge's rates clear 0.8 on outputs it never saw while you tuned it?&lt;/li&gt;
&lt;li&gt;Were your labels drawn from the same traffic the judge is grading now?&lt;/li&gt;
&lt;li&gt;Will the judge's prompt or model change before the next report? If so, its rates expire.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Glossary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LLM judge&lt;/strong&gt; — a model prompted to grade each output of your feature as Pass or Fail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TPR&lt;/strong&gt; — the share of truly good outputs the judge passes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TNR&lt;/strong&gt; — the share of truly bad outputs the judge fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rogan-Gladen correction&lt;/strong&gt; — arithmetic that turns a judge's pass rate back into the true one, using its two rates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;interval&lt;/strong&gt; — the range the true pass rate probably sits in, after counting the noise in your labels.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://hamel.dev/blog/posts/decision-model-evals/" rel="noopener noreferrer"&gt;How To Use Decision Models for Evals&lt;/a&gt; — Hamel Husain&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/hamelsmu/evals-skills/blob/main/skills/validate-evaluator/SKILL.md" rel="noopener noreferrer"&gt;validate-evaluator&lt;/a&gt; — Hamel Husain, evals-skills on GitHub&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ai-evals-course/judgy" rel="noopener noreferrer"&gt;judgy&lt;/a&gt; — ai-evals-course on GitHub (MIT)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://hamel.dev/blog/posts/evals-faq/" rel="noopener noreferrer"&gt;AI Evals: Everything You Need to Know&lt;/a&gt; — Hamel Husain&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://theaicommit.com/2026-10-26-how-to-correct-an-llm-judges-pass-rate-with-100-human-labels/" rel="noopener noreferrer"&gt;theaicommit.com&lt;/a&gt;, a daily AI engineering lab for software engineers. The original has an &lt;a href="https://theaicommit.com/2026-10-26-how-to-correct-an-llm-judges-pass-rate-with-100-human-labels/" rel="noopener noreferrer"&gt;interactive version&lt;/a&gt; you can play with, and the full runnable code is on &lt;a href="https://github.com/magna56/aI_daily_run/blob/main/2026-10-26/code_example.py" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>observability</category>
      <category>devops</category>
    </item>
    <item>
      <title>How to Compare Model Prices When Each Model Counts Tokens Differently</title>
      <dc:creator>Mayur Agnani</dc:creator>
      <pubDate>Sun, 25 Oct 2026 11:52:22 +0000</pubDate>
      <link>https://dev.to/theaicommit/how-to-compare-model-prices-when-each-model-counts-tokens-differently-23on</link>
      <guid>https://dev.to/theaicommit/how-to-compare-model-prices-when-each-model-counts-tokens-differently-23on</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Engineer's view.&lt;/strong&gt; This is comparing cloud machines by price per vCPU when one provider's vCPU is a full core and the other's is half of one. The unit has the same name and a different size. A token is that kind of unit: every model defines its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Each model splits text into tokens with its own vocabulary, so the same prompt is a different number of tokens on each. A price per token only compares after you measure tokens per character on your own text. The same effect quietly shrinks context windows and output limits.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Explain Like I'm 5
&lt;/h2&gt;

&lt;p&gt;Two shops sell rope by the "length." At the first shop, a length is your arm. At the second, a length is the shopkeeper's arm, which is shorter. Both signs say ten coins per length. You need enough rope to cross a river. At the second shop you need more lengths, so you pay more. The price signs matched, and the bills did not. To compare, measure the river once in your own steps. Then ask each shop how many of its lengths that is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;You have shipped this bug before, in a system with no AI in it.&lt;/p&gt;

&lt;p&gt;Your team picked a cloud machine by price per vCPU. Provider A was cheaper per vCPU, so the batch jobs moved there. The jobs ran slower, and the monthly bill went up. On provider B, a vCPU was a full core. On provider A, it was one hyperthread, about half a core. Both units were called vCPU. They were not the same size, so the comparison was wrong before anyone ran it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model pricing&lt;/strong&gt; has the same bug. Every provider lists a price per million &lt;strong&gt;tokens&lt;/strong&gt;, the chunks of text a model reads and writes. Comparison sites line those prices up in one table. Your usage dashboard and your plan's limits are in tokens too. It looks like one shared unit.&lt;/p&gt;

&lt;p&gt;It is not. Each model cuts text into tokens its own way. In April 2026, Anthropic shipped Opus 4.7 at the same price as Opus 4.6, with a new tokenizer. OpenRouter then studied over a million real requests from users who switched. Their prompts now came out 32% to 45% longer in tokens. Simon Willison pasted one system prompt into both models. 5,039 tokens became 7,335. The price tag did not move, and the bill did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix: convert every price to your own text before you compare, then recheck every limit you set in tokens.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Measure Tokens per Character on Your Own Text
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is a token, exactly?
&lt;/h3&gt;

&lt;p&gt;A &lt;strong&gt;tokenizer&lt;/strong&gt; turns text into tokens before the model sees it. It is trained once, before the model. Training starts from single characters. It then keeps merging the pair that appears most often in its training text into a new, longer token. After enough merges, common words are one token each. Rare words, odd code and unfamiliar scripts stay split into small pieces. This method is called &lt;strong&gt;byte-pair encoding&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7x9rbnme6c0xvokpgwfd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7x9rbnme6c0xvokpgwfd.png" alt="How a price per token becomes your bill" width="800" height="306"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do two models disagree on the same text?
&lt;/h3&gt;

&lt;p&gt;Each tokenizer learned from different text. In &lt;a href="https://github.com/magna56/aI_daily_run/blob/main/2026-10-25/code_example.py" rel="noopener noreferrer"&gt;&lt;code&gt;code_example.py&lt;/code&gt;&lt;/a&gt;, two toy tokenizers get the same number of merges. One trained on prose, the other on prose plus code. An 80-character line of Python costs 70 tokens on the first and 44 on the second. The prose sample costs 33 on both. Code, JSON and languages other than English vary the most.&lt;/p&gt;

&lt;h3&gt;
  
  
  So what number can I compare?
&lt;/h3&gt;

&lt;p&gt;The cost of one of your tasks: price per token, times tokens per character, times characters sent. Say model B lists 17% cheaper but cuts your text into 35% more tokens. It costs 13% more per task.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does 35% more tokens always mean a 35% bigger bill?
&lt;/h3&gt;

&lt;p&gt;If nothing else changes, yes. Cached and uncached tokens scale alike. But a new model also changes how much it writes. OpenRouter found median answers on short prompts got 62% shorter, so that bill stayed flat. Above 2,000 tokens, costs rose 12% to 27%.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Means for You
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;When this matters.&lt;/strong&gt; You choose between models by their price page. You switch the default model in Cursor, Claude Code or your own app. A provider ships a new version "at the same price." You run near a context limit, a usage cap or a tight &lt;code&gt;max_tokens&lt;/code&gt;. In each case, a number you set or compare is measured in that model's tokens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it affects you.&lt;/strong&gt; Your bill is the obvious part. The quieter part is every limit. A limit in tokens is a limit in text only for the model it was set on. Move to a tokenizer that makes 35% more tokens, and a 200,000-token window holds 26% less of your repository. An 800-token answer cap now stops at about 2,370 characters instead of 3,200. Long answers get cut off sooner, and nothing errors. Agents start compacting their history earlier. A weekly token allowance runs out sooner, for the same work.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx4tgid4tkkq4uymgbnok.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx4tgid4tkkq4uymgbnok.png" alt="What a 200,000-token context window holds" width="800" height="251"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the vCPU bug again. The number on the label is right. It is counted in a unit whose size you never checked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to do about it.&lt;/strong&gt; Pick 20 to 50 real prompts and answers from your own logs. Count them with each candidate model's own token counter. Divide by characters to get tokens per thousand characters. Multiply by each price to get cost per task. Do the same check whenever a model changes version, even at an unchanged price. Then recompute every limit you set in tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementing It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The change.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three roles touch this: whoever picks the model, whoever tracks spend, and whoever sets limits.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Role 1, picking a model.&lt;/em&gt; Count your own sample with each provider's counter. Each counter counts with that model's tokenizer, so the ratio is honest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tiktoken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;genai&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;tokens_per_kchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;enc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tiktoken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_encoding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;o200k_base&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# check which encoding your model uses
&lt;/span&gt;&lt;span class="n"&gt;openai_tpk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tokens_per_kchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;

&lt;span class="n"&gt;claude&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;claude_tpk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tokens_per_kchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;claude&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count_tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;CLAUDE_MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;}]).&lt;/span&gt;&lt;span class="n"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;genai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;gemini_tpk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tokens_per_kchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;gem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count_tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;GEMINI_MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;contents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;total_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use real prompts with their system prompt and tool definitions included. Those are often the largest part of each request. A sample of only short user messages will mislead you. Keep the sample in a file, so every later comparison counts the same text. Never estimate with "four characters per token." That rule of thumb was tuned on English prose for one tokenizer.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Role 2, tracking spend.&lt;/em&gt; Record cost per task, not tokens per day. A token count cannot be compared across models, and dollars can. Read the usage fields each response already returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;                                      &lt;span class="c1"&gt;# Anthropic Messages API
&lt;/span&gt;&lt;span class="n"&gt;dollars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_tokens&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;P_IN&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_read_input_tokens&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;P_CACHE_READ&lt;/span&gt;
           &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_creation_input_tokens&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;P_CACHE_WRITE&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output_tokens&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;P_OUT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;1e6&lt;/span&gt;
&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;task_cost&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dollars&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;dollars&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chars&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;OpenAI reports &lt;code&gt;prompt_tokens&lt;/code&gt;, &lt;code&gt;completion_tokens&lt;/code&gt; and &lt;code&gt;prompt_tokens_details.cached_tokens&lt;/code&gt; instead. Gemini reports them in &lt;code&gt;usage_metadata&lt;/code&gt;. Group by task type, then compare models on the same tasks. Store the model name on every record. Without it, a model switch looks like a change in traffic.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Role 3, setting limits.&lt;/em&gt; Every limit in tokens was tuned for one tokenizer. First list them: search your code and config for &lt;code&gt;max_tokens&lt;/code&gt;, context budgets, history truncation and compaction thresholds. Then rescale them when you switch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rescale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tpk_old&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tpk_new&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit_tokens&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;tpk_new&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;tpk_old&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;MAX_TOKENS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rescale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;338&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                 &lt;span class="c1"&gt;# 1082: same answer length in text
&lt;/span&gt;&lt;span class="n"&gt;COMPACT_AT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;rescale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;150_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;338&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;        &lt;span class="c1"&gt;# 202,800 would pass a 200k window,
&lt;/span&gt;                 &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.8&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;NEW_WINDOW&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;             &lt;span class="c1"&gt;# so cap at the model's real limit
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The window itself does not grow. When the rescaled number passes it, less of your text fits, and you plan for that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How you know it worked.&lt;/strong&gt; Your cost-per-task chart is continuous across a model switch. A jump there is a real change, not a unit change. The truncation rate stays the same after the switch. If answers suddenly hit &lt;code&gt;max_tokens&lt;/code&gt; more often, a limit was not rescaled. Finally, your measured tokens per thousand characters for each model sits in your notes. The next price comparison should take five minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Per-Character Comparison Is the Wrong Tool
&lt;/h2&gt;

&lt;p&gt;When quality decides, not price. A model that solves the task in one try beats a cheaper one that needs three. Cost per task must include retries, and per-character math does not see them.&lt;/p&gt;

&lt;p&gt;When you pay a flat seat price with no token cap. The tokenizer then changes nothing you pay. It still changes how much fits in the context window.&lt;/p&gt;

&lt;p&gt;When your inputs are mostly images or audio. Those are counted by size, resolution or duration, with rules of their own. Measure them separately.&lt;/p&gt;

&lt;p&gt;When the gap is small. A 5% difference in tokens per character is inside the noise of most samples. Do not switch models over it.&lt;/p&gt;

&lt;p&gt;When the workload will change soon. Tokens per character depends on what you send. A ratio measured on chat prompts will not hold for a code agent.&lt;/p&gt;

&lt;p&gt;Before you adopt it, ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How many tokens per thousand characters does each model use on my own text?&lt;/li&gt;
&lt;li&gt;Which limits in my system are set in tokens?&lt;/li&gt;
&lt;li&gt;Did the last model switch change how long the answers are?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Glossary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;token&lt;/strong&gt; — a chunk of text a model reads or writes; each model defines its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tokenizer&lt;/strong&gt; — the program that splits text into a model's tokens before the model sees it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;byte-pair encoding&lt;/strong&gt; — training a tokenizer by repeatedly merging the most frequent pair of symbols.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tokens per thousand characters&lt;/strong&gt; — how many tokens one model uses for 1,000 characters of your text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;context window&lt;/strong&gt; — the most tokens a model can read in one request.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://openrouter.ai/blog/insights/opus-47-tokenizer-analysis/" rel="noopener noreferrer"&gt;Opus 4.7's New Tokenizer: What It Actually Costs&lt;/a&gt; — OpenRouter (Justin Summerville)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://simonwillison.net/2026/Apr/20/claude-token-counts/" rel="noopener noreferrer"&gt;Claude Token Counter, now with model comparisons&lt;/a&gt; — Simon Willison's Weblog&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/openai/tiktoken" rel="noopener noreferrer"&gt;tiktoken&lt;/a&gt; — OpenAI, GitHub&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://theaicommit.com/2026-10-25-how-to-compare-model-prices-when-each-model-counts-tokens/" rel="noopener noreferrer"&gt;theaicommit.com&lt;/a&gt;, a daily AI engineering lab for software engineers. The original has an &lt;a href="https://theaicommit.com/2026-10-25-how-to-compare-model-prices-when-each-model-counts-tokens/" rel="noopener noreferrer"&gt;interactive version&lt;/a&gt; you can play with, and the full runnable code is on &lt;a href="https://github.com/magna56/aI_daily_run/blob/main/2026-10-25/code_example.py" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>performance</category>
    </item>
    <item>
      <title>Why Forced JSON Output Is Always Valid and Sometimes Wrong</title>
      <dc:creator>Mayur Agnani</dc:creator>
      <pubDate>Sat, 24 Oct 2026 13:19:03 +0000</pubDate>
      <link>https://dev.to/theaicommit/why-forced-json-output-is-always-valid-and-sometimes-wrong-2jc8</link>
      <guid>https://dev.to/theaicommit/why-forced-json-output-is-always-valid-and-sometimes-wrong-2jc8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Engineer's view.&lt;/strong&gt; This is a required dropdown with no "Not applicable" option. Validation always passes, so the data looks clean. But users who had no right answer picked the first item, and your reports now count them. A JSON schema enforced token by token works the same way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Forced JSON mode removes the tokens your schema forbids and rescales the rest, so the output always parses. The model still chooses among legal tokens. When it wanted something illegal, it guesses, and the guess looks as sure as a real answer.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Explain Like I'm 5
&lt;/h2&gt;

&lt;p&gt;A teacher hands out a quiz where every question has three boxes. You must tick one. There is no box for "I don't know." So when you don't know, you tick a box anyway. The teacher sees a neat page with every box ticked. The teacher cannot tell which ticks you knew and which you guessed. The fix is easy: add a fourth box that says "I don't know."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;You have shipped this bug before, in a system with no AI in it.&lt;/p&gt;

&lt;p&gt;A signup form asks for a country. The field is required, and the dropdown has no "Other" option. Users from a missing country still have to submit. So they pick the first item in the list. Months later, an analyst asks why so many users live in Afghanistan. The validation never failed once. It only made sure every answer was a legal one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structured output&lt;/strong&gt; has the same bug. You give the model a JSON schema, and the engine guarantees the response matches it. OpenAI, Gemini, vLLM, SGLang and llama.cpp all offer this. Teams switch it on to stop parse errors, and the parse errors stop.&lt;/p&gt;

&lt;p&gt;Then quieter failures start. FriendliAI ran Gemma 4 31B against a complex schema. The output filled with newlines until it hit the token limit, and the JSON never closed. In other pipelines, a field limited to three values gets one of the three, even for a document the model cannot read. The value looks as sure as any other.&lt;/p&gt;

&lt;p&gt;All of these come from one rule. The engine decides which tokens are legal. The model still chooses among them, and it must choose even when it wanted to say something else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix: give the model a legal way to say what it means, bound what it can repeat, and measure how much it was forced.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Give the Model a Legal Way Out
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How does the engine force valid JSON?
&lt;/h3&gt;

&lt;p&gt;A model writes one &lt;strong&gt;token&lt;/strong&gt; at a time, a word or a piece of one. At each step it gives every token in its vocabulary a probability. &lt;strong&gt;Constrained decoding&lt;/strong&gt; adds one step. The engine checks the grammar built from your schema, deletes every token that would break it, and rescales the rest to sum to one. Then it samples as usual.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl7s69khyerldvrkj79o7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl7s69khyerldvrkj79o7.png" alt="One token of a forced JSON field" width="800" height="241"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  So what happens when the model wanted an illegal token?
&lt;/h3&gt;

&lt;p&gt;Its wish is deleted, and the leftovers are scaled up. Take a field that must be &lt;code&gt;high&lt;/code&gt;, &lt;code&gt;medium&lt;/code&gt; or &lt;code&gt;low&lt;/code&gt;. On a clear invoice, those three hold 95% of the model's probability. On a blurry scan, they hold 15%, because the model wanted to write "unknown." After rescaling, the blurry scan still gets &lt;code&gt;high&lt;/code&gt; 40% of the time. Both outputs look the same.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does output sometimes fill with whitespace?
&lt;/h3&gt;

&lt;p&gt;JSON allows spaces and newlines between fields, so the grammar never deletes them. A little leftover whitespace cannot do this alone. Even at a 99% share each step, filling 2,048 tokens has odds of about one in a billion. The loop needs the model to be nearly certain at every step. That happens when repetition feeds itself: each newline makes the next more likely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the order of fields matter?
&lt;/h3&gt;

&lt;p&gt;The engine writes fields in the order the schema lists them, left to right. A &lt;code&gt;confidence&lt;/code&gt; value written before a &lt;code&gt;reasoning&lt;/code&gt; field cannot depend on that reasoning.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Means for You
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;When this matters.&lt;/strong&gt; You use JSON mode, a response schema or strict tool calls, and your code acts on a field's value. Agents do this constantly. A tool call is a schema the model must fill in. A routing step reads one enum. An extraction job writes rows to a database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it affects you.&lt;/strong&gt; Parse-error rates drop to zero, so dashboards built on them look healthy. The failures move into the values. A forced pick is a wrong row nobody flags. A whitespace loop is a timeout, or a bill for 2,048 tokens of nothing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F75z238dpl017xt9hecdn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F75z238dpl017xt9hecdn.png" alt="The blurry scan: what the model wanted vs. what it wrote" width="800" height="326"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the same bug as the country dropdown. Validation guarantees that every answer is a legal one. It says nothing about whether the model had an answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to do about it.&lt;/strong&gt; Today, open the schemas your code acts on. For every enum, ask what the model should say when it does not know. If there is no legal answer for that, add one. Next, put any reasoning field before the fields that depend on it. Whether that improves accuracy depends on the task and the model. It is cheap to test, so measure it on yours rather than assume. Then cap output length and treat a truncated response as a failure. If you host the model, switch off free whitespace and start logging how much probability the grammar removed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementing It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The change.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Four roles touch this: whoever writes the schema, whoever writes the prompt, whoever runs the server, and whoever reads the response.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Role 1, the schema.&lt;/em&gt; Give every field your code acts on a legal "I don't know." Put reasoning first. Bound free strings where your provider's schema subset allows &lt;code&gt;maxLength&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;reasoning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# written first, read by the fields below
&lt;/span&gt;    &lt;span class="n"&gt;vendor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# None is the legal "not on the page"
&lt;/span&gt;    &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lint every schema in CI with &lt;code&gt;check_schema()&lt;/code&gt; from &lt;a href="https://github.com/magna56/aI_daily_run/blob/main/2026-10-24/code_example.py" rel="noopener noreferrer"&gt;&lt;code&gt;code_example.py&lt;/code&gt;&lt;/a&gt;. It flags an enum with no way out, an unbounded string, and an answer written before its reasoning.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Role 2, the prompt.&lt;/em&gt; Include one filled-in example of the output. FriendliAI's whitespace loop went away when the prompt carried a concrete example. The mask only removes options; the example puts probability on the right ones. Use a real record, not placeholders. An example full of &lt;code&gt;"string"&lt;/code&gt; teaches the model to write &lt;code&gt;"string"&lt;/code&gt;. Show an &lt;code&gt;unknown&lt;/code&gt; case too, so the model sees that the way out is allowed.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Role 3, the server, if you host the model.&lt;/em&gt; vLLM can forbid whitespace between JSON fields, which removes the loop's open door:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vllm serve &amp;lt;model&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--structured-outputs-config&lt;/span&gt; &lt;span class="s1"&gt;'{"backend":"xgrammar","disable_any_whitespace":true}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works only with the &lt;code&gt;xgrammar&lt;/code&gt; and &lt;code&gt;guidance&lt;/code&gt; backends. With &lt;code&gt;transformers&lt;/code&gt;, wrap your grammar processor to record how much probability it removed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;LogitsProcessor&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;KeptMass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LogitsProcessor&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;grammar&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;                  &lt;span class="c1"&gt;# any processor that sets illegal tokens to -inf
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;grammar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kept&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;grammar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__call__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;softmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="o"&gt;=-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;grammar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clone&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isfinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Role 4, the caller.&lt;/em&gt; A cut-off response is a failure, not something to repair:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;msgs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;response_format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json_schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json_schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SCHEMA&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;choice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;finish_reason&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;length&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;TruncatedOutput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                &lt;span class="c1"&gt;# retry or route to review; never parse
&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_validate_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gemini reports the same case as &lt;code&gt;MAX_TOKENS&lt;/code&gt; in &lt;code&gt;finishReason&lt;/code&gt;. Count truncations per schema, because one bad schema hides inside a healthy average.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How you know it worked.&lt;/strong&gt; Three numbers per field, on a week of traffic. The truncation rate should be near zero. The &lt;code&gt;unknown&lt;/code&gt; rate should be above zero, and roughly match how often a human finds no answer in a sample. If it is exactly zero, the model is still being forced somewhere. If you host, look at kept mass on value tokens only. Structure tokens like &lt;code&gt;{&lt;/code&gt; lose mass on every request for harmless reasons. A value position that kept under half the model's probability is a guess worth reviewing.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Forcing JSON Is the Wrong Tool
&lt;/h2&gt;

&lt;p&gt;When the model needs room to think. A schema that starts on token one leaves no space for free reasoning. For hard tasks, let the model answer in plain text first. Then make a second, cheap call that formats the answer into JSON.&lt;/p&gt;

&lt;p&gt;When your schema is huge or changes per request. The engine compiles each new grammar before the first token. A cached schema costs little, but a new one per request adds delay to every first token. Grammar engines cache compiled schemas for this reason.&lt;/p&gt;

&lt;p&gt;When you use a small model on a hard schema. Forcing makes the output parse. It cannot make a weak model know the answer. Nested optional fields are where small models fill values with whatever is left.&lt;/p&gt;

&lt;p&gt;When the provider's schema subset cannot express your rules. Some keywords are silently unsupported or rejected. Check the response against the full schema in your own code anyway.&lt;/p&gt;

&lt;p&gt;Before you adopt it, ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What should each field say when the model does not know?&lt;/li&gt;
&lt;li&gt;Which fields does my code act on, and is anything written before its reasoning?&lt;/li&gt;
&lt;li&gt;How would I notice a value the model was forced into?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Glossary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;token&lt;/strong&gt; — a word or piece of a word; the model writes one at a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured output&lt;/strong&gt; — an API mode that guarantees the response matches a JSON schema you supply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Constrained decoding&lt;/strong&gt; — deleting the tokens a grammar forbids at each step, then sampling from what is left.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;enum&lt;/strong&gt; — a schema field limited to a fixed list of allowed values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;kept mass&lt;/strong&gt; — the share of the model's probability that survives the grammar's deletion at one position.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://friendli.ai/blog/structured-output" rel="noopener noreferrer"&gt;Structured Output Requires More Than Guided Generation&lt;/a&gt; — FriendliAI Tech &amp;amp; Research&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.vllm.ai/en/latest/api/vllm/config/structured_outputs/" rel="noopener noreferrer"&gt;vLLM: StructuredOutputsConfig&lt;/a&gt; — vLLM documentation&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://blog.mlc.ai/2024/11/22/achieving-efficient-flexible-portable-structured-generation-with-xgrammar" rel="noopener noreferrer"&gt;Achieving Efficient, Flexible, and Portable Structured Generation with XGrammar&lt;/a&gt; — MLC team (CMU and collaborators)&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://theaicommit.com/2026-10-24-why-forced-json-output-is-always-valid-and-sometimes-wrong/" rel="noopener noreferrer"&gt;theaicommit.com&lt;/a&gt;, a daily AI engineering lab for software engineers. The original has an &lt;a href="https://theaicommit.com/2026-10-24-why-forced-json-output-is-always-valid-and-sometimes-wrong/" rel="noopener noreferrer"&gt;interactive version&lt;/a&gt; you can play with, and the full runnable code is on &lt;a href="https://github.com/magna56/aI_daily_run/blob/main/2026-10-24/code_example.py" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>agents</category>
      <category>promptengineering</category>
    </item>
    <item>
      <title>Why Split Prefill and Decode GPUs Lose Time in the Hand-Off, Not the Network</title>
      <dc:creator>Mayur Agnani</dc:creator>
      <pubDate>Fri, 23 Oct 2026 12:01:50 +0000</pubDate>
      <link>https://dev.to/theaicommit/why-split-prefill-and-decode-gpus-lose-time-in-the-hand-off-not-the-network-386i</link>
      <guid>https://dev.to/theaicommit/why-split-prefill-and-decode-gpus-lose-time-in-the-hand-off-not-the-network-386i</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Engineer's view.&lt;/strong&gt; This is a Node.js server doing a synchronous parse on every upload. Each parse is quick, but it blocks the one loop every connection shares, so every response slows. With a fixed connection pool, slower responses then mean waiting for a free connection. The decode server's hand-off work behaves the same way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; When prompt processing and token generation run on separate GPUs, each request's cache must cross between them. The bytes are cheap. Setup work on the generation server's loop slows every token, and that can run the server out of slots.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Explain Like I'm 5
&lt;/h2&gt;

&lt;p&gt;A bakery has one person mixing dough and another baking it. The mixer hands each bowl to the baker. Passing a bowl takes a second. But this baker stops all the ovens to write a label for every new bowl. Each label is quick. When bowls arrive fast, the ovens are stopped so often that every loaf bakes slower. Soon the oven shelves are full, and new bowls wait in line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;You have shipped this bug before, in a system with no AI in it.&lt;/p&gt;

&lt;p&gt;An API reads from a database through a pool of 20 connections. A release adds a small synchronous step to each query. Every query now holds its connection a little longer. At normal traffic nobody notices. At peak, all 20 connections are busy, and requests wait in line for one. The step costs milliseconds. The wait costs seconds. Nobody finds it by looking at the database, because the database is fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disaggregated serving&lt;/strong&gt; has the same bug. It splits an LLM server in two. &lt;strong&gt;Prefill&lt;/strong&gt; GPUs read the whole prompt. &lt;strong&gt;Decode&lt;/strong&gt; GPUs then write the answer one token at a time. Splitting them stops long prompts from stalling other users' answers. In between, each request's &lt;strong&gt;KV cache&lt;/strong&gt;, the model's notes on the prompt, moves from one pool to the other.&lt;/p&gt;

&lt;p&gt;Teams blame the network when the first token is slow. The numbers say otherwise. vLLM measured Qwen3-32B on two eight-GPU H200 servers. At 8 requests per second with 16,000-token prompts, the first token took about 1,744 ms with the default hand-off. A different hand-off cut it to about 540 ms. Moving that prompt's cache takes about 10 ms. &lt;a href="https://theaicommit.com/#2026-10-11" rel="noopener noreferrer"&gt;How to Work Out How Much GPU Memory One Request Really Needs&lt;/a&gt; sized that cache.&lt;/p&gt;

&lt;p&gt;The lost second is setup work. Part of it runs on the decode server's main loop, where it pauses every request in progress.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix: keep hand-off work off the decode loop, and size decode slots with headroom. Then the split pays for itself.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Keep Hand-Off Work Off the Decode Loop
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why split prefill and decode at all?
&lt;/h3&gt;

&lt;p&gt;Prefill is limited by arithmetic. Decode is limited by memory reads. On one server, a long prefill delays every answer being written. Splitting them lets you tune first-token time and per-token time separately. vLLM's own guide is blunt about the cost: disaggregation does not improve throughput. It buys predictable latency.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much do the bytes actually cost?
&lt;/h3&gt;

&lt;p&gt;Very little. Qwen3-32B stores 256 KiB per token. A 16,000-token prompt is 4.2 GB. With tensor parallelism across 8 GPUs, each GPU sends its own slice over its own links. At 50 GB/s per GPU, that takes about 10 ms.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F65wl6lq0swjfs8rusjba.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F65wl6lq0swjfs8rusjba.png" alt="Where a request's first token waits" width="800" height="321"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Then where does the second go?
&lt;/h3&gt;

&lt;p&gt;Into two places. First, pull runs its steps in series after prefill. The router relays where the blocks are. Decode allocates space. Then decode reads the blocks. Second, decode does that setup on its main loop. That loop also produces every token for every request. While it posts transfer work, nobody gets a token.&lt;/p&gt;

&lt;p&gt;So the loop loses arrivals per second times setup time per request. A vLLM pull request measured 14.7 ms of posting per request at 2,000-token prompts. At 8 requests per second, the loop loses 12%. Moving that work to a thread took time per token from 6.14 ms to 5.52 ms.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does a slower token slow the first token?
&lt;/h3&gt;

&lt;p&gt;Because a request holds a decode slot until its answer ends. Slots held equals arrivals times output tokens times time per token. That is &lt;strong&gt;Little's law&lt;/strong&gt;. Past &lt;code&gt;--max-num-seqs&lt;/code&gt;, new requests wait for a slot before their cache can land.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does push change?
&lt;/h3&gt;

&lt;p&gt;Decode registers its free blocks as soon as the request arrives. Prefill writes straight into them from a background thread. Nothing waits after prefill, and nothing runs on the decode loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Means for You
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;When this matters.&lt;/strong&gt; You run prefill and decode on separate GPUs with vLLM, SGLang, Dynamo or llm-d. Your prompts are long, and your traffic is high.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it affects you.&lt;/strong&gt; Look at vLLM's 16,000-token run at 8 requests per second. Time per token rose from 7.0 ms with push to 10.4 ms with pull. With 128-token answers, that means 7.2 slots held with push and 10.6 with pull. The benchmark started its servers with &lt;code&gt;--max-num-seqs 8&lt;/code&gt;. So pull asked for more slots than existed. That turns a few milliseconds per token into a second of waiting.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1vjlk4jy9aen8v6wtxw2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1vjlk4jy9aen8v6wtxw2.png" alt="Decode slots held at 8 requests per second, 16k prompts" width="799" height="272"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the same arithmetic as the connection pool. A small per-request cost decides nothing until the pool is nearly full. Then it decides everything. It also explains why the two modes match at low load. At 1 request per second, even pull holds barely one slot. The setup still happens, but nobody is waiting behind it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to do about it.&lt;/strong&gt; Today, compute slots held for your own traffic at peak. Use your measured time per token, not the benchmark's. Keep it under about 80% of &lt;code&gt;--max-num-seqs&lt;/code&gt;. Then move hand-off work off the decode loop, and re-measure. If you cannot change the hand-off this quarter, raise &lt;code&gt;--max-num-seqs&lt;/code&gt; only if decode memory allows it. More slots need more KV cache space on every decode GPU.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementing It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The change.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three roles touch this. Whoever plans capacity sizes the decode pool. Whoever owns the serving config picks the hand-off. Whoever runs the load test proves it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Role 1, capacity.&lt;/em&gt; Compute the three numbers for your model and traffic. The first is usually small. The third is the one that bites:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;kv_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;layers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kv_heads&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;head_dim&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dtype_bytes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;layers&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;kv_heads&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;head_dim&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dtype_bytes&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;transfer_ms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nbytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;link_gb_s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;nbytes&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;tp&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;link_gb_s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1e9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;   &lt;span class="c1"&gt;# each GPU sends its slice
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;slots_held&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tpot_ms&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;qps&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;out_tokens&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;tpot_ms&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;         &lt;span class="c1"&gt;# Little's law
&lt;/span&gt;
&lt;span class="nf"&gt;transfer_ms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;kv_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 10.5 ms
&lt;/span&gt;&lt;span class="nf"&gt;slots_held&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;10.4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                             &lt;span class="c1"&gt;# 10.6, with 8 slots
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Take &lt;code&gt;tpot_ms&lt;/code&gt; from production at peak, not from a quiet benchmark. Use your real output length, too. Agents that write long answers hold slots far longer than chat does. A 2,000-token agent answer holds a slot sixteen times longer than a 128-token chat reply. So the same request rate needs sixteen times the slots.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Role 2, the serving config.&lt;/em&gt; The connector is a setting in &lt;code&gt;--kv-transfer-config&lt;/code&gt; on both servers. The vLLM post ran push with this, on the prefill side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vllm serve Qwen/Qwen3-32B &lt;span class="nt"&gt;--tensor-parallel-size&lt;/span&gt; 8 &lt;span class="nt"&gt;--max-num-seqs&lt;/span&gt; 8 &lt;span class="nt"&gt;--port&lt;/span&gt; 8100 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--kv-transfer-config&lt;/span&gt; &lt;span class="s1"&gt;'{"kv_connector":"NixlPushConnector","kv_buffer_device":"cuda",
    "kv_role":"kv_producer","engine_id":"prefill-engine-001","kv_rank":0,
    "kv_parallel_size":2,"kv_connector_extra_config":{"backends":["LIBFABRIC"]}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The decode server uses the same connector with &lt;code&gt;"kv_role":"kv_consumer"&lt;/code&gt;, as in vLLM's NIXL guide. Mixing modes crashes the server after the first request. Push also needs a proxy that knows the prefill server's address up front. The post uses &lt;code&gt;disagg_proxy_pushconnector_demo.py&lt;/code&gt;. Pull stays the right choice if you route each request to the least-loaded decode server after prefill. Push picks decode early, so it gives that up.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Role 3, measuring.&lt;/em&gt; Sweep request rate and prompt length together, because the gap only opens at the top of both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;qps &lt;span class="k"&gt;in &lt;/span&gt;1 2 4 8&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do for &lt;/span&gt;len &lt;span class="k"&gt;in &lt;/span&gt;1024 16384&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;vllm bench serve &lt;span class="nt"&gt;--model&lt;/span&gt; Qwen/Qwen3-32B &lt;span class="nt"&gt;--request-rate&lt;/span&gt; &lt;span class="nv"&gt;$qps&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--random-input-len&lt;/span&gt; &lt;span class="nv"&gt;$len&lt;/span&gt; &lt;span class="nt"&gt;--random-output-len&lt;/span&gt; 128 &lt;span class="nt"&gt;--num-prompts&lt;/span&gt; 400
&lt;span class="k"&gt;done&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How you know it worked.&lt;/strong&gt; Push and pull should match at low rate and short prompts. At the top of the sweep, time per token should stay near its light-load value. Watch &lt;code&gt;vllm:num_requests_waiting&lt;/code&gt; on the decode server. It should stay near zero at your peak rate. If it climbs, you are out of slots, whatever the hand-off. Then split first-token time into its parts. Compare the prefill server's own first-token time with the proxy's. The difference is the hand-off plus any wait for a slot. After the change, that difference should shrink most at long prompts and high rates.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Splitting Prefill and Decode Is the Wrong Tool
&lt;/h2&gt;

&lt;p&gt;When you want more throughput. Splitting does not add any. It trades GPUs for steadier latency.&lt;/p&gt;

&lt;p&gt;When your prompts are short. Prefill then barely disturbs decode, so there is little interference to remove. Chunked prefill on one server may be enough.&lt;/p&gt;

&lt;p&gt;When you have one GPU node. The hand-off then crosses no network, but you still pay for two pools and a proxy.&lt;/p&gt;

&lt;p&gt;When you need load-aware decode routing. Push chooses the decode server before prefill finishes. If your decode servers run unevenly, pull's late choice may be worth its setup cost.&lt;/p&gt;

&lt;p&gt;When the decode pool is already full. No hand-off fixes too few slots. Add decode capacity first, then tune the hand-off.&lt;/p&gt;

&lt;p&gt;Before you adopt it, ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How many decode slots does my peak traffic hold, by Little's law?&lt;/li&gt;
&lt;li&gt;Is my first-token time spent in prefill, in the hand-off, or in a queue?&lt;/li&gt;
&lt;li&gt;Do I need to route decode after prefill, or can I choose early?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Glossary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Disaggregated serving&lt;/strong&gt; — running prompt reading and answer writing on separate GPU pools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefill&lt;/strong&gt; — the step that reads the whole prompt and builds its cache.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decode&lt;/strong&gt; — the step that writes the answer, one token at a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;KV cache&lt;/strong&gt; — the model's stored notes on every token so far, kept on the GPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Little's law&lt;/strong&gt; — items in a system equal arrival rate times time each one stays.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;slot&lt;/strong&gt; — one of the requests a decode server can generate for at once.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://vllm.ai/blog/2026-10-14-nixl-push-connector" rel="noopener noreferrer"&gt;Push-Based KV Transfer to Improve TTFT and TPOT for Disaggregated Inference in vLLM&lt;/a&gt; — vLLM Blog (Sunita Nadampalli, AWS, and Nicolò Lucchesi, Mistral)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/vllm-project/vllm/pull/45211" rel="noopener noreferrer"&gt;vLLM pull request 45211: offload NIXL READ submission to a dedicated reader thread&lt;/a&gt; — vLLM project, GitHub&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://haoailab.com/blogs/distserve/" rel="noopener noreferrer"&gt;Throughput is Not All You Need: Maximizing Goodput in LLM Serving using Prefill-Decode Disaggregation&lt;/a&gt; — Hao AI Lab, UC San Diego (the DistServe authors)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.vllm.ai/en/latest/features/disagg_prefill/" rel="noopener noreferrer"&gt;vLLM: Disaggregated Prefilling&lt;/a&gt; — vLLM documentation&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://theaicommit.com/2026-10-23-why-split-prefill-and-decode-gpus-lose-time-in-the-hand-off/" rel="noopener noreferrer"&gt;theaicommit.com&lt;/a&gt;, a daily AI engineering lab for software engineers. The original has an &lt;a href="https://theaicommit.com/2026-10-23-why-split-prefill-and-decode-gpus-lose-time-in-the-hand-off/" rel="noopener noreferrer"&gt;interactive version&lt;/a&gt; you can play with, and the full runnable code is on &lt;a href="https://github.com/magna56/aI_daily_run/blob/main/2026-10-23/code_example.py" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>performance</category>
      <category>devops</category>
    </item>
    <item>
      <title>Your AI app might be losing money on its heaviest users. Here's how to check.</title>
      <dc:creator>Mayur Agnani</dc:creator>
      <pubDate>Fri, 23 Oct 2026 00:01:45 +0000</pubDate>
      <link>https://dev.to/theaicommit/your-ai-app-might-be-losing-money-on-its-heaviest-users-heres-how-to-check-4150</link>
      <guid>https://dev.to/theaicommit/your-ai-app-might-be-losing-money-on-its-heaviest-users-heres-how-to-check-4150</guid>
      <description>&lt;p&gt;If you sell an AI app by subscription, your revenue per user is flat and your cost per user is not.&lt;/p&gt;

&lt;p&gt;A light user costs you a few cents a month. A heavy one, or a user stuck in an agent loop, can cost more than they pay you. Your OpenAI bill shows tokens. Your payments dashboard shows revenue. Nothing joins the two by user, so you find out from the invoice.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://github.com/magna56/fiscallm" rel="noopener noreferrer"&gt;FiscaLLM&lt;/a&gt; to answer one question: &lt;strong&gt;which users cost me more than they pay?&lt;/strong&gt; Here's the idea and how to try it in about ten minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you need
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;A backend between your app and the AI provider (so apps that call OpenAI straight from the phone aren't covered yet).&lt;/li&gt;
&lt;li&gt;A way to know what each user pays: RevenueCat, Stripe (beta), or your own code.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  1. Report usage after each AI call
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createMeter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fiscallm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// npm install fiscallm  (Python: pip install fiscallm)&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;meter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createMeter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FISCALLM_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;waitUntil&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;meter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trackOpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chat&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SDK reads only the model name and token counts. It never sees prompts or answers. Events are batched in the background, so it doesn't add latency to your request, and if FiscaLLM is down your app is unaffected.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Connect revenue
&lt;/h2&gt;

&lt;p&gt;Paste a RevenueCat or Stripe webhook URL, or send payments yourself with the revenue API. FiscaLLM joins revenue and cost on the user ID.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Look at margin per user
&lt;/h2&gt;

&lt;p&gt;You get margin per user, per plan and per free trial, plus which model or feature drives the bill. Then you can act:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Budget alerts by email, Slack or webhook when a user goes over.&lt;/li&gt;
&lt;li&gt;A runaway-loop alert for users whose usage suddenly explodes.&lt;/li&gt;
&lt;li&gt;Optional caps: mark a budget as Block and call &lt;code&gt;meter.check(userId)&lt;/code&gt; before an AI call. It fails open, so an outage never blocks your users.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A few details that mattered
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No double counting.&lt;/strong&gt; Events carry client-generated UUIDs, so retries are safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cached tokens are priced separately&lt;/strong&gt; from normal input tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unknown models are flagged as unpriced&lt;/strong&gt;, not silently counted as zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RevenueCat aliases are resolved&lt;/strong&gt;, so a user's anonymous and logged-in IDs merge.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Stripe support is beta. I've tested it on sample payloads only, and it's USD only.&lt;/li&gt;
&lt;li&gt;Caps use cost already recorded, so a user can go slightly over before being refused.&lt;/li&gt;
&lt;li&gt;Built-in prices are checked against provider pages (&lt;a href="https://github.com/magna56/fiscallm/blob/main/ACCURACY.md" rel="noopener noreferrer"&gt;ACCURACY.md&lt;/a&gt;), but I haven't compared them with a real invoice over weeks yet.&lt;/li&gt;
&lt;li&gt;No customers yet. The demo dashboard uses sample data.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Live demo, no sign-up: &lt;a href="https://fiscallm.com/demo?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=launch" rel="noopener noreferrer"&gt;https://fiscallm.com/demo?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=launch&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Code (MIT, TypeScript and Python SDKs plus the server): &lt;a href="https://github.com/magna56/fiscallm" rel="noopener noreferrer"&gt;https://github.com/magna56/fiscallm&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Self-hosting guide: &lt;code&gt;SELF-HOSTING.md&lt;/code&gt; in the repo, so your data never touches a third party's servers. Or use the free hosted version.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'm looking for the first 10 apps to try it. If you ship an AI app with subscriptions, tell me what's missing in the comments or on the &lt;a href="https://github.com/users/magna56/projects/1" rel="noopener noreferrer"&gt;roadmap board&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>typescript</category>
      <category>startup</category>
    </item>
  </channel>
</rss>
