统一差异格式使 GPT-4 Turbo 的”懒惰”行为减少 3 倍

机器人流程图

Aider 现在要求 GPT-4 Turbo 使用 统一差异格式 来编辑代码。 这显著提升了 GPT-4 Turbo 在具有挑战性的 新基准测试中的表现, 并大幅减少了它”懒惰”编码的不良习惯—— 即编写带有类似”…在此添加逻辑…” 这样注释的代码。

Aider 新的”懒惰度”基准测试套件 旨在诱发并量化这种懒惰编码行为。 它包含 89 个 Python 重构任务, 这些任务往往会促使 GPT-4 Turbo 写出类似”…包含原始方法体…” 这样的懒惰注释。

使用 gpt-4-1106-preview 模型, 这个新的懒惰度基准测试得出以下结果:

  • GPT-4 Turbo 基线得分仅为 20%(使用 aider 现有的”SEARCH/REPLACE 块”编辑格式)。它在 12 个任务中输出了”懒惰注释”。
  • Aider 的新统一差异编辑格式将得分提升至 61%。使用此格式使懒惰行为减少了 3 倍,GPT-4 Turbo 仅在 4 个任务中使用懒惰注释。
  • 添加情感诉求提示反而更糟。那些广为流传的”情感诉求”民间偏方(如声称用户失明、无手、会付 2000 美元小费、害怕代码截断创伤等)在 SEARCH/REPLACE 基线和统一差异格式下的基准得分都更差。

较旧的 gpt-4-0613 模型使用统一差异格式后, 在懒惰度基准测试中也有更好表现:

  • 6 月版 GPT-4 基线得分为 26%(使用 aider 现有的”SEARCH/REPLACE 块”编辑格式)。
  • Aider 的新统一差异编辑格式将 6 月版 GPT-4 得分提升至 59%
  • 该基准测试设计使用大文件,其中 28% 的文件超过 6 月版 GPT-4 8k 上下文窗口的限制,这使得该模型的理论最高得分上限为 72%。

使用统一差异格式后,GPT 的表现更像是 在编写供程序读取的文本数据, 而非与人交谈。 差异文件通常由 patch 程序处理, 该程序相当严格。 这似乎能鼓励严谨性, 使 GPT 不太可能在注释中留下非正式的编辑指令, 或偷懒不编写所有必要的代码。

Aider 的新统一差异编辑格式 远超我评估过的其他解决方案。 我探索过许多其他方法,包括: 关于不知疲倦和勤奋的提示、 OpenAI 的函数/工具调用能力、 aider 现有编辑格式的多种变体、 基于行号的格式 以及其他类似差异的格式。 本文分享的结果反映了 对多种方法进行的广泛调查和基准评估。

本文其余部分将描述 aider 的新编辑格式和重构基准测试, 重点介绍一些关键设计决策, 并通过消融实验评估其重要性。

统一差异编辑格式

aider 新设计的统一差异编辑格式在实现过程中,帮助明确了 GPT-4 代码编辑的一些通用原则:

  • 熟悉性 - 选择 GPT 已经熟悉的编辑格式
  • 简洁性 - 选择简单格式,避免转义字符、语法开销以及行号或行数等易碎的定位标识
  • 高层次 - 鼓励 GPT 将编辑内容组织为完整代码块(函数、方法等)的新版本,而不是对单行代码进行零散/最小化的修改
  • 灵活性 - 在解释 GPT 的编辑指令时尽可能保持最大灵活性

这里有个实用技巧:要站在 GPT 的角度思考,想象你自己被要求指定代码编辑。你会愿意手动输入一个需要正确转义的 json 数据结构来对特定行号执行插入、删除、替换操作吗?你会想使用一个脆弱的格式,任何错误都会导致所有工作被丢弃吗?

通过使用熟悉、简洁、高层次且灵活的编辑格式来减轻格式编辑负担,GPT 在代码编辑方面的表现会显著提升。

选择熟悉的编辑格式

统一差异格式可能是展示代码编辑最常用的方式,因为它是 git diff 的默认输出格式:

--- a/greeting.py
+++ b/greeting.py
@@ -1,5 +1,5 @@
 def main(args):
     # 显示问候语
-    print("Hello!")
+    print("Goodbye!")
     return

选择这种流行格式意味着 GPT 在其训练数据中已经见过大量示例。它经过训练能够生成符合统一差异语法的文本。

使用简单的编辑格式

