<?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[Daniel's Blog]]></title><description><![CDATA[Daniel's Blog]]></description><link>https://danielsdevjourney.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 05 Sep 2026 11:12:47 GMT</lastBuildDate><atom:link href="https://danielsdevjourney.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Running AI Models Locally with Flutter: On-Device LLMs in Flutter Desktop Apps (Windows/MacOS/Linux)]]></title><description><![CDATA[In my previous article, I walked through fine-tuning a model and running it locally inside a Flutter Android app. I ended that article promising that the next one would be about running models locally]]></description><link>https://danielsdevjourney.hashnode.dev/running-ai-models-locally-with-flutter-on-device-llms-in-flutter-desktop-apps-windows-macos-linux</link><guid isPermaLink="true">https://danielsdevjourney.hashnode.dev/running-ai-models-locally-with-flutter-on-device-llms-in-flutter-desktop-apps-windows-macos-linux</guid><dc:creator><![CDATA[Daniel Oluremi]]></dc:creator><pubDate>Sat, 08 Aug 2026 00:02:27 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/693d3fd1db65a6f6378bd0b7/95747b77-17f2-4a1e-b8c8-1712df1fd95b.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In my <a href="https://danielsdevjourney.hashnode.dev/running-ai-models-locally-in-flutter-a-mobile-developer-s-guide-to-fine-tuning-and-on-device-ai-for-android">previous article</a>, I walked through fine-tuning a model and running it locally inside a Flutter <strong>Android</strong> app. I ended that article promising that the next one would be about running models locally in a Flutter <strong>desktop</strong> app. Well, here it is. Actually, it turned into more than one article, because desktop turned out to be a more of a hassle than mobile.</p>
<p>A quick reminder of my POV (in case you're new here): I'm a Flutter &amp; AI Engineer. I'm comfortable on both sides: I fine-tune, train and quantize my own models, and I wire them into real apps. I'll always walk you through my entire journey in achieving the goal - what worked and what didn't. The "didn't" part is where I learn the most, and I'm sure you will too.</p>
<p>If you want my full argument for <em>why</em> you'd want to run a model on-device (offline, private, free, independent), I explained that in my previous article. On desktop, the same reasons apply, except that you typically have more RAM, a real GPU, and no strict app-store size limits to fight with. Desktop is infact, the better place to run local AI models.</p>
<p>This article is an <strong>end-to-end, standalone</strong> one. By the end, you'll have gotten an LLM running on a Flutter Windows/MacOS/Linux app (on CPU <em>and/or</em> GPU), and you'll know what's left to do to distribute it to end users. I still have other articles coming in this series to show <em>alternative</em> and <em>better</em> engines and methods, but everything here works on its own.</p>
<p><em><strong>Note:</strong> I focus primarily on Windows in this article as it is the most popular laptop/desktop OS in the world right now, however, the process, as I explain it here (specifically from part 2), is the same for MacOS and Linux except for one thing which I shed more light on briefly later in the article.</em></p>
<hr />
<h2>Part 1: Turning a Flutter App Into a Windows App</h2>
<p>I'm assuming you already have a Flutter mobile app project or know how to create one.</p>
<h3>What you need first</h3>
<ul>
<li><p><strong>The Flutter SDK</strong> (of course, you already have this if you've built ever for mobile). <a href="https://docs.flutter.dev/get-started/install/windows/desktop">Install / upgrade instructions here</a>.</p>
</li>
<li><p><strong>Visual Studio</strong> (not VS Code. You definitely have this already too if you've built for mobile) with the <strong>"Desktop development with C++"</strong> workload installed. This is non-negotiable as Flutter compiles the Windows shell with MSVC and CMake. <a href="https://visualstudio.microsoft.com/downloads/">Download Visual Studio here</a> and tick that C++ workload during install or go back to edit the install and tick the workload in case you had Visual Studio without the workload.</p>
</li>
</ul>
<p>Run <code>flutter doctor</code> after and make sure the "Visual Studio" line has a green check.</p>
<h3>Next: Enabling Windows in your Flutter Installation</h3>
<p>This step is usually only needed once, and mostly unnecessary on modern Flutter (Flutter 3.x and above).</p>
<pre><code class="language-bash">flutter config --enable-windows-desktop
</code></pre>
<p>If you created the project before you thought about desktop, it won't have a <code>windows/</code> folder yet. Generate it without touching the rest of your app by running:</p>
<pre><code class="language-bash"># Run this from your project root. The "." is intentional.
flutter create --platforms=windows .
</code></pre>
<p>That creates the native Windows runner side-by-side your already existing <code>android/</code> and <code>ios/</code> folders. Your Dart code is not tampered with.</p>
<h3>To run it and/or build it</h3>
<pre><code class="language-bash"># Run in development
flutter run -d windows

# Build a release .exe 
flutter build windows --release
</code></pre>
<p>The release build can be found in:</p>
<pre><code class="language-plaintext">build\windows\x64\runner\Release\
</code></pre>
<p>Open that folder and you'll see <code>your_app.exe</code>, <code>flutter_windows.dll</code>, a <code>data\</code> folder, and a few other DLLs. **Note: That whole folder is your app** - the executable (the <code>.exe</code> file) depends on those other files and generally won't run correctly if you move it by itself. This becomes important when we talk about shipping (in a future article).</p>
<p>That's pretty much what it takes to create a Windows app in Flutter. Now to the more interesting parts about running an LLM in it.</p>
<hr />
<h2>Part 2: The Methods That Didn't Work</h2>
<p>On Android, as I explained in my previous article, I eventually found packages (<code>flutter_gemma</code> and <code>llama_flutter_android</code>) that wrapped all the native complexity I was having issues with. So of course, on Windows, I looked for packages too. I assumed it would be a solved problem.</p>
<p>Well, it wasn't -- at least, not at the time.</p>
<h3>Attempt 1: flutter_gemma on Windows</h3>
<p><code>flutter_gemma</code> is excellent on Android. On Windows, <strong>at the time I tried it,</strong> it just wouldn't get the job done. I followed the documentation to the letter, made sure the runtime requirements were in place, and still kept hitting errors around <strong>model activation and installation.</strong> The model simply would not initialize.</p>
<h3>Attempt 2: llama_cpp_dart</h3>
<p>Next I tried <code>llama_cpp_dart</code>, which binds to <code>llama.cpp</code> directly. This one got further, but it kept throwing errors about <strong>missing DLLs</strong>. I resolved one missing dependency after the other on end. It became like a game of whack-a-mole with native libraries. Long story short, it didn't work.</p>
<h3>The conclusion I reached</h3>
<p>After going through every resource I could find, my conclusion at the time was that the available Flutter packages did not yet have <strong>full, reliable Desktop support</strong> for running LLMs locally. (TBF, they may have improved since then or maybe I didn't research well enough - actually I think I did - but I had a fast approaching deadline so I had to move quickly.)</p>
<p>To be clear, there <em>is</em> a "proper" way to do this: write your own native C++ bridge and call into it with Dart FFI. But on Android I'd already learned the hard way that FFI is a deep, sharp pit if you're new to it. I wasn't ready to jump into it on Desktop just yet. (Actually, I did eventually, and it's the subject of my next article, so if FFI is what you're here for, just wait for it.)</p>
<p>So I paused and thought about it differently.</p>
<hr />
<h2>Part 3: Thinking Outside the Box - Bundling the llama.cpp Server</h2>
<p><strong>Hmm, what if I don't embed the model in my app at all?</strong></p>
<p><code>llama.cpp</code> isn't only available as a library, it also provides a ready made program called <code>llama-server.exe</code>. That's a small, fast HTTP server that loads a model and exposes an <strong>OpenAI-compatible API</strong> (<code>/v1/chat/completions</code> and other related ones). It's the same server several people use to run models locally.</p>
<p>So instead of struggling with FFI and DLL perpetually, I figured I could:</p>
<ol>
<li><p>Bundle <code>llama-server.exe</code> (and my model file) alongside my Flutter app.</p>
</li>
<li><p>Launch it as a <strong>subprocess</strong> ("sidecar") when the app starts.</p>
</li>
<li><p>Communicate with it over <code>http://127.0.0.1:&lt;port&gt;</code> like any other normal API.</p>
</li>
</ol>
<p>From my app's perspective, the LLM became just another endpoint, except that this one runs on the user's own machine, fully offline.</p>
<p>Smart innit? 😌</p>
<h3>Step 1: Download the server binary file</h3>
<p>Download the prebuilt (ready made) Windows/MacOS/Linux release from the official <a href="https://github.com/ggml-org/llama.cpp/releases">llama.cpp releases page</a>. The files have a clear naming pattern:</p>
<pre><code class="language-plaintext">llama-b&lt;build&gt;-bin-win-cpu-x64.zip          ← CPU build (start here)
llama-b&lt;build&gt;-bin-win-sycl-x64.zip         ← Intel GPU build (we'll discuss this in Part 5)
llama-b&lt;build&gt;-bin-win-cuda-12.4-x64.zip    ← NVIDIA build
llama-b&lt;build&gt;-bin-win-vulkan-x64.zip       ← cross-vendor GPU build
llama-b&lt;build&gt;-bin-macos-arm64.tar.gz       ← Apple Silicon Macs (Metal GPU on by default)
llama-b&lt;build&gt;-bin-macos-x64.tar.gz         ← Intel Macs (CPU build)
llama-b&lt;build&gt;-bin-ubuntu-x64.tar.gz        ← Linux CPU build
llama-b&lt;build&gt;-bin-ubuntu-vulkan-x64.tar.gz ← Linux cross-vendor GPU build
</code></pre>
<p>Choose the <strong>CPU</strong> zip first to get things working. Inside, you'll find <code>llama-server.exe</code> and its DLLs (its supporting runtime libraries).</p>
<h3>Step 2: Get a model (GGUF format)</h3>
<p><code>llama.cpp</code> only works with the <strong>GGUF</strong> format. You can use a model you fine-tuned and exported to GGUF (I showed how to do that in my previous article), or just find a public one. For these examples I'll use a small instruct model: <a href="https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF">Qwen2.5-1.5B-Instruct-GGUF</a>. Download the <code>q4_k_m</code> (4-bit quantized version), it's a good size/quality balance for desktop.</p>
<h3>Step 3: The sidecar service</h3>
<p>"Sidecar" is really just a fancy word for any helper process that runs side-by-side a main application. The helper process in this case is the llama.cpp server. Here's what the service that manages the server process and streams responses looks like. A real one should obviously have more retry logic, but this is the shape of it:</p>
<pre><code class="language-dart">import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;

class LlamaServerService {
  static const String _host = '127.0.0.1';
  int _port = 8088;
  Process? _process;
  bool _useGpu = true;
  bool isRunning = false;

  Future&lt;void&gt; start({
    required String serverBinaryPath, // path to llama-server.exe
    required String modelPath,        // path to your .gguf
  }) async {
    _port = await _findAvailablePort(_port);

    // OpenAI-compatible server arguments
    final args = &lt;String&gt;[
      '-m', modelPath,
      '--host', _host,
      '--port', '$_port',
      '-c', '4096',   // context window
      '-n', '-1',     // no hard cap on generated tokens
    ];

    if (_useGpu) {
      // Offload all model layers to the GPU (see Part 5)
      args.addAll(['-ngl', '99']);
    } else {
      // CPU: use about half the cores, clamped to a sane range
      final threads = (Platform.numberOfProcessors ~/ 2).clamp(2, 8);
      args.addAll(['-t', '$threads']);
    }

    _process = await Process.start(serverBinaryPath, args);

    // Pipe the server's logs to your debug console — invaluable for debugging
    _process!.stdout.transform(utf8.decoder).listen(debugPrint);
    _process!.stderr.transform(utf8.decoder).listen(debugPrint);

    await _waitForReady();
    isRunning = true;
  }

  Future&lt;void&gt; stop() async {
    _process?.kill(ProcessSignal.sigterm);
    await Future.delayed(const Duration(milliseconds: 500));
    _process?.kill(ProcessSignal.sigkill);
    _process = null;
    isRunning = false;
  }
}
</code></pre>
<p>The server takes a few seconds to load the model, so we check its health endpoint to know whether it is ready (and we watch for the process dying early, which usually means a bad model - or on the GPU build, maybe a driver problem):</p>
<pre><code class="language-dart">Future&lt;void&gt; _waitForReady({
  Duration timeout = const Duration(seconds: 60),
}) async {
  final stopwatch = Stopwatch()..start();

  // Different server versions expose different "I'm alive" endpoints
  const endpoints = ['/health', '/', '/v1/models'];

  while (stopwatch.elapsed &lt; timeout) {
    for (final endpoint in endpoints) {
      try {
        final res = await http
            .get(Uri.parse('http://$_host:$_port$endpoint'))
            .timeout(const Duration(seconds: 3));
        if (res.statusCode == 200) return; // ready!
      } catch (_) {
        // not up yet, keep polling
      }
    }
    await Future.delayed(const Duration(seconds: 1));
  }
  throw TimeoutException('Server did not become ready in time.');
}
</code></pre>
<p>For text generation, it's just streaming a Server Side Event (OpenAI style) response:</p>
<pre><code class="language-dart">Stream&lt;String&gt; chat(List&lt;Map&lt;String, String&gt;&gt; messages) async* {
  final uri = Uri.parse('http://$_host:$_port/v1/chat/completions');

  final request = http.Request('POST', uri)
    ..headers['Content-Type'] = 'application/json'
    ..body = jsonEncode({
      'messages': messages,          // [{role: 'user', content: '...'}]
      'stream': true,
      'cache_prompt': true,          // reuse KV cache across turns — big speedup
    });

  final response = await http.Client().send(request);

  await for (final chunk in response.stream.transform(utf8.decoder)) {
    for (final line in chunk.split('\n')) {
      if (!line.startsWith('data: ')) continue;
      final payload = line.substring(6).trim();
      if (payload.isEmpty || payload == '[DONE]') continue;

      final json = jsonDecode(payload) as Map&lt;String, dynamic&gt;;
      final delta = (json['choices'] as List).first['delta'];
      final content = delta?['content'] as String?;
      if (content != null &amp;&amp; content.isNotEmpty) yield content;
    }
  }
}
</code></pre>
<p>A small helper to prevent port collisions (in case you already have something on 8088):</p>
<pre><code class="language-dart">Future&lt;int&gt; _findAvailablePort(int start) async {
  for (var port = start; port &lt; start + 10; port++) {
    try {
      final socket = await ServerSocket.bind(_host, port);
      await socket.close();
      return port;
    } catch (_) {/* busy, try next */}
  }
  return start;
}
</code></pre>
<p>Then wire <code>chat()</code> into your Cubit/Bloc/Provider/Riverpod just like the streaming pattern from my previous article, and there you've got an offline AI chatbot.</p>
<h3>Why this method is actually good (not just a workaround)</h3>
<p>To be clear, I didn't eventualy settle for this method on Windows, but it's a strong approach regardless, and here's why:</p>
<ul>
<li><p><strong>Simplicity.</strong> No FFI, no ABI issues, no manually chasing missing DLLs. <code>Process.start</code> and <code>http</code> are typical, everyday Dart stuff.</p>
</li>
<li><p><strong>Reliability &amp; isolation.</strong> The model runs in a <em>separate process</em>. If inference crashes (which can happen), it doesn't take the Flutter UI down with it. You can detect the dead process and restart it.</p>
</li>
<li><p><strong>You use optimized binaries.</strong> The <code>llama.cpp</code> team are <em>defolutely</em> better at squeezing performance out of hardware than I am. Every llama release I download is faster than the last, for free.</p>
</li>
<li><p><strong>A standard API.</strong> Because it's OpenAI-compatible, If you're familiar with OpenAI style APIs, it feels very natural. You can even point it at the real OpenAI API as a fallback by changing just one URL.</p>
</li>
<li><p><strong>Easy CPU/GPU switching.</strong> If you want to use GPU on Windows, just swap the binary and add one flag. That is the subject of the next section.</p>
</li>
<li><p><strong>Cross-platform for free.</strong> This same approach works on macOS and Linux with zero code changes. <code>Process.start</code> and <code>http</code> don't really care what OS they're running on. Just put in the right <a href="https://github.com/ggml-org/llama.cpp/releases/latest"><code>llama.cpp</code> build</a> (<code>bin-macos-arm64</code> for Apple Silicon Macs, <code>bin-macos-x64</code> for Intel Macs, <code>bin-ubuntu-x64</code> for Linux) and everything just works. Even better: on Apple Silicon (Mac M1 and above), that binary has Metal GPU acceleration enabled by default, so Macs get the GPU path without needing any server swap at all.</p>
</li>
</ul>
<p>The only main trade-off I'd say exists is that you're bundling and launching a separate executable, and then of course there's an HTTP roundtrip. In practice on localhost though, that overhead is pretty much negligible compared to the actual inference time.</p>
<hr />
<h2>Part 4: What Is GPU Inference, and Why Bother?</h2>
<p>So far, everything I've explained runs on the <strong>CPU</strong>. And that's fine, but for anything bigger than a tiny model, performance may begin to decline (inference or token generation would be slower).</p>
<p>Generating text with an LLM is basically a huge pile of matrix multiplications. A CPU has a few powerful cores that do things one after another. A <strong>GPU</strong> on the other hand has <em>thousands</em> of small cores built primarily to do a lot of math <strong>in parallel (at the same time).</strong> So for AI inference (process of generating a response to your prompt), a GPU is many times faster than a CPU.</p>
<p>Also, another advantage of using GPU is, while the GPU does the heavy inference lifting, the CPU is free to keep your UI smooth and handle everything else the app is doing.</p>
<p>Unfortunately, not all computers have a GPU. So, in production, I run GPU <strong>with an automatic CPU fallback.</strong> It gives me the best of both worlds. How we set that up is explained below.</p>
<hr />
<h2>Part 5: GPU Inference with the SYCL (Intel) Binaries</h2>
<p>If the hardware you're building for is <strong>Intel</strong>-based (Intel integrated graphics and Arc GPUs), the relevant <code>llama.cpp</code> build is the <strong>SYCL</strong> one. SYCL is the open standard Intel uses to run compute on its GPUs (via oneAPI). The beauty of the sidecar approach is that switching to GPU is basically a matter of <strong>swapping the binary</strong>.</p>
<h3>Step 1: Download the SYCL build</h3>
<p>From the same <a href="https://github.com/ggml-org/llama.cpp/releases">llama.cpp releases page</a>, download:</p>
<pre><code class="language-plaintext">llama-b&lt;build&gt;-bin-win-sycl-x64.zip
</code></pre>
<p>This contains a <code>llama-server.exe</code> built with the SYCL backend.</p>
<h3>Step 2: Tell the server to use the GPU</h3>
<p>You must have seen it in the service code - the not-gonna-lie flag <code>-ngl</code> (just kidding 😅, it's actually 'number of GPU layers'):</p>
<pre><code class="language-dart">args.addAll(['-ngl', '99']); // 99 = "offload all layers to the GPU"
</code></pre>
<p><code>99</code> is really just a comfortably large number that means "put the whole model on the GPU (because many models have fewer than 99 transformer layers)." If the model has fewer layers, it offloads all of them; if it had more, well that's unlikely but it means 99 would be on the GPU and the rest will be on the CPU. Really, what this does is to split the model's transformer layers (its literal building blocks) between the GPU and CPU.</p>
<h3>Step 3: The runtime dependency that makes it work</h3>
<p><strong>The SYCL build will not run on a Windows computer just because you copied the</strong> <code>.exe</code><strong>.</strong> It needs Intel's GPU runtime present on the machine. Be sure to check for these two things:</p>
<ol>
<li><p><strong>Up-to-date Intel GPU drivers.</strong> Modern Intel graphics drivers include the <strong>Level Zero</strong> runtime that SYCL talks to. On most computers, this is already there, but you never know. You can get the latest drivers from the <a href="https://www.intel.com/content/www/us/en/download-center/home.html">Intel Download Center</a>.</p>
</li>
<li><p><strong>The oneAPI / DPC++ runtime libraries.</strong> Depending on the particular build, the SYCL <code>llama-server.exe</code> may also need a few Intel oneAPI runtime DLLs (the SYCL/DPC++ redistributables) sitting next to it. The best way to handle this in production may be to <strong>bundle those DLLs along with the binary</strong> so you don't have to bet on the user computer having the <a href="https://www.intel.com/content/www/us/en/developer/tools/oneapi/base-toolkit-download.html">Intel oneAPI runtime</a> installed. (I'll talk more about bundling "seemingly unrelated dependencies" in a future article.)</p>
</li>
</ol>
<h3>Step 4: GPU first, CPU fallback</h3>
<p>Attempting to use GPU can fail for many reasons outside your control (old drivers, no Intel GPU like I mentioned earlier, a bad machine), so don't put all your eggs in that basket. Try to start in GPU mode, but in the event there's any server crash error or startup delay, restart it in CPU mode. Here's the logic for it:</p>
<pre><code class="language-dart">Future&lt;void&gt; startWithFallback({
  required String gpuBinary,
  required String cpuBinary,
  required String modelPath,
}) async {
  try {
    _useGpu = true;
    await start(serverBinaryPath: gpuBinary, modelPath: modelPath);
  } catch (e) {
    debugPrint('GPU startup failed ($e). Falling back to CPU...');
    _useGpu = false;
    await start(serverBinaryPath: cpuBinary, modelPath: modelPath);
  }
}
</code></pre>
<p>You can also scan the server's stderr for specific keywords like <code>sycl</code>, <code>level_zero</code>, <code>gpu not found</code>, etc., so you can log <em>why</em> it fell back, but the try/restart skeleton above is sufficient. The user will never see a failure; worst case scenario, it'll just take longer and then startup.</p>
<h3>For non-Intel hardware</h3>
<p>If you don't use an Intel computer or machine, the exact same pattern applies — you just download a different binary set of files and the GPU is activated with <code>-ngl</code>:</p>
<ul>
<li><p><strong>NVIDIA:</strong> download <code>llama-b&lt;build&gt;-bin-win-cuda-12.4-x64.zip</code> (CUDA). Needs an NVIDIA driver / CUDA runtime.</p>
</li>
<li><p><strong>AMD:</strong> <code>llama-b&lt;build&gt;-bin-win-hip-radeon-x64.zip</code> (ROCm/HIP).</p>
</li>
<li><p><strong>Anything (cross-vendor):</strong> <code>llama-b&lt;build&gt;-bin-win-vulkan-x64.zip</code> (Vulkan). Probably the best option if you don't know your users' hardware.</p>
</li>
</ul>
<p>All from the <a href="https://github.com/ggml-org/llama.cpp/releases">same releases page</a>. Nothing changes in the code, just the binary files you bundle and you're done.</p>
<hr />
<h2>Part 6: The Single-File Problem (and What's Next)</h2>
<p>Now, step back and just <em>negodu</em> what you have to hand to a client:</p>
<ul>
<li><p><code>your_app.exe</code> and its Flutter DLLs and <code>data\</code> folder</p>
</li>
<li><p><code>llama-server.exe</code> (GPU build) plus its Intel runtime DLLs</p>
</li>
<li><p><code>llama-server.exe</code> (CPU build) for fallback</p>
</li>
<li><p>one or more large GGUF model files</p>
</li>
</ul>
<p>A folder full of files, some of them large, with hidden dependencies (the VC++ runtime, the Intel runtime). You cannot reasonably tell a client "unzip this and find the actual app executable file to open." You need to give them <strong>a single file</strong> that installs everything correctly, pulls in the unrelated dependencies, and even cleans up itself when uninstalled. There are native solutions for this on MacOS and Linux, but on Windows, it requires a third party.</p>
<p>That's going to be the subject of the next article in this series: Bundling a Flutter Windows App and Everything It Needs Into a Single Installer.</p>
<h2>And, that's it.</h2>
<p>I'd recommend this route if you want the <strong>shortest, most reliable path</strong> to a having local LLM on a Flutter Desktop app, as it avoids native code entirely and leans on compiled, ready to run packages/binaries.</p>
<p>But it's not the <em>only</em> way, and it's arguably not the neatest. In a future article, I'll write about how I finally did the thing I avoided here: writing a native C++ FFI bridge, using Intel's <strong>OpenVINO GenAI</strong>, which runs the model <em>in-process</em> and gave even better results on Intel hardware. After that, we will make the app <strong>see and hear</strong> with vision and speech recognition models all offline.</p>
<p><em>Got questions, or want to go deeper on any part?</em> <a href="https://danieloluremi.pages.dev"><em>Visit my website for my contact details</em></a><em>. Watch out for the rest of this series.</em></p>
]]></content:encoded></item><item><title><![CDATA[Running LLMs Locally on Android: A Mobile (Flutter) Developer's Guide to Fine-Tuning and On-Device AI]]></title><description><![CDATA[A few months ago, I posted on LinkedIn about the work I've been doing, building mobile applications powered by locally deployed, fine-tuned LLMs and I promised a technical breakdown, so here it goes.
]]></description><link>https://danielsdevjourney.hashnode.dev/running-llms-locally-on-android-a-mobile-flutter-developer-s-guide-to-fine-tuning-and-on-device-ai</link><guid isPermaLink="true">https://danielsdevjourney.hashnode.dev/running-llms-locally-on-android-a-mobile-flutter-developer-s-guide-to-fine-tuning-and-on-device-ai</guid><dc:creator><![CDATA[Daniel Oluremi]]></dc:creator><pubDate>Fri, 29 May 2026 19:45:35 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/693d3fd1db65a6f6378bd0b7/fdd5d71d-a3e8-40f5-bf93-aa76157c251f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A few months ago, I posted on LinkedIn about the work I've been doing, building mobile applications powered by locally deployed, fine-tuned LLMs and I promised a technical breakdown, so here it goes.</p>
<p>Before we begin, you should know my POV: Although I have some experience on Machine Learning, I am primarily a Flutter Mobile/Desktop app developer. Infact, when I started working on this, I did not know the difference between a base model and an instruct model; terms like "QLoRA" and "quantization" meant nothing to me.</p>
<p>What I can tell you is that I figured it out anyway (learning a lot on the way), and if you're a mobile developer who wants to run AI locally inside your app without needing a backend or an internet connection, this article is for you. By the end, you'll understand how to fine-tune a model if you need to, and more importantly, how to actually get one running inside a Flutter Android app. In a future article, I'll write about running a model locally in a Flutter desktop app.</p>
<p>One more thing: <strong>you don't need to fine-tune a model to follow along with the integration section in this article.</strong> Pre-trained models are available for free in formats that work directly with the integration approach I'll show you. Fine-tuning is an option, not a prerequisite. So if you're here only for the Flutter integration, feel free to skip Part 1 entirely and jump directly to Part 2 (the integration section).</p>
<hr />
<h2>Why Offline-First AI?</h2>
<p>Let me make a case for why you'd want to run an LLM locally on a device in the first place.</p>
<p>The most obvious answer is connectivity. I don't know about the rest of the world, but where I come from, mobile phone users live with unreliable, slow, and expensive internet. If your AI-powered feature requires a connection to a cloud API, you've excluded all those people by default for the most part. But guess what? connectivity is actually the least interesting reason.</p>
<p>Here are some reasons I think matter more:</p>
<p><strong>Privacy.</strong> Every message a user sends to a cloud API leaves the device. With a local model, the data never leaves. For applications that deal with sensitive information like health, education, finance, legal stuff, this is particularly important. It is a trust-building architectural choice.</p>
<p><strong>Cost.</strong> Cloud AI APIs are not free. The cost increases as you scale. A local model however incurs zero cost. You don't even have to pay for the compute to produce or fine-tune a decent-sized model (that's if you don't want a pre-trained model). Considering the recent news about a company's $500M AI bill, I think you'd find this reason very compelling 😂</p>
<p><strong>Ownership.</strong> Your app will work on a plane, in a village school, underground, anywhere.</p>
<p><strong>Independence.</strong> You're not at the mercy of OpenAI's pricing changes, Claude's rate limits, or changes in policies. The model runs on your terms.</p>
<p>I believe offline-first AI is the future for apps. The models are getting smaller and better at an impressive rate, and it can only get better.</p>
<hr />
<h2>Part 1: Fine-Tuning a Model with QLoRA and Unsloth</h2>
<p>Again, you can skip this section if you have a pre-trained model and you just want integration. But if you want to customise a model's behaviour, teach it a specific domain, or adapt it to a particular style or language, fine-tuning is how you do it.</p>
<h3>What Is Fine-Tuning Exactly?</h3>
<p>A large language model in its base form has been trained on pretty much all the text on the internet. It knows a lot of general things. What it doesn't know is <em>your specific context</em> i.e. the domain-specific WAY you want it to respond, the format you want its answers in, or knowledge about specialised areas that may have been very little in its training data.</p>
<p>If you want a formal definition, Fine-tuning is the process of taking a pre-trained model and continuing to train it on a smaller, more focused dataset that's specific to your needs. You're not training from scratch since that would require a very large computing power, you're just nudging the model in a specific direction.</p>
<p>The catch is that fine-tuning a model requires quite a bit of memory. A 7B (7 billion) parameter model in full precision (no quantization) can't even fit on a standard GPU, let alone be trained on one. This is where <strong>QLoRA</strong> comes in.</p>
<h3>QLoRA: The Magical Shortcut</h3>
<p>QLoRA (Quantised Low-Rank Adaptation) is the reason fine-tuning became accessible to people like me and probably you. Here's a brief overview of what it does:</p>
<ul>
<li><p>The model weights are <strong>quantised</strong> i.e. compressed to 4-bit precision. This drastically reduces memory usage.</p>
</li>
<li><p>Instead of updating all the model's weights during training, <strong>LoRA</strong> adds small trainable "adapter" matrices alongside the frozen original weights, so you're training a tiny fraction of the total parameters.</p>
</li>
<li><p>This allows us fine-tune any model up to about 7B parameters on a FREE Google Colab T4 GPU.</p>
</li>
</ul>
<p>That's amazing isn't it? Something that would have required expensive hardware a few years ago can now be done in a browser tab for free.</p>
<h3>The Tool: Unsloth</h3>
<p><a href="https://github.com/unslothai/unsloth">Unsloth</a> is the library that makes QLoRA training fast and practical. It wraps Hugging Face's <code>transformers</code> and <code>trl</code> libraries with optimised kernels, makes the model setup nearly one-liners, and is actively maintained. I used it for everything in this section.</p>
<h3>Setting Up: Dataset Preparation</h3>
<p>The most challenging part of fine-tuning is not the training, it's getting your dataset right. This surprised me. I thought training was the hard part. It isn't. The data is.</p>
<p>I learned a few important things in this aspect:</p>
<p><strong>Your data determines your model.</strong> If your training examples are low quality, inconsistent, or too narrow, your model will mirror that. It's basically garbage in, garbage out. Sometimes, the garbage is even subtle. The model will happily train on it and produce confident-sounding but wrong and/or unnatural output.</p>
<p><strong>You need more data than you think.</strong> A few hundred examples will make the model learn the format you want, but it won't meaningfully change its underlying knowledge or reasoning capability. For real domain adaptation, you need thousands of diverse examples.</p>
<p><strong>Format consistency matters.</strong> Every example in your dataset has to follow the same instruction-input-output structure. If it's inconsistent, the model will produce inconsistent outputs.</p>
<p><strong>Curriculum order matters too.</strong> When you're combining multiple datasets, the order you train on them matters. You should train on simpler, more general examples first before moving to harder or more specific ones.</p>
<p>This is how I structured a dataset combining multiple sources. Regardless of the domain you're working in, the pattern is pretty much the same:</p>
<pre><code class="language-python">import re

CANONICAL_INSTRUCTION = "Solve the following mathematics problem."

def clean_text(text):
    text = re.sub(r'\s+', ' ', text).strip()
    return text

def format_canonical(question, solution):
    return {
        "instruction": CANONICAL_INSTRUCTION,
        "input": clean_text(question),
        "output": clean_text(solution)
    }
</code></pre>
<p>The idea here is a <strong>canonical (or standard) format</strong>: every example has an instruction, an input, and an output. This consistency is what the model will learn to expect and will replicate when processing an actual query (at inference time).</p>
<p>For dataset sources, personally, I pulled from:</p>
<ul>
<li><p><strong>Public HuggingFace datasets</strong> - there are thousands of datasets there, covering most domains. For anything general purpose, there's almost surely a dataset someone already prepared.</p>
</li>
<li><p><strong>Custom documents</strong> (PDFs and Word files) - for domain-specific content that isn't in any public dataset. I extracted text from these using <code>pymupdf</code> and <code>python-docx</code>.</p>
</li>
</ul>
<p>Here's the PDF/document extraction approach:</p>
<pre><code class="language-python">import fitz
from docx import Document

def extract_text_from_pdf(pdf_path):
    doc = fitz.open(pdf_path)
    text_blocks = []
    for page in doc:
        text = page.get_text()
        if text.strip():
            text_blocks.append(text)
    doc.close()
    return text_blocks

def extract_text_from_docx(docx_path):
    doc = Document(docx_path)
    text_blocks = []
    for para in doc.paragraphs:
        text = para.text.strip()
        if not text:
            continue
        if para.style.name.startswith('Heading'):
            text_blocks.append(f"\n## {text}\n")
        else:
            text_blocks.append(text)
    return text_blocks
</code></pre>
<p>After extracting, I filtered through it for quality, skipping blocks that were too short, had a low volume of actual letters (which indicated garbled formula extraction since I was working in the mathematics domain), or had long strings of special characters:</p>
<pre><code class="language-python">def filter_quality(text):
    if len(text) &lt; 100:
        return False
    alpha_ratio = sum(1 for c in text if c.isalpha()) / max(len(text), 1)
    if alpha_ratio &lt; 0.35:
        return False
    if re.search(r'[^a-zA-Z0-9\s]{10,}', text):
        return False
    return True
</code></pre>
<p>For ordering the curriculum, I combined datasets in a special sequence - starting from general reasoning examples, moving to more structured problem-solving, and ending with domain-specific material as the final stage:</p>
<pre><code class="language-python">curriculum_data = []

# Stage 1: General reasoning (30k examples)
curriculum_data.extend(general_samples)

# Stage 2: More structured QA (50k examples)
curriculum_data.extend(structured_samples)

# Stage 3: Domain-specific content (~25% of total, upsampled)
# Added last so the model adapts to domain style without losing general capability
curriculum_data.extend(domain_samples)

# DO NOT SHUFFLE — preserve curriculum order
</code></pre>
<p>One important note: I did not shuffle the combined dataset. The curriculum order was clearly intentional. Shuffling would have mixed the stages and ruined the progressive learning structure.</p>
<h3>Loading the Model and Applying LoRA</h3>
<p>Once you have your dataset ready, the actual training setup with Unsloth is quite direct:</p>
<pre><code class="language-python">import torch
from unsloth import FastLanguageModel

MODEL_NAME = "unsloth/Qwen2.5-1.5B-Instruct-bnb-4bit"

model, tokenizer = FastLanguageModel.from_pretrained(
    model_name=MODEL_NAME,
    max_seq_length=1024,
    load_in_4bit=True,
    dtype=None,
)

model = FastLanguageModel.get_peft_model(
    model,
    r=16,
    lora_alpha=16,
    lora_dropout=0.05,
    bias="none",
    target_modules=[
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj",
    ],
    use_gradient_checkpointing="unsloth",
)
</code></pre>
<p>The <code>r</code> parameter (LoRA rank) controls how many trainable parameters you're adding. A higher rank gives it more capacity to adapt, but also increases the risk of overfitting on the dataset. Setting <code>r=16</code> is considered a generally reasonable starting point for most tasks. I used <code>r=32</code> for a more complex domain that needed deeper reasoning, but for a language or style adaptation task, <code>r=16</code> is likely enough.</p>
<h3>Formatting Examples for Training</h3>
<p>The tokenizer takes text, not a dictionary. So I converted each dataset example into a single formatted string:</p>
<pre><code class="language-python">def format_example(example):
    prompt = (
        "### Instruction:\n"
        f"{example['instruction']}\n\n"
        "### Response:\n"
        f"{example['output']}"
        f"{tokenizer.eos_token}"
    )
    return {"text": prompt}

dataset = dataset.map(format_example, remove_columns=dataset.column_names)
</code></pre>
<p>The <code>eos_token</code> at the end is very important, as it teaches the model when to stop generating. Without it, the model will likely keep generating past the end of a reasonable response.</p>
<p>For examples with both an instruction and a separate input:</p>
<pre><code class="language-python">def format_example_with_input(example):
    if example["input"]:
        prompt = (
            f"### Problem:\n{example['input']}\n\n"
            f"### Instruction:\n{example['instruction']}\n\n"
            f"### Solution:\n{example['output']}"
            f"{tokenizer.eos_token}"
        )
    else:
        prompt = (
            f"### Instruction:\n{example['instruction']}\n\n"
            f"### Response:\n{example['output']}"
            f"{tokenizer.eos_token}"
        )
    return {"text": prompt}
</code></pre>
<h3>Training</h3>
<pre><code class="language-python">from transformers import TrainingArguments
from trl import SFTTrainer

training_args = TrainingArguments(
    per_device_train_batch_size=2,
    gradient_accumulation_steps=8,   # Effective batch size = 16
    warmup_steps=50,
    max_steps=1000,
    learning_rate=2e-4,
    fp16=not torch.cuda.is_bf16_supported(),
    bf16=torch.cuda.is_bf16_supported(),
    logging_steps=10,
    save_steps=100,
    output_dir="outputs",
    optim="adamw_8bit",
    weight_decay=0.01,
    lr_scheduler_type="cosine",
    seed=42,
)

trainer = SFTTrainer(
    model=model,
    tokenizer=tokenizer,
    train_dataset=dataset,
    dataset_text_field="text",
    max_seq_length=1024,
    args=training_args,
)

trainer.train()
</code></pre>
<p>Let me explain the hyperparameters:</p>
<ul>
<li><p><strong>Gradient accumulation</strong> this simulates a larger batch size without needing more GPU memory. With <code>per_device_train_batch_size=2</code> and <code>gradient_accumulation_steps=8</code>, you're basically training with a batch size of 16.</p>
</li>
<li><p><strong>Cosine learning rate schedule</strong> gradually reduces the learning rate, which generally produces a smoother convergence than keeping it constant.</p>
</li>
<li><p><code>max_steps</code> <strong>instead of epochs</strong>: for large datasets, it's generally more practical to set a step limit than train for full epochs.</p>
</li>
</ul>
<p>After training, we save the model:</p>
<pre><code class="language-python">model.save_pretrained("final_model")
tokenizer.save_pretrained("final_model")
</code></pre>
<h3>Exporting to GGUF</h3>
<p>This step connects the fine-tuning to integration. There are several formats you can save your model in. GGUF is the file format used by <code>llama.cpp</code> and any library built on top of it. It supports quantization at export time, so the full-precision fine-tuned model can be compressed to a fraction of the size without losing much quality, and size is a really big deal when running a model on device.</p>
<p>Unsloth helps like so:</p>
<pre><code class="language-python">model.save_pretrained_gguf(
    "gguf_output",
    tokenizer,
    quantization_method="q4_k_m"
)
</code></pre>
<p><code>q4_k_m</code> is the quantization type I recommend for mobile. It compresses weights to 4-bit using k-means clustering, with slightly higher precision retained for the most important layers. It produces a model that is about 4 to 6 times smaller than the original float-16 version, with performance that is typically within a few percent of full precision on most tasks.</p>
<p>Once you have the <code>.gguf</code> file, you are ready. And if you don't want to fine-tune at all, many models are freely available in GGUF format on HuggingFace, just search for the model you want (append "GGUF" in your search to get tailored results).</p>
<h3>Before we move on from finetuning....</h3>
<p>Before we move on from finetuning, let me talk a bit about some limitations to finetuning.</p>
<p><strong>I previously explained that fine-tuning gives you:</strong></p>
<ul>
<li>A model that reliably follows a specific output format, a particular tone, language style or persona. It also gives you improved performance on a specific domain that probably wasn't well represented in the base model's training (assuming good data).</li>
</ul>
<p><strong>But here are some limitations to finetuning and what finetuning can cost you if not done well:</strong></p>
<ul>
<li><p><strong>Catastrophic forgetting.</strong> If your training data is too narrow, the model can lose general capability. Some of the models I initially fine-tuned learned my specific domain format so aggressively that they started responding poorly to basic English questions. This is a real risk with small models and small datasets.</p>
</li>
<li><p><strong>Bad data = bad finetune.</strong> A bad dataset will produce mediocre results and degrade a model that was previously good.</p>
</li>
<li><p><strong>Finetuning doesn't give you new facts.</strong> Fine-tuning teaches the model <em>how</em> to respond, not <em>what</em> to know. If what you want is for a model to know facts it wasn't trained on at all, RAG (Retrieval Augmented Generation) may give you better results than fine-tuning.</p>
</li>
<li><p><strong>Small models hit a ceiling.</strong> A 1B parameter model fine-tuned on domain data will still struggle with complex multi step reasoning as all 1B models do. Fine-tuning cannot quite change the model's capability. Higer parameter models always do better than smaller ones.</p>
</li>
</ul>
<p>From my experience, I found that a properly crafted system prompt (or RAG at most) with a good base model achieved similar results to fine-tuning with none of the downsides. Fine-tuning is mostly worth it when you have a very specific format requirement, a language or domain that was under-represented in the base model's training, or if you're optimising for size (a fine-tuned smaller model can sometimes match a larger base model on a specific task as general knowledge is essentially trimmed off in favour of the emphasised domain).</p>
<hr />
<h2>Part 2: Integrating a Local LLM in Flutter (Android)</h2>
<p>Now to the more fun (harder maybe 😅) part!</p>
<h3>My First Attempt: Native FFI (didn't work)</h3>
<p>I tried integrating <code>llama.cpp</code> into Flutter the standard way. I compiled <code>llama.cpp</code> for Android ARM64 using the Android NDK, wrote a C++ bridge, and then wrote Dart FFI bindings to call into it.</p>
<p>I had C++ files in a <code>native/</code> directory, Dart binding files, a shell script that called CMake targeting the NDK toolchain for <code>arm64-v8a</code> and compiled a <code>.so</code> file which I bundled into the APK.</p>
<p>The Dart code looked like this:</p>
<pre><code class="language-dart">// llama_bindings.dart
class LlamaBindings {
  late final DynamicLibrary _lib;

  late final int Function(Pointer&lt;Void&gt;) _llamaDartInit;
  late final int Function(Pointer&lt;Utf8&gt;) _llamaInit;
  late final Pointer&lt;Utf8&gt; Function(Pointer&lt;Utf8&gt;) _llamaGenerate;

  void init(String modelPath) {
    final Pointer&lt;Utf8&gt; cPath = modelPath.toNativeUtf8();
    try {
      final int rc = _llamaInit(cPath);
      if (rc != 0) {
        throw Exception('llama_init failed (code=$rc)');
      }
    } finally {
      malloc.free(cPath);
    }
  }
}
</code></pre>
<p>And the C++ bridge had to handle Dart ports, the isolates, and the native threading just to stream tokens back to Flutter:</p>
<pre><code class="language-cpp">// llama_bridge.cpp
extern "C" {
int32_t llama_dart_init(void *init_data) {
    void *init_sym = dlsym(RTLD_DEFAULT, "Dart_InitializeApiDL");
    // ... 325 more lines of this
}
</code></pre>
<p>My git commit message on October 30th after a full day of back and forth was:</p>
<blockquote>
<p><em>"lots of failed attempts. llama model initialization keeps failing"</em></p>
</blockquote>
<p>The model would compile, the library would load but the model initialisation would fail every time. I never got it to work.</p>
<p>I figured bridging native C++ into Flutter via FFI is not trivial. It was my first time working with native FFI. Memory management, ABI compatibility, initialising the Dart API correctly from native code, and handling the threading model correctly are things that can go wrong in many ways that are difficult to debug. Except you have a lot experience working with c++ and integrating it into Flutter, you probably shouldn't take this route. P.S.: I later got the hang of bridging native C++ into Flutter, but I did it for a Flutter Windows app I made. I'll write about it in my next article.</p>
<h3>Success: Finding the Right Flutter Packages</h3>
<p>I'm not sure why it didn't occur to me to search for existing packages initially (I actually just wanted to figure it out myself 😅), but it turned out there were a few Flutter packages that wrapped the native complexity. Depending on the model format you're working with, there are two excellent options I ended up using: <code>flutter_gemma</code> and <code>llama_flutter_android</code>.</p>
<p>I used both of them extensively in my work, as they cater to different model formats and offer different levels of control.</p>
<h4>Working with LiteRT Models: flutter_gemma</h4>
<p>If you're working with LiteRT (formerly TensorFlow Lite) models, <code>flutter_gemma</code> is a fantastic, straightforward package built directly on Google's LiteRT runtime. It works with <code>.bin</code>, <code>.tflite</code>, <code>.task</code> and <code>.litertlm</code> model files</p>
<p>Add to <code>pubspec.yaml</code>:</p>
<pre><code class="language-yaml">dependencies:
  flutter_gemma: ^0.11.12
</code></pre>
<p>Using it was also very intuitive:</p>
<pre><code class="language-dart">final model = InferenceModel();
await model.init();

final chat = await model.createChatSession(
  systemPrompt: "You are a helpful assistant.",
);

await for (final token in chat.sendMessageStream("Hello!")) {
  // stream tokens to UI
}
</code></pre>
<p>This package handles model loading internally and abstracts away most of the resource management, which makes it easier to get up and running quickly. It's a great choice if the model you want to run is available in one of its supported formats which I earlier mentioned.</p>
<h4>Working with GGUF Models: llama_flutter_android</h4>
<p>While <code>flutter_gemma</code> was perfect for LiteRT models, most models you will find or fine-tune yourself (as demonstrated in part 1) will actually be in GGUF format. Many of the community fine-tunes and newer open weights on HuggingFace are in the GGUF format.</p>
<p>For this, I found <code>llama_flutter_android</code> to be perfect. This package uses <code>llama.cpp</code> under the hood but packages everything needed- the native library, the GGUF loading, and the generation logic - as a proper Flutter plugin. So no NDK, CMake or C++ bridge problems anymore, which were the things I struggled with when trying to work with llama.cpp directly.</p>
<p>Now, while <code>flutter_gemma</code> <em>does</em> allow you to configure basic sampling settings (like <code>temperature</code>, <code>topK</code>, and <code>randomSeed</code>), <code>llama_flutter_android</code> allows for more control over both the generation process and hardware execution. This distinction became important as I started optimizing the app for performance on lowerend devices.</p>
<p>Let me walk you through the detailed process of using <code>llama_flutter_android</code> for GGUF models:</p>
<h4>1. Add the Dependency to pubspec per usual</h4>
<pre><code class="language-yaml">dependencies:
  llama_flutter_android: ^0.1.1
</code></pre>
<p>Don't forget to run <code>flutter pub get</code>.</p>
<h4>2. Add the Model to Your Project</h4>
<p>You have two options: bundle the model as an asset, or download it (once throughout the lifetime of the app) at runtime. I recomment downloading it at runtime, but your use case may require bundling which is okay.</p>
<p>For bundling:</p>
<pre><code class="language-yaml"># pubspec.yaml
flutter:
  assets:
    - assets/models/your_model.gguf
</code></pre>
<p>Remember, AI models are large (even the most quantized ones with &lt;500M parameters can be up to 300MB-600MB). It will bump up your APK size. If this is a concern for you, you should host the model (you can create a HuggingFace account and upload your fine-tuned model there securely) and download at first launch instead.</p>
<p>For download-on-first-launch:</p>
<pre><code class="language-dart">Future&lt;String&gt; downloadModel() async {
  final dir = await getApplicationDocumentsDirectory();
  final modelPath = '${dir.path}/your_model.gguf';

  if (File(modelPath).existsSync()) return modelPath;

  final response = await http.get(Uri.parse(MODEL_URL));
  await File(modelPath).writeAsBytes(response.bodyBytes);
  return modelPath;
}
</code></pre>
<p>If you chose to bundle the model as an asset, you'll need to copy it out to a writable directory first before loading (Flutter assets are read-only):</p>
<pre><code class="language-dart">Future&lt;String&gt; extractModelFromAssets() async {
  final dir = await getApplicationDocumentsDirectory();
  final modelPath = '${dir.path}/your_model.gguf';

  if (File(modelPath).existsSync()) return modelPath;

  final data = await rootBundle.load('assets/models/your_model.gguf');
  await File(modelPath).writeAsBytes(
    data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes),
  );

  return modelPath;
}
</code></pre>
<h4>3. Next, build the Service class</h4>
<p>I wrapped everything in a singleton service class like this:</p>
<pre><code class="language-dart">import 'dart:async';
import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:llama_flutter_android/llama_flutter_android.dart';

class LlamaService {
  static final LlamaService _instance = LlamaService._();
  LlamaController? _controller;
  String? _currentModelPath;

  factory LlamaService() =&gt; _instance;
  LlamaService._();

  LlamaController get _assertController {
    final existing = _controller;
    if (existing != null) return existing;
    final recreated = LlamaController();
    _controller = recreated;
    return recreated;
  }

  Future&lt;void&gt; loadModel(String filePath) async {
    _currentModelPath = filePath;

    final cpuCores = Platform.numberOfProcessors;
    final ramGB = await _getTotalRamGB();

    // Adapt context size and threads to available device memory
    int threads;
    int contextSize;

    if (ramGB &gt;= 12) {
      contextSize = 6144;
      threads = (cpuCores ~/ 2).clamp(4, 8);
    } else if (ramGB &gt;= 8) {
      contextSize = 4096;
      threads = (cpuCores ~/ 2).clamp(3, 6);
    } else if (ramGB &gt;= 6) {
      contextSize = 3072;
      threads = (cpuCores ~/ 2).clamp(2, 4);
    } else {
      contextSize = 2048;
      threads = 2;
    }

    debugPrint('Loading model: threads=$threads, context=$contextSize, ram=${ramGB.toStringAsFixed(1)}GB');

    try {
      await _assertController.loadModel(
        modelPath: filePath,
        threads: threads,
        contextSize: contextSize,
      );
    } catch (e) {
      // Retry with conservative settings if adaptive settings fail
      if (contextSize &gt; 2048) {
        debugPrint('Retrying with conservative settings...');
        await _assertController.loadModel(
          modelPath: filePath,
          threads: 2,
          contextSize: 2048,
        );
      } else {
        rethrow;
      }
    }
  }

  Stream&lt;String&gt; chat(List&lt;ChatMessage&gt; messages) {
    return _assertController.generateChat(
      messages: messages,
      template: null,
      maxTokens: 1024,
      temperature: 0.7,
      topK: 40,
      topP: 0.9,
    );
  }

  Future&lt;void&gt; clearContext() async =&gt; _assertController.clearContext();

  Future&lt;void&gt; dispose() async {
    await _controller?.dispose();
    _controller = null;
  }

  Future&lt;double&gt; _getTotalRamGB() async {
    try {
      final memInfo = await File('/proc/meminfo').readAsString();
      final match = RegExp(r'MemTotal:\s+(\d+) kB').firstMatch(memInfo);
      if (match != null) {
        final kb = int.parse(match.group(1)!);
        return kb / (1024 * 1024);
      }
    } catch (_) {}
    return 4.0; // safe default
  }
}
</code></pre>
<p>Let me explain a few key things from the code:</p>
<p><strong>What (or why)</strong> <code>/proc/meminfo</code><strong>?</strong> Android's <code>Platform.numberOfProcessors</code> returns CPU core count, but there's no standard Flutter API for available RAM. On Android, <code>/proc/meminfo</code> is a virtual file that the kernel populates with current memory info. Reading it allows us to dynamically set the context window based on the actual device the app is running on, instead of just hardcoding a value that either runs out of memory on low-end phones or under-utilizes high-end ones.</p>
<p><strong>Why adapt context size and threads?</strong> Text generation (or inference) with large language models uses the CPU and so, running more threads can make it faster, but it can only do so up to a point. After that, too many threads start competing for the same CPU power, which begins to slow things down. Using cpuCores ~/ 2 is a safe starting point as it gives a good speed boost while still leaving enough CPU for the app UI and other background work. We clamp it based on available RAM because more threads also means more memory pressure.</p>
<p><strong>Why the fallback retry?</strong> Well, model loading on devices sometimes fails at higher context sizes due to failure to allocate adequate resources rather than any other other fundamental issue. Retrying at lower tokens can succeed where higher ones failed. It's just a fail safe. Better to degrade than crash, isn't it?.</p>
<p><strong>Now to generation parameters:</strong> Tuning the model generation parameters influences how the model chooses its next tokens and manages resources. A badly tuned model can effectively render it useless. I played around with the parameters extensively to find the right balance. I'll explain them and give my recommendations:</p>
<ul>
<li><p><code>maxTokens</code>: This defines the limit on how many tokens the model can generate in one turn. <strong>My Recommendation:</strong> Keep this between 512 and 1024 for mobile apps. Anything higher risks draining the battery and holding up the device for too long.</p>
</li>
<li><p><code>temperature</code>: This controls randomness. A lower value (say 0.6) makes the model more direct or deterministic, while a higher value (say the max, 1.0) makes it more creative but prone to hallucination. <strong>My Recommendation:</strong> You may start at 0.7 for general chat. If you need strict formatting and reliable responses, drop it lower.</p>
</li>
<li><p><code>topK</code>: Restricts the model's token choices to only the top K most likely next tokens. <strong>My Recommendation:</strong> 40 is the standard default, that works fine for pretty much every task.</p>
</li>
<li><p><code>topP</code>: (also called nucleus sampling) This restricts the model's choices to a smaller group of tokens whose combined probability exceeds the value P. <strong>My Recommendation:</strong> 0.9 works well with a 0.7ish temperature.</p>
</li>
<li><p><code>minP</code>: Sets a relative minimum probability threshold. Tokens that fall below a the specified percentage of the most likely token's probability are jettisoned. This can be instrumental in ensuring quality. <strong>My Recommendation:</strong> 0.05 to 0.1 is usually perfect.</p>
</li>
<li><p><code>repeatPenalty</code>: This parameter penalizes the model for repeating the same words or phrases. If you discover your model repeats its output, this could solve that. <strong>Recommendation:</strong> 1.1 to 1.2. Smaller models usually have this looping or repeat issue, so this is crucial.</p>
</li>
<li><p><code>penalizeNewline</code>: This specifically penalizes the generation of newline characters (\n) to prevent formatting loops or overly spaced out text. <strong>My Recommendation:</strong> Set to <code>true</code> for general text tasks, but if your model needs to generate code or formatted lists, set to <code>false</code>.</p>
</li>
<li><p><strong>Thread count</strong>: Already explained this. It determines ow many CPU threads to allocate to generation. <strong>My Recommendation:</strong> Make it adaptive based on device cores (as shown in the code).</p>
</li>
<li><p><strong>Context size</strong>: The maximum number of tokens (both prompt and output) that the model can hold in memory. This directly determines how much RAM is required. <strong>My Recommendation:</strong> 2048 for low-end devices, 4096 for 8GB RAM devices, 6144 for 12GB+ RAM devices.</p>
</li>
</ul>
<h4>4. Handling Chat Format</h4>
<p>It turns out different models actually expect prompts in different formats. The <code>generateChat</code> method handles this automatically for most models using their built-in chat template. However, some models may need manual formatting.</p>
<p>For example, Gemma 3's format uses special turn tokens:</p>
<pre><code class="language-dart">String _formatGemma3Prompt(List&lt;ChatMessage&gt; messages) {
  final buffer = StringBuffer();
  String? systemText;

  for (final msg in messages) {
    if (msg.role == 'system') {
      systemText = msg.content;
    }
  }

  for (final msg in messages) {
    if (msg.role == 'system') continue;

    buffer.write('&lt;start_of_turn&gt;');
    if (msg.role == 'user') {
      buffer.write('user
');
      if (systemText != null) {
        buffer.write('$systemText

');
        systemText = null; // inject once only
      }
      buffer.write(msg.content);
      buffer.write('&lt;end_of_turn&gt;
');
    } else if (msg.role == 'assistant') {
      buffer.write('model
');
      buffer.write(msg.content);
      buffer.write('&lt;end_of_turn&gt;
');
    }
  }

  buffer.write('&lt;start_of_turn&gt;model
');
  return buffer.toString();
}
</code></pre>
<p>The important thing is that the system prompt is inserted into the first user turn, not as a separate turn. This is how Gemma 3 was trained to expect it. Getting this wrong can produce garbled output as the model will receive your instructions but won't follow them reliably.</p>
<p>If you're using a different model, check its model card on HuggingFace for the expected chat format. Most instruct models document this properly.</p>
<h4>5. Using It in Your UI</h4>
<p>Once you're done with the service, wiring it into a Cubit or Bloc is straightforward:</p>
<pre><code class="language-dart">class ChatCubit extends Cubit&lt;ChatState&gt; {
  final LlamaService _llama = LlamaService();
  final List&lt;ChatMessage&gt; _history = [];

  ChatCubit() : super(ChatInitial());

  Future&lt;void&gt; sendMessage(String userText) async {
    _history.add(ChatMessage(role: 'user', content: userText));
    emit(ChatGenerating(messages: List.from(_history)));

    final responseBuffer = StringBuffer();

    _llama.chat(_history).listen(
      (token) {
        responseBuffer.write(token);
        emit(ChatStreaming(
          messages: List.from(_history),
          currentResponse: responseBuffer.toString(),
        ));
      },
      onDone: () {
        _history.add(ChatMessage(
          role: 'assistant',
          content: responseBuffer.toString(),
        ));
        emit(ChatIdle(messages: List.from(_history)));
      },
      onError: (e) =&gt; emit(ChatError(e.toString())),
    );
  }
}
</code></pre>
<p>The streaming approach is important as LLM generation produces tokens one by one unlike an HTTP response that arrives all at once. Streaming the tokens to the UI as they arrive makes the app feel responsive and natural. If you wait for the full response before displaying anything, your users may quickly get bored staring at a loading spinner for several seconds.</p>
<h4>6. Initialisation and Model Loading</h4>
<p>Load the model once ideally when the feature is first needed or maybe just at app startup:</p>
<pre><code class="language-dart">void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final modelPath = await extractModelFromAssets();
  await LlamaService().loadModel(modelPath);

  runApp(const MyApp());
}
</code></pre>
<p>P.S.: If you're loading at startup, do it before <code>runApp</code> so that the UI doesn't appear before the model is ready. Model loading for a quantized small model can take 2–5 seconds on a mid-range device.</p>
<h3>You're all set!</h3>
<p>Remember:</p>
<p>The model generation speed is primarily determined by:</p>
<ul>
<li><p><strong>Model size</strong> -- smaller = faster</p>
</li>
<li><p><strong>Quantization level</strong> -- lower bit = faster, lower quality</p>
</li>
<li><p><strong>Context size</strong> -- larger context = more memory consumed (slower)</p>
</li>
<li><p><strong>Device</strong> -- RAM and CPU core count, can be properly handled per the adaptive logic above</p>
</li>
</ul>
<hr />
<h2>Top level Summary</h2>
<ol>
<li><p><strong>Prepare your dataset</strong> - pull from HuggingFace or extract from your own documents, clean it, format it by a specific standard, and order it.</p>
</li>
<li><p><strong>Fine-tune with Unsloth</strong> - load a quantized base model, apply QLoRA, train with SFTTrainer.</p>
</li>
<li><p><strong>Export to GGUF</strong> - <code>model.save_pretrained_gguf("output", tokenizer, quantization_method="q4_k_m")</code>.</p>
</li>
<li><p><strong>Bundle or download the GGUF</strong> in your Flutter project.</p>
</li>
<li><p><strong>Add</strong> <code>llama_flutter_android</code> to <code>pubspec.yaml</code>.</p>
</li>
<li><p><strong>Build a service</strong> using <code>LlamaController</code>, with adaptive threading and context sizing.</p>
</li>
<li><p><strong>Connect it to your UI</strong> via your state management, stream the tokens as they arrive.</p>
</li>
</ol>
<p>If you're skipping fine-tuning, just start from step 4 with a pre-quantized GGUF from HuggingFace.</p>
<hr />
<h2>Well, that's it.</h2>
<p><em>Have questions, or want to go deeper on any part of this? Find me on LinkedIn. Watch out for my next article which is likely going to be on running AI models (LLMs &amp; VLMs) on Flutter Windows Applications (that's a lot more fun than this one)</em></p>
]]></content:encoded></item></channel></rss>