<?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>Why Great Engineers Bomb the Behavioral Interview Round</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Thu, 27 Aug 2026 03:48:24 +0000</pubDate>
      <link>https://dev.to/ji_ai/why-great-engineers-bomb-the-behavioral-interview-round-56i4</link>
      <guid>https://dev.to/ji_ai/why-great-engineers-bomb-the-behavioral-interview-round-56i4</guid>
      <description>&lt;p&gt;He nailed the system design. Distributed rate limiter, shard plan, back-of-envelope math, graceful degradation when Redis falls over. Best design hour I'd sat in on all quarter.&lt;/p&gt;

&lt;p&gt;Then the last interviewer asked him to describe a project of his that failed, and he said, "Honestly, I can't think of one."&lt;/p&gt;

&lt;p&gt;That sentence ended the loop. Not the design round. Not the coding round, which he also passed. The behavioral interview round killed the strongest candidate in the pipeline by every technical measure in that debrief.&lt;/p&gt;

&lt;p&gt;I've sat on both sides of this. Here's what's actually happening in that room.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;The behavioral interview round scores four things: ownership, conflict handling, impact awareness, and self-awareness. Likability is not on the rubric.&lt;/li&gt;
&lt;li&gt;Interviewers write &lt;strong&gt;quotes&lt;/strong&gt;, not vibes. In the debrief, the most specific quote wins the argument. Vague answers give your advocate nothing to fight with.&lt;/li&gt;
&lt;li&gt;The single most common killer is the "we" trap: 90 seconds of "we migrated, we decided, we shipped" and the interviewer cannot write one bullet about &lt;em&gt;you&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;STAR is table stakes. The part that separates a hire from a no-hire is what you'd do differently now.&lt;/li&gt;
&lt;li&gt;Target 90 seconds per answer, max 3 sentences of setup before you enter your own story, and at least one real number.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does the behavioral interview round actually test?
&lt;/h2&gt;

&lt;p&gt;It tests whether someone can hand you a problem and stop thinking about it.&lt;/p&gt;

&lt;p&gt;Every rubric I've worked with is a rewording of that. Ownership: do you drive things or get assigned things? Conflict: can you disagree with a staff engineer without folding or starting a war? Impact: do you know whether your work mattered, in units? Self-awareness: can you name a thing you got wrong without a lawyer present?&lt;/p&gt;

&lt;p&gt;The mechanism matters more than the rubric. During your answer I'm typing quotes into a doc. Days later, five people who each saw 45 minutes of you argue for an hour, and the person defending you has only my notes. If my notes say "solid communicator, seemed collaborative," you're dead. If they say "caught the double-write bug in staging, paged himself at 2am, wrote the runbook so it wouldn't happen twice," you're hired. Give people ammunition. That's the whole game.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do great engineers bomb the behavioral interview round?
&lt;/h2&gt;

&lt;p&gt;Because they've spent ten years being rewarded for the exact opposite instincts.&lt;/p&gt;

&lt;p&gt;Engineering culture trains modesty. Saying "I did that" feels like stolen valor when six people touched the service. So you say "we," you round your contribution down, and you read as a passenger.&lt;/p&gt;

&lt;p&gt;It also trains precision. You won't say "cut latency by 60%" because it might have been 45%, and only on the write path. So you say "it got noticeably faster," which is worthless to me.&lt;/p&gt;

&lt;p&gt;And your best work is often your least narratable work. Two years grinding a legacy billing system into something maintainable has no dramatic arc. The person who spent three weeks on a flashy demo interviews better than you, and that's genuinely unfair. The fix isn't to become that person. It's to build the narrative your work deserves before someone asks for it cold.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the "we" trap and how do I get out of it?
&lt;/h2&gt;

&lt;p&gt;The "we" trap is telling a team story so thoroughly that you delete yourself from it. It's the number one reason a strong engineer gets a "no signal" score instead of a hire.&lt;/p&gt;

&lt;p&gt;I once counted 14 uses of "we" and zero uses of "I" in one answer. The work sounded excellent. I could not tell you what the candidate did.&lt;/p&gt;

&lt;p&gt;The fix is mechanical. Use &lt;strong&gt;we&lt;/strong&gt; for context. Use &lt;strong&gt;I&lt;/strong&gt; for decisions.&lt;/p&gt;

&lt;p&gt;Bad:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"We were seeing timeouts on checkout, so we looked at the query patterns and we added a cache layer, and after that we got the p99 down."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Better:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Checkout was timing out about 2% of the time. I pulled the slow query log and found one N+1 in the cart serializer that was firing 300 queries per request. I argued for fixing the serializer instead of adding a cache, because caching would have hidden it. Two of us pushed back on that for a day. We fixed the serializer, p99 went from about 1.9s to 240ms, and the cache work got cancelled."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Same story. Same team. One of them is a bullet in the debrief doc.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I answer "tell me about a time you disagreed with someone"?
&lt;/h2&gt;

&lt;p&gt;The question is not asking whether you were right. It's asking whether you can hold a position with evidence and then commit to a decision that went against you.&lt;/p&gt;

&lt;p&gt;Three ways people fail it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The doormat.&lt;/strong&gt; "I raised my concern, they disagreed, so I went with their call." No conviction, no data, no follow-up. Reads as someone who won't stop a bad launch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The martyr.&lt;/strong&gt; "I warned them the schema wouldn't scale, they ignored me, and six months later it fell over exactly like I said." You just told me you'd rather be right than effective. Also that you're still angry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The ghost.&lt;/strong&gt; "I don't really have conflicts, I get along with everyone." Nobody believes this and it burns 30 seconds.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What lands is the boring version: you disagreed, you brought a number or a prototype instead of an opinion, you set a decision deadline, you lost, you committed anyway, and you said what you'd watch for. Then add the sentence most people skip: what you learned about &lt;em&gt;why&lt;/em&gt; the other person made that call. That's the self-awareness box, and almost nobody checks it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is STAR still the right format for behavioral answers?
&lt;/h2&gt;

&lt;p&gt;STAR is fine scaffolding, but Situation-Task-Action-Result is the floor, not the ceiling. Everyone hits it. On a panel day I'll hear eight structurally identical STAR answers and they blur.&lt;/p&gt;

&lt;p&gt;Add a fifth beat: &lt;strong&gt;what I'd do differently now.&lt;/strong&gt; One or two sentences at the end.&lt;/p&gt;

&lt;p&gt;"Looking back, I'd have shipped the read path behind a flag first instead of doing both at once. The rollback took four hours and it should have taken four minutes."&lt;/p&gt;

&lt;p&gt;That line proves the experience actually updated you. It also quietly answers the failure question before anyone asks it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How long should a behavioral answer be?
&lt;/h2&gt;

&lt;p&gt;Ninety seconds to two minutes. Setup gets three sentences, maximum, before you appear in your own story.&lt;/p&gt;

&lt;p&gt;I've heard four-minute answers where the candidate was still describing the org chart at minute two. By then I'm not scoring the story, I'm scoring whether you notice people have stopped listening. Worse question for you.&lt;/p&gt;

&lt;p&gt;If you're not sure, stop early and ask, "Want me to go deeper on the technical side or on how the team handled it?" That question alone reads as senior.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you practice behavioral interviews without a friend to run them?
&lt;/h2&gt;

&lt;p&gt;Out loud, timed, recorded. Reading notes silently doesn't work, because the failure mode is verbal. You'll find out that you say "we" constantly, that your setup runs 90 seconds, and that you have no number for the project you're proudest of.&lt;/p&gt;

