100KLOC Later

(Read time: ~ 15 minutes.)

In this blog post, I've condensed everything I've learned about getting LLMs to write reliable code into five principles:

  1. Never argue; fix the system.
  2. Build for reproducibility.
  3. Control the context.
  4. Handle cross-cutting concerns explicitly.
  5. Know the training set.

If you're sceptical of the value of LLMs in programming, as I was, you might find some of these ideas help realize the benefits of AI assistance without compromising quality.

Value of the LLM Contribution

When I first experimented with having ChatGPT write some simple coding examples, I concluded that it wasn't ready for serious use. Then in 2024, the most productive programmer I know told me that GPT-4 was writing all his code. My friend's remark forced me into some slightly more principled experiments.

As an industry, we face the same conundrum. The first question is whether the LLM is even a net benefit for a seasoned professional. If you follow opinions on the internet, it's very clear that there is no consensus on this question yet, which I found surprising. If this is really a great leap forward in productivity, some of the strongest programmers out there seem to be missing it, and if it's a net negative, then those of us building and funding these tools are fooling ourselves. How can something so fundamental remain unresolved?

A few of the most obvious ideas don't seem to hold up. Yes, it is likely that LLMs are simply much better at some tasks than others, so we might expect that some projects are simply above or below a difficulty threshold where LLMs provide a net benefit. However, some people are successfully using AI even in objectively hard areas, while some companies that applied AI even to relatively trivial programming domains are already failing. If the inherent ability of the LLM relative to the problem complexity was the main issue, we would expect to see a different pattern where the most complex work is failing in the adoption of AI. Howevere, there seems to be no correlation between the difficulty of the problem and the success in adopting AI-assisted workflows. Another possible idea is that some programmers are simply better than the LLM while some are worse, so the ones who are better off letting the current best model write the code will benefit the most, while the better programmers will find the LLM only gets in the way. This idea is appealing, and it gives us an ego boost to feel that we are better programmers than the LLM, but it falls down immediately on gathering even the most cursory evidence. There is essentially no correlation between programmer skill and opinions on the value of AI, and even among the strongest programmers, opinions run the full gamut.

Before resolving this paradox, first we should ask if we even have a way to measure what we are talking about. Can we even define the effectiveness of LLM-assisted programming? Two factors we might measure are the time taken for a programmer who is new to the system to understand it, and the rate of change in the system by a programmer who is fully familiar with it. Both of these are, of course, subjective, at least until someone does some very expensive studies to try and measure them. (If you have a research budget for this, please get in touch!) Fortunately, our taste as programmers makes an excellent proxy metric for both of these.

From a systems perspective, given the availability of AI assistance, the codebase is no longer just a tree of files that produces some binary artifact that we deploy to production. The codebase now is a system that facilitates its own modification by way of an advanced but commercially-available LLM. The factors we care about must be considered for this system as a whole, including the codebase, the tooling, the programmer, and the best available LLM.

Nearing the end of 2025, I've written well over 100KLOC, including my own AI-assisted programming tool, first as an open source TUI and then again as a Web-based SaaS, with 100% of the code in those projects being written by an LLM (almost all by GPT-4 or GPT-5 (so did I really write that 100KLOC or did the model? one for the philosophers...)). If I had to compress what I've learned into just one sentence, it would be this:

Principle 1: Never explain, never teach, and never argue with the model.

Senior engineers have a teaching instinct, and this is wasted on LLMs, which do not learn. Fortunately we also have a systems-building instinct, which is what is called for here.

Anytime you find yourself in a chat or with an agentic system, correcting the mistakes that the model has made by explaining what it got wrong and why, you should instead fix the lacuna in the context that the model was seeing when it started to do the work.

The rest of this essay is essentially the elaboration of this principle, and what I've found necessary or useful for this approach.

Management writers from Peter Drucker on have formulated similar principles: fix the system, don't blame the individual. To build a system that reliably generates code of high quality, treat the model as making the best decision it can given its inherent capacities and the information available to it. Your only lever is the information available to it. If the codebase is now a system that uses an LLM to generate code, then our job as programmers is to maintain that system and to establish the standard of quality that is appropriate to the work at hand.

