译:我是如何利用 LLM 进行编程的

原文:https://crawshaw.io/blog/programming-with-llms
作者:David Crawshaw
译者:ChatGPT 4 Turbo

编者注:作者主要通过三种方式使用 LLM:1) 自动补全代码,提高输入效率,2) 搜索技术问题,获得比传统搜索引擎更好的答案,3) 对话式编程,虽然需要适应但收益最大。作者认为 LLM 最适合处理考试式的明确任务,建议将代码拆分成更小的包以便 LLM 理解。

本文是我过去一年间在编程时使用生成模型的个人经验总结。这并非一个被动的过程。我有意寻找在编程时使用 LLM 的方式来学习它们。结果是,我现在在工作时经常使用 LLM,我认为它们对我的生产力有着净正面的影响。(我尝试回到不使用它们的编程工作中去,感觉很不愉快。)

一路上,我发现经常重复的步骤可以自动化,我们中的一些人正在努力将这些步骤构建成一个专门针对 Go 编程的工具:sketch.dev。虽然这还处于初级阶段,但到目前为止,体验是积极的。

背景

我对新技术通常充满好奇。很少有实验性探索就让我想看看是否能从 LLM 中提取出实际价值。对于一项技术,如果它能(至少在某些时候)对复杂问题制定出精巧的回应,那将具有极大的吸引力。更令人兴奋的是,观察计算机尝试按照请求编写程序的一部分,并取得扎实的进展。

我经历过的唯一一个与此类似的技术转变发生在 1995 年,当时我们首次配置了具有可用默认路由的局域网。我们用一台能够路由拨号连接的机器替换了另一个房间中的共享计算机,这台机器运行着 Trumpet Winsock,而我突然间就拥有了随时可用的互联网。全天候拥有互联网是令人惊叹的,感觉就像未来一样。对我而言,可能比许多在大学中已经用过互联网很长时间的人更有感觉,因为我立即被带入了高端互联网技术:网页浏览器、JPEG 图像,以及数以百万计的人。能够使用强大的 LLM 给我带来了同样的感觉。

因此,我跟随着这份好奇心,看看一个大部分时间能生成大致正确的东西的工具是否能在我日常工作中带来净收益。答案似乎是肯定的,当我编程时,生成模型对我有用。到达这一点并不容易。如果没有我对这项新技术的根深蒂固的迷恋,我是无法弄明白它的,所以当其他工程师声称 LLM 是“无用”的时候,我能够感同身受。但既然已经不止一次被问到我如何可能有效地使用它们,这篇文章就是我到目前为止发现的尝试描述。

概述

在我的日常编程中,我用 LLMs 的方式有三种:

  1. 自动完成。这通过为我完成大量较为显而易见的打字工作,使我更加高效。目前来看,这方面的最新技术还有改进的空间,但那是另一天的话题。即便是市面上可以买到的标准产品,对我来说也比没有好。我通过尝试放弃它们来说服自己,结果我无法忍受一个星期没有 FIM 模型就会对大量的平凡打字感到沮丧。这是首先要尝试的地方。
  2. 搜索。如果我对复杂环境有疑问,例如“如何在 CSS 中使按钮透明”,向任何基于消费者的 LLM 提问,例如 o1, sonnet 3.5 等,我会得到比使用老式网络搜索引擎并试图从落脚的页面中分析细节更好的答案。(有时 LLM 是错的。人也是一样。前几天我把鞋子放在头上,问我的两岁孩子对我的帽子有什么看法。她妥善处理了这一情况并给了我适当的训斥。我也可以接受 LLM 有时候会出错。)
  3. 聊天式编程。这是三者中最困难的。这是我从 LLMs 中获得最大价值的方式,但也是让我最感困扰的。它涉及到大量的学习和调整编程方式,原则上我不喜欢这样。它至少需要同学习使用计算尺一样多的摸索才能从 LLM 聊天中获得价值,加上它是一种非确定性服务,其行为和用户界面定期更改,这增加了烦恼。的确,我工作中的长期目标是替换掉聊天式编程的需求,以一种不那么令人反感的方式将这些模型的力量带给开发者。但就目前而言,我致力于以渐进的方式处理这个问题,这意味着需要弄清楚如何最好地使用我们现有的资源并改进它。