&lt;p&gt;I got obsessed enough with this problem that I built a tool for it. Preterview runs voice mock interviews with three different interviewer styles, reads your resume or portfolio first so the questions are about your actual work, and hands back a written report on what you said (full disclosure: I built it, it's at &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;preterview.com&lt;/a&gt;). The reason I made it voice-based rather than text: typing your answers hides every problem that matters here.&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 AI mock interview in progress" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Whatever you use, the artifact you want is a story bank: six written stories, each tagged for what it covers. A shipped thing, a failure, a conflict, an ambiguous problem, a time you mentored someone, a time you changed your mind. Six flex to cover about forty questions. Trying to have a unique story per question is how people freeze.&lt;/p&gt;

&lt;h2&gt;
  
  
  What kills candidates instantly in a behavioral round?
&lt;/h2&gt;

&lt;p&gt;Naming and blaming a specific ex-teammate. Saying you can't think of a failure. Trashing your last codebase without saying what you did about it. Claiming scope that dies on one follow-up, which is the fastest trust collapse there is: say "I built the platform," fail to answer who owned deploys, and everything else you said gets re-audited.&lt;/p&gt;

&lt;p&gt;And the quiet one: answering a people question with pure architecture. Ask how you handled a teammate who kept shipping without tests, get a description of CI config, and I've learned you route around humans instead of talking to them.&lt;/p&gt;

&lt;h2&gt;
  
  
  So why do great engineers bomb the behavioral interview round?
&lt;/h2&gt;

&lt;p&gt;Because they treat it as small talk wrapped around the real interview, when it's the round with the widest scoring spread and the least preparation behind it. The technical rounds have a ceiling everyone's grinding toward. The behavioral round is scored on ownership, conflict, impact, and self-awareness, and most strong engineers walk in with no stories prepared, no numbers, and a lifetime of habit that says "we" when it should say "I." Write six stories down, put a real number in each, say what you'd do differently, keep it under two minutes, and rehearse it out loud. It's the cheapest interview points available and almost nobody picks them up.&lt;/p&gt;

</description>
      <category>interview</category>
      <category>career</category>
      <category>jobs</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Vibe Coding Is Fine. Vibe Debugging Is What Kills You</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Wed, 26 Aug 2026 16:02:20 +0000</pubDate>
      <link>https://dev.to/ji_ai/vibe-coding-is-fine-vibe-debugging-is-what-kills-you-23i0</link>
      <guid>https://dev.to/ji_ai/vibe-coding-is-fine-vibe-debugging-is-what-kills-you-23i0</guid>
      <description>&lt;p&gt;We shipped a job that sent order confirmation emails. It deduped by keeping message IDs in an in-memory &lt;code&gt;Set&lt;/code&gt;. Clean code. An agent wrote it, I skimmed it, tests passed, staging was happy for two weeks.&lt;/p&gt;

&lt;p&gt;Then traffic spiked, autoscaling added a second instance, and a few hundred customers got the same email twice. Two &lt;code&gt;Set&lt;/code&gt;s. Zero shared state. Obvious in hindsight, invisible in review.&lt;/p&gt;

&lt;p&gt;Here's the part that actually cost me: finding it took nine hours, and roughly seven of those were spent watching an AI agent confidently fix things that were not the bug. Vibe coding didn't hurt me. Vibe debugging did.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vibe coding&lt;/strong&gt; works because generation has a cheap oracle: you run it, you see if it does the thing. Wrong guesses die instantly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vibe debugging&lt;/strong&gt; breaks because debugging is a search over &lt;em&gt;runtime state&lt;/em&gt;, and your agent has never observed your runtime. It only sees text.&lt;/li&gt;
&lt;li&gt;A wrong guess while coding costs you a rejected diff. A wrong guess while debugging costs you a plausible diff that changes behavior without fixing the cause. Now you have two bugs.&lt;/li&gt;
&lt;li&gt;The fix is not a better prompt. It's a deterministic repro. &lt;strong&gt;A failing test is the best prompt you will ever write.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;After ~3 failed attempts, the agent's own rejected theories are polluting its context. Reset the session instead of pushing harder.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is vibe coding, and why does it usually work?
&lt;/h2&gt;

&lt;p&gt;Vibe coding is letting a model write code you accept without fully reading it. I do it every day and I'm not going to pretend otherwise.&lt;/p&gt;

&lt;p&gt;It works because the feedback loop is short and the correctness check lives outside the model. Does the endpoint return 200? Does the page render? Does the CLI print the right thing? You don't need to understand the diff to evaluate it, because reality evaluates it for you in about four seconds.&lt;/p&gt;

&lt;p&gt;Generation is also the thing language models are genuinely best at. Millions of examples of "function that takes X and returns Y" exist in training data. Your CRUD handler is not a novel artifact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is vibe debugging so much worse than vibe coding?
&lt;/h2&gt;

&lt;p&gt;Because a bug is a claim about something that happened inside a process the model never watched. It gets a stack trace, a log line, and your grumpy one-sentence description. From that it does the only thing available: pattern-match your symptom against the most statistically common cause of that symptom.&lt;/p&gt;

&lt;p&gt;That's right often enough to be dangerous. When it's wrong, it doesn't return an error. It returns a fix.&lt;/p&gt;

&lt;p&gt;My duplicate-email bug looked exactly like a race condition, because it &lt;em&gt;was&lt;/em&gt; a concurrency problem. So the agent proposed a mutex around the &lt;code&gt;Set&lt;/code&gt;. Textbook answer for a single process. Completely useless across two of them. The tests still passed. The duplicates kept going out. And now the code had a lock in it that would live there forever, quietly making every future reader assume the concurrency question had been handled.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does an AI agent actually do when it can't find the bug?
&lt;/h2&gt;

&lt;p&gt;Four moves, in this order, every time. You'll recognize all of them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The null guard.&lt;/strong&gt; &lt;code&gt;if (!x) return;&lt;/code&gt; The crash disappears. The bug relocates downstream, where it now looks like a different bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The try/except sponge.&lt;/strong&gt; Wrap it, log it, call it "handled." Your error rate drops. Your correctness doesn't change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rewrite.&lt;/strong&gt; Narrow fixes failed, so it widens the scope and reimplements the whole function. Now the diff is 200 lines and you cannot tell which line mattered, or whether anything got fixed versus reshuffled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The confident close.&lt;/strong&gt; "Fixed! The issue was that the &lt;code&gt;Set&lt;/code&gt; wasn't thread-safe." Stated as fact. Nothing was run.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is the model lying. It's what you get from a system optimized to produce a plausible patch, with no ground truth to check itself against. Give it no oracle and it will invent the feeling of one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does the agent get worse the longer you debug with it?
&lt;/h2&gt;

&lt;p&gt;Because its own wrong theories become context. Turn 1 it says the issue is the lock. Turn 4 it's reading "the issue is the lock" as evidence in its own transcript. You've built a machine that cites itself.&lt;/p&gt;

&lt;p&gt;I've watched an agent spend twelve turns defending a diagnosis I had already disproven in turn three, because my disproof was one line and its theory was six paragraphs.&lt;/p&gt;

&lt;p&gt;Three strikes and you reset. Open a clean session and paste only facts: the repro steps, the actual observed values, what you ruled out and how. Not the conversation. The evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I stop the vibe debugging death spiral?
&lt;/h2&gt;

&lt;p&gt;Five rules. They cost real time up front and they've saved me entire days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. No repro, no agent.&lt;/strong&gt; Spend the first hour making it fail on command. This is not preparation for the work, it &lt;em&gt;is&lt;/em&gt; the work. Once you have a deterministic failing test, the agent's search has a terminal condition it can check by itself, and its hit rate goes up enormously. Before that, you're asking it to guess.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Make it explain before it edits.&lt;/strong&gt; Ask for the causal chain with &lt;code&gt;file:line&lt;/code&gt; citations. Which line writes the bad value, which line reads it, what's between them. If it can't cite, it's guessing, and you just saved yourself a patch. One extra turn, kills most bad fixes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Cap the diff.&lt;/strong&gt; "Fix this in 10 lines or tell me you can't." Big diffs are where unfixed bugs hide. The constraint also forces a real diagnosis, because you can't shotgun a whole module in 10 lines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Delete the fix and re-derive it.&lt;/strong&gt; Once it's green, revert the patch and confirm the test goes red again. Embarrassing but true: a solid share of "fixes" I've accepted were placebo. The real change was a restart, a cleared cache, or an unrelated edit made three turns earlier in the same session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Feed it observations, not adjectives.&lt;/strong&gt; Don't say "it's flaky." Have the agent add instrumentation, run it yourself, and paste the actual values back in. It is an inference engine running on a bad prior until you hand it data. Accuracy is a function of what's in the context window, not how sternly you phrase the request.&lt;/p&gt;

&lt;h2&gt;
  
  
  What finally fixed the duplicate emails?
&lt;/h2&gt;

&lt;p&gt;A unique constraint on &lt;code&gt;(order_id, template)&lt;/code&gt; and an insert that catches the conflict. Three lines. The dedupe belonged in the database, which is the only thing in that system both instances agreed on.&lt;/p&gt;

&lt;p&gt;The agent could have gotten there in one turn if I'd given it the right input. Instead of "emails are duplicating, here's the handler," the prompt that would have worked was: "this dedupe uses process-local memory; we run 2+ instances." That's not a prompting trick. That's me having done the diagnosis. The model was never going to discover the deploy topology by staring at the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is vibe coding still worth it?
&lt;/h2&gt;

&lt;p&gt;Yes, and I'd argue it gets &lt;em&gt;more&lt;/em&gt; worth it once you stop vibe debugging. Let the agent generate freely in places where reality checks the work fast: UI, scaffolding, glue, one-off scripts, anything with a visible output. Then move your attention from reading diffs to owning the feedback loop. Tests, repros, instrumentation, and a clear picture of what your system does at runtime.&lt;/p&gt;

&lt;p&gt;The skill that appreciates here isn't prompt writing. It's being the person who can make a bug happen on demand. That's the one thing an agent still can't do for you, and it's the input that makes everything else it does actually work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;So: is vibe coding dangerous?&lt;/strong&gt; Vibe coding is fine. It fails cheaply and visibly, because running the code tells you immediately whether it worked. Vibe debugging is what kills you, because an AI agent can't observe your runtime state and will answer a bug report with the most statistically plausible fix instead of the correct one. That fix often passes your tests, changes behavior, and hides the real cause under a lock or a null guard. Fix the loop, not the prompt: build a deterministic repro first, demand a cited causal chain before any edit, cap the diff size, revert-and-re-verify every fix, and start a fresh session after three failed attempts.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>discuss</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Why Strong Engineers Fail Coding Interviews: A Scorecard Autopsy</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Wed, 26 Aug 2026 03:43:04 +0000</pubDate>
      <link>https://dev.to/ji_ai/why-strong-engineers-fail-coding-interviews-a-scorecard-autopsy-18oa</link>
      <guid>https://dev.to/ji_ai/why-strong-engineers-fail-coding-interviews-a-scorecard-autopsy-18oa</guid>
      <description>&lt;p&gt;The strongest candidate I ever voted no on solved the problem in eleven minutes. Clean. Optimal. Caught the edge case I normally have to hint at twice.&lt;/p&gt;

&lt;p&gt;Then I opened my notes to write the scorecard and found one line: &lt;em&gt;"Solved it. I have no idea how."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That is the short version of why strong engineers fail coding interviews. Not because they can't code. Because nothing they did survived the trip from the room to the scorecard. The interview is not the thing being graded. The document I write forty minutes later is the thing being graded, and you are not in the room when it gets read.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Strong engineers fail coding interviews mostly on &lt;strong&gt;signal density&lt;/strong&gt;, not correctness. A silent correct answer scores lower than a narrated near-miss.&lt;/li&gt;
&lt;li&gt;Interviewers score 3-4 rubric axes (problem solving, coding, communication, and for senior roles, judgment) and each axis needs &lt;strong&gt;quotable evidence&lt;/strong&gt;, not vibes.&lt;/li&gt;
&lt;li&gt;The decision happens in the &lt;strong&gt;debrief&lt;/strong&gt;, where ambiguity defaults to no. "Lean hire" across the board is a rejection at most companies.&lt;/li&gt;
&lt;li&gt;The most common senior failure is &lt;strong&gt;solving a senior problem like a junior&lt;/strong&gt;: no scoping, no tradeoffs, no failure modes, no tests.&lt;/li&gt;
&lt;li&gt;Fix it by talking in sentences your interviewer can transcribe verbatim: assumption, tradeoff, complexity, test.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What do interviewers actually score in a coding interview?
&lt;/h2&gt;

&lt;p&gt;Not "did you get the answer." Almost every structured loop I've been part of scores a fixed rubric, and correctness is one box inside one axis. Here is roughly what the form looks like:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Axis&lt;/th&gt;
&lt;th&gt;What it's really asking&lt;/th&gt;
&lt;th&gt;What lands on the scorecard&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Problem solving&lt;/td&gt;
&lt;td&gt;Did you scope before you built?&lt;/td&gt;
&lt;td&gt;"Asked whether input fits in memory before choosing an approach."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coding&lt;/td&gt;
&lt;td&gt;Would this survive code review?&lt;/td&gt;
&lt;td&gt;"Named things well, extracted a helper, no off-by-one."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Communication&lt;/td&gt;
&lt;td&gt;Could I follow you in real time?&lt;/td&gt;
&lt;td&gt;"Told me the plan first, then coded the plan."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Judgment (senior+)&lt;/td&gt;
&lt;td&gt;Do you know what breaks in prod?&lt;/td&gt;
&lt;td&gt;"Unprompted, called out the retry storm risk."&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notice what every right-hand cell has in common: it's a sentence I could only write because you said something out loud. That's signal density. Your score is capped by how much evidence you handed me.&lt;/p&gt;

&lt;p&gt;Here is the brutal asymmetry. If you go quiet and produce a perfect solution, I have four boxes and one piece of evidence. If you narrate your way to a solution that's 85% done when time runs out, I have four boxes and twelve pieces of evidence. The second person gets the offer. I have watched it happen more times than I can count.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do strong engineers fail coding interviews?
&lt;/h2&gt;

&lt;p&gt;Because the habits that make you effective at work are invisible under a 45-minute clock. The six failure modes I see most:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Silent solving.&lt;/strong&gt; You think in your head, which is exactly what a great engineer does at their desk. In an interview it reads as a black box. I can't score a black box, so I score the one line I could observe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Coding before scoping.&lt;/strong&gt; Senior engineers at real jobs have context. In an interview you have none, and you skip the part where you get it. Twenty seconds of "how big is n, can I mutate the input, are duplicates possible" is worth more rubric points than the entire back half of your solution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Solving a senior problem like a junior.&lt;/strong&gt; This kills more experienced candidates than anything. You are given a deliberately vague prompt, you pick the first reasonable approach, you implement it flawlessly, and you never say &lt;em&gt;why that one&lt;/em&gt;. No tradeoff, no alternative rejected, no "this breaks if the file doesn't fit in memory." Flawless execution with zero judgment reads as mid-level. That's a downlevel or a no.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Treating a hint as an insult.&lt;/strong&gt; Hints are not charity, they're a probe. I'm testing collaboration. Candidates who tense up, argue, or ignore the hint and keep typing generate the single worst scorecard sentence there is: &lt;em&gt;"Hard to redirect."&lt;/em&gt; That one line has ended more loops than any wrong answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Gold-plating.&lt;/strong&gt; You spend fourteen minutes on a generic class hierarchy for a problem that wanted a dictionary and a loop. Now you're out of time with nothing running. "Over-engineered the setup" is not a compliment on a rubric.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. No tests, ever.&lt;/strong&gt; Not "write a test framework." Just walk one concrete input through your code out loud. Empty list, single element, duplicate. Candidates who do this catch their own bugs, and &lt;em&gt;catching your own bug in front of me is a higher score than never having one&lt;/em&gt;. I promise. Self-correction is the trait I'm actually hunting for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happens in a hiring debrief?
&lt;/h2&gt;

&lt;p&gt;Four to six interviewers sit down, each with a written scorecard and a vote on a scale like strong no / no / lean no / lean hire / hire / strong hire. Then three mechanics take over, and none of them are about you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ambiguity defaults to no.&lt;/strong&gt; If nobody in the room is willing to fight for you, you are not hired. "Lean hire" from everyone is a rejection, because a room full of shrugs means nobody's reputation is attached to you working out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Evidence beats seniority.&lt;/strong&gt; The interviewer who wrote 400 words of direct quotes wins the argument against the staff engineer who wrote "seemed fine, kind of quiet." Vibes lose to transcripts. This is your leverage: you are writing your own advocate's script during the interview.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anchoring is real.&lt;/strong&gt; Whoever speaks first sets the frame, and everyone else's language drifts toward it. A well-run debrief collects written scores before anyone talks, precisely to blunt this. Plenty of debriefs are not well run.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you get a good scorecard written about you?
&lt;/h2&gt;

&lt;p&gt;Speak in transcribable sentences. Interviewers are typing while you talk, and the sentences that make it into the doc are short, declarative, and structured. Feed them.&lt;/p&gt;

&lt;p&gt;Four templates that turn into rubric points almost automatically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Assumption:&lt;/strong&gt; "I'm assuming the input fits in memory. If it doesn't, I'd want to stream it. Which do you want?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tradeoff:&lt;/strong&gt; "Hash map here costs O(n) memory and buys O(1) lookups. This is read-heavy, so I'll take that trade."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complexity, unprompted:&lt;/strong&gt; "This is O(n log n) because of the sort. The rest is linear."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test:&lt;/strong&gt; "Let me run &lt;code&gt;[]&lt;/code&gt; through it. Line 3 would index into an empty list, so I need a guard."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also: narrate your dead ends instead of hiding them. "I considered two pointers, but the array isn't sorted and sorting costs more than it saves" is pure judgment signal. Deleting that thought silently earns you nothing.&lt;/p&gt;

&lt;p&gt;And when you get stuck, say the actual shape of the stuck: "I know I need the previous index but I'm not tracking it yet." That's a debuggable statement. "Um, sorry, one sec" is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do AI interviews change any of this?
&lt;/h2&gt;

&lt;p&gt;Less than you'd think. AI screeners and auto-graded async rounds still score against a rubric, and the newer ones weight your explanation heavily precisely because the code alone stopped being a reliable signal. The scarce thing shifted from producing an answer to demonstrating you understand the answer.&lt;/p&gt;

&lt;p&gt;If anything, narration matters more now. When any candidate can produce plausible code, the tiebreaker is whether you can defend it under follow-up questions. Onsite loops have already adapted: expect more "why did you choose that" and more "now change this requirement and tell me what breaks."&lt;/p&gt;

&lt;h2&gt;
  
  
  So why do strong engineers fail coding interviews?
&lt;/h2&gt;

&lt;p&gt;Strong engineers fail coding interviews because interviews score observable evidence, not ability, and the decision is made in a debrief from a written scorecard the candidate never sees. A silent correct solution produces one line of evidence across a four-axis rubric, while a narrated partial solution produces a dozen, so the narrator wins. The fix isn't more LeetCode. It's making your reasoning audible: state your assumptions before coding, name the tradeoff behind every data structure you pick, call out complexity unprompted, walk one edge case through your code out loud, and treat hints as collaboration rather than criticism. Interview like the transcript is the deliverable, because it is.&lt;/p&gt;

&lt;p&gt;What's the most unfair rejection you've gotten? I'd bet the scorecard said something you'd never guess.&lt;/p&gt;

</description>
      <category>career</category>
      <category>interview</category>
      <category>programming</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Why AI Coding Agents Never Delete Code (I Measured It)</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Tue, 25 Aug 2026 15:41:07 +0000</pubDate>
      <link>https://dev.to/ji_ai/why-ai-coding-agents-never-delete-code-i-measured-it-5156</link>
      <guid>https://dev.to/ji_ai/why-ai-coding-agents-never-delete-code-i-measured-it-5156</guid>
      <description>&lt;p&gt;My agent opened a PR: &lt;code&gt;+214 / -3&lt;/code&gt;. The task was "remove the legacy retry path."&lt;/p&gt;

&lt;p&gt;It did not remove the legacy retry path. It wrote a &lt;em&gt;new&lt;/em&gt; retry path, put the old one behind a flag that defaults to on, added a compatibility shim between them, and deleted three lines of comments. Tests passed. The code was, honestly, fine code. There was just more of it than before, and I had asked for less.&lt;/p&gt;

&lt;p&gt;That is the pattern I keep hitting, across models and across harnesses: AI coding agents never delete code. They add. Then they add a wrapper around what they added. Then, when you say "simplify this," they add a simplification layer.&lt;/p&gt;

&lt;p&gt;I got curious and ran &lt;code&gt;git log --numstat&lt;/code&gt; over six months of my main side project, splitting commits by author. My hand-written commits sat around 3 lines added per line removed. The agent-authored ones were north of 10:1. Same repo, same feature work, same me reviewing. The difference was not skill. It was direction: the agent only knows how to go one way.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;AI coding agents never delete code because deletion is a whole-repo claim ("nothing else uses this") and the agent only sees the handful of files it happened to open.&lt;/li&gt;
&lt;li&gt;Adding is locally verifiable: write code, run tests, green. Deleting is globally verifiable: prove no caller anywhere breaks. Agents optimize for the thing they can check.&lt;/li&gt;
&lt;li&gt;The bloat has a signature: parallel helpers, old paths kept behind flags, defensive branches for impossible states, and commented-out tombstones.&lt;/li&gt;
&lt;li&gt;Fix it by making deletion the &lt;em&gt;task&lt;/em&gt;, not a side effect. Separate subtraction PRs, hand the agent the grep evidence, and require a negative net diff.&lt;/li&gt;
&lt;li&gt;If you want it to stick, ratchet it in CI. A failing check works; a line in CLAUDE.md does not.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why do AI coding agents never delete code?
&lt;/h2&gt;

&lt;p&gt;Because deleting code is a claim about the entire repository, and the agent is working from a keyhole view of it.&lt;/p&gt;

&lt;p&gt;Three things stack up:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Verification is asymmetric.&lt;/strong&gt; When an agent adds a function, the proof it worked is right there: the test it just wrote goes green. When it removes a function, the proof is the absence of breakage &lt;em&gt;everywhere&lt;/em&gt;, including the template file it never opened, the dynamic import, the string-keyed dispatch table, the cron job. The agent cannot run that proof. So it hedges.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Context is partial by construction.&lt;/strong&gt; Your agent read 12 files. Your repo has 900. It has no idea that &lt;code&gt;formatCurrency&lt;/code&gt; already exists in &lt;code&gt;lib/format.ts&lt;/code&gt;, because it grepped &lt;code&gt;utils/&lt;/code&gt; and found nothing. Adding is safe under uncertainty. Deleting under uncertainty is how you page someone at 2am.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Doing more looks like helping.&lt;/strong&gt; I can't see anyone's training data, but the behavior is consistent with a strong prior that a thorough answer is a &lt;em&gt;long&lt;/em&gt; answer. Ask a model to clean up a file and reply with a 40-line deletion and nothing else, and it reads as lazy. Reply with a refactor, a docstring, a type guard, and a comment explaining the deletion you didn't do, and it reads as diligent. One of those is right and it is not the one that gets rewarded.&lt;/p&gt;

&lt;p&gt;Put those together and you get an agent that treats your codebase like a whiteboard nobody is allowed to erase.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does agent-written bloat actually look like?
&lt;/h2&gt;

&lt;p&gt;It has a fingerprint. Once you can see it, you cannot unsee it in a review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The parallel helper.&lt;/strong&gt; A second &lt;code&gt;slugify&lt;/code&gt;, a second &lt;code&gt;retryWithBackoff&lt;/code&gt;, a second date formatter, each slightly different in the edge cases. Nothing is broken. Six months later a bug fix lands in one of the three and not the others.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The flag that defaults to old.&lt;/strong&gt; "I've kept the previous implementation behind &lt;code&gt;USE_LEGACY_SYNC&lt;/code&gt; for safety." Now you own two code paths forever, and one of them is untested in production and untested in CI because nobody flips it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Defensive code for states that cannot happen.&lt;/strong&gt; &lt;code&gt;if (!user) return null&lt;/code&gt; inside a function that only runs behind auth middleware. Individually harmless. A hundred of them and your codebase turns to fog, because now every null check is ambiguous: is this a real case or agent lint? Worse, those branches swallow the bugs that would have told you something upstream is broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Comment tombstones.&lt;/strong&gt; &lt;code&gt;// Previously handled by processQueue(), see PR #412&lt;/code&gt;. Commented-out blocks "kept for reference." Your VCS already does this. The agent does not trust that you have one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Isn't this just bad prompting?
&lt;/h2&gt;

&lt;p&gt;Partly, yes. Prompting moves the needle and you should do it. "Delete the legacy retry path. Do not write a replacement. The new path in &lt;code&gt;sync/retry.ts&lt;/code&gt; already covers every caller" gets you dramatically closer than "clean up the retry logic."&lt;/p&gt;

&lt;p&gt;But it decays. Put "prefer minimal diffs" in your system prompt and watch it hold for the first two tool calls of a long agent loop, then evaporate somewhere around step 15 when the model is deep in its own context and optimizing for closing the loop. Instructions are weak forces. Structure is a strong one.&lt;/p&gt;

&lt;p&gt;The honest version: prompting fixes maybe a third of this. The rest is on you for asking an agent with a keyhole view to make a whole-repo decision without giving it the whole-repo evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you make an AI coding agent actually delete code?
&lt;/h2&gt;

&lt;p&gt;Give it what it is missing: evidence, permission, and a hard constraint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Split the subtraction pass from the addition pass.&lt;/strong&gt; Never in the same PR. Feature PR ships the new path. A separate PR, a separate agent run, does one thing: remove the old one. When deletion is the &lt;em&gt;only&lt;/em&gt; success criterion, the model stops hedging, because hedging is now visibly failing the task.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Hand it the call-graph evidence up front.&lt;/strong&gt; Do not ask "is this used?" Run the search yourself and paste the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rg &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;--stats&lt;/span&gt; &lt;span class="s1"&gt;'legacyRetry|LEGACY_RETRY'&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="s1"&gt;'!node_modules'&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="s1"&gt;'!dist'&lt;/span&gt;
rg &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'legacyRetry'&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="s1"&gt;'*.{html,ejs,yaml,yml,json,sql}'&lt;/span&gt;   &lt;span class="c"&gt;# the ones it will forget&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dynamic dispatch, config files, and templates are exactly where an agent's confidence outruns its knowledge. I have had an agent declare a function dead because it only searched &lt;code&gt;.ts&lt;/code&gt; files. It was called from a Handlebars template. Provide the evidence, or verify the claim yourself before you accept it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Make the constraint numeric.&lt;/strong&gt; "The net line count of this diff must be negative" is a constraint a model can actually evaluate against its own output. "Be concise" is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Tests are the oracle, and the agent must run them.&lt;/strong&gt; Not "this should be safe to remove." Run the suite, paste the output. Claimed-green and actually-green are different states of the world.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Ratchet it in CI.&lt;/strong&gt; This is the part that survives contact with a busy week.&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="c"&gt;# .github/workflows/diet.sh&lt;/span&gt;
&lt;span class="nv"&gt;ADD&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;--numstat&lt;/span&gt; origin/main... | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{a+=$1} END {print a+0}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;DEL&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;--numstat&lt;/span&gt; origin/main... | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{d+=$2} END {print d+0}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ADD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt; DEL &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Diff is &lt;/span&gt;&lt;span class="nv"&gt;$ADD&lt;/span&gt;&lt;span class="s2"&gt; added / &lt;/span&gt;&lt;span class="nv"&gt;$DEL&lt;/span&gt;&lt;span class="s2"&gt; removed. Justify with the 'growth' label or cut it."&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Crude on purpose. It is not measuring quality, it is creating friction in the one direction that currently has none. Add a label escape hatch for genuinely new features and let it annoy you the rest of the time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does it cost you to ignore this?
&lt;/h2&gt;

&lt;p&gt;It compounds, and it compounds against the agent itself.&lt;/p&gt;

&lt;p&gt;Next week that agent reads the codebase it wrote. Three near-identical helpers means retrieval pulls the wrong one. Two flag-gated paths means it has to reason about both. Fog-level null checks mean it cannot tell which invariants are real. You pay in tokens, in latency, and in the agent making worse decisions because its own past output is the noise in its context.&lt;/p&gt;

&lt;p&gt;The trap is that none of this shows up as a bug. It shows up as your agent getting slowly, unaccountably dumber in your repo while it stays sharp in a fresh one. That is not model drift. That is the landfill you let it build.&lt;/p&gt;

&lt;h2&gt;
  
  
  So, why do AI coding agents never delete code?
&lt;/h2&gt;

&lt;p&gt;Because adding code is a local action an agent can verify on the spot, while deleting code is a global claim it has no way to prove from inside a partial view of your repository, and under uncertainty the safe move is always to add. The behavior is rational given what the agent can see. The fix is not a better model or a sterner prompt, it is structure: run deletion as its own task with its own PR, hand the agent the search results that prove a symbol is unused, require a negative net diff, make it run the tests instead of predicting them, and put a lines-added ratchet in CI so growth costs something. Do that and agents delete code just fine. They were never refusing. They were just never asked in a way they could answer.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>productivity</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Any Questions for Us? 7 Questions to Ask the Interviewer</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Tue, 25 Aug 2026 03:38:54 +0000</pubDate>
      <link>https://dev.to/ji_ai/any-questions-for-us-7-questions-to-ask-the-interviewer-2efi</link>
      <guid>https://dev.to/ji_ai/any-questions-for-us-7-questions-to-ask-the-interviewer-2efi</guid>
      <description>&lt;p&gt;The strongest candidate I ever debriefed lost the hire decision in the last four minutes.&lt;/p&gt;

&lt;p&gt;He shredded the system design round. Then the hiring manager said "any questions for us?" and he said, "No, I think you covered everything." One line went into the scorecard: &lt;em&gt;no questions, can't tell if he wants this job or a job.&lt;/em&gt; We hired someone with visibly rougher code who had asked us what our rollback story looked like, and then asked a follow-up when our answer got mushy.&lt;/p&gt;

&lt;p&gt;That was the moment I understood that the questions to ask the interviewer are not a politeness ritual. They are a scored round with no rubric published, and they are also the only round where you get to interview them.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;The "any questions?" segment is scored in most structured loops, usually as evidence of role-specific interest and seniority, not as trivia.&lt;/li&gt;
&lt;li&gt;Bad questions ask for things the careers page already answers ("what's the culture like?"). Good questions ask for a &lt;strong&gt;specific past event&lt;/strong&gt; the interviewer had to live through.&lt;/li&gt;
&lt;li&gt;The single most useful trick: ask the &lt;em&gt;same&lt;/em&gt; question to three different interviewers and compare the variance. The disagreement is the signal, not the answer.&lt;/li&gt;
&lt;li&gt;Match the question to the role: recruiter gets process, peer gets daily reality, hiring manager gets decision rights, skip-level gets roadmap risk.&lt;/li&gt;
&lt;li&gt;If an answer is vague, ask one calm follow-up. How they handle being probed tells you more than the answer itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why do interviewers score the questions you ask?
&lt;/h2&gt;

&lt;p&gt;Because it is the cheapest way to separate people who want &lt;em&gt;this&lt;/em&gt; team from people who want &lt;em&gt;an&lt;/em&gt; offer. Most scorecards have a "motivation" or "role fit" line, and for a lot of interviewers, your questions are the only evidence that line ever gets.&lt;/p&gt;

&lt;p&gt;There is a second reason, and it is more brutal: your questions leak your seniority. A junior asks about the tech stack. A mid-level asks about the process. A senior asks about ownership, blast radius, and who decides what. Nobody plans it that way, but every interviewer I know pattern-matches on it instantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What questions should you ask the interviewer?
&lt;/h2&gt;

&lt;p&gt;Ask about events, not adjectives. Adjectives ("collaborative", "fast-paced") are free to say and impossible to check. Events have to be remembered, and people are bad at inventing them under time pressure. Here are seven that consistently pull real information out of a room, roughly in order of how much they've paid off for me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. "What happened to the last person in this role?"&lt;/strong&gt;&lt;br&gt;
Backfill or growth? Promoted internally or quietly gone? If it's a backfill and nobody wants to say where the person went, you've learned something the offer letter will not tell you. Ask it flat, without apology. It's a normal question.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. "Tell me about the last incident that woke someone up at 3am. What changed afterward?"&lt;/strong&gt;&lt;br&gt;
This one is my favorite because it's a three-for-one. You learn the on-call reality, whether postmortems produce actual changes, and whether the person can talk about failure without getting defensive. Teams with a healthy blameless culture answer this in cheerful detail. Teams without one get weirdly clipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. "How does code get from my laptop to production, and how long does that take today?"&lt;/strong&gt;&lt;br&gt;
Note the word &lt;em&gt;today&lt;/em&gt;. Everyone has a beautiful CI diagram. Ask how long it took last Tuesday. "About twenty minutes, we deploy a few times a day" and "well, we batch into a release train and QA signs off" are two completely different jobs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. "How much of a sprint goes to work nobody outside engineering asked for?"&lt;/strong&gt;&lt;br&gt;
That's tech debt, tooling, migrations, and reliability. If the honest answer is zero, you're joining a feature factory and your first year will be spent apologizing for a codebase you didn't write. A real number, even a small one, means someone is protecting engineering time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. "Who decides what I work on next quarter, and what happens when I disagree?"&lt;/strong&gt;&lt;br&gt;
Decision rights are the actual org chart. The first half is easy for them to answer. The second half is where you find out whether engineers here are partners or ticket-takers. Listen for a concrete instance of an engineer changing a plan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. "What's the most recent thing this team shipped that didn't work, and how did you find out?"&lt;/strong&gt;&lt;br&gt;
"How did you find out" is the payload. If the answer involves dashboards, an experiment, or a customer metric, they measure. If nobody remembers anything failing, either nothing ships or nobody checks, and both are bad.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. "If I'm doing great six months in, what does that look like specifically? And who besides you has to agree?"&lt;/strong&gt;&lt;br&gt;
The second sentence separates managers who can actually promote you from managers who can only advocate. Ask it politely and you will occasionally watch someone realize, live, that they don't know.&lt;/p&gt;

&lt;h2&gt;
  
  
  What questions kill your rating?
&lt;/h2&gt;

&lt;p&gt;Questions whose answers are on the careers page, and questions that can't be wrong. "What's the culture like?" cannot be answered badly. "Do you like working here?" is answered by a person whose employer is in the room. "What's a typical day?" gets you a job-description recital.&lt;/p&gt;

&lt;p&gt;Also skip these until an offer exists: total comp, vacation policy, remote flexibility beyond what's already posted. Not because wanting them is shameful, but because the recruiter is the right person and post-offer is the right time, when your leverage is at maximum. Asking a peer interviewer about PTO burns your one high-signal slot on information you'll get for free later.&lt;/p&gt;

&lt;p&gt;And one warning about the clever ones. "What's your biggest weakness as a manager?" reads as a gotcha you got from a listicle. If you want that information, ask "what's something this team is bad at that you're actively trying to fix?" Same information, no theater.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's the trick that actually works?
&lt;/h2&gt;

&lt;p&gt;Ask the same question in every round, then compare the answers. This is the highest-leverage thing in this entire post.&lt;/p&gt;

&lt;p&gt;Pick one: "what's the biggest risk to this team hitting its goals this year?" Ask the recruiter, the peer, the manager, and the skip-level. Write down all four answers.&lt;/p&gt;

&lt;p&gt;If they're broadly consistent, that team has a shared understanding of reality. That is rarer than you think, and it correlates with almost everything good.&lt;/p&gt;

&lt;p&gt;If the manager says "hiring" and the peer says "the legacy billing service" and the skip-level says "we're waiting on a decision from another org," you've just mapped the dysfunction in ten minutes of small talk. The variance &lt;em&gt;is&lt;/em&gt; the finding. I've turned down offers on variance alone and never regretted it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you ask a hard question without sounding hostile?
&lt;/h2&gt;

&lt;p&gt;Frame it as your own planning, not as an audit. "I want to understand what my first quarter would actually look like, so: how does code get to production today?" is the same question as an interrogation, minus the interrogation.&lt;/p&gt;

&lt;p&gt;Then, if the answer is vague, ask exactly one follow-up. One. "Got it. What did that look like the last time it came up?" That's it. Two follow-ups on a sore spot and you're the person who cross-examined the team.&lt;/p&gt;

&lt;p&gt;Watching how someone handles that single follow-up is worth more than their answer. Somebody who says "honestly, that's a mess right now and here's what we're doing about it" has just given you a better reason to take the job than any perk in the packet.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what are the best questions to ask the interviewer?
&lt;/h2&gt;

&lt;p&gt;The best questions to ask the interviewer are specific, event-based, and answerable only by someone who works there: what happened to the last person in this role, what the last incident changed, how long a deploy takes today, how much time goes to unglamorous engineering work, who decides your roadmap, what recently failed and how they found out, and what success at six months concretely looks like. Ask two or three of them per round, ask one of them in &lt;em&gt;every&lt;/em&gt; round so you can compare answers across interviewers, and treat inconsistency between those answers as your most reliable signal. You will get a better read on the team than any Glassdoor page, and you will score higher on a rubric line most candidates never knew existed.&lt;/p&gt;

&lt;p&gt;What's the one question you always ask? I'm collecting good ones.&lt;/p&gt;

</description>
      <category>career</category>
      <category>interview</category>
      <category>jobs</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Using AI in a Live Coding Interview: How Interviewers Know</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Mon, 24 Aug 2026 03:37:06 +0000</pubDate>
      <link>https://dev.to/ji_ai/using-ai-in-a-live-coding-interview-how-interviewers-know-11jg</link>
      <guid>https://dev.to/ji_ai/using-ai-in-a-live-coding-interview-how-interviewers-know-11jg</guid>
      <description>&lt;p&gt;The candidate was good. Too evenly good.&lt;/p&gt;

&lt;p&gt;Every answer started with the same pause. Not a thinking pause, where someone squints and says "hmm, okay, so the naive thing would be..." A flat pause. Four seconds of nothing, then a paragraph of clean, confident, perfectly structured explanation. Then another flat pause. Then more fluency. Using AI in a live coding interview does not usually get caught by a dramatic gotcha moment. It gets caught by rhythm.&lt;/p&gt;

&lt;p&gt;I've sat on both sides of this table. Here's what actually shows up from the interviewer's chair, and the part almost nobody tells you: getting spotted is not the thing that sinks you.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Interviewers rarely detect AI use through tooling. They detect it through &lt;strong&gt;rhythm&lt;/strong&gt;: even-width pauses, code that arrives already refactored, and answers that are more polished than the person speaking them.&lt;/li&gt;
&lt;li&gt;The killer test is not a question about your code, it's a &lt;strong&gt;change to the problem&lt;/strong&gt;. Owners edit their solution. Transcribers restart from zero.&lt;/li&gt;
&lt;li&gt;You don't get "caught" and confronted. You get quietly scored low on "can defend own work" and rejected with no feedback, which is why nobody learns this lesson.&lt;/li&gt;
&lt;li&gt;In interviews that explicitly allow AI, the bar moves to &lt;strong&gt;how you use it&lt;/strong&gt;: showing your prompt, distrusting the output, and rejecting part of it out loud is the highest-scoring move available.&lt;/li&gt;
&lt;li&gt;Take-home tests aren't dead. They mutated into "here's the code, defend it" and "here's a broken PR, review it."&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do interviewers know you're using AI in a live coding interview?
&lt;/h2&gt;

&lt;p&gt;They almost never know. They notice a cluster of small mismatches and quietly stop trusting the signal. Here are the seven that come up most.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The latency signature.&lt;/strong&gt; Human thinking is uneven. People stall on the hard part and speed up on the easy part, backtrack, say "wait, no." AI-assisted answers invert that: silence, then fluency, at a constant interval. It's not the pause that's suspicious. It's that every pause is the same width.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Code that arrives already refactored.&lt;/strong&gt; Real people write &lt;code&gt;for i in range(len(arr))&lt;/code&gt; and clean it up later, if ever. Under time pressure, in minute three of a thirty-minute problem, nobody writes a helper function with a perfect name, a typed signature, and a docstring. That's not skill. That's a different process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. It solves the general problem, not mine.&lt;/strong&gt; Good interviewers take a known problem and bend one constraint: the array is nearly sorted, the API is rate-limited, you can't hold the input in memory. A pasted answer solves the textbook version and sails straight past the bend. That's the loudest tell of all, and it's completely deniable, which makes it useless as an accusation and perfect as a filter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Edge cases handled, but not noticed.&lt;/strong&gt; The code guards against empty input. Then I ask "what happens on empty input?" and they scroll up to find their own guard. Reading your own code for the first time in front of me is a distinct facial expression.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Typing cadence.&lt;/strong&gt; Not paste detection, just rhythm. People type in bursts around the hard parts and slow down on variable names. Transcribing looks different: steady, even keystrokes with small stalls at line breaks, where the eyes jump back to the source.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Register mismatch.&lt;/strong&gt; They speak like a person ("yeah so I'd just kinda loop through the thing") and their comments read like documentation ("Time complexity: O(n log n), dominated by the sort"). Two different writers sharing one window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. The follow-up ends it.&lt;/strong&gt; This is the cheap, reliable one, and it needs no suspicion to work. Don't ask about the code. Change the world around it. "Now the input arrives as a stream and doesn't fit in memory." Someone who owns their solution edits it, muttering, deleting a line, keeping the shape. Someone who received it starts over from an empty screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does using AI in an interview automatically disqualify you?
&lt;/h2&gt;

&lt;p&gt;Not the way people imagine. Almost no interviewer runs a tribunal. There's no confrontation, no "were you using ChatGPT," no dramatic reveal, because accusing someone with no proof is a terrible idea and everyone knows it.&lt;/p&gt;

&lt;p&gt;What actually happens is worse for you: the interviewer writes something like &lt;em&gt;"clean solution, couldn't extend it, unclear ownership"&lt;/em&gt; and moves the rating down. You get a polite rejection with no reason attached. So you never find out which part broke, and you run the same play at the next company.&lt;/p&gt;

&lt;p&gt;The disqualifier was never the tool. It was the eight seconds where you couldn't explain the thing on your own screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should you do if the interview allows AI?
&lt;/h2&gt;

&lt;p&gt;Use it visibly and disagree with it in front of them. Plenty of teams now run "bring your assistant" interviews, and the scorecard shifts from &lt;em&gt;did you produce code&lt;/em&gt; to &lt;em&gt;are you dangerous with a code generator&lt;/em&gt;. That's a real, gradeable skill and most candidates score badly on it because they go quiet the moment the tool takes over.&lt;/p&gt;

&lt;p&gt;Concretely, the moves that read well:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Say what you're going to ask for before you ask for it. "I'll have it stub out the retry wrapper, I want to write the backoff math myself."&lt;/li&gt;
&lt;li&gt;Read the output out loud and reject a piece of it. "This catches the generic exception, which will swallow the timeout I care about. I'm rewriting that block." One honest rejection is worth more than ten correct lines.&lt;/li&gt;
&lt;li&gt;Name what you'd verify. "I don't trust this off-by-one. Give me a second, I'll run it on an empty list and a single element."&lt;/li&gt;
&lt;li&gt;Never let generated code enter your file unread. The moment you ship a line you can't explain, you've handed the interviewer tell number four.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The uncomfortable truth is that all of this is a performance skill, and performance skills only survive contact under pressure if you've rehearsed them. Talking through your reasoning while someone silently watches is genuinely hard the first few times, and your first rep should not be the job you want. I got interested enough in this problem to build a tool for it: Preterview runs voice mock interviews with three different interviewer personalities, scores your resume and portfolio, and hands back a written report on where you drifted, rambled, or went silent. Full disclosure, I built it, so take the recommendation with the appropriate salt: &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;preterview.com/en&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%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 AI mock interview in progress" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Are take-home tests dead now?
&lt;/h2&gt;

&lt;p&gt;No, but the honest version of them changed shape. If a take-home can be finished by any assistant in four minutes, its grading value is zero, and hiring teams figured that out fast.&lt;/p&gt;

&lt;p&gt;What replaced them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Defense sessions.&lt;/strong&gt; You submit the take-home, then spend twenty minutes explaining decisions and making one live change to it. Same take-home, entirely different filter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code review as the test.&lt;/strong&gt; They hand you a PR, often deliberately generated, and score what you catch. This one is brutal and I love it, because "spot the plausible wrong thing" is now most of the job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Debugging over building.&lt;/strong&gt; A repo, a failing test, a clock. Assistants help here, which is the point: they want to see you drive.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern behind all three: writing code was never the scarce skill they were measuring. It was just the cheapest proxy for it. AI didn't kill the proxy, it exposed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What do interviewers actually score?
&lt;/h2&gt;

&lt;p&gt;Rarely the optimal solution. Read enough hiring debriefs and the reasons for a yes cluster into three things: &lt;em&gt;would I want this person next to me at 6pm on a Friday during an incident&lt;/em&gt;, &lt;em&gt;do they notice when they're wrong before I tell them&lt;/em&gt;, and &lt;em&gt;do they make problems smaller instead of bigger&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Nobody has ever written "reached the O(n) solution" as the reason a hire went through. They write "explained the tradeoff clearly," "caught their own bug," "asked the right question about scale." Which is exactly why an unexplainable perfect answer scores lower than a defended imperfect one. The perfect answer contains no information about you.&lt;/p&gt;

&lt;h2&gt;
  
  
  So, can interviewers really tell?
&lt;/h2&gt;

&lt;p&gt;Interviewers can rarely prove you're using AI in a live coding interview, and most won't try. What they detect is the gap between the quality of your code and the quality of your explanation of it: uniform pauses, solutions that arrive pre-refactored, answers that solve the textbook problem instead of the constrained one you were given, and the collapse that happens when they change a requirement and ask you to adapt. That gap is what gets scored, not the tool. Use whatever help the interview allows, but never put a line on the screen you can't defend, extend, and argue with out loud, because defending it is the actual test and always was.&lt;/p&gt;

</description>
      <category>interview</category>
      <category>career</category>
      <category>ai</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Gradient Accumulation Loss Bug: Why accum=8 Isn't Batch Size 8</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sun, 23 Aug 2026 15:34:54 +0000</pubDate>
      <link>https://dev.to/ji_ai/gradient-accumulation-loss-bug-why-accum8-isnt-batch-size-8-he7</link>
      <guid>https://dev.to/ji_ai/gradient-accumulation-loss-bug-why-accum8-isnt-batch-size-8-he7</guid>
      <description>&lt;p&gt;You drop &lt;code&gt;per_device_train_batch_size&lt;/code&gt; from 8 to 1 and set &lt;code&gt;gradient_accumulation_steps=8&lt;/code&gt; to fit the model on one GPU. Same math, less memory — that is the whole selling point. Then the fine-tune comes out measurably worse than the large-batch run, and the loss curve sits visibly above it from step one.&lt;/p&gt;

&lt;p&gt;That gap is the &lt;strong&gt;gradient accumulation loss bug&lt;/strong&gt;: the standard accumulation loop computes a mean of per-micro-batch means, not the mean over all tokens in the optimizer step. When your micro-batches contain different numbers of supervised tokens — which they always do in instruction tuning — the two are not equal, and short examples end up dominating the gradient.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;loss.backward()&lt;/code&gt; per micro-batch with &lt;code&gt;reduction="mean"&lt;/code&gt;, then dividing by &lt;code&gt;G&lt;/code&gt;, gives each &lt;strong&gt;micro-batch&lt;/strong&gt; weight &lt;code&gt;1/G&lt;/code&gt; instead of giving each &lt;strong&gt;token&lt;/strong&gt; weight &lt;code&gt;1/N&lt;/code&gt;. That is only correct when every micro-batch has the same unmasked-token count.&lt;/li&gt;
&lt;li&gt;Each token in micro-batch &lt;code&gt;g&lt;/code&gt; gets its gradient scaled by &lt;code&gt;n̄ / n_g&lt;/code&gt; (mean token count over that micro-batch's count). A 20-token answer next to an 800-token answer gets ~40x the per-token pull it deserves.&lt;/li&gt;
&lt;li&gt;DDP repeats the same error one level up: &lt;code&gt;all_reduce&lt;/code&gt; averages gradients across ranks, so uneven token counts &lt;em&gt;between&lt;/em&gt; GPUs re-skew the batch.&lt;/li&gt;
&lt;li&gt;Fix: compute &lt;code&gt;num_items_in_batch&lt;/code&gt; = total unmasked label tokens across all micro-batches &lt;strong&gt;and&lt;/strong&gt; all ranks, use &lt;code&gt;reduction="sum"&lt;/code&gt;, and divide by that one number (times &lt;code&gt;world_size&lt;/code&gt; to undo DDP's mean).&lt;/li&gt;
&lt;li&gt;Transformers ships the fix since v4.46 via &lt;code&gt;num_items_in_batch&lt;/code&gt; plus &lt;code&gt;average_tokens_across_devices&lt;/code&gt;. A custom &lt;code&gt;compute_loss&lt;/code&gt; override that omits the kwarg silently reinstates the bug.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is the gradient accumulation loss bug?
&lt;/h2&gt;

&lt;p&gt;It is the mismatch between mean-of-means and the true token mean. The objective you &lt;em&gt;think&lt;/em&gt; you are minimizing over an optimizer step is&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;L = (1 / N) * Σ_g Σ_t  ℓ(g, t)        N = Σ_g n_g
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;where &lt;code&gt;n_g&lt;/code&gt; is the number of non-masked label tokens in micro-batch &lt;code&gt;g&lt;/code&gt; and &lt;code&gt;ℓ(g,t)&lt;/code&gt; is the per-token cross-entropy. The objective the naive loop actually minimizes is&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;L̂ = (1 / G) * Σ_g [ (1 / n_g) * Σ_t ℓ(g, t) ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pull out the weight on a single token in micro-batch &lt;code&gt;g&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;true weight: &lt;code&gt;1 / N&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;naive weight: &lt;code&gt;1 / (G · n_g)&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Their ratio is &lt;code&gt;N / (G · n_g) = n̄ / n_g&lt;/code&gt;. The gradient contribution of every token is inversely proportional to how many &lt;em&gt;other&lt;/em&gt; supervised tokens happened to share its micro-batch. That is a data-ordering artifact, not a property of your loss.&lt;/p&gt;

&lt;h2&gt;
  
  
  How much gradient weight does a short example actually steal?
&lt;/h2&gt;

&lt;p&gt;Concretely. Two micro-batches, &lt;code&gt;G = 2&lt;/code&gt;, completion-only masking, &lt;code&gt;n = [20, 800]&lt;/code&gt;. Then &lt;code&gt;N = 820&lt;/code&gt;, &lt;code&gt;n̄ = 410&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tokens in the 20-token answer: scaled by &lt;code&gt;410 / 20 = 20.5x&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Tokens in the 800-token answer: scaled by &lt;code&gt;410 / 800 = 0.51x&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Relative skew between the two examples: &lt;strong&gt;40x&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instruction datasets are exactly this shape. "Yes, that's correct." sits in the same shuffle as a 900-token code explanation. With &lt;code&gt;per_device_train_batch_size=1&lt;/code&gt;, every micro-batch is one sample, so the naive loop hands each &lt;em&gt;sample&lt;/em&gt; equal weight regardless of length — you have silently switched from token-level to sequence-level averaging, without choosing to.&lt;/p&gt;

&lt;p&gt;Sequence-level averaging is a defensible objective. It is just not the one your config claims, it is not what your &lt;code&gt;bs=8&lt;/code&gt; baseline computed, and it makes short, terse, low-information targets the loudest gradient signal in the run. In practice you see it as a model that gets curt, drops formatting on long generations, and emits EOS early.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does DDP make it worse?
&lt;/h2&gt;

&lt;p&gt;Because &lt;code&gt;DistributedDataParallel&lt;/code&gt; all-reduces gradients with &lt;strong&gt;mean&lt;/strong&gt;, not sum. So even with &lt;code&gt;gradient_accumulation_steps=1&lt;/code&gt;, eight ranks holding &lt;code&gt;n = [12, 15, 640, 30, 22, 700, 18, 25]&lt;/code&gt; unmasked tokens produce a mean of eight independently normalized gradients. The two ranks that drew long samples get their tokens crushed by the same &lt;code&gt;n̄ / n_g&lt;/code&gt; factor.&lt;/p&gt;

&lt;p&gt;This is why the bug survives so long in multi-node setups: people validate their loss on a single GPU with &lt;code&gt;accum=1&lt;/code&gt;, where mean-of-means is trivially correct, and never re-check after scaling out.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you fix the gradient accumulation loss bug?
&lt;/h2&gt;

&lt;p&gt;Count tokens for the entire optimizer step first, then use &lt;code&gt;reduction="sum"&lt;/code&gt; and normalize once. The counting has to happen before any &lt;code&gt;backward()&lt;/code&gt;, which means materializing the micro-batches for the step up front.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;torch.distributed&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;dist&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;torch.nn.functional&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;cross_entropy&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;optimizer_step&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;micro_batches&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;optimizer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;world_size&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# 1) Count real supervised tokens across the WHOLE step.
&lt;/span&gt;    &lt;span class="c1"&gt;#    labels are shifted, so the last position has no target -&amp;gt; slice [..., 1:]
&lt;/span&gt;    &lt;span class="n"&gt;num_items&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mb&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;labels&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="o"&gt;!=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;mb&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;micro_batches&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float32&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;cuda&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;# 2) Every rank must divide by the SAME global denominator.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;world_size&lt;/span&gt; &lt;span class="o"&gt;&amp;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;dist&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all_reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;num_items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;dist&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReduceOp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SUM&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;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mb&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;micro_batches&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;logits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_ids&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;mb&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;attention_mask&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;mb&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attention_mask&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="n"&gt;logits&lt;/span&gt;

        &lt;span class="n"&gt;shift_logits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;[...,&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="p"&gt;:].&lt;/span&gt;&lt;span class="nf"&gt;contiguous&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;shift_labels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mb&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;labels&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="nf"&gt;contiguous&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="c1"&gt;# sum, not mean: normalization is global, not per micro-batch
&lt;/span&gt;        &lt;span class="n"&gt;loss&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cross_entropy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;shift_logits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;view&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="n"&gt;shift_logits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&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="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;shift_labels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;view&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="n"&gt;ignore_index&lt;/span&gt;&lt;span class="o"&gt;=-&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;reduction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# DDP averages grads over ranks -&amp;gt; multiply by world_size to undo it
&lt;/span&gt;        &lt;span class="n"&gt;loss&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;loss&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;world_size&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;num_items&lt;/span&gt;

        &lt;span class="c1"&gt;# only sync gradients on the final micro-batch
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&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;micro_batches&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="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;world_size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;with&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;no_sync&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
                &lt;span class="n"&gt;loss&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;backward&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;loss&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;backward&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utils&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clip_grad_norm_&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;parameters&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;optimizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;optimizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zero_grad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;set_to_none&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details that bite people writing this by hand:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The shift.&lt;/strong&gt; &lt;code&gt;labels[..., 1:]&lt;/code&gt; — if you count &lt;code&gt;(labels != -100).sum()&lt;/code&gt; on the unshifted tensor you over-count by one per sequence that ends on a supervised token. Small, but it makes your bs-vs-accum equivalence test fail at 1e-3 and you will spend an afternoon on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.float()&lt;/code&gt; on the logits.&lt;/strong&gt; In bf16 the summed loss over 8k tokens loses low bits fast. Upcast before the softmax, or accept noise larger than the effect you are measuring.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;world_size&lt;/code&gt; multiply.&lt;/strong&gt; Skip it and your effective learning rate is &lt;code&gt;1/W&lt;/code&gt; of what you set. This one hides beautifully — the run still trains, just slower, and you blame the LR schedule.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you use Hugging Face &lt;code&gt;Trainer&lt;/code&gt; or TRL's &lt;code&gt;SFTTrainer&lt;/code&gt; on a recent version, this is already handled: the trainer computes &lt;code&gt;num_items_in_batch&lt;/code&gt; for the accumulation window and threads it into the model's loss function, with &lt;code&gt;average_tokens_across_devices&lt;/code&gt; gathering the count across ranks. The trap is subclassing:&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="c1"&gt;# reintroduces the bug — kwarg dropped, model falls back to per-batch mean
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MyTrainer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Trainer&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;compute_loss&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_outputs&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="bp"&gt;...&lt;/span&gt;

&lt;span class="c1"&gt;# correct — accept it and pass it through
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MyTrainer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Trainer&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;compute_loss&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_outputs&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;num_items_in_batch&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="n"&gt;outputs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;num_items_in_batch&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;num_items_in_batch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Custom model classes have the same failure: if your &lt;code&gt;forward&lt;/code&gt; does not accept and honor &lt;code&gt;num_items_in_batch&lt;/code&gt;, the kwarg is swallowed by &lt;code&gt;**kwargs&lt;/code&gt; and you are back to mean-of-means with no warning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do some people never see this?
&lt;/h2&gt;

&lt;p&gt;Because packing hides it. If you train on packed sequences — concatenate documents to a fixed &lt;code&gt;max_seq_length&lt;/code&gt; — then &lt;code&gt;n_g&lt;/code&gt; is nearly constant across micro-batches, &lt;code&gt;n̄ / n_g ≈ 1&lt;/code&gt;, and mean-of-means is approximately right. Pretraining pipelines are packed, which is why this bug is an SFT problem, not a pretraining problem.&lt;/p&gt;

&lt;p&gt;It also disappears when every sample is a fixed-format short answer (classification-style tuning), and it is small when you compute loss over the &lt;em&gt;full&lt;/em&gt; sequence including the prompt, since prompt length dominates and varies less than completion length. Completion-only masking, variable answer lengths, and &lt;code&gt;per_device_train_batch_size=1&lt;/code&gt; is the worst case — and it is the default recipe for QLoRA on a single 24 GB card.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you verify your trainer is correct?
&lt;/h2&gt;

&lt;p&gt;One test, two minutes, and it is exact. Fix the seed, disable dropout, use fp32, take 8 samples with deliberately lopsided label counts, and compare:&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="c1"&gt;# A: one batch of 8
&lt;/span&gt;&lt;span class="n"&gt;loss_a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;step_and_get_grad_norm&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="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;batch_of_8&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="c1"&gt;# B: 8 micro-batches of 1, same samples, same order
&lt;/span&gt;&lt;span class="n"&gt;loss_b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;step_and_get_grad_norm&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="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;b0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b7&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loss_a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;loss_b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;loss_a&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;1e-5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Correct normalization gives agreement to float32 round-off. The buggy path will be off by percent-level or worse on skewed data — and the sign of the gap tells you which examples are being over-weighted. Run the same check with &lt;code&gt;torchrun --nproc_per_node=2&lt;/code&gt; against the single-GPU result to catch the DDP half.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does this affect DPO and RL fine-tunes?
&lt;/h2&gt;

&lt;p&gt;Yes, and it is more consequential there because the loss is a &lt;em&gt;difference&lt;/em&gt;. In DPO, chosen and rejected completions rarely have the same length; a preference pair normalized per-sequence versus per-token changes what the implicit reward is measuring, which is why length-normalized DPO variants exist as a separate knob. Policy-gradient fine-tunes have the identical decision at the group-mean level: normalizing each rollout by its own length weights short rollouts more per token. The mechanism is the same &lt;code&gt;n̄ / n_g&lt;/code&gt; factor.&lt;/p&gt;

&lt;p&gt;The difference is that in DPO/RL the normalization is an explicit modeling choice you can defend. In SFT accumulation, nobody chose it — it fell out of a memory-saving config flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  So why isn't accum=8 the same as batch size 8?
&lt;/h2&gt;

&lt;p&gt;Because gradient accumulation reproduces a larger batch only if you normalize the loss over the total token count of the whole optimizer step. The default loop takes the mean inside each micro-batch and then averages those means, which weights each micro-batch equally instead of each token equally — so every token's gradient gets multiplied by &lt;code&gt;n̄ / n_g&lt;/code&gt;, and short supervised targets can carry tens of times their fair share. The fix is mechanical: sum the per-token losses, divide once by the global unmasked-token count gathered across micro-batches and ranks, and multiply by &lt;code&gt;world_size&lt;/code&gt; to undo DDP's mean all-reduce. Verify with a &lt;code&gt;bs=N&lt;/code&gt; versus &lt;code&gt;bs=1 × accum=N&lt;/code&gt; gradient-norm equality test in fp32; if they do not match to round-off, your accumulated run is optimizing a different objective than the one you benchmarked.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Filtered Vector Search: Why HNSW Recall Collapses at 1% Selectivity</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sun, 23 Aug 2026 03:32:43 +0000</pubDate>
      <link>https://dev.to/ji_ai/filtered-vector-search-why-hnsw-recall-collapses-at-1-selectivity-393d</link>
      <guid>https://dev.to/ji_ai/filtered-vector-search-why-hnsw-recall-collapses-at-1-selectivity-393d</guid>
      <description>&lt;p&gt;You ship a RAG system. Retrieval looks great. Then a customer asks for per-tenant isolation, you add &lt;code&gt;WHERE tenant_id = 'acme'&lt;/code&gt; to the vector query, and nothing visibly breaks — the API still returns 10 chunks, the LLM still answers, latency is fine. Six weeks later someone notices the answers got worse for small tenants.&lt;/p&gt;

&lt;p&gt;Nothing broke loudly because filtered vector search doesn't fail with an exception. It fails by silently returning the 10 nearest neighbors &lt;em&gt;that the graph traversal happened to reach&lt;/em&gt;, which is not the same set as the 10 nearest neighbors that match your filter. Below roughly 1-3% filter selectivity, those two sets barely overlap.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HNSW is a graph, and a filter deletes nodes from it.&lt;/strong&gt; Search is greedy traversal over neighbor links; excluded nodes cut the edges the traversal needs to walk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cliff is a percolation threshold.&lt;/strong&gt; With layer-0 degree &lt;code&gt;M0 = 2M&lt;/code&gt; (default &lt;code&gt;M=16&lt;/code&gt; → 32), retaining a fraction &lt;code&gt;p&lt;/code&gt; of nodes leaves mean retained degree &lt;code&gt;≈ 32p&lt;/code&gt;. Below &lt;code&gt;p ≈ 1/32 ≈ 3%&lt;/code&gt; the matching subgraph shatters into disconnected components and greedy search can only see the component it started in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Post-filtering under-returns.&lt;/strong&gt; Fetch &lt;code&gt;ef_search&lt;/code&gt; candidates, then filter, and you get fewer than &lt;code&gt;k&lt;/code&gt; rows — pgvector before 0.8.0 did exactly this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixes, in order of preference:&lt;/strong&gt; partition/shard by the filter key; use a flat (brute-force) index for small filtered sets; enable iterative index scans (&lt;code&gt;hnsw.iterative_scan&lt;/code&gt; in pgvector 0.8+); use filter-aware graphs (Qdrant's payload subgraph links, Weaviate's ACORN strategy).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure filtered recall@k against brute force per selectivity bucket&lt;/strong&gt;, not global recall. Global recall@10 of 0.98 tells you nothing about the 0.5%-selectivity tenant.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why does adding a metadata filter break HNSW recall?
&lt;/h2&gt;

&lt;p&gt;Because HNSW recall depends on graph connectivity, and a filter is a node deletion on that graph.&lt;/p&gt;

&lt;p&gt;HNSW (Hierarchical Navigable Small World) doesn't scan vectors. It builds a multi-layer proximity graph: every node links to &lt;code&gt;M&lt;/code&gt; neighbors chosen to be a mix of near and long-range, layer 0 holds all vectors with up to &lt;code&gt;M0 = 2M&lt;/code&gt; links, and upper layers hold exponentially thinning samples that act as an express lane. A query enters at a fixed entry point in the top layer, greedily hops to whichever neighbor is closer to the query vector, descends a layer when it hits a local minimum, and at layer 0 runs a best-first search maintaining a candidate heap of size &lt;code&gt;ef_search&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The whole thing works because of the small-world property: any node is a few hops from any other. That property is a function of the edge set. When you filter, you're asking for nearest neighbors within the induced subgraph on matching nodes — and nobody built an index for that subgraph.&lt;/p&gt;

&lt;h2&gt;
  
  
  What selectivity is the actual cliff?
&lt;/h2&gt;

&lt;p&gt;Take random (uncorrelated) filters retaining a fraction &lt;code&gt;p&lt;/code&gt; of nodes. Each surviving node keeps each of its &lt;code&gt;≈ M0&lt;/code&gt; neighbors with probability &lt;code&gt;p&lt;/code&gt;, so mean retained degree is &lt;code&gt;d̄ ≈ M0 · p&lt;/code&gt;. Random graph percolation (Molloy–Reed) says a giant connected component exists roughly when mean degree exceeds 1. So:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;p_c ≈ 1 / M0 = 1 / (2M)

M = 16  → M0 = 32 → p_c ≈ 3.1%
M = 32  → M0 = 64 → p_c ≈ 1.6%
M = 64  → M0 = 128 → p_c ≈ 0.8%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Below &lt;code&gt;p_c&lt;/code&gt;, the matching set is not one graph — it's confetti. Traversal reaches one fragment and reports whatever is in it as "nearest."&lt;/p&gt;

&lt;p&gt;Two caveats make reality worse than this estimate:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Greedy search is not BFS.&lt;/strong&gt; Even inside a connected component, best-first search with heap size &lt;code&gt;ef_search&lt;/code&gt; terminates when no candidate improves the frontier. Getting from fragment to fragment often requires walking &lt;em&gt;through&lt;/em&gt; non-matching nodes whose distance is worse — exactly the moves greedy search refuses to make. Degradation starts well above &lt;code&gt;p_c&lt;/code&gt;, typically in the 5-20% band.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real filters are correlated with the embedding space.&lt;/strong&gt; &lt;code&gt;lang = 'ja'&lt;/code&gt;, &lt;code&gt;doc_type = 'invoice'&lt;/code&gt;, &lt;code&gt;tenant_id = 'acme'&lt;/code&gt; — these select clusters, not random samples. That cuts both ways: the matching set is more internally connected (good), but it sits in one region of the space while the entry point sits somewhere else (bad). The search descends the hierarchy toward the &lt;em&gt;globally&lt;/em&gt; nearest region, and if that region is filtered out, you land in a local minimum with no matching neighbors to escape through.&lt;/p&gt;

&lt;p&gt;So you get two distinct failure regimes: &lt;strong&gt;shattering&lt;/strong&gt; on uncorrelated low-selectivity filters, and &lt;strong&gt;unreachability&lt;/strong&gt; on correlated ones. Different fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does my filtered query return fewer than k rows?
&lt;/h2&gt;

&lt;p&gt;That's post-filtering. The engine asks HNSW for &lt;code&gt;ef_search&lt;/code&gt; candidates by pure vector distance, then discards the ones that fail the predicate. If 1% match, an &lt;code&gt;ef_search=40&lt;/code&gt; scan returns ~0 rows.&lt;/p&gt;

&lt;p&gt;pgvector before 0.8.0 did this. The reproduction is embarrassingly simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- pgvector &amp;lt; 0.8.0, or 0.8+ with iterative_scan = off&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector_cosine_ops&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ef_construction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ef_search&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- default&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'acme'&lt;/span&gt;          &lt;span class="c1"&gt;-- 0.4% of 5M rows&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- returns 0-2 rows. No error. No warning.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;pgvector 0.8.0 added iterative index scans, which re-enter the index and keep scanning until &lt;code&gt;LIMIT&lt;/code&gt; is satisfied or a budget is exhausted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iterative_scan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;relaxed_order&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- off | strict_order | relaxed_order&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_scan_tuples&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;-- default; raise for very low selectivity&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scan_mem_multiplier&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="c1"&gt;-- work_mem multiple for the scan buffer&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ef_search&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;strict_order&lt;/code&gt; guarantees results come back in exact distance order; &lt;code&gt;relaxed_order&lt;/code&gt; allows slight reordering and gets better recall for the same budget (re-sort in your app if order matters). &lt;code&gt;max_scan_tuples&lt;/code&gt; is the honest knob — it's a hard ceiling on effort, and when you blow through it you get partial results again, just later. For a tenant holding 0.4% of 5M rows, 20k scanned tuples covers ~80 matching rows in expectation. That may be enough for &lt;code&gt;k=10&lt;/code&gt;; it is not enough for a reranker that wants 200 candidates.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I fix filtered vector search?
&lt;/h2&gt;

&lt;p&gt;In roughly this order:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Partition by the filter key when the key is low-cardinality and always present.&lt;/strong&gt; Multi-tenancy is the obvious case. One index per tenant (or a tenant-aware payload index in Qdrant, a partition key in Milvus, a partitioned table with per-partition HNSW in Postgres) turns a 0.4%-selectivity filtered search into a 100%-selectivity unfiltered search. Selectivity becomes 1.0 and every problem in this post disappears. The cost is index-count overhead and worse behavior on cross-tenant queries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Brute-force below a threshold.&lt;/strong&gt; If the filtered set is a few thousand vectors, exact scan over 3k × 1024 dims is a handful of milliseconds and gives recall 1.0. Qdrant does this automatically via cardinality estimation and a &lt;code&gt;full_scan_threshold&lt;/code&gt;; Weaviate offers a flat index and a &lt;code&gt;dynamic&lt;/code&gt; index that switches from flat to HNSW as a collection grows. In Postgres, a partial index or simply letting the planner pick a sequential scan for a highly selective predicate achieves the same thing — check &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; and stop assuming the index is a win.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Filter-aware graph construction.&lt;/strong&gt; Qdrant builds additional links restricted to payload-defined subgraphs (tuned with &lt;code&gt;payload_m&lt;/code&gt;) so that filtered traversal has real edges to walk. ACORN — which Weaviate exposes as &lt;code&gt;filterStrategy: acorn&lt;/code&gt; — takes the predicate-agnostic route: it builds a denser graph and, during traversal, expands through the two-hop neighborhood of filtered-out nodes, effectively bridging fragments at query time. Both trade index size and build time for filtered recall.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Raise &lt;code&gt;ef_search&lt;/code&gt; last.&lt;/strong&gt; It's the reflex fix and the weakest one. It widens the frontier but cannot cross a disconnected component boundary, so it buys you linear latency for sublinear recall in exactly the regime where you need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I measure filtered recall@k?
&lt;/h2&gt;

&lt;p&gt;Bucket by selectivity, compare against exact brute force, and never report a single global number.&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;filtered_recall_at_k&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index_search&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;queries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;index_search(q, k, mask) -&amp;gt; list[int] of row ids from the vector DB.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;sub_ids&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;flatnonzero&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;sub_ids&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;sub&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;sub&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;recalls&lt;/span&gt; &lt;span class="o"&gt;=&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;q&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;queries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;q&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;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;truth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sub_ids&lt;/span&gt;&lt;span class="p"&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;argsort&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="n"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;))[:&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;        &lt;span class="c1"&gt;# exact, filtered
&lt;/span&gt;        &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;index_search&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="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                      &lt;span class="c1"&gt;# ANN, filtered
&lt;/span&gt;        &lt;span class="n"&gt;recalls&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="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;truth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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;got&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;k&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;np&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;recalls&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;sub_ids&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;embeddings&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;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.004&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.001&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;mask&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;rand&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;embeddings&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;p&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actual_p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;filtered_recall_at_k&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;queries&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="s"&gt;selectivity=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;actual_p&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  recall@10=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it twice: once with a random mask (models the shattering regime) and once with a mask derived from a real metadata field like &lt;code&gt;tenant_id&lt;/code&gt; or &lt;code&gt;lang&lt;/code&gt; (models the unreachability regime). The two curves look different, and the second one is the one your users experience.&lt;/p&gt;

&lt;p&gt;Two things to watch for. First, &lt;code&gt;len(got) &amp;lt; k&lt;/code&gt; — count truncation separately from misranking, because under-returning points at post-filtering while low overlap points at graph fragmentation. Second, log selectivity per production query so you know your actual distribution; the aggregate is usually dominated by a few large tenants while the complaints come from the long tail.&lt;/p&gt;

&lt;h2&gt;
  
  
  The direct answer
&lt;/h2&gt;

&lt;p&gt;Filtered vector search collapses at low selectivity because HNSW retrieves by walking a proximity graph, and a metadata filter deletes nodes from that graph without repairing its edges. With the default &lt;code&gt;M=16&lt;/code&gt; (32 links at layer 0), retaining a fraction &lt;code&gt;p&lt;/code&gt; of nodes leaves mean degree &lt;code&gt;≈ 32p&lt;/code&gt;, so around &lt;code&gt;p ≈ 3%&lt;/code&gt; the matching subgraph drops below the percolation threshold and fragments — greedy search then returns the best vectors inside whichever fragment it landed in, which are not the true nearest matching neighbors. Post-filtering makes it visible by returning fewer than &lt;code&gt;k&lt;/code&gt; rows; pre-filtering hides it by returning &lt;code&gt;k&lt;/code&gt; wrong rows. Fix it by partitioning on the filter key when you can, brute-forcing small filtered sets, enabling iterative index scans (&lt;code&gt;hnsw.iterative_scan&lt;/code&gt; in pgvector 0.8+) or filter-aware graphs (Qdrant payload links, Weaviate ACORN) when you can't — and validate with filtered recall@k bucketed by selectivity, because a global recall number will never show you the failure.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>YaRN vs NTK RoPE Scaling: Why 4x Context Breaks Short Prompts</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sat, 22 Aug 2026 15:30:23 +0000</pubDate>
      <link>https://dev.to/ji_ai/yarn-vs-ntk-rope-scaling-why-4x-context-breaks-short-prompts-493o</link>
      <guid>https://dev.to/ji_ai/yarn-vs-ntk-rope-scaling-why-4x-context-breaks-short-prompts-493o</guid>
      <description>&lt;p&gt;You bump &lt;code&gt;rope_theta&lt;/code&gt; from 500000 to 4000000, the model suddenly survives a 128k needle-in-a-haystack test, you ship it — and two weeks later someone notices the 1.5k-token tool-calling eval lost a few points. Nobody touched the short-prompt path. RoPE scaling touched it for you.&lt;/p&gt;

&lt;p&gt;This is the least-discussed trade in long-context work: every method that stretches a rotary model's context window pays for it in &lt;em&gt;positional resolution at short distances&lt;/em&gt;. Position Interpolation pays a lot. NTK-aware base scaling pays less. YaRN pays least, because it explicitly refuses to touch the dimensions that carry local order. Knowing which dimensions get squeezed tells you exactly which capabilities will regress.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RoPE scaling&lt;/strong&gt; (Position Interpolation, NTK-aware base scaling, YaRN) extends context by lowering rotary frequencies so out-of-range positions map back into phases the model saw in training.&lt;/li&gt;
&lt;li&gt;Lowering frequencies compresses &lt;strong&gt;relative-position resolution at distance 1–16 tokens&lt;/strong&gt; — the high-frequency dims that encode local word order. That is why short prompts regress: exact copying, argument boundaries, syntax, dedup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linear PI&lt;/strong&gt; scales every frequency by &lt;code&gt;1/s&lt;/code&gt; and is the most destructive. &lt;strong&gt;NTK-aware&lt;/strong&gt; raises the base to &lt;code&gt;s^(D/(D-2))&lt;/code&gt;, which barely touches high frequencies but under-interpolates the lowest ones. &lt;strong&gt;YaRN&lt;/strong&gt; ramps per dimension: extrapolate short wavelengths, fully interpolate long ones, blend the middle — plus an attention temperature &lt;code&gt;t&lt;/code&gt; where &lt;code&gt;sqrt(1/t) = 0.1·ln(s) + 1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Llama 3.1's &lt;code&gt;"rope_type": "llama3"&lt;/code&gt; config is a production-grade version of the same idea, gated on &lt;code&gt;original_max_position_embeddings&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Always re-run your &lt;strong&gt;short-context&lt;/strong&gt; eval suite after any RoPE change. Long-context wins are loud; short-context losses are silent.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does RoPE scaling actually change?
&lt;/h2&gt;

&lt;p&gt;RoPE encodes position by rotating query/key vectors in 2D planes. For head dimension &lt;code&gt;D&lt;/code&gt;, dimension pair &lt;code&gt;d ∈ [0, D/2)&lt;/code&gt; gets angular frequency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;θ_d = base^(-2d/D)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;so its wavelength in tokens is &lt;code&gt;λ_d = 2π · base^(2d/D)&lt;/code&gt;. Attention scores depend only on the &lt;em&gt;relative&lt;/em&gt; rotation &lt;code&gt;(m − n)·θ_d&lt;/code&gt;, which is what makes RoPE relative-position-aware for free.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;base = 10000&lt;/code&gt; and &lt;code&gt;D = 128&lt;/code&gt;: &lt;code&gt;λ_0 ≈ 6.3&lt;/code&gt; tokens (a full rotation every ~6 tokens) and &lt;code&gt;λ_63 ≈ 5.2 × 10^4&lt;/code&gt; tokens (barely a fraction of a turn across the whole trained window). The low-index dims are a fine ruler for local order; the high-index dims are a coarse ruler for "roughly where in the document."&lt;/p&gt;

&lt;p&gt;RoPE scaling changes &lt;code&gt;θ_d&lt;/code&gt;. That is the entire mechanism, and every variant differs only in &lt;em&gt;which&lt;/em&gt; &lt;code&gt;θ_d&lt;/code&gt; it changes and by how much.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does raising rope_theta extend context at all?
&lt;/h2&gt;

&lt;p&gt;Because extrapolation fails on phase, not on magnitude. During training at length &lt;code&gt;L&lt;/code&gt;, dimension &lt;code&gt;d&lt;/code&gt; only ever sees relative rotations in &lt;code&gt;[0, L·θ_d]&lt;/code&gt;. Feed it position 100k when it trained to 8k and the low-frequency dims land in an angular region the model has literally never seen — attention logits go out of distribution and the output degenerates, usually into repetition or a total collapse of retrieval.&lt;/p&gt;

&lt;p&gt;Raising &lt;code&gt;base&lt;/code&gt; shrinks every &lt;code&gt;θ_d&lt;/code&gt;, so position 100k now produces the same rotation angles that position 8k used to. Nothing is out of distribution anymore. That is why &lt;code&gt;base&lt;/code&gt; grew from 10000 (Llama 2, 4k) to 500000 (Llama 3, 8k) to 1000000 (Mistral v0.3, 32k) as native windows grew.&lt;/p&gt;

&lt;p&gt;The three classic scaling recipes, for scale factor &lt;code&gt;s = L_new / L_train&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Linear PI&lt;/strong&gt;: &lt;code&gt;θ_d' = θ_d / s&lt;/code&gt;. Uniform. Every dimension loses resolution by &lt;code&gt;s&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NTK-aware&lt;/strong&gt;: &lt;code&gt;base' = base · s^(D/(D-2))&lt;/code&gt;. For &lt;code&gt;D = 128&lt;/code&gt; the exponent is ~1.016, so the highest-frequency dim is scaled by roughly &lt;code&gt;s^(2/126)&lt;/code&gt; — essentially untouched — while the lowest-frequency dim absorbs nearly the full &lt;code&gt;s&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;YaRN&lt;/strong&gt;: a per-dimension blend of the two, plus a logit temperature correction.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why does RoPE scaling hurt short prompts?
&lt;/h2&gt;

&lt;p&gt;Because the tokens in a 1.5k-token prompt are still separated by 1–16 positions, and PI-style interpolation divides the angle those distances produce.&lt;/p&gt;

&lt;p&gt;Concretely, under linear PI with &lt;code&gt;s = 8&lt;/code&gt;, two adjacent tokens that used to differ by 1.0 radian in the fastest dimension now differ by 0.125 radians. The dot product between rotated queries and keys becomes nearly identical for &lt;code&gt;m − n = 1&lt;/code&gt; and &lt;code&gt;m − n = 2&lt;/code&gt;. The model still knows roughly where things are; it loses the crisp local ordering signal it was trained to rely on.&lt;/p&gt;

&lt;p&gt;What that breaks, in my experience, in rough order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Verbatim copying from prompt to output (IDs, hashes, quoted strings).&lt;/li&gt;
&lt;li&gt;Tool-call argument boundaries — where one JSON field ends and the next begins.&lt;/li&gt;
&lt;li&gt;Code indentation and bracket matching in short files.&lt;/li&gt;
&lt;li&gt;Anything relying on "the sentence immediately before this one."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is a second, independent effect: when you stretch the position grid, the average attention logit distribution shifts and entropy rises. YaRN handles this with an attention temperature applied to the softmax, implemented for free by scaling &lt;code&gt;q&lt;/code&gt; and &lt;code&gt;k&lt;/code&gt; by &lt;code&gt;sqrt(1/t)&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sqrt(1/t) = 0.1 · ln(s) + 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a third: if you change &lt;code&gt;base&lt;/code&gt; at inference on weights fine-tuned at the old base, &lt;em&gt;every&lt;/em&gt; layer sees shifted phase simultaneously. NTK-aware scaling is tolerable zero-shot; PI and YaRN really want a short fine-tune (a few hundred steps at the target length) before they behave.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is YaRN better than NTK-aware base scaling?
&lt;/h2&gt;

&lt;p&gt;YaRN's insight: interpolation is only &lt;em&gt;necessary&lt;/em&gt; for dimensions whose wavelength exceeds the trained context. If a dimension completes 32+ full rotations inside the original window, the model has already seen every phase it can produce — extrapolating it costs nothing. If a dimension does not complete even one rotation in the original window, it must be interpolated fully or it goes out of distribution.&lt;/p&gt;

&lt;p&gt;Define rotations-per-context &lt;code&gt;r_d = L / λ_d&lt;/code&gt;, then with &lt;code&gt;α = 1&lt;/code&gt;, &lt;code&gt;β = 32&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;import&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;

&lt;span class="n"&gt;D&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;L&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="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;8.0&lt;/span&gt;
&lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;beta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;32.0&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;D&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="n"&gt;lam&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pi&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;D&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# wavelength in tokens
&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;L&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;lam&lt;/span&gt;                               &lt;span class="c1"&gt;# rotations within trained context
&lt;/span&gt;    &lt;span class="n"&gt;gamma&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;beta&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;r&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="n"&gt;beta&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;  &lt;span class="c1"&gt;# 0 = keep, 1 = interpolate
&lt;/span&gt;    &lt;span class="n"&gt;theta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;D&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;theta_new&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;gamma&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;theta&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;gamma&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;theta&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&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="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;21&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;44&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;63&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="s"&gt;d=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; lam=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;lam&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;10.1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; r=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;8.2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; gamma=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;gamma&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Llama-2 geometry (&lt;code&gt;base=10000&lt;/code&gt;, &lt;code&gt;L=4096&lt;/code&gt;, &lt;code&gt;D=128&lt;/code&gt;) the boundaries land at &lt;code&gt;λ = 128&lt;/code&gt; tokens and &lt;code&gt;λ = 4096&lt;/code&gt; tokens, i.e.:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;dims 0–20&lt;/strong&gt;: &lt;code&gt;r &amp;gt; 32&lt;/code&gt; → &lt;code&gt;γ = 0&lt;/code&gt;, frequencies untouched. Local order survives intact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;dims 21–44&lt;/strong&gt;: ramped blend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;dims 45–63&lt;/strong&gt;: &lt;code&gt;r &amp;lt; 1&lt;/code&gt; → &lt;code&gt;γ = 1&lt;/code&gt;, fully interpolated by &lt;code&gt;s&lt;/code&gt;. These carry global position, where losing resolution is cheap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the whole difference. NTK-aware achieves something similar as a smooth side effect of the power law, but it leaves the lowest-frequency dims slightly under-interpolated, which is why pure NTK-aware tends to underperform at the very top of the extended range. YaRN's piecewise ramp is explicit about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does Llama 3.1's rope_scaling config do differently?
&lt;/h2&gt;

&lt;p&gt;It is YaRN's ramp expressed in wavelength ratios, and it is gated on the original window:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config.json — Llama 3.1 style&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"rope_theta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;500000.0&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"max_position_embeddings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;131072&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"rope_scaling"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rope_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"llama3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"factor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;8.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"low_freq_factor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"high_freq_factor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"original_max_position_embeddings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8192&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;original_max_position_embeddings: 8192&lt;/code&gt; is the key field. Wavelengths shorter than &lt;code&gt;8192 / high_freq_factor&lt;/code&gt; are left alone; wavelengths longer than &lt;code&gt;8192 / low_freq_factor&lt;/code&gt; are divided by &lt;code&gt;factor&lt;/code&gt;; the rest are smoothed between. Same three-zone structure, different parameterization.&lt;/p&gt;

&lt;p&gt;If you are applying YaRN yourself at serve time, vLLM takes it as JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vllm serve mistralai/Mistral-7B-Instruct-v0.3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-model-len&lt;/span&gt; 131072 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--rope-scaling&lt;/span&gt; &lt;span class="s1"&gt;'{"rope_type":"yarn","factor":4.0,
                   "original_max_position_embeddings":32768}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable-chunked-prefill&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things people get wrong here. First, &lt;code&gt;factor&lt;/code&gt; must match what the weights were fine-tuned with — a YaRN-tuned checkpoint already has the scaling in its config, and passing your own on top double-applies it. Second, raising &lt;code&gt;--max-model-len&lt;/code&gt; also multiplies KV cache demand per sequence, so your achievable batch size drops; the throughput hit often exceeds the quality hit.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you measure short-context regression from RoPE scaling?
&lt;/h2&gt;

&lt;p&gt;Do not evaluate a RoPE change with a long-context benchmark alone. Needle-in-a-haystack is nearly free to pass — retrieval of a single distinctive string is the easiest long-context task there is, and it will look perfect while short-prompt reasoning quietly rots.&lt;/p&gt;

&lt;p&gt;The minimum honest protocol:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Freeze a &lt;strong&gt;short-context&lt;/strong&gt; suite (≤2k tokens): your real tool-calling traces, a code-completion set, exact-match extraction, plus raw perplexity on a held-out short corpus.&lt;/li&gt;
&lt;li&gt;Run it on base and scaled configs with identical sampling and identical seeds. Compare deltas, not absolutes.&lt;/li&gt;
&lt;li&gt;Add a &lt;strong&gt;distance-stratified&lt;/strong&gt; probe: ask the model to copy the Nth token back for N ∈ {1, 2, 4, 8, 16, 64}. PI-style damage shows up as a clean gradient — worst at N = 1–4, recovering by N = 64. That gradient is the fingerprint of high-frequency compression, and it distinguishes RoPE damage from an unrelated regression.&lt;/li&gt;
&lt;li&gt;Only then run long-context evals with multiple distractors and multi-hop retrieval.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the short-context delta is unacceptable, the fix is usually not a different scaling formula — it is &lt;strong&gt;routing&lt;/strong&gt;. Serve two endpoints from the same weights, one at native length and one scaled, and dispatch on input token count. The extra memory is one model copy; the alternative is paying the interpolation tax on every 800-token request you handle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you scale RoPE at all?
&lt;/h2&gt;

&lt;p&gt;Often, no. If your workload is 95% short prompts with an occasional long document, a scaled endpoint plus retrieval beats a globally stretched model. If you are consuming a frontier API — Claude Opus 4.5, Sonnet 4.5, GPT-5.x — you do not own &lt;code&gt;rope_theta&lt;/code&gt; anyway; those windows come from native long-context training, not post-hoc interpolation, which is precisely why they do not exhibit the short-prompt cliff. The transferable lesson is the eval discipline: when you adopt any longer-context checkpoint, re-run the short-context suite before you assume it is a pure upgrade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Direct answer:&lt;/strong&gt; 4x RoPE scaling breaks short prompts because interpolation-based methods lower rotary frequencies uniformly, and the high-frequency dimensions they compress are exactly the ones encoding relative position at distances of 1–16 tokens. Linear Position Interpolation divides all frequencies by &lt;code&gt;s&lt;/code&gt; and does the most damage; NTK-aware base scaling (&lt;code&gt;base · s^(D/(D-2))&lt;/code&gt;) spares high frequencies but under-interpolates low ones; YaRN keeps dimensions completing 32+ rotations within the original window untouched, fully interpolates those completing fewer than one, ramps the middle, and applies an attention temperature of &lt;code&gt;sqrt(1/t) = 0.1·ln(s) + 1&lt;/code&gt;. Use YaRN or Llama 3.1-style &lt;code&gt;rope_scaling&lt;/code&gt; with a short fine-tune at target length, and always measure the regression with a distance-stratified short-context probe rather than a needle test.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Attention Sinks: Why Sliding-Window KV Eviction Breaks Your LLM</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sat, 22 Aug 2026 03:27:58 +0000</pubDate>
      <link>https://dev.to/ji_ai/attention-sinks-why-sliding-window-kv-eviction-breaks-your-llm-152k</link>
      <guid>https://dev.to/ji_ai/attention-sinks-why-sliding-window-kv-eviction-breaks-your-llm-152k</guid>
      <description>&lt;p&gt;A chat server runs clean for three hours. Then, at the exact turn where the rolling KV cache first exceeds its 4096-token window and evicts the oldest block, the model stops producing English. Not degraded output — broken output. Repeated tokens, random punctuation, perplexity in the thousands. Nothing else changed: same weights, same sampler, same prompt template.&lt;/p&gt;

&lt;p&gt;The cause is &lt;strong&gt;attention sinks&lt;/strong&gt;. Your eviction policy threw away the first few tokens of the sequence, and those tokens were not carrying information — they were carrying the softmax normalizer that every other head depends on.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Attention sinks&lt;/strong&gt; are the first few tokens of a sequence (usually token 0, often the BOS token) that absorb a large share of attention probability mass in most heads and layers, despite being semantically empty.&lt;/li&gt;
&lt;li&gt;They exist because softmax must sum to 1. A head that wants to attend to nothing still has to put its mass somewhere, so training pushes it onto a token every query can see under causal masking — position 0.&lt;/li&gt;
&lt;li&gt;Evict those tokens from a sliding-window KV cache and the mass gets redistributed onto real tokens, shifting every value vector. Perplexity explodes by orders of magnitude within a few tokens.&lt;/li&gt;
&lt;li&gt;The fix is StreamingLLM's: &lt;strong&gt;keep the first 4 tokens pinned forever&lt;/strong&gt;, roll the window over everything else, and assign RoPE positions by &lt;em&gt;cache slot&lt;/em&gt;, not by original token index.&lt;/li&gt;
&lt;li&gt;If you use a hosted API (Claude Opus 4.x, GPT-5.x), you never touch a KV cache — but the same logic bites you when you hand-roll conversation truncation and drop the prefix your prompt cache and your model both depend on.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is an attention sink in a transformer?
&lt;/h2&gt;

&lt;p&gt;An attention sink is a token position that consistently receives disproportionate attention weight across heads and layers while contributing almost nothing semantically. Instrument any decoder-only model — Llama, Mistral, Qwen — and print the attention distribution for a mid-depth layer. Past layer 2 or so, a large fraction of heads put most of their probability on position 0, regardless of the query. In many heads the first token takes more mass than the entire rest of the context.&lt;/p&gt;

&lt;p&gt;This is not a bug in the checkpoint. It is a structural consequence of the architecture, and it shows up in every model trained with causal masking and standard softmax attention.&lt;/p&gt;

&lt;p&gt;The companion finding is &lt;em&gt;massive activations&lt;/em&gt;: the residual-stream hidden state at those sink positions contains a handful of feature dimensions with magnitudes hundreds to thousands of times larger than typical. Those dimensions are nearly constant across inputs. The model is using the sink token as a fixed bias vector — a place to park attention and a place to store a learned constant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does softmax force the model to create attention sinks?
&lt;/h2&gt;

&lt;p&gt;Because softmax has no "none of the above" option.&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;attn_weights&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;softmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;QK&lt;/span&gt;&lt;span class="o"&gt;^&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;causal_mask&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# rows sum to exactly 1
&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;attn_weights&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt; &lt;span class="n"&gt;V&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Consider a head that has already found what it needs — say, an induction head with no matching prefix in this context. The correct output is "add nothing to the residual stream." There is no way to express that. The row must sum to 1, so &lt;em&gt;something&lt;/em&gt; gets multiplied by its value vector and written into the residual.&lt;/p&gt;

&lt;p&gt;The trick the model learns: designate a token whose value vector is near-zero (or whose contribution is a constant the rest of the network compensates for), give it a large key-query alignment, and dump the surplus mass there. Attending to it is a no-op.&lt;/p&gt;

&lt;p&gt;Which token? It has to be visible to every query. Under a causal mask, exactly one position satisfies that for every sequence length: position 0. Positions 1–3 usually get recruited too, since early tokens are visible to nearly everything and carry little content.&lt;/p&gt;

&lt;p&gt;This is also why the "softmax off by one" proposal (adding a +1 to the denominator so rows can sum to less than 1) exists, and why some recent open-weight models — OpenAI's gpt-oss family among them — ship a &lt;strong&gt;learned per-head sink logit&lt;/strong&gt; appended to the attention logits. That gives every head a real null option, so it does not need to hijack a token to get one. Models trained that way are far more tolerant of window eviction, because the sink is a parameter rather than a cache entry.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually breaks when you evict token 0?
&lt;/h2&gt;

&lt;p&gt;The moment position 0 leaves the KV cache, every head that was dumping 40–80% of its mass there has to renormalize over the remaining keys. That surplus does not vanish — softmax redistributes it proportionally onto the surviving tokens.&lt;/p&gt;

&lt;p&gt;So a head that was effectively writing nothing now writes a large weighted average of whatever happens to be in the window. Every value vector in that layer shifts. The perturbation compounds through the remaining layers, and the residual stream leaves the distribution the LM head was trained on.&lt;/p&gt;

&lt;p&gt;The failure is abrupt, not gradual. StreamingLLM (Xiao et al., 2023) demonstrated the clean version of this: dense sliding-window attention that evicts the oldest tokens shows stable perplexity right up to the eviction threshold, then jumps by orders of magnitude within a handful of tokens. Re-adding just four initial tokens restores stable perplexity over millions of tokens of streaming input.&lt;/p&gt;

&lt;p&gt;Four is the number to remember. Not one — BOS alone is usually not enough, because positions 1–3 typically carry sink duty too.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you fix sliding-window KV eviction?
&lt;/h2&gt;

&lt;p&gt;Pin the sinks, roll everything else. The subtlety is positional encoding: with RoPE, cached keys are already rotated by their original absolute position. If you evict a middle block and leave the survivors rotated at their original indices, you have punched a hole in the position sequence — the model sees positions &lt;code&gt;[0,1,2,3, 5000,5001,...]&lt;/code&gt; with a 5000-slot gap it never saw in training.&lt;/p&gt;

&lt;p&gt;RoPE is relative, so the correct fix is to encode by &lt;strong&gt;position within the cache&lt;/strong&gt;, not position within the stream. Two ways to do 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;torch&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SinkWindowCache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Keep n_sink initial tokens forever; roll a window over the rest.
    Stores UNROTATED keys so RoPE can be applied by cache slot at read time.
    &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;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_sink&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;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window&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;1020&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n_sink&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;n_sink&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v&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="bp"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# [B, H, T, D], keys pre-rotation
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k_new&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v_new&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;k_new&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k_new&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v_new&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v_new&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&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="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n_sink&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&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;budget&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n_sink&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="p"&gt;:,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;:]],&lt;/span&gt; &lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="p"&gt;:,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;:]],&lt;/span&gt; &lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# positions are cache slots, NOT original token indices
&lt;/span&gt;        &lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;device&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;apply_rope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The alternative, used by &lt;code&gt;llama.cpp&lt;/code&gt;'s context shift, is to keep keys rotated and apply a &lt;strong&gt;delta rotation&lt;/strong&gt; to the survivors when you discard a block. Because RoPE rotation composes, multiplying cached keys by the rotation matrix for &lt;code&gt;-Δ&lt;/code&gt; is exactly equivalent to having encoded them at the shifted position. That is what &lt;code&gt;llama_kv_cache_seq_add&lt;/code&gt; does, and it is why &lt;code&gt;--keep N&lt;/code&gt; exists on the CLI:&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="c"&gt;# keep BOS + first 4 tokens pinned, shift the rest down on overflow&lt;/span&gt;
./llama-server &lt;span class="nt"&gt;-m&lt;/span&gt; model.gguf &lt;span class="nt"&gt;-c&lt;/span&gt; 8192 &lt;span class="nt"&gt;--keep&lt;/span&gt; 4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you must not do is the naive version — slice the rotated key tensor and carry on. It is two lines of code, it runs, it produces tokens, and it is wrong in a way that only shows up after the window fills.&lt;/p&gt;

&lt;p&gt;Also note the ordering constraint: sinks must stay at the &lt;em&gt;front&lt;/em&gt; of the cache. Some hand-rolled implementations concatenate &lt;code&gt;[window, sinks]&lt;/code&gt; because it is easier to index. Under causal attention the relative offsets are then inverted, and you get a different flavor of the same corruption.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why don't production inference servers hit this?
&lt;/h2&gt;

&lt;p&gt;Because they refuse to evict. vLLM under memory pressure preempts a sequence and &lt;strong&gt;recomputes&lt;/strong&gt; its prefix rather than dropping keys from a full-attention model. Sliding-window attention is only applied when the model config declares it — Mistral 7B's 4096-token window, Gemma's alternating local/global layers — because those models were &lt;em&gt;trained&lt;/em&gt; with that mask and learned their sinks inside the window.&lt;/p&gt;

&lt;p&gt;That distinction is the whole thing: window eviction is safe if and only if the model was trained under the same mask. Applying a sliding window at inference to a model trained with full attention is a train/test mismatch on the attention denominator.&lt;/p&gt;

&lt;p&gt;Learned-eviction schemes (H2O, SnapKV, and their descendants) all rediscover this empirically. Their "heavy hitter" sets always include the first tokens, because those tokens have the highest accumulated attention score by a wide margin. If you implement a KV compression policy and don't hard-pin the first few slots, your policy will spend its budget rediscovering them anyway — or fail catastrophically the one time it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do attention sinks matter if I only call Claude or GPT-5?
&lt;/h2&gt;

&lt;p&gt;Directly, no — you never manage a KV cache through the Anthropic or OpenAI API, and the serving stack handles this correctly. Indirectly, yes, in two places.&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;conversation truncation&lt;/strong&gt;. When your agent loop trims history to fit a context budget, the safe move is the same shape as the sink fix: pin the prefix (system prompt, tool definitions, the first few turns), and drop from the middle or the tail. Sliding a window that eats the head of the conversation is the semantic analogue of evicting token 0, and it also invalidates every prompt-cache prefix downstream of the cut.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;quantization&lt;/strong&gt;. Massive activations live at sink positions. Per-tensor activation quantization computes a scale over a tensor containing outliers hundreds of times the median, which crushes everything else to a handful of representable levels. Any calibration set that under-samples sink positions produces scales that break the model at exactly those tokens. Per-channel or per-token scales exist largely because of this.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you detect attention sink loss in production?
&lt;/h2&gt;

&lt;p&gt;Log two things. First, at generation time, the fraction of attention mass on the first four cache slots for a mid-depth layer — run with &lt;code&gt;output_attentions=True&lt;/code&gt; on a canary request and check it is not near zero. Second, rolling mean token logprob. Sink loss shows up as a step function in the logprob trace within 5–20 tokens of the eviction event, not a slow drift. If your degradation curve looks like a cliff, correlate its timestamp with the moment your cache crossed its budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  The direct answer
&lt;/h2&gt;

&lt;p&gt;Sliding-window KV eviction breaks your LLM because the first few tokens of a sequence are attention sinks: softmax rows must sum to 1, so heads that want to output nothing dump their surplus probability mass onto the one position every causal query can see. Evicting those tokens forces that mass onto real content, perturbing every value vector in every layer at once, and perplexity jumps by orders of magnitude the instant the window rolls past the start. Fix it by pinning the first four tokens permanently, rolling the window over the rest, and assigning RoPE positions by cache slot (or applying a delta rotation to survivors) so the model never sees a gap in its position sequence. If the model was trained with a sliding window or ships learned sink logits, this is already handled — otherwise, do not evict.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Claude Prompt Caching: Why Agent Loops Miss the 20-Block Lookback</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Fri, 21 Aug 2026 15:25:29 +0000</pubDate>
      <link>https://dev.to/ji_ai/claude-prompt-caching-why-agent-loops-miss-the-20-block-lookback-d36</link>
      <guid>https://dev.to/ji_ai/claude-prompt-caching-why-agent-loops-miss-the-20-block-lookback-d36</guid>
      <description>&lt;p&gt;Your agent starts a run with &lt;code&gt;cache_read_input_tokens&lt;/code&gt; at 40K and climbing. Twelve tool calls later, reads drop to zero and &lt;code&gt;cache_creation_input_tokens&lt;/code&gt; jumps to the full conversation length — on every single turn. Nothing in your prompt changed. No timestamp, no reordered tool, no model switch. The prefix is byte-identical.&lt;/p&gt;

&lt;p&gt;You just hit the 20-block lookback window, and it is the single most expensive thing about Claude prompt caching that nobody puts in their retro.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;cache_control&lt;/code&gt; breakpoint searches backward through &lt;strong&gt;at most 20 content blocks&lt;/strong&gt; to find an existing cache entry. One agentic turn with 11 parallel tool calls emits 22+ blocks and blows past that — the next request finds nothing and rewrites the whole prefix at 1.25x.&lt;/li&gt;
&lt;li&gt;Fix it by placing &lt;strong&gt;rolling breakpoints every ~15 blocks&lt;/strong&gt;, not one marker on the last block. You get 4 breakpoints per request total; spend 1 on tools+system and rotate the other 3 through the message list.&lt;/li&gt;
&lt;li&gt;Invalidation is &lt;strong&gt;tiered&lt;/strong&gt;, not all-or-nothing: &lt;code&gt;tool_choice&lt;/code&gt;, images, and toggling thinking preserve the tools+system cache. Only tool-definition changes and model switches force a full rebuild.&lt;/li&gt;
&lt;li&gt;Changing the system prompt mid-run nukes everything downstream — unless you append a &lt;code&gt;{"role": "system", ...}&lt;/code&gt; message to &lt;code&gt;messages[]&lt;/code&gt; instead (Claude Opus 5, Opus 4.8, Fable 5; not Sonnet 5).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;input_tokens&lt;/code&gt; in the usage block is the &lt;strong&gt;uncached remainder only&lt;/strong&gt;. Total prompt size is &lt;code&gt;input_tokens + cache_creation + cache_read&lt;/code&gt;. Dashboards that graph &lt;code&gt;input_tokens&lt;/code&gt; alone will show you a flat line while you burn cache writes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why does Claude prompt caching miss in the middle of an agent loop?
&lt;/h2&gt;

&lt;p&gt;Because cache lookup is bounded. Prompt caching is a prefix match on exact bytes, but a breakpoint doesn't scan the entire history for a matching entry — it walks backward a limited number of content blocks. That limit is 20. If the previous request's cached block is more than 20 blocks behind your new breakpoint, the lookup fails, and the API treats your request as cold even though a perfectly valid entry exists.&lt;/p&gt;

&lt;p&gt;Chat apps never see this. One user turn is one text block; one assistant turn is one text block. You'd need ten round trips to move 20 blocks, and you place a breakpoint on each turn anyway.&lt;/p&gt;

&lt;p&gt;Agent loops are different. Count what a single "turn" actually appends:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Assistant message: 1 thinking block + 1 text block + N &lt;code&gt;tool_use&lt;/code&gt; blocks&lt;/li&gt;
&lt;li&gt;User message: N &lt;code&gt;tool_result&lt;/code&gt; blocks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An agent that fires 8 parallel tool calls appends 8 &lt;code&gt;tool_use&lt;/code&gt; + 8 &lt;code&gt;tool_result&lt;/code&gt; + 2 = &lt;strong&gt;18 blocks in one round trip&lt;/strong&gt;. Two of those turns and your single trailing breakpoint is 36 blocks past the last cached point. Silent miss. No error, no warning field — just &lt;code&gt;cache_read_input_tokens: 0&lt;/code&gt; and a cache-creation charge for the full history.&lt;/p&gt;

&lt;p&gt;The economics are brutal at scale. On Claude Opus 5 at $5/MTok input, a cache read is ~$0.50/MTok and a 5-minute cache write is ~$6.25/MTok. A 12x price swing per turn, triggered by a config detail you never set.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I place breakpoints so an agentic loop keeps hitting the cache?
&lt;/h2&gt;

&lt;p&gt;Stop putting one marker on the last block. Rotate a small set of markers through the message list at a stride shorter than the lookback window. Every breakpoint is both a write point &lt;em&gt;and&lt;/em&gt; a read point, so a trailing chain of them means each new request always finds a prior entry within 20 blocks.&lt;/p&gt;

&lt;p&gt;The budget matters: &lt;strong&gt;4 breakpoints per request, total, across tools + system + messages.&lt;/strong&gt; Spend one on the last system block (it caches tools and system together, since render order is &lt;code&gt;tools&lt;/code&gt; → &lt;code&gt;system&lt;/code&gt; → &lt;code&gt;messages&lt;/code&gt;) and rotate the remaining three.&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;CACHEABLE&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;text&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;image&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;tool_use&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;tool_result&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;document&lt;/span&gt;&lt;span class="sh"&gt;"&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;15&lt;/span&gt;          &lt;span class="c1"&gt;# &amp;lt; 20-block lookback, with headroom
&lt;/span&gt;&lt;span class="n"&gt;MSG_BREAKPOINTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;  &lt;span class="c1"&gt;# 4 total minus the one on the system block
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;place_breakpoints&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Re-place cache_control so no two markers are &amp;gt;STRIDE blocks apart.
    Mutates plain-dict messages in place (round-trip SDK objects with
    .model_dump() first — you cannot set cache_control on a response object).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;flat&lt;/span&gt; &lt;span class="o"&gt;=&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;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;m&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="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&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;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;m&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;m&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;b&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_control&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# clear last request's markers
&lt;/span&gt;            &lt;span class="n"&gt;flat&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;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;marks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;=&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;flat&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="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;pos&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="ow"&gt;and&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;marks&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;MSG_BREAKPOINTS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# thinking blocks can't carry cache_control — skip back to an eligible one
&lt;/span&gt;        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;pos&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="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;flat&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;CACHEABLE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;break&lt;/span&gt;
        &lt;span class="n"&gt;marks&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;pos&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;STRIDE&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;marks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;flat&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_control&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;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ephemeral&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;messages&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;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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-opus-5&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_tokens&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="n"&gt;system&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&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;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_control&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ephemeral&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}}],&lt;/span&gt;   &lt;span class="c1"&gt;# caches tools + system
