Skip to main content

12 posts tagged with "Python"

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

Data Pipeline Snapshot Slots Misaligned? Empty Segments Collapse Positions

· 5 min read

Auditing a production task's decision snapshot, we found stage 5's feature data sitting in array slot 3 instead of the documented slot 4. Downstream consumers and the detail drawer read by "segment number minus one" — and were silently getting the previous stage's output.

Encountered this while building AI Analytics — an LLM-powered analytics platform that surfaces market trends, user behavior, and sales data; the snapshot is what the pipeline leaves behind for frontend rendering and post-hoc audit.

TL;DR​

The pipeline appends each stage's output into the snapshot array in execution order, and an if rows.empty: skip guard drops 0-row segments entirely, shifting every later segment forward. The static mapping "segment number − 1 = slot" breaks at will, and which segments are empty depends on runtime state — slots differ per run. Two fixes: resolve slots at read time by in-row keys (recommended), or keep placeholders for empty segments so slots stay constant.

Symptoms​

The assembly logic looks like this:

snapshot = {"features": [], "rule_output": []}
for seg in segments: # stages 1..5 run in order
df = execute_sql(seg.sql)
if not df.empty: # 0-row segments dropped here
snapshot["features"].append(df.to_dict("records"))

The design assumption was "stage 5 → slot 4". Production snapshot audit found stage 5's features in slot 3:

all segments populated:   ①→0  ②→1  ③→2  ④→3  ⑤→4   ✓ matches assumption
stage 4 empty, dropped: ①→0 ②→1 ③→2 ⑤→3 ✗ shifted
stages 3+4 empty: ①→0 ②→1 ⑤→2 ✗ shifted again

The same code version produces completely different slot layouts depending on shop and permissions — an empty whitelist table drops stage 4; a disabled feature flag drops stage 3. Slots drift with runtime state.

Root Cause​

Positional addressing collided with sparse assembly. The snapshot array is a runtime product of concatenating "segments that produced output" — a sparsely-filled collection compressed. Consumers hard-coding "segment − 1" implicitly assume every segment always yields at least one row. That assumption shatters in three routine situations: empty whitelist tables, disabled feature flags, naturally empty business data. 0-row segments are the norm, not the exception.

Deeper down, the emptiness guard itself (if not df.empty) is not wrong — wrong is the contract's implicit premise. The design doc says "slot = segment − 1" but nobody declared it as an explicit contract. Every position-addressing consumer inherits an unacknowledged, unmaintained assumption.

Solution​

Let every row carry a segment identifier key; consumers resolve positions at read time with no static mapping:

def locate_segment(features: list, seg_key: str) -> dict:
for row in features:
if seg_key in row: # row carries its own identity
return row
raise KeyError(f"segment '{seg_key}' missing in snapshot")

Slot drift becomes irrelevant — you look for "the segment whose keys look like this", not "element N". The one requirement: all consumers go through this single resolution entry point (write it into the processor docstring and the consumer contract: no hard-coded slots, ever).

Option B: keep placeholders for empty segments on the write side​

If downstream can't change yet, keep "slot = segment number" constant at assembly time:

snapshot["features"].append(
df.to_dict("records") if not df.empty else {"__empty__": True}
)

The cost: placeholder objects appear in the snapshot and every consumer must handle them. Fine as a transition; converge on Option A long-term.

Step 3: contract tests over multiple runtime states​

Build snapshot fixtures for "all populated / one empty / several empty" and assert consumers parse all three identically. Testing only the all-full scenario tests nothing.

Notes

  • Any positional mapping in a spec must explicitly declare the addressing scheme (by key / by ID) and note that hard-coded indices are forbidden; implicit assumptions get broken by some runtime state eventually.
  • Empty-skip guards are the most common source of compression — a sibling case of silent data loss is Airflow PostgresHook truncating multi-statement SQL to the first result: again "no error, quietly less data".
  • Before rolling out consumer changes, regression-test with all three runtime-state fixtures; validating only the all-populated state misses every misalignment path.

FAQ​

How do you handle schema drift in data engineering?​

Replace positional contracts with key-based ones: address snapshots, messages, and interfaces by field name or segment identifier, never array index. When upstream changes without notice (empty segments skipped, fields added or removed), key-addressed consumers at worst raise "not found" — they never silently read the wrong data.

What is the difference between schema drift and schema evolution?​

Evolution is explicitly managed versioning: add a field, cut a release, migrate consumers. Drift is passive: upstream changes and downstream quietly misaligns. The slot shifting in this post is textbook drift — nobody touched the contract; the data shape changed.

How do I detect this kind of slot misalignment in a pipeline?​

