GPT-4 Turbo 通过统一差异 (unified diffs) 更有效率地编程 [译]
Aider 现在让 GPT-4 Turbo 采用统一差异来编辑代码。这大幅提升了 GPT-4 Turbo 在全新且富有挑战性的基准测试中的表现,并显著减少了它在编程时倾向于写出像“...在此添加逻辑...”这类注释的惰性。
Aider 设计的这套新“惰性编程”基准测试,旨在诱导并量化编程中的惰性。它包括了 89 个 Python 代码重构任务,这些任务常常导致 GPT-4 Turbo 编写出类似“...包含原始方法体...”的惰性评论。
在这个新的惰性基准测试中,使用 gpt-4-1106-preview
版本得到了以下结果:
- GPT-4 Turbo 在 Aider 原有的“搜索/替换块”编辑模式下,基准得分仅为 20%。它在 12 个任务中产生了惰性评论。
- 采用 Aider 新的统一差异编辑模式后,分数提升至 61%。这种模式使惰性降低了三倍,GPT-4 Turbo 只在 4 个任务中表现出惰性。
- 若添加提示,如用户是盲人、没有手、愿意支付 2000 美元小费且担心代码不完整,情况会更糟。这种广泛传播的“情感呼吁式”民间解决办法在基础搜索/替换和新统一差异编辑模式下都带来了更差的成绩。
使用统一差异的早期版本 gpt-4-0613
在惰性基准测试中的表现也有所提升:
- 6 月版本 GPT-4 在 Aider 原有的“搜索/替换块”编辑模式下的基础得分为 26%。
- 采用 Aider 新的统一差异编辑模式后,6 月版本 GPT-4 的分数提升至 59%。
- 由于这个基准测试设计用于处理大文件,其中 28% 的文件过大,无法适应 6 月版 GPT-4 的 8k 上下文窗口。因此,6 月版模型可能达到的最高分数上限为 72%。
在应用统一差异 (unified diffs) 的情况下,GPT 的行为更像是在编写供程序阅读的文本数据,而不是在与人进行交流。这类差异通常被 patch 程序处理,而该程序的运作相当严格。这种方式似乎促进了严谨性,降低了 GPT 在注释中留下非正式编辑指令或在编写必要代码时显得马虎的可能性。
Aider 推出的新型统一差异编辑格式在我所评估的众多方案中表现卓越。我探索了包括持续不懈和勤奋的提示、OpenAI 的功能/工具调用能力、Aider 现有编辑格式的多种变体、基于行号的格式及其他类似差异的格式等多种方法。这里分享的结果是对众多方法进行了广泛的调查和基准测试后得出的。
接下来的部分将介绍 Aider 的新编辑格式和重构基准。文章将着重介绍一些关键的设计决策,并通过剥离实验 (ablation experiments) 来评估它们的重要性。
统一差异编辑格式
在设计和实施 Aider 的新型统一差异编辑格式过程中,我们明确了几项适用于 GPT-4 代码编辑的一般原则:
- 熟悉 - 选择 GPT 已经熟悉的编辑格式。
- 简单 - 选用一种简单的格式,避免复杂的转义、句法负担,以及像行号或行数这样易出错的指示符。
- 高层次 - 鼓励 GPT 将编辑安排在代码的重要部分(如函数、方法等),而非对单行代码进行微小的连续更改。
- 灵活 - 力求在解释 GPT 的编辑指令时尽可能保持灵活性。
这里一个有效的思考方式是设身处地为 GPT 考虑,想象自己被要求指定代码编辑。你会想要手动键入一个经过正确转义的 json 数据结构,以实现对特定代码行的精确插入、删除或替换操作吗?你愿意使用一个错误容易导致整体工作失效的脆弱格式吗?
当你通过使用一个熟悉、简单、高层次和灵活的编辑格式来降低编辑格式化的负担时,GPT 在代码编辑方面的表现会有显著提升。
选择一个熟悉的编辑格式
统一差异格式(Unified diffs)可能是最常用来展示代码更改的方法,主要因为它是 git diff
命令的默认输出形式:
--- a/greeting.py+++ b/greeting.py@@ -1,5 +1,5 @@def main(args):# show a greeting- print("Hello!")+ print("Goodbye!")return
选择这种广泛使用的格式意味着 GPT 在它的训练数据中接触过大量此类示例。因此,它被训练得能够生成遵循统一差异格式规则的文本。
使用简单的编辑格式
Aider 的 先前的基准测试 明确显示,简单的编辑方式效果最好。虽然 OpenAI 提供了对如 json 和函数调用这类结构化格式的广泛支持,但 GPT 在使用这些格式编辑代码时效果不佳。我重复了这些和其他类似的基准测试,针对 GPT-4 Turbo,得出了相同的结论。
非正式地讲,这可能是因为把 源代码 放进 JSON 很复杂,容易出错。比如,把 Python 代码 print("On Windows use \"C:\\\"")
转换成有效的 JSON 格式,既困难又容易出错。由于转义字符的问题,从 JSON 中提取出来的 GPT 代码经常语法不正确,或者是 JSON 解码根本就失败。
另一方面,统一差异格式(unified diff format)的核心非常简单。你只需引入文件中需要更改的部分,每行前加一个字符来标示这行是保持不变、新增还是删除。统一差异的样式几乎和它要修改的代码一样。
唯一比较复杂的是每个更改块(hunk)开头的行号。它们看起来像这样:@@ -2,4 +3,5 @@
。GPT 在处理源代码中的行号时表现很差。这是关于在任何编辑格式中使用行号的通用观察,许多量化的基准测试也支持了这一点。
你可能在看过的每个代码差异中都忽略了行号,因为即使没有行号,这些差异通常仍然是有意义的。Aider 会指示 GPT 不包含行号,只是把统一差异中的每个更改块当作一个查找和替换操作:
这个差异:
@@ ... @@def main(args):# show a greeting- print("Hello!")+ print("Goodbye!")return
意味着我们需要在文件中查找 空格 和 减号 -
的行:
def main(args):# show a greetingprint("Hello!")return
然后用 空格 和 加号 +
的行来替换它们:
def main(args):# show a greetingprint("Goodbye!")return
很简单,对吧?
鼓励进行高级编辑
我们之前看到的示例中的合并后的差异都是单行更改,这让它们易于阅读和理解。试想一个稍微复杂些的更改:将变量 n
重命名为 number
:
@@ ... @@-def factorial(n):+def factorial(number):- if n == 0:+ if number == 0:return 1else:- return n * factorial(n-1)+ return number * factorial(number-1)
下面展示的是同一更改的“高级差异”,虽然它不如最小差异那么简洁,但更便于我们看出 factorial()
函数的两个不同但协调的版本。
@@ ... @@-def factorial(n):- if n == 0:- return 1- else:- return n * factorial(n-1)+def factorial(number):+ if number == 0:+ return 1+ else:+ return number * factorial(number-1)
Aider 系统的提示促使 GPT 产生这些高级差异。这让 GPT 更擅长生成正确的差异,能够成功地应用到原始文件上。
实验显示,如果不使用“高级差异”提示,编辑错误会增加 30-50%, 这些错误包括差异无法应用或应用不当,从而产生无效的代码。当修补程序失败时,aider 需要向 GPT 请求更正后的差异版本,这不仅耗时,还会消耗 Token,并且有时即便多次尝试也无法成功进行编辑。
高级差异之所以有效,可能有以下几个原因:
- 生成那些既能准确匹配原始代码,又能正确产生预期新代码的差异变得更加容易。与生成一系列混入现有代码中的细致编辑相比,GPT 混淆的可能性更小。
- 高级差异块通常包含更多行数,相较于精细编辑,它们不太可能误匹配代码中的无关部分。这一点很重要,因为 GPT 不能可靠地提供具体的行号来精确指明文件中更改的位置。
在应用修改时需灵活处理
GPT 经常产生不完整的差异记录(diffs),这些记录往往无法直接有效应用。它们存在各种问题:
- GPT 常忘记包含注释、文档字符串、空行等元素,或者忽略了一些本不打算修改的代码。
- GPT 有时忽略了标识新增代码行的前导 加号
+
,错误地将这些行以前导 空格 的形式呈现,好像这些代码行原本就存在。 - GPT 会错误地修改代码的缩进,移除了代码行之间共有的空白。这样,一个深度缩进的代码块在差异记录中,只显示了行与行之间的不同缩进部分。
- GPT 有时会直接跳转到文件的其他部分进行编辑,而没有使用
@@ ... @@
分隔符来开始一个新的修改块(hunk)。
例如,在以下源代码中:
import sysdef main(args):# show a greetingprint("Hello!")returnmain(sys.argv[1:])
下面的差异记录缺少了“显示问候”这一注释行,这是 GPT 常犯的典型错误。当我们寻找标记为 减号 -
的原代码行时,由于缺少注释,我们在原始文件中找不到对应内容。
@@ ... @@-def main(args):- print("Hello!")- return+def main(args):+ print("Goodbye!")+ return
Aider 在应用这些差异记录时尽量保持灵活,以处理这些缺陷。如果某个修改块无法顺利应用,Aider 会采用多种策略来解决。
- 通过对比 减号
-
和 空格 行作为代码片段的一种表达形式,以及 空格 和 加号+
行作为另一种形式,进行真实的统一差异比较(unified diff),以此来规范化代码片段。 - 尝试寻找 GPT 试图添加的新行,但忘记标记为 加号
+
。这是通过将 减号-
和 空格 行与原始文件进行差异对比来实现的。 - 尝试采用“相对前导空格”策略来应用代码片段,确保即使代码片段整体缩进或缩出,也能正确匹配和修复。
- 将大的代码片段分解为重叠的小片段序列,每个小片段只包含一组连续的 加号
+
和 减号-
行。尝试分别应用这些子片段。 - 调整用于将编辑定位到文件特定部分的代码片段中 空格 行的“上下文窗口”的大小和偏移量。
- 结合以上方法,逐步提高对如何应用代码片段的灵活性和容忍度。
这些灵活的打补丁策略至关重要。去掉它们会显著增加无法应用的代码片段数量。在一项禁用灵活打补丁的实验中,编辑错误率在 aider 的原始 Exercism 基准测试上增加了 9 倍。
性能基准测试的重构
Aider 长期使用的是一个基于 133 个 Exercism Python 练习的基准测试套件。但这些主要是规模较小的编程问题,通常仅需数十行代码。GPT-4 Turbo 通常在这些练习中的 2-3 个表现较为迟缓,尤其是那些代码量较大且涉及代码重构的练习。
鉴于此,我着手构建了一个基于重构较大文件中大量代码的基准测试。为此,我使用了 Python 的 ast
模块来分析9 个流行的开源 Python 仓库,目的是找出具有挑战性的重构任务。我们的目标是寻找:
- 包含有复杂方法的类的源文件,这些方法的实现中包含 100-250+ 个 AST 节点。
- 重点关注那些属于较大类的一部分的方法,其中类的代码量至少是方法本身的两倍。
- 选取那些不使用
self
参数的方法,以便可以简单地将其从类中独立出来进行重构。
我们接下来将这些源文件转化为基准测试中的任务,其中包括像这样的挑战给 GPT:
请将
CsrfViewMiddleware
类中的_set_csrf_cookie
方法重构为一个独立的顶级函数。新函数的命名仍为_set_csrf_cookie
,与原方法名完全相同。请更新所有现有的self.__set_csrf_cookie
调用,以适应新的_set_csrf_cookie
函数。
一个简易的 Python AST 扫描脚本找到了 89 个适合的文件,并将它们整理成了基准测试的任务。每个任务都包含一个测试,用来检查重构是否基本正确地完成:
- 更新后的源文件必须能被正确解析为有效的 Python 代码,以便于检测那些可能导致代码无效的错误编辑。
- 现在,目标方法必须作为文件中的一个顶层函数存在。
- 这个新的顶层函数应该包含与原先类方法大致相同数量的抽象语法树(AST)节点,确保 GPT 没有删减原代码,或是用注释替代原代码。
- 文件中的原始类依然存在,并且在去除了某个方法后,它的大小应该减少大约该方法中包含的 AST 节点数。这有助于验证该方法确实被从类中移除,且没有进行其他重大修改。
明确地说,这不是一个严格的测试来验证重构是否正确执行。但它确实可以作为一个基本的合理性检查,以确保重构基本上是通过剪切和粘贴完成的,没有通过注释的形式删减任何代码。此外,它与基准测试期间收集的其他指标(如引入含有“…”的新注释)相关联。
这个实践性强的基准测试套件用于诱发、检测和量化 GPT 编码时的懒惰现象。
结论和未来的工作
根据重构基准测试的结果,aider 的新统一差异格式显著提高了 GPT-4 Turbo 在处理更复杂编码任务上的能力。它也有效减少了 GPT-4 Turbo 中被广泛指出的懒惰编码问题。
统一差异格式是我在最初构建 aider 时尝试的第一种编辑格式。我认为很多其他的 AI 编码助手项目也尝试过这种方式。看起来,任何对结构化差异格式的直接使用几乎都无法成功。但是,结合到 aider 中的这些技术,为有效利用 GPT 对统一差异格式的了解提供了有效的方法。
对 aider 的简洁、高级统一差异格式进行微调可能带来重大益处。在编辑时去除代码块头部的行号,专注于具有语义连贯性的代码块差异,这似乎是成功进行 GPT 代码编辑的关键(除了灵活地应用编辑)。大多数大语言模型(大语言模型)在正常训练数据中已经看过大量统一差异,因此,对这种特定差异风格的微调应该是可行的。