<?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: jidonglab</title>
    <description>The latest articles on DEV Community by jidonglab (@ji_ai).</description>
    <link>https://dev.to/ji_ai</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%2F3791767%2F6eb19afc-a99c-4736-9d12-459108893a16.png</url>
      <title>DEV Community: jidonglab</title>
      <link>https://dev.to/ji_ai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ji_ai"/>
    <language>en</language>
    <item>
      <title>A Local-First Plan, Then 4 Days of Scope Creep</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Wed, 14 Oct 2026 18:08:23 +0000</pubDate>
      <link>https://dev.to/ji_ai/a-local-first-plan-then-4-days-of-scope-creep-400o</link>
      <guid>https://dev.to/ji_ai/a-local-first-plan-then-4-days-of-scope-creep-400o</guid>
      <description>&lt;p&gt;The product plan my agent team wrote on September 21, 2026 had a rule in it: never tell users their data stays on their PC. By September 25 the same project had guestbooks, gifts and a cosmetics gacha with 266 items.&lt;/p&gt;

&lt;p&gt;This is post 2 of "Building Refmade". Refmade is an independent desktop app (not affiliated with Anthropic or OpenAI) that draws your Claude Code and Codex sessions as a pixel-art office. Post 1 covered the agent team I couldn't see and the dashboard that went empty whenever an agent skipped its status report. Claude Code and Codex agents wrote most of the code and documents here at my direction; I made the calls and reviewed the results.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plan was good
&lt;/h2&gt;

&lt;p&gt;On September 21 I asked for a plan for a customer-facing version: other people install it locally, watch their agents on the web, and maybe send work to their own CLI from the web. My agents produced a plan, an architecture note, a user guide, a logic review, a source list, a verification list and two PDFs.&lt;/p&gt;

&lt;p&gt;Two sentences in it still look right to me.&lt;/p&gt;

&lt;p&gt;The first says the product must not tell users that their data never leaves the PC. A local CLI sends prompts and file contents to a model provider, so that promise would be false the first time someone ran a task. The second says the service must not pool customers' subscription tokens to run other people's work. The customer's PC opens an outbound connection to the server, and the server never exposes anything on the local machine.&lt;/p&gt;

&lt;p&gt;I also decided early that the office is private by default, and that letting others visit it and showing task content are two separate switches. Price and launch date were left open, and the plan says in plain words that it grants no permission to sell.&lt;/p&gt;

&lt;p&gt;The first-run screen still follows the first rule. Here is the preview-mode screen from the September 29 build.&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%2F3sani3exlmr2hi4zjcnb.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%2F3sani3exlmr2hi4zjcnb.png" alt="Refmade first-run screen with two cards, Claude Code ready and Codex needing login, and a list of notes about what is sent where" width="800" height="597"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Refmade first-run screen, preview mode, a late-September 0.2.x build, UI in Korean. Title: "I'll get the AI ready first". Cards: "Claude Code: ready" and "Codex: login needed". Notes below: the app itself sends nothing to the internet; when you hand over a job, the Claude Code or Codex on this computer sends the request and needed file contents to Anthropic, OpenAI or a provider you connected, and Claude Code may also contact Anthropic when the app checks readiness; login is managed by each AI tool; the work counts against your own plan; AI answers can be wrong.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That caption has an expiry date. This build said the app sends nothing itself. Later builds added a version check against my server and an ad-board request, and the current first-run screen says so in its first note. The note about what Claude Code and Codex send is still there. I measure what actually leaves the machine in a later post.&lt;/p&gt;

&lt;p&gt;I also had a naming guide written for the Claude and Codex references the app needs. Product names and domains don't contain them, and there are no logos or look-alike mascots. The guide cites the official brand and usage pages and I designed around it. That's my reading, not legal advice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the plan grew
&lt;/h2&gt;

&lt;p&gt;What nobody wrote was a list of things the product would not do. Between September 22 and 25 the following got added, each one a reasonable step from the one before.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Added Sept 22 to 25&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Standalone dashboard mode&lt;/td&gt;
&lt;td&gt;PC pairing; only an anonymous ID, state and timestamps are sent; three sharing settings default to off&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web requests to a local CLI&lt;/td&gt;
&lt;td&gt;natural-language request that can edit up to 8 tracked documents or code files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Social layer&lt;/td&gt;
&lt;td&gt;mini home pages, guestbooks, gifts, decorating&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cosmetics gacha&lt;/td&gt;
&lt;td&gt;266 items; rarity D/C/B/A/S at 50/30/15/4/1 percent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two "world" layouts&lt;/td&gt;
&lt;td&gt;a token pyramid and a construction-site village&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Languages&lt;/td&gt;
&lt;td&gt;Korean, Japanese, Chinese and English UI, with automatic post translation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The plan itself recommended visits, gifts and decorating for the first beta, so the drift was partly written into the document I had asked for. The gacha plan file lists its rarity odds as approved on September 21, the day of the plan. As of the September 24 report none of it was deployed. It ran locally and lived in review screenshots.&lt;/p&gt;

&lt;p&gt;The September 24 report counted 62 confirmed defects dropping to 0, 104 final screens (13 screens, 4 languages, 2 widths), and passing test suites in the hundreds. Those numbers show how far agents can polish a feature that nobody has decided to keep. On September 25 I stopped and asked for a redesign from scratch. That's post 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  The race I reproduced on a real database
&lt;/h2&gt;

&lt;p&gt;One piece of that week is worth keeping on its own. The remote-execution feature had a setting to turn execution off. I wanted proof that "off" meant off, and unit tests against a mock couldn't show that.&lt;/p&gt;

&lt;p&gt;On September 24 I had my agents start a throwaway PostgreSQL 17.10 cluster on a random loopback port, apply the three SQL files, and use only synthetic users and devices. Production was never touched. Seven checks ran on independent connections. Four passed, two reproduced a race, and one couldn't be judged.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A: BEGIN; lock device row; set execution = off      (not committed yet)
B: queue a task -&amp;gt; wrapper reads execution = on     (no lock taken)
B: waits for A's row lock (pg_stat_activity: Lock / transactionid)
A: COMMIT
B: resumes -&amp;gt; inserts a queued task                 &amp;lt;- should be refused
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Simplified timeline of the two failing checks, from the September 24 test report.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The wrapper functions read the setting without locking, then called an original function that took the row lock. "Off" was committed first, and a new task (or a new lease, in the claim variant) appeared anyway. The report doesn't call this a strict ordering violation, because B started while A's change was still in flight. It does say what happened: after the off-switch was committed, new work was still created. The fix was to take &lt;a href="https://www.postgresql.org/docs/17/explicit-locking.html" rel="noopener noreferrer"&gt;&lt;code&gt;FOR UPDATE&lt;/code&gt;&lt;/a&gt; on the device row first and read the setting afterward, inside one transaction. I had the two wrappers changed and reran the same seven checks on a fresh throwaway database: 7 of 7 passed. The unfixed SQL under the same harness still reproduced both races. Nothing was applied to production.&lt;/p&gt;

&lt;p&gt;The seventh check taught me about my own tooling. The harness updated a table directly as a service role, which the schema rightly forbids, and got "permission denied". That was a test bug, so the report left the check as "not determined" instead of counting it either way. In the rerun it calls the product's own revoke function instead. An earlier failure was also a harness bug: it skipped the "started" state and sent "completed" straight away. And the real Claude delegation test stopped after four calls on a token budget, which I recorded as &lt;code&gt;stopped&lt;/code&gt;, not as a pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd change
&lt;/h2&gt;

&lt;p&gt;The plan was the right document. It had no section called "not in scope". An agent will happily build the next adjacent thing, and a 266-item gacha is adjacent to a guestbook, which is adjacent to a profile page. The September 25 redesign came with exactly that: a written list of what to remove.&lt;/p&gt;

&lt;p&gt;Most of the week is gone now. Today's app has no social layer, no gacha, no worlds and no web-to-PC execution; the code sits in an archive folder. What survived is the first rule and an office that is local-only.&lt;/p&gt;

&lt;p&gt;Do you keep a written "not in scope" list for agent-built projects, and who is allowed to overrule it?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>postgres</category>
      <category>productivity</category>
    </item>
    <item>
      <title>GitHub Actions fetch-depth: 1 Made My CI Skip Tests for 41 PRs</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Tue, 13 Oct 2026 17:01:25 +0000</pubDate>
      <link>https://dev.to/ji_ai/github-actions-fetch-depth-1-made-my-ci-skip-tests-for-41-prs-ebc</link>
      <guid>https://dev.to/ji_ai/github-actions-fetch-depth-1-made-my-ci-skip-tests-for-41-prs-ebc</guid>
      <description>&lt;p&gt;A PR broke &lt;code&gt;main&lt;/code&gt; on a Tuesday. CI had been green. I opened the run log and found the test job had finished in 4 seconds, with one line of output: &lt;code&gt;No code changes, skipping tests&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The PR changed 14 files in &lt;code&gt;src/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I scrolled back through the history. For three weeks, 41 pull requests had skipped the test suite. Every one of them was green. The cause was one default in &lt;code&gt;actions/checkout&lt;/code&gt;: &lt;strong&gt;&lt;code&gt;fetch-depth: 1&lt;/code&gt;&lt;/strong&gt;, plus a &lt;code&gt;|| true&lt;/code&gt; I had written myself and felt clever about.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;actions/checkout&lt;/code&gt; defaults to &lt;code&gt;fetch-depth: 1&lt;/code&gt;. You get one commit, with no parents, no other branches and no tags.&lt;/li&gt;
&lt;li&gt;That means &lt;code&gt;git diff origin/main...HEAD&lt;/code&gt;, &lt;code&gt;git merge-base&lt;/code&gt;, &lt;code&gt;git describe&lt;/code&gt;, &lt;code&gt;HEAD~1&lt;/code&gt; and &lt;code&gt;git rev-list --count HEAD&lt;/code&gt; all fail or return the wrong answer.&lt;/li&gt;
&lt;li&gt;If your step has no &lt;code&gt;shell:&lt;/code&gt; key, GitHub runs it with &lt;code&gt;bash -e {0}&lt;/code&gt;, which has &lt;strong&gt;no &lt;code&gt;pipefail&lt;/code&gt;&lt;/strong&gt;. Add a &lt;code&gt;|| true&lt;/code&gt; and a fatal git error turns into an empty string.&lt;/li&gt;
&lt;li&gt;Empty string means "nothing changed", so the tests get skipped and the job still passes.&lt;/li&gt;
&lt;li&gt;Fix: use &lt;code&gt;fetch-depth: 0&lt;/code&gt;, or on &lt;code&gt;pull_request&lt;/code&gt; use &lt;code&gt;fetch-depth: 2&lt;/code&gt; with &lt;code&gt;git diff HEAD^1 HEAD&lt;/code&gt;. Then make git failures stop the job.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does fetch-depth: 1 actually fetch in actions/checkout?
&lt;/h2&gt;

&lt;p&gt;It fetches exactly one commit: the one the workflow runs on. There are no parent commits, no remote branches besides the one checked out, and no tags. The result is a shallow repository whose history is a single node.&lt;/p&gt;

&lt;p&gt;On a &lt;code&gt;pull_request&lt;/code&gt; event that commit isn't even your branch tip. It's the merge commit GitHub creates at &lt;code&gt;refs/pull/&amp;lt;N&amp;gt;/merge&lt;/code&gt;, which merges your PR head into the current base. That detail matters for the fix later.&lt;/p&gt;

&lt;p&gt;You can see it for yourself with a throwaway workflow:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shallow-check&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pull_request&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;look&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="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;git rev-parse --is-shallow-repository&lt;/span&gt;
          &lt;span class="s"&gt;git log --oneline | wc -l&lt;/span&gt;
          &lt;span class="s"&gt;git branch -a&lt;/span&gt;
          &lt;span class="s"&gt;git tag | wc -l&lt;/span&gt;
          &lt;span class="s"&gt;git rev-parse HEAD~1 || echo "no parent"&lt;/span&gt;
          &lt;span class="s"&gt;git rev-parse origin/main || echo "no origin/main"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a repo with years of history, mine printed &lt;code&gt;true&lt;/code&gt;, &lt;code&gt;1&lt;/code&gt;, a single remote ref for the PR, &lt;code&gt;0&lt;/code&gt; tags, and both fallback messages. One commit is the whole repo as far as git knows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did my changed-files check return nothing instead of failing?
&lt;/h2&gt;

&lt;p&gt;The git command did fail, loudly, in stderr. The shell just never treated that as a failure. Here's the step I had written, which I'd bet a lot of monorepos have some version of:&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="pi"&gt;-&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;Detect code changes&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;changes&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;CHANGED=$(git diff --name-only origin/main...HEAD | grep -E '^(src|tests)/' || true)&lt;/span&gt;
    &lt;span class="s"&gt;if [ -z "$CHANGED" ]; then&lt;/span&gt;
      &lt;span class="s"&gt;echo "No code changes, skipping tests"&lt;/span&gt;
      &lt;span class="s"&gt;echo "skip=true" &amp;gt;&amp;gt; "$GITHUB_OUTPUT"&lt;/span&gt;
    &lt;span class="s"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things went wrong at the same time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;origin/main&lt;/code&gt; doesn't exist.&lt;/strong&gt; With a depth-1 checkout there's no &lt;code&gt;origin/main&lt;/code&gt; ref, so git prints &lt;code&gt;fatal: ambiguous argument 'origin/main...HEAD': unknown revision or path not in the working tree&lt;/code&gt; and exits 128.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. No &lt;code&gt;pipefail&lt;/code&gt;.&lt;/strong&gt; If a &lt;code&gt;run:&lt;/code&gt; step doesn't set &lt;code&gt;shell:&lt;/code&gt;, GitHub Actions on Linux runs it as &lt;code&gt;bash -e {0}&lt;/code&gt;. That's errexit without pipefail. A pipeline's exit status is the status of its &lt;strong&gt;last&lt;/strong&gt; command, so the pipeline's result came from &lt;code&gt;grep&lt;/code&gt;, not from &lt;code&gt;git&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The &lt;code&gt;|| true&lt;/code&gt;.&lt;/strong&gt; I added it because &lt;code&gt;grep&lt;/code&gt; exits 1 when nothing matches, and "a docs-only PR" is a normal case I didn't want to fail. But &lt;code&gt;|| true&lt;/code&gt; doesn't care why the command failed. It swallowed git's fatal error together with grep's no-match.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;CHANGED&lt;/code&gt; came back empty. Empty means skip. The fatal message was right there in the log, gray text above a green checkmark, and I never read it, because nobody reads the logs of passing jobs.&lt;/p&gt;

&lt;p&gt;The nasty part is that this works fine on your laptop. Your local clone has &lt;code&gt;origin/main&lt;/code&gt; and full history. It only breaks on the runner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which git commands break in a shallow GitHub Actions checkout?
&lt;/h2&gt;

&lt;p&gt;Pretty much anything that needs history, a second branch, or tags. Here's what I found when I went looking through my other workflows:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;What happens at depth 1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git diff origin/main...HEAD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;fatal: unknown revision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git merge-base origin/main HEAD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;fails, no base ref&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git diff HEAD~1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;fatal: unknown revision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git describe --tags&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;fatal: No names found&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git rev-list --count HEAD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;returns &lt;code&gt;1&lt;/code&gt;, so build numbers reset to 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;git log v1.2.0..HEAD&lt;/code&gt; (changelog)&lt;/td&gt;
&lt;td&gt;fails, tag not present&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;git diff ${{ github.event.before }}&lt;/code&gt; on push&lt;/td&gt;
&lt;td&gt;fails, the before commit isn't fetched&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;rev-list --count&lt;/code&gt; one is my favorite because it doesn't even error. It just quietly says your project has one commit. Any tool that builds a version string from tags or commit counts will fall back to some default version, and you'll ship artifacts labeled like a first release.&lt;/p&gt;

&lt;p&gt;Affected-only test runners in monorepo tools hit the same wall when they compare against a base branch. If yours has a "fall back to running nothing" mode, check it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I fix fetch-depth: 1 for git diff in GitHub Actions?
&lt;/h2&gt;

&lt;p&gt;You've got three workable options. Pick based on what you actually need from history.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 1: &lt;code&gt;fetch-depth: 0&lt;/code&gt; (full history)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&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="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This fetches all history, all branches and all tags. &lt;code&gt;origin/main...HEAD&lt;/code&gt;, &lt;code&gt;git describe&lt;/code&gt; and changelog generation all work like they do locally. It's the right choice when you need tags or versioning.&lt;/p&gt;

&lt;p&gt;The cost is clone time, and it grows with the repo. On my repo (around 6,000 commits) it added roughly 8 seconds. On a huge monorepo with a decade of history it can be a lot more, and you pay it on every job that checks out code. Only set it on the jobs that need it, not across the board.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 2: &lt;code&gt;fetch-depth: 2&lt;/code&gt; with the merge commit (PRs only)
&lt;/h3&gt;

