我如何使用 LLM 进行编程 [译]

本文档总结了我在过去一年中使用生成式模型进行编程的个人经验。我要强调,这并不是一个被动的过程。为了更好地了解这些模型的潜力,我有意识地在编程时寻找机会使用它们。结果就是,现在我在工作时经常使用 LLM,并且我认为它们对我的生产力有正向提升。(每当我尝试回到没有它们的编程方式时,都会感觉非常不适。)

在此过程中,我发现了一些常见的重复性步骤是可以被自动化的。我们中的一部分人正在努力将这些自动化构建到一个专门为 Go 编程服务的工具中:sketch.dev。目前它还处于非常早期的阶段,但迄今为止的体验令人满意。

背景

我对新技术通常都很好奇。仅仅对 LLM 做了一点简单的尝试,就让我产生了一个想法:看看我是否能从中获得实际价值。对于一种“(至少在某些时候)能够对复杂问题做出复杂回答”的技术,它本身就很有吸引力。更让人兴奋的是,看着计算机按照请求编写一段程序,并且还能取得不错的进展。

我曾体验过的唯一可相提并论的技术转变,发生在 1995 年,那时我们第一次在局域网中配置了可用的默认路由。我们用一台可以路由拨号连接的机器,替换了放在另一个房间里、运行 Trumpet Winsock 的共享计算机。这意味着我可以随时上网了。当时,这让我非常震惊,也让我感到这就是未来。对于更早就接触互联网、使用大学网络的人来说,也许不会有那么强烈的冲击,但我一上来就接触到高阶的互联网技术:网页浏览器、JPEG 图片,以及数百万的用户。现在使用功能强大的 LLM 的感觉就像当年的那种体验。

于是,我带着这种好奇心来检验:一个“在大多数时候能产生大致不算错误的结果”的工具,是否真的能在日常工作中带来净收益?从我的使用情况来看,答案似乎是肯定的。对我来说,生成式模型在编程时确实很有用。然而,要走到这一步并不容易。我之所以能坚持下去,也正是因为我对这项新技术的强烈兴趣。所以,当我听到其他工程师说 LLM “没用”时,我也能理解。可每当有人问我“你到底是怎么使用它们并得到实效的?”我也会尽力解释。我写下这篇文章,正是为了概括我目前所发现的使用方法。

概览

在日常编程中,我主要通过三种方式使用 LLM:

  1. 自动补全(Autocomplete)。它能替我打出很多更“显而易见”的代码,从而提高我的效率。其实当下的最先进技术还有很多提升空间,这是以后再讨论的话题。但即便是现成的主流产品,对我而言都比没有要好。我曾经尝试过放弃它们来做对比测试,却一周都坚持不了,就因为我发现没有 FIM 模型后要打大量的常规代码,实在令人烦躁。对于初次尝试的人,这应该是最先去体验的地方。

  2. 搜索(Search)。如果我对复杂的环境有疑问,比如“如何在 CSS 里让按钮透明?”我发现任何面向消费者的 LLM(o1、sonnet 3.5 等等)给出的答案,往往比传统的网页搜索更好,而且也更直接。我不需要再自己去解析落到的页面内容。(有时 LLM 会答错,但人也会出错。有一天我把鞋子戴在头上,问我两岁大的女儿: “你觉得我的帽子怎么样?” 她先把这个场景应付过去,然后严肃地数落我一顿。我也能接受 LLM 偶尔出错这件事。)

  3. 基于对话的编程(Chat-driven programming)。这是难度最大的,也是我收益最大的,同时也是最让我头疼的。要真正用好它,需要学到很多东西,并调整自己的编程方式,而原则上我并不喜欢这种改变。与之配套的还需要大量摆弄,因为想从对话式编程中获得价值,和学会使用计算尺一样需要投入精力;更别提这是一个不确定性极高、会经常改变行为和界面的服务。事实上,我现在长期的目标,就是尽量摆脱对话式编程,试图以一种更直观的方式将这些模型的能力带给开发者,不要那么令人抗拒。但就目前而言,我依然得一点点去摸索,怎么利用好当前的工具,并在此基础上改进它。

由于这是关于编程实践,它本质上比较定性,很难做到定量地严谨分析。最接近“数据”的部分大概是:根据我的记录,我现在每两小时的编程中,会接受超过 10 次的自动补全建议,进行一次类似搜索的 LLM 查询,以及开一次对话式编程会话。