&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="c1"&gt;# sorted, frozen for the run
&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;place_breakpoints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;thinking&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;adaptive&lt;/span&gt;&lt;span class="sh"&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;Two details that bite:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clear the old markers.&lt;/strong&gt; Breakpoints you set on turn 5 are still sitting in the history you resend on turn 6. If you don't strip them you'll exceed 4 and get a validation error, or worse, waste your budget on positions that no longer help.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Thinking blocks are not cacheable anchors.&lt;/strong&gt; &lt;code&gt;cache_control&lt;/code&gt; goes on &lt;code&gt;text&lt;/code&gt;, &lt;code&gt;image&lt;/code&gt;, &lt;code&gt;tool_use&lt;/code&gt;, &lt;code&gt;tool_result&lt;/code&gt;, and &lt;code&gt;document&lt;/code&gt; blocks. Landing a stride on a thinking block and silently dropping the marker is exactly how you end up 20+ blocks apart again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which request changes actually invalidate the Claude prompt cache?
&lt;/h2&gt;

&lt;p&gt;Not all of them, and this is where most teams over-engineer. There are three tiers — tools, system, messages — and a change only invalidates its own tier and everything after it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Tools&lt;/th&gt;
&lt;th&gt;System&lt;/th&gt;
&lt;th&gt;Messages&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tool definitions (add/remove/reorder)&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model switch&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;speed&lt;/code&gt;, web-search/citations toggle&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;System prompt content&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tool_choice&lt;/code&gt;, images, thinking on/off&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Message content&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the practical consequence: you can force &lt;code&gt;tool_choice&lt;/code&gt; on one turn, flip thinking off on the next, and keep the tools+system cache intact. Stop threading a "cache-safe mode" flag through your call sites for those.&lt;/p&gt;

