Skip to main content

3 posts tagged with "LLM"

View all tags

DeepSeek/Qwen Structured Calls Succeed but Return Empty? Disable Thinking Before It Burns Your Token Budget

ยท 6 min read

While running batch LLM semantic validation over production data, all 2,449 calls "succeeded" โ€” yet every single result fell back to the default value, and the logs contained almost no failures.

Encountered this while building AI Ops โ€” LLM-powered analysis that surfaces market trends, user behavior, and sales insights to drive precise operations strategy. The product title optimization pipeline asks an LLM to semantically validate "keyword ร— product" pairs: one call per keyword batch, returning a tiny JSON judgment. This should be the simplest kind of LLM call โ€” yet on the first production run, all 2,449 pairs silently fell back, and the validation layer produced zero effective LLM judgments.

TL;DRโ€‹

Thinking-capable models like DeepSeek and Qwen share a single max_tokens budget between reasoning text and final content. If structured small-output calls don't explicitly disable thinking, the reasoning chain eats the entire budget on its own: finish_reason becomes length, content comes back empty โ€” and since the parse-retry loop only logs exceptions, the silent retries exhaust and degrade the whole batch without a single error. Two things to do: disable thinking explicitly for structured calls, and log finish_reason on parse failure instead of only catching exceptions.

The Symptomโ€‹

This minimal reproduction shows the whole process (requires pip install openai and a thinking-capable model):

import os
from openai import OpenAI

client = OpenAI(
api_key=os.environ["DEEPSEEK_API_KEY"],
base_url="https://api.deepseek.com",
)

resp = client.chat.completions.create(
model="deepseek-reasoner",
messages=[
{
"role": "user",
"content": (
'ๅˆคๆ–ญไธ‹้ข็š„ๅ…ณ้”ฎ่ฏๆ˜ฏๅฆ้€‚ๅˆๅ†™ๅ…ฅๅ•†ๅ“ๆ ‡้ข˜๏ผŒ'
'ๅช่ฟ”ๅ›ž JSON๏ผš{"suitable": true} ๆˆ– {"suitable": false}ใ€‚\n'
"ๅ…ณ้”ฎ่ฏ๏ผšsummer women dress\n"
"ๅ•†ๅ“๏ผšfloral midi dress for women"
),
}
],
max_tokens=2000, # reasoning and content share this budget
)

print("finish_reason:", resp.choices[0].finish_reason)
print("content:", repr(resp.choices[0].message.content))

Typical output with thinking enabled:

finish_reason: length
content: ''

Everything looks fine at the HTTP layer: no timeout, no 5xx, no SDK exception. If the outer layer is a "retry on parse failure, fall back after retries exhaust" loop, the logs end up with a single "retries exhausted" warning โ€” which reads exactly like an intermittent network problem.

Root Causeโ€‹

Thinking models don't budget reasoning separately. In DeepSeek, Qwen and similar models, reasoning content and final content share the same max_tokens ceiling โ€” there is no independent "reasoning budget" field.

Structured small-output calls tend to use small budgets. A boolean judgment is expected to produce a few dozen tokens, so max_tokens=2000 looks generous โ€” but the reasoning chain's length is completely uncontrolled. Once it consumes all 2,000 tokens, the model never gets a chance to emit the actual answer: finish_reason returns length, content is an empty string, yet the API returns 200 normally.

There's also an amplifier on the engineering side. An empty string is not valid JSON, but many retry loops only catch network and API exceptions, treating parse failure as "no result this round" and retrying silently. This kind of exception-swallowing silent failure is notoriously hard to diagnose inside retry loops: all N retries fail for the same root cause, yet the log shows only the final fallback warning โ€” easy to misread as network flakiness.

The Fixโ€‹

Step 1: Explicitly disable thinking for structured-output callsโ€‹

Boolean judgments, JSON extraction, and classification calls don't need multi-step reasoning. Disable thinking per provider:

def thinking_disabled_extra_body(provider: str) -> dict:
"""Structured small-output calls: disable thinking explicitly per provider."""
if provider == "deepseek":
return {"thinking": {"type": "disabled"}}
if provider == "qwen":
return {"enable_thinking": False}
return {}


resp = client.chat.completions.create(
model=model_name,
messages=messages,
max_tokens=2000,
extra_body=thinking_disabled_extra_body("deepseek"),
)

With thinking off, the entire 2,000-token budget goes to the JSON judgment itself. After the fix, rerunning the batch returned valid judgments for all 2,449 keywordร—product pairs โ€” before the fix, the whole batch silently degraded, leaving just 3 "retries exhausted" warnings in the logs.

Step 2: Don't let parse failures go silentโ€‹

