ENZH
和 AI 讨论这篇文章
ChatGPTClaude

agent 看不见没写下来的约定

上一篇 我说,仓库现在是 agent 要对着编程的接口,接口里最贵的东西是 tribal knowledge,或者说默契。agent 没有跨 session 的记忆,这些默契它每个 session 都忘光一次。这篇讲具体怎么办。办法说出来挺无聊的,就一句话:把每条边界都变成写下来的契约。但我只能说,这一招在整套打法里杠杆是最高的。

先看一个具体的事故,这篇要防的就是这类事。

这个 bug,agent 根本看不见

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

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

别的地方有东西在消费 'user.updated',并且指望它是某个特定的形状。可能在同一个服务里,也可能在另一个仓库里,agent 从来没打开过。这个字符串其实就是一条契约,两个系统就它达成过一致。但这个一致哪儿都没写,只活在当年把两头建起来的那几个人脑子里。

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

讲道理,它没做错什么,任务确实完成了。问题是它弄坏的那条契约是隐形的:它能看见的所有文件里,没有一个字说「另一个系统依赖这个形状」。换个人来做,可能会想起来移动端也在消费这个事件;agent 没有可以拿来「想起来」的记忆。所以对 agent 来说规则特别简单:一条契约只要没写下来,它就当不存在。隐性的东西在它眼里通通不存在。

默契是看不见的线,契约是写下来的线默契是看不见的线,契约是写下来的线

所以「改 user.updated 的时候小心点」这种话没有用,读它的人每个 session 忘一次,你说了等于没说。有用的做法是把这条边界变成 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));

改完之后的差别是这样的:事件名变成一个要 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 是唯一能 import 的符号,它就没得编,编了也编译不过。

parse,别 cast

有一个具体版本值得单独讲,因为它写起来太顺手,错得又很隐蔽:

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

这个 cast 等于向类型检查器承诺:网络上下来的这堆字节就是 Invoice 的形状。但 TypeScript 的类型在运行时是不存在的,代码跑起来的时候这句承诺已经被抹掉了,所以这个 cast 什么都没验。API 真返回了别的东西,坏数据就顶着一个被信任的类型一路往下走,最后在离入口三层远、看起来完全正常的地方炸掉。

人写这个 cast,一般至少瞄过一眼 API 文档,算是心里有数的赌。agent 不是。agent 的目标是把类型签名满足掉、往下走,cast 是阻力最小的那条路:类型检查器满意了,看起来就是做完了。所以这个 cast 对 agent 是个天然的坑,它就是会往这儿走。

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

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

写起来一样省事,运行时完全是两回事。不被信任的数据在进你系统的那一刻就被查了,错误发生在边界上,好定位。下游拿到的值,形状是真的对得上它的类型。这条规则小到可以直接强制执行——所有外部来的东西,HTTP body、route 参数、storage、postMessage、webhook、配置、持久化的 JSON,过边界的时候套一层运行时 schema。自己代码在内存里造出来的值不用,类型就够了。类型管得住内部,管不住边界,schema 补的就是这个口子。

最硬的契约在数据库里

这个思路推到最狠的版本是后端教我的:不变量要放在犯错的 agent 绕不过去的那一层。

拿「一个客户只能有一个 active 订阅」这条规则说。你可以在应用层代码里查,人类团队大体没问题,因为大家记得去查。但 agent 不一定记得,一次并发重试也不管你查没查,下个季度新加的第二个写入方可能根本不知道有这条规则。规则活在代码里,而代码恰恰就是天天被改的那个东西。

推进数据库就不一样了:

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

现在这条规则由每个写入方都必须经过的那一层来执行,包括以后那些从没读过你应用逻辑的 agent。not-null、外键、check 约束、unique partial index,这些东西扛得住并发和重试,也扛得住那种因为一个测试挡路、就很自信地把你校验删掉的 agent——这种事是真的会发生的。

同样的思路可以接着往外推:API spec 在 CI 里 lint,破坏性改动直接挡下来;migration 一旦 apply 过就不许再改;错误统一走一个带类型的信封,不要每个 handler 手搓一个 { error: string }。反正思路就是把不变量往绕不过去的那一层挪。放在那儿的规则是真的有东西在执行的,不用指望每个改代码的人都记得。

真出事的时候差别在哪

契约这个东西真正值钱的地方是 blast radius(爆炸半径),倒不是说代码变整洁了。agent 的错误是被它能看见的东西框住的。边界有契约管着,改错了当场就炸——schema 拒收,常量不存在,数据库拒绝写入,契约测试变红——错误还在 diff 里就被逮住了,根本到不了线上。契约外面就不一样了,改错了一点动静都没有:类型过了,能跑到的测试也过了,看起来就是做完了。得等一个礼拜,坏数据浮上来,或者哪个客户端崩了,而且到那个时候,没人会想到去查一次看起来无辜的改名。

爆炸半径:契约内当场炸,契约外一周后炸爆炸半径:契约内当场炸,契约外一周后炸

契约干的事就是把炸的时间点往前挪,挪到 diff 里、上线之前。它不会让 agent 变聪明,它是让 agent 的盲区出事的时候当场就能被发现。一个干活很快、很自信、又没有记忆的工人,想让它规模化干活还不悄悄搞坏东西,我想不出第二种办法。这也是为什么靠写文档管不住这个事:CLAUDE.md 里写一句「小心处理事件」,说到底还是 tribal knowledge,只是多包了一层文件。契约必须是机器能强制执行的东西。文档到底能管什么、管不住什么,是下一篇的题目。

反正 tribal knowledge 这个东西一直都是隐患,没爆只是因为总有人记得。现在读代码的换成了没有记忆的 agent,没人兜底了。能做的就是把它一条条写下来:给名字,给类型,给 schema,给约束,给测试。这个活不难,跟上一篇说的一样,就是得做。


这是 agent 可维护仓库系列四篇里的第二篇。上一篇:repo 得当成 agent 的 API 来设计。下一篇:一份 CLAUDE.md 管不住 agent。相关:我造了个知识编译器,讲的是把知识编译下来、而不是留成默契。

和 AI 讨论这篇文章
ChatGPTClaude
AI 随想第 25 篇 · 共 28 篇

订阅更新

新文章发布时发到你的邮箱,不发别的。


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