<?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: Alexander Likhachev</title>
    <description>The latest articles on DEV Community by Alexander Likhachev (@avlihachev).</description>
    <link>https://dev.to/avlihachev</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%2F1020065%2F26074a2c-fd58-4448-b3bb-bc50aeaa72aa.jpeg</url>
      <title>DEV Community: Alexander Likhachev</title>
      <link>https://dev.to/avlihachev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/avlihachev"/>
    <language>en</language>
    <item>
      <title>Trust the parser, not the prompt: what running a cheap LLM in production taught me</title>
      <dc:creator>Alexander Likhachev</dc:creator>
      <pubDate>Wed, 05 Aug 2026 15:20:57 +0000</pubDate>
      <link>https://dev.to/avlihachev/trust-the-parser-not-the-prompt-what-running-a-cheap-llm-in-production-taught-me-3908</link>
      <guid>https://dev.to/avlihachev/trust-the-parser-not-the-prompt-what-running-a-cheap-llm-in-production-taught-me-3908</guid>
      <description>&lt;p&gt;I run an LLM in the core loop of a small production app: &lt;a href="https://mening.app" rel="noopener noreferrer"&gt;Mening&lt;/a&gt; corrects language learners' writing every day. Unit economics put the everyday call on a cheap, fast model tier, and the output feeds a database, so it has to obey a contract: strict JSON, a closed set of error categories, explanations in the learner's UI language, no invented "fixes".&lt;/p&gt;

&lt;p&gt;The cheap tier is genuinely good at the task. It is terrible at following the rules around the task. This post is about the escalation ladder I climbed before accepting the house rule in the title, with real numbers from the two fights that taught me it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fight one: the model that wouldn't stop "correcting" 了
&lt;/h3&gt;

&lt;p&gt;Chinese learners kept getting a specific non-correction: the model would insert 了 into sentences that were already fine, then explain why the learner "needed" it. A native-speaker check said the original sentences were correct.&lt;/p&gt;

&lt;p&gt;I climbed the prompt ladder one rung at a time, deploying and watching live traffic after each:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A judgment-style rule ("only flag genuine errors").&lt;/li&gt;
&lt;li&gt;Concrete examples of what not to flag.&lt;/li&gt;
&lt;li&gt;Moving the rule later in the system prompt, close to the generation point.&lt;/li&gt;
&lt;li&gt;An absolute rule, in caps, no exceptions.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Four iterations, and the model still did it. Not always - which is worse than always, because it looks fixed until it isn't.&lt;/p&gt;

&lt;p&gt;The fix that held wasn't wording. The parsing boundary now drops two classes of edits before anything reaches the database: no-op edits, where the "wrong" and "correct" strings are equal, and pure 了-insertion edits. A few lines of Go, zero regressions since. The prompt still asks nicely; the parser doesn't care whether the model listened.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fight two: explanations drifting into the wrong language
&lt;/h3&gt;

&lt;p&gt;The contract says: explain errors in the user's UI language (say, Russian), never in the target language (say, Finnish). A real user reported Finnish explanations. Intermittent, of course.&lt;/p&gt;

&lt;p&gt;Before touching anything I measured it: the same seven real submissions, 21 runs, exact production request shape. Six out of 21 responses drifted (29%), and drift was all-or-nothing per response - the model commits to one language for the entire JSON.&lt;/p&gt;

&lt;p&gt;Then I measured the fixes everyone reaches for first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Temperature to 0.0: still 5/21 drifted. &lt;strong&gt;Temperature is not the lever&lt;/strong&gt; - the coin flip isn't sampling noise, it's the instruction losing to the payload. When the input is dominated by Finnish text, "answer in Russian" fades.&lt;/li&gt;
&lt;li&gt;Moving the language rule to the end of the system prompt: 3/21. Better, still broken.&lt;/li&gt;
&lt;li&gt;Repeating the rule as the &lt;strong&gt;last line of the user turn&lt;/strong&gt;, after the learner's text: 0/21.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the cheapest lesson in this post: the system prompt is far from the generation point, and for a small model, distance matters. A rule that must survive belongs in the user turn, next to the data that fights it.&lt;/p&gt;

&lt;p&gt;But 0/21 on a 21-run sample is not a guarantee, so the boundary got a guard anyway. &lt;code&gt;offScriptExplanations&lt;/code&gt; checks the &lt;em&gt;writing system&lt;/em&gt; of every explanation in the response against the UI language's script. Deliberately dumb on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It's a script check, not language identification - no model call, no dependency, just Unicode ranges.&lt;/li&gt;
&lt;li&gt;It only fires when the UI and target scripts differ (Cyrillic UI vs Latin target). Same-script pairs are inert, because there a script check can't tell the languages apart, and guessing is how guards become bugs.&lt;/li&gt;
&lt;li&gt;It requires the &lt;strong&gt;whole response&lt;/strong&gt; to be off-script before acting. A quoted Finnish snippet inside a Russian explanation must never trip it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When it fires, the correction runs one repair retry with the violation spelled out. And if the retry is still wrong, the wrong-language answer is &lt;strong&gt;kept, not dropped&lt;/strong&gt; - a correction the user has to squint at beats no correction at all. Guards should degrade, not destroy.&lt;/p&gt;

&lt;h3&gt;
  
  
  The quiet third fight: the schema itself
&lt;/h3&gt;

&lt;p&gt;Every error the model reports lands in a closed set of six categories, enforced twice - a CHECK constraint in SQLite and validation at the parse boundary. Anything outside the set is rejected, and any fields the model invents are dropped on the floor.&lt;/p&gt;

&lt;p&gt;That last part turned out to be a free security property. The model output is the only untrusted input in the system, and the parser treats it accordingly: no tool calls to hijack, no extra fields to smuggle instructions through, a hard token cap. Prompt injection against this pipeline mostly has nowhere to go, not because the prompt says "ignore injections", but because the boundary only accepts the shape it expects.&lt;/p&gt;

&lt;h3&gt;
  
  
  The ladder, summarized
&lt;/h3&gt;

&lt;p&gt;What I now do, in order, when a cheap model breaks a rule:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Rewrite the rule with concrete names, not abstractions ("in Russian, never in Finnish" beats "in the UI language").&lt;/li&gt;
&lt;li&gt;Move it late in the system prompt.&lt;/li&gt;
&lt;li&gt;Repeat it as the last line of the user turn, after the data.&lt;/li&gt;
&lt;li&gt;Measure on real inputs - 20 runs tells you more than any prompt review.&lt;/li&gt;
&lt;li&gt;If it still leaks even rarely: stop prompting. Enforce it at the parse boundary, with a repair retry if the output is salvageable and a degrade path if it isn't.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Rungs 1-4 reduce the failure rate. Only rung 5 sets it to zero, and the rules that reach rung 5 are exactly the ones where "rarely" is unacceptable.&lt;/p&gt;

&lt;p&gt;The prompt is a request. The parser is a contract.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The app this comes from is &lt;a href="https://mening.app" rel="noopener noreferrer"&gt;mening.app&lt;/a&gt; - daily writing practice that remembers which mistakes you keep repeating. The memory side of it is written up in &lt;a href="https://medium.com/@lihachev/error-memory-under-the-hood-how-a-language-app-decides-youve-finally-beaten-a-mistake-56a432adb417" rel="noopener noreferrer"&gt;error memory under the hood&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>go</category>
      <category>promptengineering</category>
    </item>
  </channel>
</rss>
