<?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: Milo Bergstrom</title>
    <description>The latest articles on DEV Community by Milo Bergstrom (@emmalane).</description>
    <link>https://dev.to/emmalane</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%2F4000974%2Fe4d22246-d09c-4c49-a1d5-93db08204c8b.png</url>
      <title>DEV Community: Milo Bergstrom</title>
      <link>https://dev.to/emmalane</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/emmalane"/>
    <language>en</language>
    <item>
      <title>`finish_reason=length` Returned Empty Content — and the Error Message Lied to Me</title>
      <dc:creator>Milo Bergstrom</dc:creator>
      <pubDate>Thu, 30 Jul 2026 01:26:11 +0000</pubDate>
      <link>https://dev.to/emmalane/finishreasonlength-returned-empty-content-and-the-error-message-lied-to-me-168n</link>
      <guid>https://dev.to/emmalane/finishreasonlength-returned-empty-content-and-the-error-message-lied-to-me-168n</guid>
      <description>&lt;p&gt;The error said the model returned empty content. I assumed the response stream had broken. It hadn't.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Was Chasing (Wrong Direction)
&lt;/h2&gt;

&lt;p&gt;My multi-agent content pipeline hit a wall: one of the generation steps was throwing &lt;code&gt;"model returned empty content"&lt;/code&gt; and the whole run was aborting. The message pointed at the output layer, so I started there.&lt;/p&gt;

&lt;p&gt;I spent a few rounds instrumenting the streaming path — checking whether the SSE connection was dropping mid-response, whether the provider was rate-limiting and silently closing the socket, whether my deserialization was eating bytes. The logs looked clean every time. Request went out, response came back, full round-trip, no timeout. The only thing missing was actual content in the assistant turn.&lt;/p&gt;

&lt;p&gt;I then suspected a provider-side issue: maybe the endpoint was returning a malformed chunk that my client was silently discarding. I added more logging at the HTTP layer. Still nothing. The request was completing successfully. &lt;code&gt;finish_reason: "length"&lt;/code&gt;. Zero tokens in the content field.&lt;/p&gt;

&lt;p&gt;I'd been chasing the wrong layer entirely.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Was Actually Happening
&lt;/h2&gt;

&lt;p&gt;The model I was calling — deepseek-v4 over an OpenAI-compatible channel — is a reasoning model. It has an internal chain-of-thought that runs before it writes visible output. That reasoning lives in &lt;code&gt;reasoning_content&lt;/code&gt;, not &lt;code&gt;content&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The token budget I'd set was being consumed entirely by the reasoning chain. By the time the model finished thinking, there was nothing left for the actual response. So &lt;code&gt;content&lt;/code&gt; came back as &lt;code&gt;""&lt;/code&gt; — legitimately empty, not a transport error. The &lt;code&gt;reasoning_content&lt;/code&gt; field had plenty of text. The model had done work. It just ran out of budget before it could write a single token of visible output.&lt;/p&gt;

&lt;p&gt;The misleading part: my orchestration wrapper saw &lt;code&gt;content: ""&lt;/code&gt;, threw &lt;code&gt;"model returned empty content"&lt;/code&gt;, and that error looked exactly like a network fault. Nothing in the error message mentioned token budgets or reasoning. It just said empty. So I went looking for why the content was empty at the transport layer, when the real answer was sitting one level up in the API response the whole time.&lt;/p&gt;

&lt;p&gt;The signal was there if I'd looked at the raw response first. &lt;code&gt;finish_reason: "length"&lt;/code&gt; combined with a non-empty &lt;code&gt;reasoning_content&lt;/code&gt; and an empty &lt;code&gt;content&lt;/code&gt; is a specific fingerprint. It doesn't mean the stream dropped. It means the model thought itself into a corner.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Two parts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First: raise the token budget.&lt;/strong&gt; The fix wasn't a clever parameter split — it was simply that &lt;code&gt;maxTokens&lt;/code&gt; was too low for a reasoning model on a complex task. Reasoning models burn tokens differently than standard chat models. A budget that's fine for a non-reasoning model can be entirely consumed by chain-of-thought before a single output token gets written.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second: make the error tell the truth.&lt;/strong&gt; The more durable fix was changing what the error actually says. I added a &lt;code&gt;reasoningOnly&lt;/code&gt; flag to the &lt;code&gt;ChatResult&lt;/code&gt; type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;reasoningOnly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;hasReasoning&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When that flag is true, the error now says something like &lt;code&gt;"reasoning consumed full token budget — no output generated"&lt;/code&gt; instead of the generic &lt;code&gt;"model returned empty content"&lt;/code&gt;. Same underlying condition, completely different diagnostic direction.&lt;/p&gt;

&lt;p&gt;The fallback chain also changed. The second tier used to be "swap to a different model." Now it's "double the budget and retry" — because if &lt;code&gt;reasoningOnly&lt;/code&gt; is true, a model swap doesn't help. You'd hit the same wall with a different model family if the budget is still too small.&lt;/p&gt;

&lt;p&gt;The commit is &lt;code&gt;193aba6&lt;/code&gt;, changes in &lt;code&gt;packages/llm-client/src/index.ts&lt;/code&gt; and &lt;code&gt;produce.ts&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Doesn't Cover
&lt;/h2&gt;

&lt;p&gt;This fix holds when you control the token budget directly in the API call. If you're routing through a proxy or a managed endpoint that doesn't expose budget parameters — or if your orchestration layer abstracts them away — you need a different gate. The &lt;code&gt;reasoningOnly&lt;/code&gt; detection still works for classification, but you can't fix what you can't configure. That's a separate problem.&lt;/p&gt;




&lt;p&gt;The hours I lost weren't really about the bug. They were about the error message. &lt;code&gt;"model returned empty content"&lt;/code&gt; is technically accurate and completely useless. It describes the symptom at the wrong layer. The moment I looked at the raw &lt;code&gt;finish_reason&lt;/code&gt; and both content fields together, the cause was obvious. The error just never pointed me there.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>debugging</category>
      <category>typescript</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
