本文讨论了LLM在代码生成方面的应用,指出了LLM与传统编译器的根本区别在于LLM的非确定性,这可能导致代码中出现难以察觉的语义错误。文章通过实际案例分析,强调了LLM在处理复杂、遗留代码库时的局限性,并提出了未来发展方向,包括形式化验证框架、更好的上下文编码方法以及更全面的测试框架,以确保LLM生成的代码在语义上的正确性。
我最近参加了在纽约举行的 AI Engineer Code Summit,这是一个仅限受邀者参加的人工智能领导者和工程师聚会。与使用 AI 构建的与会者交谈时,一个主题反复出现:我们正在接近一个开发者永远不需要再看代码的未来。当我追问这些支持者时,有几个人提出了类似的论点:
四十年前,当像 C 这样的高级编程语言越来越流行时,一些老前辈抵制它,因为 C 给予你的控制权比汇编语言更少。同样的事情现在也发生在 LLM 身上。
表面上看,这种类比似乎是合理的。两者都代表着抽象程度的提高。两者最初都遇到了阻力。两者最终都改变了我们编写软件的方式。但这种类比真的让我感到不快,因为它忽略了一个比抽象级别更重要的根本区别:确定性。
编译器和 LLM 之间的区别不仅仅在于控制或抽象。它关乎语义保证。正如我将要论证的那样,这种区别对软件的安全性和正确性有着深远的影响。
编译器有一项工作:在更改语法的同时,保留程序员的语义意图。当你在 C 中编写代码时,编译器会将其转换为汇编语言,但你的代码的含义保持不变。编译器可能会选择使用哪些寄存器、是否内联函数或如何优化循环,但它不会更改你的程序做什么。如果语义发生意外更改,那不是一个特性。那是一个编译器错误。
这种属性,语义保留,是现代编程的基础。当你在 Python 中编写 result = x + y 时,该语言保证会发生加法运算。解释器可能会优化它执行加法的方式,但它不会更改发生什么运算。如果它这样做了,我们会将其称为 Python 中的一个错误。
从汇编语言到 C,再到 Python,再到 Rust 的历史进程始终保持着这一属性。是的,我们提高了抽象程度。是的,我们放弃了细粒度的控制。但我们从未放弃确定性。编程行为仍然是可组合的:你从更简单、定义明确的部分构建复杂的系统,并且组合本身是确定且明确的。
在某些罕见的情况下,高级语言的抽象会阻止程序员语义意图的保留。例如,密码学代码需要在所有可能的输入上以恒定的时间量运行;否则,攻击者可以使用时间差异作为预言机来执行诸如暴力破解密码之类的操作。“恒定时间执行”之类的属性不是大多数编程语言允许程序员指定的。直到最近,还没有好的方法可以强制编译器发出恒定时间代码;开发者不得不求助于使用危险的内联汇编。但是,通过 Trail of Bits 对 LLVM 的新扩展,我们现在也可以让编译器保留此语义属性。
正如我在 2017 年的“自动化的自动化”中写道的那样,我们可以自动化的内容存在根本性的限制。但这些限制并没有消除我们构建的工具中的确定性;它们只是意味着我们无法自动证明每个程序都是正确的。编译器不会尝试证明你的程序是正确的;它们只是忠实地翻译它。
LLM 在设计上是不确定的。这不是一个错误;这是一个特性。但它有一些我们需要理解的后果。
两次通过 LLM 运行相同的提示,你可能会得到不同的代码。即使将温度设置为零,模型更新也会改变行为。对“在此函数中添加错误处理”的相同请求可能意味着捕获异常、添加验证检查、返回错误代码或引入日志记录,并且 LLM 每次可能会选择不同的方式。
这对于创意写作或头脑风暴来说很好。当需要保留代码的语义含义时,就没那么好了。
自然语言本质上是模棱两可的。当你告诉 LLM“修复身份验证错误”时,你假设它理解:
LLM 将自信地根据它认为你的意思生成代码。这是否与你实际的意思相符是概率性的。
你可能会说,“好吧,但是如果我给 LLM 明确的输入呢?如果我说‘将这段 C 代码翻译成 Python’ 并提供确切的 C 代码呢?”
问题是:即使那样也没有看起来那么明确。考虑以下 C 代码:
// C 代码
int increment(int n) {
return n + 1;
}
我要求 Claude Opus 4.5(扩展思维)、Gemini 3 Pro 和 ChatGPT 5.2 将此代码翻译成 Python,它们都产生了相同的结果:
## Python 代码
def increment(n: int) -> int:
return n + 1
它很微妙,但语义已经改变了。在 Python 中,有符号整数算术具有任意精度。在 C 中,有符号整数溢出是未定义的行为:它可能会回绕,可能会崩溃,可能做任何事情。在 Python 中,它的定义很明确:你会得到一个更大的整数。没有一个领先的基础模型捕捉到这种差异。为什么不呢?这取决于它们是否接受过高亮显示这种区别的示例的训练,它们是否在推理时“记住”这种差异,以及它们是否认为它足够重要而需要标记。
存在无限数量的 Python 程序,对于所有有效输入,这些程序的行为与 C 代码完全相同。LLM 不能保证生成其中的任何一个。
事实上,如果不知道原始 C 开发者期望或打算C 编译器如何处理此边缘情况,LLM 就不可能完全翻译代码。开发者是否知道输入永远不会导致加法溢出?或者他们是否检查了汇编输出并得出结论,他们的特定编译器在溢出时回绕为零,并且代码中的其他地方需要这种行为?
让我分享一个最近的经历,它完美地明确了这个问题。
一位开发者怀疑一种新的开源工具窃取了他们的代码并在没有许可证的情况下将其开源。他们决定使用 Vendetect,这是一款我在 Trail of Bits 开发的自动化源代码剽窃检测工具。Vendetect 专为此用例而设计:你将其指向两个 Git 存储库,它会找到一个存储库中从另一个存储库复制的部分,包括特定的违规提交。
当开发者运行 Vendetect 时,它失败并显示堆栈跟踪。
开发者理所当然地向 Claude 寻求帮助。Claude 分析了代码,检查了堆栈跟踪,并迅速确定了它认为的罪魁祸首:Vendetect 的 Git 存储库分析核心中的一个复杂的递归 Python 函数。Claude 乐于提交了一个 GitHub 问题和一个广泛的拉取请求,以“修复”该错误。
我被分配审查 PR。
首先,我查看了 GitHub 问题。我已经几个月没写那个递归函数了,Claude 的解释似乎是合理的!它看起来确实像一个错误。当我从 PR 中检出代码时,崩溃确实消失了。没有更多堆栈跟踪。问题解决了,对吧?
错了。
Vendetect 的输出现在是空的。当我运行单元测试时,它们失败了。有些东西坏了。
现在,我知道 Python 中的递归是有风险的。Python 的堆栈帧足够大,你可以很容易地通过深度递归来溢出堆栈。但是,我也知道此特定递归函数的输入受到约束,因此它永远不会递归超过几次。Claude 要么错过了此约束,要么没有被它说服。因此 Claude 痛苦地重写了该函数以使其成为迭代的。
并在此过程中破坏了逻辑。
我恢复到 main 分支上的原始代码并重现了崩溃。经过几分钟的调试后,我发现了实际问题:这根本不是 Vendetect 中的错误。
开发者的输入存储库包含两个具有相同名称但大小写不同的文件:一个以大写字母开头,另一个以小写字母开头。开发者和我都在运行 macOS,默认情况下 macOS 使用不区分大小写的文件系统。当 Git 尝试在不区分大小写的文件系统上对具有文件名冲突的存储库进行操作时,它会引发错误。Vendetect 忠实地报告了此 Git 错误,但随后是一个堆栈跟踪,以显示 Git 错误发生在代码中的哪个位置。
我最终确实修改了 Vendetect 来处理此边缘情况并打印一个更易于理解的错误消息,该消息没有被堆栈跟踪所掩盖。但是 Claude 自信地诊断和“修复”的错误根本不是错误。Claude “修复”了正常工作的代码,并在该过程中破坏了实际功能。
这种经历明确了问题:LLM 像人类在第一天查看代码库时那样处理代码:对事物为何如此没有背景知识。
递归函数在 Claude 看来是有风险的,因为 Python 中的递归可能是有风险的。在没有 Git 存储库结构的性质会限制此特定递归的背景知识的情况下,Claude 做出了看似合理的更改。即使在崩溃消失的意义上,它甚至也“起作用”了。只有彻底的测试才能发现它破坏了核心功能。
这里有个关键:Claude 很自信。GitHub 问题很详细。PR 很广泛。没有回避,没有不确定性。就像一个不知道自己不知道什么的初级开发者一样。
LLM 在具有明确规范的全新项目中运行良好。一个简单的 Web 应用程序、一个标准的 CRUD 界面、样板代码。这些是 LLM 见过数千次的模板。问题在于,这些不是开发者最需要帮助的情况。
将软件架构视为建筑架构。预制棚在存储方面表现良好:要求简单、约束标准,并且可以对设计进行模板化。这是你的具有明确规范的全新 Web 应用程序。LLM 可以生成一些功能性的东西。
但是想象一下,从一开始就用模块化部件拼凑摩天大楼,而没有一个有凝聚力的计划。你最终会得到九龙城寨:功能齐全,但无法维护。
图 1:Gemini 对迭代构建的摩天大楼的看法。
那么,翻新一座有 100 年历史的建筑呢?你需要知道:
建筑计划(原始的、确定性的规范)至关重要。你不能只是派一个第一次看到这座建筑的承包商,并根据其认为正确的内容开始挥舞大锤。
遗留代码库与此完全一样。它们具有:
当你有一个具有模糊内部 API 的复杂系统,不清楚哪个服务与哪个服务对话或者出于什么原因,并且文档已经过时多年且太大而无法放入 LLM 的上下文窗口中时,这正是 LLM 最有可能自信地做错事的时候。
Vendetect 的故事是这个问题的一个缩影。重要的背景知识(递归受到 Git 结构的限制,真正的问题是文件系统怪癖)从查看代码中并不明显。Claude 用看似合理的假设填补了空白。这些假设是错误的。
我并不是反对 LLM 编码助手。在我广泛使用 LLM 编码工具进行代码生成和错误查找的过程中,我发现它们非常有用。它们擅长生成样板代码、提出建议、充当调试的橡皮鸭以及总结代码。生产力的提高是真实的。
但我们需要清楚地了解它们的根本局限性。
当你拥有以下内容时,LLM 最有效:
最后两个对于成功绝对是必要的,但通常是不够的。在这些环境中,LLM 可以加速开发。生成的代码可能并不完美,但错误会很快被发现,并且迭代的成本很低。
如果最终目标是将开发者的抽象级别提高到高于审查代码的水平,我们将需要以下框架和实践:
用于 LLM 输出的形式验证框架。 我们将需要可以证明语义保留的工具,即 LLM 的更改维护了代码的预期行为。这很难,但并非不可能。我们已经有了某些领域的形式方法。我们需要扩展它们以覆盖 LLM 生成的代码。
更好的编码上下文和约束的方式。 LLM 需要的不仅仅是代码;它们需要理解不变量、假设、历史背景。我们需要更好的方法来捕获和传达这些信息。
超越“它会崩溃吗?”的测试框架。 我们需要测试语义正确性,而不仅仅是语法有效性。代码是否按预期执行?是否维护了安全属性?性能特征是否可以接受?单元测试是不够的。
用于衡量语义正确性的指标。 “它可以编译”是不够的。即使“它通过了测试”也是不够的。我们需要量化语义是否已被保留的方法。
默认安全的可组合构建块。 我们将需要 LLM 使用经过验证为安全的模块化、可组合的构建块来构建,而不是允许 LLM 编写任意代码。有点像工业用品如何商品化为类似乐高的零件。需要一个带有 D 型轴的 NEMA 23 方形步进电机吗?无需自己设计和构建它,你可以从十几个不同的制造商那里购买现成的电机,它们都可以很好地安装到你的项目中。同样,LLM 不应实施自己的身份验证流程。它们应该协调预制的身份验证模块。
在拥有这些框架之前,我们需要一个清晰的 LLM 输出心智模型:将其视为来自第一次看到代码库的初级开发者的代码。
这意味着:
作为一种概率系统,LLM 始终有可能引入错误或错误地解释其提示。(这些实际上是同一件事。)这个概率需要多小?理想情况下,它应该小于人类的错误率。我们还没有达到目标,甚至接近。
自从我在 2017 年写下它们以来,自动化的基本计算限制并没有改变。改变的是,我们现在拥有了可以更轻松地自信且大规模地生成不正确代码的工具。
当我们从汇编语言转向 C 时,我们没有放弃确定性;我们构建了保证语义保留的编译器。当我们转向 LLM 辅助开发时,我们需要类似的保证。但解决方案不是拒绝 LLM!它们为某些任务提供了真正的生产力提升。我们只需要记住,它们的输出与第一次看到代码库的人的代码一样值得信赖。正如我们不会在没有审查和测试的情况下合并来自新开发者的 PR 一样,我们也不能将 LLM 输出视为自动正确。
如果你对形式验证、自动化测试或构建更值得信赖的人工智能系统感兴趣,请 联系我们。在 Trail of Bits,我们正在研究这些问题,我们很乐意听取你使用 LLM 编码工具的经验,无论是成功还是失败。因为现在,我们都在一起学习哪些有效,哪些无效。我们分享的经验越多,我们就越有能力构建我们需要的验证框架。
我们需要一种衡量 AI 安全性的新方法 2023 年 3 月 14 日\ Trail of Bits 推出了一项专注于机器学习和人工智能的实践,将安全……结合在一起从不乏味的时刻:利用机器学习 pickle 文件 2021 年 3 月 15 日\ 许多机器学习 (ML) 模型在底层都是 Python pickle 文件,这是有道理的。使用 pickling ……驯服文件背叛行为的两款新工具 2019 年 11 月 1 日\ 即使文件格式指定良好,解析也很困难。但是,当规范不明确时,会导致……
- 原文链接: blog.trailofbits.com/202...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!