如何停止“保姆式”监管AI代码
LLM代理让功能开发变得廉价,但带来了架构腐化。通过将架构决策与实现分离,并用构建系统强制执行规则,开发者可以摆脱对代理生成代码的繁重审查,将精力重新聚焦于系统设计。
LLM代理让功能开发变得几乎不费吹灰之力。你提出修改需求,代理便编写代码、添加测试并运行。那一刻,未来看起来就像是“氛围编码”。
然而,账单随后就到。代理从最近的可用路径导入,让前端代码直接访问数据库。这个选择让功能更容易实现,却让短期便利重写了架构。
显而易见的下一步是将规则写下来。AGENTS.md文件、规范和规划文档都试图在代理编写代码之前将架构加载到其上下文中。它们有所帮助,但文字仍然是提醒而非约束。代理可能忘记规则,只遵守方便的部分,或在未满足规则时声称成功。
代码审查成为了最后防线,这意味着检查每个差异中是否有此类捷径。这有所帮助,但将开发变成了繁琐的监督工作。
解决之道是将架构决策与实现细节分离,然后添加检查来强制实现尊重这些决策。一旦到位,审查就从生成的管道代码转向长期存在的架构决策,如模块边界、公共API和依赖关系。
规则需要安身之所
更深层的问题是记忆。人类携带社会情境向前推进。代理需要每个运行周期重新加载或强制执行这些情境。它们可能十次违反同一书面规则,仍将第十一次提醒视为新信息。这就像雇佣了一个每天重置的极快实习生。
文档是有用的上下文,但对于项目无法承受被破坏的规则而言,它是一个糟糕的容器。上下文是稀缺的。指令相互稀释。如果一个规则必须每次都被遵守,它应该存在于代码库可以强制执行的地方。
成本曲线翻转
软件团队已经在使用检查。重要的规则离开文字,变成类型、测试、linter、构建检查或运行时验证。团队通常只在违规常见或强制执行足够重要时才提升规则。即便如此,他们也会使用标准lint预设和配置成本低的检查。
一个自定义检查器,能够理解你的模块布局、导入边界以及你对公共接口的理解,需要前期巨大的工程投入,而回报却不确定。标准lint预设和作为质量后盾的代码审查通常是更好的权衡。
编码代理在两方面改变了经济性。
- 它们比人类审查速度更快地产生架构漂移。
- 它们使定制检查器的成本低到可以在一个下午内完成原型。
漂移的来源现在有助于遏制它。
以下是一个小例子。代理喜欢将简单参数包装成仪式。
而不是:load(id: string)
你得到一个微小应用程序形式:load(input: { id: string })
在指令文件中告诉代理“不要这样做”暂时有效。让代理编写一个检查,在公共接口中拒绝该模式,我只花了几分钟。从那时起,检查处理提醒,构建失败直到代理修复它。
该模式很容易扩展到真正重要的规则。前端不能导入数据库模式?那是一个导入边界检查。不希望验证逻辑存在于UI中?那是一个源代码检查。一旦规则成为检查,失败是确定的,错误信息命名规则,代理在我看到差异之前修复自己的违规。
外部合约,内部生成代码
在我的项目中,本地规则成为构建系统中的检查。它们位于包源代码之外,并按它们所管理的代码进行分组,例如UI原语、UI组件和领域包。
以领域包为例。它主要是携带项目业务逻辑的普通TypeScript代码。逻辑被拆分成小模块,具有狭窄的公共接口。每个模块遵循强制执行的结构。
some-module/ 源代码文件按审查职责拆分。
contract/— 权威文件
* interface.ts — 仅公共API,无实现
* create.ts — 暴露模块构造函数,声明每个必需的依赖
* cases.ts — 定义行为示例作为测试,通过create.ts构建模块,用假依赖对真实模块进行测试
impl/— 代理编写的实现代码
架构决策存在于权威文件中。manifest.json、README.md和contract/**定义了模块的意图、公共接口、依赖和行为示例。
实现细节保留在impl/**中。它们必须满足合约并保持在导入边界内。
对权威文件的更改可以改变项目的形状,并且需要重新生成impl/**。对impl/**的更改应保持本地化。如果超出该边界,检查将失败。
检查存在于构建系统中,在模块外部,强制执行这些角色。
规范系统检查
一些示例检查:
interface.ts: 仅公共API,无实现create.ts: 暴露模块构造函数,声明每个必需的依赖cases.ts: 定义行为示例作为测试,通过create.ts构建模块,用假依赖对真实模块进行测试impl/**: 只能导入create.ts和interface.ts,不能使用环境能力如Date或crypto
目标是使impl/**成为一个封闭的盒子。如果合约文件规定良好,生成的代码仍然可能出错,但混乱保持在实现目录内。
这使得审查可以集中在权威文件上,那里是持久决策所在。
依赖关系
依赖是设计决策。生成的实现不应自行授予自己调用网络、触摸存储、读取时钟或生成ID的权限。
在我的项目中,impl/**中的代码不能直接调用Date.now()、fetch、localStorage或crypto.randomUUID()。如果一个模块需要时间、网络、存储或ID,该设计决策会通过将依赖声明为contract/create.ts中的构造函数参数来记录。
这将审查转向正确的问题。我不再问代理是否正确使用了localStorage,而是问这个模块是否应该具有持久性。
同样的模式适用于模块边界。如果一个模块需要另一个模块,这种依赖也必须显式。当生成的代码跨越该边界时,失败应指向规则。
例如,代理可能使order/impl/price.ts深入目录实现:
// order/impl/price.ts
import type { OrderDeps } from "../contract/create";
import type { PriceQuote, PriceRequest } from "../contract/interface";
import { catalogStore } from "../../catalog/impl/store";
export function priceOrder(_deps: OrderDeps, request: PriceRequest): PriceQuote {
const item = catalogStore.lookup(request.sku);
return { amount: item.price * request.quantity };
}检查在差异到达审查之前在终端中失败:
$ repo check
error import-boundary
order/impl/price.ts imports ../../catalog/impl/store
impl/** may only import:
../contract/create
../contract/interface修复方法是将目录依赖作为合约的一部分,并从模块外部传入:
// order/contract/create.ts
import type { CatalogStore } from "../../catalog/contract/interface";
import type { OrderService } from "./interface";
export type OrderDeps = {
catalog: CatalogStore;
};
export declare function createOrder(deps: OrderDeps): OrderService;之后,impl/**使用deps.catalog而不是直接导入目录实现。
行为
类型和方法签名定义形状,但留行为于解释空间。合约案例通过可执行示例填补这一空白。实际上,这些检查就是普通的单元测试。
当contract/cases.ts编写时,公共接口已存在于interface.ts中。每个依赖,包括环境能力如时间、存储和ID,必须在contract/create.ts中声明。这意味着案例可以在impl/**存在之前编写。它们首先失败,然后指导生成。
模拟也变得更简单。测试不会修补隐藏在实现中的时钟、存储或网络调用。它通过构造函数传递这些能力,与生产代码相同。
如果实现发生变化,案例固定住合约的含义。如果含义必须改变,案例首先改变。
审查
审查转向值得人类关注的决策。结构使高影响更改难以隐藏,因为公共形状、依赖和行为示例都生活在我期望审查的文件中。
这改变了我提出的问题。这个模块是否有单一目的?依赖是否被挣得?公共形状会随着时间变化而保持良好吗?示例是否覆盖了重要行为?
在impl/**内部,代理可以构建一个适配器迷宫或给每个变量起两个名字。如果合约和边界成立,我就不必关心。
局限性
即使有代理帮助,一些规则仍然太难或太昂贵而无法良好编码。一个变得复杂、脆弱或充满异常的检查可能产生比节省更多的维护工作。值得强制执行的规则是运行成本低、易于理解且足够稳定以每次应用。
检查需要仔细设计和有用的错误信息。如果错误模糊,代理会绕过检查。这会将强制执行变成打地鼠。好的检查需要清晰的规则、精确的信息以及供人类审查的逃生舱口。
结论
代码生成使编写软件更便宜。审查生成的代码正在成为瓶颈。如果工程师的注意力是稀缺资源,它应该花在塑造系统的决策上。
强制层是一个关于重复工作的赌注。只有当代理足够频繁地返回相同边界,并通过节省审查注意力来偿还前期成本时,前期成本才有意义。
这种方法消除了审查代理生成代码的大量乏味工作。它把我的精力放回工程系统本身:塑造合约、选择边界,并决定哪些部分应该允许相互依赖。工作感觉更少像保姆,更多像驾驶。