由于这涉及到编程的_实践_,这本质上是一个难以用定量严谨性来书写的定性过程。我所能提供的最接近数据的说法是:根据我的记录,现在我每两小时的编程工作中,我会接受超过 10 次自动完成的建议,使用 LLM 进行一次类似搜索的任务,并进行一次聊天会话编程。

剩下的内容是关于从基于聊天的编程中提取价值。

为什么要使用聊天?

让我来为持怀疑态度的人解释一下。我个人从基于聊天的编程中获得的很多价值是,当我达到一天中知道需要写什么、能够描述它,但没有精力创建新文件、开始输入,然后开始查找我需要的库的时候。(我是一个早上比较有精神的人,所以对我来说这通常是在上午 11 点之后的任何时间,虽然当我切换到不同的语言/框架等时,也可能是任何时间。)LLM 为我提供了编程服务。它们给了我一个初稿,里面有一些好的想法,带有我需要的几个依赖项,而且经常有一些错误。通常,我发现修正这些错误比从头开始容易得多

这意味着基于聊天的编程可能不适合你。我正在做一种特定类型的编程,产品开发,可以粗略地描述为试图通过一个健壮的界面将程序带给用户。这意味着我在构建很多内容,抛弃很多内容,并在环境之间来回跳跃。有些日子我主要写 TypeScript,有些日子主要写 Go。上个月我在一个 C++ 代码库中花了一周时间探索一个想法,并刚刚有机会学习 HTTP 服务器端事件格式。我到处都是,不断地忘记和重新学习。如果你花更多时间证明你的加密算法优化不容易受到时间攻击,而不是编写代码,我认为我这里的任何观察都对你没什么用。

基于聊天的 LLM 最擅长考试风格的问题

给 LLM 一个具体的目标和它所需要的所有背景材料,这样它就能制定一个包含良好的代码审查包,并期望它能随着你的提问进行调整。这里有两个主要元素:

  1. 避免创建过于复杂和含糊的情况,使得 LLM 感到困惑并产生糟糕的结果。这就是为什么我在我的 IDE 内部很少与聊天成功。我的工作空间通常很乱,我正在工作的存储库默认太大,它充满了干扰。人类在(截至 2025 年 1 月)不分心方面似乎比 LLM 做得好得多。这就是为什么我仍然通过网络浏览器使用 LLM,因为我想要一个空白的画布来制定一个包含良好的请求。
  2. 请求容易验证的工作。作为一个使用 LLM 的程序员,你的工作是阅读它产生的代码,思考,并决定这项工作是否优良。你可以让 LLM 做一些你永远不会要求人类做的事情。“重写所有的新测试,引入一个 <旨在使测试更易读的中间概念>”是一个要求人类做的可怕事情,这会让你经历几天的紧张反复,以决定工作的成本是否值得其好处。LLM 在 60 秒内就能完成,且不会让你为了完成它而争执。利用重做工作成本极低的事实。

LLM 的理想任务是需要使用大量常见库的任务(比人类能记住的还要多,因此它为你执行了大量的小规模研究),按照你设计的接口工作或产生一个你可以快速验证为合理的小接口,并且它能编写可读的测试。有时这意味着为它选择库,如果你想要一些不常见的东西(尽管有开源代码,LLM 在这方面相当擅长)。

你总是需要先通过编译器运行 LLM 的代码并运行测试,然后再花时间阅读它。它们都会产生有时无法编译的代码。(总是犯错误,我发现每次看到这样的错误时,都会想,但凭上帝的恩典,我也会如此。)更好的 LLM 在从错误中恢复方面非常擅长,通常它们需要的只是你将编译器错误或测试失败粘贴到聊天中,它们就会修复代码。

额外的代码结构成本更低