&lt;p&gt;What you cannot do casually is touch the tool array. Tools render at position 0, so adding one tool for one turn rebuilds &lt;em&gt;everything&lt;/em&gt;. Serialize tool definitions deterministically — sort by name, &lt;code&gt;sort_keys=True&lt;/code&gt; on any JSON schema you generate — because a dict-ordering flip in your schema builder is a full-prefix invalidation that looks like nothing in a diff.&lt;/p&gt;

&lt;p&gt;Model switching has no escape hatch; caches are model-scoped. If you want a cheap Haiku 4.5 pass over a long context, that's a separate cache lineage, not a discount on the existing one.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I inject a new instruction mid-run without losing the cache?
&lt;/h2&gt;

&lt;p&gt;Append it to &lt;code&gt;messages[]&lt;/code&gt; as a system-role message instead of editing the top-level &lt;code&gt;system&lt;/code&gt; field:&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;history&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="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="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;history&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="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;system&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Terse mode enabled — keep responses under 40 words.&lt;/span&gt;&lt;span class="sh"&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;Editing top-level &lt;code&gt;system&lt;/code&gt; changes bytes ahead of the entire conversation, so every cached turn gets reprocessed at full price. A &lt;code&gt;role: "system"&lt;/code&gt; message sits &lt;em&gt;after&lt;/em&gt; the history and leaves the cached prefix intact.&lt;/p&gt;