Two layers: contract tests covering multiple runtime states (all populated / one empty / several empty) asserting identical consumer parsing; and periodic production snapshot audits checking each slot's content against its declared segment. "Content and position disagree" is drift.

CCLEE

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

Work with me

Monitor Missed 38 Log Lines? Python's WARNING Is Not the Contract's warn

· 5 min read

Chasing a monitoring gap: server-monitor filters alert-grade logs by the level name warn, but 38 rows in the shared logs table carried level='warning'. The filter didn't match by one character — and in the monitor's eyes, those 38 rows did not exist.

Encountered this while building AI Analytics — an LLM-powered analytics platform that surfaces market trends, user behavior, and sales data; server-monitor is its alerting module, consuming one logs table written by four services.

TL;DR​

The cross-language logging contract defines lowercase warn/fatal; Python's stdlib record.levelname produces WARNING/CRITICAL — written raw or with a bare .lower() you get warning/critical, which contract-name filters never match. Two principles fix it: normalize at a single point in the write boundary (WARNING→warn, CRITICAL/FATAL→fatal) so no consumer ever has to juggle spellings; and write the contract doc as the intended implementation, not a snapshot of the current one — this drift survived so long precisely because the contract's Python column documented the buggy code.

Symptoms​

Four services write one logs table; the contract specifies level values: debug / info / warn / error / fatal. A reconciliation query:

SELECT service, level, count(*)
FROM logs
GROUP BY service, level ORDER BY 1, 2;

turns up spellings that don't exist in the contract:

 service    | level    | count
------------+----------+-------
ai-dag | warning | 21 ← not in the contract
rag-service| warning | 17 ← not in the contract
... | warn | ... ← the actual contract name

The monitor filters level = 'warn'; these 38 alert-grade rows silently vanish.

Root Cause​

Layer one is literal mismatch: Python's stdlib levels are DEBUG / INFO / WARNING / ERROR / CRITICAL — there is no WARN (a deprecated alias) and no FATAL. Both services pushed record.levelname into the table: one raw (uppercase WARNING), one lowercased (warning). Neither matches the contract's warn.

Layer two is the one worth losing sleep over: the contract document itself specified the wrong implementation. In the cross-service contract's field table, the Python services' level column literally read "record.levelname" and "record.levelname.lower()". The doc was describing reality instead of prescribing it — so the buggy implementations carried the contract's endorsement, and nobody questioned them. This is the same harm shape as try/except swallowing exceptions into silent failures: nothing crashes, things just quietly go missing — by the time anyone looks, dozens of alert rows were never seen.

Solution​

Step 1: Single-point mapping at the write boundary​

Each service defines one normalization function; every write path (formatter and DB sink) goes through it:

_LEVEL_NAME_MAP = {"WARNING": "warn", "CRITICAL": "fatal", "FATAL": "fatal"}

def normalize_level(levelname: str) -> str:
"""WARNING→warn, CRITICAL/FATAL→fatal, everything else lowercased."""
return _LEVEL_NAME_MAP.get(levelname.upper(), levelname.lower())
payload = {"level": normalize_level(record.levelname)}   # always emits a contract name

The keyword is "single point": the JSON formatter and the DB handler share one function, so the mapping changes in exactly one place and no second implementation can appear.

Step 2: Rewrite the contract doc as the intended implementation​

The field table's Python columns now read normalize_level(record.levelname), and a new "level name mapping" section documents the rules, the anti-patterns (no raw writes, no bare lowercasing), and each service's function entry point. A contract is a spec — not a snapshot of whatever happens to be deployed.

Step 3: Add a reconciliation query so drift is discoverable​

SELECT level, count(*) FROM logs
WHERE service IN ('ai-dag', 'rag-service')
GROUP BY level ORDER BY 2 DESC;

Any spelling besides warn is drift. This query belongs in routine inspection, turning "contract vs implementation" from a verbal promise into an assertable check.

Step 4: Clean up存量 (optional)​

New writes no longer produce off-contract names; handle the existing 38 rows as needed:

UPDATE logs SET level = 'warn' WHERE level = 'warning';

Small volumes can be left to age out; large ones, or anything feeding historical statistics, deserves the UPDATE.

Notes

  • Normalize at the write boundary; don't expect consumers to handle multiple spellings — the consumer list only grows (monitoring, alerting, BI, debug scripts), and every new consumer multiplies the compatibility burden.
  • Cover the non-standard levels in the map: CRITICAL→fatal, FATAL→fatal. Miss that and fatal-grade alerts leak past the monitor as critical.
  • Every "implementation" column in a contract doc is part of the spec: before writing one, ask whether it's how it should work or merely how it works today.
  • Keep cross-service logging contracts (level names, traceId, service names) in one maintained place that all services reference — not re-stated per service.

