A normal program that breaks throws an exception. An LLM pipeline that breaks writes you a paragraph explaining how well it went.
You know the shape of this problem even if you haven't named it. Something worked in testing. It shipped. Six weeks later a number stops making sense, and nobody can say when it started going wrong — because nothing ever failed. There were no errors. The logs are full of successes.
This isn't bad luck or sloppy engineering. It's structural. The failure surface of a language model is plausible output, and plausible output passes every check you would normally write. Type checks pass. Schema validation passes. The string is the right length, the JSON parses, the number has the right number of digits. Your pipeline has no way to tell the difference between a correct answer and a confident wrong one, so it records both as success.
Below are five of these failure modes: what happens, why it stays hidden, and how to catch it. Then a fifteen-minute audit you can run against your own pipeline today.
The principle underneath all of them
A step has not succeeded because it did not raise. It has succeeded when something independent confirms the intended effect happened.
Nearly every failure in this article is a violation of that one sentence. There are three levels of knowing an operation worked, and most systems sit at the second while believing they're at the third:
| Level | What you have | Trustworthy? |
|---|---|---|
| Self-report | The step says it worked | No — this is the failure mode |
| No exception | Nothing raised | No — absence of error isn't evidence of effect |
| Verified | A separate read confirms the effect | Yes. This is the bar. |
Hold onto the distinction between didn't crash and verified. It's the whole thing.
1. Empty-success
Hides for weeks.
An extraction step gets an input it can't handle — an unusual layout, a truncated document, a language it wasn't prompted for — and returns an empty structure instead of raising. {}. []. An empty string.
Downstream code treats "no data" as "no data found", which is a completely legitimate result. So the pipeline completes, the batch is marked done, and the record count just quietly drops.
Why it hides: the shape is valid. If you're validating with a schema, an empty dict often satisfies it. Nothing is malformed. There's simply less than there should be, and nobody is counting.
The fix: assert on content, not shape. If a field is required for the record to be worth having, an empty value is a failure — not a result.
# Wrong — shape check only. `{}` sails straight through.
assert isinstance(result, dict)
# Right — the fields that make the record useful must be present
REQUIRED = ("name", "email")
missing = [k for k in REQUIRED if not result.get(k)]
if missing:
raise ExtractionFailure(f"empty required fields: {missing}")
Note not result.get(k) rather than k not in result. A present-but-empty field is the more common case and the harder one to see — {"name": ""} passes a membership check without complaint.
2. The finish_reason nobody reads
One-line fix.
Your output hits max_tokens. You get a clean-looking partial result: a JSON object missing its last two fields, a list missing its tail, a summary that stops before the conclusion.
The API told you this happened. It's right there in finish_reason, and almost nobody checks it.
Why it hides: the prefix is perfectly valid. Truncated JSON often still parses if the truncation landed after a closing brace. And short outputs look like short inputs — there's nothing obviously wrong with a brief answer.
The fix:
if response.choices[0].finish_reason != "stop":
raise TruncatedOutput(f"finish_reason={response.choices[0].finish_reason}")
That's the entire fix. Anthropic-shaped responses use stop_reason with end_turn as the good value; same idea.
One refinement worth making: if finish_reason is absent entirely — a proxy stripped it, a wrapper didn't forward it — treat that as a failure too. You cannot confirm the generation completed, so don't assume it did. Fail closed.
3. The swallowed exception
Most common bug in this article.
# The most expensive four lines in AI engineering
try:
result = call_model(prompt)
except Exception as e:
logger.warning(f"call failed: {e}")
result = None # pipeline continues, reports success
Here's what makes this one interesting: it was written deliberately, by a competent engineer, for a good reason. You have a batch of 5,000 records and you don't want one malformed input to kill a forty-minute job. So you catch, log, and continue.
The intent is sound. The effect is that failure becomes invisible. The warning goes into a log nobody reads, the job reports success, and you have holes in your data that no alert will ever mention.
The fix: continuing past a failure is fine. Continuing past a failure while reporting success is not. Count them, surface them, and fail the run past a threshold you chose on purpose.
failures = []
for item in items:
try:
results.append(call_model(item))
except Exception as e:
failures.append((item.id, str(e)))
if failures:
report_failures(failures) # visible, not buried in a log
if len(failures) > len(items) * 0.05: # explicit tolerance
raise BatchFailure(f"{len(failures)}/{len(items)} failed")
The tolerance is the key part. Accepting a 5% failure rate is a legitimate engineering decision. Accepting an unknown failure rate because the number was never computed is not the same thing, even though the code looks similar.
4. Auth failure as empty result
Hides for months.
A token expires. The API returns 401. Somewhere in your stack a wrapper converts any non-200 response into an empty list.
Your pipeline now reports "no new records." It will report that every single run, indefinitely, with total confidence.
Why it hides: this is the most durable failure mode I know of, because "no new data" is a completely unremarkable outcome. Quiet week? Slow season? Nobody investigates a zero. I've seen this run for months.
The fix: empty and unavailable are different states and must never collapse into the same value. If you can't determine the answer, return something that is neither empty nor zero, and make callers handle it explicitly.
# Wrong — 'no records' and 'couldn't ask' become identical
def fetch(status):
return [] if status != 200 else rows
# Right — unavailable is its own state
class Unknown:
def __bool__(self): return False # falsy, so guard clauses still work
def __eq__(self, o): return o is self # but never equal to [] or 0
UNKNOWN = Unknown()
def fetch(status):
if status != 200:
return UNKNOWN
return rows
The __bool__ returning False matters — existing if not result: checks keep working. The __eq__ matters more: UNKNOWN != [] means any code that specifically tests for emptiness is forced to handle the unknown case rather than silently absorbing it.
5. Self-reported completion
The agent-specific one.
This is where multi-step agents fail in ways single calls don't.
Your agent's final message says: "I've successfully saved all 12 records to the database." Your orchestrator sees a confident completion statement and marks the task done.
The database has zero rows.
Why it hides: the agent is the least reliable available witness to its own success, and it's also the most articulate. It will describe what it intended to do in the past tense, fluently, whether or not it happened. Related: agents narrate tool calls they never made. The transcript reads as though a search ran; the execution log is empty.
The fix: the completion signal has to come from outside the agent. For each task, define what observable state proves it's done — a row exists, a file is on disk, an API read returns the new value — and check that instead.
# Wrong — narration as evidence
if "task complete" in agent_output.lower():
mark_done()
# Right — independent read of the world
if db.count("records") == expected:
mark_done()
And if the verifier itself throws — the database is unreachable, the API times out — that is not success. It's unconfirmed. Fail closed, every time.
The same logic applies to asking a model how confident it is. That number is generated text, not a calibrated estimate, and it correlates poorly with correctness. Treat self-reported confidence as exactly zero evidence and derive confidence from checks that actually ran:
checks = {
"grounded_in_source": verify_grounded(value, source),
"schema_valid": validate(value),
"cross_check_agrees": second_source(value),
}
confidence = sum(checks.values()) / len(checks)
With no checks, confidence is zero — not an optimistic default.
The fifteen-minute audit
Run these against your own pipeline now. Each one takes a minute or two.
- Feed it malformed input — truncated JSON, an empty string, the wrong content type. Does it report success?
- Revoke a credential and run it. Does it say "no new data"?
-
Read every
exceptblock. Can any of them produce a success status? - Read every retry decorator. What does it return when it gives up? If it returns a default instead of re-raising, that's a silent failure by construction.
-
Grep for
finish_reason. If it's absent, you have undetected truncation. -
Set
max_tokensto 10 and run. Does anything notice? - Reconcile one batch by hand. Items in, versus items actually processed.
- Ask what proves "done" for your main task. If the answer is "the step returned", that's self-report.
- Break it and wait. Does anyone find out without reading logs?
- Check what feeds your dashboard. Self-report, or an independent read?
Any "yes, it reported success" is a live silent failure in production right now.
Two things worth internalising
A permissive fallback on an error path converts a loud failure into a silent one. This is the single most common way well-intentioned defensive code makes things worse. except: data = best_effort_parse(raw) feels robust and is strictly worse than crashing, because now you have wrong data and no signal.
Alert on the absence of success, not just on errors. A cron job that stops running produces no errors — it produces nothing, which is indistinguishable from a system at rest. Similarly, "alert if error rate > 5%" never fires when the pipeline processes zero items: zero errors out of zero attempts is a 0% error rate. Watch throughput too.
This covers 5 failure modes. I catalogued 19 across output, control flow, agents, and observability — each with a runnable test, plus a dependency-free guards.py and 75 tests demonstrating every mode failing silently and then being caught. It's $99: The Silent Failure Atlas. The five above are the ones I'd fix first regardless of whether you buy anything.
Top comments (0)