接下来,我会重点谈谈如何从“对话式编程”中获得价值。

为什么要使用对话式编程?

让我试着说服那些对这一点持怀疑态度的读者吧。我之所以能从基于对话的编程中获益,主要是在我每天会遇到一个或多个时刻——我知道自己接下来需要写什么功能,也能描述清楚,但我已经没有精力去新建一个文件、敲打代码并查阅所需库的文档了。(我是早起型,通常在上午 11 点之后就开始疲惫;或者在我需要切换到一个陌生的语言/框架/环境时,也同样会毫无动力。)在这种情况下,LLM 能为我“代劳”:它会给出一个初稿,包含一些不错的思路,也包含了部分依赖库,虽然也常有错误。我发现,修正这些错误,往往比从零开始写要轻松很多。

如果你从来不需要这样的帮助,那么基于对话的编程可能不适合你。我的日常编程主要是产品开发,大致可以描述为“针对用户,快速构建和迭代功能以提供健壮接口”。这意味着我需要编写大量的代码,也会不断推翻重写,并且会在各种环境之间频繁切换。有时我写 TypeScript,有时我写 Go;上个月我还在一个 C++ 代码库里探究了一个想法,最近又刚好需要学习 HTTP 服务器端的事件格式。我的工作状态就是不停地忘记、又重新学习。如果你主要的工作是去证明你对某个加密算法的优化不存在计时攻击漏洞,而不是写一堆代码,那么我的这些观察可能并不适用你。

对话式 LLM 最擅长“考试式(exam-style)”的问题

如果给一个 LLM 一个明确的目标,再提供完成目标所需的所有上下文背景,让它能产出一份相对完整的代码变更包,并在你追问时做出相应调整,效果就比较好。其中有两个关键点:

  1. 避免让 LLM 处于过于复杂或模糊的境况,以防它受到干扰并产生糟糕结果。这也是为什么我在 IDE 里使用对话式 LLM 并不成功。我现在的工作环境通常都比较“杂乱”,默认要处理的代码库也很大,里面充满了干扰性内容。而截至 2025 年 1 月,人类比 LLM 更擅长不被干扰。所以我至今都还是通过浏览器来使用 LLM——我需要一个白板(blank slate)来构造一个信息完整且相对隔离的请求。

  2. 要求它完成的工作最好易于验证。作为一个使用 LLM 的程序员,你需要去阅读它生成的代码,思考并判断它是否可行。你可以理直气壮地对 LLM 提出一些永远不会对人类提出的要求,比如:“把你新写的所有测试改成某中间概念,以使得这些测试更易读”。对于人类而言,这会耗费无数天的撕扯讨论,纠结做这件事究竟值不值得,但 LLM 可以 60 秒内搞定,而且不会与你争执。重做工作非常便宜,这一点一定要充分利用。

对 LLM 来说,最理想的任务是:它需要调用很多常见的库(比人脑能记住的多),针对一个你设计好的接口(或者是它自己提出一个小而易于验证的接口),然后写出清晰可读的测试用例。有时,如果你想让它用某个比较冷门的库,你需要先告诉它要用的是哪个库(不过在开源代码领域,LLM 在这方面也相当擅长)。

无论如何,你总要先把 LLM 生成的代码编译并跑测试,然后再去读它写的内容。所有的 LLM 都有可能生成无法编译的代码。(通常是一些很“人性化”的错误,每次看见我都想:“要不是我留神,也会写出这样的代码。”)质量更高的 LLM 在出错时也更擅长纠错,通常你只要把编译器报错或者测试失败信息粘贴回去,它就能自动修复。

额外的代码结构成本更低了

我们每天都在权衡一些模糊的取舍:写代码的成本、读代码的成本,以及重构的成本。以 Go 的包(package)为例。Go 标准库中有个名为 net/http 的包,里面既包含了处理 HTTP 数据报文的基本类型,也包含了 HTTP 客户端、HTTP 服务器等。那么,这些东西究竟应该放在一个包里,还是拆分到多个包里?对这个问题,合理的人可以有不同看法!所以这么多年过去了,我们目前也并不能确定什么才是最佳拆分方式。现状虽有各种缺点,但也用了 15 年,并没表现出多大问题。

