<?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: Jasur Yuldoshev</title>
    <description>The latest articles on DEV Community by Jasur Yuldoshev (@dreamdeck).</description>
    <link>https://dev.to/dreamdeck</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%2F4025043%2F381b89bf-27d3-4845-967d-0ca3706885b6.png</url>
      <title>DEV Community: Jasur Yuldoshev</title>
      <link>https://dev.to/dreamdeck</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dreamdeck"/>
    <language>en</language>
    <item>
      <title>Streaming returned 0 tokens and llama_decode died: asyncio.wait_for was killing my generator</title>
      <dc:creator>Jasur Yuldoshev</dc:creator>
      <pubDate>Tue, 11 Aug 2026 07:30:09 +0000</pubDate>
      <link>https://dev.to/dreamdeck/streaming-returned-0-tokens-and-llamadecode-died-asynciowaitfor-was-killing-my-generator-1ame</link>
      <guid>https://dev.to/dreamdeck/streaming-returned-0-tokens-and-llamadecode-died-asynciowaitfor-was-killing-my-generator-1ame</guid>
      <description>&lt;p&gt;Short prompts streamed fine. Anything document-sized came back empty: the route&lt;br&gt;
logged &lt;code&gt;done (~0 chars streamed)&lt;/code&gt;, the UI showed a canned fallback line, and if&lt;br&gt;
anything retried on the same instance the whole backend went down with&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;llama_decode: failed to decode, ret = -3
GGML_ASSERT: tensor buffer not set
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I spent two days on the runtime. The runtime was fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What everyone tells you to look at
&lt;/h2&gt;

&lt;p&gt;Search that symptom and you get one answer, from GitHub issues, from forum&lt;br&gt;
threads, and — I checked while writing this — from the search engine's own&lt;br&gt;
summary: your KV cache is too small, or your quantized KV cache needs flash&lt;br&gt;
attention, or llama-cpp-python is broken again. Increase &lt;code&gt;n_ctx&lt;/code&gt;. Drop the&lt;br&gt;
batch. Turn off the quantized cache and go back to &lt;code&gt;f16&lt;/code&gt;, the safe side.&lt;/p&gt;

&lt;p&gt;It is a good story. It fits the evidence: only big prompts die, big prompts use&lt;br&gt;
more KV, therefore KV. I believed it for a day and a half.&lt;/p&gt;
&lt;h2&gt;
  
  
  The measurements
&lt;/h2&gt;

&lt;p&gt;Eventually I stopped reasoning and started booting. One configuration per boot,&lt;br&gt;
same 17k-token prompt, same model (Gemma-4-12B-Q4), M4 Pro with 24 GB:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;flash attention&lt;/th&gt;
&lt;th&gt;KV type&lt;/th&gt;
&lt;th&gt;n_ctx&lt;/th&gt;
&lt;th&gt;result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;on&lt;/td&gt;
&lt;td&gt;&lt;code&gt;q8_0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;32k&lt;/td&gt;
&lt;td&gt;streams fine, 259 chunks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;on&lt;/td&gt;
&lt;td&gt;&lt;code&gt;f16&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;32k&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;llama_decode -3&lt;/code&gt;, no tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;off&lt;/td&gt;
&lt;td&gt;&lt;code&gt;f16&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;32k&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;llama_decode -3&lt;/code&gt;, no tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read that table twice, because it says the opposite of the advice.&lt;/p&gt;

&lt;p&gt;The "risky" configuration — flash attention plus a quantized key/value cache,&lt;br&gt;
the one every thread warns you about — is the only one that worked. The "safe&lt;br&gt;
side" I was being told to retreat to is the broken one. &lt;code&gt;f16&lt;/code&gt; KV simply does not&lt;br&gt;
fit a 32k sliding-attention window on a 24 GB box, so retreating there swaps a&lt;br&gt;
bug you can fix for an out-of-memory you cannot.&lt;/p&gt;

&lt;p&gt;That table killed the KV theory. It also meant I had been tuning the wrong&lt;br&gt;
component for a day and a half, which is a specific kind of annoying.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it actually was
&lt;/h2&gt;

