为什么 LLM 无法让代码变得更简洁
这篇文章最初发表 在媒体上.
长话短说,Peter Naur 博士的“编程作为理论构建”指出,真正的程序,他所说的理论,大写的 T,都在工程师的脑海中。 代码和文档只是下游(因此不完整)工件。 我对法学硕士的主要抱怨之一是代码非常冗长,而且复杂性无处不在。 我始终相信,我们可以使用 LoC 等指标或独立代码路径的数量来约束它们,从而改进这一点。 然而,读完 Naur 后,我意识到我们试图降低的复杂性是理论复杂性,而不是代码复杂性,并且没有相关措施,因为它非常主观。
我最近读了一篇很棒的论文 “编程作为理论构建”作者:Peter Naur。它极大地改变了我对当前法学硕士能做什么或不能做什么、如何提示他们、当前代理系统的主要限制是什么或为什么结对编程如此有效的看法。
今天我将重点讨论它与代码复杂性的关系。
如果您还没有阅读过这篇论文,我强烈建议您阅读一下。 事实上,我写这篇文章的主要目的是让你们中的一些人阅读原始论文。 这是值得的。 这也是练习(或学习!)精读的绝佳机会 溶剂矿因为这样简单多了:你可以在全文中提出任何问题,深入任何你喜欢的深渊,或者直接让Solveit让你更清楚地理解语言。关于细读的更多信息请参见这篇博客文章你可以快速开始分支我自己的对话.
考虑到这一点,如果你仍然决定不读,这里是论文的简要总结;总结:
程序是由构建和维护它的人所持有的理论:理解程序如何与现实世界的问题相关,哪些约束和权衡塑造了它,为什么它有效,以及哪些变化适合其设计。代码和文档是该理论的下游产物,永远无法完全捕捉它。工程师通过与用户交谈、观察故障、学习领域以及观察系统在现实世界中的表现来培养这种理解。这种理解指导了对相关性、相似性、简洁性和良好设计的判断。
大型语言模型不擅长持续学习、与用户交流或体验现实世界。但这为什么与复杂性相关?
我想我们都能同意,如果不加以控制,LLMs总体上会增加代码库的复杂度。原因有很多:他们未能意识到已有的方法并复制它,写出过度防御性的代码,比如防止不可能发生的边缘情况,或者过早过度优化。
顺带一提,大多数Frontier实验室在你消耗的代币越多,收益越高,所以他们有动力推广代币最大化。总的来说,大型语言模型很少遵循KISS原则。
这个问题在不检查输出的情况下进行振动编码时最严重。但即使你在审查代码时,如果你想让程序保持简洁,也需要积极努力尽可能减少复杂度。
在我天真的日子里(大约两周前),我曾以为我们总有一天能走出这个复杂难关。Frontier实验室只是会给他们的强化学习训练增加一些复杂度惩罚。
他们可以用总 LoC 作为最小化的度量,但我们都同意,有时一行的话比 2–3 行更复杂。另一种选择是循环复杂度,衡量独立代码路径的总数。
读完彼得·诺尔后,我意识到这些方法其实解决不了问题(不过也许能稍微缓解一点)。让我们看看为什么。
为什么代码复杂度不是错误的指标
在下面的(虚构的)示例中,我们希望支持调用OpenAI和Anthropic。想象一下,所有消息准备和重试的处理方式相同,但请求体参数略有不同,所以我们有两种不同的方法call_openai以及call_anthropic.
在第一个朴素版本中,我们有两个不同的类,代码重复(例如prepare以及with_retries).
# Separate — duplication a metric would flag
class OpenAIClient:
def complete(self, prompt):
msgs = prepare(prompt) # shared boilerplate
y = call_openai(msgs) # the only line that differs
return with_retries(y) # shared boilerplate
class AnthropicClient:
def complete(self, prompt):
msgs = prepare(prompt)
y = call_anthropic(msgs)
return with_retries(y)一个简单的重构是创建一个单一类,这样我们就干掉了。从许多指标来看,这是一种更好的实现:代码行数更少,Halstead体积更佳——以程序长度(N)和词汇量(n)计算为V=N×log2(n)——而且更好可维护性指数.
# Merged — genuinely better by the metrics: DRY, fewer lines
class LLMClient:
def __init__(self, provider): self.provider = provider
def complete(self, prompt):
msgs = prepare(prompt)
y = call_openai(msgs) if self.provider == "openai" else call_anthropic(msgs)
return with_retries(y)一般来说,第二种版本(或类似减少LoC和复制的版本)更简单。但是,如果我告诉你,下个月我们很可能停止支持Anthropic呢?在这种情况下,我觉得最好把它们分开,这样到时候我可以直接删除包含AnthropicClient.
当然,像这样简单的例子很容易解决,尤其是现在大型语言模型可以帮你写代码。但如果不是两个供应商,而是像LiteLLM那样支持165+供应商呢?
那就用LiteLLM吧。但这样你和最终供应商之间会有+120万个python的LoC。值得吗?
设计良好的API是抽象复杂性的好方法。你有明确的合同,不需要理解合同背后发生了什么。即使LiteLLM是一个庞大的软件包,也可能不计入你的理论总复杂度。
LLM推理端点的早期阶段是这样的:“你发送文本,你收到文本输出”。然而,当合同不再可靠时,这种安排就会失效。这可能是因为系统存在bug,或者API背后隐藏了大量状态而无法获取,或者仅仅是因为你不确定某个新的提供者功能是否被支持。
每当你被迫窥探API抽象层之外的深渊时,复杂性就成了你的问题。这个问题越来越普遍。像OpenAI和Anthropic这样的服务提供商越来越多地在服务器端隐藏数据,比如加密的压缩数据或推理令牌(更多相关内容).任何试图在其上方提供抽象层的人,都被迫要么暴露提供者特定的细节,要么重新实现日益脆弱的兼容性逻辑,或者接受该抽象无法忠实表示其底层系统。

