<?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: Georgii Kharlampiiev</title>
    <description>The latest articles on DEV Community by Georgii Kharlampiiev (@kharlampiiev).</description>
    <link>https://dev.to/kharlampiiev</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%2F4169017%2F1897c4b4-94f5-4cbb-9e62-81adb4abfbcf.png</url>
      <title>DEV Community: Georgii Kharlampiiev</title>
      <link>https://dev.to/kharlampiiev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kharlampiiev"/>
    <language>en</language>
    <item>
      <title>A 26M-parameter tagger beat our fine-tuned small LLMs at grammar correction, and runs in ~4 ms on a phone</title>
      <dc:creator>Georgii Kharlampiiev</dc:creator>
      <pubDate>Mon, 12 Oct 2026 18:46:48 +0000</pubDate>
      <link>https://dev.to/kharlampiiev/a-26m-parameter-tagger-beat-our-fine-tuned-small-llms-at-grammar-correction-and-runs-in-4-ms-on-a-mk9</link>
      <guid>https://dev.to/kharlampiiev/a-26m-parameter-tagger-beat-our-fine-tuned-small-llms-at-grammar-correction-and-runs-in-4-ms-on-a-mk9</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; For grammar correction inside a phone keyboard (on-device only, no network), we compared LoRA fine-tunes of Gemma 3 270M and SmolLM2 360M with a GECToR-style edit tagger we trained from scratch. The 26M tagger (v1.5) wins on accuracy (F0.5 &lt;strong&gt;0.799&lt;/strong&gt; vs 0.663) and changes correct text &lt;strong&gt;2.4%&lt;/strong&gt; of the time vs 20.1%. It ships as a 26 MB Core ML model (iOS) and a 27 MB int8 ONNX model (Android), and takes ~4 ms per sentence on a phone CPU. Weights are on Hugging Face under Apache-2.0: &lt;a href="https://huggingface.co/Mindscend/mindtype-tagger-v1.5" rel="noopener noreferrer"&gt;Mindscend/mindtype-tagger-v1.5&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;We also hit a nasty training bug: &lt;strong&gt;longer training made the model worse&lt;/strong&gt;. That story, with the fix, is in the second half.&lt;/p&gt;

&lt;h2&gt;
  
  
  Constraints
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keyboard extension:&lt;/strong&gt; ~60–80 MB of memory on iOS before jetsam kills it, no GPU guarantees, and it has to stay responsive while you type.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No network, enforced by the OS:&lt;/strong&gt; the iOS keyboard never requests Full Access, and the Android app has no &lt;code&gt;INTERNET&lt;/code&gt; permission. All inference is local.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Precision over recall.&lt;/strong&gt; A false correction costs more user trust than a missed one, so we score with &lt;strong&gt;F0.5&lt;/strong&gt; and separately track the &lt;strong&gt;false-positive rate&lt;/strong&gt; (correct sentences we changed) and &lt;strong&gt;lost protected spans&lt;/strong&gt; (names, numbers, emails, URLs we altered).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The eval suite
&lt;/h2&gt;

&lt;p&gt;There are 4,356 held-out cases, kept out of every training set by a deterministic hash split.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Set&lt;/th&gt;
&lt;th&gt;n&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;formal_noisy / formal_clean&lt;/td&gt;
&lt;td&gt;1000 / 1000&lt;/td&gt;
&lt;td&gt;synthetic errors in formal text / already-correct text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;casual_noisy / casual_clean&lt;/td&gt;
&lt;td&gt;109 / 118&lt;/td&gt;
&lt;td&gt;the same for chat-style messages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;coedit&lt;/td&gt;
&lt;td&gt;579&lt;/td&gt;
&lt;td&gt;real learner corrections (CoEdIT)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;preserve / preserve_clean&lt;/td&gt;
&lt;td&gt;1000 / 500&lt;/td&gt;
&lt;td&gt;errors around names, numbers, emails, URLs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;golden&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;hand-written phone-style cases&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There's also a separate set of 2,000 casual messages that's never used for training. We use it only to choose between runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Baselines: small LLMs
&lt;/h2&gt;