&lt;p&gt;The route streams tokens to the client and sends a heartbeat while it waits, so&lt;br&gt;
the client's idle timer stays fed. The waiting looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;chunk&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;wait_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stream_aiter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;__anext__&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;6&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;asyncio.wait_for&lt;/code&gt; does not merely stop waiting when the timeout expires. &lt;strong&gt;It&lt;br&gt;
cancels the thing it was waiting on.&lt;/strong&gt; The coroutine here is &lt;code&gt;__anext__()&lt;/code&gt; of an&lt;br&gt;
async generator, so cancelling it does not cancel one step — it kills the&lt;br&gt;
generator. The next &lt;code&gt;__anext__()&lt;/code&gt; on a dead generator raises&lt;br&gt;
&lt;code&gt;StopAsyncIteration&lt;/code&gt;, which to the &lt;code&gt;async for&lt;/code&gt; above it is indistinguishable&lt;br&gt;
from a model that finished with nothing to say.&lt;/p&gt;

&lt;p&gt;So the route did exactly what it was written to do: the stream ended, zero&lt;br&gt;
characters had arrived, it logged that honestly and served the fallback.&lt;/p&gt;

&lt;p&gt;Meanwhile llama.cpp was still inside &lt;code&gt;llama_decode&lt;/code&gt;, holding a context that now&lt;br&gt;
belonged to nobody. Any later decode on that instance walked into the torn state&lt;br&gt;
and hit the &lt;code&gt;GGML_ASSERT&lt;/code&gt;, which does not raise — it aborts the process. That is&lt;br&gt;
why the crash looked like a &lt;em&gt;runtime&lt;/em&gt; crash: by the time it happened, my bug was&lt;br&gt;
several seconds in the past.&lt;/p&gt;

&lt;p&gt;And the reason only long prompts died is the least mysterious part of the whole&lt;br&gt;
story. Prefill on a document-sized prompt takes longer than six seconds.&lt;br&gt;
Heartbeat fires, &lt;code&gt;wait_for&lt;/code&gt; cancels, generator dies — before the model has&lt;br&gt;
produced its first token. Short prompts finish prefill inside one heartbeat and&lt;br&gt;
never meet the bug.&lt;/p&gt;

&lt;p&gt;A keep-alive that kills the thing it is keeping alive. I have written better&lt;br&gt;
code.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Keep one task alive across heartbeats and poll it with &lt;code&gt;asyncio.wait&lt;/code&gt;, which&lt;br&gt;
returns on timeout and leaves the task running:&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;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stream_aiter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;__anext__&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&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;wait&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nf"&gt;heartbeat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;# the task is still alive, still prefilling
&lt;/span&gt;        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;result&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;StopAsyncIteration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;                      &lt;span class="c1"&gt;# a real end, not a cancelled one
&lt;/span&gt;    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;
    &lt;span class="n"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stream_aiter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;__anext__&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole difference. &lt;code&gt;wait_for&lt;/code&gt; cancels; &lt;code&gt;wait&lt;/code&gt; does not. One of them&lt;br&gt;
is a timeout, the other is a kill switch with a timeout-shaped name.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to tell whether it is you or the runtime
&lt;/h2&gt;

&lt;p&gt;If you are staring at an empty stream right now, this ordering would have saved&lt;br&gt;
me most of two days:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Run the same prompt without streaming.&lt;/strong&gt; If non-stream produces text, the
model, the weights, the KV config and the context size are all fine. You have
a plumbing bug. I had this evidence on day one and explained it away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grep your own code for &lt;code&gt;wait_for&lt;/code&gt; anywhere near an async generator.&lt;/strong&gt; Also
&lt;code&gt;async_timeout&lt;/code&gt;, also any framework middleware with a request timeout. All of
them cancel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correlate with prompt length, not prompt content.&lt;/strong&gt; "Only big prompts" says
&lt;em&gt;something takes too long&lt;/em&gt;, which points at a timer, not at a tensor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Change one thing per boot.&lt;/strong&gt; Two of my configurations differed by two
variables and told me nothing; the table above is boring precisely because
each row moved one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distrust the safe-sounding fallback.&lt;/strong&gt; &lt;code&gt;f16&lt;/code&gt; KV was the retreat everyone
recommended, and on this machine it is strictly worse than the configuration
it was supposed to rescue me from. Measure your own box.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I would say to the version of me on day one
&lt;/h2&gt;