我们每天都在写代码、读代码和重构代码的成本之间做着模糊的权衡。以 Go 包边界为例。标准库中有一个包 “net/http”,它包含了一些处理线路格式编码、MIME 类型等的基本类型。它包含一个 HTTP 客户端和一个 HTTP 服务器。它应该是一个包还是几个包?有理由的人可能会有不同的看法!如此之多,我今天不知道是否有一个正确的答案。我们所拥有的运作良好,经过 15 年的使用,我仍然不确定是否有其他包排列会更好。

包的较大尺寸的优势包括:为调用者提供集中式文档、初始编写更容易、重构更容易、无需为它们设计健壮的接口就能更容易地共享助手代码(这通常涉及到将包的基本类型移到另一个充满类型的叶子包中)。缺点包括包因为有很多不同的事情在进行而更难阅读(尝试阅读 net/http 客户端实现时不要绊倒,并发现自己在几分钟内处于服务器代码中),或者因为它内容太多而更难使用。例如,我有一个代码库,它在一些基本类型中使用了 C 语言库,但是代码库的部分需要在一个广泛分发到许多平台的二进制文件中,而该二进制文件技术上不需要 C 语言库,所以在代码库中你可能会发现比预期中更多的包来隔离 C 语言库的使用,以避免在多平台二进制文件中使用 cgo。

这里没有正确答案,相反,我们在交换一个工程师将不得不做的不同类型的工作(前期和持续进行)。LLM 影响这些权衡:

  • 随着 LLM 在考试风格的问题上做得更好,更多且更小的包使得为一项工作提供完整且孤立的上下文变得更容易。这对人类同样适用,这也是我们使用包的原因,但我们权衡包的大小与额外的打字/管道/归档以制作更易读的代码。有了 LLM 处理并从大量额外工作中受益,这种权衡发生了变化。(作为额外奖励,我们人类得到了更易读的代码!)
  • 更小且更多的包可以独立于无关代码进行编译和测试。这对于 LLM 开发周期很有帮助,因为这意味着在其他依赖包被重构之前,可以编译和测试更改,且包有一个更简单的测试环境。

一个例子

让我举一个例子来结合讨论的几个想法:

为浮点数的四分位数编写一个水库抽样器。

首先,包结构。如果是在 LLM 出现之前,我可能会选择创建一个包含几种算法的 streamstat 包,可能每个文件一个算法。这似乎并非独特的观点,这有一个开源的 quantile 包遵循了这种模型。现在,我只想要这一个算法在它自己的包中。其他变种或相关算法可以有它们自己的包。

接下来,我们从 LLM 获取了什么。第一次尝试还不错。这个提示,加上一些关于想要它用 Go 写的细节,让我得到了 quartile_sampler.go:

// QuartileSampler 维持一个水库样本以估计流中的四分位数
// float64 值的流。它使用水库抽样在处理大型数据流时保持代表性
// 样本。
type QuartileSampler struct {  
   reservoir []float64  
   seen      int  
   maxSize   int  
   rng       *rand.Rand  
}

// NewQuartileSampler 创建一个新的采样器,维持最多 maxSize 个样本。
// 更大的 maxSize 提供更准确的估计,但使用更多的内存。
func NewQuartileSampler(maxSize int) *QuartileSampler { … }

核心接口也很好:

// Add 向采样器中添加一个新值。
func (qs *QuartileSampler) Add(value float64) { … }

// Quartiles 返回迄今为止所见数据的估计的第 25、50(中位数)、和第 75 百分位数。
// 如果没有添加数据,则返回 (0,0,0)。
func (qs *QuartileSampler) Quartiles() (q1, median, q3 float64) {  
   if len(qs.reservoir) == 0 {  
       return 0, 0, 0  
   }  
    
   // 复制以避免修改水库
   sorted := make([]float64, len(qs.reservoir))  
   copy(sorted, qs.reservoir)  
   slices.Sort(sorted)  
    
   n := len(sorted)  
   q1 = percentile(sorted, 0.25)  
   median = percentile(sorted, 0.50)  
   q3 = percentile(sorted, 0.75)  
    
   return q1, median, q3  
}

太棒了!还有测试。

