Of 517 incident records, 46 titles carry the word “still”. Every step of each fix was correct, and together they solved nothing.
I counted my incident records: of 517, forty-six titles contain the word “still” — still triggering, still duplicating, still not taking effect, still sending at three in the morning. Every “still” means the previous fix only made the symptom go away for a while.
Start with one that has no “still” in it but is the same disease. A line of code had been reading chat history with a sixty-message ceiling for a long time. It always pulled twenty. The evidence sat on the same log line: selected=20 messages (max_messages=60, max_chars=12000). Nobody noticed, because it never raised anything.
The one that stung most was prompt prefix caching. I anchored the prefix on history length: take the most recent N messages, round down to a block of twenty. After shipping, users reported the hit rate dropping from 59% to 48% following a restart. I went to the logs and found all 488 calls recording the same thing: 原始历史记录: 100 条消息, a hundred messages. The upstream reader defaults to limit=100 and silently ignored what I passed, which made my formula permanently equal to ceil((100-60)/20)*20 = 40. The quantization had been a no-op the entire time.
What I wrote in my notes at the time was 我上一条改错了 — my last change was wrong. The formula itself was fine; the quantity it anchored to was truncated upstream, and I never checked what it actually received. Switching to a time anchor, three hundred seconds per block, put prefix reuse at 68 out of 79 on the same data: 86%.
Lay those forty-six side by side and the causes repeat. These five account for most of them:
- Taking the minimum. A twenty-minute cooldown met sleep = min(dynamic interval, model-suggested wait), and it woke after seven minutes. Any fallback that takes the smaller value or the earlier time is quietly revoking an upstream constraint.
- Fallback first. To pick the last assistant message, the code preferred a proactive message and fell back to a normal one. Nothing wrong on the surface, but in practice it always used the older proactive message and ignored the newer reply, so the character carried on from the previous turn.
- Fixing only the call site. One function had three callers. The one that errored got patched, the other two did not, and three days later the same error arrived through a different door.
- Unconverged double-write. State was written to file and to memory; only the file path got fixed. On restart the stale in-memory value overwrote it again.
- Fixing only one path. Main chat got fixed while proactive care did not; the QQ client got fixed while Android did not. One backend, two paths, two behaviours.
Each of those fixes was reasonable at the time: smallest diff, lowest risk, verifiable on the spot, and given another go I’d probably make the same change. The failure was in what came after: the symptom went away, I called it solved, and never asked where else the same bug could get in.
So I added one closing step. After a fix I have to be able to answer this:
Which entry point will it come through next time?
If I cannot answer it, the fix is not finished. If I can, I close that entry too, or at least leave a searchable marker in the logs. This question fires earlier than writing a test; plenty of bugs are stopped by it before any test exists.
I only run this check on three kinds of bug: ones that recur, ones that change behaviour a user can see, and ones where nobody can afterwards explain why the fix worked. That last kind is the worst, because next time there is nothing to reuse.