深度实践文章,分享如何在 TDD 流程中有效利用 LLM 能力提高效率和代码质量。219 点赞表明高质量内容,对开发者直接可用。
欢迎来到这个新博客的第一篇文章!在这里,我将讨论软件开发、SRE 工作和其他有趣的东西。有时候一个想法太好了,不能错过。我希望这个博客能够激励我将灵感和零碎的想法通过落笔成文转化为更系统的知识。
前不久,我和一个同事讨论了 Tabby。我们谈论了是否应该认为 AI 自动补全代码有害,并放弃大家新养成的这个习惯,理由是 LLM 本身的不可靠性和它们倾向于产生意大利面条式代码,忽视了 DRY 等传统软件工程原则。我不同意这个看法:如果我们能有一个框架,既能集成 AI 开发工具,同时也能让一切变得更好、更可靠呢?这让我立刻想到了测试驱动开发(TDD),我认为当与大语言模型结合使用时,它是很棒的。
TDD 本质上是在处理主程序之前编写全面的单元测试。既然你写了那么多测试用例,它们本质上就成了完整的规范,最后让这些测试通过就"证明"了你的程序是正确的。尽管 TDD 很有前景,但很多人觉得它是一个极其笨拙的过程,甚至是生产力的巨大拖累。可能好几天都没有什么有用的进展。不过,LLM 从根本上改变了做 TDD 的经济学。
自从 Github Copilot 这样的工具出现以来,我就一直在大量使用它们。它们擅长找到模式和补全接下来的几行代码,但通常难以:看清楚一个规范,深入思考它,然后根据这个规范生成一个完整可用的模块。有时候一个问题看起来这么简单,我确信 Copilot 一定能处理它,但它只是勉强生成一行代码,而不是完整的解决方案。
为了实现我想要的,LLM 需要被给予格式良好的请求,包含必要但不过载的上下文。如果把我们手上的每个工具和库都塞进模型的上下文中,它就会分心,容易偏离具体任务。
最后,我发现自己在用更通用的术语重新表述和重新命名项目特定的东西,只在意识到解决任务真的需要时才引入额外的上下文。
我在与 LLM 合作中的另一个观察是,它们是很好的调试器。我经常可以把原始错误输出粘贴给 LLM,在大多数情况下,它们都能成功猜出原因。
在某个时刻,我意识到与 LLM 编码的大部分摩擦来自于在我的 IDE、Shell 终端和聊天界面之间来回复制粘贴。
所以我写了一个小事件循环来自动化这个过程。
在我们给 LLM 的第一个提示中,我们输入我们想要实现的函数的规范和函数签名以增加稳定性。LLM 应该生成一个好的单元测试,然后是实现。
让我们给它一个非平凡的、真实世界的问题来解决:
% go run main.go \
--spec 'develop a function to take in a large text, recognize and parse any and all ipv4 and ipv6 addresses and CIDRs contained within it (these may be surrounded by random words or symbols like commas), then return them as a list' \
--sig 'func ParseCidrs(input string) ([]*net.IPNet, error)'
模型会很乐意给我们一个初稿,我们立即解析它并将其加载到"sandbox"子目录中进行自动验证:
% tree ./sandbox
./sandbox
├── go.mod
├── go.sum
├── main.go
└── main_test.go
使用 go mod tidy && gofmt -w . && goimports -w . 来修复小的语法问题,然后运行 go test . -v。
如果失败了(在这个阶段完全是预料之中的),我们就使用第二个(迭代)提示。现在我们在处理现有代码,这个提示包括我们刚刚运行的代码和,最重要的是,运行测试的命令行输出,它要么是编译错误,要么是关于一些失败测试的信息。模型应该思考发生了什么,并通过在循环中生成修订的测试+实现来迭代,直到所有测试都通过。
这个想法是,在大多数情况下,发送完整的调试会话不是对模型上下文的节省利用。一个相当聪慧的模型可以思考出什么地方出了问题,并做出增量式的改变。我们也因为保持上下文长度相对恒定而受益,无论我们最终进行多少次迭代,API 账单都会大大降低。
有时候模型会卡住。在我们的 CIDR 例子中,claude-3.5-sonnet 在第二次尝试时非常接近全部通过,但接着在同一个测试用例上连续失败两次。这时我就会介入,查看代码,意识到正则表达式直到 "2001:db8::1" 中最后的 '1' 才匹配,把这个作为显式提示提供给模型:
Claude 在我们的帮助下取得进展,并通过了所有测试:
现在我们需要对付"谁来监督监督者"的问题。因为 LLM 是不可靠的代理,Claude 可能只是通过生成无用的(或者不用心的)测试用例而骗了我们。总之,实现代码的人不应该编写自己的测试是个好实践,因为设计中的同样盲点会在测试中出现。在我们的例子中,LLM 生成了两者。所以现在是时候引入一些人工输入,以额外的测试用例的形式,这变得特别方便,因为模型已经提供了我们测试的整体结构。如果这些用例都通过了,我们可以相当有信心地把这个函数集成到我们的代码库中。理论上,你甚至可以调用第三个提示来做一些 AI 驱动的变异测试,通过要求实现中有一个微妙但关键的改变,应该破坏我们的测试,然后找出它是否真的破坏了!
所以这个方法似乎能可靠地处理 LeetCode 风格的问题,但在一个有真实依赖图的实际代码库中能行得通吗?我相信通过一些工程努力是可以的,如果你这样做了,对代码库的长期可维护性来说是个好消息。为了最好的效果,我们的项目结构需要在考虑 LLM 工作流的情况下设置。具体来说,我们应该仔细管理并将理解和为项目贡献代码所需的认知负荷保持在最低水平。
每个包或目录应该由几个独立可测试的代码子集组成,其中每个子集基本包含 3 个文件:shared.go 用于包的共享类型定义和全局变量,而 x.go 和 x_test.go 关注我们功能逻辑的特定方面,理想情况下每个文件只有一个公共函数。有时我们也会有 main_test.go,它提供了一个 TestMain 函数来设置测试环境(例如 testcontainers)。
最近我在工作中开始了一个全新的软件项目,所以我有机会从零开始设计代码组织。我目前正在探索为更大项目扩展这个 AI TDD 工作流。整个项目代码需要被复制到 sandbox 中执行,但把整个代码库发送给 LLM 是不切实际的,并会分散模型的焦点。相反,我们指定一个特定的包(子目录),LLM 在任何给定时间都会在这上面工作,包括为每个我们认为有帮助的依赖生成的 gomarkdoc 文档,最后包括同包的示例代码(也许是一个紧耦合实体的完成实现)。模型会像以前一样生成一个函数和一个测试,但这次我们在 sandbox 深处的目标子目录中写入文件来运行单元测试。
有了这个模式,我们不仅限制了将有问题的代码引入生产的机会(因为有默认的测试覆盖),而且我们还鼓励积极的解耦和单元测试优先的项目结构。因为添加额外的上下文到模型会产生一些推理成本(和人类心理成本),我们不断被推动去将我们的代码维护为漂亮的小块,每个块都只消耗很小的认知负荷来完全理解。希望这种方法能让我们最终得到"深层"模块,功能丰富但表面积最小,因为在外部力量缺失的情况下,熵总是增长,逻辑总是分散到各处。
最后,你应该记住 AI 的苦涩教训。明天我们醒来后 AI 架构可能会发生重大转变,消除我们谈论的 LLM 限制,使我们的努力变得毫无意义,这种概率是非零的。所以也许不要根据这个建议立刻开始重构你的 10 万行代码项目!