&lt;p&gt;The symptom appeared in the model layer, so I searched in the model layer, and&lt;br&gt;
the internet had a confident, popular, wrong answer waiting there. Nothing about&lt;br&gt;
&lt;code&gt;llama_decode -3&lt;/code&gt; points at an &lt;code&gt;await&lt;/code&gt; in a web route thirty files away.&lt;/p&gt;

&lt;p&gt;The thing that finally broke it open was the least clever step available: stop&lt;br&gt;
theorising, boot once per configuration, write down what happened. The table&lt;br&gt;
took an afternoon and ended the argument. The two days before it were spent&lt;br&gt;
being smart.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Config, for anyone matching symptoms: llama-cpp-python 0.3.33, Metal, M4 Pro&lt;br&gt;
24 GB, Gemma-4-12B-Q4, 32k context, K and V both &lt;code&gt;q8_0&lt;/code&gt; — keep them symmetric,&lt;br&gt;
a q8/q4 mix with flash attention crashes on Metal for real, and that one is not&lt;br&gt;
a heartbeat.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>asyncio</category>
      <category>llm</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Hot-swapping GGUF models kernel-panicked my M4 Mac: wired memory, llama.cpp, and why we restart the server instead</title>
      <dc:creator>Jasur Yuldoshev</dc:creator>
      <pubDate>Sat, 18 Jul 2026 13:43:17 +0000</pubDate>
      <link>https://dev.to/dreamdeck/the-model-switcher-that-kernel-panicked-my-mac-1o4j</link>
      <guid>https://dev.to/dreamdeck/the-model-switcher-that-kernel-panicked-my-mac-1o4j</guid>
      <description>&lt;p&gt;I shipped a model switcher last week. A settings screen, two local GGUF models on disk, a "Make active" button. I clicked it, watched a spinner for about ninety seconds, and then my Mac rebooted.&lt;/p&gt;

&lt;p&gt;Not the app. The Mac.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;panic(cpu 0 caller 0xfffffe004ac0433c): watchdog timeout: no checkins
from watchdogd in 93 seconds (299 total checkins since monitoring last enabled)
...
Compressor Info: 12% of compressed pages limit (OK) and 8% of segments
limit (OK) with 7 swapfiles and OK swap space
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A kernel panic is the operating system filing a formal complaint. This particular flavor — a watchdog timeout — is not about my code crashing. It means userspace as a whole stopped responding, and after 93 seconds of silence the kernel concluded the machine was beyond saving and pulled the plug itself.&lt;/p&gt;

&lt;p&gt;My settings button did that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the watchdog actually saw
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;watchdogd&lt;/code&gt; is a tiny daemon with one job: telling the kernel "userspace is still alive" every few seconds. It doesn't do anything heavy. For it to miss checkins for a minute and a half, the system has to be starved so badly that a trivial process can't get scheduled or can't allocate a page.&lt;/p&gt;

&lt;p&gt;The panic log has the tell: &lt;strong&gt;7 swapfiles&lt;/strong&gt;, and a memory compressor reporting "OK" with plenty of headroom. The machine spent its final minutes frantically paging out everything that could be paged. It wasn't enough — because the memory that mattered couldn't be paged at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two models, one process
&lt;/h2&gt;

&lt;p&gt;The arithmetic is embarrassingly simple in hindsight. My app runs llama.cpp in a Python sidecar (llama-cpp-python 0.3.33), all layers on Metal, on a 24 GB Apple Silicon machine. The active model was a 12B at Q4_K_M — 7.4 GB of weights, call it ~9 GB resident with the KV cache. The user switches to a 14B at Q5_K_M — 9.8 GB of weights, ~11 GB resident.&lt;/p&gt;