旁白:这可能是停止的地方。有时我将 LLM 代码生成作为一种特定形式的搜索。例如,我对水库抽样感到好奇,但想看看该算法在一些令人惊讶的限制下如何应用,例如窗口时间抽样。我可能会修改我的提示,以获得一个跟踪新鲜度的实现。(我也可以要求它在注释中包含文献引用,我可以手动检查以确定它是在编造还是有一些可靠的研究可以参考。)通常我会花 60 秒读一些生成的代码,看到一个我之前没想到的明显技巧,然后放弃并重新开始。现在我知道这个技巧是可能的。这就是为什么很难评估 LLM 生成的价值。是的,有时它会生成糟糕的代码,陷入固定模式,制造一些不可能的东西(它前几天幻想了一个我希望存在的摩纳哥 API 的一部分)并浪费我的时间。它也可以通过指出我不知道的相关内容来节省我几个小时。

回到代码上来。初始代码产生的结果没有通过编译是非常有趣的。在 Quartiles 实现的中间有这样一行:

n := len(sorted)

这本身是一行没问题的代码,sorted 是几行前定义的一个切片。但这个值从未被使用,所以 gopls(如果你运行 go build,Go 编译器也会)立即提示:

declared and not used: n

这个问题非常容易修复。如果我把这个错误再粘贴回到 LLM,它会纠正它。尽管在这种情况下,当我读这段代码时,我很清楚我可以自己删除这行,所以我就这么做了。

现在来看测试。我得到了我预期的结果。在 quartile_sampler_test.go 中:

func TestQuartileSampler(t *testing.T) {  
   tests := []struct {  
       name     string  
       data     []float64  
       wantQ1   float64  
       wantMed  float64  
       wantQ3   float64  
       epsilon  float64 // 允许的浮点数比较误差  
   }{  
       {  
           name:    "基础序列",  
           data:    []float64{1, 2, 3, 4, 5, 6, 7},  
           wantQ1:  2,  
           wantMed: 4,  
           wantQ3:  6,  
           epsilon: 0.1,  
       },  

   }

   for _, tt := range tests {  
       t.Run(tt.name, func(t *testing.T) {  
           qs := NewQuartileSampler(100) // 使用大容量水库以获得准确测试  
           for _, v := range tt.data {  
               qs.Add(v)  
           }

           q1, med, q3 := qs.Quartiles()  
            
           if math.Abs(q1-tt.wantQ1) > tt.epsilon {  
               t.Errorf("Q1 = %v, want %v%v)", q1, tt.wantQ1, tt.epsilon)  
           }  
           if math.Abs(med-tt.wantMed) > tt.epsilon {  
               t.Errorf("Median = %v, want %v%v)", med, tt.wantMed, tt.epsilon)  
           }  
           if math.Abs(q3-tt.wantQ3) > tt.epsilon {  
               t.Errorf("Q3 = %v, want %v%v)", q3, tt.wantQ3, tt.epsilon)  
           }  
       })  
   }
}

这完全是我会写的那种东西!我会通过另一种实现运行一些案例来生成预期的输出,并把它们复制到这样的测试中。但这里有两个问题。

首先,LLM 没有通过另一个实现来运行这些数字。(据我所知。当使用一个复杂的 LLM 服务时,很难确切知道幕后发生了什么。)它是编造出来的,而且 LLMs 因算术能力弱而声名狼藉。因此,虽然对于人类来说,基于另一个工具的输出或者如果我们特别老派的话自己做一些算术是合理的,但这对 LLM 来说并不是个好方法。

第二个问题是我们可以做得更好。我很高兴我们现在生活在一个程序员编写自己的测试的时代,但我们对测试的标准不如生产代码那么高。这是一个合理的权衡,毕竟一天就那么多小时。但是 LLM 在算术能力上的不足,它们以热情来弥补。

让我们寻求一个更好的测试。

在测试中,实现标准代码的最简单、最易读的版本,以便在一个固定的已知值的切片上计算四分位数。然后通过标准代码和水库采样器运行测试用例,并确认它们相差在一个 epsilon 之内。构建比较代码,以便也可以在模糊测试中使用。

这给我们带来了一些新的测试代码:

// referenceQuartiles 计算一个 float64 值切片的精确四分位数
// 使用线性插值,匹配采样器预期的行为。
func referenceQuartiles(data []float64) (q1, median, q3 float64) { … }