&lt;p&gt;It's supported on Claude Opus 5, Opus 4.8, Fable 5, and Mythos 5 with no beta header — &lt;strong&gt;not&lt;/strong&gt; Sonnet 5, which returns a 400. Wrap it and fall back to putting the instruction in a user-turn block.&lt;/p&gt;

&lt;p&gt;There's a security bonus that's easy to miss: this is a non-spoofable operator channel. Instructions embedded as text inside user or tool content can be forged by anything that writes into that content — a scraped page, a tool response, a file. A &lt;code&gt;role: "system"&lt;/code&gt; message cannot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do my parallel sub-agent requests all pay full price?
&lt;/h2&gt;

&lt;p&gt;Because a cache entry only becomes readable once the first response &lt;strong&gt;begins streaming&lt;/strong&gt;. Fire ten identical-prefix requests simultaneously and none of them can read what the other nine are still writing. You pay ten cache writes at 1.25x — worse than not caching at all.&lt;/p&gt;

&lt;p&gt;The fix is a two-phase fan-out: send one request, await the first streamed token (not the full response), then release the remaining N−1. They read the entry the first one just wrote. On a 100K-token shared prefix across ten workers, that's the difference between ten writes and one write plus nine reads.&lt;/p&gt;

&lt;p&gt;Same reasoning applies to sub-agents and compaction passes. If a fork rebuilds &lt;code&gt;system&lt;/code&gt; or &lt;code&gt;tools&lt;/code&gt; with any difference from the parent, it misses the parent's cache entirely. Copy &lt;code&gt;model&lt;/code&gt;, &lt;code&gt;system&lt;/code&gt;, and &lt;code&gt;tools&lt;/code&gt; verbatim, then append fork-specific content at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why doesn't my prompt cache at all, even with the marker set?
&lt;/h2&gt;