&lt;p&gt;The switch endpoint did the obvious thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_llama&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;                      &lt;span class="c1"&gt;# release the old model
&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;_llama&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Llama&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;      &lt;span class="c1"&gt;# load the new one
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop the reference, load the replacement. It works in every tutorial, because every tutorial has one model and enough RAM.&lt;/p&gt;

&lt;p&gt;Here's what actually happened: 9 GB of "released" old model + 11 GB of new model loading + macOS + my app + a browser, on a 24 GB machine. And the crucial detail: model weights on Metal are &lt;strong&gt;wired&lt;/strong&gt; memory — physical pages pinned so the GPU can address them. Wired pages don't swap. They don't compress. When wired allocations eat the machine, the kernel has nothing left to steal, userspace grinds to a halt, and the watchdog does what watchdogs do.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Released" is not "returned"
&lt;/h2&gt;

&lt;p&gt;That &lt;code&gt;self._llama = None&lt;/code&gt; line does less than it appears to. On our stack — llama-cpp-python 0.3.33, Metal backend — we had already observed this once in a different corner: our idle unloader drops the model object after a few minutes of inactivity, and the process footprint... doesn't come down. The Python object dies; the wired pages stay attached to the process. The only event we've found that reliably returns that memory to the OS is &lt;strong&gt;process exit&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I won't claim this is a law of nature. Maybe an explicit &lt;code&gt;close()&lt;/code&gt; at exactly the right moment behaves better in your version, maybe a future release fixes it. But we measured ours, twice, on the machine that matters: memory comes back when the process dies, and not before. If your swap design depends on the old model's memory being available for the new one, you're betting your users' machines on a deallocation you don't control.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is boring, and that's the point
&lt;/h2&gt;

&lt;p&gt;The switch button no longer touches the loaded model at all. It writes the chosen model path to a small state file and returns &lt;code&gt;{"restart_required": true}&lt;/code&gt;. The app then restarts the sidecar process: the old process dies (taking every wired byte with it, guaranteed, by the only mechanism that guarantees it), the new process reads the state file and loads the chosen model.&lt;/p&gt;

&lt;p&gt;Cost to the user: 10–20 seconds of "Restarting the server…" instead of occasionally costing them their entire machine. I'll take that trade every day of the week.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three traps between me and the boring fix
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;terminate()&lt;/code&gt; doesn't wait.&lt;/strong&gt; Signalling the old process and immediately launching the new one reintroduces the exact bug through a different door — for a few seconds both processes hold their models. You have to wait for actual death: SIGTERM, poll until the process is gone, SIGKILL after a deadline (a load stuck inside C code never reaches a signal handler). Only then launch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The port lies after death.&lt;/strong&gt; Our supervisor probes the sidecar's port before launching, with a plain &lt;code&gt;bind()&lt;/code&gt;. The connections a just-killed process leaves in FIN_WAIT_2 make that probe fail — port "busy" — so the supervisor helpfully relocated to the next port on every single swap. The server itself binds with SO_REUSEADDR and retakes the port without complaint. The probe now does the same, plus a &lt;code&gt;connect()&lt;/code&gt; check — because SO_REUSEADDR alone will happily bind 127.0.0.1 right over someone else's wildcard listener, and shadowing a stranger's server is worse than moving.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Gate by total RAM, not available.&lt;/strong&gt; We added a pre-flight check: refuse to load a model that can't fit. First version used &lt;em&gt;available&lt;/em&gt; memory. Wrong metric — at swap time the old model is still resident, so "available" is tiny and the gate refused every legitimate swap. Whether a model fits this machine is a property of &lt;strong&gt;total&lt;/strong&gt; RAM (the weights are wired; the OS will evict everything else to make room). "Available" is only good for a soft warning: close some apps, this will be tight.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you're building a model switcher
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Assume wired model memory returns on process exit and at no other time. Design the swap as a restart.&lt;/li&gt;
&lt;li&gt;Wait for the old process to actually die before starting the new one. Poll, then SIGKILL. No overlap, ever.&lt;/li&gt;
&lt;li&gt;Probe ports the way your server binds them (SO_REUSEADDR), and verify with connect(), or enjoy your app quietly migrating ports.&lt;/li&gt;
&lt;li&gt;Hard-refuse models by total RAM. Warn by available. Never block on available.&lt;/li&gt;
&lt;li&gt;If the refusal message says "needs ≥N GB", compute N from the actual numbers. Users on a 24 GB machine reading "requires ≥24 GB" will have questions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The repro script and the full panic log are here: &lt;a href="https://github.com/JackYU96/swap-models-restart-process" rel="noopener noreferrer"&gt;https://github.com/JackYU96/swap-models-restart-process&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Mac survived. The hot-swap didn't.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>macos</category>
      <category>debugging</category>
      <category>llamacpp</category>
    </item>
    <item>
      <title>Your Hugging Face download isn't stuck — you're being rate-limited</title>
      <dc:creator>Jasur Yuldoshev</dc:creator>
      <pubDate>Mon, 13 Jul 2026 10:39:24 +0000</pubDate>
      <link>https://dev.to/dreamdeck/your-hugging-face-download-isnt-stuck-youre-being-rate-limited-44dm</link>
      <guid>https://dev.to/dreamdeck/your-hugging-face-download-isnt-stuck-youre-being-rate-limited-44dm</guid>
      <description>&lt;p&gt;I killed a perfectly healthy 2 GB model download three times before I understood what was happening. Each time the same picture: the first 100–200 MB fly by in about a minute, then the progress bar freezes at 0 B/s. Not slow — zero. A minute passes. Five. Ten. Any reasonable person concludes the download is dead, kills the process, and tries again. And again the first 200 MB arrive instantly, and again everything stops.&lt;/p&gt;