&lt;p&gt;This is the cheap and exact option for "what did this PR change", and it's the one I use now.&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="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="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&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;git diff --name-only HEAD^1 HEAD&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works because of the merge-commit detail from earlier. On &lt;code&gt;pull_request&lt;/code&gt;, &lt;code&gt;HEAD&lt;/code&gt; is the merge of your PR into the base. Its first parent, &lt;code&gt;HEAD^1&lt;/code&gt;, is the tip of the base branch. Depth 2 fetches the merge commit plus its parents, so &lt;code&gt;HEAD^1 HEAD&lt;/code&gt; is exactly the set of changes the PR brings into base.&lt;/p&gt;

&lt;p&gt;Two caveats. This only holds on the default &lt;code&gt;pull_request&lt;/code&gt; checkout of the merge ref. If you check out &lt;code&gt;github.event.pull_request.head.sha&lt;/code&gt; instead, &lt;code&gt;HEAD^1&lt;/code&gt; is just the previous commit on your branch. And it doesn't work for &lt;code&gt;push&lt;/code&gt; events at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 3: fetch the base branch yourself
&lt;/h3&gt;

&lt;p&gt;If you need a true merge-base on a deep branch but don't want full history:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git fetch &lt;span class="nt"&gt;--no-tags&lt;/span&gt; &lt;span class="nt"&gt;--depth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;200 origin &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GITHUB_BASE_REF&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
git merge-base &lt;span class="s2"&gt;"origin/&lt;/span&gt;&lt;span class="nv"&gt;$GITHUB_BASE_REF&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; HEAD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This still fails if the branch split off more than 200 commits ago. You can loop with &lt;code&gt;git fetch --deepen=200&lt;/code&gt; until &lt;code&gt;merge-base&lt;/code&gt; succeeds, but at that point you're writing a clone strategy, and Option 1 is usually simpler.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I make git failures fail the job instead of passing?
&lt;/h2&gt;

&lt;p&gt;Changing the depth fixes today's bug. Making the step loud fixes the next one. I added three guards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set &lt;code&gt;shell: bash&lt;/code&gt; explicitly.&lt;/strong&gt; With an explicit &lt;code&gt;shell: bash&lt;/code&gt;, GitHub runs &lt;code&gt;bash --noprofile --norc -eo pipefail {0}&lt;/code&gt;. Same shell, but now pipefail is on. You can set it once for the whole workflow:&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;defaults&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="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Split git from grep.&lt;/strong&gt; Let git fail on its own line, and only tolerate grep's no-match:&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="nv"&gt;FILES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git diff &lt;span class="nt"&gt;--name-only&lt;/span&gt; HEAD^1 HEAD&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;CHANGED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FILES&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^(src|tests)/'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;git diff&lt;/code&gt; fails now, errexit kills the step right there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assert the ref exists before diffing:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git rev-parse &lt;span class="nt"&gt;--verify&lt;/span&gt; &lt;span class="nt"&gt;--quiet&lt;/span&gt; HEAD^1 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"::error::Base commit missing. Check fetch-depth."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After this, I broke it on purpose by setting the depth back to 1. The job went red on the first line with a message telling me exactly what to fix. That's the behavior I'd wanted all along.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should "skip tests" ever be the default path?
&lt;/h2&gt;

&lt;p&gt;My actual mistake wasn't the depth. It was designing a check where "I couldn't figure out what changed" and "nothing changed" produced the same output. When detection is uncertain, run everything. A wasted 6-minute test run costs you 6 minutes. A skipped one cost me 41 unverified PRs and a broken &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If your workflow decides to skip work, it should be able to prove it has nothing to do. An empty string isn't proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  So why does GitHub Actions fetch-depth: 1 break git diff?
&lt;/h2&gt;

&lt;p&gt;Because &lt;code&gt;actions/checkout&lt;/code&gt; with the default &lt;code&gt;fetch-depth: 1&lt;/code&gt; clones a single commit with no parents, no other branches and no tags, so any git command that compares against &lt;code&gt;origin/main&lt;/code&gt;, &lt;code&gt;HEAD~1&lt;/code&gt; or a tag has nothing to compare against and exits with a fatal error. In a &lt;code&gt;run:&lt;/code&gt; step without an explicit &lt;code&gt;shell:&lt;/code&gt;, bash runs without &lt;code&gt;pipefail&lt;/code&gt;, so a &lt;code&gt;| grep ... || true&lt;/code&gt; pipeline turns that error into an empty result, and a changed-files check reads it as "nothing changed". Use &lt;code&gt;fetch-depth: 0&lt;/code&gt; when you need tags or full history, use &lt;code&gt;fetch-depth: 2&lt;/code&gt; with &lt;code&gt;git diff HEAD^1 HEAD&lt;/code&gt; for pull request diffs, set &lt;code&gt;shell: bash&lt;/code&gt; so pipefail is on, and make any skip logic fail closed.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>tooling</category>
      <category>tutorial</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I Built an Agent Team, Then Couldn't See What It Did</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Mon, 12 Oct 2026 21:49:53 +0000</pubDate>
      <link>https://dev.to/ji_ai/i-built-an-agent-team-then-couldnt-see-what-it-did-11fk</link>
      <guid>https://dev.to/ji_ai/i-built-an-agent-team-then-couldnt-see-what-it-did-11fk</guid>
      <description>&lt;p&gt;The first files of my agent team were written at 22:47 on September 20, 2026. By the next day I had a web dashboard to watch it. The dashboard was only as full as the agents were diligent: when one skipped its status report, that part of the office was empty.&lt;/p&gt;

&lt;p&gt;This is the first of 16 posts about building Refmade, one every two days. Refmade is a desktop app that reads the session logs Claude Code and Codex already write on your machine and draws them as a small pixel-art office, so you can see which agent is doing what. It's an independent app, not affiliated with Anthropic or OpenAI. I'm a solo developer based in Korea, and I should say up front how the work is split: Claude Code and Codex agents wrote most of the code at my direction. I planned, decided and reviewed. The posts are about the decisions, the reversals and the bugs.&lt;/p&gt;

&lt;p&gt;The series follows the real order of events. The next few posts cover a product plan that grew out of control, the switch to reading logs, and a map of how Claude Code and Codex write those logs. Later ones cover shipping a Mac app without notarization, a first Git commit that arrived far too late, and a gate that stopped a broken release. Failures get as much space as launches.&lt;/p&gt;

&lt;p&gt;The app's UI is Korean right now. That changes later in this series, so every screenshot has an English caption.&lt;/p&gt;

&lt;h2&gt;
  
  
  The name belonged to a dead project
&lt;/h2&gt;

&lt;p&gt;"Refmade" is older than the app. From March 20 to July 2, 2026, Refmade was a different product: a web design reference catalog with a gallery front end. It was a Next.js app with about 83 references, 131 commits and a payment integration wired in. One April 1 commit fixed a mobile out-of-memory crash in the gallery.&lt;/p&gt;

&lt;p&gt;Then I stopped working on it. On August 7 I cleaned up my hosting account and cut 29 projects down to 12. The old Refmade project survived for one reason: a custom domain was attached to it, so I left it out of the deletion. About six weeks later, on September 21, that domain was pointed at the new office dashboard.&lt;/p&gt;

&lt;p&gt;None of the gallery's code carried over. The new app started fresh on September 20. What carried over was a name and a domain that had been spared by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The team I couldn't see
&lt;/h2&gt;

&lt;p&gt;The September 20 files were not a product. They were a personal operating setup for Codex: eleven subagent definitions (PM, product, business, marketing, design, Figma, engineering, implementation, research, data analysis and review, with the team lead being the main conversation) plus a short README that says it was made at my request as a personal operating setup. I had a team. I could not tell what it was doing.&lt;/p&gt;

&lt;p&gt;With several agents running in separate terminals, the question "who is working on what, and who is waiting for me?" had no good answer. So on September 21 I asked Codex for an "office": a web dashboard with one floor per task, each person's current job, the next step, what they're waiting for, meetings and records. Codex built it, and I reviewed the screens and sent back changes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;v1 office (Sept 21)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Runs at&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;127.0.0.1:4318&lt;/code&gt; on my Mac&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remote view&lt;/td&gt;
&lt;td&gt;password-protected page through a quick tunnel; works only while the Mac and the bridge are running&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refresh&lt;/td&gt;
&lt;td&gt;every 5 seconds, paused in hidden tabs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Art&lt;/td&gt;
&lt;td&gt;pixel-art floors, shelves and 12 seated characters, drawn with GPT Image&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checks passed&lt;/td&gt;
&lt;td&gt;25 Python tests and 16 Node auth/deploy tests&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The remote view was not a hosted service. A quick tunnel exposed my Mac's state read-only, so with the Mac off the office was unreachable. That was fine for a tool only I used. It also meant the first version of "see my agents from anywhere" was really "see my agents while my laptop is on".&lt;/p&gt;

&lt;p&gt;The pixel art worked as intended. The data model is where it failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it broke
&lt;/h2&gt;

&lt;p&gt;The first version asked each agent to record its own state. A small recorder script wrote those reports to files and the dashboard drew them. A separate observer could tell that a session window was open, but it listed those on their own layer, with no role, next step or meeting attached.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;agent -&amp;gt; writes status report -&amp;gt; file -&amp;gt; dashboard -&amp;gt; "office"
              |
              +-- skips the report -&amp;gt; nothing to draw -&amp;gt; empty floor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An agent in the middle of a long task has no particular reason to stop and write a status note. When it didn't, that agent had nothing on the floor, and a floor with nobody on it looks like an idle team. The dashboard could not tell "nobody is working" from "nobody reported".&lt;/p&gt;

&lt;p&gt;On September 21 the dashboard looked right, the tests passed and the screens matched the design. By September 25 I had decided the approach itself was wrong. How I replaced self-reports with log reading is the subject of post 3.&lt;/p&gt;

&lt;p&gt;The lesson I took was narrow. A dashboard that depends on the observed system describing itself will be wrong in exactly the moments you most want it right. Anything I could read without the agent's cooperation was more trustworthy than anything the agent chose to tell me.&lt;/p&gt;

&lt;p&gt;There was a smaller problem in the same dashboard: the logo's white background stuck out of the sign. I kept the original logo file untouched and fixed its position and blend mode inside the sign.&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%2Fn0qu445mp1xqwadcvk2x.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%2Fn0qu445mp1xqwadcvk2x.png" alt="Isometric pixel-art office with five characters, desks, a whiteboard with sticky notes and plants" width="800" height="545"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;An illustration generated with Codex (gpt-image-2) for an earlier version of the product site: five characters, desks and a whiteboard with sticky notes. It is AI-generated art, not an app screenshot.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the office looks like today
&lt;/h2&gt;

&lt;p&gt;The current app draws a team per session from local logs instead of from reports. Here is a crop of version 0.3.9 with three teams.&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%2Fpeoalbpyy97kpeubub8i.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%2Fpeoalbpyy97kpeubub8i.png" alt="Three team cards from the Refmade office view, each with pixel-art characters at desks" width="800" height="330"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Refmade 0.3.9, public demo data, UI in Korean. Left: area "Neighborhood workshop" (Claude, 1 team), task "Improve homepage booking screen", working with 2 art-team members. Middle and right: the two teams of area "Spring-light cafe" (Codex). Middle: task "Prepare new-menu promotion", working with 3 marketing-team members. Right: task "Sort customer-inquiry replies", marked "I need your answer". The workshop and cafe names are invented sample data.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands today
&lt;/h2&gt;

&lt;p&gt;Refmade today runs on Mac (Apple Silicon) and Windows, it is free, and it needs Claude Code or Codex installed and logged in on your machine. Version 0.3.9 is the current Mac build. The later posts cover how each of those pieces got there, including the parts that went wrong.&lt;/p&gt;

&lt;p&gt;The app link comes in the last post of the series.&lt;/p&gt;

&lt;p&gt;Next up is the product plan my agent team wrote on September 21, and the days in which it grew guestbooks and a gacha.&lt;/p&gt;

&lt;p&gt;If your agents run in separate terminals or sessions, how do you currently know which one is stuck waiting for you?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>codex</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Sync Anthropic Client Blocked My asyncio Loop: Voice p95 Hit 11.4s</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Mon, 12 Oct 2026 16:59:26 +0000</pubDate>
      <link>https://dev.to/ji_ai/sync-anthropic-client-blocked-my-asyncio-loop-voice-p95-hit-114s-1a7p</link>
      <guid>https://dev.to/ji_ai/sync-anthropic-client-blocked-my-asyncio-loop-voice-p95-hit-114s-1a7p</guid>
      <description>&lt;p&gt;With one user on the server, my voice AI answered in 0.9 seconds. With eight users, the last person in line waited 11.4 seconds, and it sounded broken. The model, the network and the speech vendor were all fine. The problem was one line, &lt;code&gt;client = Anthropic()&lt;/code&gt;, called from inside an &lt;code&gt;async def&lt;/code&gt;. A sync Anthropic client was blocking the asyncio event loop, and every other session on the box stopped while it ran.&lt;/p&gt;

&lt;p&gt;I spent two days blaming the wrong things. Here's the whole story, with the numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Calling the &lt;strong&gt;sync&lt;/strong&gt; &lt;code&gt;Anthropic()&lt;/code&gt; client inside an &lt;code&gt;async def&lt;/code&gt; handler blocks the asyncio event loop for the whole generation. No other coroutine runs until it finishes: no WebSocket frames, no audio, nothing.&lt;/li&gt;
&lt;li&gt;Testing with one user hides it completely. Lag scales with how many sessions are active at once: my p95 time-to-first-audio went from 0.9s with 1 session to 11.4s with 8.&lt;/li&gt;
&lt;li&gt;Find it with &lt;code&gt;loop.set_debug(True)&lt;/code&gt; plus &lt;code&gt;loop.slow_callback_duration = 0.1&lt;/code&gt;, or with a 10-line loop-lag monitor in production.&lt;/li&gt;
&lt;li&gt;Fix: switch to &lt;code&gt;AsyncAnthropic&lt;/code&gt; and &lt;code&gt;async for&lt;/code&gt; over the stream. Wrapping the sync call in &lt;code&gt;asyncio.to_thread&lt;/code&gt; only helps until the default thread pool fills (6 threads on a 2-vCPU box).&lt;/li&gt;
&lt;li&gt;CPU-bound work like PDF parsing belongs in a &lt;code&gt;ProcessPoolExecutor&lt;/code&gt;, not on the loop and not in a thread.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What was I building?
&lt;/h2&gt;

&lt;p&gt;The server runs voice interview sessions. Full disclosure: I built it. It's &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, which runs realistic voice interviews with three interviewer styles, scores a resume or portfolio, and writes a report at the end. Each session is a WebSocket: audio comes in, a transcript comes back from speech-to-text, Claude writes the interviewer's next line, and text-to-speech streams it out.&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%2Ftd5zed80xfjpv7qjalsn.jpg" 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%2Ftd5zed80xfjpv7qjalsn.jpg" alt="Preterview — an interview session in progress" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's one FastAPI process under uvicorn on a 2-vCPU machine. That's one event loop serving every session at once. If you've written async Python, you probably know where this is going.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did single-user testing look perfect?
&lt;/h2&gt;

&lt;p&gt;Blocking the event loop costs nothing when there's nothing else waiting. With one user, a 2.3-second blocking call just looks like a 2.3-second LLM call. Nobody else is queued behind it, so nothing looks wrong.&lt;/p&gt;

&lt;p&gt;My local runs, my staging runs and my demo calls all had one person on the line: me. Everything measured 0.9s to first audio. I shipped it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did the bug look like in production?
&lt;/h2&gt;

&lt;p&gt;The first complaints were "the interviewer froze for a few seconds" and "the audio stuttered." Not every session. Only at certain times of day.&lt;/p&gt;

