← all posts
// architecture · langchain

I rewrote a LangChain app in fifty lines. Then rewrote it back.

The ticket-triage service I run for a support-tooling client started life as a LangChain app, became fifty lines of raw SDK calls I was genuinely proud of, and about six months later had quietly turned back into a worse version of the framework I'd deleted. The whole loop ran maybe eight months. It taught me the only framework rule I actually trust now.

the fifty lines that felt like waking up

Late last year the triage classifier was a chain of six or seven LangChain pieces: a prompt template, a chat model, a structured-output wrapper, a couple of RunnableLambdas, an output parser, a router. Take a support ticket, hand back a category, a priority, and a suggested queue. That's all it did, and it read like a lot of scaffolding around one idea. The whole thing was basically one line of LCEL once you squinted:

chain = prompt | model.with_structured_output(Ticket) | router

On a slow Friday I rewrote the core as a single function that assembled a message list and called the provider SDK directly. Fifty-ish lines. It ran, it was faster to read, and when it broke the stack trace pointed at my code instead of six frames of somebody else's. I could hold the entire request in my head. I wrote a slightly smug internal note about buying less framework than you think you need and moved on.

For about a month it genuinely was better.

the inventory I never took

Here's the part I skipped: I never actually wrote down what those six pieces were doing before I called them scaffolding. Some were. Several were load-bearing.

The structured-output wrapper wasn't just shaping a prompt. It was validating the model's JSON against a schema, retrying once when parsing failed, and coercing the loose types the model liked to emit. My fifty lines did json.loads and hoped. The chat model had backoff for a specific class of provider 529s. The callbacks I'd cheerfully deleted were feeding a tracing dashboard the client's ops lead checked every morning.

None of this fell over on day one. It fell over on day forty, when the provider had a rough afternoon and my triage service started throwing on every malformed response instead of retrying past it. Tickets backed up. I got the kind of message you do not want to read on a Saturday, and I spent that Saturday hand-writing a retry loop I had deleted a month earlier in the name of simplicity.

six months later, I had rebuilt it worse

So I added the retry back. Then provider branching, because the client wanted a cheap local model on the 3090 box triaging the easy tickets and a frontier model for the ambiguous ones. Then a real parser, because json.loads and hope is not a strategy. Then a small tracing shim, because I missed the dashboard I'd thrown away.

By June my fifty lines were closer to four hundred, spread across three files, and every single addition was a shakier, less-tested version of something LangChain had already shipped and was already maintaining. I had written a framework. Badly. For a user base of one.

A thin harness only stays thin while the problem stays simple, and problems that reach production rarely do me that favor.

the rule: growth, not size

I rewrote it back. Not entirely. The triage loop itself is still mine, because it is simple and it stays simple. But structured output, retries, and provider abstraction went home to the framework, because those were precisely the parts that kept growing new requirements every quarter.

That is the rule I use now: choose by expected feature growth, not by today's line count. A prompt-and-response that will never need retries, streaming, tracing, or a second provider should be fifty lines, and you should know when the framework is genuinely the wrong tool and skip it without guilt. But if you can already name three features you will want next quarter, the abstraction tax is just a subscription to code you would otherwise write and maintain yourself, worse and alone.

The ponytail instinct to delete code is right far more often than it is wrong, and it was still the right instinct here. It failed me in one specific way: deleting code is only a real win if you also delete the requirement. I deleted the code and kept every requirement, so the requirements grew the code back — my version this time, and worse than the one I removed.

The fifty-line function still sits in git history. I open it sometimes, the way you look at a photo of a haircut you were absolutely sure about at the time.

#langchain#architecture#simplicity