&lt;p&gt;Probably the minimum cacheable prefix — and it is &lt;strong&gt;not monotonic across model generations&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Minimum&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Opus 5, Fable 5, Mythos 5&lt;/td&gt;
&lt;td&gt;512 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opus 4.8, Sonnet 5, Sonnet 4.6, Sonnet 4.5&lt;/td&gt;
&lt;td&gt;1024 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opus 4.7, Haiku 3.5&lt;/td&gt;
&lt;td&gt;2048 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opus 4.6, Opus 4.5, Haiku 4.5&lt;/td&gt;
&lt;td&gt;4096 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Below the threshold, the marker is accepted and ignored: no error, &lt;code&gt;cache_creation_input_tokens: 0&lt;/code&gt;. A 3K-token prefix caches on Opus 5 and Sonnet 4.5, and silently doesn't on Opus 4.6 or Haiku 4.5. Teams that route cheap sub-tasks to Haiku and expensive ones to Opus routinely find their Haiku path has never cached once.&lt;/p&gt;

&lt;p&gt;The other silent killer is TTL. The default entry lives 5 minutes. If a tool call in your loop takes 6 minutes — a long build, a slow scrape, a human approval gate — the entry expires &lt;em&gt;mid-turn&lt;/em&gt; and the next request rebuilds from scratch. Use &lt;code&gt;{"type": "ephemeral", "ttl": "1h"}&lt;/code&gt; on those paths. The write costs 2x instead of 1.25x, so break-even moves from two requests to three, which is trivially cleared by any agent loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I actually verify this?
&lt;/h2&gt;

