几十年来,我们都很清楚什么样的代码才算“好代码”。完善的测试。清晰的文档。小而边界明确的模块。静态类型。无需举行一场小型宗教仪式就能启动的开发环境。
这些东西一直都是“可选的”,而在时间压力之下,“可选项”通常最先被砍掉。
但智能代理(agent)却需要这些“可选项”。它们并不擅长先把事情弄得一团糟,再事后清理。代理更像是一台扫地机器人,直接从狗屎上碾过去,然后把脏东西拖得满屋都是。
唯一的护栏,就是你设置并强制执行的那些规则。如果代理所处的上下文不完整,护栏又不够坚固,你很快就会陷入痛苦的境地¹。相反,如果护栏足够扎实,大模型就可以不知疲倦地反复尝试,直到唯一能走出的路径就是正确答案。
我们这个六人团队,为了适配“代理式编码”,做了很多具体、而且有时颇具争议的投入。下面聊聊其中一些不那么显眼,但非常关键的点。
100% 的代码覆盖率
我们最具争议的一条规范,恰恰也是最有价值的一条:我们要求 100% 的代码覆盖率²。
几乎所有人在第一次听到这条规则时都会持怀疑态度,直到他们真正体验上一天。那时你会发现,它有时简直像一件秘密武器。
在我们的语境中,覆盖率并不只是为了防止 bug;它的核心作用,是保证代理已经对它写下的每一行代码的行为进行了“双重确认”。
一个常见的误解是:别人以为我们相信 100% 覆盖率等于“没有 bug”。或者以为我们是在追逐指标,而指标总是会被“刷”。这两点都不是我们的出发点。
为什么一定要 100%?在 95% 的覆盖率下,你仍然需要判断哪些代码“重要到值得测试”。在 99.99% 时,你甚至不知道 ./src/foo.ts 里那一行没覆盖到的代码,是不是在你开始这个新功能之前就已经存在的。而当你达到 100% 时,会发生一次“相变”,所有这些模糊性都会消失³。只要有一行没被覆盖,那一定是你刚刚主动引入的。
覆盖率报告会变成一份简单直观的待办清单,告诉你还有哪些测试需要补齐。这同时也减少了一个需要交给代理去权衡和推理的自由度。
在 100% 覆盖率下,测试带来的杠杆效应会出现一次阶跃式的提升。
当模型新增或修改代码时,我们会强制它展示那一行代码是如何工作的。它不能停留在“看起来是对的”,而必须用一个可执行的例子来证明。
还有一些额外的好处:不可达代码会被删除;边界情况会被显式表达;代码评审也变得更容易,因为你能看到系统中每一个部分被期望如何行为、以及将要如何变化的具体示例。
命名空间是个了不起的想法,让我们多用一点吧
代理式工具在你的代码库中进行导航的主要机制,其实是文件系统。它们会列出目录、读取文件名、搜索字符串,并把文件拉进上下文。
你应该像对待任何其他接口一样,认真对待你的目录结构和文件命名。
一个叫做 ./billing/invoices/compute.ts 的文件,即使内部代码完全一样,也比 ./utils/helpers.ts 传达的信息要丰富得多。帮一帮 LLM,好好组织你的文件结构。
此外,尽量使用数量更多、范围更小的文件。
这会改善上下文加载的效果。代理在把大文件拉进工作集时,往往会进行摘要或截断。小文件可以显著降低这种风险。如果一个文件足够短,能够被完整加载,模型就可以在上下文中始终保留它的全部内容。
在实践中,这会加快代理的工作流,并消除一整类性能退化的问题。
快速、短暂、并发的开发环境
在旧世界里,你通常只生活在一个开发环境中。你会在那里精雕细琢解决方案,反复调整,运行命令,重启服务,逐步收敛到最终结果。
而在代理时代,你做的事情更像是养蜂:在不了解每个进程内部具体发生了什么的情况下,协调多个进程的运作。因此,你需要培育一个健康、良好的蜂巢。
快速
你的自动化护栏必须运行得足够快,因为你需要频繁运行它们。目标是让代理始终处在一条“短绳”上:做一个小改动,检查它,修复它,重复这个过程。
你可以通过多种方式来运行这些检查:代理钩子、git 钩子,或者仅仅通过提示词(比如在 AGENTS.md 里)。但不管采用哪种方式,你的质量检查都必须足够“便宜”,以至于频繁运行它们不会拖慢整体节奏。
在我们的配置中,每一次 npm test 都会创建一个全新的数据库,运行迁移,然后执行完整的测试套件。
之所以能这样做,是因为我们把每一个阶段都做到了极致地快。我们使用高并发、强隔离,并且为第三方调用加入了缓存层⁵。我们有超过一万条断言,但整个过程大约一分钟就能跑完。如果没有缓存,时间会变成 20 到 30 分钟;而如果你期望代理在一个任务中多次运行测试,那就会额外增加数小时的成本。
短暂(Ephemeral)
一旦你适应了代理的工作方式,你会很自然地开始同时运行很多代理。你每天会多次创建并销毁开发环境。这一切都必须是全自动的,否则你就会下意识地避免这么做。
我们的工作流很简单:
new-feature
这个命令会创建一个新的 git worktree,拷贝那些不在 git 中的本地配置(比如 .env 文件),安装依赖,然后启动你的代理,并提示它先采访你,一起写一份 PRD。如果功能名足够具有描述性,它甚至可能直接开始干活,假设自己能推断出其余上下文。
关键并不在于我们的具体脚本,而在于延迟。如果这个过程需要好几分钟,还涉及大量手动调整和配置,你就不会去用它。但如果它只需要一个命令,耗时 1 到 2 秒,你就会频繁地使用。
在我们的场景中,一个命令就能几乎立刻给你一个全新的、可用的环境,并且已经有代理准备开始工作。
并发
最后一个要素,是能够同时运行这些环境。拥有一堆 worktree 并没有什么用,如果你一次只能激活其中一个。这意味着任何可能发生冲突的东西(例如端口、数据库名称、缓存、后台任务)都需要是可配置的(理想情况下通过环境变量),或者以某种不会冲突的方式进行分配。
如果你使用 Docker,这其中的一部分问题会自动得到解决,但总体要求是一样的:你需要一个可靠的隔离方案,这样才能在一台机器上同时运行多个完全可用的开发环境,而不会互相干扰。
端到端类型
更广义地说,尽可能多地自动化和强制执行最佳实践。减少 LLM 的自由度。如果你还没有在使用自动化的 linter 和 formatter⁶,那就从这里开始。把它们设得尽可能严格,并配置为在 LLM 完成任务或即将提交代码时自动应用修复⁷。
同时,你还应该使用一门强类型语言⁸。
整类非法状态和非法转换都可以被直接消除。类型系统会缩小模型可采取行动的搜索空间,同时又充当了权威文档,精确描述了每一层中数据是如何流动的。
TypeScript
我们在很大程度上依赖 TypeScript。如果某件事可以被合理、干净地表达在类型系统中,我们就一定会这么做。而且我们会把语义意义压进类型名里。目标是让“这是什么?”和“它会流向哪里?”在一眼之下就能得到答案。
在使用代理时,好的语义命名是一种放大器。如果模型看到 UserId、WorkspaceSlug 或 SignedWebhookPayload 这样的类型,它能立刻理解自己在处理什么样的东西,也能非常容易地搜索到相关内容。
像 T 这样的泛型名称,在编写小而自洽的通用算法时是没问题的;但在真实的业务系统中,用来传达意图就要差得多。
OpenAPI
在 API 层面,我们使用 OpenAPI 并生成强类型客户端,从而保证前端和后端在数据结构上保持一致。
Postgres
在数据层面,我们尽可能利用 Postgres 的类型系统,并为那些无法用简单列类型表达的不变量添加检查和触发器。Postgres 的类型系统并不算特别丰富,但已经足以强制执行大量正确性约束。如果代理尝试写入无效数据,数据库通常会清晰而大声地报错。我们还使用 Kysely 为我们生成强类型的 TypeScript 客户端。
我们所有其他第三方客户端,要么本身就提供良好的类型,要么我们会对它们进行封装,以获得良好的类型。
代理是不知疲倦的,而且往往是非常出色的程序员,但它们的效率完全取决于你为它们创造的环境。一旦你意识到这一点,“好代码”就不再显得多余,而是变得至关重要。
是的,前期投入看起来像一笔税,但这正是我们多年来一直在逃避的那笔税。所以,主动去缴吧。把它放进你的代理路线图里,让工程管理层为它买单,最终交付那个你一直渴望拥有的代码库。