&lt;p&gt;My first guesses, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The speech-to-text vendor was slow at peak hours. (I moved regions. Nothing changed.)&lt;/li&gt;
&lt;li&gt;TTS chunks were too small. (I made them bigger. Stutter got worse.)&lt;/li&gt;
&lt;li&gt;The LLM was slow. (Claude's own timing for each call was flat across the day.)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That third clue was the one that mattered. The model took the same time at 3 a.m. and at 8 p.m. But the &lt;em&gt;user&lt;/em&gt; waited much longer at 8 p.m. So the extra time was being spent inside my process, before or after the API call.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you detect a blocked asyncio event loop?
&lt;/h2&gt;

&lt;p&gt;Turn on asyncio debug mode and lower the slow-callback threshold. asyncio will then log every step that holds the loop longer than the threshold, with the task that did it.&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;asyncio&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;

&lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;basicConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WARNING&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_startup&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;loop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_running_loop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;loop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_debug&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="n"&gt;loop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;slow_callback_duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;  &lt;span class="c1"&gt;# seconds; default is 0.1 in debug mode
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On staging, under a replayed load test, the logs filled with lines like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Executing &amp;lt;Task pending name='Task-412' coro=&amp;lt;session_turn() running at app/session.py:88&amp;gt;&amp;gt;
took 2.312 seconds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Debug mode adds overhead, so I didn't want it in production. Instead I added a lag monitor. It sleeps for 100ms and checks how late it woke up. If the loop is free, it wakes on time. If something is hogging the loop, it wakes late, and the delay tells you how long the loop was blocked.&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;loop_lag_monitor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interval&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="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;warn_ms&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="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;loop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_running_loop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;while&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;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;loop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;lag_ms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lag_ms&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;warn_ms&lt;/span&gt;&lt;span class="p"&gt;:&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;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event loop lag %.0f ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lag_ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In one week of production logs, I got &lt;strong&gt;1,412 warnings over 100ms&lt;/strong&gt;. When I matched them to the debug traces from staging, they broke down like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source of the block&lt;/th&gt;
&lt;th&gt;Warnings&lt;/th&gt;
&lt;th&gt;Share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sync Anthropic client calls&lt;/td&gt;
&lt;td&gt;868&lt;/td&gt;
&lt;td&gt;61%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pypdf&lt;/code&gt; resume parsing&lt;/td&gt;
&lt;td&gt;325&lt;/td&gt;
&lt;td&gt;23%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A sync &lt;code&gt;requests.post&lt;/code&gt; webhook + a &lt;code&gt;time.sleep&lt;/code&gt; in a retry helper&lt;/td&gt;
&lt;td&gt;219&lt;/td&gt;
&lt;td&gt;16%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The longest single block was 7.9 seconds: &lt;code&gt;pypdf&lt;/code&gt; grinding through a 14-page resume while every live interview on the box sat silent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a sync client inside async def block everything?
&lt;/h2&gt;

&lt;p&gt;An &lt;code&gt;async def&lt;/code&gt; only yields control at an &lt;code&gt;await&lt;/code&gt;. A sync network call has no &lt;code&gt;await&lt;/code&gt;, so the loop is stuck until the socket read returns. My code looked harmless:&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;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# sync client
&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;session_turn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ws&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="k"&gt;with&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;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stream&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;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;400&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;history&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;stream&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;text&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text_stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;# blocks the loop waiting for each chunk
&lt;/span&gt;            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;# yields, but only for a moment
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;await ws.send_text&lt;/code&gt; made it &lt;em&gt;look&lt;/em&gt; async. But &lt;code&gt;for text in stream.text_stream&lt;/code&gt; is a sync iterator doing a blocking socket read between every chunk. In practice, the loop was frozen for the whole generation, apart from a few microseconds per send.&lt;/p&gt;

&lt;p&gt;Every other session's incoming audio frames, outgoing TTS chunks and heartbeats waited in line. With eight people mid-answer at once, the last one in line waited for seven generations before their own started.&lt;/p&gt;

&lt;h2&gt;
  
  
  How bad was it under load?
&lt;/h2&gt;

&lt;p&gt;I wrote a load test that replays recorded answers across N concurrent fake sessions and measures time-to-first-audio (TTFA). It counts from the moment the user stops talking to the moment the first TTS byte goes out. Here's p95 over 200 turns at each level:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concurrent sessions&lt;/th&gt;
&lt;th&gt;Sync client&lt;/th&gt;
&lt;th&gt;Sync client in &lt;code&gt;to_thread&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;&lt;code&gt;AsyncAnthropic&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0.9s&lt;/td&gt;
&lt;td&gt;0.9s&lt;/td&gt;
&lt;td&gt;0.9s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;5.2s&lt;/td&gt;
&lt;td&gt;1.0s&lt;/td&gt;
&lt;td&gt;0.9s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;11.4s&lt;/td&gt;
&lt;td&gt;3.4s&lt;/td&gt;
&lt;td&gt;1.0s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The middle column is the part I nearly got wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is asyncio.to_thread enough to fix a blocking SDK call?
&lt;/h2&gt;

&lt;p&gt;Only up to a point. &lt;code&gt;asyncio.to_thread&lt;/code&gt; runs the call on the loop's default &lt;code&gt;ThreadPoolExecutor&lt;/code&gt;, which since Python 3.8 has &lt;code&gt;min(32, os.cpu_count() + 4)&lt;/code&gt; workers. On my 2-vCPU box, that's 6 threads.&lt;/p&gt;

&lt;p&gt;My first fix was to wrap the sync stream in &lt;code&gt;to_thread&lt;/code&gt;, and at 4 sessions it looked solved. At 8 sessions, two turns sat in the executor queue waiting for a free thread, and p95 rose to 3.4s. The loop was no longer frozen, but a new bottleneck had appeared: the thread pool.&lt;/p&gt;

&lt;p&gt;You can make the pool bigger. But at that point you're running a thread per in-flight LLM call so you can keep using a client that wasn't built for your runtime. The SDK ships an async client. Use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's the correct fix?
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;AsyncAnthropic&lt;/code&gt; and iterate the stream with &lt;code&gt;async for&lt;/code&gt;, so each wait for the next chunk is a real &lt;code&gt;await&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;anthropic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AsyncAnthropic&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AsyncAnthropic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;session_turn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ws&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&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;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stream&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;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;400&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;history&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text_stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a three-word diff, and it took p95 at 8 sessions from 11.4s to 1.0s.&lt;/p&gt;

&lt;p&gt;The other 39% of the warnings needed different fixes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pypdf&lt;/code&gt; resume parsing&lt;/strong&gt; is CPU-bound pure Python. Moving it to &lt;code&gt;to_thread&lt;/code&gt; keeps the loop &lt;em&gt;mostly&lt;/em&gt; responsive, but it still competes for the GIL with the loop thread. I moved it to a &lt;code&gt;ProcessPoolExecutor(max_workers=2)&lt;/code&gt; with &lt;code&gt;loop.run_in_executor&lt;/code&gt;. Parsing a resume now costs other sessions nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;requests.post&lt;/code&gt; to a webhook&lt;/strong&gt;: replaced it with &lt;code&gt;httpx.AsyncClient&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;time.sleep(2)&lt;/code&gt; in a retry helper&lt;/strong&gt;: changed to &lt;code&gt;await asyncio.sleep(2)&lt;/code&gt;. That one was mine, it was in a "utils" file, and it had been freezing the server for two full seconds on every retry.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After the changes, the lag monitor logged 9 warnings over 100ms in the following week, all under 180ms, all from garbage collection pauses. That's down from 1,412.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I stop this from coming back?
&lt;/h2&gt;

&lt;p&gt;Make it fail loudly instead of degrading quietly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keep the lag monitor in production&lt;/strong&gt; and alert when the p99 loop lag goes over 100ms. It's 10 lines and costs almost nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load test with concurrency, not just latency.&lt;/strong&gt; A single-session test can't catch this kind of bug. My CI now runs the replay at 1, 4 and 8 sessions and fails if p95 at 8 is more than 1.5x the value at 1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grep for the usual suspects&lt;/strong&gt; in async code paths: &lt;code&gt;Anthropic(&lt;/code&gt;, &lt;code&gt;OpenAI(&lt;/code&gt;, &lt;code&gt;requests.&lt;/code&gt;, &lt;code&gt;time.sleep&lt;/code&gt;, and sync file or PDF parsing. A lint rule that flags the sync clients in modules full of &lt;code&gt;async def&lt;/code&gt; caught two more before they shipped.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The lesson I keep relearning: in async Python, the slowest blocking call sets the latency for every user on the box, not just the one who triggered it.&lt;/p&gt;

&lt;h2&gt;
  
  
  So why did a sync Anthropic client block my asyncio event loop?
&lt;/h2&gt;

&lt;p&gt;A sync Anthropic client blocks the asyncio event loop because its network reads are ordinary blocking calls with no &lt;code&gt;await&lt;/code&gt;. Even inside an &lt;code&gt;async def&lt;/code&gt;, the single loop thread is stuck until the response or the next stream chunk arrives, and every other coroutine waits. One user never notices. Concurrent users queue up behind each other: my voice server went from 0.9s to 11.4s p95 time-to-first-audio at 8 sessions. Detect it with &lt;code&gt;loop.set_debug(True)&lt;/code&gt; and &lt;code&gt;slow_callback_duration&lt;/code&gt;, or a loop-lag monitor. Fix it with &lt;code&gt;AsyncAnthropic&lt;/code&gt; and &lt;code&gt;async for&lt;/code&gt;, not &lt;code&gt;asyncio.to_thread&lt;/code&gt;, which just moves the queue into a 6-thread pool. Send CPU-bound work to a process pool.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>performance</category>
      <category>ai</category>
      <category>llm</category>
    </item>
    <item>
      <title>Python json.dumps ensure_ascii: 24% More Claude Tokens, Wrong Kanji</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Mon, 12 Oct 2026 04:57:43 +0000</pubDate>
      <link>https://dev.to/ji_ai/python-jsondumps-ensureascii-24-more-claude-tokens-wrong-kanji-4inm</link>
      <guid>https://dev.to/ji_ai/python-jsondumps-ensureascii-24-more-claude-tokens-wrong-kanji-4inm</guid>
      <description>&lt;p&gt;The Python port of my support-ticket classifier used 24% more input tokens than the TypeScript version it replaced. Same model. Same system prompt. Same 2,000 tickets in the replay set.&lt;/p&gt;

&lt;p&gt;I spent an afternoon diffing prompts before I saw it. The TypeScript prompt said &lt;code&gt;東京&lt;/code&gt;. The Python prompt said &lt;code&gt;東京&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One default argument in &lt;code&gt;json.dumps&lt;/code&gt;, &lt;code&gt;ensure_ascii=True&lt;/code&gt;, was quietly rewriting every non-ASCII character in my prompts into hex escapes. It cost tokens, and worse, it made Claude misread Japanese customer names.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Python's &lt;code&gt;json.dumps&lt;/code&gt; defaults to &lt;code&gt;ensure_ascii=True&lt;/code&gt;, which turns &lt;code&gt;é&lt;/code&gt; into &lt;code&gt;é&lt;/code&gt; and &lt;code&gt;東&lt;/code&gt; into &lt;code&gt;東&lt;/code&gt;. JavaScript's &lt;code&gt;JSON.stringify&lt;/code&gt; doesn't do this.&lt;/li&gt;
&lt;li&gt;If you paste &lt;code&gt;json.dumps(...)&lt;/code&gt; output &lt;strong&gt;into a prompt string&lt;/strong&gt;, the model sees literal backslash-u hex, not the characters.&lt;/li&gt;
&lt;li&gt;On my 2,000-ticket replay set, that raised average input tokens from 1,180 to 1,461 per request (+24%). Japanese ticket payloads alone grew about 3.1x.&lt;/li&gt;
&lt;li&gt;Extraction quality dropped too: 29 of 340 Japanese tickets came back with a wrong character in a name or address, versus 3 of 340 with raw UTF-8.&lt;/li&gt;
&lt;li&gt;Fix: &lt;code&gt;json.dumps(obj, ensure_ascii=False)&lt;/code&gt;, plus &lt;code&gt;force_ascii=False&lt;/code&gt; in pandas &lt;code&gt;to_json&lt;/code&gt;, plus a test that fails if &lt;code&gt;\u&lt;/code&gt; shows up in a rendered prompt.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does json.dumps ensure_ascii actually do?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ensure_ascii=True&lt;/code&gt; (the default) makes &lt;code&gt;json.dumps&lt;/code&gt; escape every character outside ASCII as a &lt;code&gt;\uXXXX&lt;/code&gt; sequence, and characters outside the Basic Multilingual Plane (most emoji) as a surrogate pair of two escapes. The output is valid JSON and decodes back to the same data. It's a transport-safety feature from an era when you couldn't trust a pipe to carry UTF-8.&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="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;city&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;München&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;name&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;東京&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;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;city&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="s"&gt;M&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;u00fcnchen&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="s"&gt;name&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="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;u6771&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;u4eac&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="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;city&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;München&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;name&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;東京&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;ensure_ascii&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&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="s"&gt;city&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="s"&gt;München&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="s"&gt;name&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="s"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a JSON file or an HTTP body, this is harmless. The receiver parses the JSON and gets &lt;code&gt;東京&lt;/code&gt; back. The problem only starts when the JSON is not the transport but the &lt;em&gt;content&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does ensure_ascii only break prompts and not API calls?
&lt;/h2&gt;

&lt;p&gt;Because a prompt is a string that nobody parses as JSON before the model reads it. Here's the line that bit me:&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;prompt&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;Classify this ticket and extract the customer&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s name and city.

&amp;lt;ticket&amp;gt;
&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indent&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="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;
&amp;lt;/ticket&amp;gt;&lt;/span&gt;&lt;span class="sh"&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;messages&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;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;512&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;SYSTEM_PROMPT&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;prompt&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SDK then serializes the whole request body to send over HTTP. That outer layer is fine. But the inner &lt;code&gt;東&lt;/code&gt; is now part of a string value, so the SDK escapes the backslash, the API decodes it once, and the model receives the six literal characters &lt;code&gt;\&lt;/code&gt;, &lt;code&gt;u&lt;/code&gt;, &lt;code&gt;6&lt;/code&gt;, &lt;code&gt;7&lt;/code&gt;, &lt;code&gt;7&lt;/code&gt;, &lt;code&gt;1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The model doesn't get &lt;code&gt;東&lt;/code&gt;. It gets a hex puzzle and has to solve it in its head.&lt;/p&gt;

&lt;p&gt;The TypeScript version did the same thing with &lt;code&gt;JSON.stringify(ticket, null, 2)&lt;/code&gt;, which leaves non-ASCII characters alone. That's why nobody noticed during the port: the code looked identical line for line.&lt;/p&gt;

&lt;h2&gt;
  
  
  How many extra tokens does ensure_ascii cost?
&lt;/h2&gt;

&lt;p&gt;On my data, 24% more input tokens per request on average, and about 3x more on the ticket payload for Japanese text. I measured it with Anthropic's &lt;code&gt;count_tokens&lt;/code&gt; endpoint against the same 2,000 tickets rendered both ways:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ticket language&lt;/th&gt;
&lt;th&gt;Share of set&lt;/th&gt;
&lt;th&gt;Payload tokens, escaped vs raw&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;English&lt;/td&gt;
&lt;td&gt;38%&lt;/td&gt;
&lt;td&gt;1.00x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;German&lt;/td&gt;
&lt;td&gt;22%&lt;/td&gt;
&lt;td&gt;1.06x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spanish / Portuguese&lt;/td&gt;
&lt;td&gt;13%&lt;/td&gt;
&lt;td&gt;1.09x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Japanese&lt;/td&gt;
&lt;td&gt;17%&lt;/td&gt;
&lt;td&gt;3.1x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other (French, Polish, emoji-heavy)&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;about 2x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Average input per request went from 1,180 tokens to 1,461. About 600 of those are a fixed system prompt, so the payload part grew by almost half.&lt;/p&gt;

&lt;p&gt;The pattern makes sense once you look at what the tokenizer is fed. A German ticket only escapes the occasional &lt;code&gt;ü&lt;/code&gt; or &lt;code&gt;ß&lt;/code&gt;, so the damage is a few percent. A Japanese ticket escapes &lt;em&gt;every single character&lt;/em&gt;, and each one becomes a dozen-ish ASCII characters of hex that tokenize badly. Emoji are the worst per character: one 😀 becomes &lt;code&gt;😀&lt;/code&gt;, twelve characters of surrogate pair.&lt;/p&gt;

&lt;p&gt;At about 40,000 tickets a month, the extra 281 tokens per request comes to roughly 11 million input tokens I was paying for so the model could read hex. Multiply by your own per-token price; mine was not a fun number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does escaping non-ASCII hurt LLM accuracy?
&lt;/h2&gt;

&lt;p&gt;Yes, at least for extraction. Classification barely moved, but copying exact characters out of escaped text got noticeably worse.&lt;/p&gt;

&lt;p&gt;The bug actually surfaced through accuracy, not cost. A support agent flagged a ticket where the extracted customer name contained a kanji that appeared nowhere in the original message. I grepped the raw ticket for the extracted name: zero hits.&lt;/p&gt;

&lt;p&gt;So I scored the 340 Japanese tickets from the replay set. A result counted as wrong if the extracted name or city string didn't appear verbatim in the original ticket text:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Escaped prompts:&lt;/strong&gt; 29 of 340 had at least one wrong character (8.5%)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raw UTF-8 prompts:&lt;/strong&gt; 3 of 340 (0.9%)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Category labels:&lt;/strong&gt; within one point of each other across both versions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My working theory: common characters like &lt;code&gt;東京&lt;/code&gt; show up escaped all over the training data, so the model decodes them reliably. Rare kanji in personal names don't, and the model lands on a character whose code point is &lt;em&gt;close&lt;/em&gt; but whose meaning and shape are unrelated. Hex nearness is not visual nearness. A one-digit slip in &lt;code&gt;髙&lt;/code&gt; gives you a completely different character, and nothing in the surrounding text looks wrong to the model.&lt;/p&gt;

&lt;p&gt;Caveats, since I'd want them if I were reading this: one model, one run per version, 340 tickets, exact-match scoring. It's a strong signal on my data, not a benchmark. But the direction is not subtle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where else does ensure_ascii hide in a Python LLM pipeline?
&lt;/h2&gt;

&lt;p&gt;Anywhere you serialize data into prompt text. I found three more spots after fixing the first one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pandas.DataFrame.to_json()&lt;/code&gt;&lt;/strong&gt; defaults to &lt;code&gt;force_ascii=True&lt;/code&gt;. I used it to inline a few rows of order history into prompts. Same bug, different parameter name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Few-shot examples loaded from a file and re-dumped.&lt;/strong&gt; The loader read UTF-8 JSON correctly, then a helper called &lt;code&gt;json.dumps(example)&lt;/code&gt; to format it. Every Japanese few-shot example was escaped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool results.&lt;/strong&gt; My agent returned tool outputs as &lt;code&gt;json.dumps(result)&lt;/code&gt; in &lt;code&gt;tool_result&lt;/code&gt; content. Search results with French product names went to the model as &lt;code&gt;café&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Places that were already fine: Pydantic v2's &lt;code&gt;model_dump_json()&lt;/code&gt; writes raw UTF-8, and &lt;code&gt;orjson.dumps()&lt;/code&gt; always outputs UTF-8. If you're on either, you're probably safe. Check anyway; I was sure about the TypeScript-to-Python port too.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you fix it and keep it fixed?
&lt;/h2&gt;

&lt;p&gt;Pass &lt;code&gt;ensure_ascii=False&lt;/code&gt; everywhere JSON ends up in a prompt, then add a test so the next helper function can't bring it back.&lt;/p&gt;

&lt;p&gt;I centralized prompt serialization in one function:&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;to_prompt_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Compact separators, raw UTF-8. This goes to a model, not a wire.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ensure_ascii&lt;/span&gt;&lt;span class="o"&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;separators&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;,&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;:&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;Dropping &lt;code&gt;indent=2&lt;/code&gt; for compact separators saved another 6% on payload tokens. I checked extraction accuracy on the same set and saw no difference, but test that on your own data before copying it; some prompts read better indented.&lt;/p&gt;

&lt;p&gt;Then a guard that runs in CI against every prompt template with a fixture that includes Japanese, German, and an emoji:&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;re&lt;/span&gt;

&lt;span class="n"&gt;ESCAPE&lt;/span&gt; &lt;span class="o"&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;compile&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;\\u[0-9a-fA-F]{4}&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;test_prompts_contain_no_unicode_escapes&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;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PROMPT_TEMPLATES&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="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MULTILINGUAL_FIXTURE&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;ESCAPE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&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;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; leaks &lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;u escapes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It caught the pandas call on its first run. And a grep for the lazy version of the audit:&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="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"json.dumps("&lt;/span&gt; src/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"ensure_ascii=False"&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"to_json("&lt;/span&gt; src/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"force_ascii=False"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the fix, the Python version came in at 1,148 average input tokens: 3% under the TypeScript original, thanks to the compact separators. Japanese extraction errors dropped back to 3 of 340.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson I'm keeping
&lt;/h2&gt;

&lt;p&gt;The scary part isn't that &lt;code&gt;ensure_ascii&lt;/code&gt; exists. It's that every layer looked correct. The JSON was valid. The request succeeded. The logs printed fine, because I was logging the &lt;em&gt;ticket&lt;/em&gt;, not the rendered prompt. The model answered confidently. The only visible symptoms were a slightly bigger bill and a wrong character in a name that a human happened to check.&lt;/p&gt;

&lt;p&gt;Log the exact string the model receives, at least for a sample. Read it once with your own eyes. Most prompt bugs I've had were visible in about five seconds of looking at the real thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  So does json.dumps ensure_ascii waste LLM tokens?
&lt;/h2&gt;

&lt;p&gt;Yes. Python's &lt;code&gt;json.dumps&lt;/code&gt; escapes every non-ASCII character as &lt;code&gt;\uXXXX&lt;/code&gt; by default, and when that output is pasted into a prompt, the model reads literal hex instead of the original text. In my 2,000-ticket test with Claude, it raised average input tokens by 24%, tripled the payload size for Japanese tickets, and raised Japanese name-extraction errors from 3 to 29 out of 340. Use &lt;code&gt;json.dumps(obj, ensure_ascii=False)&lt;/code&gt; (and &lt;code&gt;force_ascii=False&lt;/code&gt; for pandas &lt;code&gt;to_json&lt;/code&gt;) anywhere JSON becomes prompt text, then add a test that fails if &lt;code&gt;\u&lt;/code&gt; escapes appear in a rendered prompt.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>python</category>
      <category>ai</category>
      <category>performance</category>
    </item>
    <item>
      <title>Cosine Similarity Threshold 0.8: Embedding Anisotropy Broke My RAG</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sun, 11 Oct 2026 16:55:54 +0000</pubDate>
      <link>https://dev.to/ji_ai/cosine-similarity-threshold-08-embedding-anisotropy-broke-my-rag-p29</link>
      <guid>https://dev.to/ji_ai/cosine-similarity-threshold-08-embedding-anisotropy-broke-my-rag-p29</guid>
      <description>&lt;p&gt;My RAG bot had exactly one safety rule: if the best chunk scores below 0.8 cosine similarity, answer "I don't know." That cosine similarity threshold ran for months without complaint. Then I swapped the embedding model, redeployed, and the bot said "I don't know" to everything. Including "how do I reset my password," which has its own dedicated doc page.&lt;/p&gt;

&lt;p&gt;Nothing crashed. No errors. Retrieval still returned the right chunk at rank 1. My gate just threw it away, every single time.&lt;/p&gt;

&lt;p&gt;The worst part: when I went digging, I found the old setup had been broken too. In the other direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A cosine similarity score has no universal meaning.&lt;/strong&gt; 0.8 can be "near-duplicate" for one embedding model and "random noise" for another.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embedding anisotropy&lt;/strong&gt; is the cause: most models pack all vectors into a narrow cone, so even unrelated texts score high. How narrow depends on the model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contrastive training only teaches ranking&lt;/strong&gt;, not absolute scores. Adding a constant to every similarity doesn't change the loss, so nothing pins 0.8 to "relevant."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; measure the baseline similarity of unrelated pairs in &lt;em&gt;your&lt;/em&gt; corpus, calibrate the threshold on a small labeled set, and recalibrate on every model change.&lt;/li&gt;
&lt;li&gt;Better still, gate on relative signals (score vs. baseline percentile, top-1 margin) or a reranker, not a raw cosine number.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What happened when I switched embedding models?
&lt;/h2&gt;

&lt;p&gt;The rank order survived the migration, but the score scale collapsed. Every relevant match dropped well below my 0.8 threshold, so the gate rejected all of them.&lt;/p&gt;

&lt;p&gt;Here's what my logs showed, roughly, on the same internal docs corpus:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Old model (ada-002)&lt;/th&gt;
&lt;th&gt;New model (text-embedding-3-small)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Top-1 score, questions with a real answer&lt;/td&gt;
&lt;td&gt;~0.84 median&lt;/td&gt;
&lt;td&gt;~0.55 median&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Score between two random unrelated chunks&lt;/td&gt;
&lt;td&gt;~0.72 median&lt;/td&gt;
&lt;td&gt;~0.18 median&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at the right column first. The new model was &lt;em&gt;better&lt;/em&gt; at separating relevant from unrelated. The gap between "real answer" and "noise" got wider. My threshold just sat above the whole distribution.&lt;/p&gt;

&lt;p&gt;Now look at the left column. With the old model, random junk scored 0.72 and real answers scored 0.84. My "safety" threshold of 0.8 was sitting in a 0.12-wide band between garbage and gold. I pulled an off-topic test question about pizza in Naples and it scored 0.77 against a Kubernetes runbook. My guardrail was holding by three hundredths of a point.&lt;/p&gt;

&lt;p&gt;So the threshold was never a guardrail. It was a coincidence that happened to work for one model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is cosine similarity between unrelated texts so high?
&lt;/h2&gt;

&lt;p&gt;Because most embedding models are anisotropic: their vectors don't spread over the whole sphere, they crowd into a narrow cone. Any two vectors inside a narrow cone have a small angle between them, so cosine similarity is high no matter what the texts say.&lt;/p&gt;

&lt;p&gt;This isn't a quirk of one vendor. It showed up in research on BERT and GPT-2 representations years ago (Ethayarajh, 2019, found contextual embeddings occupy a narrow cone), and "representation degeneration" has been studied in language models since.&lt;/p&gt;

&lt;p&gt;You can see why with ten seconds of math. Model every embedding as a shared component plus a unique one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v = m + r
|m|^2 = a^2   (direction every text shares)
|r|^2 = b^2   (what makes this text unique)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the unique parts are roughly orthogonal to each other and to &lt;code&gt;m&lt;/code&gt;, the cosine of two unrelated texts is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cos(v1, v2) ≈ a^2 / (a^2 + b^2)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the shared direction carries 70% of each vector's squared length, two texts with nothing in common score about 0.7. All the actual meaning has to fit in the remaining 0.3 of the range. That's exactly what my ada-002 numbers looked like.&lt;/p&gt;

&lt;p&gt;Different models end up with different &lt;code&gt;a^2 / (a^2 + b^2)&lt;/code&gt;. That ratio is your noise floor, and it's not printed on the box.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does the score scale change between embedding models?
&lt;/h2&gt;

&lt;p&gt;Because the training objective never asks for a specific score. Most embedding models are trained with a contrastive loss (InfoNCE or a close cousin) that only cares whether the right pair beats the wrong pairs.&lt;/p&gt;

&lt;p&gt;The loss for one query looks like:&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;loss&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s_pos&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;τ&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;Σ_i&lt;/span&gt; &lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s_i&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;τ&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a constant &lt;code&gt;c&lt;/code&gt; to every similarity &lt;code&gt;s_i&lt;/code&gt; and the softmax doesn't change. The &lt;code&gt;exp(c / τ)&lt;/code&gt; factor cancels top and bottom. So the model is free to put "unrelated" at 0.1 or 0.7. Nothing in training cares.&lt;/p&gt;

&lt;p&gt;The temperature &lt;code&gt;τ&lt;/code&gt; and the training data decide how spread out the scores end up. Different model, different recipe, different scale. Your 0.8 doesn't survive the trip.&lt;/p&gt;

&lt;p&gt;One more trap: if you shorten embeddings yourself (slicing a Matryoshka-style vector down to fewer dimensions), the vector is no longer unit length. Renormalize after slicing, or your "cosine" is really a dot product with drifting norms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is a cosine similarity threshold ever safe to use?
&lt;/h2&gt;

&lt;p&gt;Yes, if it was measured on your corpus, with your exact model, and you re-measure it whenever either changes. A threshold copied from a blog post, a tutorial default, or a previous model is a guess.&lt;/p&gt;

&lt;p&gt;The ranking from vector search is usually trustworthy. The absolute number next to it is not, until you calibrate it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I calibrate a cosine similarity threshold?
&lt;/h2&gt;

&lt;p&gt;Two steps: measure the noise floor, then fit the threshold on labeled examples.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: the noise floor.&lt;/strong&gt; Sample random pairs of chunks from your own index and look at the distribution. This takes a minute and tells you where "unrelated" lives for this model:&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;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;noise_floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;E&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;=&lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seed&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;E: (N, d) array of unit-normalized chunk embeddings.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;rng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;default_rng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;integers&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="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;E&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;integers&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="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;E&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;keep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;
    &lt;span class="n"&gt;sims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;einsum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nd,nd-&amp;gt;n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;E&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt; &lt;span class="n"&gt;E&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;keep&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;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;percentile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sims&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;5&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="mi"&gt;95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Random pairs from the same corpus overestimate "unrelated" a bit (chunks share topic and jargon), which is fine. You want a conservative floor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: a labeled set.&lt;/strong&gt; Write 40-50 questions your docs answer and 40-50 they don't. Record the top-1 score for each. Then pick the threshold that hits the precision you need:&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;pick_threshold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pos_scores&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;neg_scores&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_precision&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;candidates&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="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pos_scores&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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;neg_scores&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;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;candidates&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="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;t&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;pos_scores&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;fp&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;s&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;t&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;neg_scores&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;tp&lt;/span&gt; &lt;span class="ow"&gt;and&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;tp&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;min_precision&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;t&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="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pos_scores&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# threshold, recall
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If no threshold gets you there with decent recall, the problem isn't the threshold. Your embeddings can't separate those questions, and you need a reranker or better chunks.&lt;/p&gt;

&lt;p&gt;Store the model name and version next to the threshold in config. When someone bumps the model, the stale threshold should fail loudly, not silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does mean-centering embeddings fix anisotropy?
&lt;/h2&gt;

&lt;p&gt;It helps a lot for models with a strong shared direction. Subtract the corpus mean vector, renormalize, and the cone opens up. This is the idea behind "all-but-the-top" post-processing (Mu and Viswanath, 2018), which also removes a few dominant principal components.&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;mu&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;E&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;axis&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;def&lt;/span&gt; &lt;span class="nf"&gt;center&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;mu&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;linalg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;axis&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;keepdims&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;E_c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;center&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;E&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;q_c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;center&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# queries get the SAME mu, always
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catches are operational, not mathematical:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The query must be centered with the exact same &lt;code&gt;mu&lt;/code&gt; as the corpus.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mu&lt;/code&gt; drifts as the corpus grows. Recompute it on a schedule and re-embed or re-center the index.&lt;/li&gt;
&lt;li&gt;Your vector index (pgvector, FAISS, whatever) has to store centered vectors, so this is a reindex, not a flag.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Centering makes scores easier to read. It doesn't make them calibrated. You still run step 2.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should I gate on instead of a raw score?
&lt;/h2&gt;

&lt;p&gt;Gate on signals that survive a model swap. Three that have worked for me:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Score vs. noise floor.&lt;/strong&gt; Accept the top hit only if it beats the 99th percentile of random-pair similarity. When the model changes, recompute the floor and the gate moves with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Top-1 margin.&lt;/strong&gt; When the query matches something real, the top hit usually stands out from the next few. When it matches nothing, scores come out flat. &lt;code&gt;top1 - median(top2..top10)&lt;/code&gt; is a cheap, scale-relative signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cross-encoder reranker&lt;/strong&gt; for the final relevance call. It reads query and chunk together, so it judges relevance directly instead of comparing two independent vectors. Its scores need calibration too (many output raw logits), but it separates relevant from irrelevant much more cleanly.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The checklist I run now on every embedding change
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Re-embed a sample, plot the random-pair distribution. Write down p50 and p99.&lt;/li&gt;
&lt;li&gt;Re-score the labeled set. Re-pick the threshold. Commit it with the model name.&lt;/li&gt;
&lt;li&gt;Check vector norms. If they aren't ~1.0, normalize or switch to dot product on purpose.&lt;/li&gt;
&lt;li&gt;Diff top-1 results old vs. new on the labeled set. Rank changes are the real migration risk.&lt;/li&gt;
&lt;li&gt;Alert when "I don't know" rate jumps. That metric would have caught my outage in an hour instead of a day.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  So, does a cosine similarity of 0.8 mean anything?
&lt;/h2&gt;

&lt;p&gt;On its own, no. A cosine similarity threshold like 0.8 only means something relative to one specific embedding model on one specific corpus, because embedding anisotropy and contrastive training leave each model with its own score scale and its own noise floor. Unrelated texts can score 0.7 in one model and 0.15 in another. Trust the ranking that vector search gives you. Treat the number as uncalibrated until you've measured random-pair similarity in your own index and picked the threshold from labeled examples. Then redo both every time the model changes.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>machinelearning</category>
      <category>ai</category>
      <category>python</category>
    </item>
    <item>
      <title>Docker Compose depends_on Won't Wait for Postgres: 11/50 Runs Failed</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sun, 11 Oct 2026 04:53:41 +0000</pubDate>
      <link>https://dev.to/ji_ai/docker-compose-dependson-wont-wait-for-postgres-1150-runs-failed-6mb</link>
      <guid>https://dev.to/ji_ai/docker-compose-dependson-wont-wait-for-postgres-1150-runs-failed-6mb</guid>
      <description>&lt;p&gt;My integration tests failed on a Tuesday with &lt;code&gt;ECONNREFUSED 127.0.0.1:5432&lt;/code&gt;. I hit re-run and they passed. Then they failed again on Thursday. I had &lt;code&gt;depends_on: [db]&lt;/code&gt; right there in my &lt;code&gt;docker-compose.yml&lt;/code&gt;, so I assumed the database was up before my app started. That assumption was wrong, and the way &lt;strong&gt;Docker Compose depends_on&lt;/strong&gt; actually works explained every flaky run I'd been blaming on "CI being CI."&lt;/p&gt;

&lt;p&gt;So I stopped guessing. I wrote a loop that tore everything down with a fresh volume and booted the stack 50 times. &lt;strong&gt;11 of 50 runs failed&lt;/strong&gt; on the first database connection. That's 22%, which is far too high to call bad luck.&lt;/p&gt;

&lt;p&gt;Then I fixed it the way every Stack Overflow answer says to, and it still failed. Twice. That second bug is the part almost nobody writes about.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;depends_on: [db]&lt;/code&gt; (short form) only waits for the &lt;code&gt;db&lt;/code&gt; &lt;strong&gt;container to start&lt;/strong&gt;. It does not wait for Postgres to accept connections.&lt;/li&gt;
&lt;li&gt;To wait for readiness, use the long form with &lt;code&gt;condition: service_healthy&lt;/code&gt; &lt;strong&gt;and&lt;/strong&gt; define a &lt;code&gt;healthcheck&lt;/code&gt; on the database service.&lt;/li&gt;
&lt;li&gt;The default healthcheck &lt;code&gt;interval&lt;/code&gt; is 30 seconds, so a naive healthcheck can make your stack boot slowly. Set &lt;code&gt;interval: 2s&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;On a fresh volume, the official Postgres image runs a temporary init server that listens only on a Unix socket. Plain &lt;code&gt;pg_isready&lt;/code&gt; can report "ready" during that phase. Use &lt;code&gt;pg_isready -h 127.0.0.1&lt;/code&gt; to force a TCP check.&lt;/li&gt;
&lt;li&gt;Keep retry logic in your app anyway. Healthchecks gate startup only, not the rest of the container's life.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does Docker Compose depends_on actually wait for?
&lt;/h2&gt;

&lt;p&gt;Docker Compose depends_on, in its short form, waits for the dependency container to be &lt;strong&gt;created and started&lt;/strong&gt;. That's it. "Started" means the process inside the container has launched, not that it's listening on a port or finished initializing.&lt;/p&gt;

&lt;p&gt;Here's the setup I had:&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
  &lt;span class="na"&gt;tests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm test&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compose starts &lt;code&gt;db&lt;/code&gt;, sees the container running, and immediately starts &lt;code&gt;tests&lt;/code&gt;. Postgres at that moment might be anywhere: still running &lt;code&gt;initdb&lt;/code&gt;, still executing your init scripts, or restarting after initialization. My test runner opened a connection within about a second of boot. Sometimes Postgres was ready. Usually it was. 22% of the time it wasn't.&lt;/p&gt;

&lt;p&gt;The reason it "usually works" is what makes this bug so annoying. On your laptop, the volume already exists, Postgres skips initialization, and it's ready in well under a second. In CI, every run gets a fresh volume, so every run pays the full init cost. The race only loses where you can't watch it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I reproduce the depends_on race?
&lt;/h2&gt;

&lt;p&gt;Run the stack in a loop with a fresh volume each time. This is the script I used:&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="nv"&gt;fails&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 50&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;docker compose down &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nt"&gt;--remove-orphans&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; docker compose up &lt;span class="nt"&gt;--abort-on-container-exit&lt;/span&gt; &lt;span class="nt"&gt;--exit-code-from&lt;/span&gt; tests &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;fails&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;fails+1&lt;span class="k"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;fi
done
&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"failed: &lt;/span&gt;&lt;span class="nv"&gt;$fails&lt;/span&gt;&lt;span class="s2"&gt; / 50"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-v&lt;/code&gt; matters. Without it, the named volume survives, Postgres skips init, and you'll convince yourself the bug doesn't exist. &lt;code&gt;--exit-code-from tests&lt;/code&gt; makes &lt;code&gt;docker compose up&lt;/code&gt; return the test container's exit code, so the loop can count failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I make Docker Compose wait until Postgres is ready?
&lt;/h2&gt;

&lt;p&gt;Use the long form of &lt;code&gt;depends_on&lt;/code&gt; with &lt;code&gt;condition: service_healthy&lt;/code&gt;, and give the database a &lt;code&gt;healthcheck&lt;/code&gt;. Compose will hold the dependent service until the healthcheck passes.&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$${POSTGRES_USER}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$${POSTGRES_DB}"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
  &lt;span class="na"&gt;tests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm test&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details here bite people:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;$$&lt;/code&gt; is not a typo.&lt;/strong&gt; Compose interpolates &lt;code&gt;${VAR}&lt;/code&gt; from your &lt;em&gt;host&lt;/em&gt; environment when it parses the file. &lt;code&gt;$$&lt;/code&gt; escapes it so the literal &lt;code&gt;${POSTGRES_USER}&lt;/code&gt; reaches the container shell, where the variable actually exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;CMD-SHELL&lt;/code&gt;, not &lt;code&gt;CMD&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;CMD&lt;/code&gt; runs the binary directly with no shell, so &lt;code&gt;${POSTGRES_USER}&lt;/code&gt; would never expand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;interval&lt;/code&gt; yourself.&lt;/strong&gt; The Docker default is 30 seconds. With the default, your tests can sit idle for half a minute waiting for the first check, even though Postgres was ready after three seconds. People "fix" the race this way and then complain their CI got slower.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I reran the 50-boot loop. Failures dropped from 11 to 2.&lt;/p&gt;

&lt;p&gt;Two. Not zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does pg_isready say ready before Postgres accepts connections?
&lt;/h2&gt;

&lt;p&gt;Because on a fresh volume, the official Postgres image starts Postgres &lt;strong&gt;twice&lt;/strong&gt;, and the first one is lying to you, technically.&lt;/p&gt;

&lt;p&gt;Here's the sequence the image's entrypoint runs on first boot:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run &lt;code&gt;initdb&lt;/code&gt; to create the data directory.&lt;/li&gt;
&lt;li&gt;Start a &lt;strong&gt;temporary server&lt;/strong&gt; with TCP disabled (it listens on the Unix socket only).&lt;/li&gt;
&lt;li&gt;Create your user and database, then run everything in &lt;code&gt;/docker-entrypoint-initdb.d/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Stop the temporary server.&lt;/li&gt;
&lt;li&gt;Start the real server, listening on TCP.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;pg_isready&lt;/code&gt; with no &lt;code&gt;-h&lt;/code&gt; flag connects over the local Unix socket. During step 3, the temporary server is accepting socket connections, so &lt;code&gt;pg_isready&lt;/code&gt; exits 0. Docker marks the container healthy. Compose releases your tests. Your tests connect over TCP to &lt;code&gt;db:5432&lt;/code&gt;, where nothing is listening yet, because the real server hasn't started. &lt;code&gt;ECONNREFUSED&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The window is small with an empty init directory. If you have a seed script with a few thousand rows in &lt;code&gt;docker-entrypoint-initdb.d&lt;/code&gt;, the window grows with it, and so does your failure rate.&lt;/p&gt;

&lt;p&gt;The fix is one flag. Force the healthcheck to use TCP, the same path your app uses:&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;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-h&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;127.0.0.1&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$${POSTGRES_USER}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$${POSTGRES_DB}"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The temporary server doesn't listen on TCP, so this check can't pass until the real server is up. &lt;code&gt;start_period&lt;/code&gt; gives init some grace time: failed checks during that period don't count against &lt;code&gt;retries&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Loop again: &lt;strong&gt;0 of 50 failed.&lt;/strong&gt; I ran it another 50 to make sure I wasn't celebrating noise. Still zero.&lt;/p&gt;

&lt;p&gt;The general lesson: a healthcheck should test the same path your client uses. Socket vs TCP, &lt;code&gt;localhost&lt;/code&gt; vs service name, the right database name. A healthcheck that checks something adjacent will eventually pass at the wrong moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about migrations that must finish before the app starts?
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;condition: service_completed_successfully&lt;/code&gt;. It waits until the dependency container &lt;strong&gt;exits with code 0&lt;/strong&gt;, which is exactly what a one-shot migration job does.&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;migrate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run migrate&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
      &lt;span class="na"&gt;migrate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_completed_successfully&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;migrate&lt;/code&gt; exits non-zero, Compose refuses to start &lt;code&gt;app&lt;/code&gt;. That's the behavior you want: a failed migration should stop the deploy, not let the app boot against a half-migrated schema.&lt;/p&gt;

&lt;p&gt;There are three conditions in total, and it helps to know all of them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Waits until&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;service_started&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Container is running (same as short form)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;service_healthy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Healthcheck reports healthy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;service_completed_successfully&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Container exited with code 0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Is a healthcheck enough, or do I still need retries?
&lt;/h2&gt;

&lt;p&gt;You still need retries in the app. Healthchecks only gate &lt;strong&gt;startup order&lt;/strong&gt; inside Compose. They do nothing when Postgres restarts an hour later, when a network blip drops a connection, or when you deploy the same image to an orchestrator that has no &lt;code&gt;depends_on&lt;/code&gt; at all.&lt;/p&gt;

&lt;p&gt;My rule now: Compose healthchecks make local and CI boots deterministic. App-level connection retry with backoff makes production survivable. They solve different problems, and I stopped treating either as a replacement for the other.&lt;/p&gt;

&lt;p&gt;One more CI tip: &lt;code&gt;docker compose up -d --wait&lt;/code&gt; blocks until services are running and healthy, then returns. That's cleaner than a hand-rolled &lt;code&gt;until pg_isready; do sleep 1; done&lt;/code&gt; loop in your workflow file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist I paste into every Compose file now
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Long-form &lt;code&gt;depends_on&lt;/code&gt; with an explicit &lt;code&gt;condition&lt;/code&gt;, never the short list.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;healthcheck&lt;/code&gt; on every service someone depends on.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;interval: 2s&lt;/code&gt; so readiness is noticed fast.&lt;/li&gt;
&lt;li&gt;Healthcheck uses the same transport as the client (&lt;code&gt;-h 127.0.0.1&lt;/code&gt; for Postgres).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$$&lt;/code&gt; for container-side variables, &lt;code&gt;CMD-SHELL&lt;/code&gt; when you need a shell.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;service_completed_successfully&lt;/code&gt; for migrations and seed jobs.&lt;/li&gt;
&lt;li&gt;Retry logic in the app regardless.&lt;/li&gt;
&lt;li&gt;Test with &lt;code&gt;down -v&lt;/code&gt;, because a warm volume hides everything.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  So does Docker Compose depends_on wait for the database?
&lt;/h2&gt;

&lt;p&gt;No. Docker Compose depends_on, in its short form, only waits for the database container to start, not for Postgres to accept connections, which is why stacks with fresh volumes fail intermittently in CI. To wait for readiness, use &lt;code&gt;depends_on&lt;/code&gt; with &lt;code&gt;condition: service_healthy&lt;/code&gt; and add a &lt;code&gt;healthcheck&lt;/code&gt; to the database service. For the official Postgres image, run &lt;code&gt;pg_isready -h 127.0.0.1&lt;/code&gt; so the check goes over TCP. Without &lt;code&gt;-h&lt;/code&gt;, it can pass against the temporary socket-only server the image runs during first-boot initialization. In my case that one flag took a 50-run loop from 2 failures to 0.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>tutorial</category>
      <category>tooling</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Ollama num_ctx Truncated 287 of 400 Prompts and Never Told Me</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sat, 10 Oct 2026 16:52:09 +0000</pubDate>
      <link>https://dev.to/ji_ai/ollama-numctx-truncated-287-of-400-prompts-and-never-told-me-4fi4</link>
      <guid>https://dev.to/ji_ai/ollama-numctx-truncated-287-of-400-prompts-and-never-told-me-4fi4</guid>
      <description>&lt;p&gt;My local RAG bot answered 46% of my test questions correctly. Llama 3.1 8B on Ollama, a 128K context window on the model card, retrieved chunks that I had checked by hand. The right paragraph was in the prompt every single time.&lt;/p&gt;

&lt;p&gt;The model just never saw it. Ollama's &lt;code&gt;num_ctx&lt;/code&gt; was set to 2048 tokens, and Ollama quietly chopped the front off every prompt longer than that. No error, no field in the response, no warning in my Python logs. 287 of my 400 prompts got cut.&lt;/p&gt;

&lt;p&gt;This is the autopsy.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ollama &lt;code&gt;num_ctx&lt;/code&gt; is the context window Ollama actually allocates&lt;/strong&gt;, not the one on the model card. In the version I ran, the default was 2048 tokens even for a 128K model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Overflowing prompts are truncated silently from the front.&lt;/strong&gt; The tail survives, so the question stays and the instructions and top-ranked chunks vanish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spot it with &lt;code&gt;prompt_eval_count&lt;/code&gt;&lt;/strong&gt; in the response. If it sits at or just under your &lt;code&gt;num_ctx&lt;/code&gt; no matter how long your prompt is, you're being truncated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix it with &lt;code&gt;options: {"num_ctx": 8192}&lt;/code&gt;&lt;/strong&gt; on the native API, or &lt;code&gt;PARAMETER num_ctx 8192&lt;/code&gt; in a Modelfile. The OpenAI-compatible endpoint ignored my per-request setting.&lt;/li&gt;
&lt;li&gt;After the fix: accuracy went from 46% to 81% on the same 400 questions. Cost: about 1 GB more VRAM and a slower median response.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What was I building?
&lt;/h2&gt;

&lt;p&gt;A notes assistant. Around 3,000 markdown files from five years of work logs, chunked at roughly 400 tokens, embedded, top 10 chunks retrieved per question. Everything runs locally on a 12 GB GPU because I don't want my work notes leaving the machine.&lt;/p&gt;

&lt;p&gt;The prompt went to &lt;code&gt;/api/generate&lt;/code&gt; as one string, in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Instructions ("answer only from the context, reply in JSON with &lt;code&gt;answer&lt;/code&gt; and &lt;code&gt;source&lt;/code&gt;")&lt;/li&gt;
&lt;li&gt;The 10 retrieved chunks, best match first&lt;/li&gt;
&lt;li&gt;The question&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That ordering is the textbook layout. It's also exactly the wrong layout for what Ollama was doing to me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did the model ignore my instructions?
&lt;/h2&gt;

&lt;p&gt;It ignored them because it never received them. When a prompt exceeds &lt;code&gt;num_ctx&lt;/code&gt;, Ollama keeps the end of the input and discards tokens from the beginning to make it fit. My instructions lived at the beginning. So did chunk #1, the best match.&lt;/p&gt;

&lt;p&gt;The symptoms looked like a dumb model, not a broken pipeline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Answers came back as plain prose instead of JSON, about a third of the time.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;source&lt;/code&gt; field, when present, pointed at low-ranked chunks.&lt;/li&gt;
&lt;li&gt;Questions with obvious answers got confident, wrong replies built from chunks 7 through 10.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I spent an evening rewriting the system prompt. I added "IMPORTANT" in caps. I moved the JSON instruction to two places. Nothing changed, which in hindsight was the clue: the edits were landing in a region the model never read.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you detect Ollama prompt truncation?
&lt;/h2&gt;

&lt;p&gt;Check &lt;code&gt;prompt_eval_count&lt;/code&gt; in the response. It's the number of prompt tokens the model actually processed. If it's pinned at your context limit while your input keeps growing, Ollama is truncating.&lt;/p&gt;

&lt;p&gt;I logged it next to my own token estimate for every request:&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;requests&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ask&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;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;num_ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&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="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;body&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;model&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;llama3.1:8b&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;prompt&lt;/span&gt;&lt;span class="sh"&gt;"&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;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;num_ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;options&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&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;num_ctx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;num_ctx&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:11434/api/generate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&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="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ask&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;span class="nf"&gt;print&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;prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;4&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt_eval_count&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;The output was embarrassing in its regularity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;5120 2047
3890 2047
1410 1402
6230 2047
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every long prompt was capped at the same number. The short ones passed through intact. The server log had the confession all along, a line containing &lt;code&gt;truncating input prompt&lt;/code&gt; with the limit and the original length. I just never read the server log, because the API returned HTTP 200 with a perfectly normal-looking body.&lt;/p&gt;

&lt;h2&gt;
  
  
  How many of my prompts were affected?
&lt;/h2&gt;

&lt;p&gt;287 of 400, or 72%. With 10 chunks of about 400 tokens plus instructions, most prompts landed between 4,000 and 6,000 tokens. Only the questions whose retrieval returned short chunks fit under 2048.&lt;/p&gt;

&lt;p&gt;Splitting the eval by that line made the problem obvious:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Prompt length&lt;/th&gt;
&lt;th&gt;Questions&lt;/th&gt;
&lt;th&gt;Correct&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Under 2048 tokens&lt;/td&gt;
&lt;td&gt;113&lt;/td&gt;
&lt;td&gt;96 (85%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Over 2048 tokens&lt;/td&gt;
&lt;td&gt;287&lt;/td&gt;
&lt;td&gt;88 (31%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;400&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;184 (46%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The model wasn't bad at my task. The model was great at my task whenever it could see the task.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you set num_ctx in Ollama?
&lt;/h2&gt;

&lt;p&gt;Pass it per request in &lt;code&gt;options&lt;/code&gt; on the native API, or bake it into a model with a Modelfile. Both worked for me. One thing that did not work is the obvious one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 1: per request on the native API&lt;/strong&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="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ask&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;span class="n"&gt;num_ctx&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8192&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;Option 2: a Modelfile&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FROM llama3.1:8b
PARAMETER num_ctx 8192
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama create notes-llama &lt;span class="nt"&gt;-f&lt;/span&gt; Modelfile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then call &lt;code&gt;notes-llama&lt;/code&gt; instead of &lt;code&gt;llama3.1:8b&lt;/code&gt;. This is the version I kept, because it makes the setting impossible to forget in some other script.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What failed: the OpenAI-compatible endpoint&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My first fix was through &lt;code&gt;/v1/chat/completions&lt;/code&gt; with the OpenAI Python client, sending &lt;code&gt;num_ctx&lt;/code&gt; via &lt;code&gt;extra_body&lt;/code&gt;. &lt;code&gt;prompt_eval_count&lt;/code&gt; (exposed as &lt;code&gt;usage.prompt_tokens&lt;/code&gt;) stayed at 2047. That endpoint didn't honor the option in my setup. If you use Ollama behind an OpenAI-style client, set the context in a Modelfile or at the server level. Newer Ollama releases added an &lt;code&gt;OLLAMA_CONTEXT_LENGTH&lt;/code&gt; environment variable for the server-wide default, and raised the default itself, so check what your version does instead of trusting either my number or the model card.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did the bigger context window cost?
&lt;/h2&gt;

&lt;p&gt;About 1 GB of VRAM and roughly 1.5 seconds of median latency. The KV cache scales linearly with &lt;code&gt;num_ctx&lt;/code&gt;, so a 4x window costs 4x the cache.&lt;/p&gt;

&lt;p&gt;For Llama 3.1 8B at fp16 the math is short: 32 layers × 8 KV heads × 128 dims × 2 (K and V) × 2 bytes = 128 KB per token.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;2048 tokens: 256 MB of KV cache&lt;/li&gt;
&lt;li&gt;8192 tokens: 1 GB of KV cache&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;ollama ps&lt;/code&gt; moved from about 5.9 GB to 6.8 GB on my machine, which still fits the 12 GB card with room for the embedding model. Median end-to-end time per question went from 1.9 s to 3.4 s, because the model now actually evaluates 5,000 prompt tokens instead of 2,000. That's not a regression. That's the price of reading the whole prompt, which I thought I was already paying.&lt;/p&gt;

&lt;h2&gt;
  
  
  What were the results after the fix?
&lt;/h2&gt;

&lt;p&gt;81% correct, 324 of 400, same questions, same retrieval, same prompt text. JSON format compliance went from about two thirds to every response but 3. The &lt;code&gt;source&lt;/code&gt; field started pointing at chunk #1 or #2 most of the time, which is what you'd hope for when your retriever is any good.&lt;/p&gt;

&lt;p&gt;Not everything was fixed. The remaining 76 misses were real retrieval failures and a few questions my notes genuinely can't answer. Those are honest problems. The 140 extra correct answers were never a model problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should you change in your own Ollama setup?
&lt;/h2&gt;

&lt;p&gt;Three habits, all cheap:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Assert on &lt;code&gt;prompt_eval_count&lt;/code&gt;.&lt;/strong&gt; If it's within a few tokens of &lt;code&gt;num_ctx&lt;/code&gt;, raise or at least log loudly. One &lt;code&gt;if&lt;/code&gt; statement would have saved me an evening of prompt engineering against a wall.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put the question and the critical instructions at the end&lt;/strong&gt; of single-string prompts. If something gets cut, it should be the lowest-ranked chunk, not your output format.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;num_ctx&lt;/code&gt; explicitly everywhere.&lt;/strong&gt; Model card context length is a ceiling, not a default. Pick a number your VRAM can carry and write it down in a Modelfile.
&lt;/li&gt;
&lt;/ol&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;ask_checked&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;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;num_ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8192&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ask&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;span class="n"&gt;num_ctx&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;num_ctx&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;resp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt_eval_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;num_ctx&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="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&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;prompt truncated at &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;prompt_eval_count&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; tokens&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;resp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  So why did Ollama truncate my prompts without telling me?
&lt;/h2&gt;

&lt;p&gt;Ollama truncated 287 of my 400 prompts because &lt;code&gt;num_ctx&lt;/code&gt;, the context window it actually allocates, defaulted to 2048 tokens in my version, regardless of the model's advertised 128K. When a prompt exceeds &lt;code&gt;num_ctx&lt;/code&gt;, Ollama drops tokens from the front, keeps the tail, and returns a normal HTTP 200; the only traces are a server log line and a &lt;code&gt;prompt_eval_count&lt;/code&gt; stuck near the limit. Setting &lt;code&gt;num_ctx&lt;/code&gt; to 8192 via request &lt;code&gt;options&lt;/code&gt; or a Modelfile &lt;code&gt;PARAMETER&lt;/code&gt; (not the OpenAI-compatible endpoint, which ignored it for me) raised my RAG accuracy from 46% to 81% for about 1 GB of extra VRAM.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>python</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>bge-reranker Truncates at 512 Tokens: 31% of My Chunks Got Cut</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sat, 10 Oct 2026 04:50:26 +0000</pubDate>
      <link>https://dev.to/ji_ai/bge-reranker-truncates-at-512-tokens-31-of-my-chunks-got-cut-2gm9</link>
      <guid>https://dev.to/ji_ai/bge-reranker-truncates-at-512-tokens-31-of-my-chunks-got-cut-2gm9</guid>
      <description>&lt;p&gt;My RAG pipeline had a question it kept getting wrong. "How do I fix the replication lag alert on the orders DB?" The answer was in my runbooks. Retrieval found the right chunk at rank 3. Then the reranker moved it to rank 14, my top-5 cutoff threw it away, and the LLM confidently answered from a chunk about a &lt;em&gt;different&lt;/em&gt; database.&lt;/p&gt;

&lt;p&gt;The reranker wasn't dumb. It was blind. My bge-reranker truncates at 512 tokens, and the fix commands lived at the bottom of a chunk it never finished reading. When I counted, 31% of my chunks were longer than what the reranker could see.&lt;/p&gt;

&lt;p&gt;This post is about that one mechanism: why a cross-encoder reranker truncates at 512 tokens, which part of your text it throws away, and what to do about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Cross-encoder rerankers like &lt;code&gt;BAAI/bge-reranker-base&lt;/code&gt; read the query and passage as &lt;strong&gt;one sequence&lt;/strong&gt; capped at 512 tokens, including 4 special tokens.&lt;/li&gt;
&lt;li&gt;When the pair is too long, the Hugging Face tokenizer uses &lt;code&gt;longest_first&lt;/code&gt; truncation, which trims the &lt;strong&gt;end of the passage&lt;/strong&gt;. No error, no warning.&lt;/li&gt;
&lt;li&gt;Your chunker, your embedding model and your reranker usually measure length with three different rulers. A chunk that fits one can overflow another.&lt;/li&gt;
&lt;li&gt;Detect it by tokenizing &lt;code&gt;(query, passage)&lt;/code&gt; with the &lt;strong&gt;reranker's own tokenizer&lt;/strong&gt; and logging how often the length exceeds &lt;code&gt;max_length&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Fix it by chunking to the reranker's budget, or by scoring overlapping windows of the passage and taking the max.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How did my reranker bury the right chunk?
&lt;/h2&gt;

&lt;p&gt;Here's the setup, because the details are where this bug hides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Corpus:&lt;/strong&gt; about 2,400 chunks from internal runbooks and incident notes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chunker:&lt;/strong&gt; LangChain's &lt;code&gt;RecursiveCharacterTextSplitter&lt;/code&gt;, &lt;code&gt;chunk_size=2000&lt;/code&gt;. Characters, not tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrieval:&lt;/strong&gt; &lt;code&gt;text-embedding-3-small&lt;/code&gt;, top 20 by cosine similarity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reranking:&lt;/strong&gt; &lt;code&gt;CrossEncoder("BAAI/bge-reranker-base")&lt;/code&gt; from sentence-transformers, keep top 5.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I had 60 hand-labeled questions. Retrieval put the correct chunk somewhere in the top 20 for 54 of them. After reranking, the correct chunk made the top 5 for only 41.&lt;/p&gt;

&lt;p&gt;So the reranker was &lt;em&gt;losing&lt;/em&gt; answers that retrieval had already found. That's backwards. The whole point of a reranker is to be the smarter, slower second pass.&lt;/p&gt;

&lt;p&gt;I went through the 13 losses by hand. In 9 of them, the sentence that actually answered the question sat in the last third of the chunk. Runbooks are written that way: title, symptoms, context, and then, at the very bottom, the &lt;code&gt;## Resolution&lt;/code&gt; section with the commands you need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a cross-encoder reranker truncate at 512 tokens?
&lt;/h2&gt;

&lt;p&gt;A cross-encoder reranker truncates at 512 tokens because it is a BERT-style encoder with a fixed number of position embeddings, and it reads the query and passage together as a single input. &lt;code&gt;bge-reranker-base&lt;/code&gt; is built on XLM-RoBERTa, which was trained with 512 positions. Anything past that has no position to sit in, so the tokenizer cuts it before the model runs.&lt;/p&gt;

&lt;p&gt;This is different from an embedding model (a bi-encoder). A bi-encoder embeds the query and the passage separately. A cross-encoder concatenates them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;s&amp;gt; query tokens &amp;lt;/s&amp;gt;&amp;lt;/s&amp;gt; passage tokens &amp;lt;/s&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's 4 special tokens for XLM-RoBERTa pairs. So your real passage budget is:&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="mi"&gt;512&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;4&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;query_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 30-token question leaves 478 tokens for the passage. That joint attention over query and passage is exactly why cross-encoders rank better than cosine similarity. It's also why they have this hard ceiling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which part of the passage gets cut?
&lt;/h2&gt;

&lt;p&gt;The end of the passage gets cut. sentence-transformers' &lt;code&gt;CrossEncoder&lt;/code&gt; calls the tokenizer with &lt;code&gt;truncation=True&lt;/code&gt;, which in Hugging Face means the &lt;code&gt;longest_first&lt;/code&gt; strategy. It removes tokens one at a time from whichever sequence is currently longer. Your query is 30 tokens and your passage is 700, so the passage loses its tail every single time.&lt;/p&gt;

&lt;p&gt;You can watch it happen:&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;transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AutoTokenizer&lt;/span&gt;

&lt;span class="n"&gt;tok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AutoTokenizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_pretrained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BAAI/bge-reranker-base&lt;/span&gt;&lt;span class="sh"&gt;"&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="nf"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;passage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;truncation&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;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input_ids&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;skip_special_tokens&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="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;:])&lt;/span&gt;  &lt;span class="c1"&gt;# the last thing the reranker actually read
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I ran this on my replication-lag chunk, the last thing the reranker read was the middle of a paragraph explaining what replication lag &lt;em&gt;is&lt;/em&gt;. The &lt;code&gt;pg_stat_replication&lt;/code&gt; query and the fix were gone. The model scored "a chunk that explains the concept" against "a short chunk about a different DB that mentions the alert name in full," and the short chunk won. That's a reasonable judgment on the text it was given.&lt;/p&gt;

&lt;p&gt;The silent part is what makes this nasty. With &lt;code&gt;truncation=True&lt;/code&gt;, the tokenizer does not warn you. Your scores look like normal floats. Nothing in your logs says "I only read 60% of this."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why didn't retrieval catch the problem?
&lt;/h2&gt;

&lt;p&gt;Retrieval didn't catch it because every stage measured the chunk with a different ruler:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Unit&lt;/th&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chunker&lt;/td&gt;
&lt;td&gt;characters&lt;/td&gt;
&lt;td&gt;2,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;text-embedding-3-small&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;OpenAI tokens&lt;/td&gt;
&lt;td&gt;8,191&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bge-reranker-base&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;XLM-RoBERTa tokens, query included&lt;/td&gt;
&lt;td&gt;512&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The embedding model saw the whole chunk, including the Resolution section, and ranked it well. The reranker saw a truncated version of the same chunk and ranked it badly. The stage with the smallest window had the final say.&lt;/p&gt;

&lt;p&gt;The character limit made it worse. 2,000 characters of English prose fits under 512 tokens comfortably. 2,000 characters of runbook does not. Shell commands, hostnames, UUIDs, stack traces and YAML break into many more subword tokens than ordinary words do. My chunks that overflowed were almost all the operational ones, which are exactly the ones people ask about.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you check if your reranker is truncating?
&lt;/h2&gt;

&lt;p&gt;Tokenize every &lt;code&gt;(query, chunk)&lt;/code&gt; pair with the reranker's tokenizer, without truncation, and count how many exceed the limit. It takes a few minutes on a few thousand chunks.&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;transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AutoTokenizer&lt;/span&gt;

&lt;span class="n"&gt;tok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AutoTokenizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_pretrained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BAAI/bge-reranker-base&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;MAX_LEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;
&lt;span class="n"&gt;TYPICAL_QUERY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;how do I fix the replication lag alert on the orders db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;passage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TYPICAL_QUERY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;passage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;truncation&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input_ids&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="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;MAX_LEN&lt;/span&gt;

&lt;span class="n"&gt;over&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;chunks&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;overflow&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="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nf"&gt;print&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="si"&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;over&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&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;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; chunks truncated &lt;/span&gt;&lt;span class="sh"&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;(&lt;/span&gt;&lt;span class="si"&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;over&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;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mine printed 31%. Two more checks worth adding:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Log it in production.&lt;/strong&gt; Record the untruncated pair length for every reranked candidate. A truncation rate is a metric you can alert on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the &lt;code&gt;max_length&lt;/code&gt; you actually pass.&lt;/strong&gt; Wrappers and frameworks set their own defaults. If you swap in a reranker trained on longer inputs but your wrapper still passes 512, you're paying for a model you're not using.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How do you fix reranker truncation?
&lt;/h2&gt;

&lt;p&gt;There are three fixes, and I ended up using the first two together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Chunk to the reranker's budget, measured with the reranker's tokenizer.&lt;/strong&gt; Reserve room for the query and the special tokens, then cap passages at what's left.&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;QUERY_RESERVE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;      &lt;span class="c1"&gt;# longest query you expect, in reranker tokens
&lt;/span&gt;&lt;span class="n"&gt;PASSAGE_BUDGET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;QUERY_RESERVE&lt;/span&gt;   &lt;span class="c1"&gt;# 444
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;token_len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&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="nf"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;add_special_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input_ids&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="n"&gt;splitter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RecursiveCharacterTextSplitter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;chunk_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;PASSAGE_BUDGET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;chunk_overlap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;length_function&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;token_len&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one-line change that matters is &lt;code&gt;length_function=token_len&lt;/code&gt;. Now the chunker and the reranker use the same ruler.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Score overlapping windows and take the max.&lt;/strong&gt; Some documents shouldn't be split, or you can't re-index right now. Slide a window over the passage, score each window, and keep the highest. In IR research this is known as MaxP scoring.&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;windowed_score&lt;/span&gt;&lt;span class="p"&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;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;passage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stride&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;passage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;add_special_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input_ids&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="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;window&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;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;predict&lt;/span&gt;&lt;span class="p"&gt;([(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;passage&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="n"&gt;starts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;range&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="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;window&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;stride&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;starts&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="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;ids&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;starts&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;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# always cover the tail
&lt;/span&gt;
    &lt;span class="n"&gt;windows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&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;s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;window&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;starts&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;float&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="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;predict&lt;/span&gt;&lt;span class="p"&gt;([(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&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;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;windows&lt;/span&gt;&lt;span class="p"&gt;])))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch the &lt;code&gt;starts.append&lt;/code&gt; line. Without it, a passage whose length isn't a neat multiple of the stride loses its last few dozen tokens. That would be the same bug you're fixing, just smaller.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Use a reranker trained on longer inputs.&lt;/strong&gt; This works, but read the cost. Self-attention scales with the square of sequence length, and you run the reranker once per candidate. Going from 512 to 2,048 tokens on 20 candidates per query is a real latency change, so measure it on your own hardware before shipping.&lt;/p&gt;

&lt;p&gt;After re-chunking with the reranker's tokenizer and using windowed scoring for the few long documents I kept whole, the correct chunk made the top 5 for 51 of my 60 questions, up from 41. Same embedding model, same reranker, same LLM. The only difference was that the reranker could now read the whole chunk.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should you take away from this?
&lt;/h2&gt;

&lt;p&gt;Every model in a RAG pipeline has a context window, and the smallest one controls quality. People check the LLM's window and maybe the embedding model's. Almost nobody checks the reranker's, because it returns a clean float either way.&lt;/p&gt;

&lt;p&gt;If your documents put the important part at the end, like runbooks, API docs with examples at the bottom, or support tickets with the resolution last, you're in the worst case for &lt;code&gt;longest_first&lt;/code&gt; truncation.&lt;/p&gt;

&lt;h2&gt;
  
  
  So does bge-reranker really truncate at 512 tokens?
&lt;/h2&gt;

&lt;p&gt;Yes. &lt;code&gt;bge-reranker-base&lt;/code&gt; and similar BERT- and XLM-RoBERTa-based cross-encoders read the query and passage as one sequence capped at 512 tokens, including special tokens. sentence-transformers truncates with &lt;code&gt;longest_first&lt;/code&gt;, which silently drops the end of the passage. If your chunks are sized in characters, or in another model's tokens, a meaningful share of them may be partly invisible to your reranker, and any answer in that tail can't help the score. Measure overflow with the reranker's own tokenizer, size chunks to its budget, and use overlapping-window max scoring for passages you can't split.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>machinelearning</category>
      <category>ai</category>
      <category>python</category>
    </item>
    <item>
      <title>LLM-as-a-Judge: Claude Gave 68% of Answers a 7 or 8 Out of 10</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Fri, 09 Oct 2026 16:48:15 +0000</pubDate>
      <link>https://dev.to/ji_ai/llm-as-a-judge-claude-gave-68-of-answers-a-7-or-8-out-of-10-2097</link>
      <guid>https://dev.to/ji_ai/llm-as-a-judge-claude-gave-68-of-answers-a-7-or-8-out-of-10-2097</guid>
      <description>&lt;p&gt;A user emailed me a screenshot of two of their reports side by side. One session they'd nailed. One they'd bombed so badly they quit halfway. Both said "7/10."&lt;/p&gt;

&lt;p&gt;That's when I learned my LLM-as-a-judge wasn't judging anything. It was printing a seven with extra steps.&lt;/p&gt;

&lt;p&gt;I pulled every score the system had ever produced: 1,140 scored answers. 41% were a 7. 27% were an 8. Anything below 5 showed up 3% of the time. My carefully written grading prompt had become a very expensive random number generator with a strong opinion about the number 7.&lt;/p&gt;

&lt;p&gt;Here's what I measured, the four fixes I tried, and the one that actually worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LLM-as-a-judge on an unanchored 1-10 scale collapses into 7s and 8s.&lt;/strong&gt; Mine put 68% of 1,140 answers there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Telling the model to "be strict" doesn't fix it.&lt;/strong&gt; It moves the whole pile down one point. Ranking quality barely changed (Spearman 0.41 → 0.44).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A 1-5 scale with a written description for every level&lt;/strong&gt; jumped agreement with my hand grades to 0.68.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Binary checklist items, summed in code&lt;/strong&gt;, hit 0.79, close to the 0.83 agreement between two human graders. Rerun noise dropped from 0.9 to 0.31 points.&lt;/li&gt;
&lt;li&gt;The cost: scoring got ~75% more expensive per session, and the checklist still mis-scores short, unusual answers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What was I actually scoring?
&lt;/h2&gt;

&lt;p&gt;The system is &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, the interview practice platform I run (full disclosure: I built it). It runs a voice interview, transcribes each answer, and an LLM scores it on a few dimensions before writing a report. If the scores are mush, the report is mush, and the whole product is a chatbot that says "nice job."&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%2Ftd5zed80xfjpv7qjalsn.jpg" 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%2Ftd5zed80xfjpv7qjalsn.jpg" alt="Preterview — an interview session in progress" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The original judge prompt was what most people write the first time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Score the candidate's answer from 1 to 10 on:
- clarity
- specificity
- relevance
Return JSON: {"clarity": int, "specificity": int, "relevance": int, "feedback": str}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clean. Readable. Useless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does LLM-as-a-judge cluster scores at 7 out of 10?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Because a number with no definition defaults to the model's prior for "fine," and that prior is about 7.&lt;/strong&gt; Without anchors, "6" and "8" don't mean anything specific, so the model reaches for the safe middle-high answer that sounds supportive without sounding fake.&lt;/p&gt;

&lt;p&gt;A few things stack up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No anchors.&lt;/strong&gt; What's the difference between a 6 and a 7 for "specificity"? I never said. The model can't either.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leniency.&lt;/strong&gt; Instruction-tuned models are trained to be helpful and pleasant. Handing a struggling person a 3 feels rude. A 7 never offends anyone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Too many levels.&lt;/strong&gt; Ten buckets is more resolution than either of us can use consistently. The extra buckets just become noise around the mode.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's what my production distribution looked like before any fixes:&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you test whether an LLM judge is accurate?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Build a small hand-graded calibration set and measure rank agreement against it.&lt;/strong&gt; You don't need thousands of examples. You need a set where you already know the right ordering.&lt;/p&gt;

&lt;p&gt;I took 60 real answers (with permission, anonymized) and graded them myself on a 1-5 scale, deliberately picking 12 per level so the set was spread out. A friend who has run a lot of hiring loops graded them blind. Our agreement (Spearman ρ) was &lt;strong&gt;0.83&lt;/strong&gt;. That's the ceiling. No judge prompt should be expected to beat two humans agreeing with each other.&lt;/p&gt;

&lt;p&gt;Then I ran every prompt variant over all 60 answers, five times each, and tracked three numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Spearman ρ&lt;/strong&gt; against my grades (does it rank answers correctly?)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Share of scores at the mode&lt;/strong&gt; (is it collapsing?)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rerun standard deviation&lt;/strong&gt; (does the same answer get the same score?)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The baseline prompt on the calibration set: ρ = 0.41, and 71% of answers got a 7 or 8 even though I'd picked the set to be evenly spread. Rerun std dev: 0.9 points. One answer scored 5, 7, 8, 7, 6 across five runs. Same text, same prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: "Be strict." Did it work?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;No. It shifted every score down about one point and left the ranking almost unchanged.&lt;/strong&gt; ρ went from 0.41 to 0.44, which is inside the noise.&lt;/p&gt;

&lt;p&gt;I added "You are a demanding senior interviewer. Most answers deserve below 6." The mode moved from 7 to 6. That's it. The pile slid left as one block. Bad answers and good answers were still crammed together, just at a lower altitude.&lt;/p&gt;

&lt;p&gt;This is the most common fix I see recommended and it's basically a cosmetic filter. You get scores that &lt;em&gt;look&lt;/em&gt; harsher, which feels like rigor. It isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: A 1-5 scale with anchored levels
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;This was the first real improvement: ρ jumped to 0.68.&lt;/strong&gt; I cut the scale to five levels and wrote a concrete, observable description for each one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;specificity:
1 = No concrete example. Speaks in generalities ("I'm a team player").
2 = Mentions a situation but no actions or outcome.
3 = Situation and actions, but the outcome is vague ("it went well").
4 = Situation, own actions, and a concrete outcome.
5 = All of 4, plus a measurable result and what they'd do differently.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trick is that each level describes something the model can &lt;em&gt;check&lt;/em&gt; in the transcript, not a vibe like "good" or "excellent." A 3 now means a specific thing is missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3: Anchors plus reference answers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Adding two graded example answers per dimension pushed ρ to 0.74.&lt;/strong&gt; I included one real answer I'd graded a 2 and one I'd graded a 4, with a one-line explanation each.&lt;/p&gt;

&lt;p&gt;The downside: the prompt grew from about 1,100 tokens to about 3,400. Since that block is identical on every call, it caches well, so the real cost increase was small. The bigger issue was that the model started pattern-matching to the examples: answers that &lt;em&gt;looked&lt;/em&gt; like my "4" example got 4s even when the content was weaker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 4: Binary checklists, scored in code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The winner: ρ = 0.79, rerun std dev 0.31, and no pile-up at any single level.&lt;/strong&gt; Instead of asking for a number, I asked the model a set of yes/no questions and computed the score myself.&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;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="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Check&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;passed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;
    &lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;  &lt;span class="c1"&gt;# exact quote from the transcript, or "" if none
&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SpecificityChecks&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;names_a_specific_situation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Check&lt;/span&gt;
    &lt;span class="n"&gt;describes_own_actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Check&lt;/span&gt;     &lt;span class="c1"&gt;# "I", not "we"
&lt;/span&gt;    &lt;span class="n"&gt;states_an_outcome&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Check&lt;/span&gt;
    &lt;span class="n"&gt;outcome_is_measurable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Check&lt;/span&gt;
    &lt;span class="n"&gt;includes_a_reflection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Check&lt;/span&gt;

&lt;span class="n"&gt;WEIGHTS&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;names_a_specific_situation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;describes_own_actions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;states_an_outcome&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;outcome_is_measurable&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;includes_a_reflection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&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;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SpecificityChecks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;total&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;WEIGHTS&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="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;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;checks&lt;/span&gt;
        &lt;span class="k"&gt;if&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;passed&lt;/span&gt; &lt;span class="ow"&gt;and&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;evidence&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="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&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;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 0-5 checks → 1-5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details mattered more than I expected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Require evidence.&lt;/strong&gt; A check only counts if the model quotes the transcript. Without that rule, the model happily marked "states an outcome" as true for answers that didn't have one. Forcing a quote killed most of the generous passes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Yes/no is easier than 1-10.&lt;/strong&gt; "Did they state a measurable result?" has a right answer. "How specific was this, out of 10?" doesn't. Models are far more consistent on the first kind of question, which is exactly why rerun noise dropped by two thirds.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What did the checklist approach break?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Short, unusual, genuinely strong answers.&lt;/strong&gt; On the calibration set, 5 of 60 answers came out 2 or more levels off my grade, and 4 of those 5 were concise answers from strong candidates.&lt;/p&gt;

&lt;p&gt;One answer was two sentences: a precise problem, a precise fix, a number. I graded it a 5. The checklist gave it a 3 because there was no "reflection" and the actions were implied rather than spelled out with "I did X." A checklist rewards structure, and structure correlates with length. I haven't fully solved this. My current patch is a separate check for "complete despite being brief," and it helps on about half of those cases.&lt;/p&gt;

&lt;p&gt;Other honest costs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Price.&lt;/strong&gt; Evidence quotes mean more output tokens. Scoring went from about $0.012 to $0.021 per session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Users noticed.&lt;/strong&gt; The average score dropped, and in the first week I got a handful of "this feels harsh" emails. Nobody had ever emailed to say a 7 felt too generous. That asymmetry is the whole problem in one sentence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checklists need maintenance.&lt;/strong&gt; Every new dimension means writing and validating new checks against the calibration set, not just adding a word to a prompt.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  So how do you stop an LLM-as-a-judge from giving everything a 7?
&lt;/h2&gt;

&lt;p&gt;Stop asking it for a number. An LLM-as-a-judge on an unanchored 1-10 scale will cluster around 7 because "7" is the model's default for "acceptable," and telling it to be strict only slides the cluster down. Replace the scale with concrete yes/no checks that each require a quoted piece of evidence, compute the score in your own code, and validate the result against a small hand-graded set where you know the right ranking. In my case that took rank agreement with human grades from 0.41 to 0.79, against a human-vs-human ceiling of 0.83, at the cost of higher token spend and a known blind spot for brilliant short answers.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>machinelearning</category>
      <category>python</category>
    </item>
    <item>
      <title>GitHub Actions GITHUB_TOKEN Won't Trigger Workflows: 0 of 11 Releases</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Thu, 08 Oct 2026 04:46:24 +0000</pubDate>
      <link>https://dev.to/ji_ai/github-actions-githubtoken-wont-trigger-workflows-0-of-11-releases-42em</link>
      <guid>https://dev.to/ji_ai/github-actions-githubtoken-wont-trigger-workflows-0-of-11-releases-42em</guid>
      <description>&lt;p&gt;Eleven releases. Eleven green "Create Release" runs. Eleven tags sitting on GitHub. And my npm package was still on the version from three weeks earlier.&lt;/p&gt;

&lt;p&gt;The publish workflow had never run once. No failure, no red X, no email. It simply didn't exist in the Actions tab. My release workflow created tags and releases with the default &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;, and &lt;strong&gt;GITHUB_TOKEN won't trigger workflows&lt;/strong&gt;. That's a deliberate rule, not a bug, and it took me an embarrassing amount of time to find it.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Events created with the built-in &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; (pushes, tags, releases, pull requests, comments) &lt;strong&gt;do not start new workflow runs&lt;/strong&gt;. The two exceptions are &lt;code&gt;workflow_dispatch&lt;/code&gt; and &lt;code&gt;repository_dispatch&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;GitHub does this on purpose so a workflow can't trigger itself in an infinite loop.&lt;/li&gt;
&lt;li&gt;The symptom is silence: no failed run, just a missing one. Bot-opened PRs show required checks stuck on "Expected — Waiting for status to be reported".&lt;/li&gt;
&lt;li&gt;Fixes: use a GitHub App token, a fine-grained PAT, an explicit &lt;code&gt;workflow_dispatch&lt;/code&gt; call, or merge the two workflows into one job.&lt;/li&gt;
&lt;li&gt;If you switch to a token that &lt;em&gt;does&lt;/em&gt; trigger workflows, add a loop guard.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What exactly broke?
&lt;/h2&gt;

&lt;p&gt;My setup looked like what half the internet runs:&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;# release.yml&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&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;release&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;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&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="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;VERSION=$(node -p "require('./package.json').version")&lt;/span&gt;
          &lt;span class="s"&gt;git tag "v$VERSION"&lt;/span&gt;
          &lt;span class="s"&gt;git push origin "v$VERSION"&lt;/span&gt;
          &lt;span class="s"&gt;gh release create "v$VERSION" --generate-notes&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# publish.yml&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;v*'&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;publish&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;npm publish&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tag push is real. You can see the tag in the repo. But &lt;code&gt;publish.yml&lt;/code&gt; never fires, because the push was authenticated with &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;. Same story if &lt;code&gt;publish.yml&lt;/code&gt; listened on &lt;code&gt;release: types: [published]&lt;/code&gt; instead. The release exists. The event is swallowed.&lt;/p&gt;

&lt;p&gt;The kicker: when I pushed a tag by hand from my laptop to test it, publish ran perfectly. So my "test" proved the workflow was fine, and I went looking for bugs in the wrong file for an hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why won't GITHUB_TOKEN trigger workflows?
&lt;/h2&gt;

&lt;p&gt;GitHub blocks it to prevent recursive runs. If a workflow on &lt;code&gt;push&lt;/code&gt; could push a commit with &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; and trigger itself, one formatting bot would spin forever and burn everyone's minutes.&lt;/p&gt;

&lt;p&gt;So the rule is blunt: anything your workflow does with &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; is treated as "the workflow did it," and the workflow system ignores it as a trigger. The only events that still fire are &lt;code&gt;workflow_dispatch&lt;/code&gt; and &lt;code&gt;repository_dispatch&lt;/code&gt;, because those are explicit "please run this" requests, not side effects.&lt;/p&gt;

&lt;p&gt;This applies no matter how the token reaches the API:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;git push&lt;/code&gt; after &lt;code&gt;actions/checkout&lt;/code&gt; (checkout stores &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; in the local git config by default)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gh&lt;/code&gt; CLI with &lt;code&gt;GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;actions/github-script&lt;/code&gt; using the default token&lt;/li&gt;
&lt;li&gt;Any marketplace action that defaults to &lt;code&gt;github.token&lt;/code&gt;, which is most of them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is the sneaky one. Plenty of release and PR-bot actions take a &lt;code&gt;token&lt;/code&gt; input that silently defaults to &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;. You never typed it, so you never thought about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which GitHub Actions workflows are affected?
&lt;/h2&gt;

&lt;p&gt;Any workflow whose trigger is an event a bot produced. The common casualties I've hit or watched others hit:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Tag-triggered publish jobs.&lt;/strong&gt; A release workflow pushes &lt;code&gt;v1.2.3&lt;/code&gt;, the &lt;code&gt;on: push: tags&lt;/code&gt; workflow never runs. My exact bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;release: published&lt;/code&gt; deploys.&lt;/strong&gt; A release-automation action creates the release, the deploy listening for it stays idle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI on bot-opened pull requests.&lt;/strong&gt; A workflow opens a PR (version bumps, generated code, dependency syncs) using &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;. Your &lt;code&gt;pull_request&lt;/code&gt; CI never runs on it. If those checks are required by branch protection, the PR sits forever on "Expected — Waiting for status to be reported" and nobody can merge it without admin override.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commit-then-test chains.&lt;/strong&gt; A formatter or codegen job commits back to the branch, and the follow-up test workflow on &lt;code&gt;push&lt;/code&gt; doesn't run on the new commit. You end up merging an untested commit that &lt;em&gt;looks&lt;/em&gt; tested because the previous commit was green.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comment-driven bots.&lt;/strong&gt; A workflow posts an issue comment hoping to kick an &lt;code&gt;issue_comment&lt;/code&gt; workflow. Nothing happens.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The pattern: if a human did the same action, it would work. If your workflow did it with the default token, it won't.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I make a GitHub Actions push trigger another workflow?
&lt;/h2&gt;

&lt;p&gt;You have four real options. Pick based on who should "own" the action.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 1: Use a GitHub App installation token (my pick)
&lt;/h3&gt;

&lt;p&gt;Create a small GitHub App, install it on the repo with the permissions you need (Contents: write, Pull requests: write), and store its ID and private key. Then mint a short-lived token inside the job:&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;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/create-github-app-token@v1&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-token&lt;/span&gt;
    &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.RELEASE_APP_ID }}&lt;/span&gt;
      &lt;span class="na"&gt;private-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.RELEASE_APP_PRIVATE_KEY }}&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="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.app-token.outputs.token }}&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="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;git tag "v$VERSION"&lt;/span&gt;
      &lt;span class="s"&gt;git push origin "v$VERSION"&lt;/span&gt;
      &lt;span class="s"&gt;gh release create "v$VERSION" --generate-notes&lt;/span&gt;
    &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.app-token.outputs.token }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Passing the token to &lt;code&gt;actions/checkout&lt;/code&gt; matters. Checkout persists whatever token it was given into the git config, so the later &lt;code&gt;git push&lt;/code&gt; uses the App identity instead of &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;. Forget that line and you've fixed &lt;code&gt;gh&lt;/code&gt; but not &lt;code&gt;git push&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Why I prefer this: the token expires in an hour, it isn't tied to a person who might leave, and the commits show up as &lt;code&gt;your-app[bot]&lt;/code&gt;, which makes loop guards easy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 2: Use a fine-grained personal access token
