ENZH

默契这东西,agent 是真不懂

上一篇 我说,仓库现在是 agent 编程时要对着写的接口,而里面最贵的东西是默契,因为这个"部落"每个会话都忘光一次。这一篇讲对付它杠杆最高的一招,具体到有点无聊:把每条边界都变成契约。

先从它要防的那个事故说起,因为事故才能让抽象的道理一下子卡进去。

这个 bug,agent 根本看不见

你代码库某处有这么一行:

queue.publish('user.updated', JSON.stringify(user));

别处有东西在消费 'user.updated',并且指望它是某个特定的形状。可能在同一个服务里,也可能在 agent 这辈子没打开过的另一个仓库里。这个字符串是个契约,两个系统就它达成了一致。但这份一致哪儿都没写,它活在两头是谁建的那几个人脑子里,外加一句"但愿没人乱动它"的祈祷。

现在让 agent 去把这个事件改个名,或者加个字段,或者把 user 从一个扁平对象改成带嵌套 profile 的。看会发生什么。Agent 看见一个字符串和一个序列化调用,它改了。它把能找到的测试跑了一遍。生产者那边的测试过了,因为生产者没毛病。消费者在另一个 agent 从没打开过的服务里,它的测试压根没跑。Agent 回头读自己的 diff,看到一段干净、完全照要求干活的代码,报告成功。

它也不算错。任务它确实做了。它弄坏的那条契约是隐形的,它能看见的文件里没有任何一句话说"另一个系统依赖这东西的精确形状"。换个人,也许会想起来还有个移动端在消费。Agent 没有可以拿来"想起来"的记忆。对 agent 来说,**唯一存在的契约,是被写下来的那条。**所有隐性的东西,在它眼里就是不存在。

所以"改 user.updated 的时候小心点"根本不是策略,它是一句写给失忆读者的祈祷。策略是把这条边界变成 agent 躲不开、机器能查的东西:

// contracts/events.ts
export const userEvents = {
  updated: 'user.updated.v1',
} as const;

export const UserUpdatedV1 = z.object({
  version: z.literal(1),
  userId: z.string().uuid(),
  profile: z.object({ /* ... */ }),
});

// 生产者
publishUserUpdated(UserUpdatedV1.parse(payload));

现在事件名是一个 agent 要 import 的常量,不是一个它能敲错一点点的字符串。形状是一个 schema,payload 一漂移就大声报错。版本写进了名字,所以一个破坏性改动是一条契约,不是把旧的悄悄改了。这条边界从人的记忆里搬进了仓库,agent 看得见,检查能拦得住。

什么才算一条边界

把那个事故吃透之后,操作规则自己就写出来了。值得用一句话讲,因为 agent 对一句话的规则反应特别好:

只要还有另一个模块、进程、用户、服务、文件、数据库、队列、CLI 或 agent 依赖它,它就是一条契约。

这张网比大多数人的直觉要宽,故意的。真正咬人的,很少是那个显眼的公开 API,而是散在代码里、没人当它是接口的那些小小的裸字符串约定:

  • route 路径和它的参数
  • API 的请求和响应形状
  • localStorage 的 key,以及存在它底下的 JSON
  • 事件名、消息名
  • 错误码
  • feature flag 的 key
  • 指标名、span 名
  • 配置和环境变量
  • 一个文件格式的列
  • 你端到端测试拿来选元素的 data-testid

这里每一个,都是系统里两个部分就某件事达成了一致的地方。每一个,留成一个内联的字面量,就是一颗 agent 可能踩上、却从头到尾看不见的地雷。这套纪律是机械的:永远不内联那个字面量,永远 import 一个有名字的常量、或者一个生成出来的类型。同一个契约字符串出现第二次,那不是你以后再清理的重复,而是一条缺失的契约,第二次出现就是那张 bug 报告。

还有个顺带的好处:这一手也干掉了一类幻觉。Agent 找不到真的 key 时,会编一个看着挺像的字符串 key 出来。要是真的那个 key 是唯一能 import 的符号,就没什么可编的,敲错了根本编译不过。

"Parse,别 cast",现在是承重的

这里有一个具体版本值得单开一段,因为它又好写又错得离谱:

const invoice = (await res.json()) as Invoice;

