<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[How to Run a Multilingual LLM Fully Offline on a $200 Android Phone]]></title><description><![CDATA[How to Run a Multilingual LLM Fully Offline on a $200 Android Phone]]></description><link>https://run-llmoffline.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Tue, 27 Oct 2026 23:42:14 GMT</lastBuildDate><atom:link href="https://run-llmoffline.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Three Crashes and One Mystery: Deploying a Medical AI Model Offline for Four Nigerian Languages]]></title><description><![CDATA[I set out to deploy a fine-tuned LLM fully offline, on a mid-range Android phone, answering medical questions in Yoruba, Hausa, Igbo, and Nigerian Pidgin…]]></description><link>https://run-llmoffline.hashnode.dev/three-crashes-and-one-mystery-deploying-a-medical-ai-model-offline-for-four-nigerian-languages</link><guid isPermaLink="true">https://run-llmoffline.hashnode.dev/three-crashes-and-one-mystery-deploying-a-medical-ai-model-offline-for-four-nigerian-languages</guid><category><![CDATA[MachineLearning]]></category><category><![CDATA[Machine Learning]]></category><category><![CDATA[nlp]]></category><category><![CDATA[nlp transformers]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[Open Source AI]]></category><category><![CDATA[opensource]]></category><category><![CDATA[Africa]]></category><category><![CDATA[@Africa Agility]]></category><dc:creator><![CDATA[Afolabi Faruq]]></dc:creator><pubDate>Sun, 19 Jul 2026 06:05:27 GMT</pubDate><content:encoded><![CDATA[<p>I set out to deploy a fine-tuned LLM fully offline, on a mid-range Android phone, answering medical questions in Yoruba, Hausa, Igbo, and Nigerian Pidgin. No internet connection required, because that's the reality for a lot of the people this was meant to help.</p>
<p>The model worked. Getting there broke three times, in three completely different ways, and left me with one problem I still haven't solved.</p>
<h2>The setup</h2>
<p>I fine-tuned <code>unsloth/Llama-3.2-3B-Instruct</code>, Unsloth's 4-bit-optimized derivative of Meta's Llama 3.2 3B, in two stages: QLoRA supervised fine-tuning on a curated dataset of 3,917 medical question-answer pairs across the four languages, followed by direct preference optimization (DPO) to sharpen response quality. SFT converged to a loss of 1.099. DPO landed a reward margin of 18.40.</p>
<p>Then I merged to 16-bit and tried to convert to GGUF, the format <code>llama.cpp</code> needs to run the model on-device. That's where things started breaking.</p>
<h2>Crash 1: the tokenizer that thought it was someone else</h2>
<p>First conversion attempt. Model loads. First inference call. Instant crash:</p>
<pre><code class="language-plaintext">terminating due to uncaught exception of type std::out_of_range: 
unordered_map::at: key not found
</code></pre>
<p>Turns out the conversion had written <code>tokenizer.ggml.model = "llama"</code> into the GGUF file. That field tells the runtime which tokenizer code path to use, and <code>"llama"</code> routes to SentencePiece. Llama 3.2 doesn't use SentencePiece. It uses BPE. The runtime was trying to read BPE tokens through a SentencePiece parser, and predictably, it found nothing where it expected something.</p>
<p>Fix: manually set the field to <code>"gpt2"</code>, which routes to the correct BPE path.</p>
<h2>Crash 2: the tokenizer that couldn't decide what it was</h2>
<p>Fixed crash 1, tried again. New failure, before the model even finished converting:</p>
<pre><code class="language-plaintext">TypeError: Llama 3 must be converted with BpeVocab
</code></pre>
<p>followed, after the code's fallback path kicked in, by:</p>
<pre><code class="language-plaintext">ValueError: Cannot instantiate this tokenizer from a slow version
</code></pre>
<p>This one took longer to trace. Unsloth's saved <code>tokenizer_config.json</code> includes <code>"from_slow": true</code> and <code>"backend": "tokenizers"</code>, fields that made <code>transformers</code> try to instantiate the tokenizer as if it were a slow SentencePiece-style tokenizer, when it's actually a fast BPE tokenizer. The auto-detection logic just picked the wrong branch.</p>
<p>Fix: skip the auto-detection entirely. Route Llama 3's vocabulary directly through <code>BpeVocab</code>, and load the tokenizer directly via <code>PreTrainedTokenizerFast</code> instead of letting <code>AutoTokenizer</code> guess.</p>
<h2>Crash 3: the vocabulary with no rulebook</h2>
<p>Two down. Converted again. This time the model failed to <em>load</em>, not just infer:</p>
<pre><code class="language-plaintext">error loading model vocabulary: cannot find tokenizer merges in model file
</code></pre>
<p>BPE needs a merges table, basically the rulebook for how to combine subword tokens back together. By default, the conversion path wasn't passing <code>load_merges=True</code>, so the 280,147-entry merges table for this tokenizer never made it into the GGUF file at all.</p>
<p>Fix: pass <code>load_merges=True</code> explicitly.</p>
<p>Three fixes in, the model finally converted cleanly and ran on-device.</p>
<h2>The part I haven't solved</h2>
<p>Here's where I want to be straight with you, because it would be easy to wrap this up as "and then everything worked perfectly."</p>
<p>After all three fixes, the model runs. Most output is fine. But on a subset of Yoruba and Igbo prompts, it produces fluent-looking output that isn't real language. I asked it, in Yoruba, what to take for a headache:</p>
<pre><code class="language-plaintext">ogun ori fifo?
</code></pre>
<p>And got back:</p>
<pre><code class="language-plaintext">B��ọpọ nile, ounllo ni bi lojijoe obi mii. Odi kurosi ti wa (o) 
oju, ekuruji nira naa rẹ lọwalo ninu ile ounllo.
</code></pre>
<p>That's not grammatical Yoruba. It reads like Yoruba if you don't look closely, invented words with plausible Yoruba shape, dropped into something that isn't a sentence.</p>
<p>I have four candidate explanations, and I want to be honest that I haven't isolated which one, or which combination, is actually responsible:</p>
<p><strong>Sparse coverage.</strong> The base model almost certainly saw very little Yoruba and Igbo text during pretraining, which alone could produce exactly this kind of surface-plausible nonsense.</p>
<p><strong>Not enough fine-tuning data.</strong> 3,917 QA pairs across four languages isn't a lot, especially split across languages with very different scripts and morphology.</p>
<p><strong>Quantization damage.</strong> I quantized down to Q2_K, an aggressive compression level. It's plausible whatever capability the model had for these languages took a disproportionate hit.</p>
<p><strong>My own patch, possibly.</strong> The fix for Crash 2 changed which code path writes the vocabulary, from <code>LlamaHfVocab</code> to <code>BpeVocab</code>. Those two paths can handle byte-level encoding and word-boundary markers differently. I can't rule out that this difference is quietly scrambling morpheme boundaries specifically for low-frequency languages.</p>
<p>The test that would actually tell me which one it is: run the same prompts against the unquantized model, and separately against an unpatched conversion, and compare. I didn't run either comparison. The unpatched conversion crashed unconditionally (that's Crash 1), so there was never a clean unpatched baseline to compare against on-device, and I didn't benchmark the full-precision model before quantizing.</p>
<p>So I don't know yet. I'm saying so, instead of picking the most plausible-sounding explanation and presenting it as settled.</p>
<h2>What I actually built and can hand you</h2>
<p>The three crash fixes are concrete and verified, I confirmed each one by watching the corresponding failure disappear. I also built a small diagnostic tool: a round-trip checker that encodes prompts with the original tokenizer, compares against what the converted GGUF vocabulary actually contains at every token ID, and flags any mismatch. It won't tell you why output is wrong, but it will tell you fast whether your conversion silently corrupted something, before you find out the hard way in production.</p>
<p>Open source, MIT license: <a href="https://github.com/Afolabi-cyber/tinymed-tokenizer-fix">github.com/Afolabi-cyber/tinymed-tokenizer-fix</a></p>
<p>I've also filed the three crash bugs upstream against <code>llama.cpp</code>, since two of them turned out to have partial precedent in older, unresolved issue reports going back to 2024. If you're converting anything derived from an Unsloth checkpoint, you may hit the same three walls I did.</p>
<h2>Why I'm posting the unsolved part too</h2>
<p>It would be a cleaner story to only post the three fixes and call it a win. But the more important finding here might be the one I can't close: this class of failure, quiet, fluent-sounding, morphologically wrong output, doesn't show up in standard perplexity evaluation, because the language patterns it affects are exactly the ones underrepresented in most evaluation data for these languages to begin with. A metric that looks healthy can be hiding a real problem, and you won't know until you go looking specifically for it.</p>
<p>If you've hit something like this deploying quantized models for low-resource or morphologically-rich languages, I'd genuinely like to compare notes.</p>
]]></content:encoded></item><item><title><![CDATA[How to Run a Multilingual LLM Fully Offline on a $200 Android Phone]]></title><description><![CDATA[Most tutorials on deploying large language models assume you have a GPU, stable internet, and an API key. That assumption breaks down fast once you leave a…]]></description><link>https://run-llmoffline.hashnode.dev/how-to-run-a-multilingual-llm-fully-offline-on-a-200-android-phone</link><guid isPermaLink="true">https://run-llmoffline.hashnode.dev/how-to-run-a-multilingual-llm-fully-offline-on-a-200-android-phone</guid><dc:creator><![CDATA[Afolabi Faruq]]></dc:creator><pubDate>Sun, 19 Jul 2026 05:55:12 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a5c6008ea2e137bcf141dc9/fe256dba-53ab-4633-a2bd-bf0863c4d6c7.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most tutorials on deploying large language models assume you have a GPU, stable internet, and an API key. That assumption breaks down fast once you leave a small slice of the world.</p>
<p>I work on AI systems for African markets, and one problem keeps showing up: the people who need AI assistance the most, in local languages, in low-connectivity regions, are the least likely to have cloud access or high-end hardware. So I built a fully offline, multilingual medical assistant app that runs entirely on a mid-range Android phone, no internet connection required after install.</p>
<p>This tutorial walks through how I did it: quantizing a 3B parameter model, running it on-device with llama.cpp, and getting inference times low enough to actually be usable. I'll also show the real numbers from deploying this on a TECNO CAMON 40 Pro, a phone that costs a fraction of a flagship device and is representative of what most people in low-resource regions actually own.</p>
<h2>Table of Contents</h2>
<ul>
<li><p>Why offline matters more than people assume</p>
</li>
<li><p>The hardware and model constraints</p>
</li>
<li><p>Step 1: Picking a base model</p>
</li>
<li><p>Step 2: Quantizing with llama.cpp</p>
</li>
<li><p>Step 3: Fine-tuning for the target languages</p>
</li>
<li><p>Step 4: Packaging for Android</p>
</li>
<li><p>Real benchmarks from a $200 device</p>
</li>
<li><p>What I'd do differently</p>
</li>
<li><p>Wrapping up</p>
</li>
</ul>
<h2>Why Offline Matters More Than People Assume</h2>
<p>It's easy to design AI products around the assumption of a constant connection. In much of Sub-Saharan Africa, mobile data is expensive relative to income, network coverage is inconsistent outside urban centers, and even where connectivity exists, it can be unreliable during the exact moments someone needs help, like a rural health worker trying to get a quick answer during a consultation.</p>
<p>An offline model removes the dependency entirely. Once it's installed, it works in a forest, on a bus, during a network outage, anywhere. For applications like basic medical guidance, that reliability isn't a nice-to-have. It's the difference between the tool being useful and the tool being decorative.</p>
<p>The tradeoff is that you give up the size and power of cloud-hosted models. A model that runs offline on a phone has to be small enough to fit in memory and fast enough to respond in a reasonable time, on hardware with a fraction of the compute of a server GPU. That's the engineering problem this tutorial solves.</p>
<h2>The Hardware and Model Constraints</h2>
<p>Before picking a model, it helps to know what you're working with. A representative low-cost Android device has:</p>
<ul>
<li><p>4 to 8 GB of RAM, shared with the OS and other apps</p>
</li>
<li><p>No dedicated GPU worth relying on for inference</p>
</li>
<li><p>A CPU that will throttle under sustained load</p>
</li>
<li><p>Storage that's often limited, so a multi-gigabyte model isn't free</p>
</li>
</ul>
<p>Given those constraints, a full-precision 7B or larger model is out of the question. You need something small enough to quantize down to a few gigabytes, and an inference engine built for CPU execution rather than GPU.</p>
<h2>Step 1: Picking a Base Model</h2>
<p>I used Llama 3.2 3B as the base model. At 3 billion parameters, it's small enough to quantize into something that fits comfortably on a phone, but still large enough to hold enough general language capability to be fine-tuned effectively for a specific domain and set of languages.</p>
<p>The choice of base model matters more than most tutorials let on. A smaller model needs to already be reasonably strong before you quantize and fine-tune it, because both steps degrade quality somewhat, and you don't have room to spare. Llama 3.2 3B was, at the time, one of the better tradeoffs between size and capability for this kind of constrained deployment.</p>
<h2>Step 2: Quantizing With llama.cpp</h2>
<p>llama.cpp is the tool that makes on-device inference realistic. It's a C/C++ implementation built specifically to run LLaMA-family models efficiently on CPUs, including mobile CPUs, and it supports quantization formats that shrink a model's memory footprint significantly with a controlled loss in precision.</p>
<p>Here's the basic flow for converting and quantizing a fine-tuned model:</p>
<pre><code class="language-bash"># Convert the Hugging Face model to GGUF format
python convert_hf_to_gguf.py ./my-finetuned-model \
  --outfile model-f16.gguf \
  --outtype f16