&lt;/h3&gt;

&lt;p&gt;Same idea, faster to set up. Create a fine-grained PAT scoped to the one repo, store it as a secret, and use it in place of &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; in both checkout and &lt;code&gt;gh&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The trade-off is ownership. Every action shows up as you. It expires on whatever schedule you set, and the day it does, your releases go silent again. When you leave the team, so does the token.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 3: Call &lt;code&gt;workflow_dispatch&lt;/code&gt; explicitly
&lt;/h3&gt;

&lt;p&gt;Since &lt;code&gt;workflow_dispatch&lt;/code&gt; is one of the two exceptions, you can keep &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; and trigger the downstream workflow by name:&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;# publish.yml&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;v*'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;tag&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="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# end of release.yml&lt;/span&gt;
&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;   &lt;span class="c1"&gt;# required to dispatch workflows&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;gh workflow run publish.yml --ref "v$VERSION" -f tag="v$VERSION"&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No extra secrets. The catch: the &lt;code&gt;actions: write&lt;/code&gt; permission is easy to forget, and the coupling is now explicit, so renaming &lt;code&gt;publish.yml&lt;/code&gt; breaks the release job.&lt;/p&gt;

&lt;p&gt;This doesn't help with bot-opened PRs. You can dispatch CI, but branch protection waits for checks reported against the PR, so for that case use Fix 1 or 2.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 4: Don't chain at all
&lt;/h3&gt;