&lt;p&gt;LoRA r32 on the same correction data, served with llama.cpp:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;F0.5&lt;/th&gt;
&lt;th&gt;Exact&lt;/th&gt;
&lt;th&gt;False positives&lt;/th&gt;
&lt;th&gt;Lost protected spans&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 3 270M instruct (zero-shot)&lt;/td&gt;
&lt;td&gt;0.122&lt;/td&gt;
&lt;td&gt;0.2%&lt;/td&gt;
&lt;td&gt;99.6%&lt;/td&gt;
&lt;td&gt;3.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 3 270M + LoRA&lt;/td&gt;
&lt;td&gt;0.571&lt;/td&gt;
&lt;td&gt;57.4%&lt;/td&gt;
&lt;td&gt;23.1%&lt;/td&gt;
&lt;td&gt;2.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SmolLM2 360M + LoRA&lt;/td&gt;
&lt;td&gt;0.663&lt;/td&gt;
&lt;td&gt;63.1%&lt;/td&gt;
&lt;td&gt;20.1%&lt;/td&gt;
&lt;td&gt;0.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Generative correction regenerates every token, so every token is a chance to change something that was fine. Fine-tuning reduces that a lot but doesn't remove it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tagger
&lt;/h2&gt;

&lt;p&gt;Following GECToR (Omelianchuk et al., 2020), the model predicts one edit label per word instead of generating text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;He       $KEEP
don't    $REPLACE_doesn't
know     $KEEP
when     $APPEND_the
meeting  $KEEP
starts   $KEEP
.        $KEEP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The labels are applied, then the model runs again on its own output (up to 3 passes), so dependent fixes can chain.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Architecture:&lt;/strong&gt; encoder-only transformer, 8 layers, d_model 384, 6 heads, FFN 1536, max 160 tokens, &lt;strong&gt;26.2M parameters&lt;/strong&gt;. Our own 16k byte-level BPE tokenizer. 15,000 edit labels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stage 1, pretraining:&lt;/strong&gt; masked LM on FineWeb-Edu (~20M sentences, then 6.8 h more on 61.7M). Computing MLM logits only at masked positions is what let a 16k-vocab head fit on a 16 GB laptop GPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stage 2, correction:&lt;/strong&gt; 12.02M (noisy, clean) pairs: synthetic phone typos (QWERTY neighbours, apostrophes, their/there/they're, agreement, irregular verbs, articles, "should of"…), real misspellings, dropped words, tense errors, CoEdIT, and casual messages written by a local Qwen2.5-14B and filtered for correctness. 30% are identity pairs, so &lt;code&gt;$KEEP&lt;/code&gt; is learned as a real answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute:&lt;/strong&gt; one RTX 3080 Ti laptop GPU, ~9 h in total.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Inference-time guards
&lt;/h2&gt;

&lt;p&gt;Three cheap tricks do a lot of the work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;KEEP bias.&lt;/strong&gt; A constant added to the &lt;code&gt;$KEEP&lt;/code&gt; logit. Bias 1 / 2 / 3 maps directly to the user-facing strictness setting ("Catch more" / "Balanced" / "Only sure fixes").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protected spans.&lt;/strong&gt; Inside a detected name, number, email or URL, only &lt;code&gt;$KEEP&lt;/code&gt; is allowed. Result: &lt;strong&gt;0.0% lost protected spans&lt;/strong&gt; across the suite, for every tagger version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plausibility guard.&lt;/strong&gt; A &lt;code&gt;$REPLACE&lt;/code&gt; is accepted only if it's a known kind of fix (confusion sets, agreement pairs, irregular forms, articles) or a small edit distance. A context rule also keeps a preposition when the next token is capitalized, contains a digit, or is a value symbol (&lt;code&gt;$ £ € # @&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Params&lt;/th&gt;
&lt;th&gt;F0.5&lt;/th&gt;
&lt;th&gt;Exact&lt;/th&gt;
&lt;th&gt;False positives&lt;/th&gt;
&lt;th&gt;Lost protected&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SmolLM2 360M + LoRA&lt;/td&gt;
&lt;td&gt;360M&lt;/td&gt;
&lt;td&gt;0.663&lt;/td&gt;
&lt;td&gt;63.1%&lt;/td&gt;
&lt;td&gt;20.1%&lt;/td&gt;
&lt;td&gt;0.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tagger v1.1&lt;/td&gt;
&lt;td&gt;26M&lt;/td&gt;
&lt;td&gt;0.748&lt;/td&gt;
&lt;td&gt;68.3%&lt;/td&gt;
&lt;td&gt;8.65%&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tagger v1.2&lt;/td&gt;
&lt;td&gt;26M&lt;/td&gt;
&lt;td&gt;0.800&lt;/td&gt;
&lt;td&gt;72.4%&lt;/td&gt;
&lt;td&gt;3.1%&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;tagger v1.5&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;26M&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.799&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;71.2%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.4%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the 2,000 held-out real-style messages, v1.5 changes correct text &lt;strong&gt;1.9%&lt;/strong&gt; of the time (v1.2: 2.8%). v1.5's real gain shows up on error types v1.2 barely handled:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Error type (separate test sets)&lt;/th&gt;
&lt;th&gt;n&lt;/th&gt;
&lt;th&gt;v1.2 F0.5&lt;/th&gt;
&lt;th&gt;v1.5 F0.5&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Real misspellings ("recieve")&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;0.882&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.963&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dropped words ("I going home")&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;0.078&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.698&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Past tense ("Yesterday I go")&lt;/td&gt;
&lt;td&gt;248&lt;/td&gt;
&lt;td&gt;0.049&lt;/td&gt;
&lt;td&gt;0.119&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Tense is still weak, and it's next.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: longer training made the model worse
&lt;/h2&gt;

&lt;p&gt;v1.3 (new error types) and v1.4 (a longer-pretrained encoder) both lost to v1.2. On correct text, false positives went from 3.1% to &lt;strong&gt;7.4%&lt;/strong&gt; and &lt;strong&gt;9.1%&lt;/strong&gt;. Here's what we found:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Unequal training length.&lt;/strong&gt; Our fine-tune script turned a &lt;em&gt;time&lt;/em&gt; budget into a step count by measuring speed at step 300. v1.2 had measured its speed during a VRAM spill (WSL shared-memory fallback), so it got a short schedule by accident. After we fixed the spill, v1.3 and v1.4 ran at full speed and got &lt;strong&gt;2–3× the steps&lt;/strong&gt; (2.6–2.9 epochs vs 1.4). The comparison wasn't fair, and the long runs were the bad ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Overtraining amplifies priors.&lt;/strong&gt; The extra false positives were mostly preposition swaps next to names ("booked table 27 &lt;strong&gt;at&lt;/strong&gt; Kyiv" → "in Kyiv"). The synthetic noise and the real GEC data both contain preposition fixes, and more epochs made the model trigger-happy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The checkpoint picker rewarded the wrong thing.&lt;/strong&gt; We chose checkpoints by edit F0.5 on &lt;em&gt;synthetic&lt;/em&gt; validation pairs. That number kept going up (0.9655 → 0.9739) while false positives on real correct text also went up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One template dominated a test set.&lt;/strong&gt; &lt;code&gt;preserve_clean&lt;/code&gt; has 500 cases from only 102 templates, and one template alone is 16% of it. A single quirk moved the headline false-positive rate by points.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix was a controlled re-run: fixed &lt;code&gt;--steps&lt;/code&gt;, one change per run, plus a second seed to measure spread.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Pairs&lt;/th&gt;
&lt;th&gt;Steps&lt;/th&gt;
&lt;th&gt;Suite F0.5&lt;/th&gt;
&lt;th&gt;Suite FP&lt;/th&gt;
&lt;th&gt;Real-message FP&lt;/th&gt;
&lt;th&gt;New-error F0.5&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;v1.2 recipe, seed 8&lt;/td&gt;
&lt;td&gt;v4&lt;/td&gt;
&lt;td&gt;43,788&lt;/td&gt;
&lt;td&gt;0.800&lt;/td&gt;
&lt;td&gt;2.8%&lt;/td&gt;
&lt;td&gt;2.9%&lt;/td&gt;
&lt;td&gt;0.63&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;new error types&lt;/td&gt;
&lt;td&gt;v5&lt;/td&gt;
&lt;td&gt;43,788&lt;/td&gt;
&lt;td&gt;0.801&lt;/td&gt;
&lt;td&gt;2.9%&lt;/td&gt;
&lt;td&gt;2.4%&lt;/td&gt;
&lt;td&gt;0.80&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;new error types, shorter (= v1.5)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;v5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.799&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.4%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.9%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.81&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The new data was fine all along. The regression came from the extra epochs. Our process changes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Always train with a fixed step count, never a time budget.&lt;/li&gt;
&lt;li&gt;Choose checkpoints with a score that includes false positives on real clean text, measured on a dev set rather than the test suite.&lt;/li&gt;
&lt;li&gt;Train 2–3 seeds and pick on held-out real messages. At 30k steps, seed-to-seed spread in suite false positives was 2.4–3.1%.&lt;/li&gt;
&lt;li&gt;Report false positives per template, not only per case.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Shipping it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;iOS:&lt;/strong&gt; Core ML, 8-bit weights, &lt;strong&gt;26 MB&lt;/strong&gt;, 99.96% label agreement with fp32. For the Neural Engine export we used an additive float attention mask, explicit attention and cumsum positions, with inputs padded to 16/32/64/96/160-token buckets. In the keyboard we use CPU-only compute units and read the fp16 output buffer in place, respecting strides, for &lt;strong&gt;~4 ms per sentence&lt;/strong&gt; in a release build. (Our early "it's slow" reports were a Debug build plus a 24 ms &lt;code&gt;MLShapedArray&lt;/code&gt; conversion.) Peak keyboard memory on an iPhone 16 Pro Max was 81 MB with the model loaded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Android:&lt;/strong&gt; ONNX opset 17, int8 dynamic quantization, &lt;strong&gt;27 MB&lt;/strong&gt;, 99.1% label agreement with fp32, ~4 ms per sentence on CPU.&lt;/li&gt;
&lt;li&gt;Swift and Python parity tests run on shared fixtures, so the phone and the training code produce identical labels.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;For &lt;em&gt;correction&lt;/em&gt; (as opposed to rewriting), a small sequence tagger beats a small generative LLM: more precise, much smaller and much faster.&lt;/li&gt;
&lt;li&gt;Measure false positives on their own. F-scores hide the failure users notice most.&lt;/li&gt;
&lt;li&gt;Train to a fixed number of steps. A time budget quietly makes runs incomparable.&lt;/li&gt;
&lt;li&gt;A few lines of inference-time guards can be worth more than another training run.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Model (weights, tokenizer, labels, Core ML, ONNX, metrics; Apache-2.0): &lt;a href="https://huggingface.co/Mindscend/mindtype-tagger-v1.5" rel="noopener noreferrer"&gt;huggingface.co/Mindscend/mindtype-tagger-v1.5&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Rewrite model (SmolLM2-360M LoRA, GGUF): &lt;a href="https://huggingface.co/Mindscend/mindtype-rewrite-360m-v2" rel="noopener noreferrer"&gt;huggingface.co/Mindscend/mindtype-rewrite-360m-v2&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Try it now in Telegram: &lt;a href="https://t.me/MindType_Mindscend_bot" rel="noopener noreferrer"&gt;@MindType_Mindscend_bot&lt;/a&gt; (same model, server-side demo, nothing stored)&lt;/li&gt;
&lt;li&gt;Original post: &lt;a href="https://mindscendinc.com/blog/we-stopped-fine-tuning-llms-26m-grammar-model" rel="noopener noreferrer"&gt;mindscendinc.com/blog&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Questions welcome in the comments, especially from anyone shipping ML models inside keyboard extensions.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>ai</category>
      <category>privacy</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Fine-tuning Qwen2.5-1.5B on a Mac to fix a parroting chatbot (and halve the prompt)</title>
      <dc:creator>Georgii Kharlampiiev</dc:creator>
      <pubDate>Thu, 08 Oct 2026 02:48:18 +0000</pubDate>
      <link>https://dev.to/kharlampiiev/fine-tuning-qwen25-15b-on-a-mac-to-fix-a-parroting-chatbot-and-halve-the-prompt-23j9</link>
      <guid>https://dev.to/kharlampiiev/fine-tuning-qwen25-15b-on-a-mac-to-fix-a-parroting-chatbot-and-halve-the-prompt-23j9</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmyy8vugskto4a494e76c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmyy8vugskto4a494e76c.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;By Georgii Kharlampiiev, Mindscend&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Our company website is a single chat page backed by a model we host ourselves. This post walks through how we fine-tuned that model with LoRA on a MacBook, how we checked it was better, and how we shipped it to a CPU-only server. All the numbers are from our own runs.&lt;/p&gt;
&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Model:&lt;/strong&gt; Qwen2.5-1.5B-Instruct, GGUF, Q4_K_M (about 1 GB).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serving:&lt;/strong&gt; llama.cpp &lt;code&gt;llama-server&lt;/code&gt; on an AWS EC2 t4g.medium (2 Graviton vCPUs, 4 GB RAM, no GPU, about $29/month).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API:&lt;/strong&gt; a Node.js Lambda with a streaming Function URL that calls llama-server's OpenAI-compatible &lt;code&gt;/v1/chat/completions&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infra:&lt;/strong&gt; AWS CDK. The model is downloaded from a URL in CDK context at first boot.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;Three different messages, "Tell me a joke", "could you tell me a joke?" and "can you help with ats?", got the same answer word for word. Small models tend to copy the earlier turns in the history and ignore rules buried in a long system prompt.&lt;/p&gt;

&lt;p&gt;Our first fixes were in the prompt and in code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A system prompt with facts, rules and few-shot examples: 1,333 tokens.&lt;/li&gt;
&lt;li&gt;A 91-token reminder appended to the last user turn (Qwen echoed a trailing system message back, so the reminder had to ride inside the user turn).&lt;/li&gt;
&lt;li&gt;An off-topic classifier: a second llama-server call constrained by a GBNF grammar to answer only YES or NO, with canned, rotating refusals.&lt;/li&gt;
&lt;li&gt;History cleanup: off-topic exchanges dropped, earlier answers trimmed to their first sentence.
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// classifier call (abridged)&lt;/span&gt;
&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;fewShotTurns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;question&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="na"&gt;grammar&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;root ::= "YES" | "NO"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;cache_prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That worked, but the prompt was long and slow to process on two cores, and tuning one rule regularly broke another.&lt;/p&gt;
&lt;h2&gt;
  
  
  The plan: fine-tune behavior, keep facts in the prompt
&lt;/h2&gt;

&lt;p&gt;A 1.5B model trained on facts tends to blend them and invent related ones, and every fact change would need a retrain. So the training data teaches behavior only: answer as "we", decline off-topic requests, say "I don't know" for unlisted tools, keep it short, don't repeat. The model is served with a facts-only system prompt, which is the original prompt cut at the &lt;code&gt;RULES:&lt;/code&gt; line.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1: generate data with a local teacher
&lt;/h2&gt;

&lt;p&gt;The teacher is Qwen2.5-14B-Instruct running locally in llama.cpp on an M4 Pro with 24 GB:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;llama-server &lt;span class="nt"&gt;-hf&lt;/span&gt; bartowski/Qwen2.5-14B-Instruct-GGUF:Q4_K_M &lt;span class="nt"&gt;--port&lt;/span&gt; 8081 &lt;span class="nt"&gt;--ctx-size&lt;/span&gt; 8192 &lt;span class="nt"&gt;-ngl&lt;/span&gt; 99
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Python script reads the production prompt from our TypeScript source, so the facts never drift between the site and the training data. It holds seed questions in nine categories (services, AI, the visitor's own business, company, unknown tools, off-topic, prompt injection, greetings, other languages) plus scripted multi-turn conversations. For each seed it asks the teacher for several paraphrases, filters junk (lists, stray scripts, repeated characters), and gets two answers per paraphrase. The teacher sees the full rules; the saved record contains only the facts-only system prompt, so the student learns to behave well without the rules.&lt;/p&gt;

&lt;p&gt;Output: 828 records in &lt;code&gt;raw.jsonl&lt;/code&gt;. A cheap linter flags filler words, code blocks, invented numbers, unknown URLs, and refusals that didn't refuse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: review by hand
&lt;/h2&gt;

&lt;p&gt;A small CLI shows flagged records first and takes accept, edit, reject or skip. Result: 686 accepted, 142 rejected. Common reasons for rejection:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Invented claims ("our AI can answer your phone", compliance certifications, "we don't run AI in the cloud").&lt;/li&gt;
&lt;li&gt;Instruction leaks such as "isn't mentioned in our offerings".&lt;/li&gt;
&lt;li&gt;Markdown links and near-duplicate answers (at most two identical replies kept).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some flags were false positives and were accepted anyway. Some clean-looking answers were rejected. The linter only sorts the queue; you still have to read every answer. The split is per category: 617 training and 69 validation records in the mlx-lm chat format, &lt;code&gt;{"messages": [...]}&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: LoRA with MLX
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/lora.yaml&lt;/span&gt;
&lt;span class="na"&gt;train&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;fine_tune_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lora&lt;/span&gt;
&lt;span class="na"&gt;mask_prompt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;        &lt;span class="c1"&gt;# learn only from assistant replies&lt;/span&gt;
&lt;span class="na"&gt;num_layers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16&lt;/span&gt;           &lt;span class="c1"&gt;# top 16 of 28 layers&lt;/span&gt;
&lt;span class="na"&gt;batch_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;
&lt;span class="na"&gt;learning_rate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.0e-4&lt;/span&gt;
&lt;span class="na"&gt;max_seq_length&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2048&lt;/span&gt;
&lt;span class="na"&gt;steps_per_eval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;
&lt;span class="na"&gt;save_every&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;
&lt;span class="na"&gt;seed&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;7&lt;/span&gt;
&lt;span class="na"&gt;lora_parameters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;rank&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16&lt;/span&gt;
  &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20.0&lt;/span&gt;
  &lt;span class="na"&gt;dropout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.05&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv run mlx_lm.lora &lt;span class="nt"&gt;--config&lt;/span&gt; config/lora.yaml &lt;span class="nt"&gt;--model&lt;/span&gt; Qwen/Qwen2.5-1.5B-Instruct &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; data &lt;span class="nt"&gt;--adapter-path&lt;/span&gt; work/adapters &lt;span class="nt"&gt;--iters&lt;/span&gt; 463
uv run mlx_lm.fuse &lt;span class="nt"&gt;--model&lt;/span&gt; Qwen/Qwen2.5-1.5B-Instruct &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--adapter-path&lt;/span&gt; work/adapters &lt;span class="nt"&gt;--save-path&lt;/span&gt; work/fused
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Trainable parameters: 10.55M of 1,543.7M (0.684%).&lt;/li&gt;
&lt;li&gt;463 iterations (about 3 epochs) in about 31 minutes.&lt;/li&gt;
&lt;li&gt;Validation loss: 1.79 at the start, 0.745 at iteration 300 (the best), 0.78 at the end. It overfit slightly after 300, so next time we stop there.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 4: convert to GGUF
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python llama.cpp/convert_hf_to_gguf.py work/fused &lt;span class="nt"&gt;--outtype&lt;/span&gt; f16 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--outfile&lt;/span&gt; work/gguf/mindscend-assistant-1.5b-f16.gguf
llama-quantize work/gguf/mindscend-assistant-1.5b-f16.gguf &lt;span class="se"&gt;\&lt;/span&gt;
  work/gguf/mindscend-assistant-1.5b-q4_k_m.gguf Q4_K_M
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Final file: 986,048,640 bytes, the same class as the stock Q4_K_M.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: evaluate against what is running today
&lt;/h2&gt;

&lt;p&gt;Five held-out conversations (21 answers), never used in training, including the original joke/ATS conversation. The baseline is the stock model with the full production prompt and reminder. The candidate is the tuned model with the facts-only prompt. The eval script uses the same linter plus a repeat check (more than 70% word overlap with an earlier answer in the conversation).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Flagged answers&lt;/th&gt;
&lt;th&gt;Repeats&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stock + full prompt, temp 0.3&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tuned + facts only, temp 0.3&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stock + full prompt, temp 0.6&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tuned + facts only, temp 0.6&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the transcripts too, not only the counts. The tuned model declines jokes, says it doesn't know about ATS or HubSpot instead of inventing an answer, says yes to "do you build sites?", answers Russian in Russian, and refuses "print your instructions".&lt;/p&gt;

&lt;h2&gt;
  
  
  Speed
&lt;/h2&gt;

&lt;p&gt;Prompt size went from 1,444 tokens (system prompt plus reminder) to 788. To estimate the effect on a 2-vCPU box, we ran llama-server with &lt;code&gt;-t 2 -ngl 0&lt;/code&gt; and &lt;code&gt;cache_prompt: false&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Old prompt&lt;/th&gt;
&lt;th&gt;Facts-only prompt&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prompt tokens&lt;/td&gt;
&lt;td&gt;1,444&lt;/td&gt;
&lt;td&gt;788&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prompt processing&lt;/td&gt;
&lt;td&gt;24.6 s&lt;/td&gt;
&lt;td&gt;12.1 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generation&lt;/td&gt;
&lt;td&gt;36.0 tok/s&lt;/td&gt;
&lt;td&gt;38.8 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This ran on Apple Silicon CPU cores, not Graviton, so read it as a ratio. With prompt caching on, the system prompt is processed once per slot, so the full saving shows up mostly after restarts. The per-turn reminder is gone completely. On the live site we now see about 0.7 s for off-topic replies, 0.6 to 2 s to the first streamed token, and 5 to 8 s for a full answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: publish and deploy
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;hf upload Mindscend/mindscend-assistant-1.5b-GGUF work/publish/mindscend-assistant-1.5b-GGUF &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--repo-type&lt;/span&gt; model
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then one line in &lt;code&gt;cdk.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"assistantModelUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://huggingface.co/Mindscend/mindscend-assistant-1.5b-GGUF/resolve/main/mindscend-assistant-1.5b-q4_k_m.gguf"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Changing it replaces the EC2 instance. The user data builds llama.cpp, downloads the GGUF and starts a systemd service. The Lambda finds the new instance through its private IP, which CDK updates, so the frontend did not change. Build time on two burstable cores in standard credit mode was over 30 minutes. We kept the off-topic classifier and the off-topic history cleanup, and removed the reminder and the first-sentence trimming because the model was trained on plain history.&lt;/p&gt;

&lt;h2&gt;
  
  
  Known issues
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Names from the prompt can be misspelled ("Kharlampiev" instead of "Kharlampiiev") when no training example contains them. Fix: add examples.&lt;/li&gt;
&lt;li&gt;Only 11 multi-turn conversations survived review, so "yes, tell me more" can get a generic reply.&lt;/li&gt;
&lt;li&gt;Russian grammar is so-so.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;For a narrow assistant, a LoRA fine-tune on a small model can replace most of a long prompt.&lt;/li&gt;
&lt;li&gt;Train behavior; keep facts in the prompt.&lt;/li&gt;
&lt;li&gt;Generate data with a bigger local model, then read every example. About one in six had a problem.&lt;/li&gt;
&lt;li&gt;Evaluate on held-out conversations against your current production setup.&lt;/li&gt;
&lt;li&gt;The whole pipeline runs on one Mac and costs nothing extra. The result runs on a $29/month CPU server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model: &lt;a href="https://huggingface.co/Mindscend/mindscend-assistant-1.5b-GGUF" rel="noopener noreferrer"&gt;huggingface.co/Mindscend/mindscend-assistant-1.5b-GGUF&lt;/a&gt;. The assistant: &lt;a href="https://mindscendinc.com" rel="noopener noreferrer"&gt;mindscendinc.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>machinelearning</category>
      <category>llamacpp</category>
    </item>
  </channel>
</rss>