Even with thinking disabled, turn "parse failure" into an evidence-bearing log line, so next time an empty output occurs โ€” for any reason โ€” a single log line locates it:

import json
import logging

logger = logging.getLogger(__name__)


def parse_judgment(resp) -> dict | None:
content = resp.choices[0].message.content
try:
return json.loads(content)
except (TypeError, json.JSONDecodeError):
# Key: log finish_reason and raw content, not just exceptions
logger.warning(
"LLM output parse failed: finish_reason=%s content=%r",
resp.choices[0].finish_reason,
content,
)
return None

When debugging LLM degradation, check finish_reason first: length means the output budget was exhausted (most likely by reasoning), while stop means normal completion. This is far more effective than scrolling exception logs.

If your returned JSON also passes through schema validation and you use Zod in a TypeScript project, watch out for this related pitfall: Zod schema validation silently dropping LLM output.

Heads up

The parameter to disable thinking is not standardized across providers: DeepSeek uses {"thinking": {"type": "disabled"}}, Qwen uses {"enable_thinking": False}, and OpenAI's o-series uses reasoning_effort-style parameters. Check each provider's docs before integrating โ€” don't assume the parameter is universal.

If a provider's thinking cannot be disabled, you must raise max_tokens based on measured reasoning length โ€” otherwise the same silent degradation will happen again.

FAQโ€‹

How do I disable thinking output in DeepSeek?โ€‹

On the OpenAI-compatible API, pass {"thinking": {"type": "disabled"}} via extra_body; Qwen uses {"enable_thinking": False}. Structured small-output calls like boolean judgments and JSON extraction don't need a reasoning chain โ€” turn it off by default so the entire budget goes to the actual answer.

Why does my DeepSeek API call succeed but return empty content?โ€‹

Thinking models share max_tokens between reasoning and content. When reasoning exhausts the budget, finish_reason returns length and content is empty โ€” with no exception thrown. Disable thinking or raise max_tokens based on measured reasoning length, and check finish_reason before digging through exception logs.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Every LLM Batch Validation Falls Back? The All-or-Nothing Trap in Capacity Gates

ยท 5 min read

The first production run of an LLM batch validation job finished green โ€” status success, no errors. But the output told another story: all 2,449 items to validate were tagged with the fallback marker, and the actual LLM call count was zero. A feature shipped as "semantic validation on by default" had never once run.

Encountered this while building AI Analytics โ€” an LLM-powered analytics platform that surfaces market trends, user behavior, and sales data; this job runs in the title-optimization stage of its data pipeline.

TL;DRโ€‹

The capacity gate is all-or-nothing: if the workload exceeds the cap, the entire batch degrades to fallback with zero LLM calls โ€” and the job still reports success. The trial cap was 400 while production actually had 2,449 keywordร—product pairs. Two lessons: calibrate capacity limits against measured production scale, and degrade at unit granularity (per group / queue / truncation) with abnormal fallback rates made observable.

Symptomsโ€‹

Stage 2 of the pipeline is LLM semantic validation, guarded by a capacity gate at the entrance:

def semantic_validate(pairs, cap=400):
if len(pairs) > cap:
# over the cap: degrade the whole batch, not a single LLM call
return [mark_overflow(p) for p in pairs]
return [llm_validate(p) for p in pairs]

First production run:

keywordร—product pairs: 2449/2449 all marked validation_mode='overflow'
actual LLM calls: 0
job status: success (no errors at all)

Judging by "did it finish", everything looks fine; only the distribution of the output column reveals the feature was disabled wholesale.

Root Causeโ€‹

Two problems stack up. The number: the trial cap was 400, but production scale โ€” 60 market keywords ร— same-category products plus 50 own-store keywords ร— products across 92 products โ€” pushed the pair count to 2,449. Off by an order of magnitude. The structure: the gate is all-or-nothing โ€” over the cap means the whole batch degrades. What was meant as a capacity constraint effectively became "over the limit = feature off", and the degradation landed silently in a data column: no exception, no log line.

This is the same family as DeepSeek thinking consuming the output budget and silently returning empty: the fallback logic digests the failure, and the surface always says success.

Solutionโ€‹

Step 1: Calibrate the cap against measured production scaleโ€‹

Count the real workload before launch; don't estimate:

# dry-run: measure the scale, zero LLM calls
python -c "from pipeline import build_pairs; print(len(build_pairs(shop='prod')))"

Measured 2,449 โ†’ set the cap to 3,000 (roughly 1.2-2x headroom), and confirm an oversized shop still has a degradation path instead of hitting a wall.

Step 2: Switch the granularity from "total" to "grouped"โ€‹

Call per product group so call counts grow linearly with product count instead of exploding with the keywordร—product product:

