Real bugs from building an LLM-powered feature

Every earlier lesson in this domain covers a pattern. This one covers what actually goes wrong when those patterns aren't yet in place — genuine bugs, not hypotheticals, each with the fix that resolved it.

Advanced

13 min read

The model passed a name where the code expected a real ID

A tool that records an expense against an account took an account identifier as an argument. The model, reasoning from the conversation, passed the account's name ("cleaning") instead of its database ID — and the lookup at confirm time failed with a 404. Worse than the immediate failure: the pending action had already been marked CONFIRMED before the error was thrown, leaving the UI stuck showing success for an action that hadn't actually completed.

The fix, two parts: the write tool's argument schema was changed to explicitly require the real ID field (obtained by first calling a context tool that lists valid accounts with their IDs, forcing the model through the correct lookup rather than guessing a value), and the confirm handler was wrapped in a try/catch that returns a typed FAILED status instead of letting a raw exception leave the record in an inconsistent, ambiguous state.

The role check happened too late

A user without permission to record expenses asked the agent to record one anyway. The agent walked them through a full, multi-turn conversation — gathering the amount, the category, the date — before finally revealing, only at the moment it tried to act, that this user wasn't allowed to do this at all. Technically correct (the action was blocked), but a frustrating experience: several turns of a conversation that could never have succeeded.