&lt;p&gt;The download was fine. I was being rate-limited, and everything about how that presents itself is designed — unintentionally, I assume — to convince you it's a hang.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part where I blamed the VPN
&lt;/h2&gt;

&lt;p&gt;My setup at the time went through a VPN, so naturally the VPN got blamed first. It's the obvious suspect: flaky route, dropped connection, MTU weirdness, pick your favorite.&lt;/p&gt;

&lt;p&gt;But the evidence didn't fit, and it took me embarrassingly long to notice. &lt;strong&gt;The first burst always came through at full speed.&lt;/strong&gt; A broken network path doesn't hand you 200 MB in a minute and then die at exactly the same point every retry. Broken networks are random. This was punctual. Whatever was stopping the download lived on the other end and had a policy about it.&lt;/p&gt;

&lt;p&gt;That's the diagnostic worth remembering, because it applies to a lot more than Hugging Face: &lt;em&gt;a fast start followed by a consistent stall is a limiter, not a failure.&lt;/em&gt; Random is hardware; punctual is policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The warning everyone scrolls past
&lt;/h2&gt;

&lt;p&gt;Once I stopped blaming infrastructure and read my own logs from the top, the answer was sitting in plain sight, printed once at the very beginning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You are sending unauthenticated requests to the HF Hub.
Please set a HF_TOKEN to enable higher rate limits and faster downloads.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It scrolls by in the first second, right before hundreds of progress-bar updates bury it. It reads like boilerplate — every tool prints some variation of "log in for a better experience", and we've all learned to ignore that sentence shape. But this one is literal.&lt;/p&gt;

&lt;p&gt;Since then Hugging Face has actually published &lt;a href="https://huggingface.co/docs/hub/en/rate-limits" rel="noopener noreferrer"&gt;official numbers&lt;/a&gt;, and they explain the shape of what I saw. All quotas run in &lt;strong&gt;fixed 5-minute windows&lt;/strong&gt; — which is precisely why the pattern is burst, wall, burst. File downloads ("resolver" requests) get 3,000 requests per window for anonymous users, and a big model is nowhere near one request: multiple files, ranged chunks, retries and redirects all count. Blow through the window and the Hub answers &lt;strong&gt;429&lt;/strong&gt; with a &lt;code&gt;RateLimit&lt;/code&gt; header saying exactly how long until reset.&lt;/p&gt;

