Part VI · Chapter 10 · Models in Production · lesson 5
Quality dropped for six weeks and nothing reported anything
14 min
Builds on It was broken for six hours and no alert fired
Part VI · Chapter 10 · Models in Production · lesson 5
14 min
Builds on It was broken for six hours and no alert fired
Nobody filed a bug. The dashboards are flat — latency unchanged, error rate flat at zero, cost per call steady, every test green, no alert in the channel. Six weeks after a change nobody connected to it, an account manager forwards a customer email saying the assistant has become useless. You go looking, find the decline began on the day of a routine dependency upgrade, and discover that every signal you own was structurally incapable of seeing it.
Predict
Case file
Composite, not a real person
A routine dependency upgrade in the ingestion pipeline, shipped on a Tuesday with eleven other version bumps in the same pull request. The upgrade changed the default behaviour of the text splitter: where it had previously broken documents on heading boundaries, it now broke them at a fixed character count, cutting through sentences and separating headings from the text beneath them.
The splitter was pinned to its previous behaviour and the whole corpus re-chunked overnight, which restored answer quality within a day. The ticket rate took three weeks to return to its previous level, partly because the campaign cohort was genuinely larger and partly because customers who had been given useless answers had stopped asking. The team's post-incident work was entirely instrumentation: an in-product signal on whether the answer was used, a weekly window comparison on it, and a distribution check on chunk lengths at ingestion time.
The failure had no error. Retrieval returned chunks. The model answered from them. The response was well formed, the status was a success, the answer was fluent. Every layer did exactly what it was built to do with worse input.
The eval set was a fixed sample of a moving distribution. It was not wrong and it was not neglected. It was asking questions about articles that had not changed, in a system where only changed articles were affected, which is the most ordinary form of this problem: an offline set measures the inputs you thought of at the time you thought of them.
Non-determinism destroyed the one report that came in. An engineer saw it in week four and was asked to reproduce it. Replay produced a different answer, and everybody in the thread read that as evidence against rather than as a property of the system. This is the cost of the thing with no lesson of its own.
A plausible alternative explanation arrived at the same time. The campaign genuinely did raise signups and ticket volume. A rising ticket rate had an innocent owner, and a coincidence that explains your symptom is more expensive than no explanation at all.
One in-product signal, chosen because the product already does it. Whether the user copied the answer, edited the draft before sending, clicked the citation, rated it, escalated to a human anyway, or rephrased and asked again. Pick whichever the interface already makes someone do, record it with the request identifier, and you have a quality measurement on every real input at no cost in model calls. This is the instrument that cannot go stale, because its sample is whatever arrived.
A refusal and abstention rate. The share of answers that say some version of "the documentation does not cover this". It is cheap, it is a single string match, and it is the earliest moving number in most retrieval declines — when retrieval gets worse, a well-built feature declines to answer more often before it starts answering badly.
Distribution checks on what goes into the prompt, not only on what comes out. Chunk length, number of chunks retrieved, retrieval score of the top result, prompt token count, share of answers citing nothing. In this case a histogram of chunk lengths would have changed shape on the Tuesday. These are cheap, they are ordinary metrics, and they catch cause rather than effect — at the price of only catching causes somebody anticipated.
The nightly eval run, with the set refreshed on a schedule. Keep it, plot it, and put a recurring calendar item on adding recent real inputs to it. An eval set with no refresh policy has a half-life, and the half-life is shorter than anybody expects.
Alert on a window comparison, never on a threshold. Compare the last seven days against the trailing thirty, on each of the signals above, and raise something when a signal moves by more than its own normal week-to-week spread. A threshold on a noisy quality number is either muted within a month or never fires at all. A trailing comparison survives the baseline drifting, which it will.
Every other alert in this subject is a threshold, because every other signal has a meaning in absolute terms. Error rate above some small figure is bad at any point in history. Latency above the budget is bad because the budget is a promise.
Quality signals have no such anchor. An acceptance rate is a number about your users, your prompt, your corpus and the questions people happen to be asking this month, and all four move for reasons that are nobody's fault. There is no level that is correct, so a threshold has to be set from the recent past — at which point you have reinvented the window comparison badly, with the baseline frozen at whatever it was on the day somebody configured it.
Compare windows instead. Last week against the trailing month, with a band derived from the signal's own variability over the preceding quarter. The property that matters is not that the number is low but that it fell, and that it stayed fallen — a one-day dip is Tuesday, and a two-week slope is this lesson.
The cost is detection time. A seven-day window cannot tell you anything in a day, and a sharp degradation will reach customers before this fires. So run a much shorter window alongside it for cliffs, and accept that the instrument for slopes is inherently slow. Six weeks became one is the win, not six weeks became an hour.
Check
It stops where a deterministic downstream check validates the output.
A feature whose answer is parsed into a schema, resolved against a database, executed as a query, or compared with a value that a non-model path also computes has an oracle. A decline in that feature produces parse failures, lookup misses, mismatches or rejections, which are errors — and errors already have alerting, dashboards and an on-call rotation pointed at them. The whole problem in this lesson exists because fluent prose has no oracle.
So the strongest version of this work is not better monitoring. It is arranging for as much of the output as possible to be checkable, and then letting your existing alerting cover the checkable part. A structured field that must resolve to a real identifier, a citation that must match a document in the context, a total that must agree with a figure computed in code — each one converts a silent decline into a loud failure.
That arrangement is not always available, and it is never available for the whole output. The part of the answer that is prose a human reads will always be the part with no oracle, and that part needs an in-product signal and a window comparison for as long as the feature exists. Know which part of your output is which, because the monitoring for the two is entirely different and teams routinely build one and assume it covered both.