The fix: making the system prompt role-aware from the very first turn (covered in this domain's system-prompts lesson) — a server-computed permission table included in the prompt lets the model recognize and state the limitation immediately, instead of discovering it only when a tool call gets rejected.

A bilingual response leaked both languages into one answer

For a genuinely ungrounded, low-content message (a simple "hi"), the model's response mirrored both languages it had seen used in the conversation — answering in Arabic, then a literal separator, then the full answer again in English. Nothing in the underlying logic was wrong; the model's language-matching instinct just produced an oddly duplicated response for a message too thin to clearly signal which language the reply should be in.

The fix: an explicit anti-mirroring rule added directly to the system prompt, telling the model plainly never to produce a language-separator pattern — a case where the fix is prompt engineering, not application code, because the "bug" lived entirely in the model's own generated behavior.

A capability flag was set for a model that doesn't support it

A provider-specific option (an "adaptive thinking" mode) was configured unconditionally, but one of the models actually in use — a smaller, faster one selected for a lower-stakes surface — didn't support that capability at all. The fix was mechanical once identified: making the option conditional on which specific model was actually being called, rather than assuming every model behind one provider supports the same feature set.

The general lesson: "same provider" doesn't mean "same capabilities" across every model tier that provider offers — a configuration that works for the flagship model can silently misbehave (or simply fail) for a smaller model chosen deliberately for cost or latency reasons.

Images were sent at full resolution for no benefit

A vision-capable tool accepted photos directly from a phone camera — 3 to 8 megabytes each, far larger than what the model actually uses. Past a certain resolution (roughly 1568px on the longest edge, for the model in question), additional pixels aren't just wasted — they add real cost with no corresponding gain in the model's ability to interpret the image.

The fix: resizing images down to that effective ceiling before upload/storage, with a fallback to the original bytes if the resize step itself fails — cutting real cost without sacrificing anything the model could actually use.

A load balancer's default timeout cut long streaming responses short

An infrastructure detail, not a model or prompt issue: the load balancer sitting in front of the always-on backend server had a default idle timeout of 60 seconds. A genuinely long streaming response — several tool calls, a lengthy final answer — could exceed that window, and the connection would simply be cut mid-stream from the infrastructure layer, with nothing wrong in the application code at all.

The fix: raising the load balancer's idle timeout explicitly, specifically to accommodate long-lived SSE connections — a reminder that an agent's reliability depends on every layer of infrastructure it passes through, not just the application code talking to the model.

The model said it would act — but never called the tool

A messaging-channel integration (a booking assistant replying over a chat API) started intermittently failing to send its own confirmation buttons. No error appeared anywhere: no failed API call, no thrown exception, no warning log — the kind of silence that usually means "nothing happened," except something clearly should have.

The first instinct was to suspect the send itself — a malformed request, a transient platform error. Both were ruled out quickly: every genuine send failure already logged loudly elsewhere in the same system, and none did here. The actual cause sat one layer up: on the affected turns, the model's reply narrated the action in plain text ("I'll send you a confirm button now") without actually emitting the corresponding tool call that turn. Nothing was broken downstream, because nothing downstream ever ran — the tool that would have created the pending action and sent the buttons was simply never invoked.

Diagnosing it: a log line correlating two independent signals on every turn — what the reply text claimed versus which tools actually fired — so a mismatch surfaces immediately instead of presenting as unexplained silence. This is a distinct failure mode from every other bug in this lesson: it isn't a bad argument, a late permission check, or an infra default — it's the model's own generated text and its own tool invocation drifting apart, something no amount of watching the API layer will ever catch, because from the API's perspective, absolutely nothing went wrong.

The actual fix, once diagnosed: purely a system-prompt rewrite, no code change at all. The original instruction read as a two-step plan — "once you have the details, call propose_booking" — which the model could satisfy by narrating the next step instead of taking it, since a chat model has no real concept of "later in this same turn" versus "later, generally." The fix collapsed it to a same-turn imperative: the moment you have the required details, call propose_booking in that same response — there is no later turn where you'll remember to. Paired with an explicit negative rule: never tell the user a confirmation UI "is on its way" unless the tool call producing it is present in this exact response. Two sentences of prompt, zero lines of application code, and the gap closed completely.

The general lesson: agent observability can't stop at "did the tool call succeed" — it also needs "did a tool call happen at all, given what the reply implied." And once you've found that gap, the fix usually isn't more code — the model doesn't have a concept of "I'll do this next," so any instruction that reads as a future step is an invitation to narrate instead of act. Rewriting it as "in this exact response" closes the gap at the source.

A tool serving two purposes triggered the wrong side effect

A read-only lookup tool (list_services, returning service IDs and names for the model to reason with) was reused as the signal for "the model wants to show the user a services menu" — the webhook triggered the menu UI any time that tool appeared in usedTools. It worked in testing, then broke in exactly the way overloaded signals always do: the model also called list_services internally, mid-conversation, just to resolve a service the user had already named in plain text ("the checkup one") — and every one of those internal lookups re-fired the external menu UI, so the same tappable menu kept reappearing over and over regardless of what the user actually asked for.

The fix: split the one overloaded tool into two single-purpose ones — list_services stays pure data, no side effect, safe to call as often as reasoning requires; a new show_services_menu tool exists only to signal "display the menu now," with a handler that does nothing but confirm the signal. The webhook keys off the new tool specifically, not the data lookup. The same negative system-prompt rule from the previous bug generalized cleanly here too: never describe a UI element appearing unless the tool that produces it is the one actually called.

The general lesson: a tool's presence in usedTools is only a safe trigger for a side effect if that tool has exactly one reason to ever be called. The moment a tool serves double duty — data the model reasons with, and a signal your code acts on — every legitimate use of the first purpose becomes a false positive for the second. Split them before that happens, not after tracing a live "why does this menu keep reappearing" report back to a single overloaded tool call.

An empty model turn got written to permanent history, and broke every message after it

A turn that ends in a tool call with no trailing prose is a completely normal, valid model response — the text portion is simply empty. One system persisted that response verbatim into conversation history without checking for this, and the very next API call resent the full history, empty message included. The provider's API rejects empty text content blocks outright — so that one degenerate turn didn't just fail once, it made every subsequent message in that conversation fail the same way, forever, because the bad row never left storage and got resent on every future turn. For a session with no expiry (keyed permanently per user, not per browser tab), this meant a single ordinary tool-only response could permanently wedge an entire conversation with no self-recovery path.

The fix, two parts: a non-empty fallback string substituted at the moment of persistence, whenever the model's text response is blank — so a bad turn can never be written to storage in the first place; and, since existing conversations already had bad rows sitting in them, a defensive filter on the read path too, dropping any empty-content message before it's ever sent back to the model. The second part matters as much as the first — a fix that only prevents new corruption doesn't repair sessions corrupted before the fix shipped.

The general lesson: validate a model's output before it's written to any store with no TTL. A malformed response that only affects the reply shown once is an inconvenience; the same malformed response, persisted into history a stateless API resends on every future call, is a permanent, self-perpetuating outage for that one conversation — and it will look like a totally unrelated bug (a rejected API call) on every message after the one that actually caused it.

The throughline across all nine

None of these are exotic failures — a wrong argument type, a permission check in the wrong place, a language-formatting quirk, a capability mismatch, an oversized upload, an infrastructure default never revisited for a new kind of traffic, a promised action that silently never fired, a tool signal that meant two different things depending on context, an empty response persisted somewhere with no expiry. Individually mundane; collectively, they're the actual texture of what building a real LLM-powered feature involves, once it moves past a demo and into something real users depend on. In every diagram above, red is the state that actually shipped and broke; green is the one line (usually just a prompt sentence, sometimes a schema change) that would have prevented it.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. A model passed an account's name ("cleaning") instead of its database ID to a write tool. The lookup failed at confirm time with a 404, but the pending action had already been marked CONFIRMED. What was the fix?

2. A user without the right permission was walked through several conversation turns before being told the requested action wasn't allowed. What fixed this frustrating experience?

3. A provider-specific "adaptive thinking" option was configured unconditionally, but broke for a smaller, faster model chosen for a lower-stakes surface. What general lesson does this illustrate?

4. A long, genuinely lengthy streaming AI response was being cut off mid-stream in production, with no error in the application code. What turned out to be the cause?