将多个功能合并到同一个包中的好处在于:对调用者而言,它的文档是集中统一的;初期的编写、后期重构、以及内部共享辅助代码都更容易,因为不必维护太多抽象接口(不然往往要把这些核心类型再抽到其他的包里去)。缺点是,这个包会变得难以阅读,因为里面事情太多了(如果你想研究 net/http 的客户端代码,就很容易迷失到服务器端实现里);对于只需要其中很少功能的人来说,也会显得过度臃肿。打个比方,我有一个代码库,其中在一些核心类型里使用了 C 库,而代码库的某些部分则需要在一个跨多个平台的二进制文件里使用,这部分二进制并不需要 C 库,所以我们要“额外”多拆分一些包来隔离 C 库,这样就能确保多平台编译时可以不启用 cgo。

这一切并没有“正确答案”,而是在各种工程层面的工作量之间做权衡(前期和后期)。LLM 的出现改变了这些平衡:

  • 由于 LLM 更擅长“考试式”任务,所以拆分成更多、更小的包能让每个请求都能提供一个更完整且孤立的上下文。人类对这种方式也受益,所以我们才会使用包,不过我们此前总是会在更易读和更少打包/更少文件等需求之间妥协。有了 LLM,可以让它帮我们完成“额外写文件、写接口、写测试”这类重复劳动,同时我们(人类)还能得到更易读的代码!

  • 更小、更多的包可以独立编译测试,而无需顾及无关代码。对于 LLM 的开发循环而言,这很有帮助:因为在进行重构时,可以先只编译、测试当前包,不用关心依赖它的其他包;并且测试环境也更简单。

示例

下面让我举个示例,来结合一下前面提到的一些思路:

“写一个基于蓄水池抽样(reservoir sampling)来估计浮点数四分位数(quartiles)的示例。”

首先是包结构。如果在 LLM 出现之前,我可能会选择写一个类似 streamstat 的包,里面包含好几种算法,每个算法一个文件。社区里一些开源包,比如这个 quantile 就是这样的思路。现在,我只需要这个算法,就单独给它一个包。将来如果还有其他变体或者相关算法,我再新建包。

接下来,让我们看看 LLM 输出的第一版代码。假设我给它的提示里包含了“我要用 Go 写”,然后它就生成了 quartile_sampler.go 文件:

// Add adds a new value to the sampler.  
func (qs *QuartileSampler) Add(value float64) { … }

// Quartiles returns the estimated 25th, 50th (median), and 75th percentiles  
// of the data seen so far. Returns (0,0,0) if no data has been added.  
func (qs *QuartileSampler) Quartiles() (q1, median, q3 float64) {  
   if len(qs.reservoir) == 0 {  
       return 0, 0, 0  
   }  
    
   // Make a copy to avoid modifying the reservoir  
   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 的代码生成为一种“专业搜索”方式。比如说我对 reservoir sampling 感兴趣,但还想看看在某个特殊约束下怎么应用。与其去做文献检索,不如直接改一下提示,让它写一个支持时效性窗口的实现。(我也可能让它在注释中附上相关文献索引,以便我事后查证,这些参考文献到底是真还是它杜撰的。)有时候,我只需要花 60 秒看一下它生成的代码,发现里面有一个我没想到的小技巧,于是我就扔掉这段代码,重新开始。但是因为我已经知道这种实现思路可行,所以我省下了不少时间。这种场景下,很难去准确衡量 LLM 为我节省了多少工作量。是的,有时它会浪费时间,输出质量不佳的代码,或在一个思路里兜圈子、幻想出一些不存在的 API(比如前几天它“编”出一条我很希望 Monaco 有的 API)。但它有时也能在几分钟内给我指出一个正确且高效的思路,让我省掉大量时间。

回到示例。比较有意思的是,LLM 生成的初始代码其实没能通过编译。在 Quartiles 的实现中,有这样一行代码:

n := len(sorted)

这是合乎逻辑的一行代码,sorted 确实在上几行被定义。但是 n 并没有被使用,所以 gopls(或者 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 // allowed error for floating point comparison  
   }{  
       {  
           name:    "basic sequence",  
           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) // Use large reservoir for accurate testing  
           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 服务来说,你也很难完全知道它在幕后做了什么)。它只是臆造了某些数字,而 LLM 在算术上并不总是靠谱。所以对于人类而言,我们之所以这么写测试,是因为我们要么确实有一个权威的对照实现,要么就自己手算。但对 LLM 来说,这么写没有多少实际意义。

第二,我们其实可以把测试写得更好。如今很欣慰的是,程序员们终于会自发地为自己的代码写测试了,但我们对测试的要求并没有对生产代码那么严格。这是一个合理的权衡,毕竟时间有限。但 LLM 不擅长算数,却擅长“冲劲儿十足地写代码”。

所以,让我们要求 LLM 再写一个更好的测试:

在测试中,实现一段最简洁、可读的标准四分位数计算(对给定切片进行完整计算),然后把同一组数据分别跑在参考实现和蓄水池抽样上,对比二者之间的差异是否在允许范围内。并写好这段对比代码,让它也能用于模糊测试(fuzz test)。

这样一来,我们就得到了新的测试代码:

// referenceQuartiles calculates the exact quartiles for a slice of float64 values  
// using linear interpolation, matching the behavior expected from the sampler.  
func referenceQuartiles(data []float64) (q1, median, q3 float64) { … }

// compareQuartiles checks if two sets of quartiles are within epsilon of each other.  
// Returns true if they match within the tolerance, false otherwise.  
func compareQuartiles(q1a, meda, q3a, q1b, medb, q3b, epsilon float64) bool { … }

// checkQuartiles is a test helper that compares sampler output against the reference  
// implementation and reports any differences.  
func checkQuartiles(t *testing.T, data []float64, epsilon float64) {  
   t.Helper()  
    
   // Get reference values  
   wantQ1, wantMed, wantQ3 := referenceQuartiles(data)  
    
   // Get sampler values using a large reservoir for accuracy  
   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("Quartiles mismatch:\ngot  (q1=%v, med=%v, q3=%v)\nwant (q1=%v, med=%v, q3=%v)\nepsilon=%v",  
           gotQ1, gotMed, gotQ3, wantQ1, wantMed, wantQ3, epsilon)  
   }  
}