如果你动作快,使用基础功能,想尝试多个供应商,那么LiteLLM或类似的库可能值得。但如果你重视控制、调试和理解你的技术栈,那可能就不行了。
没有所谓的“复杂度”(虽然我更喜欢第二种选择)。
理论登场
决定路径的信息不存在于代码中。这些信息是Naur所称的理论节目的。到目前为止,大型语言模型几乎无法接触这些信息,因为它们存在于人们的脑海中。
他们可以通过即时通讯、邮件或其他书面文件获得暗示,但这些信息总是部分的(最好情况)。
其中一些信息可以作为上下文提供给LLM,如业务优先级、预期的产品变更、运营限制以及早期决策背后的原因。这样做可能会改善其选择,但这些仍然是理论的产物。
他们无法完全传递团队发展该理论时的经验和判断力。

这些都很好,但如果我全心投入氛围编码和tokenmaxxing,如果我根本不在乎代码呢?在这种情况下,博客文章的其余部分也不会说服你改变想法。
如果你处于两者之间,我可以描述我们经历过的真实情况Answer.AI通过我们的Solveit计费系统。
剧透警告,还有Answer.AI我们非常重视不惜一切代价最小化复杂性。这种投资实际上会让代码更简单,更容易记住。
初始计费系统
解决这是一个可以用AI在类似笔记本的环境中工作的平台。环境是持久的,所以我们对LLM的使用、CPU、磁盘、内存和带宽收费。
最初的计划是向用户收取月订阅费(例如5美元)。这笔月度订阅金额会变成当月消费的积分,如果你用完所有积分,就必须在下个月前充值。
我们先在一个较小规模的项目中测试了这种方法以验证它。
这种方法比我们预期的要复杂得多。首先,信用+订阅机制会让一些人(包括我自己)感到困惑。其次,它使代码在多个层面上变得更加复杂。
你得先处理“月度订阅积分”的消耗逻辑,然后再考虑普通积分或剩余积分的处理。在Stripe方面,它有两条不同的代码路径:手动充值和订阅服务。
对于不熟悉的人来说,Stripe 订阅是一个完全托管的服务。Stripe负责生命周期管理(复发、开票、重试都发生在他们这边)。
你只需大致按照以下方式创建订阅:
stripe.Subscription.create(customer=cust_id, items=[{"price": "price_5usd_monthly"}])当订阅状态变化或付款到账时(或其他多种选项),请倾听他们的webhook,更新你的数据库。
直接扣费需要你启动结账会话,这样用户才能进入Stripe的域名并填写他们的卡片。它要求你提供客户ID。你应该在哪里创建Stripe客户?
你的用户什么时候注册?当他们试图付款时,还是别的?这些都是合理的选择。
stripe.checkout.Session.create(mode="payment", customer=cust_id,
line_items=[{"price": "price_5usd", "quantity": 1}],
success_url="https://yoursite.com/done")到目前为止还好吗?如果你对Stripe或支付业务不多,很可能已经很难把这些信息记在脑海里了。也许你已经把它简化成这样:
- 订阅 - >由 Stripe 订阅管理
- 加值 ->条纹结账
一个不明显的问题是,使用 Stripe 托管服务意味着你会有重复的数据。一半的数据存储在Stripe的后台,你必须确保本地数据库与它同步。
另一个问题是,调试时你必须同时查询Stripe服务并检查自己的数据库。例如,如果付款没有显示,是因为Stripe没有发送webhook(“他们的责任”),还是因为我们没把它存到数据库里(“我们的责任”)?
Stripe订阅服务很棒且易于设置,但它设计时支持了大量使用场景。这意味着,即使设计得很好(确实如此),API 抽象最终也会相当复杂。
在这种情况下,你是在用自己动手写代码的复杂性,换取学习 Stripe API 的复杂性。
LiteLLM和Stripe在不同尺度下展示了同样的权衡:外部抽象可以在理论的契约成立时极大简化,但当你需要调试、修改或推理超越该契约时,其隐藏的复杂性就会成为你的。