Even lacking any objective measures, senior programmers are well placed to evaluate these systems subjectively, once we shift to the whole-system perspective. Programmers don't just love the fact that we can ship features, we love the fact that we are building a system in such a way that it has a pleasing conceptual integrity, such that future features will also be easy to ship. Because good programmers have good taste, a system that is pleasing to use is probably also an effective one. Arguing with the model feels like a waste, because it is: the time that you spend brow-beating the model into getting something right is stolen from the time you could have spent improving the system such that even GPT-5 cannot get it wrong.

This is my resolution to the paradox of differing informed opinions on the value of AI tools. Some programmers have stumbled into a workflow for improving a system that reliably generates high-quality code. If you do, it feels like the model is getting smarter over time, even though it hasn't. Other programmers get stuck arguing with the model to try to get it to do what they want, or criticizing it. For them, AI assistance feels like an endless time sink, like trying to teach a junior coworker who is incapable of learning, because LLMs don't learn. This is naturally frustrating, and confirms their existing opinion of LLMs as incompetent programmers. The trick is to shift from the code level up to the system level, and apply the programmer mindset to the whole codebase itself as a code-producing system. If the LLM produces garbage code, do not blame the LLM, but fix the system.

The other four principles are all intended to facilitate that shift to a systems-oriented approach.

Reproducibility

This goes against the grain both of agentic systems and of reasoning models like GPT-5, but I consider it critical. This is the importance of reproducibility, repeatability, and robustness. A system that generates code once should be able to do so again, and the code should still work, otherwise it's worth vastly less to us, because we cannot rely on making changes and being able to get back to where we started as additional requirements shift or change. The process argument for repeatibility is simple: you need repeatable code generation for the same reason that you need repeatable builds and deployment procedures: because the alternative is that you have a system that you are afraid to modify. If it is more productive for a programmer to write a prompt than it is to write the code, then it is more productive to store and modify the prompt rather than the code. The reason why we do not do this is because in general the systems that we currently use to generate code are not reliable. Code generation is then expensive both in tokens and human effort. Repeatibility is the first systems-level shift to reduce the waste of human effort.

Both agentic systems and reasoning models with hidden output tokens are highly path-dependent systems. Their visible output has much higher entropy than the first tokens to come out of the underlying model, because they circle back on their own internal results before emitting the part that we look at and evaluate. This path dependence is how you build the opposite of a repeatable, reproducible system. (It is in fact a way to build a chaotic system, where the result is completely unpredictable given the initial inputs, which is a fair description of many agentic systems in use.) I don't think we should accept un-reproducible LLM outputs into a project any more than we would accept un-reproducible outputs from a compiler. This does not mean byte-for-byte but it does mean feature-for-feature, with reliably working code.

Principle 2: Build for reproducibility, as circumstance permits.

The chat-based agentic UX itself goes against repeatability, as you write instructions to an agent or in a chat, which may go back and forth for many iterations, and the output is generally not checked into the codebase (nor should it be, as it is generally a mess). Even my own product, which is based on the idea of reproducible code generation, has at the top level a chat-oriented agentic approach.

In a few years, I think we'll have this worked out, and we will have moved from agents to "maintainer" systems. The distinction I am making here is that an agent is assigned a task interactively by a human, while a maintainer system continuously and permanently takes responsibility to ensure some set of invariants; agents gather state in service of a task, while the state of a maintainer system is continuously kept up to date. A maintainer system is like an agent that you can forget about until requirements change, because it is reliable. For now, the closest thing we have to maintainer systems, given current tooling, is defined by the programmer itself through the structure of the whole codebase.

Recently Sam Altman boasted about progress in Codex, such that it can now take on tasks that take a day, rather than an hour. From the perspective of reproducibility, or agents vs maintainer systems, this represents progress in the wrong direction. The big AI vendors are in the business of selling tokens, but to build efficient systems we have to think in a different way. We do not want agents that spend even longer potentially going off the rails, what we want are tighter feedback loops. An ideal system is one that will either immediately make a desired change without any fuss, or will immediately understand that further input is required, e.g. because the programmer intent is underspecified, or because of conflicts with decisions made earlier in the development of the codebase.

Another expression of this principle could be the slogan: unreproducible AI code is tech debt. This means that AI-generated code that is checked in to a repo without a recipe to reproduce it should be regarded as technical debt, and must gradually be replaced to bring the codebase into a healthy state. This is for the same reasons we would apply to binary compiler output: if it is not the preferred form of editing the artifact, then it should not be what is checked into the repo.

Whether the work is done by a human or a machine agent, if it is helpful to clarify the requirements of the program in English, or to write tests that define specific behavior, or to run experiments and collate results, then those things should be collected into a context that is sufficient to regenerate the code again, rather than thrown away once an immediate task is accomplished. So, reproducibility leads us to the context principle.