&lt;p&gt;And here's the detail that turns a rate limit into a "hang": recent &lt;code&gt;huggingface_hub&lt;/code&gt; (1.2+) reads that header and &lt;strong&gt;silently sleeps until the window resets, then retries&lt;/strong&gt;. Older versions sit in exponential backoff. Either way, what you see is a progress bar frozen at 0 B/s while the client obediently waits out its penalty. Nothing is printed. It's the correct behavior, and it looks exactly like a dead download. For the model I was pulling — about 2.2 GB, stored as both safetensors and pytorch bins, so effectively downloaded twice — that turned an expected 3–5 minutes into 15–25.&lt;/p&gt;

&lt;p&gt;One more trap hiding in there: &lt;strong&gt;the anonymous quota is shared per IP address.&lt;/strong&gt; Behind an office NAT, a university network or a busy CI runner, you're splitting those 3,000 requests with everyone else on the same address. That's how you get "works from home, stalls at work" — and, to be fair, the reverse mystery too: some anonymous users pull terabytes and &lt;a href="https://discuss.huggingface.co/t/downloads-intermittently-fail-403-low-bandwidth/140198" rel="noopener noreferrer"&gt;never see a stall&lt;/a&gt;. Whether you hit the wall depends on who you share an IP with. It can even get sillier than a slow download: there's a &lt;a href="https://github.com/ggml-org/llama.cpp/issues/21677" rel="noopener noreferrer"&gt;llama.cpp issue&lt;/a&gt; where hitting the limit made the client unable to use a model it had &lt;em&gt;already downloaded&lt;/em&gt;, because it checked freshness against the Hub before loading the cache.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check whether it's alive
&lt;/h2&gt;

&lt;p&gt;Before killing anything, look at the cache. Hugging Face downloads land in content-addressed blobs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-lh&lt;/span&gt; ~/.cache/huggingface/hub/models--&amp;lt;org&amp;gt;--&amp;lt;name&amp;gt;/blobs/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There'll be a file ending in &lt;code&gt;.incomplete&lt;/code&gt;. Note its size, wait a few minutes, look again. Flat, then a jump of a few hundred megabytes, then flat again — that's the window cycle, and the process (sitting quietly in sleep/IO-wait, not spinning CPU) will eventually get there. And because blob names are content hashes, partial files survive a restart — resume actually resumes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fixes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The token.&lt;/strong&gt; A free account moves you from the shared anonymous pool to a &lt;strong&gt;per-user&lt;/strong&gt; quota (and bumps resolvers to 5,000 per window). That per-user part is the real win — nobody else's CI can eat your budget anymore:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;hf auth login          &lt;span class="c"&gt;# huggingface_hub 1.0+&lt;/span&gt;
huggingface-cli login  &lt;span class="c"&gt;# older installs&lt;/span&gt;
&lt;span class="c"&gt;# or just: export HF_TOKEN=hf_...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this had been the first line of that warning message in bold red, this article wouldn't exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The mirror.&lt;/strong&gt; For public open-weight models there's a fix that needs no account at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;HF_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://hf-mirror.com python your_script.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://hf-mirror.com/" rel="noopener noreferrer"&gt;hf-mirror.com&lt;/a&gt; is a long-running public mirror (still alive and growing as of 2026). It speaks the same protocol, so resume picks up your existing partial blobs. Since you're downloading tensors, not executable code, and integrity is checked against hashes, the trust story is manageable — a judgment call I'd only make for public weights, though, never for anything sensitive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The speed flag — but check your version.&lt;/strong&gt; This one changed under everyone's feet. The classic advice — &lt;code&gt;pip install hf_transfer&lt;/code&gt; + &lt;code&gt;HF_HUB_ENABLE_HF_TRANSFER=1&lt;/code&gt; — is &lt;strong&gt;dead in &lt;code&gt;huggingface_hub&lt;/code&gt; 1.0+&lt;/strong&gt;: the Xet backend replaced it, and the old env var is now &lt;a href="https://github.com/huggingface/huggingface_hub/issues/4219" rel="noopener noreferrer"&gt;silently ignored&lt;/a&gt;, which is its own little gotcha-inside-a-gotcha. On current installs the equivalent knob is &lt;code&gt;HF_XET_HIGH_PERFORMANCE=1&lt;/code&gt; (meant for fat pipes and machines with RAM to spare). On pre-1.0 installs, the old &lt;code&gt;hf_transfer&lt;/code&gt; advice still applies. Neither raises your rate limit — they just make the bytes flow faster between penalties.&lt;/p&gt;