// compareQuartiles 检查两组四分位数是否相差在 epsilon 之内。
// 如果它们在容差内匹配,则返回 true,否则返回 false。
func compareQuartiles(q1a, meda, q3a, q1b, medb, q3b, epsilon float64) bool { … }

// checkQuartiles 是一个测试帮手,它将采样器输出与参考
// 实现进行比较并报告任何差异。
func checkQuartiles(t *testing.T, data []float64, epsilon float64) {  
   t.Helper()  
    
   // 获取参考值  
   wantQ1, wantMed, wantQ3 := referenceQuartiles(data)  
    
   // 使用大容量水库获取采样器值以提高准确度
   qs := NewQuartileSampler(1000)  
   for _, v := range data {  
       qs.Add(v)  
   }  
   gotQ1, gotMed, gotQ3 := qs.Quartiles()  
    
   if !compareQuartiles(gotQ1, gotMed, gotQ3, wantQ1, wantMed, wantQ3, epsilon) {  
       t.Errorf("四分位数不匹配:\ngot  (q1=%v, med=%v, q3=%v)\nwant (q1=%v, med=%v, q3=%v)\nepsilon=%v",  
           gotQ1, gotMed, gotQ3, wantQ1, wantMed, wantQ3, epsilon)  
   }  
}

上面的原始测试已经被重写以使用 checkQuartiles,我们有了一些新内容:

func FuzzQuartileSampler(f *testing.F) {  
   // 添加一些种子语料库  
   f.Add([]float64{1, 2, 3, 4, 5})  
    
   f.Fuzz(func(t *testing.T, data []float64) {  
       // 由于我们可能会得到更极端的值,因此在模糊测试中使用更大的 epsilon  
       checkQuartiles(t, data, 0.2)  
   })  
}

这很有趣,因为这是错的。我运行的 gopls 工具立即指出:

模糊测试参数只能有以下类型:
    string, bool, float32, float64,
    int, int8, int16, int32, int64,
    uint, uint8, uint16, uint32, uint64,
    []byte

将该错误信息粘贴回 LLM,它会重新生成围绕 func(t *testing.T, data []byte) 函数的模糊测试,该函数使用 math.Float64frombits 从数据切片中提取浮点数。像这样的互动指向了我们自动化来自工具的反馈:它只需要明显的错误信息,就能朝着有用的东西稳步前进。我是不需要的。

快速回顾我过去几周的 LLM 聊天历史(正如我之前提到的,这绝不是一个正式的定量分析)显示,超过 80% 的时间里,如果存在工具错误,LLM 能在没有我添加任何见解的情况下取得有用的进展。大约一半的时间,它甚至可以在没有我说任何值得注意的事情的情况下完全解决问题,我只是充当传递者。

我们要去往何方?更好的测试,或许甚至更少的 DRY

大约 25 年前,有一个围绕原则 “不要重复你自己(don’t repeat yourself)” 的编程运动。正如那些教给本科生们的简洁原则常常发生的那样,这个原则被过分强调了。将一段代码抽象出来以便重用,与之相关的成本很高,它需要创建必须学习的中间抽象,并且它需要为抽取出来的代码添加功能,以使它对尽可能多的人尽可能有用,这意味着我们依赖于充满无用分心特性的库。

过去 10-15 年里,编程领域采取了更为克制的编码方式,许多程序员明白,如果共享实现的成本高于实现和维护独立代码的成本,那么重新实现一个概念会更好。在代码审查时,写上“这样做不值得,分开实现吧。”这种话变得少见了。(这很幸运,因为人们在完成所有工作之后,真的不想听到这样的话。)程序员在权衡利弊方面做得更好了。

我们现在所处的世界,权衡已经发生了变化。编写更全面的测试变得更加容易。你可以让 LLM 编写你想要但没有时间正确构建的模糊测试实现。你可以花更多时间编写可读性强的测试,因为 LLM 不会一直想“如果我去处理问题跟踪器中的另一个 bug 而不是做这件事,对公司会更好。”所以权衡转向了拥有更多专门实现的方向。