Aider 的先前基准测试结果清楚地表明,简单的编辑格式效果最佳。尽管 OpenAI 对 JSON 和函数调用等结构化格式提供了广泛支持,但如果使用这些格式,GPT 在编辑代码方面的表现会更差。我针对 GPT-4 Turbo 重复了这些以及其他类似的基准测试,并再次得出了相同的结论。

非正式地说,这可能是因为将源代码塞入 JSON 既复杂又容易出错。将 Python 代码 print("On Windows use \"C:\\\"") 包装为有效的 JSON 相当麻烦且容易出错。由于转义问题,GPT 的代码在从 JSON 解包时经常会出现语法错误,或者 JSON 解码完全失败。

另一方面,统一差异格式(unified diff)的核心非常简单。你只需要包含需要更改的文件块,每行前面加上一个字符来指示未更改、新增或删除的行。统一差异看起来几乎就像它正在修改的代码一样。

唯一复杂的部分是每个块开头的行号。它们看起来像这样:@@ -2,4 +3,5 @@。GPT 在处理源代码行号方面表现非常糟糕。这是关于任何编辑格式中使用行号的一般观察,并得到了许多定量基准实验的支持。

你可能已经忽略了你在每个差异中看到的行号,因为差异通常在没有它们的情况下仍然有意义。Aider 告诉 GPT 不要包含行号,而是将统一差异中的每个块解释为搜索和替换操作:

这个差异:

@@ ... @@
 def main(args):
     # show a greeting
-    print("Hello!")
+    print("Goodbye!")
     return

意味着我们需要在文件中搜索空格减号 - 行:

def main(args):
    # show a greeting
    print("Hello!")
    return

并将它们替换为空格加号 + 行:

def main(args):
    # show a greeting
    print("Goodbye!")
    return

很简单,对吧?

鼓励高层次编辑

目前我们看到的统一差异(diff)示例都是单行修改,这使得它们相对容易阅读和理解。现在来看一个稍复杂的修改示例,它将变量 n 重命名为 number

@@ ... @@
-def factorial(n):
+def factorial(number):
-    if n == 0:
+    if number == 0:
         return 1
     else:
-        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 经常生成不完美的 diff 补丁,无法直接干净地应用。这些问题表现为多种形式:

  • GPT 会遗漏注释、文档字符串、空行等内容。或者它会跳过一些本不想修改的代码。
  • GPT 会忘记在新增行前添加加号 + 标记。错误地将这些行以空格开头显示,就像它们已经存在一样。
  • GPT 会移除所有行的公共前导空白,导致代码整体缩进不正确。因此一段深度缩进的代码在 diff 中显示时,可能只保留了各行之间变化的那部分前导空格。
  • GPT 会直接跳转到文件中不同部分的编辑,而没有用 @@ ... @@ 分隔符开始一个新的代码块。

以第一个问题为例,考虑以下源代码:

import sys

def main(args):
    # show a greeting
    print("Hello!")
    return

main(sys.argv[1:])

下面的差异缺少了”显示问候语”的注释行, 这是一个GPT可能会犯的常见错误类型。 当我们搜索带减号-的行时, 由于缺少注释, 我们不会在原始文件中找到这些行。

@@ ... @@
-def main(args):
-    print("Hello!")
-    return
+def main(args):
+    print("Goodbye!")
+    return

aider在应用差异时会非常灵活, 以处理各种缺陷。 如果一个代码块(hunk)无法干净地应用,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 新采用的统一差异格式(unified diff)似乎能显著提升 GPT-4 Turbo 在复杂编码任务中的表现。 该格式对于减少 GPT-4 Turbo 的”懒惰编码”问题(这一现象已被广泛关注)也表现出显著效果。

统一差异格式实际上是我最初构建 aider 时尝试的首批编辑格式之一。 我认为许多其他 AI 编码助手项目也曾探索过这条路径。 任何对结构化差异格式的简单或直接使用似乎都注定会失败。 但本文描述并已整合到 aider 中的技术, 为利用 GPT 对统一差异格式的理解提供了一种高效方法。

基于 aider 这种简洁、高层次的统一差异风格对模型进行微调, 可能会带来显著收益。 从差异块头中移除行号信息, 专注于语义连贯的代码块差异, 似乎是实现成功 GPT 代码编辑的关键要素 (除了始终强调灵活应用编辑之外)。 大多数 LLM 在常规训练数据中已经接触过大量统一差异格式, 因此应该能够通过微调适应这种特定的差异风格。


目录