&lt;p&gt;Log all three usage fields per request, not one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;
&lt;span class="n"&gt;total_prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_tokens&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_creation_input_tokens&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_read_input_tokens&lt;/span&gt;
&lt;span class="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="s"&gt;read=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_read_input_tokens&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; write=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_creation_input_tokens&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &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;raw=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_tokens&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; total=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;total_prompt&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The diagnostic pattern for the lookback bug is unmistakable: &lt;code&gt;cache_read&lt;/code&gt; grows normally for the first few turns, then collapses to 0 while &lt;code&gt;cache_creation&lt;/code&gt; equals the full history — and it flips exactly on the turn where a burst of parallel tool calls landed. Correlate the miss with the number of blocks that turn appended and you'll see the 20-block cliff in your own logs.&lt;/p&gt;

&lt;p&gt;If you want the API to tell you directly, there's a cache diagnostics beta: &lt;code&gt;client.beta.messages.*&lt;/code&gt; with the &lt;code&gt;cache-diagnosis-2026-04-07&lt;/code&gt; beta flag, passing &lt;code&gt;diagnostics: {previous_message_id: &amp;lt;prior response id&amp;gt;}&lt;/code&gt;. It reports why a request missed instead of leaving you to diff prompt bytes by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short answer
&lt;/h2&gt;