然后把上面最初的测试重新改写成调用 checkQuartiles,再加了一个新的 fuzz test:

func FuzzQuartileSampler(f *testing.F) {  
   // Add some seed corpus  
   f.Add([]float64{1, 2, 3, 4, 5})  
    
   f.Fuzz(func(t *testing.T, data []float64) {  
       // Use a larger epsilon for fuzzing since we might get more extreme values  
       checkQuartiles(t, data, 0.2)  
   })  
}

这看起来很不错,但其实是错的。我的 gopls 程序直接报:

fuzzing arguments can only have the following types:
    string, bool, float32, float64,
    int, int8, int16, int32, int64,
    uint, uint8, uint16, uint32, uint64,
    []byte

把这个错误贴回给 LLM,它就能重新生成 fuzz 测试,改为 func(t *testing.T, data []byte),然后通过 math.Float64frombits 从原始字节流里提取浮点数。像这样的交互就说明,如果能自动把编译器的反馈传给模型,它便能在不依赖我提供见解的情况下做出有用的改进。我只是个“传话筒”。

回顾我近几周的 LLM 对话记录(这并不是什么定量分析),80% 以上当工具层面出现错误时,LLM 都能在没有我提供额外洞见的情况下给出下一个有用的迭代。大概有一半的时候,它甚至能在我基本没说什么的情况下直接修复问题,我只用把报错贴过去就行。

接下来的方向:更好的测试,甚至更“不求 DRY”

25 年前有个编程理念“不要重复你自己(Don’t Repeat Yourself)”。可就像许多简明扼要的原则一样,它也被某些课程灌输得过火了。将一段代码抽象出来以供复用,得先构建一些中间抽象,这本身需要额外花费时间来学习和维护。而且要想让抽象的代码适配更多人的需求,就要不断加功能,最后常常就依赖了一个里面满是“对我用不着”的功能的库。

过去 10~15 年,业界对“不要重复”这点的做法已经更加理性。很多程序员都意识到:如果把一些代码单独实现一遍、分别维护,比努力封装以便多处复用更省事儿,那就别抽象。现在我也很少会在 code review 里跟人说:“你这个抽象没必要,拆开写。”(而且一般同事也不想听这话,尤其当他们已经花大量时间做出了抽象之后。)大家更擅长在这样的取舍里做平衡。

而现在,LLM 又改变了这个平衡。写更全面、细致的测试变得更容易了。你可以让 LLM 帮你写出一个完整的 fuzz 测试实现,而不需要你花费大量时间。你也可以让测试写得更可读,而不会担心“要是还不如直接搞别的功能更值”——毕竟 LLM 不会琢磨“公司会不会更希望我去修别的 bug。” 这样就让我们更倾向于写一些更专业、更细的实现。