Context

Principle 3: Control the context to control the result.

Context is key to getting an LLM to perform effectively in any domain, and too much context also has been shown to degrade performance. There are many tools to try to automatically surface the right context in order to allow the LLM to write effective code. When you give an agent a programming task, it first spends time gathering context from your existing codebase so that it knows what to write, where to write it, what else to modify, and what it needs to be compatible with. When at first it doesn't succeed, almost all of the time it is because it did not find the right context, or worse, it found the wrong context and integrated with something that it should not have been looking at at all. For example, an LLM hallucination of a method that your code doesn't contain is because it did not have in its context window the documentation for the correct method to use. A use of a deprecated method is because that's the code or the documentation that it found first, or because it copied from code that's using that method instead of what you want new code to be using. The creation of a duplicate method is because it did not have the context of the existing one. And so on.

Despite the promises of many vendors, there is no automated solution that can remotely approach the effectiveness of a human programmer in establishing context, for a few simple reasons: as a programmer, you know the codebase at a higher level than the LLM will, and you also know the direction that you want the code to move in, because you always have plans about how the codebase is going to evolve, and those plans themselves are changing as you learn. It's impossible to fully communicate either of these to an LLM through documentation, as keeping that documentation up to date would probably double the work you have to do in maintaining the code itself. The conclusion is that, as the programmer, you have to be responsible for the context that the LLM is seeing when it generates code for you. The more difficult or tightly integrated that code is with the rest of the system, the more critical this becomes. The goal again is to spend your efforts, not in providing context to this agent at this moment, but to setting up the system such that it finds the correct context instantly and cheaply.

It immediately follows that any system we use for code generation must support at least two basic capabilities: first, the ability to control the context that the LLM will see, and second, visibility into the context that the LLM is using when it writes the code that we review. These two things are like your ability to steer and to see where you are going. You can't fly without both of them.

Context control is where vibe coding leaves off and AI-assisted programming begins.

Of course, if we talk about maintaining code quality in the face of AI use, perhaps we should try to define what that means.

Code quality

I would like to say that my standards for code quality haven't changed at all since I was writing every line myself. In fact, my standards are not even clearly higher or lower, but almost completely different. My standards for things like variable names, code formatting, and general matters of "style" have almost entirely gone out the window. What I care about now are reliability of code generation (can I regenerate the code again tomorrow without changes and still get the same, correct, behavior?) and consistency across the project with the conventions and practices that provide the high-level guarantees that I need about the program as a whole.

A simple example of the latter is error handling. It's entirely possible to use exceptions, or to return some kind of sum type encoding error and success results separately, or to handle errors locally by logging them and returning some kind of best-effort result that allows graceful degradation, or to fail fast and loud as in Erlang, or ...name your favorite strategy here. While you or I may have strong opinions about which of these is right for any particular project, what we can hopefully all agree on is that consistency across a project is critical. In teams, we try to handle these issues with conventions, code reviews, linters, etc. In LLM-assisted programming, these cross-cutting concerns must be addressed by a consistent policy, and provided as part of the context to the LLM at the moment when it is generating code where those concerns are relevant. This will include policies that are specific to your backend or frontend, or to one language, or even to a specific library, subsystem, or service. Beyond error handling, this includes things like what libraries to use, conventions around variable names, conventions in user-visible strings, and probably hundreds of other specific issues that will be unique to your codebase.

Principle 4: Cross-cutting concerns are first-class.

The decisions you make on such cross-cutting concerns must be explicit and they must be part of the context just as much as specific coding goals or outcomes of a particular function or task.

This isn't rocket science and it isn't vector database voodoo. It's easy, it's boring, it's good old-fashioned software engineering work. The trick again is to think about the system as a whole and set that system up with this goal in mind.

We can think of this as factoring. Just as a thousand-line function should probably be refactored into orthogonal concerns, a thousand-line prompt should probably be factored into e.g. a JavaScript style policy, a policy for frontend use of the backend API, a policy for error handling in this specific part of the UI, and so on. Once this is done, most of those policies will apply to multiple parts of the codebase. I have found that typically 80% of the prompt that is necessary to reliably generate some function is about policy and only 20% is specific to that function.