&lt;p&gt;Often the cleanest answer. If publish always follows release, make it a second job in the same workflow:&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;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;release&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# ...creates the tag&lt;/span&gt;
    &lt;span class="na"&gt;outputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.v.outputs.version }}&lt;/span&gt;
  &lt;span class="na"&gt;publish&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;release&lt;/span&gt;
    &lt;span class="c1"&gt;# ...npm publish&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No events, no tokens, no invisible walls. I ended up doing this for npm publish and keeping the App token only for the PR bot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Won't a non-GITHUB_TOKEN push cause an infinite loop?
&lt;/h2&gt;

&lt;p&gt;It can, which is exactly why GitHub put the wall there. Once you swap in an App token or PAT, a workflow that commits on &lt;code&gt;push&lt;/code&gt; will trigger itself again. Add a guard:&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;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.actor != 'release-bot[bot]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or put &lt;code&gt;[skip ci]&lt;/code&gt; in the bot's commit message. GitHub skips &lt;code&gt;push&lt;/code&gt; and &lt;code&gt;pull_request&lt;/code&gt; workflows for commits containing it. Or use &lt;code&gt;paths-ignore&lt;/code&gt; so changes to &lt;code&gt;CHANGELOG.md&lt;/code&gt; and &lt;code&gt;package.json&lt;/code&gt; don't retrigger the release workflow. One of the three, every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I spot this bug quickly?
&lt;/h2&gt;