def semantic_validate(pairs, cap):
groups = group_by_product(pairs) # 92 products โ†’ ~92 calls/round
results = []
for g in groups:
if within_budget(g, cap): # per-group check, no wholesale give-up
results.extend(llm_validate(g))
else:
log.warning("capacity gate: group degraded",
extra={"size": len(g), "cap": cap})
results.extend([mark_overflow(p) for p in g])
return results

After calibration the third run showed 2,449/2,449 going through LLM validation; larger shops now degrade per group instead of losing everything.

Step 3: Make degradation observableโ€‹

Instrument the fallback path and alert on abnormal ratios (e.g. fallback rate > 50%). Degradation is a safety net, not a cover โ€” it should be seen, not silently absorb the over-limit condition for you.

Notes

  • Capacity-style parameters (caps, concurrency, batch size) must be calibrated against measured production scale before launch; small test-environment samples never reproduce production magnitudes.
  • All-or-nothing gates only fit "hard cost ceiling" scenarios and must come with explicit alerting; otherwise they are a silent kill switch for the feature.
  • Land degradation in a dedicated queryable column/metric (here: a validation_mode column), and during acceptance check the distribution before the correctness.
  • Another silent-failure family in LLM outputs comes from structural validation โ€” see Zod validates LLM output but fails silently? Don't use .strict().

FAQโ€‹

How should a fallback mechanism in an LLM pipeline be designed?โ€‹

Keep the granularity small โ€” degrade per item or per group instead of abandoning the batch; leave traces (marker columns, logs, metrics) and configure alerts. An all-or-nothing gate, once triggered, equals switching the feature off; it only fits hard-cost-ceiling scenarios with explicit alarms.

How do I set a capacity limit for LLM batch jobs?โ€‹

Don't guess. Run a dry-run on production-scale or proportionally sampled data to measure the actual workload, set the limit at 1.5-2x the measured value, and revisit it as the business grows. An estimate off by an order of magnitude is the standard setup for this incident.

How do I detect that an LLM job was silently degraded?โ€‹

The job status usually still says success. Inspect the output: count the share of fallback markers and check whether actual LLM calls match expectations. Alert on abnormal fallback rates (especially 100%) to turn silent failures into explicit signals.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Zod Validation of LLM Output Failing Silently? Drop the .strict()

ยท 7 min read

When you validate LLM tool_call / function call output with Zod, the model occasionally emits an extra field โ€” say you only defined amount / category, and it also fills in note โ€” and the whole validation fails, the action is silently dropped, and the user just gets "not recognized." In reality an entire tool_call was whole-rejected.

Encountered this while building Life โ€” a natural-language bookkeeping and wellness assistant where you just talk to record entries and the AI extracts amount, category, and account. When a user said "delete that coffee from yesterday," the model slipped an extra note: "coffee" into the delete locator, trying to locate by remark.

TL;DRโ€‹

Zod's .strict() means "this object must not contain any unknown keys โ€” one extra throws." That constraint fits validating clients you fully control, but LLM function call output is model-generated and inherently uncontrollable โ€” it fills in fields it "thinks should be there," especially when multiple tools share similar schemas. One irrelevant field kills the whole tool_call, validation returns null, and the action is silently lost. Fix: drop .strict() and use Zod's default strip (silently removes unknown keys) for tolerance, paired with safeParse as a fallback.

Symptomsโ€‹

The locator schema for delete/update defines a few known fields, tightened with .strict():

import { z } from "zod";

// โŒ dangerous: with .strict()
const LocatorSchema = z.object({
date: z.string().optional(),
category: z.string().optional(),
noteContains: z.string().optional(),
}).strict(); // โ† any unknown key throws

// parse the LLM's tool_call arguments
function parseToolCall(raw: unknown) {
const parsed = LocatorSchema.safeParse(raw);
if (!parsed.success) {
return null; // โ† the whole tool_call is dropped
}
return parsed.data;
}

The user says "delete that coffee from yesterday," and the model produces a reasonable but extra-fielded output:

{
"date": "yesterday",
"noteContains": "coffee",
"note": "coffee"
}

The model filled both noteContains (in schema) and note (out of schema, which it thought should exist). .strict() rejects the unknown key note outright, parseToolCall returns null, and the delete action is silently dropped โ€” the user gets "not recognized" when it was actually a whole-reject.

Root Causeโ€‹

.strict() changes Zod's policy on unknown keys, and LLM output naturally carries unknown keys.

Zod z.object() has three policies for unknown keys:

FormUnknown key behaviorSuited for
default (strip)silently removedLLM output, loose external input
.strict()throws (unknown key)client APIs you fully control
.passthrough()kept as-iswhen downstream needs unknown keys

