Milo Bergstrom

The error said the model returned empty content. I assumed the response stream had broken. It hadn't.


What I Was Chasing (Wrong Direction)

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

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.

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. finish_reason: "length". Zero tokens in the content field.

I'd been chasing the wrong layer entirely.


What Was Actually Happening

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 reasoning_content, not content.

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 content came back as "" — legitimately empty, not a transport error. The reasoning_content 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.

The misleading part: my orchestration wrapper saw content: "", threw "model returned empty content", 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.

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


The Fix

Two parts.

First: raise the token budget. The fix wasn't a clever parameter split — it was simply that maxTokens 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.

Second: make the error tell the truth. The more durable fix was changing what the error actually says. I added a reasoningOnly flag to the ChatResult type:

reasoningOnly: !content.trim() && hasReasoning

Enter fullscreen mode Exit fullscreen mode

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

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 reasoningOnly 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.

The commit is 193aba6, changes in packages/llm-client/src/index.ts and produce.ts.


What This Doesn't Cover

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 reasoningOnly detection still works for classification, but you can't fix what you can't configure. That's a separate problem.


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