&lt;p&gt;Claude prompt caching misses inside agent loops because each &lt;code&gt;cache_control&lt;/code&gt; breakpoint only searches backward 20 content blocks for a matching entry, and a single agentic turn with parallel tool calls easily appends more than 20 blocks — so a lone trailing breakpoint lands out of range and the API rewrites the entire prefix at 1.25x instead of reading it at 0.1x. Fix it by rotating breakpoints through the message list at a ~15-block stride within the 4-marker budget, keeping one marker on the last system block, stripping stale markers before each request, skipping thinking blocks as anchors, and switching to a 1-hour TTL wherever tool latency can exceed five minutes. Then confirm it with &lt;code&gt;cache_read_input_tokens&lt;/code&gt; rather than the &lt;code&gt;input_tokens&lt;/code&gt; field, which reports only the uncached remainder and will happily look healthy while your cache does nothing.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>claude</category>
      <category>llm</category>
      <category>performance</category>
    </item>
    <item>
      <title>Why temperature=0 Isn't Deterministic: LLM Batch Invariance</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Fri, 21 Aug 2026 03:23:10 +0000</pubDate>
      <link>https://dev.to/ji_ai/why-temperature0-isnt-deterministic-llm-batch-invariance-3044</link>
      <guid>https://dev.to/ji_ai/why-temperature0-isnt-deterministic-llm-batch-invariance-3044</guid>
      <description>&lt;p&gt;Same weights. Same prompt. Same server. &lt;code&gt;temperature=0&lt;/code&gt;. You run it twice and token 217 comes back different, and from there the two outputs have nothing in common. Nothing in your stack sampled anything. Your seed didn't matter because greedy decoding never drew a random number.&lt;/p&gt;

&lt;p&gt;The culprit is not your code. It's that the floating-point reduction order inside your GEMM and attention kernels depends on how many &lt;em&gt;other people's&lt;/em&gt; requests were in the batch alongside yours.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Why temperature=0 isn't deterministic:&lt;/strong&gt; greedy decoding is deterministic given the logits, but the logits aren't deterministic given the server. Kernels pick reduction/split strategies based on runtime batch shape and occupancy, and floating-point addition is not associative, so the last bits move.&lt;/li&gt;
&lt;li&gt;The perturbation is tiny (roughly 1e-6 to 1e-3 in logit units for a bf16 pipeline), but &lt;code&gt;argmax&lt;/code&gt; is discontinuous. One near-tie flips, and after that the two trajectories are sampling from different contexts entirely.&lt;/li&gt;
&lt;li&gt;Main sources: split-K GEMM, split-KV attention (FlashDecoding), MoE expert grouping, chunked-prefill boundaries, prefix-cache hits, and tensor-parallel all-reduce order.&lt;/li&gt;
&lt;li&gt;Fix at the kernel level with &lt;strong&gt;batch-invariant kernels&lt;/strong&gt; (fixed split counts, no atomics, fixed merge order) — real bitwise reproducibility, at a throughput cost. There is no fix through a hosted API.&lt;/li&gt;
&lt;li&gt;If you're doing RL, this is not cosmetic: sampler and trainer disagree on logprobs for the &lt;em&gt;same&lt;/em&gt; tokens, so your "on-policy" GRPO/PPO step is quietly off-policy at step 0.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why temperature=0 isn't deterministic: the short version
&lt;/h2&gt;

&lt;p&gt;Greedy decoding is a pure function of the logit vector. So the question is whether the forward pass is a pure function of &lt;code&gt;(weights, tokens)&lt;/code&gt;. It isn't — it's a function of &lt;code&gt;(weights, tokens, batch shape, occupancy, kernel selection)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A GPU matmul doesn't sum the K dimension in one thread. It splits the work, produces partials, and combines them. How it splits depends on the shape: with &lt;code&gt;M=1&lt;/code&gt; (one decode row) there's no row parallelism, so the library reaches for split-K to fill the SMs; with &lt;code&gt;M=256&lt;/code&gt; it doesn't need to. Different split → different summation order → different rounding.&lt;/p&gt;

&lt;p&gt;In bf16, an element carries 8 explicit mantissa bits. Accumulation is usually in fp32, so end-to-end logit agreement lands somewhere around 1e-6 to 1e-3 absolute. That is invisible in the text — until it isn't.&lt;/p&gt;

&lt;p&gt;Worth killing a common misdiagnosis: this is usually &lt;strong&gt;not&lt;/strong&gt; nondeterministic atomics in the forward pass. Most inference kernels are run-to-run deterministic &lt;em&gt;for a fixed shape&lt;/em&gt;. That's exactly why the bug feels haunted: rerun it alone at 3am and it reproduces fine, then it drifts under production traffic, because your batch composition is a function of other users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does the batch dependence actually come from?
&lt;/h2&gt;

&lt;p&gt;Five places, in rough order of how often they bite:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Split-K GEMM.&lt;/strong&gt; Decode-time &lt;code&gt;M&lt;/code&gt; equals the number of sequences currently decoding. That number is traffic. Kernel heuristics switch on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split-KV attention.&lt;/strong&gt; FlashDecoding chunks the KV cache and merges partial attention outputs with a log-sum-exp rescale. The number of chunks is picked from sequence length &lt;em&gt;and&lt;/em&gt; available parallelism, so a long sequence alone on the GPU gets a different split than the same sequence sharing with 31 others. The merge is a sum of rescaled partials — order matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chunked prefill.&lt;/strong&gt; Whether token 2048 is computed at the tail of a prefill chunk or in a mixed prefill+decode batch changes the kernel path it goes through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefix caching.&lt;/strong&gt; A cache hit reuses KV computed under some &lt;em&gt;earlier&lt;/em&gt; batch shape. Cache hit and cache miss are numerically different runs of the same prompt. This one surprises people because prefix caching is supposed to be semantically transparent — it is, to within rounding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MoE routing.&lt;/strong&gt; Per-expert GEMMs have &lt;code&gt;M&lt;/code&gt; = number of tokens routed to that expert in this batch. Co-tenants change your expert's tile shape.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And one config-level source that isn't load-dependent but breaks reproducibility across deployments: &lt;strong&gt;tensor parallelism degree&lt;/strong&gt;. TP=2 and TP=4 build different all-reduce trees, so they sum partial hidden states in a different order. Same model, same prompt, different numbers. Pin TP if you compare across clusters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a 1e-6 logit change flip a whole answer?
&lt;/h2&gt;

&lt;p&gt;Because &lt;code&gt;argmax&lt;/code&gt; is discontinuous, and because divergence compounds.&lt;/p&gt;

&lt;p&gt;The flip only needs the top-2 logit gap to be smaller than the perturbation. Most positions are safe — the model is confident and the gap is &amp;gt;1 nat. But near-ties are not uniformly distributed. They cluster exactly where you'd expect: &lt;code&gt;", "&lt;/code&gt; vs &lt;code&gt;" "&lt;/code&gt;, list-marker choices, synonym pairs, the first token after a heading, whether to open a code fence now or after one more sentence.&lt;/p&gt;

&lt;p&gt;Once one token flips, the two runs no longer share a context. Everything after is a legitimately different generation. So measure &lt;strong&gt;divergence-free rate over N runs and the index of first divergence&lt;/strong&gt;, not average per-token difference — the latter saturates immediately and tells you nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I measure this on my own stack?
&lt;/h2&gt;

&lt;p&gt;Run the same request repeatedly and look at two things: whether outputs diverge, and how thin the top-2 margins are. Run the probe at &lt;code&gt;temperature=1&lt;/code&gt; so the reported logprob differences are interpretable in logit units (&lt;code&gt;log p_i − log p_j = z_i − z_j&lt;/code&gt;, since softmax's shift cancels).&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AsyncOpenAI&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;AsyncOpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8000/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x&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="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Summarize the tradeoffs of split-K GEMM in three sentences.&lt;/span&gt;&lt;span class="sh"&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;probe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;load&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;One measured request, optionally with `load` junk requests co-scheduled.&lt;/span&gt;&lt;span class="sh"&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;junk&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;m&lt;/span&gt;&lt;span class="sh"&gt;"&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;count to 200, run &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
            &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;temperature&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="n"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;m&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;temperature&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="n"&gt;logprobs&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;top_logprobs&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="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="c1"&gt;# co-tenants change the batch shape our request is computed in
&lt;/span&gt;    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&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;gather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;junk&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
    &lt;span class="n"&gt;toks&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="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;logprobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;gaps&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="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;top_logprobs&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;logprob&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;top_logprobs&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;logprob&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;logprobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;top_logprobs&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;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;toks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gaps&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;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;runs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;l&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;l&lt;/span&gt; &lt;span class="ow"&gt;in&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;runs&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="mi"&gt;0&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;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;toks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gaps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toks&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;a&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;thin&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;g&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;1e-3&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;gaps&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="s"&gt;run&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: first_divergence=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  thin_margins(&amp;lt;1e-3)=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;thin&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;gaps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;first_divergence=None&lt;/code&gt; on every row means you're batch-invariant &lt;em&gt;for these shapes&lt;/em&gt;. What you normally see instead is &lt;code&gt;None&lt;/code&gt; for the two idle runs and an integer for the loaded ones — that's the signature. The &lt;code&gt;thin_margins&lt;/code&gt; count tells you how exposed the prompt is: a response with 40 sub-1e-3 margins is a coin flip waiting for traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can I actually make it deterministic?
&lt;/h2&gt;

&lt;p&gt;Yes, on hardware you control, with &lt;strong&gt;batch-invariant kernels&lt;/strong&gt;. The rule is that the reduction strategy must be a function of the shape you &lt;em&gt;promise&lt;/em&gt;, not of runtime occupancy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;fixed split-K count (or none) regardless of &lt;code&gt;M&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;fixed-size KV chunks in attention, so the number of partials depends only on sequence length;&lt;/li&gt;
&lt;li&gt;one block per row for RMSNorm instead of switching strategies at small batch;&lt;/li&gt;
&lt;li&gt;no atomic accumulation anywhere in the path;&lt;/li&gt;
&lt;li&gt;a fixed-order tree merge for partials.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is what the batch-invariant kernel work that appeared in 2025 does, and inference stacks have started shipping toggles for it — vLLM has a batch-invariant mode in recent versions; check your version's flag rather than trusting a name from a blog post. Expect a throughput hit, concentrated at small batch and long context, because you're deliberately declining the occupancy-driven tuning that made those shapes fast.&lt;/p&gt;

&lt;p&gt;Determinism also requires freezing everything else that selects a kernel:&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="c"&gt;# reproducibility profile — every one of these changes the numbers&lt;/span&gt;
vllm serve &lt;span class="nv"&gt;$MODEL&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tensor-parallel-size&lt;/span&gt; 4 &lt;span class="se"&gt;\ &lt;/span&gt;       &lt;span class="c"&gt;# TP degree changes all-reduce order&lt;/span&gt;
  &lt;span class="nt"&gt;--dtype&lt;/span&gt; bfloat16 &lt;span class="se"&gt;\ &lt;/span&gt;               &lt;span class="c"&gt;# not "auto"&lt;/span&gt;
  &lt;span class="nt"&gt;--max-num-seqs&lt;/span&gt; 64 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-enable-prefix-caching&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt;     &lt;span class="c"&gt;# cache hit != cache miss, numerically&lt;/span&gt;
  &lt;span class="nt"&gt;--no-enable-chunked-prefill&lt;/span&gt;       &lt;span class="c"&gt;# chunk boundary changes the kernel path&lt;/span&gt;
&lt;span class="c"&gt;# plus: pin the vLLM version, the attention backend, and the GPU generation&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turning off prefix caching and chunked prefill is a large performance sacrifice. Do it for an eval or repro rig, not for your serving fleet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about hosted models like Claude Opus 4.x or GPT-5.x?
&lt;/h2&gt;

&lt;p&gt;You can't fix it, and you shouldn't build as if you could. &lt;code&gt;temperature=0&lt;/code&gt; on any hosted endpoint is best-effort, not a contract. Providers batch across tenants, route across hardware generations, and update kernels without telling you. OpenAI's &lt;code&gt;seed&lt;/code&gt; is explicitly best-effort and pairs with a fingerprint field precisely so you can detect that the backend moved; Anthropic's API doesn't offer a seed at all. Treat model output as a distribution you sample, not a function you call.&lt;/p&gt;

&lt;p&gt;Design accordingly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cache at the application layer&lt;/strong&gt;, keyed by a hash of the exact rendered prompt. That is the only real determinism available to you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evaluate with n≥5 and report an interval.&lt;/strong&gt; A single-run "regression" between two prompt versions is usually noise. Use paired sampling on the same items.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never gate CI on exact string equality.&lt;/strong&gt; Assert on parsed fields, schema validity, or a judge score.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make agent tools idempotent&lt;/strong&gt; and keyed by a request id, because a retried step can silently take a different branch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log tokens and top logprobs&lt;/strong&gt;, not just the final text. Without margins you can't tell a real behavior change from a coin flip.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why this breaks RL fine-tuning specifically
&lt;/h2&gt;

&lt;p&gt;This is the case where "it's only the last bits" stops being true. Your rollout worker (paged attention, small decode batches) and your trainer (large fused prefill-style batches) compute &lt;em&gt;different logprobs for the exact same token sequence&lt;/em&gt;. So the importance ratio π_train/π_sample is not 1 at step 0, even though the data is genuinely on-policy.&lt;/p&gt;

&lt;p&gt;Symptoms: nonzero KL against the reference at the very first step, PPO ratio clipping firing before the policy has moved, and a gradient bias that no hyperparameter sweep will explain. Either record the behavior logprob from the sampler and use it as the denominator (with truncated importance sampling), or make the two paths bitwise identical with batch-invariant kernels so the ratio is exactly 1. Averaging the discrepancy away is not an option; it's systematic, not zero-mean.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short answer
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;temperature=0&lt;/code&gt; isn't deterministic because greedy decoding only removes the &lt;em&gt;sampler's&lt;/em&gt; randomness, not the numerical variability of the forward pass. GPU kernels choose their reduction and split strategy from the runtime batch shape, floating-point addition isn't associative, so logits shift by roughly 1e-6 to 1e-3 depending on how many co-tenant requests shared your batch — and any position where the top-2 logit gap is thinner than that shift can flip, after which the two generations diverge completely. On your own hardware you can get bitwise reproducibility with batch-invariant kernels plus a pinned TP degree, dtype, backend, and version, paying throughput for it. On a hosted API you cannot, so cache prompts at the application layer, evaluate over multiple samples with intervals, and stop asserting exact strings.&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