# Quantize down to a smaller format (Q4_K_M is a good balance
# of size vs quality for this kind of deployment)
./llama-quantize model-f16.gguf model-q4_k_m.gguf Q4_K_M
</code></pre>
<p>Q4_K_M quantization cuts the model down to roughly a quarter of its original size, with a quality tradeoff that's noticeable in benchmarks but often not in practical use, especially for a narrow domain task like answering medical questions in a specific set of languages.</p>
<p>The quantization level is the main lever you have for balancing speed, size, and quality. If your target device has more RAM to spare, a higher-precision quantization like Q5 or Q6 will preserve more quality at the cost of size and speed. If you're targeting the lowest end of the hardware spectrum, Q4 or even Q3 quantization may be necessary.</p>
<h2>Step 3: Fine-Tuning for the Target Languages</h2>
<p>A generic base model, even a good one, won't reliably handle Yoruba, Hausa, Igbo, or Nigerian Pidgin out of the box. These languages are underrepresented in most pretraining datasets, so getting usable output means fine-tuning on data that actually covers them.</p>
<p>I used a two-stage fine-tuning approach:</p>
<ol>
<li><p><strong>Supervised fine-tuning (SFT)</strong> on curated question-answer pairs in the target languages, covering common medical queries and appropriate responses.</p>
</li>
<li><p><strong>Direct Preference Optimization (DPO)</strong> on top of the SFT model, using preference pairs to push the model toward safer, more helpful responses and away from ones that were technically fluent but medically inappropriate or unclear.</p>
</li>
</ol>
<p>This two-stage approach mattered more than I expected going in. SFT alone gets you a model that produces grammatically correct output in the target language, but DPO is what pushes it toward output that's actually useful and appropriately cautious for a medical context.</p>
<p>For reference, the SFT stage converged to a loss of 1.099, and the DPO stage achieved a reward margin of 18.40, meaning the model reliably preferred the better response in each preference pair by a wide margin. If your DPO reward margin is hovering close to zero, that's usually a sign your preference data isn't clearly differentiated enough, or your SFT checkpoint wasn't strong enough going in.</p>
<h2>Step 4: Packaging for Android</h2>
<p>Once you have a quantized GGUF file, the last step is wrapping it in something an Android app can call. llama.cpp exposes a C API that you can bind into a Java or Kotlin app through JNI, or you can use one of the existing Android wrapper projects that expose llama.cpp as a library.</p>
<p>The core things to get right at this stage:</p>
<ul>
<li><p>Load the model once at app startup, not on every query, since loading a multi-gigabyte file repeatedly will kill your response times.</p>
</li>
<li><p>Keep the context window as small as your use case allows. Every extra token of context costs memory and inference time.</p>
</li>
<li><p>Run inference on a background thread. A 3B model on a phone CPU is not instant, and blocking the UI thread will make the app feel broken even when it's working correctly.</p>
</li>
<li><p>Bundle the model file with the app or make it a one-time download on first launch, since redownloading a multi-gigabyte file on every session defeats the purpose of an offline app.</p>
</li>
</ul>
<h2>Real Benchmarks From a $200 Device</h2>
<p>Here's what this setup actually achieves running on a TECNO CAMON 40 Pro, a mid-range device representative of what's widely available and affordable in the markets this app targets:</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>Base model</td>
<td>Llama 3.2 3B</td>
</tr>
<tr>
<td>Quantization</td>
<td>Q4_K_M</td>
</tr>
<tr>
<td>Inference latency</td>
<td>~62 seconds per response</td>
</tr>
<tr>
<td>SFT loss</td>
<td>1.099</td>
</tr>
<tr>
<td>DPO reward margin</td>
<td>18.40</td>
</tr>
<tr>
<td>Connectivity required</td>
<td>None, after initial install</td>
</tr>
<tr>
<td>Languages supported</td>
<td>Yoruba, Hausa, Igbo, Nigerian Pidgin</td>
</tr>
</tbody></table>
<p>62 seconds is not fast. It's not going to compete with a cloud-hosted API response. But for the use case, a fully offline assistant that works with zero connectivity, in a local language, on hardware people actually own, that latency is an acceptable tradeoff, not a failure. The alternative for a lot of users isn't a faster online model. It's no model at all.</p>
<h2>What I'd Do Differently</h2>
<p>A few things I'd change if I were starting this project today:</p>
<ul>
<li><p><strong>Test quantization levels more systematically before committing.</strong> I settled on Q4_K_M partly on convention. A structured sweep across quantization levels, measuring both latency and task-specific accuracy, would have given a more defensible choice.</p>
</li>
<li><p><strong>Build the preference dataset earlier in the process.</strong> DPO made a bigger difference than I expected, and I'd allocate more time to curating high-quality preference pairs from the start rather than treating it as a later refinement step.</p>
</li>
<li><p><strong>Profile memory usage across a wider range of devices,</strong> not just the target device. What runs fine on 6GB of RAM can behave very differently on a 4GB device that's also running background apps.</p>
</li>
</ul>
<h2>Wrapping Up</h2>
<p>Deploying an LLM offline on a low-end phone means working backwards from your hardware constraints instead of starting with the biggest model you can find. Pick a base model small enough to quantize without falling apart, use llama.cpp to get it running efficiently on CPU, fine-tune it deliberately for your target languages and domain, and measure your results on the actual device your users will have, not a developer laptop.</p>
<p>The result won't be as fast as a cloud API. But it will work where cloud APIs can't reach, and for a lot of the world, that's the</p>
]]></content:encoded></item></channel></rss>