&lt;p&gt;Look for a missing run, not a failed one. My checklist now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The downstream workflow has &lt;strong&gt;zero&lt;/strong&gt; runs for the event you expected, while the upstream run is green.&lt;/li&gt;
&lt;li&gt;Pushing the same tag or opening the same PR by hand makes it work.&lt;/li&gt;
&lt;li&gt;A bot PR shows required checks as "Expected" with no run attached.&lt;/li&gt;
&lt;li&gt;Grep the upstream workflow for &lt;code&gt;secrets.GITHUB_TOKEN&lt;/code&gt;, &lt;code&gt;github.token&lt;/code&gt;, and any action with an optional &lt;code&gt;token&lt;/code&gt; input. Assume the default is &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; until the README says otherwise.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  So why doesn't GITHUB_TOKEN trigger workflows?
&lt;/h2&gt;

&lt;p&gt;GITHUB_TOKEN won't trigger workflows because GitHub ignores every event created with it, apart from &lt;code&gt;workflow_dispatch&lt;/code&gt; and &lt;code&gt;repository_dispatch&lt;/code&gt;, to stop workflows from triggering themselves forever. Tags, releases, pushes and pull requests made with the default token are real, but they start no new runs. You won't see an error, just a missing run. To chain workflows, authenticate with a GitHub App token or fine-grained PAT (and pass it to &lt;code&gt;actions/checkout&lt;/code&gt; too), dispatch the downstream workflow explicitly with &lt;code&gt;actions: write&lt;/code&gt;, or merge both steps into one workflow with &lt;code&gt;needs:&lt;/code&gt;. If you switch tokens, add a loop guard before the next push.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>tooling</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>tiktoken Undercounts Claude Tokens: 37 of 418 Requests Blew the Limit</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Wed, 07 Oct 2026 16:44:27 +0000</pubDate>
      <link>https://dev.to/ji_ai/tiktoken-undercounts-claude-tokens-37-of-418-requests-blew-the-limit-46bd</link>
      <guid>https://dev.to/ji_ai/tiktoken-undercounts-claude-tokens-37-of-418-requests-blew-the-limit-46bd</guid>
      <description>&lt;p&gt;My nightly digest job died at 2:14 a.m. with a 400 and the message &lt;code&gt;prompt is too long&lt;/code&gt;. My packer had measured the prompt at 181,874 tokens, comfortably under the 200K window. Claude said it was 207,431.&lt;/p&gt;