&lt;h2&gt;
  
  
  The product-grade conclusion
&lt;/h2&gt;

&lt;p&gt;I hit all of this building a desktop app whose backend pulls models on first run — which means my users would hit it too, on their machines, on their network routes, sharing IPs with strangers, with no idea what an &lt;code&gt;HF_TOKEN&lt;/code&gt; is.&lt;/p&gt;

&lt;p&gt;So the durable lesson for anything you ship to end users: &lt;strong&gt;don't make your users talk to Hugging Face at all.&lt;/strong&gt; Host the models you depend on yourself — object storage behind a CDN, a manifest with SHA-256 checksums, resumable downloads. First-run should depend on your infrastructure, not on the rate-limit policy of a third party toward an anonymous user you'll never get to debug.&lt;/p&gt;

&lt;p&gt;For your own machine, though, the whole fix is one line in your shell profile. Set the token, or set the mirror — and the next time a download sits at 0 B/s, check the &lt;code&gt;.incomplete&lt;/code&gt; file before you reach for Ctrl-C. It's probably not dead. It's waiting for a five-minute window to roll over, and unlike you, it knows exactly how long that takes.&lt;/p&gt;

</description>
      <category>huggingface</category>
      <category>llm</category>
      <category>machinelearning</category>
      <category>debugging</category>
    </item>
    <item>
      <title>My local RAG silently returned nothing: a missing Ollama daemon, and moving embeddings in-process with llama.cpp</title>
      <dc:creator>Jasur Yuldoshev</dc:creator>
      <pubDate>Sat, 11 Jul 2026 10:41:08 +0000</pubDate>
      <link>https://dev.to/dreamdeck/in-process-embeddings-for-a-desktop-ai-app-or-how-a-missing-daemon-silently-broke-my-rag-4jbh</link>
      <guid>https://dev.to/dreamdeck/in-process-embeddings-for-a-desktop-ai-app-or-how-a-missing-daemon-silently-broke-my-rag-4jbh</guid>
      <description>&lt;p&gt;I'm building a desktop AI app for non-technical users — the kind of person who double-clicks an icon and expects search to work, and who will never open a terminal in their life. Under the hood it does local RAG: ingest documents, embed them, retrieve on each question. For a while, the embedding step went through Ollama.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that didn't look like a bug
&lt;/h2&gt;

&lt;p&gt;On my machine everything was fine, because on my machine Ollama is always running. Then I ran the flow a real user would hit — fresh login, Ollama not started — and watched the app quietly fall apart:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/ask&lt;/code&gt; returned an answer with no retrieved context.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/search&lt;/code&gt; returned nothing at all.&lt;/li&gt;
&lt;li&gt;Ingestion accepted documents and indexed none of them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No stack trace. No red banner. The app looked healthy and did nothing useful.&lt;/p&gt;

&lt;p&gt;To be clear, this wasn't Ollama being sneaky. Ollama fails loudly — you get connection refused the instant you hit &lt;code&gt;localhost:11434&lt;/code&gt; with nothing behind it. The failure was mine: my error handling caught that exception and turned it into an empty result, and every caller downstream treated "empty" as "no matches." A user asking a perfectly good question got a confident, sourceless answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happens: the daemon is a dependency you can't see
&lt;/h2&gt;

&lt;p&gt;The swallowed exception is the easy part to fix. The architectural lesson is the one worth keeping: by routing embeddings through Ollama, I had made a separate background process's liveness a hard requirement for my app to function correctly. That's a fine trade when &lt;em&gt;you&lt;/em&gt; are the operator. It's a terrible trade when your user doesn't know a daemon exists, can't tell whether it's running, and certainly won't restart it after a reboot.&lt;/p&gt;

