The Step That Crashed and Reported Success: Why AI Pipelines Fail Silently
The job finished. It reported success. The dashboard was green. And the answer it produced was quietly, measurably worse than the one it was supposed to produce — because the step that was meant to do the careful work had thrown an exception, gotten swallowed, and been replaced by a cruder backup that nobody was watching.
Nothing errored. Nothing paged. The only evidence was a single log line you had to already know to grep for. This is the most dangerous failure mode in production AI, and it has almost nothing to do with the model.
Here is the post-mortem, and the rule we now apply to every fallback we ship.
The symptom
We run an AI system that reads documents and produces judgments about them — scores, really, with reasons attached. There were two ways to produce a score. A precise path: expensive, careful, breaks the input into pieces and evaluates each one. And a coarse path: a cheaper, older method that produces a single blunt judgment when the precise one can't run.
The intended design was reasonable. Try the precise path. If there's genuinely nothing for it to work with, fall back to the coarse one. A degraded score beats no score.
The symptom was that some outputs were subtly wrong — lower quality than the precise path should ever produce — with no failures anywhere to explain it. Every job in the batch was marked complete. Latency looked normal. Error rates were flat. If you only watched the things dashboards usually watch, the system was perfectly healthy. It was also, some fraction of the time, silently handing back the cheap answer while claiming it had done the expensive one.
The investigation
Because nothing had failed, there was nothing to trace. That's the trap: your entire debugging toolkit is built around errors, and a silent downgrade produces none.
We eventually found it by comparing outputs that should have been similar and weren't, then working backward to the code that produced them. The precise scorer was wrapped, in spirit, like this:
def score_precise(item) -> Score | None:
"""Best-effort. Never raises."""
try:
requirements = load_requirements(item)
if not requirements:
return None # nothing to score on — fall back to coarse
result = expensive_evaluation(item, requirements)
if result.inconclusive:
return None # nothing resolved — fall back to coarse
return result.score
except Exception: # noqa — "never break the batch"
logger.exception("precise scoring failed -> coarse fallback")
return None # something broke — fall back to coarseRead the three return None statements. They mean three completely different things:
- There is legitimately nothing here to do. (A normal, expected skip.)
- We tried and the input was genuinely inconclusive. (Also normal.)
- Our code threw an exception. (A bug. An outage. A real failure.)
And the caller could not tell them apart, because they all came back as the same value:
score = score_precise(item) or score_coarse(item) # None -> coarse, three waysNone was overloaded. Two of its meanings were routine and safe to ignore. The third was a fire. By collapsing all three into one return value, we had built a machine that launders failures into routine skips. The except Exception was the accelerant — a catch-all with a return None will convert any bug in that entire block, forever, into a quiet downgrade.
The insight that generalizes: success is a status you have to earn
The comment on that function — "Best-effort. Never raises." — is the whole bug in three words. "Never raises" sounds like robustness. In an AI pipeline it usually means "failures here are invisible."
Traditional systems fail closed and loud: a null pointer, a 500, a stack trace. You know. AI pipelines are built to fail open — to degrade, to fall back, to always produce something, because "something" feels better than an error to a user. That instinct is right at the edge of the system and catastrophic in the middle of it. Every internal fallback is a place where quality can leak out while the "success" bit stays set.
The failure was never the model being dumb. The model wasn't even involved in the bug. The failure was our system being unable to distinguish "this worked, degraded, on purpose" from "this broke, and we hid it."
The fix
Three changes, in order of importance.
1. Give failure its own channel. A legitimate skip and a caught exception must never share a return value. The routine cases can still return the "fall back" sentinel. A real exception gets a different path — re-raised, or returned as an explicit error result the caller has to handle on purpose.
def score_precise(item) -> Score | Skip:
requirements = load_requirements(item)
if not requirements:
return Skip(reason="no_requirements") # expected, benign
result = expensive_evaluation(item, requirements)
if result.inconclusive:
return Skip(reason="inconclusive") # expected, benign
return result.score
# No catch-all. If expensive_evaluation throws, it PROPAGATES.The catch-all is gone. If the precise path genuinely can't run, it says so, specifically. If it breaks, the exception travels up to a layer that decides — deliberately, in one place — whether this unit of work should degrade or fail. That decision is no longer smeared across a dozen except blocks.
2. Alarm on every fallback — and separate the reasons. Taking the coarse path is now a counted event, tagged with why. fallback{reason="no_requirements"} climbing is fine. fallback{reason="exception"} climbing at all is a page. Before, the fallback rate was invisible; now the shape of it is a first-class signal. The single most useful metric in an AI pipeline is often "how often are we quietly not doing the thing we think we're doing?"
3. Question whether the silent fallback should exist at all. We eventually deleted the coarse path entirely. Once its failures were loud, we could see it was firing far more than anyone believed, and that a wrong-but-confident cheap score was doing more damage than an honest "we couldn't score this" would have. The safest fallback, more often than teams expect, is no fallback — a loud, honest failure of that one item, and a system that keeps going around it.
Why this matters if you are building with LLMs
None of this needed a better model. It needed the system to be honest about its own health.
And it gets worse as you get more agentic, not better. A single LLM call with a try/except around it is one place to hide a failure. An agent with ten tool calls, each wrapped "so it never breaks the run," is ten places where the output silently gets worse while the final status still says done. Multi-step systems multiply the surfaces where quality leaks without a trace. If every step is best-effort, the whole pipeline is best-effort, and nobody decided that on purpose.
The transferable rules:
- "Never raises" is not a feature. A catch-all that returns a fallback value converts every future bug in that block into a silent downgrade. Catch specific, expected exceptions; let the rest propagate.
- Don't overload one return value with "fine" and "broken." A benign skip and a real failure must be distinguishable to the caller, or the caller will treat your outage as normal.
- Instrument the fallback, not just the error. Count every degraded path and tag it with a reason. A rising exception-driven fallback rate is an incident, even when nothing throws all the way up.
- The safest fallback is often no fallback. A loud failure on one item beats a confident wrong answer shipped as success. Failing that unit of work is usually cheaper than trusting a silent downgrade.
A crash tells you where it hurts. A silent fallback lets the wound stay open and calls it healthy. Go read your except blocks — the dangerous one is the ready one that returns a slightly worse answer and moves on.
We build and debug AI systems that have to work in production — not just in the demo. If your pipeline reports success while quietly degrading, book a strategy call. No pitch deck, no sales pressure — just a conversation about what is breaking.