&lt;p&gt;That's a 14% gap, and it came from one lazy decision I made months earlier: counting Claude tokens with tiktoken. It's fast and local, and it's already in every Python environment I own. On my code-heavy inputs it was also wrong in the same direction every single time.&lt;/p&gt;

&lt;p&gt;This post covers the failure, the audit of 600 chunks, and the fix that took me from 37 failures to zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;tiktoken is OpenAI's tokenizer.&lt;/strong&gt; Using &lt;code&gt;cl100k_base&lt;/code&gt; to count Claude tokens gives you a different vocabulary's answer, not an approximation of Claude's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On my corpus it undercounted by a median of 14%&lt;/strong&gt;: about 9% on prose, 16% on Python, 27% on JSON and lockfiles, and 38% on the worst single chunk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The error only bites when you pack close to the limit.&lt;/strong&gt; 61 of my 418 requests filled the budget, and 37 of those 61 failed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It skews cost estimates too.&lt;/strong&gt; My dashboard said $41.20 for the month. The invoice said $46.90.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; do a cheap tiktoken pass with a multiplier, then verify the fully assembled request with Anthropic's free &lt;code&gt;messages.count_tokens&lt;/code&gt; endpoint and trim until it fits.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What was I building?
&lt;/h2&gt;

&lt;p&gt;I have a Python job that runs every night across my side-project repos. For each repo it collects the day's diffs, the touched files, and any related docs, then packs them into one Claude request that writes a digest: what changed, what looks risky, what I forgot to finish.&lt;/p&gt;