This brings me to my final topic, which is how to think about LLMs at all in the context of code generation. I've worked with models, built them from scratch, and trained them, from the days of BERT and before LLMs were at all practical for code generation, as well as more recently. You don't need to have lowered PyTorch kernels into pure assembly with your bare knuckles, or to have the transformer architecture tattooed on your bicep to use these systems effectively as a programmer. In fact, I'm not even sure it helps (and I already kind of regret that tattoo), but at least a basic working mental model of the LLM is helpful in getting good results.

LLM as Pattern Distillation of Training Set

The LLM is a compressed representation of a training set. At a very high level, I like to explain an LLM as a stack of rules for combining other rules. At the lowest level you have things like where to put semicolons, or how to structure an if statement in one of those if (;;) {} languages. The higher levels build on the lower levels, so on the basic syntactic understanding, the model can then build the ability to keep track of variable names in a function, and remembering to close a file it opened, or what the return type of a function is by the time it gets to the end of it. At a higher level yet, you have concepts like fundamental algorithms and when to use them, patterns like try..catch or with(), and everything you need to know to use the standard library of your favorite language successfully, and so on. This is about the level where modern LLMs sit.

Why is this important? Because at every level, when you interact with the LLM, you are interacting above all with a compressed representation of the median solution in the training set to whatever problem the LLM sees in the context you have provided.

Principle 5: Know the training set; know the model.

If you, as an expert programmer, also have a compressed mental map of the fundamental techniques of computer science, and if you know, say, when to use A* or Boyer-Moore or BLAST or your favorite algorithm, then you can communicate solutions at a highly-compressed and efficient level with the LLM that also understands the same concepts. On the negative side, if A* or BLAST or kubernetes is the "default" recommended approach to a class of programming problem in the training set, and if the model identifies your problem as falling in that class and you don't specify otherwise, then that is the solution that it will attempt.

In particular, if the majority opinion about the approach to the problem class is to vastly overcomplicate it (did I mention kubernetes?) then you will probably see the LLM vastly overcomplicate it, and then spectacularly fail in the complex approach it has committed itself to. In fact it's even worse, because LLMs are trained to minimize token loss. Therefore if there is a solution in X tokens and an overcomplicated solution in 100 * X tokens, the overcomplicated solution is already 100 times better represented in the training set. The LLM during text training has literally been rewarded 100 times as much for predicting the tokens of the overcomplicated result. It's not the "fault" of the LLM that it is rewarded for generating tokens: that's just how it's trained. Expect the LLM to fail in ways that are a consequence of the training set (as well as post-training fine-tunes that we won't go into here).

This explains the common pattern observed when a senior engineer guides an LLM, in which the LLM spends thousands of tokens in attempting to solve a problem and fails. The senior then examines the trace of the LLM's attempts and says something to the effect of "can't this be solved in one line of awk?". The LLM then succeeds in only a few dozen tokens. It's like the old story of the specialist called out to fix some complicated machine. The specialist hits the machine a hammer, fixing it, and sends a bill for $10,000 with the line items: hitting the machine with the hammer, $1; knowing where to hit, $9,999. The LLM is capable of implementing a targeted point solution, it just doesn't know where to hit.

According to Sturgeon's Law, 90% of everything is crap. Presumably this includes the code in the training set of the LLM you are working with. Of the 10% that isn't crap, it may be that 90% was written in a context different enough from your own that different fundamental tradeoffs apply. Taken together, that means you have a 99% chance of getting something that's not what you really need. It also means there is great opportunity if you know how to identify the 1% of the training set that matches what you do need using terms the LLM understands.

Treat the LLM as a compressed representation of approximately half the algorithms in the Knuth; everything in SICP; everything in your dimly-remembered compilers and operating systems courses, and everything in every design patterns book you've ever seen, including the bad ones. Once you understand the LLM as pattern-matching on the training set, you can get it to perform minor miracles, as well as understand how and why it goes wrong. The most impressive results I've ever seen from LLMs start with instructions like: use this algorithm on these inputs in this data structure and producing output with this shape. If you follow that formula, you'd be surprised at what even relatively dumb models can accomplish.

Conclusion

The field of AI-assisted programming and the tools themselves are moving rapidly. My thinking has evolved over two years of working with and building these tools and undoubtedly will continue to evolve. However, I believe the basic principle here will remain relevant as the tools and models continue to improve. The days of a codebase as a system for producing a binary artifact are over, the days of thinking of a codebase as a system which modifies itself using an LLM are here. Let me know what you think and happy hacking!