.strict() is designed for "contract strictness" โ€” the server defines which fields exist, the client should supply only those, and anything extra is a breach. That logic holds for traditional APIs because the client is developer-written and can be held to the contract.

But LLM function calling flips the premise:

  1. The output comes from model generation, not a developer-written client. The model guesses what to fill based on the schema's description and examples; schemas reused across domains (e.g. a locator shared by budget / mood / todo) confuse it further, so it fills in fields it "thinks should be there."
  2. Wrong fields are the norm, not an exception. The model occasionally emitting an extra note or omitting an optional field is expected behavior in LLM apps and shouldn't be punished by failing the whole call.
  3. The failure is silently swallowed. After safeParse fails and returns null, the upstream can only vaguely say "not recognized," while the real cause (an unknown key) sits unseen in parsed.error.
LLM output { date, noteContains, note }
โ”‚
โ–ผ
.strict() hits unknown key "note"
โ”‚
โ–ผ
safeParse โ†’ { success: false }
โ”‚
โ–ผ
parseToolCall returns null (action dropped)
โ”‚
โ–ผ
user gets "not recognized" (actually a whole-reject)

Solutionโ€‹

1. Drop .strict(), use default strip for toleranceโ€‹

// โœ… recommended: no .strict(), Zod defaults to stripping unknown keys
const LocatorSchema = z.object({
date: z.string().optional(),
category: z.string().optional(),
noteContains: z.string().optional(),
});
// the extra "note" is silently removed; known fields parse normally

After dropping .strict(), "delete that coffee from yesterday" parses cleanly into { date, noteContains }; the extra note is stripped and the delete action runs correctly.

2. If unknown keys are useful, keep them with .passthrough()โ€‹

When the extra field actually carries semantics you want to use (e.g. the model filled note to express "locate by remark"), don't drop it โ€” keep it and decide how to consume it:

const LocatorSchema = z.object({
date: z.string().optional(),
category: z.string().optional(),
noteContains: z.string().optional(),
}).passthrough(); // keep unknown keys; parsed.data.note is still readable

Even better, promote it to a known field โ€” if the model keeps filling some unknown key, the schema is missing a capability slot, so add it (here noteContains was added after absorbing the "locate by remark" need).

3. Make failures observable โ€” don't silently return nullโ€‹

Regardless of policy, when safeParse fails, log the specific error instead of swallowing it into null:

function parseToolCall(raw: unknown) {
const parsed = LocatorSchema.safeParse(raw);
if (!parsed.success) {
// log Zod's concrete error (which key, what problem) for triage
logger.warn(
{ raw, issues: parsed.error.issues },
"locator parse failed"
);
return null;
}
return parsed.data;
}

Now when something goes wrong, the log has the full issues (including the unknown-key path), instead of an unactionable "not recognized."

After the fix, "delete that coffee from yesterday" โ†’ delete_record { locator: { noteContains: "coffee" } } parses correctly with no silent drop.

Notesโ€‹

Notes

  • .strict() fits validating "clients you control," not "model-generated output." Rule of thumb: if the data source is your own code, strict is fine; if it's model-generated, use default strip or passthrough.
  • strip loses unknown fields. If a field carries the model's intent (like note in the example), use .passthrough() to keep it, or promote it to a known field โ€” don't let the intent be silently deleted.
  • Always use safeParse, not parse. parse throws on failure and can break the entire tool dispatch chain; safeParse returns a result object so failure is controllable.
  • Design LLM tool schemas with tolerance in mind. Make fields .optional(), write clear descriptions, and provide few-shot examples; anticipate that the model will "over-fill / under-fill," and let the schema absorb it.

FAQโ€‹

Why does Zod .strict() make LLM output validation fail?โ€‹

.strict() requires an object to have no unknown keys โ€” one extra throws. LLM function call output is model-generated, guessing from the schema description, and routinely includes fields it thinks should be there (especially with cross-domain schemas). The moment an unknown key appears, .strict() fails the entire validation and drops the whole tool_call.

Should I use strict when validating LLM function calling output with Zod?โ€‹

Not recommended. .strict() suits validating clients you fully control (developer-written code can be held to a contract), but LLM output is uncontrollable and over/under-filling is the norm. Drop .strict() and use Zod's default strip (silently removes unknown keys) for better tolerance; if unknown keys carry semantics you want, use .passthrough() to keep them, or promote them to known fields.

Does Zod strip or throw on unknown fields by default?โ€‹

Default is strip โ€” it silently removes unknown keys without error. .strict() makes it throw on unknown keys; .passthrough() keeps them as-is. For uncontrollable output like LLM generations, prefer default strip or passthrough over .strict(), which kills the whole payload over a single irrelevant field.


CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me