AAI的方式
在Answer.AI我们尽量拥有所有堆栈。原因值得写一篇完整的文章(甚至多篇),所以我就不深入探讨了。因此,我们对上述讨论的局面并不满意。
经过多天的探索和讨论,我们最终确定了一个更简单的系统。
首先,我们只做积分,按使用量收费。这是一个非常简单的模型(也是理论!):你通过加点来支付所用的费用。
订阅问题解决后,我们可以删除大量与保持同步的代码。只剩下两种支付路径,手动充值和自动充值。要实现自动加值,你需要能为客户节省信用卡,这样你就可以随时扣费。
对于Stripe结账,你不需要保存信用卡,只需在他们的UI中请求Stripe处理付款。
长话短说,我们最终发现最简单的方法是用户注册后立即保存信用卡。一旦他们有信用卡存档,可以用它手动充值账户,或者设置自动充值。
因为我们全程手动处理,所以几乎没有同步问题。我们只听付款成功事件(不接受订阅!)。
结果是,我们的支付系统理论可以用一句话来总结:
客户注册时添加信用卡,然后我们会手动(充值)或信用额度低时自动扣款
现在手动和自动充值都使用相同的支付路径。我们的整个支付栈——分布在Solveit和faststripe——大约有300行代码。
结果是复杂度非常低,但这是经过数小时探索、尝试和讨论的结果。我这里的解释充其量只是冰山一角。
印度登场
那么,发售当天发生了什么?一切顺利吗?不。推出后我们发现印度卡不支持刷卡非会议期你想什么时候都可以。手动充值(即会话中)仍然有效,因为用户界面会显示类似3DS的审批流程,但不会显示自动充值。
在寻找解决方案时,我们发现Stripe托管的订阅在印度实际上是可用的。为什么?Stripe帮你绕过了所有这些复杂性。他们会在前一天创建并保留非会话支付意向,这样银行可以在实际扣款前向用户发送预扣款通知或身份验证请求。
在从托管订阅迁移时,我们失去了这个功能。我问过一台前沿的大型语言模型——我记得是GPT 5.5——我们该如何处理这个问题。你愿意猜测提议的解决方案吗?
用Stripe订阅来处理
LLM提议再次使用我们刚刚迁移的解决方案。如果你看到这里,你会怎么说,你的看法是什么?我们应该迁移回去吗?
LLM列出了两个选项,要么支持两个系统(准备好编写代码,只要一声呼应!),要么完全迁移回旧系统。我们做了什么?什么都没有。我们非常重视Theory的复杂性,因此决定要求印度用户手动充值(抱歉各位!),以换取一个更简单、更强大的平台。
如果你不同意我们的选择,请思考为什么不同意。说真的,现在停下来好好想想。
没有绝对的正确答案,这篇文章的重点之一是帮助你了解问题所在你的答案来自。你的论点是什么?更具体地说,这些论点是从哪里来的?
在许多情况下,Stripe托管服务是更优选择。以下是一些例子:
- 公司收入最重要,手动补贴的额外阻力可能会让我们损失一些印度销售额,我们应该回去(或者两者都支持)
- 销售团队使用Stripe仪表盘UI,把所有支付系统信息都放在数据库里并不理想,因为他们无法管理。
我的观点是,你的代码复杂度实际上取决于很多与代码无关的因素,而这些信息并不在代码中。
隐藏的优势
那为什么不让大型语言模型完全帮你管理这些事情呢?我认为最美好的答案是,我们的工作方式发现了一个潜在的商业机会。印度并不像世界其他国家那样支持借记卡强制令。
Stripe试图解决这个问题,但远未得到完整的解决方案。
在理解和简化流程及理论的过程中,我们学到了超越产品本身的宝贵知识。在我看来,这些见解可以转化为竞争优势。
努力理解本质上是一个理论简化的过程。如果你让LLM自由发挥复杂度,你可以快速产出大量代码。选择简化路线会更长,也更费力,但从长远来看我认为这是值得的,你可能会在过程中发现隐藏的宝藏。