我认为,这会在各语言的“REST API 封装”领域最明显地体现。几乎所有大型企业的 API 都会有各种语言版本的封装,而且通常做得不够好。作者往往并不是真正使用这个 API 来实现具体目标,而是打算面面俱到,把所有 API 功能都封到一个复杂的接口里。就算做得好,我也常常觉得,还不如直接去看 REST 文档(通常是一堆 curl 命令),然后在我用到的语言里重新手动实现一个只包含“我在意的那 1% 功能”的封装——这样既少学很多 API 细节,也少给自己或后续读者增添理解成本。

举个例子,我最近在做 sketch.dev 时用 Go 写了一个 Gemini API 封装。虽然 官方封装 在 Go 语言上也做得很用心,但想要搞明白它,仍要读不少内容:

$ go doc -all genai | wc -l  
    1155

而我那初版的简易封装只有 200 行,包括 1 个方法和 3 个类型,整体加起来就 200 行,阅读成本也许只是官方包文档的 20%。要是你去看看它的实现,还会发现官方包其实是又封装了一层基于 protos 和 gRPC 的代码生成,这些东西对我而言完全是多余的。因为我只想做一次 “cURL + 解析 JSON” 而已。

当然,如果某个项目对 Gemini 依赖极深,用得越来越多,甚至公司内部的遥测系统也都基于 gRPC,这时官方封装才值得引用。但在大多数情况下,我们只想使用 API 的一小部分,使用“官方大而全”的库会让你在前期、维护期都损耗更多精力。而自己(或者由 GPU 帮你生成的代码)写一个专用的轻量封装,往往效率更高。

所以,我个人预测未来会出现更多更专业的“特定用途”代码,而更加通用的 package 反而会减少,而测试则会变得更易读、更全面。小型、健壮接口的可复用代码依然会长期存在,而其他部分则会更多地被拆开,写成定制实现。这将有可能带来更好的软件,也可能带来更糟糕的代码,但我相信从长期来看,对于我们真正关心的指标来说,软件质量总体会变好。

将这些经验自动化:sketch.dev

作为程序员,我的本能是让计算机来为我做更多工作。毕竟,要想用好 LLM,需要耗费不少精力,那么能不能让计算机自动完成呢?

我相信,想要解决某个问题,就不应该过度泛化。先聚焦去解决特定的问题,然后再慢慢扩展。因此,与其做一个“通用 UI”来对话式编程,可以同时兼顾 COBOL 和 Haskell,我更想专注于一个特定的环境。我最常写的语言是 Go,所以我设想了一个 Go 程序员想要的东西:

  • 类似 Go playground 的环境,围绕编写一个包和它的测试用例来展开

  • 带有可直接编辑代码的聊天界面

  • 其中包含一个小型的类 UNIX 环境,可以运行 go getgo test

  • goimports 的集成

  • gopls 的集成

  • 能自动给模型反馈:当模型进行代码编辑后,自动执行 go getgo buildgo test,然后将缺失包、编译器错误、测试失败等信息反馈给模型,尝试自动修复

我们几个人做了一个早期原型:sketch.dev

我们的目标并不是做一个“Web IDE”,而是质疑“对话式编程到底是不是应该集成进传统 IDE”。IDE 其实是给人用的各种工具的集合,环境比较脆弱,也比较私密。我不想让一个 LLM 在我的当前分支上随心所欲地把它的半成品搞得乱七八糟。 虽然 LLM 是一个开发者工具,但它需要一个专门的“IDE”,能够获得编译器等工具的自动反馈,才能正常工作。

换句话说:我们在 sketch.dev 中集成 goimports 不是为了让人用,而是为了让 LLM 生成的 Go 代码更容易编译,并自动提供更好的错误反馈。当编译器能提供更详细的错误信息,LLM 就能更快地纠正代码。所以,你可以把 sketch.dev 看成是“给 LLM 用的 Go IDE”。

目前这项工作还非常新,后续还有大量事情要做,比如 Git 集成——可以把现有包加载进来编辑,然后结果放在一个分支里;更好的测试反馈;更多的命令行控制(如果要跑 sed,那就让它跑,管你是人还是 LLM)。我们依然在探索,但我们坚信:针对特定编程环境进行的聚焦式优化,要比通用工具更能得到理想的效果。