这个 cast 是对类型检查器的一句承诺:从线上下来的这堆字节,是 Invoice 的形状。运行时这句承诺被抹掉了,TypeScript 的类型在代码跑的时候根本不存在,所以这个 cast 啥都没验。要是 API 返回了别的东西,这堆坏数据就穿着一件被信任的类型往里淌,最后在离它进来三层远、看着完全正确的代码里炸掉。

写下这个 cast 的人,至少通常瞄过一眼 API,是在下一个算过的赌注。Agent 会把类型签名要的东西满足掉然后继续走,cast 是阻力最小的那条路,类型检查器也满意,看着就像做完了。这个 cast 于是变成一条无声的数据污染通道,而 agent 在结构上就是倾向于伸手去够它。

修法是在边界上做校验,往里传解析过的领域值

const invoice = InvoiceSchema.parse(await res.json());

写起来一样顺手,运行时的行为完全是另一回事。现在这堆不被信任的数据,在刚跨进你系统的那一刻就被查了;错误发生在边界上,好诊断;下游拿到的,是一个真的具备它类型所声称的形状的值。这条规则小到能强制执行:任何外部来的东西——HTTP body、route 参数、storage、postMessage、webhook、配置、持久化的 JSON——在边缘上套一个运行时 schema。你自己代码造出来的、在内存里被信任的值,光靠类型就够。边界,恰好就是类型不再够用的地方。

最硬的契约,根本不是代码

后端教会我这个想法最狠的一个版本,值得搬到所有地方用:放一条不变量,最好的位置是一个犯错的 agent 绕不过去的位置。

拿"一个客户只能有一个 active 订阅"这条规则说。你可以在应用层代码里执行它。人类团队大体能蒙混过关,因为他们记得去查。但 agent,或者一次并发重试,或者下个季度新加的第二个写入方,只要那次查被忘了、或者被 race 掉,就能在那一刻破坏它。这条不变量活在代码里,而代码恰恰就是会被改的东西。

把它推进数据库,它就不再可商量:

CREATE UNIQUE INDEX one_active_subscription_per_customer
ON subscriptions (customer_id)
WHERE status = 'active';

现在这条规则由每个写入方都必须穿过的那个组件来执行,包括那些从没读过应用逻辑的未来 agent。一个 not-null 约束、一个外键、一个 check 约束、一个 unique partial index,这些是能熬过并发、重试、以及那个"因为一个测试挡道就自信地删掉你校验"的 agent 的契约。同一个直觉可以推广:在 CI 里被 lint 的 spec、一旦 apply 就不能再改的 migration、一个带类型的错误信封而不是每个 handler 手搓的 { error: string }。把不变量挪到离那个绕不过去的层越近越好,你就把它从"但愿成立"变成了"真的成立"。

为什么这是杠杆最高的一招

agent 可维护这套打法里,要我说第一天就该花在契约上,原因关乎爆炸半径,不是整洁。

Agent 的错误,被它能看见的东西框住。在一条做好契约的边界里面,一个错的改动会又快又响地失败:schema 拒了它,常量不存在,数据库拒绝写入,契约测试变红。伤害在边缘上、在 diff 里就被逮住,上线之前。一条契约外面,一个错的改动是无声的。它类型过得了,它能够到的测试也过得了,看着做完了,一礼拜后才以坏掉的数据、或者一个没人把它跟那个无辜的单行改名联系起来的崩溃客户端的形式浮上来。

契约把后一种失败,转成了前一种。它不让 agent 变聪明,它让 agent 盲区的后果变得看得见,而这是唯一能让一个又快、又自信、又失忆的工人在规模上干活、还不悄悄搞砸东西的办法。这也是为什么散文式的指令干不了这活:markdown 文件里一句"小心处理事件",仍然只是默契加了几道工序。契约得是那种机器能强制执行的东西,而这正是 下一篇 的题目。

默契一直是个负债,我们容忍它,是因为人的记忆在替它补贴。补贴没了。把契约写下来,给它类型,给它测试,让 agent 去踩那些不会炸的地雷。


这是 agent 可维护仓库系列四篇里的第二篇。上一篇:维护你代码的,已经不是人了。下一篇:别指望一份 CLAUDE.md 管住 agent。相关:我造了个知识编译器,讲的是把知识编译下来、而不是留成默契。


© Xingfan Xia 2024 - 2026 · CC BY-NC 4.0