&lt;p&gt;For a desktop app aimed at non-technical people, "keep this background service alive or your search silently breaks" is not a requirement you get to impose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Load the same model in-process. bge-m3, 1024-dim, normalized — identical to what I was pulling from Ollama, just running inside my own Python backend via sentence-transformers.&lt;/p&gt;

&lt;p&gt;Before:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:11434/api/embed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bge-m3&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;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;embeddings&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;After:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sentence_transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SentenceTransformer&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;SentenceTransformer&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_get_model&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;SentenceTransformer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="n"&gt;_model&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_model&lt;/span&gt; &lt;span class="ow"&gt;is&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;# lazy singleton
&lt;/span&gt;        &lt;span class="n"&gt;_model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SentenceTransformer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BAAI/bge-m3&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;_model&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_get_model&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;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalize_embeddings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;tolist&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;encode&lt;/code&gt; is blocking and CPU/GPU-heavy, so in a FastAPI backend you don't call it on the event loop. Push it to a worker thread:&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;anyio&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;embed_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;anyio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;to_thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And make health deterministic instead of guessing. A tiny status enum beats a boolean, because "loading a 2 GB model" is a real state that lasts several seconds:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ModelStatus&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;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;NOT_LOADED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NOT_LOADED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;LOADING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LOADING&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;READY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;READY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;FAILED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FAILED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;/health&lt;/code&gt; can tell the UI exactly where it stands, and the UI can disable search until the answer is &lt;code&gt;READY&lt;/code&gt; instead of returning empty nonsense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not a hybrid fallback
&lt;/h2&gt;

&lt;p&gt;The obvious next thought is: keep both. Try Ollama, fall back to in-process if the daemon is down. For embeddings, that is a bug — not a robustness feature.&lt;/p&gt;

&lt;p&gt;Ollama serves a quantized GGUF. sentence-transformers runs fp32. Same model name, different numerics, and therefore a different vector space. The two backends do not produce interchangeable vectors.&lt;/p&gt;

&lt;p&gt;That matters because your index is written once and queried many times. If some vectors were written by the GGUF path and you query with the fp32 path — or you re-ingest under a different backend than you started with — the cosine distances between them are quietly meaningless. Nothing throws. Retrieval just gets subtly, unpredictably worse, and you'll waste days blaming your chunking or your reranker.&lt;/p&gt;

&lt;p&gt;So the rule is one backend per index. If you want to switch backends, you re-embed the whole corpus once, deliberately. You never mix them and hope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;p&gt;I'm not going to pretend this is free.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PyTorch is heavy.&lt;/strong&gt; sentence-transformers pulls in PyTorch — gigabytes on disk. You're trading a daemon dependency for a large Python dependency. This only makes sense if your app already ships a Python backend or sidecar, which mine does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The first run downloads ~2.2 GB.&lt;/strong&gt; The bge-m3 weights come from Hugging Face on first use. For real users you bundle them with the app or self-host them, because anonymous HF downloads get throttled once you're past a few hundred megabytes, and "the app hangs on first launch" is a terrible first impression.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;fp32 eats RAM.&lt;/strong&gt; In-process fp32 uses more memory than Ollama's quantized GGUF, and it competes with the LLM for that memory on the same machine. On an 8 GB laptop, that competition is real.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When Ollama is still the right call
&lt;/h2&gt;

&lt;p&gt;If your users are developers running their own stack — people who already have Ollama up, who want bring-your-own-model, who treat the daemon as infrastructure they control — then routing through it is the right design. The daemon stops being a hidden liability and becomes a feature. My users aren't developers, so for me it wasn't.&lt;/p&gt;

&lt;p&gt;The code and a longer write-up are here: &lt;a href="https://github.com/JackYU96/embed-without-daemon" rel="noopener noreferrer"&gt;https://github.com/JackYU96/embed-without-daemon&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The app itself isn't launched yet — this is a pattern I settled on while building it, not a shipped product. If you've solved the desktop-embeddings problem a different way, I'd genuinely like to hear it.&lt;/p&gt;

</description>
      <category>ollama</category>
      <category>python</category>
      <category>fastapi</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