&lt;p&gt;Packing was greedy. Sort the files by relevance and add them until the budget is full:&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="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;cl100k_base&lt;/span&gt;&lt;span class="sh"&gt;"&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;180_000&lt;/span&gt;  &lt;span class="c1"&gt;# leave ~20K for system prompt + slack
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pack&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="n"&gt;chosen&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;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;files&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;=&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;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&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;used&lt;/span&gt; &lt;span class="o"&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;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;chosen&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;f&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;n&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;chosen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I'd picked &lt;code&gt;cl100k_base&lt;/code&gt; because a blog post once said it was "close enough" for Claude. I never checked. For two months it looked fine, because most nights the repos were small and the packer never got near the budget.&lt;/p&gt;

&lt;p&gt;Then I pointed it at a monorepo with a fat &lt;code&gt;package-lock.json&lt;/code&gt; and a folder of JSON test fixtures.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does tiktoken undercount Claude tokens?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;tiktoken undercounts Claude tokens because it implements OpenAI's BPE vocabularies, and Claude uses a different tokenizer that splits the same text into more pieces.&lt;/strong&gt; Two tokenizers trained on different data with different merge rules will disagree, and the disagreement depends on the content. Anthropic doesn't ship a local tokenizer for current Claude models, so there's no offline library that gives you the exact number.&lt;/p&gt;

&lt;p&gt;I wanted the real size of the gap on my data instead of a vibe, so I sampled 600 chunks from the job's inputs. For each one I counted with &lt;code&gt;cl100k_base&lt;/code&gt; and with Anthropic's &lt;code&gt;count_tokens&lt;/code&gt; endpoint, then took the ratio of Claude's count to tiktoken's.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Content type&lt;/th&gt;
&lt;th&gt;Chunks&lt;/th&gt;
&lt;th&gt;Median ratio (Claude / tiktoken)&lt;/th&gt;
&lt;th&gt;Worst chunk&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Markdown docs, commit messages&lt;/td&gt;
&lt;td&gt;180&lt;/td&gt;
&lt;td&gt;1.09&lt;/td&gt;
&lt;td&gt;1.15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python source&lt;/td&gt;
&lt;td&gt;210&lt;/td&gt;
&lt;td&gt;1.16&lt;/td&gt;
&lt;td&gt;1.24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript source&lt;/td&gt;
&lt;td&gt;110&lt;/td&gt;
&lt;td&gt;1.17&lt;/td&gt;
&lt;td&gt;1.26&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSON, lockfiles, fixtures&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;1.27&lt;/td&gt;
&lt;td&gt;1.38&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;All 600&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.14&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.38&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The ratio is never below 1.0 on my corpus. tiktoken never once overcounted. That's the worst kind of error, because a symmetric error would at least cancel out sometimes.&lt;/p&gt;

&lt;p&gt;Structured, whitespace-heavy, and symbol-dense text had the biggest gap. A minified JSON fixture full of UUIDs hit 1.38. Plain English prose stayed under 1.10.&lt;/p&gt;

&lt;p&gt;One caveat matters more than the table: &lt;strong&gt;this ratio isn't a constant.&lt;/strong&gt; It's specific to my content and to the model I counted against. Tokenizers change between model generations, so a ratio you calibrated last quarter can drift when you upgrade. Measure it on your own data, against the model you actually call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did only some requests fail?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Only requests that packed close to the context limit were at risk, because a 14% undercount on a half-empty prompt never reaches 200K.&lt;/strong&gt; Out of 418 requests over 19 nights, 357 never filled the budget and none of them failed. The other 61 filled it, and 37 of those blew up.&lt;/p&gt;

&lt;p&gt;The 24 that survived were mostly docs-heavy repos. At a 1.09 ratio, 180K tiktoken tokens comes out to about 196K real tokens. That's tight but it fits.&lt;/p&gt;

&lt;p&gt;Code and JSON pushed the ratio to 1.16 or higher, which puts the same 180K over 208K. The monorepo failed every night it ran.&lt;/p&gt;

&lt;p&gt;Then there was the overhead I hadn't counted at all. My system prompt was counted with tiktoken too, so it had the same error. My three tool definitions (a file lookup, a git log reader, and a "flag this" tool) added 1,180 tokens that never went through any counter. Tool schemas are part of the input. I'd just never thought of them as text.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did it do to my cost estimates?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;My cost estimate was low by about the same 14%, because I priced every request off the tiktoken count.&lt;/strong&gt; My little dashboard multiplied tiktoken counts by the per-token input price and reported $41.20 for the month. The real bill for that job was $46.90.&lt;/p&gt;

&lt;p&gt;$5.70 isn't going to bankrupt anyone. But the dashboard was the number I used to decide whether to add more repos to the job, and it was quietly flattering me. If you're quoting AI features to a client off tiktoken math, you're under-quoting every one of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you count Claude tokens accurately?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use Anthropic's &lt;code&gt;messages.count_tokens&lt;/code&gt; endpoint with the exact model, system prompt, messages, and tools you're about to send.&lt;/strong&gt; It's free to call (rate-limited separately from message creation) and returns the input token count for the whole assembled request, tool definitions included. That last part is the one I needed.&lt;/p&gt;

&lt;p&gt;I didn't want a network round trip for every file during packing, though. So the fix has two passes: a fast pessimistic estimate to fill the prompt, then one real count on the final request, trimming if needed.&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;anthropic&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tiktoken&lt;/span&gt;

&lt;span class="n"&gt;client&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;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;cl100k_base&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-sonnet-5-5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;TARGET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;194_000&lt;/span&gt;   &lt;span class="c1"&gt;# real tokens; headroom under the 200K window
&lt;/span&gt;&lt;span class="n"&gt;FAST_RATIO&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.2&lt;/span&gt;   &lt;span class="c1"&gt;# pessimistic tiktoken multiplier for the first pass
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;real_count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;system&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="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&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;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;MODEL&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;system&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;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&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;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_tokens&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pack&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="n"&gt;system&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="c1"&gt;# Pass 1: cheap local estimate, deliberately inflated
&lt;/span&gt;    &lt;span class="n"&gt;chosen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;est&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;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;files&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;=&lt;/span&gt; &lt;span class="nf"&gt;int&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;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;FAST_RATIO&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;est&lt;/span&gt; &lt;span class="o"&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;TARGET&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;chosen&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;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;est&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;

    &lt;span class="c1"&gt;# Pass 2: ask Claude's own tokenizer, drop the least relevant file until it fits
&lt;/span&gt;    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;chosen&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="nf"&gt;build_messages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chosen&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;=&lt;/span&gt; &lt;span class="nf"&gt;real_count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;system&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="n"&gt;tools&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;n&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;TARGET&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;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
        &lt;span class="n"&gt;chosen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&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;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system prompt + tools alone exceed TARGET&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;A few details that took me an iteration to get right:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Count the assembled request, not the pieces.&lt;/strong&gt; Message wrappers, file headers, and tool schemas all add tokens. Summing per-file counts misses them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep headroom.&lt;/strong&gt; Anthropic's docs describe the count as an estimate that can differ slightly from what you're billed. I target 194K, not 199,999.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pass the real model ID.&lt;/strong&gt; The count belongs to the tokenizer of the model you send to. Counting against one model and sending to another brings back the original bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trim by relevance.&lt;/strong&gt; &lt;code&gt;chosen&lt;/code&gt; is sorted most-relevant first, so &lt;code&gt;pop()&lt;/code&gt; drops the least useful file.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Did the fix actually work?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Yes: zero overflow failures across the next 21 nights and 463 requests.&lt;/strong&gt; The second pass needed one &lt;code&gt;count_tokens&lt;/code&gt; call on 79% of the requests that filled the budget and two on the rest. Nothing needed a third.&lt;/p&gt;

&lt;p&gt;The costs are small:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Latency:&lt;/strong&gt; the count call added a median of 170 ms to each request. The digest takes a minute to generate, so nobody will ever notice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Packing efficiency:&lt;/strong&gt; budget-filling prompts now average 191K real tokens against the 194K target. Before the fix they averaged "whatever tiktoken thought, plus a coin flip."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost tracking:&lt;/strong&gt; the dashboard now logs &lt;code&gt;input_tokens&lt;/code&gt; from the API response's &lt;code&gt;usage&lt;/code&gt; field instead of my estimate. This month's figure matched the invoice to the cent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last change deserves its own line. You never need to estimate tokens after the fact. Every response tells you exactly what you were billed for, so log the actual number.&lt;/p&gt;

&lt;h2&gt;
  
  
  What would I tell past me?
&lt;/h2&gt;

&lt;p&gt;Don't use a tokenizer from a different model family as a ruler, even if it's "close enough." It's close enough until you pack to the limit, and a packer exists to pack to the limit.&lt;/p&gt;

&lt;p&gt;If you just need a rough guess for a UI ("about 40K tokens"), tiktoken times a calibrated ratio is fine. If a request fails, or a bill comes out wrong, when the number is off, ask the API.&lt;/p&gt;

&lt;h2&gt;
  
  
  So, can you count Claude tokens with tiktoken?
&lt;/h2&gt;

&lt;p&gt;You can get a rough estimate, but you can't get an accurate count. tiktoken implements OpenAI's tokenizers, and Claude splits text differently. On my corpus &lt;code&gt;cl100k_base&lt;/code&gt; undercounted Claude by a median of 14%, and by up to 38% on JSON. That broke 37 of the 61 requests I packed near the 200K window and understated my bill by $5.70 a month. For anything that has to fit a limit or match an invoice, call Anthropic's free &lt;code&gt;messages.count_tokens&lt;/code&gt; endpoint on the fully assembled request (system prompt, messages, and tools) for the exact model you're calling, keep a few thousand tokens of headroom, and log real usage from the response's &lt;code&gt;usage&lt;/code&gt; field.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claude</category>
      <category>llm</category>
      <category>python</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