FAQ​

Why can't Python's WARNING be written straight into the logs table?​

The stdlib literals are WARNING/CRITICAL, while cross-language contracts define warn/fatal. Raw or lowercased values (warning/critical) are unknown levels in contract-land — every consumer filtering by contract names silently drops them. The Python side must map at the write boundary.

Are WARNING and WARN the same level?​

Same semantics, different literals. Python logging has no WARN level (deprecated alias; the emitted name is always WARNING) and no FATAL (it's CRITICAL). That's why lowercasing doesn't help — you need an explicit mapping: WARNING→warn, CRITICAL→fatal.

How do I detect that a logging contract and its implementation have drifted?​

Periodically reconcile by contract level names (GROUP BY level); any off-contract spelling is drift. More importantly, the contract doc should specify the intended implementation and name the mapping function — when the doc copies the current implementation, the bug gets an endorsement, which is exactly why this drift survived so long.

CCLEE

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

Work with me

Python json.dumps with default=str turns a set into a string? The hidden substring-match trap

· 7 min read

When you persist a dict containing a Python set with json.dumps(data, default=str) and later read it back to test membership with in, the result is silently wrong — no exception, but the in checks are completely off.

Encountered this while building AI Ops — AI-powered analytics that surfaces market trends, user behavior, and sales data to drive precise operational strategy. In a decision-replay feature for an analysis template, I needed to serialize a "missing months" set into a snapshot and read it back to decide whether a given month was missing. After replay, months that should have been flagged "missing" were silently judged "not missing" — with zero exceptions anywhere in the chain.

TL;DR​

default=str is not a universal escape hatch. It hands set to str(), storing a literal string like "{1, 2}" in the JSON instead of an array; the type is irreversible on reload, and an in check against it degrades to substring matching, silently returning wrong results. When set is involved, the correct approach is to convert to list before serializing and rebuild with set() on read.

The symptom​

This code fully reproduces the silent failure:

import json

# A dict containing a set — say, "months that still need backfill"
data = {"missing_months": {"3", "5", "12"}}

# Serialize with default=str (the common "just don't crash" shortcut)
serialized = json.dumps(data, default=str)
print(serialized)
# {"missing_months": "{'3', '5', '12'}"} ← a string, not an array!

# Read it back
back = json.loads(serialized)
value = back["missing_months"]
print(type(value)) # <class 'str'> ← no longer a set

# Silent bug: you meant to test membership in the "missing" set
print("1" in value) # True ← 1 is NOT in {3,5,12}, but "1" is a substring of "12"!
print("3" in value) # True ← correct by coincidence
print("9" in value) # False

"1" in value returns True, yet the original set {"3", "5", "12"} does not contain "1". No exception, no warning — the result is just quietly wrong. This kind of bug is especially dangerous in branches that act on the check (e.g. "is this month missing data? if so, backfill it").

Root cause​

Three layers:

Layer 1: set is not JSON serializable to begin with. JSON has only array (list) and object — no set type. A direct json.dumps({"x": {1, 2}}) raises TypeError: Object of type set is not JSON serializable.

Layer 2: default=str turns the error into silent corruption. The default parameter of json.dumps is called for objects that can't be serialized, and is expected to return a serializable value. When default=str, the object goes to str() — so a set becomes its Python literal form {'3', '5', '12'}, stored as a string in the JSON:

>>> json.dumps({"m": {"3", "5", "12"}}, default=str)
'{"m": "{\'3\', \'5\', \'12\'}"}'

The error is gone — at the cost of the type silently changing from set to str, with no signal that it happened.

Layer 3: in means different things for str vs set. This is the core of the silent bug. For set/list, x in s is a membership test; for str, x in s degrades to substring matching. The reloaded value is the string "{'3', '5', '12'}", so "1" in "{'3', '5', '12'}" tests whether the substring "1" appears — and since "12" contains "1", it returns True.

This is the same family of trap as Airflow PostgresHook silently dropping multi-statement SQL results: the most dangerous bugs don't throw — they silently return the wrong answer, leaving you no signal to investigate.

The fix​

Core principle: store only standard JSON types; rebuild set semantics on the read side.

The most direct and controllable approach — when you know where the set is, convert it to list in place:

import json

# Before serializing: set → list (a standard JSON array)
data = {"missing_months": list({"3", "5", "12"})}
serialized = json.dumps(data)
print(serialized)
# {"missing_months": ["3", "5", "12"]} ← a proper JSON array

# Rebuild the set after reading back
back = json.loads(serialized)
months = set(back["missing_months"])
print("1" in months) # False ✓
print("3" in months) # True ✓

The serialized result is a clean JSON array — portable, readable, and restorable.

Option 2: a custom default function (when data is complex)​

If the data structure is deep and you're not sure where a set might sneak in, use a default function dedicated to collection types — preserving semantics while still falling back for other non-standard types:

import json

def safe_default(obj):
# Collection types → list, kept as a standard JSON array
if isinstance(obj, (set, frozenset)):
return sorted(obj) # sort for stable, predictable output
# Only fall back to str for types that truly can't be represented
return str(obj)

data = {"missing_months": {"3", "5", "12"}, "created_at": some_datetime}
serialized = json.dumps(data, default=safe_default)
# {"missing_months": ["3", "5", "12"], "created_at": "..."}

back = json.loads(serialized)
months = set(back["missing_months"])
print("1" in months) # False ✓

Compared to a blind default=str, this function handles "types you need to preserve" (collections) explicitly and only falls back to str for genuinely unrepresentable types — minimizing silent risk.

Caveats

  • default=str is "silent", not "safe": it removes the error but flattens set/tuple/datetime/custom objects into strings irreversibly. Any operation that depends on the original type after reload (in membership, arithmetic, comparison) can misbehave.
  • tuple has the same problem: str((1, 2)) is "(1, 2)", and in against it also degrades to substring matching. Handle collection-like containers the same way: serialize as list.
  • Cross-process / cross-language portability is the litmus test: if this JSON will be read by Node.js, Go, etc., the "{1, 2}" produced by default=str is just a plain string there — not even a valid Python literal — and is nearly impossible to restore. Stick to standard JSON types for portability.
  • Convert at the source when possible: rather than patching with default after the fact, store collection semantics as list when you build the data structure, keeping set out of the serialization pipeline entirely.

FAQ​

How do I convert a Python set to JSON?​

A set has no native JSON type, so json.dumps raises TypeError. Convert it with list(set) before serializing to store a standard JSON array, then rebuild with set() when reading it back. This avoids the error and fully restores the set semantics, across languages too.

How do I fix "Object of type set is not JSON serializable" in json.dumps?​

The root cause is that sets aren't JSON serializable. The safe fix is to convert the set to a list before dumping, or pass a default function that returns list(obj) for isinstance(obj, (set, frozenset)). Avoid default=str — it doesn't crash, but it stores the set as a string, so the type can't be restored on read.

Why does in return wrong results after serializing a set with default=str?​

default=str passes the set to str(), storing the literal string '{1, 2}' in JSON. On reload the value is a str, not a set, so x in s degrades from membership testing to substring matching — e.g. "1" in "{'3','5','12'}" returns True because "12" contains the character "1", even though the original set doesn't contain "1". The fix is to serialize as list and rebuild with set() on read.

CCLEE

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

Work with me

Python task marked failed but no error? try/except swallowed the exception

· 5 min read

Debugging a silent failure where a document sync task marked everything failed in a RAG knowledge base project — full writeup below.

TL;DR​

A shared method was refactored with a new parameter signature, but one caller was missed. The caller passed arguments under the old contract and threw TypeError — except the call sat inside a try/except that quietly funneled the exception into a failed counter. No crash, no ERROR in the logs, just a number ticking up. These "silent failures" are the hardest bugs to track down. Two fixes: grep all callers after a signature refactor; and make except blocks log or re-raise, never swallow silently.

Build a Custom MCP Toolkit with Python FastMCP to Connect Any AI Model

· 8 min read

While building AI Agent systems for clients, we found that different tasks require vastly different model capabilities and costs: vision models for image analysis, lightweight models for text completion, and local models for internal data queries. MCP (Model Context Protocol) turns each capability into an independent tool that AI clients invoke on demand.

TL;DR​

Build a custom MCP Server in 30 minutes with Python FastMCP, connecting any OpenAI-compatible API based on scenario and cost. This article demonstrates the full workflow using the Doubao vision model, with extension templates for text generation, image generation, TTS, and more.

Unify Multiple Search APIs with Abstract Class, Return Errors Instead of Raising

· 4 min read

Encountered this issue while building an AI Agent platform for a client: needed to support multiple search providers (Tavily, Serper, Brave, Bing) while ensuring tool call failures don't interrupt the Agent's conversation flow.

TL;DR​

  1. Define SearchProvider abstract base class + SearchResult data model for unified interface and output
  2. Each provider inherits the base class, implements search() method with field mapping
  3. Key design: Return SearchResult with error info on failure, never raise exceptions