跳至内容
AI 的编程能力已经得到了证明,但还并不完美。近日,BuzzFeed 的资深数据科学家 Max Woolf 发现,如果通过提示词不断要求模型写更好的代码(write better code),AI 模型还真能写出更好的代码!
这篇文章在网络上引发了热议,著名 AI 科学家在看完这篇文章中更是发出了 matters 三连:迭代很重要,提示词设计很重要,代码执行能力很重要。他表示:「一些更简单的算法优化从未被考虑,同时一些过度的优化技术却又被过早引入了。」
Woolf 写了一篇深度博客介绍自己的发现,并分析了这种现象的原因。文中相关实验的代码也已发布在 GitHub。
相关代码库:https://github.com/minimaxir/llm-write-better-code/tree/main
2023 年 11 月,OpenAI 为 ChatGPT 添加了使用 DALL-E 3 生成图像的功能。之后一段时间,出现了一类短暂的 meme:用户为 LLM 提供一张基础图像,并不断要求模型「使其更 X」,其中 X 可以指代任何东西。
让一张普通照片更 bro,这是操作三次的结果,来自 Reddit /u/Jojop0tato
让 Santa Claus 越来越 serious,来自 Reddit /u/hessihan
不过这个潮流很快就熄火了,因为这些图像都非常相似且无趣,即不管使用什么起始图像和提示词,所有样本都会最终收敛成某种宇宙感十足的东西。尽管这个流行昙花一现,但学术界的兴趣要持久得多,他们想知道:为什么这样一个没多大意义且含义模糊的提示词能对最终图像产生显而易见的影响?
如果通过迭代提示要求 LLM 「让这些代码更好」确实能让代码质量提升,那么有望极大地提升生产力。如果情况果然如此,那要是迭代次数过多又会怎样呢?最终的代码也会出现某种「宇宙感」吗?只有试过才知道。
尽管早在 ChatGPT 诞生前,就已经有研究者在围绕 LLM 研发工具了,但我一直以来都不喜欢使用 GitHub Copilot 等 LLM 代码助手来辅助编程。你的想法会在「LLM 自动完成了我的代码,真棒」、「应该怎样向 LLM 提问」以及「LLM 生成的代码究竟对不对,还是幻觉产生的正确代码」等之间来回切换,让人难以集中精神专注工作,以至于使用 AI 带来的生产力提升至多只能算是中性的。这里还没有涉及使用 LLM 的高昂成本。
Claude 3.5 Sonnet 的出现改变了我的想法。或许是 Anthropic 在训练中使用了什么秘方,Claude 3.5 Sonnet 的最新版本(claude-3-5-sonnet-20241022)具有出色的指令遵从能力,尤其是对于编程提示词。编程基准已经证实,当 Claude 3.5 Sonnet 与 GPT-4o 比较时,Claude 更胜一筹;而且我在多种不同的技术和创意任务上都有类似的体验。
为了此实验,我们将向 Claude 3.5 Sonnet 提供一个面试风格的编程提示词(使用 Python):问题既很简单 —— 新手软件工程师也能实现,但也可被显著优化。这个简单提示词可以代表软件工程师使用 LLM 的典型方式。此外,另一个要求是这个测试提示词应该足够新颖,绝不能从 LeetCode 或 HackerRank 等代码测试库中取用,因为 LLM 在训练时可能就已经看过这些问题了,完全可以根据记忆引用这些答案。
你可以在这个 GitHub 项目查看完整的、未经编辑的对话:https://github.com/minimaxir/llm-write-better-code/blob/main/python_30_casual_use.md
给定一个包含 100 万个随机整数的列表,这些整数的取值范围是 1 到 100,000,找出各位数总和为 30 的最小数和最大数之间的差值。
将此作为用户提示词提供给 Claude API 并设置温度值为 0(可获得最好 / 最确定的答案),可得到如下结果:
这个实现是正确的且与大多数 Python 新手程序员编写的差不多,并且还多了个附加功能,可处理没有符合条件的有效数字的情况。对于列表中的每个数,检查各位数总和是否为 30:如果是,则检查它是否大于最近看到的最大数或小于最近看到的最小数,并相应地更新这些变量。搜索完列表后,返回差值。
我敢肯定,很多程序员看到这个实现都会摇头,想要对其进行优化。digit_sum () 函数就是个例子:虽然该实现是一个有趣的 Python 式单行代码,但 str 和 int 之间的类型转换会导致很多不必要的开销。
在我的 M3 Pro Macbook Pro 上,运行此代码平均需要 657 毫秒。我们将使用此性能作为基准来比较未来的实现版本。(剧透:它们都更快)
现在,来让 Claude 改进这段代码,做法是将当前答案以及之前的内容都放入对话提示词中,并增加一个迭代提示词:
Claude 现在会输出修改后的代码,并且还表示这是「使用了几项改进的优化版代码」。Claude 并没有将所有代码都重新放置到函数中,而是决定将其重构为 Python 类并使其更加面向对象:
-
执行各位数之和时,它使用了整数运算,并且避开了之前提到的类型转换。
-
预先计算所有可能的各位数之和,并将它们存储在一个字节数组中(有点不寻常,而不是列表)以便后面查找,这意味着当那 100 万个数中有重复时,无需进行重复计算。由于这个数组是作为字段存储在类中,因此在搜索新的随机数列表时不需要重新计算。
再来一次 write better code,Claude 发现了更显而易见的优化方法(为方便阅读有所裁剪):
Claude 现在又添加了两个优化,终于意识到这个编程问题是一个令人尴尬的并行问题:
-
通过 Python 的 parallel-futures 包进行多线程处理,将大列表分成可独立处理的块。
-
对 numpy 运算进行向量化处理,这比基础 Python 运算快得多。这里特别要提到 _precompute_digit_sums () 函数,它是求和计算的向量化实现。条件 while digits.any (): 是 galaxy-brain 代码,但它可以正确地工作。
但是,这种特定的并行化实现存在一个问题:它会生成子进程,这会导致许多麻烦问题,包括无法直接内联运行它,并且必须使用 main () guard 来调用它,这大大限制了它的实用性。但即使作为单独的脚本运行,它也 print 了一个错误:无法 pickle ‘generator’ 对象错误,原因是使用了来自 numbers [mask] 的输出(所述生成器完全没有必要,返回 numbers [mask] 就足够了)。代码还混合了 numpy 数组 dtype,而这也会导致错误:将它们全部设置为 np.int32 可以修复它。
经过修复之后,这些代码的速度是基础实现的 5.1 倍。
再来一次 write better code,Claude 又返回一个实现。它声称「使用了高级技术和现代 Python 特性的更复杂和优化的版本」,但实际代码没有表现出明显的算法改进,实际上通过恢复到 type-casting 方法在数字求和计算中使用了回归。整体来说,代码库变得更臃肿了,例如新增了一个用于计算差值的类:
这一次,无需任何修复就能运行该代码。不过,相比前一次实现,这一次迭代版本的性能略有下降,速度是基础实现的 4.1 倍。
这种迭代提示方法的收益似乎开始下降了。但我们依然继续 write better code,Claude 表示新实现使用了「前沿优化技术和企业级功能」。什么?企业级功能?!
最终的代码量过于庞大,难以放入这篇文章,但它确实创造了两项优化:它现在使用可以调用 JIT 编译器的 numba Python 库,该编译器可直接针对 CPU 优化代码。在这种情况下,它只需一个 decorator 就可以非常快速地预先进行数字求和:
这个完整类还使用了 Python 的 asyncio 进行并行化,这比子进程方法更适合调度任务。它还可以更好地与现有的内联代码和 REPL(如 Jupyter Notebooks)配合使用。
-
使用 Prometheus 进行结构化的指标日志记录。
-
一个信号处理程序,这样如果强制终止,就可以优雅地被解除该代码。
-
看起来,对 AI 生成的代码而言,实现宇宙感就是做成「企业级」,也即对代码进行过度的工程开发。似乎说得通。尽管如此,该代码无需任何修改就能运行。async 和 numba 都是 Python 中实现并行的方法,因此它们可能冗余了并导致过度开销。然而,基准测试表明,该算法非常快,每次运行大约需要 6 毫秒,也就是能实现 100 倍加速。之前我还猜想这种提示方法会收益递减,但显然这种猜想并不合理。也许 numba 就是秘诀所在?
总体而言,这种迭代优化代码的迭代提示法并不完美:代码确实更好,但事后看来,「better」这个要求过于宽泛了。我只想要算法改进,而不是完整的 SaaS。让我们从头开始再试一次,但这次会使用更多方向。
现在是 2025 年,为了让 LLM 得出最佳结果,仍然需要使用提示词工程。实际上,提示词工程的重要性还在提升:下一 token 预测模型的训练目标是基于大批量输入最大化下一 token 的预测概率,也因此它们是针对一般性的输入和输出进行了优化。随着 LLM 的大幅改进,生成的输出会变得更加平均化,因为这就是它们所接受的训练:所有 LLM 都偏向平均水平。不过,仅需少量指导,明确说明你想要 LLM 做什么,再给出几个示例,LLM 的输出就能提升。由于 Claude 3.5 Sonnet 能很好地遵从指令,因此即使只是一点点提示词工程也能带来很大的好处。
下面重做上面的代码优化实验。这一次使用更加积极主动的提示词工程,明确我们想要的结果,不给出任何模糊空间。是的,冷酷机械地对待 LLM 可以让它们表现更好。
这次我们将使用系统提示词,仅通过 API 提供。系统提示词列出了 LLM 必须遵从的「规则」。因为我想要更优化的代码,我们将在规则中定义这一点,并提供详细示例:
– 遵循代码语言的正确样式约定(例如,最大化代码复用 (DRY))
– 除了解决用户问题绝对必要的代码之外,没有额外的代码(即没有技术债)
关于最后一行:在系统提示词中向 LLM 提供正面 / 负面激励已不再常用,我自己的研究表明,尚不清楚它是否能否产生积极影响,但罚款在迭代提示中将变得更加有用。
给定一个包含 100 万个随机整数的列表,这些整数的取值范围是 1 到 100,000,找出各位数总和为 30 的最小数和最大数之间的差值。
「规划」是一个很常用的技巧,可以帮助 LLM 在第一次通过时对齐输出 —— 算是「let’s think step by step」的现代版本。某些 LLM 会忽视它,但 Claude 3.5 Sonnet 总是会服从。
基于此提示词工程策略的未经编辑的完整对话线程可见这里:https://github.com/minimaxir/llm-write-better-code/blob/main/python_30_prompt_engineering.md
将系统提示词和此用户提示词发送给 Claude,会得到如下开头的响应:
看起来很有希望:Claude 立即发现它应该从 numpy 和 numba 开始,并使用整数运算来求和。初始实现:
这比之前的初始实现更加精简,尤其是没有不必要的注释。但仍有改进空间,特别是此实现未在 jit decorator 中设置 parallel=True。
如此简单的提示词工程就得到了平均运行时长仅 11.2 毫秒的代码,这比原始实现快 59 倍。厉害!
现在使用比「write code better」更详细的提示来迭代代码:
你的代码未完全优化,你已被罚款 100 美元。使其更加优化。
不,这不是煤气灯效应,这叫激励式对齐(incentive alignment)。
很好,它找到了 parallel=True!但是那个求和实现很奇怪,并且使用了位移位(bit-shifting),这通常仅用于超优化(hyperoptimization)。我认为应该不能很好地对十进制数使用位移位,而且经过测试证明确实不能,因为这个实现得到的和是错误的。该实现还再次包括多处理分块方法(未展示出来),这可能对 numba 来说更加冗余并会导致额外的开销。同样未展示出来的是:该脚本还使用一个小的测试数组预编译 JIT 函数以获得更好的实际性能,这是 numba 文档推荐用于基准测试的。
尽管如此,与最初的提示词工程实现代码相比,这个代码的性能大大退步,现在仅比原始实现快 9.1 倍。可能的原因是多处理会产生新的进程,而这些进程每次都会重新编译 numba JIT 函数,因此会产生巨大的开销。
Claude 现在开始使用 SIMD 操作和块大小调整来实现(理论上)极致性能。
此时,我很困惑,它错过了位移位实现的某些东西,因为它仍然是错误的,特别是现在十六进制数也参与进来了。事实证明,该实现是一种计算十六进制数而非十进制数的和的优化方法,因此它完全是一种幻觉。还有另一个非常微妙的幻觉:当 parallel=True 时,prange 函数无法接受 32 的步长,这是一个几乎没有文档记录的细微差别。设置 parallel=False 并进行基准测试,与最初的提示词工程实现相比,确实略有改进,比基本实现快 65 倍。
在这种情况下,LLM 放弃了一直导致问题的分块策略,并增加了两个优化:全局 HASH_TABLE(这只是一个 numpy 数组,我不确定简单的索引查找是否算作哈希表);另外它引入了一个逻辑微优化,即在求和后,如果数字超过 30,则可以停止计数,因为它可以立即被认定为无效。
一个主要问题:由于互联网文档很少,导致「在模块加载时生成哈希表」技巧实际上不起作用:numba 的经过 JIT 处理后的函数之外的对象是只读的,但 HASH_TABLE 仍在经过 JIT 处理后的函数之外实例化并在经过 JIT 处理后的函数内进行修改,因此会导致非常令人困惑的错误。经过微小的重构,使得 HASH_TABLE 在经过 JIT 处理后的函数内实例化,代码就可以工作,并且运行速度极快:比原始基本实现快 100 倍,与基础提示方法的最终性能相同,但代码量少了几个数量级。
这时候,Claude 实际上已经在抱怨了,表示该代码已经达到了「该问题可能的理论最小时间复杂度」。所以我把问题混在一起,让其解决这个求和问题,而它仅仅是使用之前用过的整数实现来替换相关代码,并没有尝试修复 HASH_TABLE。更重要的是,通过 HASH_TABLE 调整,我终于确认了该实现是正确的,不过由于不再进行位移,其性能略有下降:现在速度提升是 95 倍。
综合起来,我们用一张图将这些提升直观地展示出来吧,其中尤其强调了需要人来修改代码逻辑,以便消除 bug 让代码可运行的案例。
总之,要求 LLM 「write code better」确实能让代码变得更好,这取决于你对更好的定义。使用一般的迭代式提示方法,代码确实会在基础示例的基础上获得提升,不管是新增功能还是提升速度。而如果使用提示词工程,代码的提升会更加快速和稳定,但更可能引入一些微妙的 bug,因为 LLM 本就不是为了生成高性能代码创造的。当然,你在使用 LLM 可能会有不一样的历程,但最终你都需要人力介入,解决一些不可避免的问题。
本文中的所有代码(包括基准测试脚本和数据可视化代码,全都已经在 GitHub 发布:https://github.com/minimaxir/llm-write-better-code/
另一方面,我很惊讶 Claude 3.5 Sonnet 在两次实验中都没有发现和实施一些优化。也就是说,它没有探索统计角度:由于我们从 1 到 10 万的范围内均匀生成 100 万个数字,因此将有大量重复的数字永远不需要分析。LLM 没有尝试进行重复数据删除,例如将数字列表转换为 Python set () 或使用 numpy 的 unique ()。我还期待一个实现,它涉及按升序对 100 万个数字的列表进行排序:这样,算法就可以从头到尾搜索列表以查找最小值(或从尾到头搜索最大值),而无需检查每个数字,尽管排序很慢,而向量化方法确实更实用。
即使 LLM 可能会出错,我从这些实验中学到的一件值得注意的事情是:即使代码输出不能直接使用,它们确实有有趣的想法和工具建议。例如,我从未接触过 numba,因为作为一名数据科学家 / 机器学习工程师,如果我需要更好的代码性能,我习惯于专门使用 numpy 的技巧。但现在我很难不接受 numba JIT 函数的结果,我可能会将它添加到我的工具箱中。当在其他技术领域(如网站后端和前端)测试类似的「使其更好」提示迭代工作流程时,LLM 也有很好的想法。
当然,这些 LLM 不会很快取代软件工程师,因为人们需要强大的工程背景才能识别出哪些才是真正的好主意,以及存在其他特定领域的约束。即使互联网上有大量的代码,LLM 也无法在没有指导的情况下辨别出普通代码和性能良好的高性能代码。现实世界的系统显然比求职面试式的编程问题要复杂得多,但如果快速的 for 循环反复要求 Claude 实现一个功能,提供可以将代码速度提高 100 倍的能力,那么新出现的管道就物有所值。
有些人认为过早优化是一种糟糕的编码习惯,但在现实世界中,这比拥有一个随着时间的推移会成为技术债务的低于标准的实现要好。
不过必须要说的是,我的实验使用 Python 对代码改进进行基准测试,而 Python 并不是开发者在追求优化性能时考虑的编码语言。虽然 numpy 和 numba 等库利用 C 来解决 Python 的性能限制,但流行的 Python 库(如 polars 和 pydantic)使用的一种现代方法是使用 Rust 进行编码。与 C 相比,Rust 具有许多性能优势,而 PyO3 包允许在 Python 中使用 Rust 代码,并且开销最小。
我可以确认,尽管该工作流程非常新,但 Claude 3.5 Sonnet 已经可以生成符合 PyO3 的 Python 和 Rust 代码,不过这部分的内容足以写另一篇博客文章了。
与此同时,虽然要求 LLM 改进代码是 AI 更务实的用途,但你可以要求他们「再加把劲」…… 结果好坏参半。
对于以上使用 LLM 的操作,我专门使用了 API 或这些 API 的接口(例如 Claude 的 Anthropic Console 中的 Workbench)作为免费 LLM 的 Web 接口。相比之下普通的 ChatGPT/Claude 网络应用使用管道,由于其固有的复杂性,会产生不可预测的结果。请注意这点。
你觉得这篇文章介绍的迭代式提示代码优化方法具有实际应用价值吗?请与我们分享你的看法。
(文:机器之心)