我预计这一点最明显的地方是特定于语言的 REST API 包装器。每家大公司的 API 都附带着数十个这样的包装器,它们通常质量低下,由那些实际上并不使用它们的实现来实现特定目标的人编写的,而是试图捕捉 API 的每一个角落和缝隙,形成一个庞大而复杂的接口。即使做得很好,我发现直接去查看 REST 文档(通常是一组 curl 命令),为我实际关心的 API 的 1% 实现语言包装器更容易。这降低了我需要 upfront 学习的 API 部分,也减少了将来的程序员(我自己)阅读代码时需要理解的内容。

例如,作为我最近在 sketch.dev 上的工作的一部分,我在 Go 中实现了一个 Gemini API 包装器。尽管 Go 中的官方包装器由熟悉语言并且明显关心的人精心打造,但要理解它还是有很多内容需要阅读:

$ go doc -all genai | wc -l  
    1155

我的简易初版封装总共 200 行代码,一个方法,三种类型。阅读整个实现的工作量是阅读官方包文档的 20%,如果你决定尝试深入其实现,你将发现它是另一个基于 protos 和 grpc 以及相关技术的大量代码生成的实现的封装。我所需要的,只是 cURL 和解析一个 JSON 对象。

显然,在项目的某个点上,当 Gemini 成为整个应用的基础,几乎每个功能都在使用时,当在你的组织中以 gRPC 构建与遥测系统很好地对齐时,你应该使用大型官方封装。但大多数时候,鉴于我们几乎总是只需要今天要使用的 API 的一小块,定制客户端,主要由 GPU 编写,对于完成工作来说要高效得多,无论是前期还是持续的时间成本。

因此,我预见一个有着更多专业代码的世界,拥有更少的通用包,和更多可读的测试。可重用代码将继续围绕小而健壮的接口茁壮成长,否则将被拆解成专业代码。这样做的成效如何,将导致更好或更糟的软件。我预期两者都有,但长期趋势是朝着重要指标衡量的更好的软件发展。

将这些观察自动化:sketch.dev

作为一名程序员,我的本能是让电脑为我工作。利用 LLMs 获得价值需要大量工作,计算机怎么能做到呢?

我相信解决问题的关键不是过度泛化。解决一个特定问题,然后慢慢扩展。所以,与其构建一个对 COBOL 和 Haskell 一样好用的通用 UI 聊天编程工具,我们希望专注于一个特定的环境。我大部分的编程工作是在 Go 中,所以我想要的对于一个 Go 程序员来说很容易想象:

  • 类似 Go playground 的东西,围绕编辑一个包和测试构建
  • 带有聊天界面的可编辑代码
  • 一个小型 UNIX 环境,我们可以运行 go get 和 go test
  • goimports 集成
  • gopls 集成
  • 自动模型反馈:在模型编辑时运行 go get、go build、go test,反馈缺失的包、编译错误、测试失败到模型中,尝试自动修复这些问题

我们中的一些人已经构建了这个的早期原型:sketch.dev

目标不是一个“Web IDE”,而是要挑战基于聊天的编程甚至是否属于传统所称的 IDE 的观念。IDE 是为人们安排的工具集合。这是一个我知道正在发生什么的微妙环境。我不希望一个 LLM 把它的初稿乱丢在我当前的分支上。 虽然 LLM 最终是一个开发者工具,但它需要自己的 IDE 来获得有效操作所需的反馈。

换句话说:我们没有把 goimports 集成到 sketch 中是为了人类使用,而是为了让 Go 代码通过自动信号更接近编译,从而使编译器能够向驱动它的 LLM 提供更好的错误反馈。把 sketch.dev 视为“LLMs 的 Go IDE”可能更合适。

这都是非常近期的工作,还有很多工作要做,例如 git 集成,使我们能够加载现有的包进行编辑,并将结果放到一个分支上。更好的测试反馈。更多的控制台控制。(如果答案是运行 sed,那就运行 sed。不管你是人类还是 LLM。)我们仍在探索,但相信,专注于特定类型的编程的环境将比通用工具带来更好的结果。